Contributing to Remote Store¶
Scope: contributor workflow, development setup, release process, and consistency checklists for remote-store.
This project follows Spec-Driven Development (SDD): every feature starts as a specification before any code is written. See sdd/000-process.md for the full methodology.
Project Principles¶
The universal working principles for this repo are listed in
CLAUDE.md § Principles. They apply to any contributor,
not only automated agents.
Documentation framework¶
See CLAUDE.md § Documentation framework for placement, structure, and longevity rules.
Authoritative Document Format¶
Internal process and reference documents follow a fixed structure.
Principle¶
Meaningful minimum. Each document covers one concern, states clear principles, and stops. No detailed instructions for every situation.
Structure¶
- Intent & Scope: What this document governs and who it is for. Max 5–10 lines. A reader must know: "this is THE source for topic X."
- Rules: Numbered, mandatory constraints.
- Guides (optional): Heuristics, examples, lookup tables. Useful but not binding.
Sections 1 and 2 alone must be sufficient to understand the document's purpose and obligations.
Scope¶
Applies to root-level process documents in sdd/ (000-process.md, AUTHORING.md, CI-OPERATIONS.md, DESIGN.md, DOCUMENTATION.md, TESTING.md, CONTENT-RULES.md, CLAUDE-REFERENCE.md). Does not apply to specs, ADRs, RFCs, research, audits, BACKLOG.md, README, CHANGELOG, DEVELOPMENT_STORY, CLAUDE.md, or CONTRIBUTING.md (which follow their own formats).
Cross-check¶
Every sentence and section must pass this test: "this would force different behavior in situation X." If it does not, it is decoration — rewrite as a rule or remove.
Exclusions¶
Authoritative documents must not contain:
- Explanation or rationale (put in ADRs, specs, research docs, or
DEVELOPMENT_STORY.md) - History or changelog-style notes
- Meta-commentary about the document itself
Spec-First Workflow¶
The full SDD pipeline is described in sdd/000-process.md. For external contributions, start here:
- Propose: Open a PR with an RFC in
sdd/rfcs/(seerfc-template.md). No code yet. - Review: Maintainers and community review for design fit and completeness.
- Accept: The RFC graduates to a spec in
sdd/specs/. It now defines the contract. - Implement: Open a follow-up PR with tests (referencing spec IDs) and implementation.
Bug Fixes¶
Bug fixes follow a strict pipeline: BACKLOG → CHANGELOG → failing TEST → FIX →
COMMIT together. See sdd/000-process.md § Rule 6 for
the canonical rule.
Repository Structure¶
sdd/
000-process.md # How specs work in this repo
specs/ # Accepted specifications (source of truth)
adrs/ # Architecture Decision Records
rfcs/ # Proposals under discussion
Browse sdd/specs/ for the full list of specifications. Each spec file is
numbered and named after the feature it describes (e.g. 008-s3-backend.md).
For spec format and ID prefixes, see sdd/000-process.md § Spec format.
Adding a New Backend¶
See Build Your Own Backend for a full
walkthrough of the Backend contract, error mapping, and capabilities.
- Write a spec in
sdd/specs/or as an addendum insdd/specs/backends/<name>.md - Implement
BackendABC insrc/remote_store/backends/_<name>.py - Register a fixture in
tests/backends/fixtures/(declare it inbackends.toml/fixtures.tomland add a per-fixture factory module) - The cross-backend conformance suite under
tests/backends/conformance/(spec-traced per-topic files:test_io.py,test_listing.py,test_atomic.py, …) runs automatically against the new fixture; Dafny-derived cases carry@pytest.mark.extended_conformanceand are validated by a Dafny-compiled oracle — seesdd/formal/README.md§ Compiled Oracle - Add user-facing guide in
docs-src/guides/backends/<name>.mdand add todocs-src/guides/_nav.yml - Update every backend enumeration — see Backend order below for how to find them all and what order they go in
- Add backend config example to
examples/configuration/configuration.py - If the backend needs an extra, add it to
pyproject.toml[project.optional-dependencies], and add the same floor torun_constraintsinpackaging/conda-forge/recipe.yaml
Backend order (step 6). Every enumeration lists backends in one order:
local (Local, Memory) → cloud (S3, S3-PyArrow, Azure, Graph) → SFTP / SSH → special-purpose (HTTP, SQLBlob, SQLQuery)
Insert the new backend into its group; do not append it. Where a table carries footnote markers, they run in first-appearance order, so re-sequence them when a new row lands above an existing marker.
Do not go looking for the enumerations — the gate knows where they are.
hatch run check-backend-order reads every enumeration across the README, the
guides, the API reference, context7.json, and the conda recipe, and fails on any
that is out of order. It runs inside hatch run lint and hatch run docs-gate, so
CI enforces it on every PR. Add the backend, run the gate, fix what it names.
This replaced a git grep, and the reason is worth knowing before anyone proposes
another one: a grep for a backend name cannot see an enumeration that abbreviates
(one wrote plain SQL), and a grep scoped to a path cannot see the enumerations
outside it (two live in packaging and repo-root metadata). Both holes were real, and
both hid live defects. If you find yourself about to hand-maintain a list of places
to check, extend scripts/check_backend_order.py instead.
The gate proves order, not membership. Whether a list should name every backend is a judgement it deliberately does not make: the API reference splits its sync and async tables on purpose, and the README's comparison row abridges on purpose. That one is on you and your reviewer.
Two things are deliberately out of scope. FEATURES.md is generated between
BEGIN_GENERATED markers and sorts alphabetically — never hand-edit it. The
tagline is a slogan rather than an enumeration, so the group order does not apply
to it; it is mirrored across packaging, citation, and docs-metadata files, and
git grep "Write file storage code once" is the way to find every copy. Do not
work from a memorised list — the mirrors outnumber the ones anyone remembers, and
the forgotten copies are the ones that go stale.
Adding an Extension¶
Extensions live in src/remote_store/ext/ and follow the contract in the extension architecture ADR. Full checklist:
- Write an RFC in
sdd/rfcs/, get it accepted as a spec insdd/specs/ - Implement in
src/remote_store/ext/<name>.py— define__all__ - Use only the public
Store/BackendAPI (never_backend). Useunwrap()for native access - Do not own Store lifecycle — never call
store.close()or usewith store: - Let
CapabilityNotSupportedpropagate — do not catch and suppress it - Add tests in
tests/ext/test_<name>.pywith@pytest.mark.spec("ID") - Write a user guide in
docs-src/guides/<name>.md - Add the page to
docs-src/guides/_nav.yml(under the Extensions section) - Add a runnable example in
examples/ - The example docs page is auto-generated at
tutorial/examples/<slug>.mdfrom the module docstring viagen_pages.py— no manual wrapper file needed - If the extension needs an extra, add it to
pyproject.toml[project.optional-dependencies], and add the same floor torun_constraintsinpackaging/conda-forge/recipe.yaml— that block covers every extra, backend and extension alike, and a dependency missing from it is silently unconstrained for conda users - Update
CHANGELOG.mdandsdd/BACKLOG.md(orsdd/BACKLOG-DONE.md) in the same commit
Export patterns¶
Pure Python (no extra dependencies) — export unconditionally from remote_store.__init__:
Optional dependency — two guards are needed:
- In the extension module, raise a helpful error if the dependency is missing:
# In ext/<name>.py:
try:
import some_lib
except ModuleNotFoundError as _exc:
raise ModuleNotFoundError(
"some_lib is required for the <name> extension. "
"Install it with: pip install 'remote-store[<name>]'"
) from _exc
- In
remote_store/__init__.py, conditionally re-export with a silent guard:
try:
from remote_store.ext.<name> import Foo, bar
__all__ += ["Foo", "bar"]
except ImportError:
pass
Add the optional dependency as an extra in pyproject.toml [project.optional-dependencies].
Third-Party Extensions¶
External packages should use the naming convention remote-store-<name> and:
- Use only the public
Store/BackendAPI - Use
register_backend()for backend registration (if applicable) - Use
unwrap()for native handle access - For backend extensions: reuse the conformance test suite by importing and parameterizing it
Development Setup¶
The repo pins its priority interpreter in .python-version (the version CI's
coverage lane runs on), so uv and pyenv select it automatically and your
local hatch run all reproduces what CI gates on. It is the single source for
the priority Python across CI: every workflow reads it (setup-python's
python-version-file, or ci.yml's setup job for the matrix), so bumping the
development version is a one-line edit there.
The default hatch env is configured with path = ".venv", so hatch run
creates and owns .venv/ at the repo root via uv. A separate
python -m venv or pip install -e ".[dev]" step duplicates the env that
hatch run would build, and a stdlib-built .venv may not match the shape
hatch expects.
# Clone and enter the repo
git clone https://github.com/haalfi/remote-store.git
cd remote-store
# Install hatch (skip if you already have it). Any of these work:
uv tool install hatch # recommended if you use uv
pipx install hatch
pip install --user hatch
# Run any hatch script — .venv/ is auto-built on first invocation:
hatch run all # or run individual steps:
hatch run lint
hatch run typecheck
hatch run test-cov # coverage variants — see pyproject.toml comments for which to use when
hatch run examples
All dev scripts are defined in pyproject.toml under [tool.hatch.envs.default.scripts]. Run hatch run to see available commands.
Bundling a code skeleton for an LLM (optional)¶
To orient a coding agent on a subtree without spending tokens on full bodies,
lx (Go, MIT) emits an AST-level skeleton —
class/method signatures, type fields, and docstrings only. For
src/remote_store/ that is ~80k tokens versus ~265k for the full source, and
there is no concise git archive / cat equivalent. This is the one job lx
does that is awkward to reproduce by hand; for whole-file context just let the
agent read the files. As a contributor's skeleton tool lx is a purely local
convenience: not a project dependency and not installed by any hatch
script. (Separately, the Read the Docs build fetches a pinned lx binary to
generate the published llms-api.txt API skeleton — see .readthedocs.yaml;
that is an automated publish step, not part of local development.)
go install github.com/rasros/lx/cmd/lx@latest # one-time
lx --xml -u -Y src/remote_store/ # skeleton: signatures + types only
git diff --name-only | lx -c # bundle just the changed files to the clipboard
It honours .gitignore and estimates tokens. Point it at a subtree, not the
repo root (a whole-repo walk is ~3.7M tokens, mostly tests/ and sdd/); no
.lxignore is committed, so use -e <glob> for ad-hoc excludes.
Fixed in
lxv1.2.2 (affectedlx≤ v1.2.1). Earlier releases could silently drop theclassheader of decorated dataclasses from the-u -Yskeleton. Onsrc/remote_store/_models.py,lxv1.2.1 dropped 4 of the 5 public data-model classes (FileInfo,WriteResult,FolderEntry,FolderInfo), orphaning their fields underContentDigestand losing some fields entirely; the output still looked like valid Python, so the loss was easy to miss.lxv1.2.2 fixes it: re-runninglx --xml -u -Y src/remote_store/now emits every public class with its fields (0 public classes dropped across the whole subtree). Upgrade to ≥ v1.2.2 (go install github.com/rasros/lx/cmd/lx@latest); onlx≤ v1.2.1 only, work around it by bundling the data models as full source (lx --xml src/remote_store/_models.py, ~1.7k tokens). Full-source and changed-file bundles were never affected. Upstream report: rasros/lx#76.The
-u -Yskeleton shows the public API only:lxintentionally omits leading-underscore (_Foo) classes and functions, so private helpers will not appear — expected, not the bug above.
Migrating an existing checkout¶
If you previously created .venv/ with python -m venv or via IDE
auto-discovery, delete it before the first hatch run. Hatch's behaviour
on a pre-existing non-uv venv is not guaranteed (it may reuse, rebuild, or
fail depending on what metadata it finds), and a reused stdlib venv will
be missing the dev / docs / bench feature installs.
On Windows, close any IDE / language server / running pytest that has
file handles inside .venv\ before deleting — otherwise the delete fails
with WinError 32 (file in use).
Commit Signing¶
All commits should be signed for supply chain transparency. GitHub Vigilant Mode is enabled on the repository — unsigned commits show as "Unverified".
Setup (SSH signing, one-time)¶
# Tell Git to use SSH for signing
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/<your-key>.pub
git config --global commit.gpgSign true
git config --global tag.gpgSign true
Then upload your public key to GitHub as a Signing Key (not Authentication): Settings > SSH and GPG keys > New SSH key > Key type: Signing Key.
SSH agent (avoid passphrase prompts)¶
If your key has a passphrase, start the SSH agent so signing happens silently:
On Windows, you can also enable the OpenSSH Authentication Agent service
(via Set-Service ssh-agent -StartupType Automatic in an elevated PowerShell)
for persistence across sessions.
Verify with echo "test" | git commit-tree HEAD^{tree} -S — a commit hash without passphrase prompt means signing works.
Verify signatures locally (one-time)¶
Producing signatures and verifying them locally are separate steps. Without an
allowed-signers file, git log --show-signature (and %G?) reports N for your
own SSH-signed commits — they are signed, but Git has nothing to verify against.
To make local verification resolve to G:
echo "$(git config user.email) $(cat ~/.ssh/<your-key>.pub)" >> ~/.ssh/allowed_signers
git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
This is purely local; GitHub verifies independently regardless. Note: commits
merged via GitHub's squash button are re-signed with GitHub's own key and show
E locally ("no public key") — that is expected, not a failure.
Code Style¶
See sdd/DESIGN.md for the full code style conventions.
- Formatter/linter: ruff (line-length 120)
- Type checking: mypy strict mode
- Tests: pytest with
@pytest.mark.spec("ID")markers for spec traceability - Coverage: Target >= 95%
Test Requirements¶
See sdd/TESTING-RUNBOOK.md for how to run the test
stages, invoke live-cloud tests, and record or refresh cassettes. See
sdd/TESTING.md for testing quality rules. Spec
traceability and test-per-spec obligations are in
sdd/000-process.md § Rules (Rules 1–2 cover spec-test traceability).
- Run
pytest -m specto verify all spec-derived tests pass - Run
pytest --cov=remote_storefor coverage reports
Tests gated on RS_TEST_LIVE_HNS=1 require a real Azure Data Lake Storage
Gen2 account. See Azure HNS account setup
for the provisioning recipe.
Tests gated on RS_TEST_LIVE_S3=1 require a real AWS S3 account. See the
"Required IAM permissions" section in
tests/backends/fixtures/s3_live.py for the IAM policy needed by the
test user.
Examples and Notebooks¶
The examples/ directory contains runnable Python scripts that are validated in CI. Example scripts must remain self-contained and use tempfile.TemporaryDirectory for cleanup.
Jupyter notebooks in examples/notebooks/ are validated in CI via
hatch run notebooks (code cells executed with exec(), no Jupyter needed).
Visual output is not checked — the runner validates that cells execute without
errors.
Dependency drift guard¶
The drift guard is one of the scheduled/automated CI guards; for the full
inventory of those guards, where each one's finding lands, and the durable-TODO
principle they follow, see sdd/CI-OPERATIONS.md.
Every [<extra>] in pyproject.toml declares a floor, and a ceiling only
where a known-incompatible major looms — see the comment on
[project.optional-dependencies] in pyproject.toml for the authoritative
per-extra ranges. .github/workflows/drift-guard.yml runs weekly (Monday 07:00 UTC):
it re-resolves each remote-store[<extra>] with pip install --upgrade --pre,
diffs against the committed baselines in infra/drift-locks/, runs the
smoke targets in scripts/drift_smoke_map.py for any extra that drifted,
and reconciles a single rolling GitHub issue. The workflow never edits
pyproject.toml and never opens a pin-update PR — it is early warning,
not automated remediation.
When you deliberately bump a floor (e.g. paramiko>=3.0 after a
known-breaking upstream release), refresh the baseline in the same PR:
hatch run drift-check refresh-baseline <extra> # regenerate the lock
hatch run drift-check render-docs # regenerate the docs page
Then commit infra/drift-locks/<extra>.txt and
docs-src/reference/tested-versions.md. Run on Python 3.13 (matching the
workflow's runner) so the lock is comparable.
hatch run drift-check refresh-baseline all refreshes every extra at once.
Versioning¶
This project follows Semantic Versioning. Pre-1.0, minor bumps may contain breaking changes. The public API surface is everything in remote_store.__init__.__all__.
When to bump¶
| Change type | Bump | Examples |
|---|---|---|
| New public API, feature, or backend | minor (0.X.0) |
Store.to_key(), new backend, new config loader |
| Bug fix, internal refactor | patch (0.0.X) |
Fix round-trip bug, update retry logic |
| Breaking API change (pre-1.0) | minor (0.X.0) |
Remove method, rename parameter |
| Breaking API change (post-1.0) | major (X.0.0) |
— |
| CI, docs, metadata-only | no bump | Add classifier, update README |
Stability tiers¶
| Label | Meaning |
|---|---|
| Alpha (pre-0.11) | API may change freely between releases |
| Beta (0.11+) | Core API (Store, Registry, Backend, models, errors) is stable. Breaking changes are documented in CHANGELOG and avoid gratuitous churn. Extensions (ext.*) may evolve more freely. |
| Stable (1.0+) | Full SemVer: breaking changes require a major bump |
How to bump¶
Version is managed with bump-my-version. It modifies the files listed in [[tool.bumpversion.files]] in pyproject.toml in-place without committing or tagging. The release checklist below handles the commit and tag lifecycle.
Quick reference for the command syntax:
bump-my-version bump patch # 0.4.1 → 0.4.2
bump-my-version bump minor # 0.4.1 → 0.5.0
bump-my-version bump major # 0.4.1 → 1.0.0
Consistency Checklists¶
Documentation, examples, and metadata live in many places. Use these to keep them in sync.
- New backend: see § Adding a New Backend above.
- New extension: see § Adding an Extension above (12-step checklist).
- New Store method / cross-reference validation: see the ripple-check table in
sdd/CLAUDE-REFERENCE.md. - Pre-PR validation: run the PR validation gates —
hatch run allfor a code change, or the lighterhatch run lint+hatch run docs-gatefor a docs/specs-only change — then verify CHANGELOG and BACKLOG are updated and check the ripple-check table insdd/CLAUDE-REFERENCE.md. Runninghatch run allis always a safe superset if you would rather not classify the diff.
Release¶
Phase 0: Pre-flight¶
- [ ] Master is clean:
git statusshows no uncommitted changes - [ ] CI is green on master (lint, typecheck, test 3.10-3.14, examples, docs, package)
- [ ]
hatch run allpasses locally (constituent scripts inpyproject.toml; the pre-commit gate variant deliberately does not enforce the 95% floor — CI does) - [ ] No open
[~]items shipping in this release insdd/BACKLOG.md— complete and move toBACKLOG-DONE.md, or defer ([ ]) - [ ]
[Unreleased]section in CHANGELOG.md is non-empty - [ ] Decide bump level (patch / minor / major) per the table above
Phase 1: Content freeze¶
- [ ] CHANGELOG.md
[Unreleased]is complete — every completed item has a stub line (see ripple-check row CHANGELOG entry) - [ ] CHANGELOG completeness cross-check:
gh api repos/haalfi/remote-store/releases/generate-notes -f tag_name=vX.Y.Z -f previous_tag_name=vPREV -f target_commitish=master --jq .bodylists every merged PR sincevPREV— confirm each user-facing PR maps to a CHANGELOG[Unreleased]entry (internal/tooling/dependabot PRs need none). Safety net over the per-PR CHANGELOG discipline; the generated text is discarded, not used for the release body (which stays CHANGELOG-derived — Phase 4) - [ ] CHANGELOG.md
[Unreleased]condensed — stubs expanded to prose at release time (release skill Phase 1) - [ ]
sdd/BACKLOG-DONE.md: all shipping items moved here under the## Unreleasedheading, each marked[x](Phase 2 versions the heading) - [ ]
FEATURES.mdupdated for this release: backends, extensions, capabilities, extras — this is the only time FEATURES.md is edited (do NOT update the version header;bump-my-versionhandles it in Phase 2) - [ ] README.md: backends table, installation extras, API table, badges are current
- [ ] Specs vs code: spot-check shipped features match their specs (
pytest -m specas proxy) - [ ] Examples:
hatch run examplespasses; manually review notebooks if API surface changed - [ ] Guides: new/changed backend guides are accurate
- [ ] DEVELOPMENT_STORY.md: add a section for this release (pre-1.0 only)
Phase 2: Version bump (on a release branch)¶
- [ ] Create release branch:
git checkout -b release-vX.Y.Z - [ ] CHANGELOG.md: rename
[Unreleased]to[X.Y.Z] - YYYY-MM-DD, add fresh empty[Unreleased]above - [ ]
sdd/BACKLOG-DONE.md: rename## Unreleasedto## vX.Y.Z, add a fresh empty## Unreleased(*(none)*) above - [ ] Update
date-releasedinCITATION.cffto today (bump-my-version only updatesversion:, not this field) - [ ] Tagline consistent across every mirror:
git grep "Write file storage code once"— check them all, do not work from a remembered list; the copies nobody lists are the copies that go stale. Historical quotes insdd/research/are the one exemption - [ ] Keywords consistent:
pyproject.toml=CITATION.cff - [ ] Conda recipe: update
context.versioninpackaging/conda-forge/recipe.yamlto X.Y.Z - [ ]
bump-my-version bump patch|minor|major --allow-dirty(modifies the files listed in[[tool.bumpversion.files]]inpyproject.toml— does NOT commit or tag;--allow-dirtyis required because the Phase 1/2 edits above are still uncommitted) - [ ]
hatch run gen-graph(stampssource_version+snapshotindocs-src/_data/graph/graph.jsonfrom the bumped version) - [ ]
hatch run gen-features(regenerates mechanical sections ofFEATURES.mdfrom updatedgraph.json) - [ ]
hatch run gen-graph-viz(regeneratesdocs-src/explanation/graph_viz.htmlfromgraph.json;hatch run allgates on its freshness, so skipping it fails Phase 3) - [ ] Review and commit:
git diffto verify nothing unexpected, thengit add -Aand commit asRelease vX.Y.Z
Phase 3: Validate¶
- [ ]
hatch run allpasses (constituent scripts inpyproject.toml) - [ ]
hatch run test-cov-strictpasses locally with Azurite running (enforces the 95% floor thathatch run alldeliberately skips) - [ ]
mkdocs build --strictpasses - [ ]
hatch build && hatch run twine check dist/*— package builds cleanly (notpython -m build:buildis not in the hatch env) - [ ]
pip install dist/*.whl && python -c "import remote_store; print(remote_store.__version__)"— version matches - [ ] Conda recipe: version in
packaging/conda-forge/recipe.yamlmatches release version
Phase 4: Ship¶
Automated by skill agent (/release). User role: review and merge PR only.
- [ ] [Agent] Push release branch to origin
- [ ] [Agent] Create PR with link to checklist
- [ ] [User] Review, approve, and merge PR to master — then notify agent
- [ ] [Agent] Confirm CI green on merge commit — including the Codecov upload from the
test-primaryjob (the badge reuses this run;publish.ymlno longer recomputes coverage) - [ ] [Agent] Create GitHub Release directly on GitHub using template (no local tags) — this triggers
publish.yml(PyPI) and versioned docs deploy - [ ] [Agent] Watch
publish.yml— confirm it completes successfully (PyPI publish) - [ ] [Agent] Delete the release branch
Release template: title = version, description = "What's Changed" header with condensed sections (Added, Fixed, Internal), two links (CHANGELOG.md + git version diff). See .claude/skills/release/SKILL.md § Phase 4 for full template.
Phase 5: Post-release verification¶
- [ ] PyPI:
pip install remote-store==X.Y.Zin a fresh venv, verify version and README renders on pypi.org - [ ] GitHub Pages: check version switcher shows new version as "latest"
- [ ] ReadTheDocs: check https://docs.remotestore.dev/stable/ shows the new version (RTD automation rule activates tag-based builds;
stableis the default version) - [ ] Conda recipe: fetch sha256 from PyPI (
curl -s https://pypi.org/pypi/remote-store/X.Y.Z/json | python -c "import sys,json; d=json.load(sys.stdin); print([f['digests']['sha256'] for f in d['urls'] if f['filename'].endswith('.tar.gz')][0])") and updatesource.sha256inpackaging/conda-forge/recipe.yaml - [ ] Commit
packaging/conda-forge/recipe.yamlsha256 update in this repo via a branch and PR - [ ] Until conda-forge/staged-recipes PR #32401 is merged: update
haalfi/staged-recipesbranchadd-remote-storevia a local clone (not the GitHub API — API commits are unverified); push with--force-with-leaseand post a bump comment on conda-forge/staged-recipes PR #32401 mentioning@conda-forge/help-python - [ ] After feedstock exists: verify bot opened a version-bump PR
- [ ] Announce if applicable (tracking issues, users)