Contributing#
Bug reports, new data products, and documentation fixes are all welcome. The issue tracker is at egagli/easysnowdata#issues.
Setting up#
git clone https://github.com/egagli/easysnowdata.git
cd easysnowdata
pixi install # resolves from pixi.lock; conda-forge
pixi run -e test-py313 test-unit # should be green before you change anything
Test in test-py313, not dev. pixi.lock pins every environment, but the
per-interpreter environments have no solve-group, so they resolve
independently of dev and land on a different stack — at one point dev had
rasterio 1.5.0 / GDAL 3.12.3 while test-py313 had 1.5.1 / 3.13.3. That is
deliberate (testing three interpreters against their own best resolutions is
the point of the matrix) but it means dev is for interactive work — a
notebook, a REPL, a docs build — and test-py3XX is what CI runs. CI now has
a dev leg too, so the gap cannot silently reopen (#20).
Never hand-edit the lock; pixi install regenerates it when pyproject.toml
changes, and CI runs with locked: true so a stale lock fails the build rather
than silently resolving something else. One caveat worth knowing: pixi lock
exits 0 even when it drops a dependency it cannot satisfy, so check that a new
dependency is actually in pixi.lock rather than trusting the exit code.
The tasks#
task |
what it does |
|---|---|
|
the whole offline tier, sockets blocked |
|
just the cassette-replayed tests |
|
offline tier with coverage (fails under 95%) |
|
live tier: one smoke test per source |
|
re-record the cassettes (needs network) |
|
ruff check / ruff format |
|
build the site without running the gallery (~17 s) |
|
build and run the credential-free gallery (~4 min) |
|
build and run the whole gallery (needs every credential) |
|
serve |
|
run the health probes and update the history |
|
print the issues the last health run would open |
|
run the upstream watch and print the digest, writing nothing |
Credentials on your machine#
CI holds every provider secret, and the scheduled docs build and the weekly health check use them; GitHub never hands a secret back, so the same values have to reach your shell separately. Two ways that need no code:
A
.envfile at the repo root (already in.gitignore), oneNAME=valueper line, loaded for a shell withset -a; . ./.env; set +abeforepixi run …. Good forNVE_API_KEY,PL_API_KEY,EARTHDATA_USERNAME/EARTHDATA_PASSWORD, andEARTHENGINE_TOKEN.The provider’s own store:
planet auth loginsaves a Planet session under~/.planet/,earthaccess.login(persist=True)writes~/.netrc, andearthengine authenticatewrites~/.config/earthengine/credentials. The auth providers read all of these without any variable set.
esd.auth.status() shows what was found and how. The requires_* test
markers and the gallery’s # esd-requires: lines skip cleanly when a
credential is absent, so a partial set is fine for local work.
The four test tiers#
Each tier answers a different question, and they are deliberately separate so that a provider outage cannot turn a pull request red.
unit — pure functions, no I/O. Sockets are blocked with pytest-socket
(--allow-unix-socket, because zarr 3’s asyncio loop needs a socketpair), so a
test that quietly reaches the network fails instead of passing by accident.
recorded — searches and reads replayed from tests/cassettes/ with
pytest-recording, plus the tiny generated rasters in tests/fixtures/. Also
offline, also socket-blocked, --record-mode=none so a missing cassette is a
failure rather than a live request. Re-record with record-cassettes when a
search changes, and check the diff: cassettes are scrubbed of cookies and
tokens but you are still committing someone’s API response.
live — one smoke test per source against the real provider, marked live
and deselected everywhere else. Runs weekly in CI and on demand. Tests marked
requires_earthaccess / requires_earthengine / requires_planet /
requires_nve skip when the credential is absent, so a contributor without
an Earth Engine account still gets a meaningful run.
docs — the gallery is a test tier in disguise: every credential-free
example is executed on every pull request, and every push to main (and the
weekly scheduled build) executes the whole gallery against live data with the
secrets, so the published pages show every example’s real output.
Run the offline tiers before every commit and the live tier before claiming a data route works.
Adding a data product#
The catalog entry is the contract: it generates the docs page, the health row, and the credential table, so the artefacts cannot drift apart. In order:
Put it in easysnowdata/<theme>/<product>.py with module-level search and
load functions. Take aoi first and time second; accept source=; return
lazy xarray. Build the return value with processing.contract.finalize() so
the dims, CRS, nodata policy, and provenance attributes come out right without
you re-deciding them. A vector product returns a GeoDataFrame through
processing.contract.finalize_frame() instead (EPSG:4326, 2-D, provenance in
.attrs); fetch its archive once with providers.raster_http.fetch and read
it with providers.vector_http.read, which pushes the AOI down. Read Concepts once before you start.
A Product with one Source per access route — the first is the default.
Every source needs a location, an extent, a notes line saying what
differs from the other routes, and a health=Probe(...). Register it at the
bottom of the module with catalog.register(PRODUCT, replace=True), and
import the module from the theme’s __init__.py so registration happens.
Give every variable its units, dtype, and nodata. Categorical variables need
flag_values, flag_meanings, and flag_colors of equal length — the offline
test checks that, and the plotting helpers read them.
One offline: a recorded search or a tiny fixture raster, asserting the shape
of the contract (dims, CRS, dtype, attrs) rather than the values. One live:
marked @pytest.mark.live, plus requires_* if it needs credentials, doing
the smallest real request that proves the route works.
The minimal request that shows the route is alive: a first-byte GET for a
static file, a one-item STAC search, a getInfo() for Earth Engine, an
earthaccess search for NSIDC. Reuse the helpers in
easysnowdata.catalog.health. GET first, not HEAD — some servers (GRDC)
answer HEAD with a 400 that looks exactly like a 404. For a file behind
Earthdata Login that is not in CMR (NSIDC’s daacdata tree), use
earthdata_https_first_byte: an anonymous GET there lands on the login page
with a 200, which a plain first-byte probe would count as a pass. That tree
also ignores a bearer token, so the probe (and the loader, through
providers.earthdata.download_with_password) logs in with the username and
password.
Give the probe a stable label. It is the key the weekly history is stored
under, so renaming one starts its history over.
docs/gallery/<theme>/plot_<product>.py: load it for one AOI, draw one or two
figures, and say one thing that is true about the product and not obvious. Use the
sphinx-gallery format — a docstring title with an === underline of exactly
the right length, then # %% cells. List it in the catalog entry’s
examples= tuple.
House style: the title names the product and its provider (SNODAS snow water equivalent and depth (NOHRSC)), never a place. A cell near the top prints the
product’s routes (esd.catalog.get(pid).sources). Maps go through
esd.plotting.map / categorical / points, vectors over them through
esd.plotting.add_outline, time series through
esd.plotting.timeseries, so every figure has equal aspect, a scale bar, a
graticule, units in [ ], and calendar dates without repeating the matplotlib.
Band arithmetic and thresholds are written out, not wrapped. Where a product has
two routes, show them side by side.
If it needs credentials, put # esd-requires: earthdata (comma-separated, one
or more providers) on the first line, above the docstring. That keeps it
out of the credential-free pull-request build; the marker itself never appears
on the rendered page.
Add the product to WATCHLIST.toml, where scripts/watch.py looks every week
for the change a health probe cannot see: a CMR revision, a republished file
([[file]]: ETag, length, status), a changelog or release feed, a
provider news page. A health probe says the route still answers; the watch
says what it answers with has changed. For a file that will be superseded
(yearly releases), watch next year’s URL too: its 404 turning into a 206 is
the signal to move on. Seed the snapshot once with
pixi run -e dev python scripts/watch.py --watchlist <just-the-new-entries.toml>
so the first weekly digest compares rather than records.
pixi run -e test-py313 test-unit — tests/test_docs_pages.py fails if the product
has no example, if the example file is missing, or if a gallery script no
product claims has appeared. pixi run -e docs docs-fast then shows you the
generated page.
Documentation#
Nothing under docs/catalog/, docs/api/, docs/auto_examples/,
docs/generated/, docs/gen_modules/, docs/credentials.md, or
docs/status.md is written by hand — they are generated at build time from
the registry and the gallery, and they are all in .gitignore. Editing a
catalog page means editing the catalog entry.
What is hand-written: index.md, the section landing pages
(getting_started.md, development.md), installation.md, concepts.md,
modules.md, glossary.md, faq.md, migration.md, releasing.md, this
page, and the gallery scripts. The navigation bar is the hidden toctree at the
end of index.md; a new prose page goes into the toctree of the section it
belongs to. Spell out a new acronym in glossary.md and link it with
{term}`CPC` .
The README’s header image is generated too: scripts/make_montage.py tiles
the thumbnails of the executed gallery into _static/gallery.webp, which the
docs build on main publishes and the README links to. It skips examples that
did not run, so a build without credentials does not turn the sixteen
credentialed examples into blank squares. Its status table and catalog summary come from
scripts/update_readme_status.py, between the sentinel comments — edit the
catalog entry, not the README.
Style#
ruff formatandruff checkmust pass:pixi run -e dev lint. Line length 88, double quotes, isort witheasysnowdataas first-party.NumPy-style docstrings. The API pages are generated from
__all__, so a function without a docstring shows up as a blank page.logging, neverprint— the one sanctioned print is the import-time credential summary.No import-time side effects, and no global
xr.set_optionsor GDAL configuration.Metric units everywhere.
The network clients under easysnowdata/stations/clients/ arrived here as a
git subtree of
global_snow_networks. That
repo deleted its copy in 2026-09 and imports these instead, so this is now
the only copy: fix a client here. They are linted and formatted like the
rest of the package. Their contract is still global_snow_networks’
DESIGN.md §3, and their tests are in tests/stations/.
Pull requests#
Branch off
main, one topic per pull request.The PR build runs ruff, the offline tiers on three interpreters and three operating systems, coverage, and a docs build with the credential-free gallery. None of it needs secrets.
Include tests. Coverage of the new modules must stay above 95%.
If you changed a public function’s behaviour, say so in the PR body; the changelog is generated from commit messages with
git-changelog.