Earthquake Logger For Dummies

A genro-bag example: reactive earthquake monitoring with resolvers, active cache, and subscriptions

Why This Example Matters

Most tutorials show Bag as a dict with dot-paths. That is like explaining a spreadsheet by showing how to type in a cell. The Earthquake Logger shows what Bag actually becomes when you combine its features: a reactive data backbone that watches a live USGS feed, versions every earthquake event, and reacts to new data — all with zero framework overhead.

No event bus library. No polling framework. No state management add-on. Just a Bag, a resolver, and two subscriptions.

🌐

Live Data Source

EarthquakeResolver fetches USGS GeoJSON feed with HTTP conditional requests (If-Modified-Since), minimizing bandwidth.

Active Cache (async only)

Negative cache_time means the resolver refreshes in the background (requires async context). You always read fresh data, never wait for HTTP.

🔄

Timer Subscription

A timer subscription fires process_feed every N seconds, converting raw feed data into versioned earthquake records.

🔔

Insert Subscription

An insert subscription on the quakes Bag reacts to every new node — logging each earthquake to the console as it arrives.

📋

Versioned Events

Earthquakes get updated by USGS (magnitude revised, etc). Each update becomes a new versioned node: 00, 01, 02...

🔍

Query Engine

features.query("#k,#a,#a.updated") extracts keys and attributes in a single pass — no manual iteration.

Data Flow

The entire pipeline, from HTTP request to console output, is wired through the Bag tree:

USGS API earthquake.usgs.gov Earthquake Resolver If-Modified-Since bag["feed"] GeoJSON data timer sub every 60s process_feed version + dedup reads writes bag["quakes"] versioned events insert sub on new node log_event format + print [New Quake] M5.8 1 2 3 4 5 6 7
StepWhatgenro-bag Feature
1USGS GeoJSON API serves earthquake data
2EarthquakeResolver fetches with If-Modified-SinceCustom resolver (extends UrlResolver)
3Result stored in bag["feed"]Resolver node with active cache
4Timer subscription fires every 60sbag.subscribe(..., timer=..., interval=60)
5process_feed reads feed, writes versioned quakesquery("#k,#a,#a.updated")
6New nodes appear in bag["quakes"]Hierarchical set with set_item
7log_event callback fires on each insertsubscribe(..., insert=...)
Key insight: The Bag is not just holding data — it IS the event system. The resolver fetches, the timer triggers processing, and the insert subscription reacts. No external event bus, no pub/sub library. The data structure itself is reactive.

Quick Start

Install

pip install genro-bag genro-bag-contrib-resolvers
Note: genro-bag-contrib-resolvers is a separate package that provides the EarthquakeResolver. It depends on httpx for HTTP requests.

Run the example

# Clone or download the example file, then:
python earthquake_resolver_example.py

Expected output

  [New Quake] nc/75337592.00 M1.45 -- 4 km NNW of The Geysers, CA
  [New Quake] us/6000sm0b.00 M3.2 -- 58 km NNW of Charlotte Amalie, U.S. Virgin Islands
  [New Quake] us/6000sm21.00 M5.8 -- 121 km WNW of Ternate, Indonesia
  [New Quake] ci/41084399.00 M1.03 -- 7 km SSE of Home Gardens, CA
  [New Quake] ew/1742200140.00 M1.64 -- 15 km N of Truckee, CA
  [New Quake] nc/75337587.00 M0.62 -- 8 km W of Cobb, CA
  [New Quake] ak/025c68p6oj.00 M1.7 -- 90 km NW of Yakutat, Alaska
  [New Quake] nc/75337582.00 M1.35 -- 1 km ESE of The Geysers, CA
  [New Quake] nc/75337577.00 M0.59 -- 9 km WNW of The Geysers, CA

Quakes loaded: 9
Listening for new earthquakes (refresh every 60s)...
Press Ctrl+C to stop.

  [New Quake] nc/75337600.00 M2.1 -- 3 km SE of Mammoth Lakes, CA
  [Update Quake] us/6000sm21.01 M5.9 -- 121 km WNW of Ternate, Indonesia

The logger loads the current feed immediately, then checks every 60 seconds. When USGS updates an earthquake (revised magnitude, location), it appears as a new version (01, 02...).

The complete source

import time
from genro_bag import Bag
from genro_bag.resolvers.contrib import EarthquakeResolver

LOG_TEMPLATE = "  [{tag}] {where}/{event_id}.{version} M{mag} -- {place}"


class EarthquakeLogger:
    """Monitors USGS earthquake feed with versioning and logging."""

    def __init__(self, interval: int = 60):
        self.bag = Bag()
        self.bag["quakes"] = Bag()
        self.bag["quakes"].subscribe("logger", insert=self.log_event)
        self.bag["feed"] = EarthquakeResolver(cache_time=-interval)
        self.bag.subscribe("poll_feed", timer=self.process_feed, interval=interval)
        self.process_feed()

    def process_feed(self, **kw):
        """Process raw feed into versioned quakes."""
        features = self.bag["feed.features"]
        quakes = self.bag["quakes"]

        for fid, attrs, updated in features.query("#k,#a,#a.updated"):
            key = f"{fid[:2]}.{fid[2:]}"
            if key not in quakes:
                quakes[key] = Bag()
            elif quakes[key].nodes[-1].attr['updated'] == updated:
                continue
            event_bag = quakes[key]
            version = f"{len(event_bag):02d}"
            event_bag.set_item(version, None, **attrs)

    def log_event(self, node=None, pathlist=None, **_kw):
        """Log new or updated earthquake events."""
        if not node.attr:
            return
        record = dict(node.attr)
        record.setdefault("place", "?")
        record.setdefault("mag", "?")
        record["where"] = pathlist[0]
        record["event_id"] = pathlist[1]
        record["version"] = node.label
        record["tag"] = "New Quake" if node.label == "00" else "Update Quake"
        print(LOG_TEMPLATE.format(**record))


if __name__ == "__main__":
    logger = EarthquakeLogger(interval=60)
    count = len(list(logger.bag["quakes"].query("#k", deep=True, branch=False)))
    print(f"\nQuakes loaded: {count}")
    print("Listening for new earthquakes (refresh every 60s)...")
    print("Press Ctrl+C to stop.\n")
    try:
        while True:
            time.sleep(10)
    except KeyboardInterrupt:
        print("\nStopped.")
That's all the code. ~50 lines of Python. No threads, no async, no event loop. The Bag's built-in timer subscription handles the periodic refresh, and the insert subscription handles the reactive logging.

Architecture

The Bag Tree

The entire application state lives in a single Bag. Here is its structure:

self.bag feed EarthquakeResolver count = 9 title = "USGS..." features us7000n1b2 {mag:2.1, place:...} nc74053451 {mag:1.8, place:...} quakes insert subscription us nc 7000n1b2 00 {mag:2.1, upd:...} 01 {mag:2.3, upd:...} 74053451 00 {mag:1.8, upd:...} timer: poll_feed on self.bag (root level) insert: log_event on bag["quakes"] (deep) Dashed borders = subscriptions | Solid borders = data nodes

How the pieces connect

There are three independent mechanisms working together through the Bag:

MechanismRegistered OnTriggerEffect
EarthquakeResolverbag["feed"]Any read of bag["feed.*"]Fetches USGS if cache expired
Timer subscriptionbag (root)Every interval secondsCalls process_feed
Insert subscriptionbag["quakes"]Any new node under quakesCalls log_event

The reactive chain

When the timer fires:

# 1. Timer fires → process_feed runs
# 2. process_feed reads bag["feed.features"]
#    → Resolver checks cache. If expired, fetches from USGS.
#    → Returns Bag of earthquake features.
# 3. process_feed writes to bag["quakes.us.7000n1b2"]
#    → set_item creates node "01" with earthquake attrs
#    → Insert subscription fires → log_event runs
# 4. log_event formats and prints the earthquake info
Key insight: The resolver and the subscriptions are completely decoupled. The resolver does not know about subscriptions. The subscriptions do not know about the resolver. They are connected only through the shared Bag tree. This is the power of using data structure as the integration layer.

The EarthquakeResolver

EarthquakeResolver extends UrlResolver (which extends BagResolver). It fetches the USGS "all earthquakes, past hour" GeoJSON feed and converts it into a Bag.

Inheritance chain

BagResolver UrlResolver Earthquake Resolver cache + resolve HTTP + headers USGS GeoJSON

The source code

class EarthquakeResolver(UrlResolver):
    """Fetches USGS earthquake feed with If-Modified-Since conditional requests."""
    class_kwargs = {**UrlResolver.class_kwargs, "url": USGS_FEED}
    class_args = ["url"]

    def init(self):
        self._last_modified = None

    def prepare_headers(self):
        if self._last_modified:
            return {"If-Modified-Since": self._last_modified}
        return {}

    def process_response(self, response):
        if response.status_code == 304:
            return self.cached_value
        response.raise_for_status()
        self._last_modified = response.headers.get("Last-Modified")
        data = response.json()
        result = Bag()
        result["count"] = len(data["features"])
        result["title"] = data["metadata"]["title"]
        features = Bag()
        for feature in data["features"]:
            props = feature["properties"]
            fid = feature["id"]
            features.set_item(fid, None,
                place=props.get("place", "Unknown"),
                mag=props.get("mag", 0),
                time=props.get("time", 0),
                updated=props.get("updated", 0))
        result["features"] = features
        return result

How it works, step by step

MethodWhen CalledWhat It Does
initResolver creationInitializes _last_modified to None
prepare_headersBefore each HTTP requestAdds If-Modified-Since header if we have a previous timestamp
process_responseAfter HTTP responseIf 304: return cached value. Otherwise: parse GeoJSON into Bag

Active cache (cache_time=-60, async only)

The resolver is assigned with a negative cache_time:

self.bag["feed"] = EarthquakeResolver(cache_time=-60)
cache_timeBehaviorUse Case
0No caching; resolve on every accessAlways-fresh data
> 0 (e.g. 300)Cache for N seconds; re-fetch when expiredReduce API calls
-1Cache forever; never re-fetchImmutable data (UUIDs, etc.)
< 0 (e.g. -60)Active cache (async only): always return cached value, refresh in background every |N| secondsLow-latency reads with periodic refresh
Why active cache? With cache_time=-60 (requires async context), reading bag["feed.features"] never blocks on HTTP. The resolver returns the last fetched data instantly and schedules a background refresh on the event loop. This means process_feed runs in microseconds, not seconds.

Conditional requests

The If-Modified-Since header tells USGS: "only send data if it changed since my last fetch." If nothing changed, USGS returns HTTP 304 (Not Modified) with no body — saving bandwidth and parse time.

Earthquake Resolver _last_modified USGS API earthquake.usgs.gov GET (no If-Modified-Since) 200 OK + Last-Modified + body GET + If-Modified-Since 304 Not Modified (no body) First call Subsequent calls

Subscriptions

The Earthquake Logger uses two subscriptions that serve completely different purposes:

Timer Subscription: poll_feed

Fires process_feed every N seconds. Registered on the root Bag. Drives the periodic refresh loop.

🔔

Insert Subscription: logger

Fires log_event on every new node under quakes/. Registered on bag["quakes"]. Reacts to new data.

Timer subscription: poll_feed

self.bag.subscribe("poll_feed", timer=self.process_feed, interval=60)
ParameterValueMeaning
"poll_feed"stringUnique subscription name (used to unsubscribe later if needed)
timer=callableThe function to call periodically
interval=int (seconds)How often to fire

The timer subscription creates a background thread that calls process_feed() every 60 seconds. The callback signature is flexible — it receives keyword arguments that process_feed absorbs with **kw.

Why timer, not a while loop? A timer subscription is managed by the Bag. It starts automatically, can be unsubscribed by name, and coexists with other subscriptions without custom threading code.

Insert subscription: logger

self.bag["quakes"].subscribe("logger", insert=self.log_event)
ParameterValueMeaning
"logger"stringUnique subscription name
insert=callableCalled when any new node is inserted under this Bag (at any depth)

The insert subscription fires every time a new node is added anywhere under bag["quakes"], including nested paths like quakes.us.7000n1b2.01.

The log_event callback

def log_event(self, node=None, pathlist=None, **_kw):
    if not node.attr:
        return
    record = dict(node.attr)
    record.setdefault("place", "?")
    record.setdefault("mag", "?")
    record["where"] = pathlist[0]       # "us"
    record["event_id"] = pathlist[1]   # "7000n1b2"
    record["version"] = node.label      # "00" or "01"
    record["tag"] = "New Quake" if node.label == "00" else "Update Quake"
    print(LOG_TEMPLATE.format(**record))
Callback ArgWhat It ContainsExample
nodeThe BagNode that was just insertedBagNode(label="01", value=None, attr={...})
node.labelThe label of the inserted node"01"
node.attrDict of attributes on the node{"place":"...", "mag":5.8, "updated":1712345}
pathlistList of path segments from subscription root to the node["us", "7000n1b2", "01"]
pathlist[0]First segment = network code"us"
pathlist[1]Second segment = event ID"7000n1b2"

Why the guard clause?

if not node.attr:
    return

When process_feed creates an intermediate Bag with quakes[key] = Bag(), that insert also triggers the subscription. But intermediate Bags have no attributes. The guard skips those and only logs leaf nodes (the actual earthquake versions with mag, place, etc.).

Remember: Insert subscriptions fire for every node insertion at any depth, including intermediate containers. Always check what you received before processing.

Versioning

USGS updates earthquake data in-place: magnitude gets revised, location gets refined, timing gets adjusted. The process_feed method turns these updates into an append-only version history.

The process_feed method

def process_feed(self, **kw):
    features = self.bag["feed.features"]
    quakes = self.bag["quakes"]

    for fid, attrs, updated in features.query("#k,#a,#a.updated"):
        key = f"{fid[:2]}.{fid[2:]}"          # "us7000n1b2" → "us.7000n1b2"
        if key not in quakes:
            quakes[key] = Bag()             # first time: create container
        elif quakes[key].nodes[-1].attr['updated'] == updated:
            continue                        # same version: skip
        event_bag = quakes[key]
        version = f"{len(event_bag):02d}"   # 00, 01, 02, ...
        event_bag.set_item(version, None, **attrs)

Key splitting

USGS earthquake IDs follow a convention: the first 2 characters identify the seismic network, and the rest is the event ID. The code splits this into a two-level path:

USGS IDfid[:2]fid[2:]Bag path
us7000n1b2us7000n1b2quakes.us.7000n1b2
nc74053451nc74053451quakes.nc.74053451
ci41084399ci41084399quakes.ci.41084399
ak025c68p6ojak025c68p6ojquakes.ak.025c68p6oj

This creates a natural grouping by network: all USGS events under us/, all Northern California events under nc/, etc. The dot in the key (f"{fid[:2]}.{fid[2:]}") leverages Bag's dot-path access to auto-create the hierarchy.

Deduplication

The updated timestamp from USGS tells us if anything changed. The logic has three paths:

For each feature in feed key not in quakes? YES Create Bag quakes[key] = Bag() NO updated == last version? continue (skip) YES NO Add version node set_item("01", None, **attrs)

Version numbering

version = f"{len(event_bag):02d}"   # 00, 01, 02, ...

The version label is the current count of nodes in the event Bag, zero-padded to 2 digits. Since we only append (never delete), this gives a monotonically increasing version history:

Eventlen(event_bag) before insertVersion labelLog output
First seen0"00"[New Quake] us/7000n1b2.00 M2.1
Magnitude revised1"01"[Update Quake] us/7000n1b2.01 M2.3
Location refined2"02"[Update Quake] us/7000n1b2.02 M2.3

The query that drives it all

features.query("#k,#a,#a.updated")

This single query call extracts three things from each node in the feed:

SpecifierReturnsExample value
#kNode label (key)"us7000n1b2"
#aFull attribute dict{"place":"...", "mag":2.1, "updated":1712345, "time":1712300}
#a.updatedSpecific attribute value1712345

The result is an iterable of tuples: (fid, attrs, updated). This eliminates the need to manually iterate nodes and extract attributes.

Elegance: The entire versioning logic — deduplication, key splitting, version numbering, attribute forwarding — is 10 lines of code. The Bag's hierarchical paths create the structure, query extracts the data, and set_item with **attrs forwards all earthquake properties as node attributes.

Cheat Sheet

Everything used in the Earthquake Logger, in "I want to..." format.

Setup and Wiring

I want to...Code
Create a Bag for my application state bag = Bag()
Add a sub-Bag for a domain bag["quakes"] = Bag()
Attach a resolver that auto-fetches data bag["feed"] = EarthquakeResolver(cache_time=-60)
Run a function every N seconds bag.subscribe("name", timer=my_func, interval=60)
React when new nodes are inserted bag["quakes"].subscribe("logger", insert=my_callback)

Reading Data

I want to...Code
Read a value via dot-path bag["feed.features"]
Check if a key exists "us.7000n1b2" in bag["quakes"]
Get the last node in a Bag bag["quakes.us.7000n1b2"].nodes[-1]
Read a node's attributes node.attr["updated"]
Extract keys + attributes in one pass features.query("#k,#a,#a.updated")
Count all leaf nodes (deep) len(list(bag.query("#k", deep=True, branch=False)))

Writing Data

I want to...Code
Create a nested path (auto-creates intermediates) quakes["us.7000n1b2"] = Bag()
Set a value with attributes event_bag.set_item("00", None, mag=2.1, place="...")
Forward all attributes from a dict event_bag.set_item(ver, None, **attrs)

Resolver Patterns

I want to...Code
Fetch data from a URL with caching bag["data"] = UrlResolver("https://...", cache_time=300)
Use active cache (background refresh, async only) bag["data"] = MyResolver(cache_time=-60)
Add conditional HTTP headers Override prepare_headers() in your resolver subclass
Parse a custom API response into a Bag Override process_response(response)
Return cached data on 304 Not Modified if response.status_code == 304: return self.cached_value

Subscription Callbacks

I want to...Code / Info
Know which node was inserted node parameter — the BagNode itself
Know where in the tree it was inserted pathlist parameter — list of path segments from subscription root
Get the node's label (key) node.label
Get the node's attributes node.attr (dict)
Skip intermediate container inserts if not node.attr: return
Distinguish new vs. updated events Check node.label == "00" (first version) vs "01"+

Query Specifiers

SpecifierReturnsUsed in example
#kNode labels (keys)query("#k", deep=True, branch=False) for counting
#aFull attribute dictquery("#k,#a,#a.updated") in process_feed
#a.nameSpecific attribute value#a.updated for deduplication check
#vNode valuesNot used (values are None in this example)
#nBagNode objectsNot used (query + attrs is sufficient)
Pattern summary: Resolver fetches external data into the tree. Timer subscription triggers periodic processing. Processing writes structured results. Insert subscription reacts to new data. The Bag is the glue — no external event system needed.

Async Version

The same Earthquake Logger can run on an asyncio event loop with minimal changes. The key difference: set_interval detects the async context and uses asyncio.create_task instead of threads.

Core insight: Subscription callbacks stay sync — they work on data already in memory. The async/sync boundary lives only at the resolver frontier (HTTP calls). The timer handles this transparently via asyncio.to_thread for sync callbacks.

What changes

No while True / sleep

The timer subscription runs as an asyncio task. Just await asyncio.Event().wait() to keep the loop alive.

No first process_feed()

With initial_delay=1 on active cache, the first load happens automatically ~1s after creation. No manual trigger needed.

Instantiate inside asyncio.run

Create the logger inside the async context so set_interval detects the event loop and uses asyncio tasks.

The async example

# earthquake_resolver_async_example.py

import asyncio

from genro_bag import Bag
from genro_bag.resolvers.contrib import EarthquakeResolver

LOG_TEMPLATE = "  [{tag}] {where}/{event_id}.{version} M{mag} -- {place}"


class EarthquakeLogger:

    def __init__(self, interval: int = 60):
        self.bag = Bag()

        # Quakes bag + subscription
        self.bag["quakes"] = Bag()
        self.bag["quakes"].subscribe("logger", insert=self.log_event)

        # Feed resolver + timer subscription
        self.bag["feed"] = EarthquakeResolver(cache_time=-interval)
        self.bag.subscribe("poll_feed", timer=self.process_feed, interval=interval)

    def process_feed(self, **kw):
        features = self.bag["feed.features"]
        quakes = self.bag["quakes"]
        for fid, attrs, updated in features.query("#k,#a,#a.updated"):
            key = f"{fid[:2]}.{fid[2:]}"
            if key not in quakes:
                quakes[key] = Bag()
            elif quakes[key].nodes[-1].attr['updated'] == updated:
                continue
            event_bag = quakes[key]
            version = f"{len(event_bag):02d}"
            event_bag.set_item(version, None, **attrs)

    def log_event(self, node=None, pathlist=None, **_kw):
        if not node.attr:
            return
        record = dict(node.attr)
        record.setdefault("place", "?")
        record.setdefault("mag", "?")
        record["where"] = pathlist[0]
        record["event_id"] = pathlist[1]
        record["version"] = node.label
        record["tag"] = "New Quake" if node.label == "00" else "Update Quake"
        print(LOG_TEMPLATE.format(**record))


async def main():
    logger = EarthquakeLogger(interval=10)
    print("Listening for new earthquakes (refresh every 10s)...")
    print("Press Ctrl+C to stop.\n")
    await asyncio.Event().wait()


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print("\nStopped.")

Sync vs Async — side by side

AspectSync versionAsync version
Event loopwhile True: time.sleep(10)await asyncio.Event().wait()
Timer mechanismthreading.Threadasyncio.create_task
First loadExplicit self.process_feed()Automatic via initial_delay=1
Callback executionDirect call in timer threadasyncio.to_thread (sync callback)
Subscription callbacksSync — data in memorySync — data in memory (same)
Resolver HTTP callsSync via smartasyncAsync native via httpx.AsyncClient

The contract

Async boundary rule: In async context, accessing a path with a resolver (e.g. bag["feed"]) returns a coroutine. You must await it. Inside subscription callbacks this is never needed — callbacks work on data already resolved and in memory.

How set_interval adapts

set_interval(60, cb) detects Async context asyncio.create_task Sync context threading.Thread sync cb → to_thread async cb → await sync cb → direct call async cb → new loop initial_delay=1 → first tick after 1s, then every N seconds