Skip to content

Architecture

The Internals section documents how snowtool is built rather than how it is driven — the object model behind the CLI and the API, the on-disk shape it reads and writes, and the machinery (provenance, zoning, ingest, queries) that turns a directory of COGs into per-basin statistics. This page is the map: the three entrypoints, the core objects they share, and the read/write/cache split that keeps them honest. The sibling pages drill into the pieces named here — the on-disk layout, the configuration surface, provenance and staleness, pourpoints and AOI rasters, the zone-layer framework and its generation engines, ingest, and the query engine.

Three surfaces, one core

snowtool splits cleanly into three parts that all sit on the same objects. The domain core lives in snowtool/snowdb/ and knows how to open a database, read its rasters, and mutate it. The CLI shell in snowtool/cli/ is a thin wrapper: each command resolves a core object, calls one method, and renders the result — new logic belongs on the core, never in a click callback. The read API in snowtool/api/ is a FastAPI app exposing the same reads over HTTP. The CLI and the API are peers over one core, not layers over each other; neither imports the other, and the API depends on a pydantic Settings the CLI never touches.

The read / write / cache split

The core's central object is SnowDb (snowdb/db.py): the lean, read-only catalog of a database. Built from a root RootConfig, it binds every registered dataset to its directory whether or not that directory exists — a dataset is defined by its config, and a missing directory just means no data yet — so the read path tolerates an un-initialized root. SnowDb holds only constants (config, paths, specs, datasets, the pourpoint index) and cache-free disk reads. It carries no mutation methods and no live raster state.

Two siblings wrap a SnowDb, each owning one lifecycle the catalog leaves out. SnowDbManager (snowdb/manager.py) is the write surface: every operation that mutates the database — creating the layout, registering and activating datasets, importing and rasterizing pourpoints, generating zone layers, ingesting data — is a method here, reachable as manager.db for the reads it needs. The inversion is deliberate: the management layer has a snowdb, not the other way around, so the read path can be constructed and served without ever pulling in the write code. SnowDbReader (snowdb/reader.py) is the cached read surface: it has a SnowDb (as reader.db) and owns the one piece of non-constant read-path state the catalog deliberately lacks — a TiffCache shared across all of a database's COG reads — plus zonal_stats, the query entry point that is the cache's sole consumer. Keeping the cache in exactly one type makes test isolation a type-level fact: a fresh reader is a fresh cache.

The manager and the reader are siblings over the same catalog, not nested in each other, because they split by lifecycle. The catalog is loop-agnostic and buildable anywhere. The reader's cache is loop-affine — alru_cache binds its in-flight tasks to the event loop that first awaits them — so a reader must be built inside the event loop that will use it, while a manager has no such constraint. That constraint drives where each object is constructed.

Where each is constructed

The API builds its objects once, at app-lifespan scope. get_app (api/app.py) opens a single catalog SnowDb from settings.snowdb_config and registers it as a gazebo provider; catalog-only routes (/, /datasets, /pourpoints) inject that SnowDb directly, while the stats routes inject an app-scoped SnowDbReader whose loop-affine cache is born in the app's event loop at lifespan. Because the catalog is opened once at startup, registering, activating, or ingesting more while the server runs requires a restart to take effect. There is no module-level app: the ASGI server calls the factory (uvicorn snowtool.api.app:get_app --factory), so importing the module has no side effects and needs no config.

The CLI builds its objects per invocation. The root cli group seeds a CliContext on click's ctx.obj; a command that needs the database takes the pass_snowdb decorator (which hands it the lazily-opened SnowDb) or pass_manager (which wraps that same SnowDb in a SnowDbManager). The open is lazy on purpose — --version, --help, and the api group must never construct a SnowDb, since that would demand a --config for commands with no business touching the database. A write command runs its whole body inside one asyncio.run, so the reader it may build lives in that loop.

Injection seams

Two categories of pluggable code reach the core by injection rather than as module globals, so tests can substitute local inputs for network sources without monkeypatching. Dataset specs — the definitions of dataset kinds — arrive as a RootConfig the SnowDb is built from; the built-in DEFAULT_DATASET_SPECS back the CLI's dataset create templates and the test fixtures rather than being passed to a SnowDb directly. Zone-layer providers (terrain, land cover) are passed to the SnowDb constructor, defaulting to DEFAULT_ZONE_LAYER_PROVIDERS; the CLI threads its own set through CliContext.zone_layer_providers. The sources those providers read during generation are injected too — declared in the config, resolved to a ZoneLayerSource per provider, and overridable per command — so a test binds a LocalFile where production reads 3DEP or NLCD over the network. The zones and generation pages own the details; the point here is that the seams exist so no SnowDb, SnowDbReader, or SnowDbManager ever reaches for a global to decide where data comes from.

The raster read path

Raster I/O uses rasterio, async-tiff, and griffine — there is no system GDAL and no osgeo bindings. A query reduces a RasterCollection of dated COGs over an AOI raster; the actual bytes are read by TiledRaster.load_tiles (snowdb/raster/tiled.py), which hands a batch of tile coordinates to async-tiff together (letting it coalesce the byte-range reads) and then decodes the returned tiles concurrently with asyncio.gather. Across a whole query, tile reads fan out over many COGs at once.

Opening a COG parses its IFD metadata, so re-opening one for every tile would be wasteful. TiffCache (snowdb/raster/tiff_cache.py) is a bounded, async LRU cache of open async-tiff TIFF handles that dedupes concurrent cold opens: the first request for a key opens the file while the rest await the same in-flight task, the cache is capped at maxsize entries, and a failed open is not cached. It is an alru_cache built fresh in __init__ — not a module-level decorator — so each instance owns an independent cache, and exactly one instance is held by a SnowDbReader and shared across that database's reads. Because the underlying LocalStore holds no file descriptors, the cache bounds only retained IFD metadata, not open fds.

Note

The cache's in-flight tasks bind to the event loop that first awaits a get, so a single TiffCache — and therefore a single SnowDbReader — must be used from one event loop only. This is why the reader is constructed inside the loop that will drive it (the API at lifespan, the CLI inside asyncio.run) rather than alongside the loop-agnostic catalog.

Datasets are data, not subclasses

A dataset kind is not a SnowDb subclass; it is a DatasetSpec (snowdb/spec.py) carrying a grid, its variables, and an ingester. The built-in specs live in snowdb/datasets/ and are collected into DEFAULT_DATASET_SPECS. Adding a dataset is a new spec plus an ingester registered in that package — no new class in the read path, which stays entirely dataset-agnostic. The ingest page covers how a spec reaches a running database and how source artifacts become per-date COGs; the walkthrough shows the same flow from the CLI.