A genro-bag example: reactive earthquake monitoring with resolvers, active cache, and subscriptions
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.
EarthquakeResolver fetches USGS GeoJSON feed with HTTP conditional requests (If-Modified-Since), minimizing bandwidth.
Negative cache_time means the resolver refreshes in the background (requires async context). You always read fresh data, never wait for HTTP.
A timer subscription fires process_feed every N seconds, converting raw feed data into versioned earthquake records.
An insert subscription on the quakes Bag reacts to every new node — logging each earthquake to the console as it arrives.
Earthquakes get updated by USGS (magnitude revised, etc). Each update becomes a new versioned node: 00, 01, 02...
features.query("#k,#a,#a.updated") extracts keys and attributes in a single pass — no manual iteration.
The entire pipeline, from HTTP request to console output, is wired through the Bag tree:
| Step | What | genro-bag Feature |
|---|---|---|
| 1 | USGS GeoJSON API serves earthquake data | — |
| 2 | EarthquakeResolver fetches with If-Modified-Since | Custom resolver (extends UrlResolver) |
| 3 | Result stored in bag["feed"] | Resolver node with active cache |
| 4 | Timer subscription fires every 60s | bag.subscribe(..., timer=..., interval=60) |
| 5 | process_feed reads feed, writes versioned quakes | query("#k,#a,#a.updated") |
| 6 | New nodes appear in bag["quakes"] | Hierarchical set with set_item |
| 7 | log_event callback fires on each insert | subscribe(..., insert=...) |
pip install genro-bag genro-bag-contrib-resolvers
genro-bag-contrib-resolvers is a separate package that provides the EarthquakeResolver. It depends on httpx for HTTP requests.
# Clone or download the example file, then:
python earthquake_resolver_example.py
[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...).
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.")
The entire application state lives in a single Bag. Here is its structure:
There are three independent mechanisms working together through the Bag:
| Mechanism | Registered On | Trigger | Effect |
|---|---|---|---|
EarthquakeResolver | bag["feed"] | Any read of bag["feed.*"] | Fetches USGS if cache expired |
| Timer subscription | bag (root) | Every interval seconds | Calls process_feed |
| Insert subscription | bag["quakes"] | Any new node under quakes | Calls log_event |
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
EarthquakeResolver extends UrlResolver (which extends BagResolver). It fetches the USGS "all earthquakes, past hour" GeoJSON feed and converts it into a Bag.
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
| Method | When Called | What It Does |
|---|---|---|
init | Resolver creation | Initializes _last_modified to None |
prepare_headers | Before each HTTP request | Adds If-Modified-Since header if we have a previous timestamp |
process_response | After HTTP response | If 304: return cached value. Otherwise: parse GeoJSON into Bag |
cache_time=-60, async only)The resolver is assigned with a negative cache_time:
self.bag["feed"] = EarthquakeResolver(cache_time=-60)
cache_time | Behavior | Use Case |
|---|---|---|
0 | No caching; resolve on every access | Always-fresh data |
> 0 (e.g. 300) | Cache for N seconds; re-fetch when expired | Reduce API calls |
-1 | Cache forever; never re-fetch | Immutable data (UUIDs, etc.) |
< 0 (e.g. -60) | Active cache (async only): always return cached value, refresh in background every |N| seconds | Low-latency reads with periodic refresh |
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.
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.
The Earthquake Logger uses two subscriptions that serve completely different purposes:
Fires process_feed every N seconds. Registered on the root Bag. Drives the periodic refresh loop.
Fires log_event on every new node under quakes/. Registered on bag["quakes"]. Reacts to new data.
self.bag.subscribe("poll_feed", timer=self.process_feed, interval=60)
| Parameter | Value | Meaning |
|---|---|---|
"poll_feed" | string | Unique subscription name (used to unsubscribe later if needed) |
timer= | callable | The 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.
self.bag["quakes"].subscribe("logger", insert=self.log_event)
| Parameter | Value | Meaning |
|---|---|---|
"logger" | string | Unique subscription name |
insert= | callable | Called 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.
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 Arg | What It Contains | Example |
|---|---|---|
node | The BagNode that was just inserted | BagNode(label="01", value=None, attr={...}) |
node.label | The label of the inserted node | "01" |
node.attr | Dict of attributes on the node | {"place":"...", "mag":5.8, "updated":1712345} |
pathlist | List 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" |
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.).
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.
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)
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 ID | fid[:2] | fid[2:] | Bag path |
|---|---|---|---|
us7000n1b2 | us | 7000n1b2 | quakes.us.7000n1b2 |
nc74053451 | nc | 74053451 | quakes.nc.74053451 |
ci41084399 | ci | 41084399 | quakes.ci.41084399 |
ak025c68p6oj | ak | 025c68p6oj | quakes.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.
The updated timestamp from USGS tells us if anything changed. The logic has three paths:
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:
| Event | len(event_bag) before insert | Version label | Log output |
|---|---|---|---|
| First seen | 0 | "00" | [New Quake] us/7000n1b2.00 M2.1 |
| Magnitude revised | 1 | "01" | [Update Quake] us/7000n1b2.01 M2.3 |
| Location refined | 2 | "02" | [Update Quake] us/7000n1b2.02 M2.3 |
features.query("#k,#a,#a.updated")
This single query call extracts three things from each node in the feed:
| Specifier | Returns | Example value |
|---|---|---|
#k | Node label (key) | "us7000n1b2" |
#a | Full attribute dict | {"place":"...", "mag":2.1, "updated":1712345, "time":1712300} |
#a.updated | Specific attribute value | 1712345 |
The result is an iterable of tuples: (fid, attrs, updated). This eliminates the need to manually iterate nodes and extract attributes.
query extracts the data, and set_item with **attrs forwards all earthquake properties as node attributes.
Everything used in the Earthquake Logger, in "I want to..." format.
| 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) |
| 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))) |
| 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) |
| 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 |
| 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"+ |
| Specifier | Returns | Used in example |
|---|---|---|
#k | Node labels (keys) | query("#k", deep=True, branch=False) for counting |
#a | Full attribute dict | query("#k,#a,#a.updated") in process_feed |
#a.name | Specific attribute value | #a.updated for deduplication check |
#v | Node values | Not used (values are None in this example) |
#n | BagNode objects | Not used (query + attrs is sufficient) |
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.
asyncio.to_thread for sync callbacks.
while True / sleepThe timer subscription runs as an asyncio task. Just await asyncio.Event().wait() to keep the loop alive.
process_feed()With initial_delay=1 on active cache, the first load happens automatically ~1s after creation. No manual trigger needed.
asyncio.runCreate the logger inside the async context so set_interval detects the event loop and uses asyncio tasks.
# 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.")
| Aspect | Sync version | Async version |
|---|---|---|
| Event loop | while True: time.sleep(10) | await asyncio.Event().wait() |
| Timer mechanism | threading.Thread | asyncio.create_task |
| First load | Explicit self.process_feed() | Automatic via initial_delay=1 |
| Callback execution | Direct call in timer thread | asyncio.to_thread (sync callback) |
| Subscription callbacks | Sync — data in memory | Sync — data in memory (same) |
| Resolver HTTP calls | Sync via smartasync | Async native via httpx.AsyncClient |
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.
set_interval adapts