Developer guide#
Everything needed to work on SpecMod: the tools, the loop, the conventions, and — the part that is easiest to get wrong — where development stops and releasing begins.
This page is the hub. The three companions are
Documentation workflow (previewing, publishing, versions),
Releasing the software (tags, PyPI, DOI) and
Publishing a dataset (data-v* artefacts).
Where to look for what
I want to… |
Go to |
|---|---|
Get a working checkout |
|
Know what lives where |
|
Make a change |
|
Understand a tool or a check |
|
Write or fix a test |
|
Read a CI failure |
|
Understand versions and releases |
|
Preview or publish docs |
|
Use Claude or Codex on this repo |
|
Build a package on top of SpecMod |
Quick start#
Requires Python 3.11+ and uv.
git clone https://github.com/sgjholt/SpecMod.git
cd SpecMod
uv venv && uv pip install -e ".[dev]"
pre-commit install # both hook types; see below
pytest -m "not dataset and not notebook" # ~3 minutes, should be all green
Four things about that, in order of how often they bite:
pre-commit installis a required step, not a nicety. It installs thepre-commitandcommit-msghooks — the config names both stages underdefault_install_hook_types, because a plainpre-commit installused to wire only the first and left the commit-msg check inert.Install with
[dev], not bare. The I/O suite needsh5pyandpyarrow, which[dev]pulls in; CI does the same.-m "not dataset and not notebook"is what CI runs.datasettests need a network download andnotebookexecutes the tutorial with a Jupyter kernel (~40 s).The editable install must come from a git checkout with history. The version is derived by
hatch-vcsfromgit describe; a tarball without.gitreports the fallback0.0.0.
Optional extras, if you are working on those code paths:
Extra |
Adds |
Needed for |
|---|---|---|
|
Prieto’s estimator, jackknife CIs |
|
|
wavelet families beyond the built-in Morlet |
|
|
h5py, pyarrow |
HDF5 and Parquet persistence (in |
|
Sphinx and friends |
building this site |
|
ipykernel, nbclient |
executing the tutorial notebook |
|
sampling-based fits |
The repository, mapped#
src/specmod/ the package
config/ layered settings, provenance stamping
core/ Spectrum, collections, noise, bandwidth, scalogram, units
transforms/ FFT, Welch, multitaper, Prieto, quadratic, CWT
smoothing/ Konno–Ohmachi, log binning
sources/ source models, attenuation, motion factors
fitting/ the fitter: base, guess, spectrum, event
picks/ pick readers, sensor resolution, the registry
acquire.py the only module that touches the network
datasets.py hash-pinned published datasets, via pooch
cli.py the `specmod` command
tests/ the suite, plus tests/golden/ reference numbers
tools/ repository scripts, each with a CI job or test behind it
docs/ this site
REFACTOR_PLAN.md the working document — not part of the built site
datasets/ dataset definitions for `specmod fetch`
tutorial/ the tutorial notebook and its data
stubs/ hand-written ObsPy type stubs
One file that is not what it looks like:
docs/REFACTOR_PLAN.mdis a working document, deliberately excluded from the built site. It records decisions, the measurements behind them, and an audit (§6.6) of claims in it that turned out to be false. When something here says “why”, that is usually where the long answer is.
The daily loop#
git switch -c my-change # branch off main
# ... edit ...
pytest -m "not dataset and not notebook" # the suite CI runs
pytest --without-optional-extras # what a default install sees
mypy # strict, on the whole package
git commit # Conventional Commits; hooks run
git push -u origin my-change # open a PR against main
ruff runs automatically on commit via pre-commit; run it by hand with
ruff check src/ tests/ tools/ and ruff format src/ tests/ tools/.
Run --without-optional-extras before pushing. A development environment
with specmod[multitaper] installed passes tests that CI, which installs only
[dev], fails. It has happened twice.
Branches#
mainis the trunk. Everything lands here, and it is the default branch.masteris frozen: the permanent record of the pre-refactor code, doing the job av0.1.0tag would have done. Never commit to it.Feature branches are short-lived and merge into
mainvia pull request.
Every pull request must target sgjholt/SpecMod. See §6.7 of the plan for
why that is worth checking rather than assuming.
Commit messages#
Conventional Commits, because
release-please parses them to decide the version and write the changelog —
see Development versus release. The type
controls where the commit lands:
Type |
Effect on the release |
|---|---|
|
minor bump; Features |
|
patch bump; Bug Fixes |
|
patch bump; Performance |
|
no bump; shown in the changelog |
|
no bump; hidden |
|
minor bump while below 1.0 |
There is no commitlint hook — the convention is followed by hand, and the
plan’s §6.6 records that as a claim it once made falsely.
No session URLs from AI coding tools in commit messages or anywhere else
published. The repository is public and those links are private state; a
commit-msg hook rejects them, which is the reason pre-commit install
appears in the quick start rather than further down.
Tooling reference#
Tool |
Runs |
Configured in |
|---|---|---|
environments, installs, builds |
|
|
lint + format, pre-commit and CI |
|
|
strict types over |
|
|
the suite |
|
|
property tests |
in-test |
|
hooks on commit |
|
|
version from git tags |
|
|
this site |
|
|
changelog and version decision |
|
The tools/ scripts#
Each one exists because something was claimed and not enforced. All are standard-library-only unless noted.
Script |
Does |
Enforced by |
|---|---|---|
|
the installed versions really are the declared minimums |
|
|
the built wheel’s version is the tag |
|
|
regenerates |
run by hand, deliberately |
|
regenerates the measured tables in |
|
measure_docs.py is worth knowing about before editing a table by hand:
python tools/measure_docs.py show # print the tables
python tools/measure_docs.py write # refresh the docs in place
python tools/measure_docs.py check # fail if any table is stale
Numbers that came from a measurement live between markers and are generated.
tests/test_docs_are_current.py runs check, so a change that moves a
published number fails the suite instead of leaving the prose quietly wrong.
The --field measurements read tutorial/data/events/ and are slower;
refresh those by hand after changing an estimator.
Configuration and provenance#
Settings live in src/specmod/config/ as semantic sections with layered
overrides, not module-level constants read at import time. Two commands:
specmod config show # the resolved configuration, with its layers
specmod config freeze # write it out, pinned
Every output records the configuration that produced it, a hash of it, and the SpecMod version. That is what makes a locally-overridden run reproducible from its own outputs, and it is the mechanism that lets the package stay alpha without published results becoming unrepeatable.
Testing#
pytest -m "not dataset and not notebook" # what CI runs
pytest --without-optional-extras # as a default install sees it
pytest -m notebook # executes the tutorial (~40 s)
pytest -m dataset # needs a network download
pytest tests/test_transforms.py -q # one module
Markers are declared in pyproject.toml and --strict-markers is on, so a
typo in a marker name is an error rather than a silently-skipped filter.
The tiers, as the plan lays them out:
Property tests (
hypothesis) encoding the physics — Parseval, scaling, units — which hold for any input rather than one recorded case.Synthetic end-to-end: generate a spectrum with known source parameters, run the whole pipeline, recover them.
Golden/regression: run the current code on the tutorial event and on Magna, and compare against committed summaries in
tests/golden/.Unit tests for specific bugs, each written as a failing test first.
Golden references, and the one rule about them#
tests/golden/*.json records what this code produced at a known-good point.
It is compared as a distributional summary — median, quantile profile,
length — with a relative tolerance, not as a byte digest: an earlier version
hashed the raw float64 bytes and failed on every CI runner, because a
different numpy or BLAS build produces last-bit differences on identical
input. A reference that only holds on the machine that generated it is not a
reference.
Do not regenerate it to make a test pass. If a change moves a number, that is the finding: say which number, by how much, and why. Then regenerate deliberately:
python tools/make_golden.py # and commit the result, with the reason
Tolerances carry comments explaining what was measured to choose them. The
cwt entry is the worked example — it records a per-runner residual that is
bounded rather than explained, and says so in as many words.
What CI runs#
Five jobs in test.yml, plus two more workflows. All of them run on every pull
request.
Job |
Workflow |
Does |
|---|---|---|
|
|
|
|
|
|
|
|
pytest on 3.11/3.12/3.13 × ubuntu/macOS, coverage to Codecov from one cell |
|
|
installs the declared minimum versions and runs the suite |
|
|
executes the tutorial notebook |
|
|
succeeds only if every job above did. This is what branch protection requires — see below |
|
|
sdist + wheel, |
|
|
builds the site as a check; publishing is Read the Docs’ job |
|
|
the release PR, and publishing — see below |
Two of those are worth understanding before you read a failure from them:
floors installs --resolution lowest-direct, exercising the oldest
dependency set the project claims to support. It caught two floors that could
never have worked: lmfit>=1.2 with numpy>=2.0 (lmfit below 1.3 calls
np.asfarray, removed in NumPy 2), and scipy>=1.13 silently breaking the
quadratic multitaper. If you raise or add a dependency, this is the job that
tells you whether the floor you wrote is real.
ci exists so branch protection has one name to require. Required status
checks match job names, and a matrix job reports one check per cell —
test (ubuntu-latest, 3.11) and five more — so requiring “the test workflow”
means listing ten names that change whenever the matrix does. ci depends on
all of them and is required in their place, alongside docs and build.
Two details in it are not decoration. It runs if: always(), because a job
whose dependency failed is skipped, and GitHub treats a skipped required
check as satisfied — so without that line branch protection would go green
over a red build. And it counts skipped as a failure, because nothing in this
workflow is conditionally skipped, so a skip means something upstream broke.
Job names are also why docs.yml’s job is called docs rather than build:
it collided with build.yml’s job, leaving two unrelated checks sharing one
name and nothing named docs at all.
docs deliberately does not use -W. Intersphinx resolves seven
inventories over the network and warns whenever one is briefly unreachable;
turning a third party’s downtime into a red build is flakiness, not a check.
Development versus release#
The thing to hold onto: merging to main releases nothing. main is
continuously integrated and continuously documented, but it is not
continuously published. Three separate clocks:
What moves it |
What it produces |
Who decides |
|
|---|---|---|---|
Development |
any merge to |
updated |
whoever merges the PR |
Software release |
merging the release PR |
a |
a human, deliberately |
Data release |
creating a |
a dataset artefact pinned by hash |
a human, deliberately |
How a version comes to exist#
No version string is committed anywhere. hatch-vcs derives it from
git describe, so the tag is the version:
On
mainbetween releases you get<last-tag>.postN.devN— a version that claims nothing.On a
v*tag you get exactly that tag without itsv.
pyproject.toml constrains which tags count, with both a --match v[0-9]* on
the describe command and a tag_regex on the parse. Both are needed: a
data-v1 tag was measured to take the package version from
0.1.0.post1.dev173 to 1, because setuptools-scm’s default pattern strips
the data- prefix and reads what is left.
That is why the two release channels use different tag prefixes, and why
tests/test_versioning.py pins the parse.
What a release actually does#
release-please watches main and keeps a standing pull request titled
chore(main): release <version>, carrying the generated CHANGELOG.md.
Merging it creates the tag and the GitHub Release; a gated job then builds from
the tag, checks the built version against it, and uploads to PyPI via Trusted
Publishing; Zenodo mints a DOI from the release webhook.
Nothing publishes until that merge. The gate exists because a DOI cannot be retracted — fully automatic tagging plus Zenodo means a typo fix can mint a citable version of the software.
The step-by-step, including the six repository settings that have to be turned on once and cannot be expressed in a commit, is in Releasing the software.
What this means day to day#
Land work whenever it is ready. The changelog accrues; you are not choosing a version when you merge.
Write the commit message for the changelog, because that is where it ends up verbatim.
Breaking changes are allowed and land in minor bumps while the project is
0.x. There is no deprecation cycle, by design — see the roadmap for what 1.0 will change about that.Datasets are versioned by registry name, not by the package version:
magna_2020_v1andmagna_2020_v2are separate entries, so a published result pinned to v1 keeps fetching v1 forever. Nothing revises an entry in place.
The stable surface#
specmod.api is a small re-export module, and the only part of SpecMod that
carries a compatibility promise: one minor release of DeprecationWarning
before anything on it is removed or changes signature, even while the package
is 0.x. Everything else may move in any release.
It exists for downstream packages. SpecMod’s internals are still being
refactored; a package that imports specmod.core or specmod.fitting directly
takes on that churn, and one that imports specmod.api does not.
Five properties hold across the surface, and they are enforced by
tests/test_api_surface.py rather than promised in prose: path-free
(nothing on it opens a file — a consumer that stores its data on S3 has to be
able to hand in arrays), deterministic, non-mutating, quiet (no
print), and typed errors rooted at SpecModError, distinguishing a
caller’s bad input from a missing optional backend from an internal bug.
Adding an export is a compatibility obligation, and the procedure is in
CONTRIBUTING.md.
The audit that established what could go on it — path coupling, hidden state,
determinism, and what one multitaper estimate actually costs — is in
Audit: what specmod.api found in core. Two of its
findings were defects in core rather than in the surface, both since fixed and
both now guarded package-wide by tests/test_ambient_state.py: no module
reads configuration at import time, and no module prints. Those two
properties are worth knowing before adding code — a config read at module
level freezes the working directory the process started in, and a print is
invisible to a caller capturing logs.
Working with agents#
Claude Code, Codex and similar tools are used on this repository. The durable
rules live in AGENTS.md
at the repository root, with CLAUDE.md pointing at it so there is one copy;
agents read those automatically. What follows is the context for a human
supervising one.
The failure modes are environmental, not intellectual. Every one of these has happened here:
A fresh container has no git hooks.
pre-commit installhas to be run in the session, or thecommit-msgcheck that rejects session links is simply not there. Three commits went out with session trailers before this was noticed; the config now installs both hook types from one command, and the first thingAGENTS.mdsays is to run it.An agent’s token cannot push
.github/workflows/. This is what theci/mirror exists for. An agent that does not know about it will either fail the push or, worse, quietly drop the change.A development container often lacks the optional extras, so an agent’s green run can be greener than CI’s.
--without-optional-extrasis the check.A harness may append its own commit trailers. The repository’s rules take precedence over a tool’s defaults, and this one is a publishing rule rather than a style preference.
What to ask for in review. The habit this repository is built around is saying what was checked and what was not. An agent that reports “fixed” should be able to show the command and its output; one that widens a tolerance or regenerates a golden file to reach green has moved the goalposts rather than found the problem. §6.6 of the plan is an audit of exactly that failure — three claims stated as fact with no mechanism behind them — and it is worth reading once before delegating anything that touches a check.
What agents are good at here. The mechanical, checkable work: splitting modules while keeping public names, writing the test that pins a behaviour before changing it, running the same verification five ways, and the tedious correctness of the docs — which is the same skill as the tests, since numbers in prose go stale silently.