From fee8f8df1ec4081bfe321d46688f4445b927a0b2 Mon Sep 17 00:00:00 2001 From: Francois Lanusse Date: Sun, 27 Sep 2026 15:53:34 -0700 Subject: [PATCH 1/2] Import the lightcone-cli docs as a starting point Copy the docs/ tree and zensical.toml from lightcone-cli main @ 3aa823b, unchanged except for the header repo link, which now points at this repository. Add a uv-managed build environment (zensical + squidfunk's mike fork), a lockfile, and a CI job that builds the site in strict mode. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 26 +++ .gitignore | 7 + README.md | 25 ++- docs/api/assets.md | 53 +++++ docs/api/container.md | 76 +++++++ docs/api/crate.md | 68 ++++++ docs/api/dataset.md | 69 ++++++ docs/api/identity.md | 53 +++++ docs/api/index.md | 38 ++++ docs/api/materialize.md | 72 ++++++ docs/api/plan.md | 57 +++++ docs/api/project.md | 60 +++++ docs/api/sandbox.md | 70 ++++++ docs/api/venue.md | 63 ++++++ docs/api/worker.md | 65 ++++++ docs/architecture.md | 199 +++++++++++++++++ docs/assets/favicon.svg | 1 + docs/assets/logo.svg | 1 + docs/cli/build.md | 90 ++++++++ docs/cli/index.md | 53 +++++ docs/cli/init.md | 135 +++++++++++ docs/cli/materialize.md | 113 ++++++++++ docs/cli/run.md | 61 +++++ docs/cli/status.md | 90 ++++++++ docs/contributing/extending.md | 55 +++++ docs/contributing/setup.md | 78 +++++++ docs/contributing/testing.md | 77 +++++++ docs/index.md | 61 +++++ docs/maintainer.md | 58 +++++ docs/stylesheets/extra.css | 57 +++++ docs/user/cluster.md | 127 +++++++++++ docs/user/concepts.md | 151 +++++++++++++ docs/user/getting-started.md | 394 +++++++++++++++++++++++++++++++++ docs/user/glossary.md | 186 ++++++++++++++++ docs/user/index.md | 73 ++++++ docs/user/install.md | 124 +++++++++++ docs/user/troubleshooting.md | 199 +++++++++++++++++ pyproject.toml | 15 ++ uv.lock | 328 +++++++++++++++++++++++++++ zensical.toml | 87 ++++++++ 40 files changed, 3614 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 docs/api/assets.md create mode 100644 docs/api/container.md create mode 100644 docs/api/crate.md create mode 100644 docs/api/dataset.md create mode 100644 docs/api/identity.md create mode 100644 docs/api/index.md create mode 100644 docs/api/materialize.md create mode 100644 docs/api/plan.md create mode 100644 docs/api/project.md create mode 100644 docs/api/sandbox.md create mode 100644 docs/api/venue.md create mode 100644 docs/api/worker.md create mode 100644 docs/architecture.md create mode 100644 docs/assets/favicon.svg create mode 100644 docs/assets/logo.svg create mode 100644 docs/cli/build.md create mode 100644 docs/cli/index.md create mode 100644 docs/cli/init.md create mode 100644 docs/cli/materialize.md create mode 100644 docs/cli/run.md create mode 100644 docs/cli/status.md create mode 100644 docs/contributing/extending.md create mode 100644 docs/contributing/setup.md create mode 100644 docs/contributing/testing.md create mode 100644 docs/index.md create mode 100644 docs/maintainer.md create mode 100644 docs/stylesheets/extra.css create mode 100644 docs/user/cluster.md create mode 100644 docs/user/concepts.md create mode 100644 docs/user/getting-started.md create mode 100644 docs/user/glossary.md create mode 100644 docs/user/index.md create mode 100644 docs/user/install.md create mode 100644 docs/user/troubleshooting.md create mode 100644 pyproject.toml create mode 100644 uv.lock create mode 100644 zensical.toml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d7eed9b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,26 @@ +name: Build Docs + +# Builds the site in strict mode, so a broken link or a nav entry +# pointing at a missing page fails the PR rather than the deploy. + +on: + pull_request: + push: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install docs dependencies + run: uv sync --locked + + - name: Build site + run: uv run zensical build --strict diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68eda9d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Built site +/site + +# Environment +.venv/ +__pycache__/ +.cache/ diff --git a/README.md b/README.md index 23c7d18..a3853fb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,25 @@ # docs -Documentation for the Lightcone Research stack + +Documentation for the Lightcone Research stack, built with +[Zensical](https://zensical.org/). + +## Preview locally + +The site only needs [uv](https://docs.astral.sh/uv/getting-started/installation): + +```bash +uv sync +uv run zensical serve # live preview at http://127.0.0.1:8000 +uv run zensical build --strict # what CI runs +``` + +## Layout + +- `zensical.toml` — site config and navigation +- `docs/` — page sources (Markdown), plus `assets/` and `stylesheets/` + +## Origin + +The initial content is the `docs/` tree of +[lightcone-cli](https://github.com/LightconeResearch/lightcone-cli) as of +`main` @ `3aa823b`, imported unchanged. diff --git a/docs/api/assets.md b/docs/api/assets.md new file mode 100644 index 0000000..b584bd9 --- /dev/null +++ b/docs/api/assets.md @@ -0,0 +1,53 @@ +# lightcone.engine.assets + +One output: its directory, its manifest, and whether it is still +current. The classification rule lives here, next to the manifest it +reads and the hashes it compares — and it is the one place in the +engine where a bug is quiet rather than loud, which is why it may not +have two implementations. + +Source: `src/lightcone/engine/assets.py`. + +## Key symbols + +| Symbol | Role | +|---|---| +| `classify(...)` | The one rule: `current` / `behind` / `stale`, with the why. Two callers — the worker and the read-only walk. | +| `Verdict.calls_for_a_remake(refresh=)` | The one place a state becomes an action: `stale` always, `behind` only when asked. | +| `data_version(path)` | Content hash of a directory or file — computed in the worker, before anything is annexed. | +| `Versions` | Per-run memo so a shared declared input hashes once, not once per dependent. | +| `read(sidecar)` / `write(...)` | The manifest, `..manifest.json`. Both take the sidecar's own path, so a caller holding an output path has to say `manifest_path` out loud. | +| `output_path(root, u, id, fmt)` | The output's file, guarded: any part that is not a single path component is refused, and so is a format that could not be an extension. | +| `manifest_path(output)` | The sidecar beside it, named from the id alone — so it keeps its path, and its history, across a re-declared format. | +| `ContentNotFetchedError` | An annexed file whose content is not in this clone, in either shape it takes. | + +## What must stay true + +- **One `classify`, two callers, one differing value.** The worker + hands live input digests; check mode hands `None` for anything + upstream that will run ("this is going to change"). That value is + the entire difference — never a second body of logic. History (the + foreign-write fact) enters the same way: computed by whoever has + git, handed in as a value. +- **The comparison is fourfold**: `definition_version`, the declared + input *set* (separate on purpose — a dropped dependency moves + neither hash), each recorded input digest, then `env_version`. + `stale` wins over `behind`; `behind` does not propagate and a behind + upstream still feeds its dependents. +- **A skip returns the *recorded* digest, never a recomputed one** — + on a bytes-free clone, rehashing dangling symlinks would quietly + report a different output. +- **Unfetched content refuses loudly, in both shapes.** A pointer file + hashes to a well-formed digest of the wrong thing; a dangling + symlink drops out of an `is_file()` walk without a word. Both raise + `ContentNotFetchedError` naming `git annex get`; only dangling + symlinks are added back to the directory walk. +- **`calls_for_a_remake` has three callers** (worker, check, the + cascade walk) and no inline re-spellings — the third copy is where + they start to disagree. + +## Tests + +`tests/test_assets.py` — pure; nothing on disk beyond `tmp_path`. +The pointer-file and dangling-symlink traps are pinned against real +annex shapes in `tests/test_dataset.py`. diff --git a/docs/api/container.md b/docs/api/container.md new file mode 100644 index 0000000..14a3256 --- /dev/null +++ b/docs/api/container.md @@ -0,0 +1,76 @@ +# lightcone.engine.image & container + +The container hatch, split down the pure/impure line. `image.py` is +what a containerized project *declares* and how that becomes an +identity — pure, no subprocess anywhere. `container.py` is building, +storing and entering images — impure, every command through +`project._run`. The exec side (the mount table) lives with the other +backends in `sandbox/oci.py`. + +Sources: `src/lightcone/engine/image.py`, +`src/lightcone/engine/container.py`, `src/lightcone/engine/sandbox/oci.py`. + +## Key symbols + +| Symbol | Role | +|---|---| +| `image.declaration(root)` | The `[tool.lightcone.image]` table, validated — a closed key set (`base`, `apt-install`, `run-commands`, `env`), because every key is hashed. | +| `image.tag(root)` | `lc-env-<16 hex>` over the rendered Containerfile *and* the identity document. | +| `image.archive_path(root, tag)` | `.datalad/environments//image` — the `datalad containers-add` layout. | +| `container.build(root)` | Build + save + commit, idempotent; returns `(Runtime, "built" \| "present")`. | +| `container.runtime_for_run(root, *, build)` | One function, two strictnesses: `lc build`/materialize-preflight may build and commit; the probe and worker only ever find, fetch, and load. | +| `container.backend(...)` | The single construction point for the exec backend — the only mode branch. | +| `container.sync(...)` | The in-container environment converge: network on, project `:rw`, host uv cache mounted, into `.lightcone/venv`. | +| `Runtime` | Facts only — root/mode/name/tag/id/arch — never mechanism. | + +## What must stay true + +- **The user never sees a Containerfile.** The render exists only in a + transient build context; the image's `LABEL` carries the identity + document so the archive stays self-describing. There is deliberately + no `pip-install` key — the Python environment is the lock's + business, never the image's. +- **The engine never enters the image.** The container is the + *recipe's* world: driver, git, annex, and classification stay the + host's `lc`; exactly two things run in-image — the sync and each + exec. Network is uncontrolled on every mechanism, symmetrically, and + the attestation says so — no consumer may read a promise into + "containerized". +- **No project file enters the build context** — that is what makes + "code edits never rebuild" structural rather than incidental. +- **The dataset is the store; runtime stores are caches.** Execution + pins the archive's config-blob **id** (readable with no runtime), + never a tag; a dropped archive never substitutes — a rebuild is a + new archive under a new id. +- **Builds and archive commits happen only on a clean tree, and only + after the graph resolves** — a refusal over a typo must not cost a + minutes-long build, and `dataset.save` commits the whole index. +- **The mount table is the mechanism** (`sandbox/oci.py`): project + `:ro`, the write scope `:rw` — the directory a recipe's output lands + in, or `results/` for a probe — declared inputs `:ro`, private HOME, + `--tmpfs /tmp`, over a `--read-only` rootfs — without that flag a + stray write *succeeds* into the ephemeral layer and vanishes while + the attestation claims `fs: declared`. Mounts are resolved source, + **declared** destination — the one policy shape that keeps its paths + unresolved, because they are addresses the recipe uses. +- **Runtime differences are spellings, never shapes.** One + `OCIBackend`, data-parameterized; the podman family is stated once + (`_PODMAN_FAMILY`) and asked positively, so a new runtime falls + outside it by default. podman-hpc adds exactly one step (`migrate`, + outside the load branch) and joins `_SHARED_STORE_RUNTIMES`. + Detection order podman-hpc → podman → docker; docker's daemon is + probed at detection. +- **The architecture gate refuses before the load** — a wrong-arch + `load` succeeds and then dies as `exec format error` deep inside a + recipe. Ignorance passes; a recorded mismatch refuses, naming the + fix. + +## Tests + +`tests/test_image.py` (pure: structure and ordering, tag sensitivity +both ways, the `env_version` frame), `tests/test_container.py` +(lifecycle against the stubbed `_run` — every refusal on recorded +argv), `tests/test_sandbox_oci.py` (the mount table, pure), and +`tests/test_container_smoke.py` — the runtime's answer, gated by +`LC_CONTAINER_TESTS_REQUIRED=1` in CI, building a real image and +proving the record on a bytes-free clone with a real `datalad rerun`. diff --git a/docs/api/crate.md b/docs/api/crate.md new file mode 100644 index 0000000..c403edb --- /dev/null +++ b/docs/api/crate.md @@ -0,0 +1,68 @@ +# lightcone.engine.crate + +The publication view: the repository described as a Workflow Run +RO-Crate. The project *is* the crate — `ro-crate-metadata.json` sits at +the root, describes what the repository already holds, and a deposit is +`git archive`, not an export step. lc's manifests stay the canonical +record; the crate is the same facts in schema.org vocabulary for +archives and viewers that will never run `lc`. + +Source: `src/lightcone/engine/crate.py` (converged by +`materialize._converge_crate`). + +## Key symbols + +| Symbol | Role | +|---|---| +| `render(root, graph, *, license, dsid, writer)` | The document, as bytes. A pure function of repository state — git comes in as the `writer` callable, the dataset id as a value. | +| `license_of(root)` | `[project].license` from `pyproject.toml`; empty means no crate is maintained. Presence is publication intent. | +| `CRATE_FILENAME` | `ro-crate-metadata.json`. | + +## What must stay true + +- **The clock never enters the render.** `datePublished` is the newest + manifest `finished_at` (the spec file's last-commit date for a + never-materialized project) and must override rocrate's + construction-time default. Entities build in sorted order, + serialization is `sort_keys` — render-twice-identical is the one + byte-level claim, and it is what makes convergence sound. The + serialization also compacts every one-element array to its value, + as RO-Crate 1.1 recommends: which properties hold one value depends + on the project, so the rule lives in one place, not in each builder. +- **Maintenance is derived, never configured.** RO-Crate requires a + license; materialize must not refuse to run science over a missing + key, and inventing one asserts terms over someone's data. Absent ⇒ + one report line; removed later ⇒ the file is left, and the line says + it is no longer maintained. +- **Run identity comes free from `git_sha`** — the driver reads HEAD + once per run, so grouping manifests by it *is* grouping by run: one + `OrganizeAction` per materialize, a `ControlAction` per execution, a + `HowToStep` per output id (deduped across universes — a step is spec + structure, an action is one execution). +- **The `Person` is the author of the output's *saving* commit** (via + `writer`), never the manifest's `git_sha` — that is the commit the + run *started* at and can be someone else's. +- **An output is a `File`, not a `Dataset` of parts.** It is one file, + so there is one annex key to look up and one `sha256` to publish — + the same number its manifest records as `data_version`, and the one + `sha256sum` prints. +- **The manifest is not transliterated.** `env_version`, + `definition_version` and `hermeticity` get no invented schema.org + spelling — the manifest itself is in the crate as a `File`, + `subjectOf` its output. Real vocabulary comes from the workflow-run + `@context`, without which `containerImage` and `sha256` are + undefined terms JSON-LD silently drops — the pre-rebuild exporter's + failure mode. +- **The rerun entry point does not regenerate the crate** — it is one + task's executor, so the crate lags until the next materialize. + Recorded residue, not a bug. + +## Tests + +`tests/test_crate.py` — pure: fixture manifests, a hand-built graph, a +stub writer, no git anywhere; structure and ordering assertions plus +the single render-twice byte check. `tests/test_crate_smoke.py` — the +official `rocrate-validator` against Provenance Run Crate 0.5: +REQUIRED clean, RECOMMENDED pinned to the recorded `_FLOOR` set (a new +failure is a regression, a disappearing one is the floor to shrink), +required in CI via `LC_CRATE_TESTS_REQUIRED=1`. diff --git a/docs/api/dataset.md b/docs/api/dataset.md new file mode 100644 index 0000000..28da872 --- /dev/null +++ b/docs/api/dataset.md @@ -0,0 +1,69 @@ +# lightcone.engine.dataset + +The git + git-annex seam: how a project stores what it produced. +Storage follows the DataLad model — git carries the pointers and the +history, git-annex carries the bytes — reached through ordinary `git` +commands. Every command goes through `project._run`, so there is one +monkeypatch point and every invocation is inspectable. + +Source: `src/lightcone/engine/dataset.py` (+ +`templates/files/gitattributes.tmpl` for the routing policy). + +## Key symbols + +| Symbol | Role | +|---|---| +| `save(root, paths, message)` | Stage scoped, commit — with `-c annex.thin=true` and `-c annex.dotfiles=true`, per-add and never written to config. | +| `restore(root, paths)` | `git clean` always; `git checkout HEAD --` only when HEAD has the path. Never `-- .`. | +| `status(root)` | The dirty question, scoped to the project (`-- .`, prefix-stripped) so a project inside a larger repository works. | +| `head(root)` | The commit a run started at — read once per run, by the driver. | +| `last_writer(root, *paths)` | Who last touched an output or its manifest — the foreign-write question. Answers "cannot say" as empty, never an error. | +| `require_committer(root)` | Refuses a repository with no git identity, before any recipe spends time. Asked as `git var`, the question a commit itself asks. | +| `dataset_id(root)` | The DataLad dataset UUID, read via `git config -f`. | +| `set_annex_filter_required(root)` | Set `filter.annex.required=true`, so a `git add` that cannot reach git-annex fails loudly instead of staging raw bytes. | +| `annex_filter_required(root)` | Whether that flag is already set — `lc init --check`'s question. | + +## What must stay true + +- **Nobody is ever asked to run a git-annex command.** `filter=annex` + plus the `.gitattributes` policy make an ordinary `git add` do the + right thing; `annex.largefiles=nothing` comes first and outputs and + data opt out — last match wins, and + `test_analysis_code_stays_in_git_and_stays_writable` pins it against + a real annex. +- **Manifests stay in git**, exempted back out of the annex, so a + bytes-free clone can classify a whole project. +- **An unfetched file exists, in two shapes** — an unlocked pointer + file (readable, hashes to the wrong thing) and a locked dangling + symlink (drops out of naive walks silently). `assets.data_version` + refuses both with `ContentNotFetchedError`; detection handles both + regardless of which shape lc writes, because `annex.thin` and + `git annex lock` are the researcher's to set. +- **Thin is per-add and only where lc writes.** Thin's hazard is an + in-place write rewriting the annex object under its own key; lc + always resets output directories rather than writing in place, but + `data/` is the researcher's, and their tools (`h5py`, astropy + `mode='update'`) do open files for update — so the flag never + reaches repository config. +- **`restore` is asymmetric on purpose:** a first materialization has + no HEAD version to go back to, and a failed task must not discard + edits made elsewhere while the graph ran. +- **Committing an archive or dot-named file needs `annex.dotfiles`** — + git-annex routes dotfiles to git whatever `largefiles` says, and + without the flag an image archive lands as a git blob, silently. +- **`filter.annex.required=true` is the storage policy's safety net.** + Without it, a `git add` whose shell cannot resolve git-annex prints + an error, exits 0, and stages the raw bytes into git history — + measured, and pinned by + `test_stock_plumbing_without_required_stages_raw_bytes_silently`. + It is the *only* thing convergence adds to what `git annex init` + wrote: no filter driver is rewritten, and no hook is touched, so how + git dispatches git-annex stays git-annex's own business and stays + resolved from `PATH`. + +## Tests + +`tests/test_dataset.py`, deliberately against **real tools** +(`real_tools` fixture): whether bytes land in the annex or as a blob +in git is not a question a stub can answer, and every bug this seam +has had was invisible to one. diff --git a/docs/api/identity.md b/docs/api/identity.md new file mode 100644 index 0000000..e7f865c --- /dev/null +++ b/docs/api/identity.md @@ -0,0 +1,53 @@ +# lightcone.engine.identity + +What a materialized output is identified by: two hashes that answer +different questions, and the lock scan that decides whether an +environment can be audited at all. + +Source: `src/lightcone/engine/identity.py`. + +## Key symbols + +| Symbol | Role | +|---|---| +| `definition_version(recipe, decisions)` | What the spec says an output *is* — the rebuild trigger. | +| `env_version(root)` | What it ran under: lock bytes ‖ interpreter pin ‖ install settings ‖ image document. The `behind` trigger. | +| `scan_lock(root)` | Refusals, reports and advisories about what the lock pins. | + +## What must stay true + +- **`env_version` is not part of `definition_version`.** That is the + whole shape of the invalidation model: an environment edit stales + nothing, it makes outputs *behind*. (The original design nested + them; staling every output in every project on an engine upgrade was + the bug, not the cost.) +- **Both hashes are length-framed** — label, length, bytes per field — + so a boundary shift between adjacent fields cannot yield the same + digest from different inputs. Mutation-checked in the suite. +- **The lock is hashed as raw bytes, never parsed.** A comment reflow + moves `env_version`, deliberately: over-invalidation costs a report + line, while a parse of our own can silently disagree with uv. +- **The install-settings list is closed** (`_INSTALL_SETTINGS`), every + key hashed whether or not the project sets it — a setting outside + the list must not move the hash, one merely *matching* today's + default must. Settings are read where uv reads them (`uv.toml` + **replaces** `[tool.uv]`, measured); only values are hashed, never + which file supplied them. User-level uv config is deliberately out + of reach — machine state, not project state — and the residue is + tracked as issue #176. +- **The git commit is recorded, never hashed, and never a signal** — + one sha covers the whole tree, so hashing it stales everything on a + README edit. The honest consequence: editing `src/fit.py` remakes + nothing unless the file is declared as an ASTRA input. Do not add a + heuristic that scans recipes for repo paths. +- **The lock scan refuses only what cannot be audited** — path, + directory, and editable dependencies (two syncs of one lock can + install different code). A registry package with no wheel is a + report; a non-default group is advisory; the project's own package + is exempt. Names compare in PEP 503 form, or a project named + `my_project` fails to recognise itself. + +## Tests + +`tests/test_identity.py` — pure, and written as sensitivity tests in +both directions: what must move each hash, and what must not. diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..f3126ea --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,38 @@ +# Engine Internals + +The `lightcone.engine.*` modules, one page each: what the module owns, +its key symbols, and the invariants a change must keep. These are +hand-written tours, not generated API dumps — the engine is not a +public API (projects don't depend on lightcone-cli), so what matters +is responsibility and contract, not every signature. + +## The map + +| Module | Owns | Character | +|---|---|---| +| [`project`](project.md) | What a project is: convergence, discovery, mode, the `_run` seam | impure | +| [`dataset`](dataset.md) | How a project stores: git + git-annex, run records, restore | impure | +| [`identity`](identity.md) | `env_version`, `definition_version`, the lock scan | pure | +| [`plan`](plan.md) | The spec, read as a graph of tasks (through ASTRA) | pure | +| [`assets`](assets.md) | One output: its directory, manifest, and state | pure | +| [`worker`](worker.md) | Making one output; the rerun entry point | impure | +| [`materialize`](materialize.md) | The driver: gates, scheduling, the save/restore loop, status | impure | +| [`venue`](venue.md) | Where a run executes: SLURM detection, the login guard | impure | +| [`sandbox`](sandbox.md) | The exec boundary: policy, backends, attestation, denials | mixed | +| [`image` & `container`](container.md) | The container hatch: declaration → image → archive → runtime | pure / impure | +| [`crate`](crate.md) | The publication view: the repo as an RO-Crate | pure | + +"Pure" here is a testing fact: pure modules are tested with nothing on +disk beyond `tmp_path` and nothing spawned; impure ones go through the +one subprocess seam (`project._run`) that the suite stubs — see +[Testing](../contributing/testing.md). + +Two files sit outside the engine on purpose: + +- **`lightcone/_sandbox_exec.py`** — the Landlock shim. Stdlib-only, + zero lightcone imports; it runs on every sandboxed exec, and an + engine import there would put click and the astra stack on that + path. Pinned by tests. +- **`lightcone/cli/commands.py`** — the CLI: flags, rendering, exit + codes. Imports the engine inside callbacks so `lc --help` stays + cheap; never contains logic worth testing beyond rendering. diff --git a/docs/api/materialize.md b/docs/api/materialize.md new file mode 100644 index 0000000..54b570d --- /dev/null +++ b/docs/api/materialize.md @@ -0,0 +1,72 @@ +# lightcone.engine.materialize + +Making a whole analysis: what runs, in what order, and what gets +committed. The driver refuses dirt, hands the graph to Dask, and owns +git alone — plus the read-only halves (`check`, `status`) that share +its classification walk. + +Source: `src/lightcone/engine/materialize.py`. + +## Key symbols + +| Symbol | Role | +|---|---| +| `materialize(root, targets, *, refresh)` | The run: guards → converge → plan → fetch → schedule → save/restore loop → crate converge. | +| `check(root, targets, *, refresh)` | The same classification without executing, committing, or fetching. Exempt from the dirty refusal. | +| `status(root)` | The report: every output's state and provenance commit, plus the mode/image/sandbox header facts. | +| `MaterializeReport` / `StatusReport` | The JSON surfaces; `ok` and `up_to_date` first. | +| `cluster_for_run()` | The venue ladder, and the two-method scheduler seam (`submit`, `completed`). | +| `run_record(...)` / `datalad_run_subject(...)` | The commit message `datalad rerun` replays, and the one spelling of its subject line — shared with the foreign-write comparator, because two strings here would drift. | +| `_engine_requirement()` | How a record pins its engine: by version for a release, by source commit (hatch-vcs) for a dev build. | + +## The run's order, and why + +1. **Login guard first** — the allocation is the remedy with queue + latency, so the user submits it before fixing anything else. +2. **Dirty refusal before the environment converge** — in + containerized mode the converge can commit an image archive, and + `dataset.save` commits the whole index; on a dirty tree the user's + staged edits would be swept in. +3. **Converge before the graph runs** — `uv run --locked --no-sync` + in workers would otherwise execute recipes against a drifted + `.venv` while manifests record the new lock (measured; the state + is made impossible rather than detected). +4. **Graph (validation, lock scan) before the image** — a refusal + over a typo must not cost a minutes-long build. +5. **HEAD, runtime, and foreign-write facts read once, handed down** + — the driver commits as results arrive, so any per-task read could + answer differently mid-run. Nondeterminism in a provenance field is + worse than either answer. +6. **Save on `ok`, restore otherwise, `try/finally` around the loop** + — an interrupt restores whatever is still outstanding; the tree + ends as clean as it started. + +## What must stay true + +- **The driver owns git, alone** — one thread, as results arrive. + A dependent may start while its upstream is being annexed; that is + measured-safe (the clean filter renames over the path, which never + stops existing) and must not be "fixed" by moving the save into the + task. +- **`up_to_date` is `ok and not made and not planned`** — a run where + every recipe failed must not report "nothing to do", and `behind` + never counts against it. +- **A read-only verb never tracebacks.** Anything `check`/`status` + cannot read classifies as "will be remade" and the real error + belongs to the recipe that follows. +- **The run record is genuinely re-runnable**: engine pinned by + requirement, project environment rebuilt by the worker from the + rerun commit's own lock, format tested *through datalad's parser* + and a real `datalad rerun` — a golden test over our own JSON stays + green through a silent break. +- **The crate converge is contained**: it runs after the loop, on the + full graph, and a failure there is a warning — the outputs are + already committed, and the crate is the publication view, not the + run. + +## Tests + +`tests/test_materialize.py` — real repositories, real recipes, a real +`LocalCluster` through the seam exactly once, real `datalad rerun` for +the record's whole claim. `cluster_for_run` is the one monkeypatch +point for venue-free tests. diff --git a/docs/api/plan.md b/docs/api/plan.md new file mode 100644 index 0000000..93cf8e3 --- /dev/null +++ b/docs/api/plan.md @@ -0,0 +1,57 @@ +# lightcone.engine.plan + +The spec, read as a graph of tasks. `astra.yaml` × `universes/*.yaml` +gives one task per `(universe, output)` pair that has a recipe; a task +carries everything executing it needs — the rendered command, where its +bytes go, what it reads, its decisions, its `definition_version` — and +nothing about *how* it will be executed. + +Source: `src/lightcone/engine/plan.py`. + +## Key symbols + +| Symbol | Role | +|---|---| +| `build(root)` | Validate the spec with ASTRA's own validators, resolve every universe, return the `Graph`. | +| `Graph` | Tasks keyed on `(universe_id, output_id)`; `order()` for the read-only topological walk, `resolve(targets)` for what a user typed, `closure(keys)` to narrow a run. | +| `Task` | One output in one universe, frozen. | +| `declared_path(root, path)` | The one rule that names a path: project-relative inside the tree, absolute outside, never resolved. | + +## What must stay true + +- **What the spec *means* is ASTRA's to say.** `astra.resolve` settles + decisions, resolves inputs, drops `when:`-excluded outputs, and + renders the placeholder grammar. This module holds only what + *execution* adds. A prior in-house interpretation diverged three + ways (couldn't build ASTRA's own nested example, ignored `when:`, + invented an input spelling `astra validate` rejects) — that history + is why re-derivation is banned. Missing semantics → PR to + astra-tools. +- **A spec ASTRA rejects never reaches a recipe.** `build` runs the + schema, file, and universe validators before resolving anything — + resolution answers what a *valid* spec means and does not re-check + that it is one. +- **The layout is flat and path-addressed.** + `results//.`, and the path in a + rendered recipe *is* the path on disk — no staging, no relocation. +- **`declared_path` is lexical, never `resolve()`d.** A declared input + under `data/` is an annex symlink; resolving it writes + `.git/annex/objects/…` into the run record — the storage instead of + the input. This shipped once. +- **Two universes cannot share an id** (the id names a directory; + `build` refuses, naming both files), and an out-of-tree absolute + input is **reported, not refused** — its bytes still hash and + cascade, but the repository cannot bring it back, and saying so is + the whole obligation. +- **A target that matches nothing is an error** listing what exists — + quietly making nothing is the least useful thing a build tool can + do. + +## Tests + +`tests/test_plan.py` — pure; tests what lc *adds* (directories, edges, +versions, the validation gate), never what a spec means — that +coverage lives in astra-tools' own suite, and re-asserting it here +would recreate the second implementation this module deleted. Every +fixture must be a spec `astra validate` accepts; the gate enforces it +for free. diff --git a/docs/api/project.md b/docs/api/project.md new file mode 100644 index 0000000..d2d0797 --- /dev/null +++ b/docs/api/project.md @@ -0,0 +1,60 @@ +# lightcone.engine.project + +What a project is: the convergence engine behind `lc init`, project +discovery, mode detection, and the one subprocess seam the whole +engine shares. + +Source: `src/lightcone/engine/project.py` (+ +`engine/templates/` for the scaffold's file content). + +## Key symbols + +| Symbol | Role | +|---|---| +| `converge(dir, *, write)` | The whole scaffold operation. `write=False` is check mode — the *same* decision path with side effects off. | +| `ConvergenceReport` | `created` / `repaired` / `unchanged` / `blocked` / `warnings`, plus `.converged` and `.as_dict()`. | +| `current_project()` | The cwd as a project: requires `pyproject.toml`, `uv.lock`, `.venv`. | +| `declared_project()` | The weaker question — what the repository carries, without `.venv`. One caller: the worker entry point, which builds the venv a moment later. | +| `mode(root)` | `"direct"` or `"containerized"` — presence of `[tool.lightcone.image]`, nothing else. | +| `uv_prefix(root, *, sync)` | The one spelling of the project uv hop. Callers differ only in `sync`: a probe converges the environment, a recipe must not. | +| `project_name(dir)` | PEP 503-ish name from the directory name. | +| `_run` / `_check_call` | Every external tool invocation, and the suite's one monkeypatch point. | +| `ProjectError` | The engine's one exception; the CLI translates it once. | + +## What must stay true + +- **Everything routes through the converger.** Every scaffold item + goes through `_Converger.item` / `.file` / `.blocked`; nothing + writes or records outside that mechanism. `.file` takes a *thunk*, + so check mode renders no template at all. +- **Derived artifacts converge by correctness, not existence.** + `uv.lock` and `.venv` are probed with uv's own no-write checks + (`uv lock --check`, `uv sync --locked --exact --check`); drift + reports as `repaired`. Check mode may probe but never mutates — + pinned by `test_check_mode_only_probes`. +- **A warning is advisory; a blocked item counts.** Convergence never + claims a project is converged while something it owns is absent or + unfixable — and repairs only ever append (`.gitignore` / + `.gitattributes` are converged entry-wise, order judged against the + template). +- **Only what git can carry is converged.** No `src/`, no empty + directories — a clone must need nothing but `.venv`, `git annex + init` and the annex filter (all three local state git does not + clone), and `test_a_clone_of_a_converged_project_is_converged` pins + it. +- **The annex filter is one config key.** `filter.annex.required=true`, + always, so a `git add` that cannot reach git-annex refuses instead of + silently staging raw bytes. Nothing else about how git dispatches + git-annex is lc's to write: the filter drivers and hooks stay exactly + as `git annex init` left them, resolved from `PATH`. +- **There is no discovery.** The invoked directory is the project or + it is a clean error; every uv call carries an explicit `--project`. +- **Templates are files** (`templates/files/*.tmpl`, `string.Template` + with strict substitution), and a template gets a function only when + there is a value to decide or a merge policy to hold. + +## Tests + +`tests/test_project.py` (semantics, against the stubbed `_run`), +`tests/test_templates.py` (content, substitution, repair logic), +`tests/test_cli.py` (the `lc init` surface). diff --git a/docs/api/sandbox.md b/docs/api/sandbox.md new file mode 100644 index 0000000..d1175a3 --- /dev/null +++ b/docs/api/sandbox.md @@ -0,0 +1,70 @@ +# lightcone.engine.sandbox + +The exec boundary: what a command may touch, and how that is enforced. +A `Policy` says *what* in mechanism-free path sets; a `Backend` turns +it into **a different argv that sandboxes itself**; `boundary` picks +one, runs it, and reports what was actually enforced. `run.py` (the +`lc run` engine) and the worker are the two consumers. + +Source: `src/lightcone/engine/sandbox/` — `model.py`, `policy.py`, +`boundary.py`, `landlock.py`, `seatbelt.py`, `oci.py`, `denial.py` — +plus `lightcone/_sandbox_exec.py`, the Landlock shim. + +## Key symbols + +| Symbol | Role | +|---|---| +| `Policy` | What we will enforce: path sets, env overlay, exec allowlist. No mechanism ever appears in it. | +| `Capability` | What this host can do — `detect()`'s answer, the only `sys.platform` branch. | +| `Attestation` | What was actually enforced, derived from the flags applied — never from what the matrix says should have happened. | +| `Backend.wrap(policy, argv)` | The pure rewrite. `contains_prefix` declares whether the uv hop rides inside (a container is a world; a host mechanism trusts host plumbing). | +| `exec_policy(...)` | The one policy: probe and recipe get the same thing. Building it is where the impurity lives (the per-run private `$HOME`); `scope()` owns its cleanup. | +| `Unavailable` | A real backend that wraps to the same argv and attests `fs: open`. Saying so is the caller's job; pretending is nobody's. | +| `denial.explain()` / `denial.trailer()` | Best-guess remedies (allowed to return nothing) and the unconditional trailer on every nonzero sandboxed exit. | + +## What must stay true + +- **`wrap` stays pure** — no temp files, no FDs, no global state + (pinned by `test_wrap_is_pure`). That is what makes every backend + testable on a host that cannot run it, and it is why the Landlock + policy travels as JSON on argv rather than an inherited ruleset FD. +- **The shim stays alone**: stdlib only, zero lightcone imports, setup + failures exit the reserved 97, and it never falls through to running + the command unsandboxed. +- **Never grant EXECUTE on a directory that could be a system + prefix.** Landlock unions rights over ancestors, so one EXECUTE on + `/usr` outranks the whole per-file allowlist — with every test still + green, because the allowlisted binaries are exactly the ones that + were going to work. This shipped once (a venv on a system python); + the rule and its test are the fix. +- **SBPL is last-match-wins; Landlock unions.** The asymmetry decides + where a rule can live: the macOS guard takes back writes the + vendored defaults hand out, and the write tier is restated *after* + the guard — get the order wrong and layer 4 materializes on Linux + and refuses on macOS with the golden test still green. +- **Anything every backend must do belongs to the seam** — the env + overlay is composed in `boundary.env_argv()` once, for every + mechanism, so a mechanism added later cannot forget what it never + had to remember. (While each backend applied its own, `Unavailable` + applied none.) +- **The macOS profiles are vendored, not authored** (codex-derived, + provenance header, single delta) — the read baseline is a list of + things that break, found one production failure at a time. Put our + rules in the generator, keep `diff` against upstream as the re-sync + tool. +- **A denial is never invisible**: `explain()` may find nothing, so + the trailer fires on every nonzero exit, unconditionally. Remedies + name only what exists today. + +## Tests + +The suite splits along the seam: +`test_sandbox_policy/wrap/denial.py` (pure, every OS), +`test_sandbox_shim.py` (the shim as a real subprocess), +`test_sandbox_oci.py` (the mount table, pure), and +`test_sandbox_enforcement.py` — **the kernel's answer**, one suite for +both mechanisms, run against the *real* `exec_policy`, with +`LC_SANDBOX_TESTS_REQUIRED=1` turning "no mechanism, skip" into a hard +failure in CI. Every denial test is mutation-checked through +`Unavailable()` — a denial test that would pass unsandboxed is testing +nothing, silently. diff --git a/docs/api/venue.md b/docs/api/venue.md new file mode 100644 index 0000000..d86735d --- /dev/null +++ b/docs/api/venue.md @@ -0,0 +1,63 @@ +# lightcone.engine.venue + +Where a run executes. A venue is host state, never project state — +nothing here reads the project or enters any identity. The one venue +beyond the local machine is a SLURM allocation, detected rather than +configured: the user already answered every resource question at +`salloc`, so the allocation *is* the declaration and lc's job is to +span it. + +Source: `src/lightcone/engine/venue.py` (consumed by +`materialize.cluster_for_run`). + +## Key symbols + +| Symbol | Role | +|---|---| +| `slurm_client()` | The allocation branch: a scheduler in the driver process bound to `SLURMD_NODENAME`, one `srun --overlap` launching a worker per node on `sys.executable`. | +| `require_compute_node(command)` | The login guard: refuses iff a known center's marker is set and `SLURM_JOB_ID` is not, printing that center's own `salloc`/`sbatch` spellings. | +| `allocation_nodes()` | How many nodes the allocation holds; 0 outside one. | +| `_SITES` | One row per known center — name, marker, remedies, **verified against the center's documentation, never guessed**. NERSC is the seeded row. | + +## What must stay true + +- **The detection ladder lives in `cluster_for_run()` alone.** Nothing + else asks where a run executes; a future submission-model venue is + one more branch there plus only the config it genuinely needs. +- **Workers run the driver's own interpreter** (`sys.executable -m + distributed.cli.dask_worker`) — on HPC that is the tool env on the + shared filesystem, so driver and workers are the identical + installation and version skew is structurally out. Workers need no + git and no annex. +- **The worker flags are each load-bearing**: `--nthreads=` + (tasks block in `subprocess.wait()` with the GIL released), + `--no-nanny` (srun won't relaunch either), `--memory-limit 0` (the + real work is behind the exec boundary; Dask would pause workers over + phantom numbers), `--death-timeout 60` (a worker whose driver died + exits instead of holding the node), `--local-directory /tmp` + **literal** (a site prolog can scope `TMPDIR` per node or step, so a + driver-resolved path can be absent elsewhere). +- **The srun child is the one documented exception to `project._run`** + — it lives as long as the run and its stderr must reach the terminal + live. Teardown retires workers first, then wait → terminate → kill, + bounded; connection is a poll loop so a dead srun reports *its exit + code* now, not a timeout later. +- **A leak refuses loudly, never falls back silently**: `SLURM_JOB_ID` + with no srun on PATH, a non-integer count variable, an unresolvable + `SLURMD_NODENAME` — each is a named refusal. +- **The guard is materialize-scoped** (plus the rerun entry point — + the record's `cmd` is how recipes reach login nodes without `lc` in + the command line). `check`, `status` and `lc run` never call it: a + login node is exactly where "where does this stand" gets asked. +- **A containerized multi-node run requires a shared image store** — + `_SHARED_STORE_RUNTIMES` (podman-hpc), asked positively, checked in + `materialize()` before the runtime resolves so the refusal costs no + build. + +## Tests + +`tests/test_venue.py` — fakes the *host*, never the code: SLURM +variables set deliberately, a bash stub standing in for srun, and the +end-to-end tests run a real graph through the real bind/launch/teardown +on any machine. The `venue_env` autouse fixture scrubs venue variables +suite-wide (derived from `_SITES`, so a new center is one row). diff --git a/docs/api/worker.md b/docs/api/worker.md new file mode 100644 index 0000000..a54cc75 --- /dev/null +++ b/docs/api/worker.md @@ -0,0 +1,65 @@ +# lightcone.engine.worker + +Making one output — the unit of work, and the only thing that runs a +recipe. Also an entry point: + +```text +python -m lightcone.engine.worker / +``` + +which is what the `[DATALAD RUNCMD]` record in every materialization +commit names, behind an engine-pinning `uv run --no-project --with …`. +It is a module rather than an `lc` verb on purpose: it makes the +output unconditionally, commits nothing, and leaves the tree dirty by +design — precisely the state `lc materialize` refuses to start from — +so advertising it would hand people a footgun. + +Source: `src/lightcone/engine/worker.py`. + +## Key symbols + +| Symbol | Role | +|---|---| +| `materialize(task, versions, ...)` | The unit: classify → reset → sandbox → recipe → check the payload → hash → manifest. Returns a `TaskResult`, always. | +| `TaskResult` | `ok` / `current` / `behind` / `failed` / `blocked`, the output's `data_version`, and the attestation. `.usable` is what dependents check. | +| `main(argv)` | The rerun entry point: guards, converges the project environment from the commit's own lock, resolves its own HEAD and runtime, executes. | +| `lc_version()` | The engine version every manifest records. | + +## What must stay true + +- **The worker never raises** — enforced at the unit boundary, so the + contract holds for failure modes nobody enumerated. Raising would + make Dask abort every task in flight; reporting all independent + failures in one run is most of what owning the loop buys. +- **`data_version` is computed here, before anything is staged** — the + dependent's argument *is* this return value, so the digest must + exist while the files are still unannexed. Deriving it from + `git annex find` records `sha256([])` for everything, silently, with + green tests — and couples the digest to the annex backend, which is + deliberately not pinned. +- **The reset takes what the output's id names, never the directory** — + outputs share a directory and Dask writes them concurrently, so a + whole-directory delete would take a neighbour's bytes with it. The + glob is `.*` plus the sidecar: an id cannot contain a dot, + so it cannot reach a sibling, and it *does* reach a payload left by a + run that declared another `format`. +- **A payload that is not a regular file fails the task.** `data_version` + hashes a directory perfectly happily, so `mkdir {output}` would + otherwise commit a well-formed digest of something that is not the + output — and exit 0 is not evidence that anything was written. +- **No git in here.** The driver commits; a worker that asked git + would race the index lock and could read a HEAD this same run moved. +- **`main`'s "no output ``" message covers the task lookup only.** + It once wrapped the whole body, and a `KeyError` from anywhere + inside astra surfaced as "bad target" — a rerun misdiagnosing itself + at the one place nobody is watching. +- **Keep it cheap to import — no click, no rich.** It is on the path + of every task and every rerun; two tests pin the imports and the + absence from `--help`. (Nothing pins the absence of a + `[project.scripts]` entry — treat that as a review item.) + +## Tests + +`tests/test_worker.py` — real recipes through the real boundary +against a real repository (the `analysis` fixture): whether gates +hold and bytes land are not questions a stub can answer. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..32081a2 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,199 @@ +# Architecture + +How lightcone-cli is put together, for someone about to change it. The +[user-guide concepts page](user/concepts.md) covers what the tool +promises; this page covers how the promises are kept. + +## The split that everything else follows + +```text +lc (CLI) engine ASTRA +───────────── ───────────────────── ───────────── +flags, rendering, ──► what a project is, ──► what a spec +exit codes how outputs are made *means* +``` + +- **`cli/commands.py`** owns flags, console rendering, and exit codes — + nothing else. It imports the engine *inside* command callbacks, so + `lc --help` stays cheap. The engine never imports click and never + prints. +- **The engine** owns everything about what a project is and how + outputs get made. It raises `ProjectError`; the CLI's group class + translates that into a clean error message, once, for every verb. +- **ASTRA** owns what a spec means. Scoping, `from:` references, + conditional outputs, universe resolution, and the recipe placeholder + grammar are all answered by `astra.resolve` and checked by + `astra.validation` — never re-implemented here. When the spec's + *meaning* looks wrong, the fix is a PR to astra-tools. + +The engine ships as the `lightcone.*` PEP 420 namespace — +`src/lightcone/` has **no `__init__.py`**, so sibling distributions can +share the namespace. The engine is the host's `uv tool`, never a +project dependency: a project's lock carries only what the analysis +imports. + +## One run, end to end + +```text +lc materialize + │ guard: compute node? tools? git identity? + │ refuse: dirty tree + │ converge: uv.lock ⇄ .venv (and the image, containerized) + │ plan: astra validate + resolve → Graph of Tasks + │ fetch: git annex get (declared inputs not in this clone) + │ venue: SLURM allocation? → srun workers · else LocalCluster + ├─► workers: reset output dir → sandbox → recipe → hash → manifest + │ (never raise; return ok/current/behind/failed/blocked) + └─ driver: consume results in one thread + ok → dataset.save (commit + run record) + failed → dataset.restore (tree as clean as it started) + finally → converge ro-crate-metadata.json (if licensed) +``` + +The division of labor is strict and load-bearing: + +- **The driver owns git, alone.** Workers execute and return a + `TaskResult`; the driver commits as results arrive, in one thread. + Concurrent git operations race on the index lock — this split is not + a preference. +- **Dask owns the ordering.** Every task is submitted with its + upstream futures as arguments; there is no ready-set loop or + hand-rolled topological sort on the execution path. +- **The worker never raises.** A recipe failure, a gate failure, an + unreadable manifest — all come back as a state, so one failure + doesn't abort every task in flight, and a run reports *all* its + independent failures. +- **Values are resolved once and handed down.** HEAD, the container + runtime, and the foreign-write facts are read by the driver and + passed to workers as values — a worker that asked git itself could + get a different answer mid-run, and workers have no git anyway. + +## Identity: two hashes, three states + +`identity.py` computes two digests that deliberately answer different +questions: + +- **`definition_version`** = hash(rendered recipe ‖ decisions) — what + the spec says the output *is*. When it moves, the artifact + contradicts the spec: **stale**, remade. +- **`env_version`** = hash(lock bytes ‖ interpreter pin ‖ install + settings ‖ image document) — what the output *ran under*. When it + moves, the artifact is merely from another time: **behind**, + reported, left alone. + +`assets.classify` is the one implementation of the rule, with two +callers: the worker (live input digests) and the read-only walk +(`None` for anything upstream that will run — "this is going to +change"). That single value is the entire difference between run and +check, which is what keeps `--check` honest. `behind` does not +propagate; `stale` wins when both apply; and a foreign write (an +output whose file or manifest was last touched by a commit that is not its own run +record) classifies stale through the same rule, as one more input +value. + +Both hashes are length-framed (label, length, bytes per field), so a +boundary shift between concatenated fields cannot produce a collision. +The lock is hashed as raw bytes, never parsed — over-invalidation +costs a report line; a parse that disagrees with uv costs correctness. + +## Storage: the repository is the record + +`dataset.py` is the whole git + git-annex seam. The model is DataLad's: +git carries pointers and history, the annex carries bytes, and +`.gitattributes` routes content (`annex.largefiles=nothing` by +default; `data/` and `results/` opt out). A researcher only ever types +ordinary `git add` / `git commit`. + +That ordinary `git add` dispatches git-annex from the *researcher's* +`PATH`, and a shell that cannot resolve it stages the raw bytes into +git history while exiting 0 — so `lc init` sets +`filter.annex.required=true`, which makes git refuse loudly instead +(every filtered command, not only `git add`). +Getting git-annex onto that `PATH` is the install's job, not the +repository's: `uv tool install lightcone-cli` puts it there alongside +`lc`. + +Each output is committed with a **run record** — a `[DATALAD RUNCMD]` +commit message whose `cmd` reconstructs the engine +(`uv run --no-project --with lightcone-cli==`) and re-executes the +worker entry point, so `datalad rerun` replays the making of an output +with the gates, the sandbox, and the manifest intact. Results are +committed *thin* (hard-linked to their annex object), which is safe +precisely because lc never writes an output in place — the worker +resets the directory first. + +## The exec boundary + +Every recipe and every `lc run` command goes through +`engine/sandbox/`: a `Policy` (mechanism-free path sets) is turned +into *a different argv that sandboxes itself* by a `Backend` — +Landlock via the stdlib-only shim `lightcone/_sandbox_exec.py`, +Seatbelt via `sandbox-exec`, the OCI mount table in containerized +mode, and `Unavailable` (wrap = identity) where no mechanism exists. +Because every backend is a pure argv rewrite, all of them are testable +on a host that can't run them, and the manifest's `hermeticity` field +records what was *actually* enforced — never what should have been. + +There is one policy, `exec_policy`: probe and recipe get exactly the +same thing (tree read-only apart from `results/`), so "works under +`lc run`" and "works as a recipe" stay the same fact. + +## The container hatch + +Containerized mode changes the recipe's world and nothing else. +`image.py` (pure) turns the `[tool.lightcone.image]` declaration into +a rendered Containerfile, an identity document, and a content tag; +`container.py` (impure) builds it, saves it as a `docker-archive` +inside the repository (`.datalad/environments//image`, annexed), +and enters it. The engine never enters the image — driver, git, and +classification stay on the host; exactly two things run in-image: the +environment sync and each recipe exec, over a read-only rootfs with +the mount table as the whole policy. Execution pins the archive's +config-blob id, never a tag. + +## Venues + +`materialize.cluster_for_run()` is the one place that decides where a +run executes, and the seam it returns is two methods wide — +`submit(fn, *args, key=…)` and `completed(handles)`. A SLURM +allocation (detected by `SLURM_JOB_ID`) gets one worker per node via a +single `srun`, running the driver's own interpreter so driver and +workers are the identical installation. Anything else is the local +machine. Venues are detected, never configured; the only venue config +that exists is the allocation the user already requested. + +## The publication view + +`crate.py` renders the repository as a Provenance Run Crate — a pure +function of repository state (sorted iteration, no clock, git injected +as a callable), which is what lets `materialize` converge +`ro-crate-metadata.json` byte-for-byte and commit only differences. +Run identity comes free from the manifests' `git_sha` (the driver +reads HEAD once per run), so one materialize maps onto one +`OrganizeAction` with no new manifest field. + +## Repository at a glance + +```text +src/lightcone/ # namespace — NO __init__.py +├── _sandbox_exec.py # the Landlock shim — stdlib only, zero lightcone imports +├── cli/commands.py # flags, rendering, exit codes — nothing else +└── engine/ + ├── project.py # what a project is: convergence, discovery, mode + ├── dataset.py # the git + git-annex seam + ├── identity.py # env_version, definition_version, the lock scan + ├── image.py # the system layer, declared → rendered — pure + ├── container.py # runtimes, the build, the archived image — impure + ├── crate.py # the publication view — pure + ├── assets.py # an output: directory, manifest, state + ├── plan.py # the spec, read as a graph of tasks + ├── worker.py # making one output; the rerun entry point + ├── materialize.py # the driver: gates, Dask, the save/restore loop + ├── run.py # what `lc run` is + ├── venue.py # where a run executes + ├── sandbox/ # the exec boundary + └── templates/ # the scaffold's file content, as real files +``` + +Each module's page in [Engine Internals](api/index.md) carries its +public surface and the invariants that bind it. diff --git a/docs/assets/favicon.svg b/docs/assets/favicon.svg new file mode 100644 index 0000000..011c383 --- /dev/null +++ b/docs/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/assets/logo.svg b/docs/assets/logo.svg new file mode 100644 index 0000000..011c383 --- /dev/null +++ b/docs/assets/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/cli/build.md b/docs/cli/build.md new file mode 100644 index 0000000..f4603a2 --- /dev/null +++ b/docs/cli/build.md @@ -0,0 +1,90 @@ +# lc build + +Build the project's system-layer image, and commit it. Containerized +projects only — a project containerizes by declaring a +`[tool.lightcone.image]` table in `pyproject.toml`, and on a direct +project this verb just says so and exits. + +## Synopsis + +```text +lc build [OPTIONS] +``` + +Idempotent: an image that is already built and committed is left +alone. + +## What the image is + +The image is the *system layer* only: the declared base (digest-pinned, +or the default), the declared apt packages, and the pinned Python +interpreter. Your analysis environment is not in it — recipes' Python +packages come from the project's lock, synced into the container at run +time — and neither is `lc` itself. That is what makes "editing code +never rebuilds the image" structural: no project file enters the build +context at all. + +The declaration is a closed set of keys, hashed into the image's +identity: + +```toml +[tool.lightcone.image] +base = "docker.io/library/debian@sha256:..." # optional; default pinned by lc +apt-install = ["libfftw3-dev"] # optional +run-commands = ["curl -L ... | tar xz"] # optional, the bounded escape +env = { OMP_NUM_THREADS = "1" } # optional +``` + +## The archive is the store + +`lc build` saves the built image into the repository — +`.datalad/environments//image`, a `docker-archive` committed +through git-annex — so the exact bytes travel with the project: a +clone obtains them with a fetch, no registry and no credentials +involved. Execution always pins the image's content *id*, never a tag, +so nothing can substitute a different image under the same name. + +The archive records the architecture it was built for, and a host that +can't execute that architecture is refused up front — build where the +architecture matches the machines that will run recipes (on NERSC, a +login node). + +## Requirements + +- A clean tree — the image commit must not sweep your staged edits in, + and the tag derives from the committed declaration. +- A build-capable runtime: `podman-hpc`, `podman`, or `docker` + (detected in that order; nothing to configure). + +`lc materialize` also builds as a preflight when the committed +declaration has no image yet, announcing it first — `lc build` exists +so you can pay the minutes when *you* choose to. + +## Options + +| Option | Default | Effect | +|--------|---------|--------| +| `--json` | off | Emit the result as JSON on stdout. | + +## The JSON result + +```json +{ + "mode": "containerized", + "tag": "lc-env-1a2b3c4d5e6f7a8b", + "id": "sha256:...", + "archive": ".datalad/environments/lc-env-1a2b3c4d5e6f7a8b/image", + "action": "built" +} +``` + +`action` is `"built"` when this invocation built and committed the +image, `"present"` when it was already there. On a direct project the +result is just `{"mode": "direct"}`. + +## Examples + +```bash +lc build # build + commit, or confirm it's already there +lc build --json # the machine-readable form +``` diff --git a/docs/cli/index.md b/docs/cli/index.md new file mode 100644 index 0000000..eda2f2e --- /dev/null +++ b/docs/cli/index.md @@ -0,0 +1,53 @@ +# CLI Reference + +The `lc` CLI is a thin wrapper around the engine. The user-facing +surface is small on purpose — `astra.yaml` carries the analysis +description, and the CLI is the durable, scriptable way to execute +and audit it. + +## Global behavior + +- **The current directory is the project.** Every command except + `init` assumes it is invoked from the project root; there is no + walk-up and no global configuration. Outside a project, a command + errors cleanly. +- **Nothing waits on a human.** No command prompts or opens an + interactive shell — every verb runs to completion on its arguments + alone, which is what makes the CLI safe to drive from scripts and + agents. +- **Refusals carry their remedy.** When a command refuses (a dirty + tree, a login node, a missing image), the message names the exact + command that fixes it. + +## Commands + +| Command | Purpose | +|---------|---------| +| [`lc init`](init.md) | Converge a directory into a Lightcone project (idempotent). | +| [`lc materialize`](materialize.md) | Make the analysis's outputs; commit each one as it lands. | +| [`lc status`](status.md) | Report the state of every output. Reads only; always exits 0. | +| [`lc run`](run.md) | Run an ad-hoc command in the project environment, under isolation. | +| [`lc build`](build.md) | Containerized projects: build the image and commit it. | + +## Global options + +```text +lc [OPTIONS] COMMAND [ARGS]... + +Options: + --version Show the version and exit. + --help Show this message and exit. +``` + +## Exit codes + +- `0` — the command did what it says. +- `1` — a refusal or a failure, with the reason on stderr. For + `lc materialize --check` and `lc init --check`, exit 1 means "work + would be done" — the gate form scripts branch on. +- `lc run` is a proxy: it exits with the command's own code + (`128 + N` for a signal), so pipelines read it exactly as they would + the bare command. + +Every verb with a report takes `--json` for the machine-readable form; +each verb's page shows its shape. diff --git a/docs/cli/init.md b/docs/cli/init.md new file mode 100644 index 0000000..3005d5b --- /dev/null +++ b/docs/cli/init.md @@ -0,0 +1,135 @@ +# lc init + +Converge a directory into a Lightcone project. Idempotent — safe to run +at any time, on an empty directory, a half-scaffolded one, an existing +project, or a fresh clone. + +## Synopsis + +```text +lc init [OPTIONS] [DIRECTORY] +``` + +`DIRECTORY` defaults to `.` (the current directory). + +## Convergence semantics + +Each run creates whatever is missing, repairs the pieces lightcone +manages, and never overwrites files you own: + +- **Created if missing** — every item in the tree below. A directory + that already holds an `astra.yaml` is *adopted*: the spec is left + untouched and only the missing lightcone pieces are added. A + directory inside an existing git repository adopts that repository + rather than nesting a new one. +- **Repaired** — derived artifacts that have drifted: a `uv.lock` that + no longer matches `pyproject.toml`, a `.venv` that no longer matches + the lock, a managed `.gitignore` or `.gitattributes` entry that a + newer `lc` added, an annexed repository still missing the + `filter.annex.required` flag. Repairs only ever append or rebuild + derived state; + hand-written lines are never reordered or removed. +- **Blocked** — something convergence can see but must not fix by + appending: a `.gitignore` rule that would silently swallow + `results/`, a `.gitattributes` whose ordering would misroute storage. + A blocked item names the file and line at fault, counts against + convergence, and is yours to resolve. +- **Warned about** — advisory facts (e.g. uv falling back to file + copies across filesystems). Warnings never affect the exit code. + +`--check` computes the same report without writing anything and exits +`1` when the project is not converged. `--json` prints it +machine-readable: + +```json +{ + "converged": true, + "created": [], + "repaired": [], + "unchanged": ["astra.yaml", "pyproject.toml", "..."], + "blocked": [], + "warnings": [] +} +``` + +Agents driving a project should run `lc init --check --json` at the +start of a session to make sure the directory is workable. + +## What it creates + +Inside `DIRECTORY` (creating it if needed): + +```text +astra.yaml # an empty analysis spec, ready to fill in +universes/ + baseline.yaml # the default universe (selects nothing yet) +pyproject.toml # the uv project — the environment's source of truth +.python-version # the exact interpreter, pinned +uv.lock # derived: converged by correctness, not existence +.venv/ # derived: built from the lock (local, never committed) +.gitignore # managed entries, converged line-wise +.git/ # a git repository, with git-annex initialized +.gitattributes # the storage policy: what the annex carries +.datalad/config # dataset identity (a DataLad dataset from birth) +data/ + README.md # declared input data lives here +results/ + README.md # outputs land here — lc's to write +myst.yml # MyST report configuration +index.md # template report, to reference astra.yaml from +``` + +Two things it deliberately does *not* create: a `src/` directory +(where analysis code lives is your layout, and git doesn't track empty +directories), and any dependency in `pyproject.toml` — the lock +carries only what *your* analysis imports, added with `uv add`. + +Inside `.git`, convergence sets one configuration key — reported as the +`annex-filter` item: + +- `filter.annex.required=true`, always. Without it, a `git add` whose + shell cannot find git-annex prints an error, **exits 0, and stages + the raw bytes into git history** — a 2 GB dataset in git proper, on + every clone, forever. With it, the same situation is a hard, loud + failure and nothing is staged. Once the project holds committed + annexed content that refusal covers every command that must run the + filter, `git status` and `git diff` included — a project you cannot + use until git-annex is back, rather than one that silently absorbed + your data. + +That is the only thing `lc init` adds to what `git annex init` wrote. +How git finds git-annex is still ordinary `PATH` resolution, which is +why `lc` should be installed with `uv tool install lightcone-cli` — it +puts `git-annex` on your `PATH` alongside `lc`. If your `git add` ever +refuses, see +[`fatal: … clean filter 'annex' failed`](../user/troubleshooting.md#fatal-clean-filter-annex-failed) +in the troubleshooting guide. + +## Options + +| Option | Default | Effect | +|--------|---------|--------| +| `--check` | off | Report drift without writing; exit 1 if not converged. | +| `--json` | off | Emit the convergence report as JSON on stdout. | + +There is deliberately nothing else — no `--no-git`, no template +selection. The project layout is the contract the other verbs rely on. + +## Examples + +```bash +lc init # converge cwd +lc init my-analysis # scaffold/converge ./my-analysis +lc init --check --json # is this directory workable? (for scripts/agents) +lc init # in a fresh clone: rebuild .venv + the annex +``` + +## Next steps + +```bash +cd my-analysis +# Describe your analysis in astra.yaml — inputs, outputs, recipes, +# decisions — and write the scripts the recipes name. +uv add numpy # declare what the scripts import +git add -A && git commit -m "First analysis" +lc materialize # make the outputs +lc status # see where everything stands +``` diff --git a/docs/cli/materialize.md b/docs/cli/materialize.md new file mode 100644 index 0000000..6a7283c --- /dev/null +++ b/docs/cli/materialize.md @@ -0,0 +1,113 @@ +# lc materialize + +Make the analysis's outputs, and commit each one as it lands. This is +the build verb: it validates the spec, converges the environment, runs +every recipe that needs running — in dependency order, in parallel +where the graph allows — and commits each result together with its +manifest, in a commit whose message is a replayable run record. + +## Synopsis + +```text +lc materialize [OPTIONS] [TARGETS]... +``` + +With no targets, everything the spec declares, across every universe. +A target narrows the run to an output and whatever it depends on: + +- `fit` — the output `fit` in every universe that has it. +- `robust/fit` — exactly one universe's output. + +A target that matches nothing is an error listing what exists — +quietly making nothing is the least useful thing a build tool can do. + +## What gets remade + +An output is remade when it is `stale` — the analysis defines it +differently than it was made (a changed recipe or decision), one of +its declared inputs changed content, or it was edited by hand since. +Inputs are compared by content, so a rebuild that comes out +byte-identical stops the cascade there. + +An output that is `behind` — still exactly what the spec asks for, +but made under an earlier environment — is reported and left alone; +`--refresh` widens the run to remake those too. A `current` output is +never touched, under any flag. + +## The run's contract + +- **Starts clean, ends clean.** A dirty tree is a refusal (the message + sorts your uncommitted work from stray files under `results/`); a + failed or interrupted recipe's partial work is rolled back. +- **Fetches what it needs.** Declared inputs whose annexed content is + not in this clone are fetched before anything hashes. +- **Commits as it goes.** Each output lands in its own commit, written + by the driver in one thread while other recipes keep running. +- **Reports every independent failure.** One failing recipe doesn't + abort the rest; its dependents report `blocked` and the run exits 1 + with all of it listed. +- **Maintains the publication view.** With a `[project].license` + declared, the run converges `ro-crate-metadata.json` in a trailing + commit. + +On a containerized project, the run resolves the committed image first +(building it as a preflight if the declaration is committed but the +image never built). Inside a SLURM allocation, the run spans every +allocated node — see [Running on a Cluster](../user/cluster.md). + +## Check mode + +`--check` classifies every output without executing, committing, or +fetching anything, and exits `1` if a run would do work — the gate a +script or CI job branches on. It is exempt from the dirty-tree +refusal: reading the state of a project before deciding what to commit +is what it is for. + +## Options + +| Option | Default | Effect | +|--------|---------|--------| +| `--check` | off | Report what would run and why; exit 1 if anything is out of date. | +| `--refresh` | off | Also remake `behind` outputs. Never touches `current` ones. | +| `--json` | off | Emit the report as JSON on stdout. | + +There is deliberately no `--jobs` (a run takes every core; sizing +belongs to the allocation you run it in), no `--force`, and no flag to +*skip* a stale output — deleting its directory is your own file +operation, and stronger consent than a flag. + +## The JSON report + +```json +{ + "ok": true, + "up_to_date": true, + "made": [], + "current": ["baseline/fit", "robust/fit", "baseline/fit_plot", "robust/fit_plot"], + "behind": {}, + "failed": [], + "blocked": [], + "planned": {}, + "warnings": [], + "notes": [] +} +``` + +The first two keys are the ones to branch on: `ok` — everything +attempted finished; `up_to_date` — nothing needed doing (a failed run +is never up to date, and `behind` outputs don't count against it). +`planned` is check mode's answer, mapping each would-run output to why; +`behind` maps each left-alone output to the commit that can rebuild its +environment. `notes` carries sandbox messages verbatim — denial +remedies are built to be pasted. + +## Examples + +```bash +lc materialize # everything, all universes +lc materialize fit # one output (and upstreams), every universe +lc materialize robust/fit # one universe's output +lc materialize --check # would anything run? (exit 1 = yes) +lc materialize --refresh # also remake behind outputs +lc materialize --check --json # the machine-readable gate +``` diff --git a/docs/cli/run.md b/docs/cli/run.md new file mode 100644 index 0000000..2171b98 --- /dev/null +++ b/docs/cli/run.md @@ -0,0 +1,61 @@ +# lc run + +Run an ad-hoc command in the project environment, under isolation. +This is the probe verb: it executes exactly one command the way a +recipe would be executed — same environment, same sandbox — so "does +it work under `lc run`?" and "will it work as a recipe?" are the same +question. + +## Synopsis + +```text +lc run COMMAND... +``` + +Everything after `run` is the command, verbatim — flags included. +Argv, the `docker run` / `uv run` convention: a single quoted string +would be exec'd as one filename, so probe shell syntax through +`bash -c` instead. `lc run` takes no options of its own, so nothing +else needs escaping: + +```bash +lc run python -c "import numpy; print(numpy.__version__)" +lc run python src/fit.py --points data/points.csv --outliers keep --output /tmp/probe +``` + +## What it does + +- **Converges the environment first.** The probe syncs `.venv` to the + lock before executing, so what you probe is what a recipe gets. +- **Applies the recipe policy.** The project tree is read-only apart + from `results/`, declared inputs are readable, undeclared tools + don't execute. On a containerized project, the command runs inside + the committed image (which must already be built — the probe never + builds). +- **Proxies the exit code.** `lc run` exits with the command's own + code — `128 + N` when a signal killed it — so scripts and pipelines + read it exactly as they would the bare command. +- **Explains denials.** On a nonzero exit, a note on stderr says the + command ran sandboxed; when the failure looks like a denial, the + note names the path and the remedy (`uv add` for a missing package, + an ASTRA input declaration for data, `results/` or + `tempfile.mkdtemp()` for writes). + +A probe has no output and writes no manifest: nothing it does is +recorded anywhere. Any uv project works — `lc run` doesn't require an +`astra.yaml`, only `pyproject.toml`, `uv.lock` and `.venv` in the +current directory. + +## What it is not + +There is no sandbox opt-out and no flag surface — a command that needs +more than the policy grants is a command that would fail as a recipe, +and the fix (declare the dependency) is the same in both places. + +## Examples + +```bash +lc run python -c "import scipy" # is the package in the lock? +lc run bash -c 'echo $HOME' # see the private HOME a recipe gets +lc run python src/fit.py --help # exercise a script exactly as a recipe would +``` diff --git a/docs/cli/status.md b/docs/cli/status.md new file mode 100644 index 0000000..fae5fdb --- /dev/null +++ b/docs/cli/status.md @@ -0,0 +1,90 @@ +# lc status + +Report what state each of the analysis's outputs is in. Reads only: it +runs nothing, commits nothing, transfers no data, does not mind an +unclean tree, and always exits `0` — a state is not a failure. The +moment you most need to know where a project stands is when it isn't +clean, so this verb works there. + +## Synopsis + +```text +lc status [OPTIONS] +``` + +## Output + +```text + mode: direct + sandbox: landlock (fs: declared, network: allowed) + + · current baseline/fit a3f1f11 + · current baseline/fit_plot a3f1f11 + · behind robust/fit 00cc14e made under an earlier environment + ! stale robust/fit_plot — no manifest — it has never been materialized + +2 current · 1 behind · 1 stale +``` + +The header is repository facts: which mode the project executes in +(and, for a containerized project, the image's tag and state), and +what enforcement a run on this host would get. No runtime and no +network is needed to answer either. + +Then one line per output the spec declares, in dependency order: its +state, **the commit it was made at**, and — for anything not current — +why. The commit column is the verb's reason to exist: "which code made +this?" has an answer for a current output too, and for a `behind` +output that commit is where the environment that produced it can be +read back. + +## States + +- `current` — exactly what the spec asks for. Nothing to do. +- `behind` — still what the spec asks for; the environment moved since. + Left alone by runs; `--refresh` remakes. +- `stale` — contradicts the project: definition changed, an input's + content changed, or the output was edited by hand since it was made + (a *foreign write* — the offending commit is named). + +## Report vs gate + +`lc status` reports; **`lc materialize --check` gates.** Two verbs +answering the same question with different exit codes is how a script +comes to depend on the wrong one, so the split is sharp: use status for +eyes, check for exit codes. + +## Options + +| Option | Default | Effect | +|--------|---------|--------| +| `--json` | off | Emit the report as JSON on stdout. | + +## The JSON report + +```json +{ + "mode": "direct", + "image": null, + "sandbox": "landlock (fs: declared, network: allowed)", + "counts": {"current": 4, "behind": 0, "stale": 0}, + "outputs": [ + { + "output": "baseline/fit", + "status": "current", + "why": "", + "git_sha": "a3f1f11791430d1becbe5548477b5910ab59a94a", + "data_version": "sha256:939e9a55...", + "foreign_write": "" + } + ], + "warnings": [] +} +``` + +Per output: the state, the reason (empty for `current`), the commit it +was materialized at and its content identity (both empty if it never +was), and `foreign_write` — the sha of a hand-edit's commit when one +was detected, which the prose `why` cannot carry for a machine +consumer. For a containerized project, `image` is +`{"tag": ..., "state": "present" | "absent" | "unfetched"}`. diff --git a/docs/contributing/extending.md b/docs/contributing/extending.md new file mode 100644 index 0000000..ae3312f --- /dev/null +++ b/docs/contributing/extending.md @@ -0,0 +1,55 @@ +# Extending the Codebase + +Where each kind of change belongs, what to read first, and the +invariant it must keep. The engine has one implementation per rule — +most review feedback is some form of "that spelling already exists; +use it". + +## The map + +| To change… | Edit | Keep true | +|---|---|---| +| What a scaffolded file contains | `engine/templates/files/*.tmpl` (+ `test_templates.py`) | A template gets a function only when a value must be decided or a merge policy held. | +| What gets converged | `engine/project.py` (+ `test_project.py`) | Everything through `_Converger.item`/`.file`/`.blocked`; repairs only append; only what git can carry. | +| How a project stores bytes | `engine/dataset.py` + `gitattributes.tmpl` (+ `test_dataset.py`, real annex) | Every command through `project._run`; nobody is asked to run git-annex. | +| How an output is identified | `engine/identity.py` (+ `test_identity.py`) | Sensitivity both ways: what must move the hash, what must not. Length-framing stays. | +| When an output is remade | `engine/assets.py` (+ `test_assets.py`) | One `classify`; callers differ by one input value, never by logic. Ask first: does the change *contradict* the project (stale) or is it *circumstance* (behind)? | +| How the spec becomes a graph | `engine/plan.py` (+ `test_plan.py`) | Ask `astra.resolve`; a missing answer is a PR to astra-tools; ambiguity is a `ProjectError`, never a guess. | +| How a recipe runs | `engine/worker.py` (+ `test_worker.py`) | Never raises; no git; mutation-check every denial test. | +| What a run commits | `engine/materialize.py` (+ `test_materialize.py`) | The driver owns git alone; the tree ends as clean as it started. | +| Where a run executes | `engine/venue.py` + `cluster_for_run` (+ `test_venue.py`) | One detection ladder; venues detected, never configured; test by faking the host. | +| Supporting a new HPC center | `venue._SITES` | One row — marker + the center's own `salloc`/`sbatch` spellings, verified against its documentation, never guessed. | +| What a sandboxed command may touch | `sandbox/policy.py` (+ `test_sandbox_policy.py`) | Path sets only — no mechanism leaks in. | +| Adding a sandbox mechanism | one module in `sandbox/` + one line in `detect()` | `wrap` pure, `attest` honest, `contains_prefix` answered. Nothing above the seam changes. | +| A denial message | `sandbox/denial.py` (+ `test_sandbox_denial.py`) | Remedies copy-pasteable and real *today*; the trailer stays unconditional. | +| What the image is made of | `engine/image.py` (+ `test_image.py`) | Pure; every declaration key hashed; structure tests, never byte goldens. | +| How images are built/stored/entered | `engine/container.py` + `sandbox/oci.py` (+ `test_container.py`) | `runtime_for_run`'s two strictnesses; runtime differences are spellings inside `OCIBackend`, never new shapes. | +| What the crate says | `engine/crate.py` (+ `test_crate.py`) | Pure builder: sorted, no clock, git injected; render-twice-identical. The validator floor lives in `test_crate_smoke._FLOOR`. | +| How a foreign write is detected | `dataset.last_writer` + `materialize._foreign_write` | History, never hashing; `datalad_run_subject` is the one spelling of the record's subject. | +| A CLI verb | `cli/commands.py` (+ `test_cli.py`) | Logic in the engine; raise `ProjectError`; render here; engine imports stay inside callbacks. | + +## Rules that apply everywhere + +- **Land code, tests, and dependencies together.** A dependency enters + `pyproject.toml` with the change that needs it, never speculatively. +- **No dead code, no foreshadowing.** Nothing references a verb, flag, + or feature that doesn't exist yet; `lc --help` advertises only what + works. +- **No escape hatches.** Enforcement ships without a flag to turn it + off; there is deliberately no `--no-sandbox`, no `--force`, no + rebuild-the-world flag. +- **Nothing waits on a human.** No prompt, no interactive shell — + either is a hang for the agents that run these verbs most. +- **Refusals carry remedies, and remedies are verified.** A message + that tells someone to run a command has been run; a center's + spellings come from its documentation. +- **Docstrings are Google-style, comments carry *why*.** A design + decision gets a sentence; its history belongs in the design record, + not the code. + +## Conventions + +Ruff (E, F, I, N, W, UP; line length 100), mypy strict with +`namespace_packages = true`. `src/lightcone/` must never gain an +`__init__.py` — the namespace is shared with future sibling +distributions, and a real package there breaks the contract. diff --git a/docs/contributing/setup.md b/docs/contributing/setup.md new file mode 100644 index 0000000..188f1cc --- /dev/null +++ b/docs/contributing/setup.md @@ -0,0 +1,78 @@ +# Development Setup + +Everything runs through [uv](https://docs.astral.sh/uv/); there is no +task runner and no other build tooling. + +## Clone & install + +```bash +git clone https://github.com/LightconeResearch/lightcone-cli.git +cd lightcone-cli +uv sync --group dev +``` + +That resolves the engine and the dev tools (pytest, ruff, mypy, +datalad, the rocrate validator) into `.venv`. `uv run lc --version` +runs the checkout's `lc`. + +You also need `git` on `PATH` (the one tool uv cannot install); +git-annex arrives as a wheel with the sync. + +## The loop + +```bash +uv run pytest # the suite +uv run ruff check src/ tests/ # lint (--fix to apply) +uv run mypy src/ # strict mode +``` + +These three are exactly what CI runs (`tests.yml`, `lint.yml`) — green +locally means green there, modulo the gated suites below. + +Most of the suite is hermetic: an autouse fixture stubs the engine's +one subprocess seam, so tests spawn nothing and touch no network. The +exceptions opt in explicitly — see [Testing](testing.md). + +### The gated suites + +Three suites answer questions only a real mechanism can, and each +skips where its mechanism is missing — with an environment variable CI +sets to turn the skip into a hard failure: + +| Variable | Suite | Needs | +|---|---|---| +| `LC_SANDBOX_TESTS_REQUIRED=1` | `test_sandbox_enforcement.py` | Landlock (Linux) or Seatbelt (macOS) | +| `LC_CONTAINER_TESTS_REQUIRED=1` | `test_container_smoke.py` | podman or docker | +| `LC_CRATE_TESTS_REQUIRED=1` | `test_crate_smoke.py` | nothing beyond dev deps | + +## Building the docs + +```bash +uv sync --group docs +uv run zensical build # renders into site/ +uv run zensical serve # live preview +``` + +The site deploys on release (`docs-deploy.yml`), so docs track the +released CLI, not `main`. A pre-release deploys nothing — the site keeps +serving the last full release. + +## Building the wheel + +```bash +uv build +``` + +CI runs this only to publish. The version comes from hatch-vcs — the +git tag for a release, tag-plus-commit for a dev build — which is also +what lets a run record pin a dev engine by its source commit. + +## Pre-PR checklist + +1. `uv run pytest` — including, if your change touches the sandbox, + containers, or the crate, the relevant gated suite on a host that + can run it. +2. `uv run ruff check src/ tests/` and `uv run mypy src/`. +3. New behavior lands with its tests, in the same PR. +4. Read [Extending](extending.md) — it says where each kind of change + belongs, and the invariants it must keep. diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md new file mode 100644 index 0000000..8143fac --- /dev/null +++ b/docs/contributing/testing.md @@ -0,0 +1,77 @@ +# Testing + +The suite's shape follows the engine's: pure modules get pure tests, +the subprocess seam gets a stub, and the questions only a kernel, a +runtime, or a validator can answer get real ones — gated so they can't +pass by not running. + +## The one seam + +`tests/conftest.py`'s autouse `tools` fixture stubs +`engine.project._run` — the single choke point every external command +goes through — emulating each tool's observable effect (`uv lock` +writes `uv.lock`, `git init` makes `.git`, …) and recording every +argv. Under the stub the suite is hermetic: no network, no resolution, +no subprocesses. + +The `real_tools` fixture opts back out, putting the real `_run` back. +Everything built on it (the `analysis` fixture, the rerun tests) does +spawn and may touch the network — that is the deliberate price of +testing execution. + +## Where a question belongs + +| Question | File | Character | +|---|---|---| +| Convergence semantics | `test_project.py` | stubbed | +| Template content & repair | `test_templates.py` | pure | +| Do bytes land in the annex? | `test_dataset.py` | **real tools** — every bug this seam had was invisible to a stub | +| Identity sensitivity | `test_identity.py` | pure, both directions | +| The graph, the gate | `test_plan.py` | pure — tests what lc *adds*, never what a spec means (that's astra-tools' suite) | +| Classification | `test_assets.py` | pure | +| One output, real recipe | `test_worker.py` | real boundary, real repo | +| The run, the record | `test_materialize.py` | real repos; one real `LocalCluster`; real `datalad rerun` | +| Venue detection & launch | `test_venue.py` | fakes the *host* (env vars, a stub srun), never the code | +| Policy / wrap / denial | `test_sandbox_*.py` | pure, run on every OS | +| The kernel's answer | `test_sandbox_enforcement.py` | gated | +| Image identity | `test_image.py` | pure — structure and ordering, never byte goldens | +| Runtime lifecycle | `test_container.py` | stubbed; refusals asserted on recorded argv | +| The runtime's answer | `test_container_smoke.py` | gated | +| The crate | `test_crate.py` | pure; the one byte claim is render-twice-identical | +| The validator's answer | `test_crate_smoke.py` | gated | +| CLI surface | `test_cli.py` | `CliRunner`; assert short unwrappable fragments | + +## The enforcement suite + +`test_sandbox_enforcement.py` is the only file that can tell you the +sandbox works, and four properties keep it honest: + +1. **One suite, both mechanisms** — parameterized by `detect()` alone; + a leak only Linux catches is a leak, and macOS CI is the sole place + the generated SBPL ever executes. +2. **The real policy** — always `exec_policy`, never one hand-built to + make the point. (`/usr` once sat in the exec set through a fully + green suite built the other way.) +3. **Real leaks, tried literally** — undeclared tools executed, + undeclared libraries `dlopen`ed, undeclared data read. +4. **It cannot pass by not running** — `LC_SANDBOX_TESTS_REQUIRED=1` + in CI turns the skip into a failure, and two tests cover the guard + itself. + +**Mutation-check every denial test**: run the same command through +`Unavailable()` and confirm it *succeeds*. A denial test that would +pass unsandboxed is testing nothing, and the failure mode is silent. +Two related traps: a write-denial must target a path the OS would let +you write (a `/etc` write pins nothing), and enforcement fixtures must +not live under `/tmp`, which is inside the write baseline — the +`outside` fixture roots at `$HOME` for exactly this reason. + +## Conventions + +- Don't add a flag whose only user is a test — stub `project._run` + instead. +- A forged-output test must break the annex hard link before writing + (`test_materialize._forge` shows how) — results are committed thin, + so an in-place write dirties every byte-identical sibling. +- Record formats are tested through their consumer (datalad's parser, + the rocrate validator), never as golden files of our own JSON. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..3146407 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,61 @@ +# lightcone-cli + +**lightcone-cli** is [Lightcone Research][lr]'s execution layer for +[**ASTRA**][astra] (Agentic Schema for Transparent Research Analysis). +It serves as the machinery that ties an analysis `astra.yaml` specification to a tree +of materialized outputs. + +!!! warning "Alpha development" + lightcone-cli is in **early alpha**. The CLI and the execution layer are + still moving — expect breaking changes between minor versions. Bug reports, design + challenges, and use cases the tooling doesn't yet cover are exactly what we want to + hear at this stage; please open an issue on the + [GitHub repo](https://github.com/LightconeResearch/lightcone-cli/issues). + +## Choose your path to the documentation + +
+ +- __I want to try it out__ – :lucide-rocket: + + --- + + Installation instructions, step-by-step tutorial, and fast tour of the lightcone framework and its workflow capabilities. + + [User Guide](user/index.md){ .md-button .md-button--primary } + +- __I want to contribute__ – :lucide-cog: + + --- + + In depth tour of the software architecture and API docs, as well as contribution instructions, aimed for + contributors and maintainers. + + [Developer corner](maintainer.md){ .md-button .md-button--primary } + +
+ +--- + +## Two libraries, one toolchain + +
+ +- __lightcone-cli__ + + The library that ships the `lc` CLI: project scaffolding, locked environments, sandboxed execution, and the provenance layer. Depends on [**astra-tools**][astra-tools], the SDK for working with ASTRA analysis specifications. + + [:fontawesome-brands-github: Repository][cli]{ .md-button } + +- __astra-tools__ + + The SDK for working with [**ASTRA**][astra] analysis specifications. This library provides the `astra` CLI which handles the [**ASTRA**][astra] lifecycle and validation process (schema, prior insights & findings, evidence verification helpers). + + [:fontawesome-brands-github: Repository][astra-tools]{ .md-button } + +
+ +[lr]: https://lightconeresearch.org/ +[astra]: https://astra-spec.org/latest/ +[astra-tools]: https://github.com/LightconeResearch/astra-tools +[cli]: https://github.com/LightconeResearch/lightcone-cli diff --git a/docs/maintainer.md b/docs/maintainer.md new file mode 100644 index 0000000..fdfac4a --- /dev/null +++ b/docs/maintainer.md @@ -0,0 +1,58 @@ +# Developer corner + +`lightcone-cli` is a small engine with strong opinions: one way to +identify an output, one way to store it, one boundary to execute it +behind. This guide covers everything below the user surface — how the +engine is put together, what each module owns, and how to get a +working dev loop. + +If you're looking for the user-facing docs, the +[user guide](user/index.md) is the other half of this site. + +## What this covers + +- [Architecture](architecture.md) — the CLI/engine/ASTRA split, the + run pipeline, identity, storage, the exec boundary, and the + invariants that hold them together. +- [CLI Reference](cli/index.md) — every `lc` command: flags, JSON + report shapes, exit codes. +- [Engine Internals](api/index.md) — the `lightcone.engine.*` + modules: what each owns, its key symbols, and what must stay true + of it. +- [Contributing](contributing/setup.md) — clone, install, run the + test suite; [how the suite is shaped](contributing/testing.md); and + [where a change belongs](contributing/extending.md). + +## Get started in three commands + +!!! tip "Dev loop" + + ```bash + git clone https://github.com/LightconeResearch/lightcone-cli.git + cd lightcone-cli + uv sync --group dev && uv run pytest + ``` + +Test, lint (`uv run ruff check src/ tests/`) and type-check +(`uv run mypy src/`) are the whole loop — there is deliberately no +task runner in between. + +## The house rules + +A few conventions run through every module; changes are reviewed +against them: + +- **No dead code, no foreshadowing.** Nothing lands before the layer + that calls it, and no message names a verb or flag that doesn't + exist yet. `lc --help` advertises only what works. +- **No escape hatches around guarantees.** A feature that enforces + something ships without a flag to turn the enforcement off. +- **Literal behavior over invented convenience.** The current + directory is the project; erroring beats walking up or guessing. + Nothing prompts — a verb is run by an agent more often than a + person, and a prompt is a hang. +- **One implementation per rule.** Classification, path naming, the + run-record subject, tool resolution — each has exactly one spelling, + and a second copy is where the two start to disagree. +- **Honest reporting.** What was enforced, what was skipped, and what + a clone can't see are all recorded or said — never assumed. diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..e454c12 --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,57 @@ +@import url('https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400..800;1,400..800&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap'); + +/* ── Light mode ─────────────────────────────────────────────────────────── */ + +:root > * { + --md-primary-fg-color: #4e5a70; + --md-primary-fg-color--light: #6b7a8d; + --md-primary-fg-color--dark: #3a4456; + --md-accent-fg-color: #426b78; + --md-default-bg-color: #f8f7f3; + --md-default-bg-color--light: #ffffff; + --md-default-bg-color--dark: #f1efe9; + --md-text-font: "Libre Baskerville", Georgia, serif; + --md-code-font: "JetBrains Mono", "Fira Code", monospace; +} + +body, +.md-header, +.md-main, +.md-main__inner, +.md-content, +.md-tabs, +.md-sidebar { + background-color: #f8f7f3; +} + +/* ── Dark mode ──────────────────────────────────────────────────────────── */ + +[data-md-color-scheme="slate"] { + --md-default-bg-color: #221f20; + --md-hue: 219; +} + +[data-md-color-scheme="slate"] body, +[data-md-color-scheme="slate"] .md-header, +[data-md-color-scheme="slate"] .md-main, +[data-md-color-scheme="slate"] .md-main__inner, +[data-md-color-scheme="slate"] .md-content, +[data-md-color-scheme="slate"] .md-tabs, +[data-md-color-scheme="slate"] .md-sidebar { + background-color: #221f20; +} + +[data-md-color-scheme="slate"] .md-nav__link--active, +[data-md-color-scheme="slate"] .md-nav__item--active > .md-nav__link { + background-color: rgba(106, 147, 160, 0.20); + color: #f8f7f3; +} + +[data-md-color-scheme="slate"] .md-content a { + color: #85c0d0; +} + +[data-md-color-scheme="slate"] .md-content :not(pre) > code { + background-color: rgba(76, 63, 70, 0.15); + color: #c8dde5; +} diff --git a/docs/user/cluster.md b/docs/user/cluster.md new file mode 100644 index 0000000..f422897 --- /dev/null +++ b/docs/user/cluster.md @@ -0,0 +1,127 @@ +# Running on a Cluster + +When local laptop time isn't enough, the same project runs on a SLURM +HPC system. There is no separate configuration to learn and no flag to +pass — `lc materialize` detects where it is running, and the allocation +you request *is* the resource declaration. + +## The big picture + +`lc materialize` runs its tasks through a scheduler, and picks the venue +by looking at the environment: + +1. **Inside a SLURM allocation** (`SLURM_JOB_ID` is set) → the run + spans every node the allocation holds: one worker per node, launched + via `srun`, using every core it was granted. +2. **Anywhere else** → the local machine, using every core. + +You already answered every sizing question at `salloc` / `sbatch` — +how many nodes, which constraint, how long — so `lc` asks none of its +own. There is no `--jobs`, no worker count, no venue config file. + +## A typical SLURM workflow + +### 1. Prepare on the login node + +Everything except executing recipes works on a login node — and one +verb is *for* it: + +```bash +cd $SCRATCH/my-analysis +lc materialize --check # what would run, and why +lc status # where every output stands +lc build # containerized projects: build + commit the image +``` + +### 2. Get an allocation and materialize inside it + +=== "Interactive" + ```bash + salloc --nodes=1 --constraint=cpu --qos=interactive --time=02:00:00 + # salloc drops you onto a compute node; from there: + cd $SCRATCH/my-analysis + lc materialize + ``` + +=== "Batch" + ```bash + cd $SCRATCH/my-analysis + sbatch --nodes=1 --constraint=cpu --qos=regular --time=02:00:00 \ + --wrap 'lc materialize' + ``` + + (Make sure `lc` is on `PATH` in the batch environment — with a + `uv tool install`, that's `export PATH=$HOME/.local/bin:$PATH` in + the script if your shell profile doesn't already do it.) + +Ask for more nodes and the run uses them — independent outputs and +universes spread across the allocation with nothing else to say. + +### 3. Guard rails on known centers + +On centers `lc` knows (NERSC today), running `lc materialize` on a +login node refuses with the center's own allocation spellings rather +than quietly hammering a shared node: + +``` +Error: lc materialize executes recipes on compute nodes, and this is a +NERSC login node (NERSC_HOST is set with no SLURM allocation active). + +Get an allocation and run it there: + + interactive: + salloc --nodes=1 --constraint=cpu --qos=interactive --time=02:00:00 + lc materialize + + batch (from the project root): + sbatch --nodes=1 --constraint=cpu --qos=regular --time=02:00:00 \ + --wrap 'lc materialize' + +lc materialize --check, lc status and lc run work anywhere. +``` + +The read-only verbs are exempt on purpose — a login node is exactly +where "where does this project stand?" gets asked. + +## Containers on HPC + +A containerized project (one with `[tool.lightcone.image]` in its +`pyproject.toml`) works the same way, with three site realities to +know: + +- **`podman-hpc` is detected first.** Sites install it precisely + because plain podman's image store is invisible to compute nodes; + where both exist, `lc` prefers the wrapper and runs its extra + `migrate` step automatically, so the image is readable from every + node. +- **Build on a login node, once.** `lc build` builds the image and + commits it into the repository as versioned content — compute nodes + never build and need no registry access; an unfetched image arrives + through the annex like any other data. The archive records the + architecture it was built for, and a mismatched host is refused + before anything runs — so build where the architecture matches the + compute nodes (on NERSC, a login node). +- **Multi-node runs require a shared image store.** With plain podman + or docker the image exists only on the driver's node, so `lc` + refuses a multi-node containerized run unless the runtime is + `podman-hpc`. Single-node allocations work with any runtime. + +## Data on parallel filesystems + +Keep active projects on the filesystem your center recommends for job +I/O (`$SCRATCH` on NERSC), and remember scratch purge policies — the +project is a git repository, so `git push` to a remote (and +`git annex copy --to` for the bytes) is the durable copy. + +!!! warning "Early days" + HPC support is the youngest part of lightcone-cli and has not yet + been broadly validated on production systems. If something refuses, + hangs, or surprises you on your center, please + [open an issue](https://github.com/LightconeResearch/lightcone-cli/issues) + — site reports are exactly what this layer needs right now. + +## Where to next + +- [Core Concepts](concepts.md) — the model all of this rests on. +- [Troubleshooting](troubleshooting.md) — the refusals, quoted, with + remedies. diff --git a/docs/user/concepts.md b/docs/user/concepts.md new file mode 100644 index 0000000..b290397 --- /dev/null +++ b/docs/user/concepts.md @@ -0,0 +1,151 @@ +# Core Concepts + +The mental model behind `lc`, in one page. Nothing here is required to +follow [Getting Started](getting-started.md) — come back when you want +to know *why* the tool behaves the way it does. + +## A project is three files + +A lightcone project is a directory holding an ASTRA spec and a uv +project: + +- **`astra.yaml`** describes the analysis — inputs, outputs, recipes, + methodological decisions. It is the single source of truth: everything + `lc` does is downstream of it. +- **`pyproject.toml` + `uv.lock`** describe the environment — every + package a recipe may import, resolved to exact versions. The `.venv` + is built *from* the lock and is disposable; the lock is what's real, + and it travels in git. + +There is no global configuration, no registry, no state outside the +project. Clone the repository and you have everything except two pieces +of local machinery (`.venv` and the git-annex initialization), which +`lc init` rebuilds. + +Adding a dependency is a uv operation, not an `lc` one: + +```bash +uv add numpy +``` + +That updates `pyproject.toml`, re-locks, and syncs `.venv` in one step. +Recipes import from the locked environment and nothing else — a stray +`pip install` on your machine changes nothing they can see. + +## An output has an identity, and three facts about it + +Every materialized output records, in its +`..manifest.json`: + +1. **What it is** — a hash of its recipe and the decision values that + shaped it (its *definition*). +2. **What it was made from** — a content hash of each declared input. +3. **What it ran under** — a hash of the environment (the lock, the + interpreter, the image declaration if any), plus the git commit the + run started at. + +Those three facts are deliberately not one fact, because they age +differently — and that is what the three states mean: + +| state | means | what `lc materialize` does | +|---|---|---| +| `current` | the output is exactly what the spec asks for, made from these inputs, under this environment | nothing | +| `stale` | the output **contradicts** the project: the spec now defines it differently, or an input's content changed | remakes it | +| `behind` | the output is still exactly what the spec asks for — only the **environment** moved since it was made | reports it, leaves it alone | + +The line between `stale` and `behind` is contradiction versus +circumstance. A stale output is mislabelled — keeping it would be a +lie, so it is remade. A behind output is not wrong in any way: one +`uv add` for a plotting script rewrites the lock for the whole project, +and remaking a week of computation over that buys nothing. Its manifest +records exactly which environment and commit produced it, and that +commit's own `uv.lock` reconstructs the environment if you ever need +it. + +When you *do* want behind outputs remade — before a release, say — +that is one flag: + +```bash +lc materialize --refresh +``` + +`--refresh` only ever widens a run: a `current` output stays current +under it, and there is deliberately no flag in the other direction — +nothing suppresses the rebuild of a stale output. + +One more way an output can be stale: a hand edit. Every output is +committed by the run that made it, so a file changed by hand and +committed shows up in history under a commit that is not a run record — +and the output classifies `stale` everywhere, with `lc status` naming +the foreign commit. + +## Everything is committed, and the tree stays clean + +`lc` versions results in the project's own git repository: git carries +the history and the small files, git-annex carries the data bytes — +transparently, behind the ordinary `git add` / `git commit` you already +type. + +That model has two consequences you'll feel: + +- **A run starts from a clean tree.** Every output is committed + together with the code that produced it; a run that started from + uncommitted edits could not say what that code was. So: commit, then + materialize. +- **A run ends with a clean tree.** Each output is committed as it + lands — with its manifest, in a commit whose message is a *run + record* that `datalad rerun` can replay. A failed recipe's partial + work is rolled back. Your `git log` is the build log. + +`results/` is `lc`'s to write. Don't put files there by hand — a +hand-placed file has no manifest and no run record, and the foreign +write check above exists precisely to catch it. + +## Two modes, derived from the project + +How recipes execute is never configured — it is read off the project: + +- **Direct mode** (the default): recipes run on your machine, in the + project's `.venv`, under an OS sandbox — Landlock on Linux, Seatbelt + on macOS. The project tree is read-only except each recipe's own + directory its output lands in; undeclared tools don't execute. +- **Containerized mode**: declaring a `[tool.lightcone.image]` table in + `pyproject.toml` *is* the switch. Recipes then run inside a + content-addressed image built from that declaration — and the image + itself is saved into the repository as versioned content, so a clone + obtains the exact bytes with no registry and no credentials. + `lc status` shows the mode and the image's state. + +Either way, every manifest records what enforcement actually ran +(`hermeticity`) — a host with no sandbox mechanism runs the recipe and +says so, rather than pretending. + +## Reading and gating are different verbs + +- **`lc status`** reports. It always exits 0 — a state is not a + failure — runs nothing, and doesn't mind a dirty tree, because the + moment you most need it is when things aren't clean. It's also the + verb that shows the commit each output was made at. +- **`lc materialize --check`** gates. It classifies everything without + running anything and exits 1 if a run would do work — the thing a + script or CI job branches on. + +Both have `--json`; the first two keys of the check report, `ok` and +`up_to_date`, are the ones to branch on. + +## Publication is a license away + +Declaring a `license` under `[project]` in `pyproject.toml` is +declaring the intent to publish. From then on, every `lc materialize` +maintains `ro-crate-metadata.json` at the project root — an +[RO-Crate](https://www.researchobject.org/ro-crate/) describing the +project, its outputs, and the runs that produced them. The repository +*is* the crate; depositing it is `git archive` on something you already +have. + +## Where to next + +- [Running on a Cluster](cluster.md) — the same model on SLURM. +- [Troubleshooting](troubleshooting.md) — the refusals quoted, with + their remedies. +- [Glossary](glossary.md) — the terms, one at a time. diff --git a/docs/user/getting-started.md b/docs/user/getting-started.md new file mode 100644 index 0000000..dd3052b --- /dev/null +++ b/docs/user/getting-started.md @@ -0,0 +1,394 @@ +# Getting Started + +Let's go from nothing on your disk to a working, reproducible analysis. +You can read this top to bottom without running anything, or follow along — +every command is copy-paste ready. + +**What you'll build:** a small two-output analysis that fits a line to a +noisy dataset and sweeps one methodological decision — whether points far +from an initial fit are kept or clipped. The result is two universes, +`baseline` and `robust`, each with its own fitted slope and figure, and a +project that ends published as an [RO-Crate](https://www.researchobject.org/ro-crate/). + +Make sure you've finished the [install](install.md) first. + +## 1. Create a project + +```bash +lc init line-fit-demo +cd line-fit-demo +``` + +`lc init` converges the directory to a small, opinionated layout and +stops; it doesn't ask any questions, and it's idempotent — re-running +it later only fills in whatever is missing. + +``` +line-fit-demo/ +├── astra.yaml # the spec, empty for now — this is where everything lives +├── pyproject.toml # the project's environment: its dependencies… +├── .python-version # …and the exact interpreter, locked by uv +├── uv.lock +├── .venv/ # built from the lock (local, never committed) +├── .git/ # a git repository, with git-annex initialized +├── .gitattributes # the storage policy: what the annex carries +├── .gitignore +├── .datalad/ # dataset identity — the project is a DataLad dataset +├── data/ # declared input data lives here +├── results/ # outputs materialize here — lc's to write, not yours +├── universes/ +│ └── baseline.yaml # one universe, selecting nothing yet +├── myst.yml # MyST report configuration +└── index.md # template report, to reference the spec from +``` + +Two things are worth registering now: + +- **The project is a git repository.** Every + output `lc` makes is committed together with the code that produced + it; large files ride in git-annex behind the scenes, but you only + ever type ordinary `git add` and `git commit`. +- **The environment is the lock.** `pyproject.toml` + `uv.lock` define + exactly what your recipes can import, and `.venv` is built from them. + You'll add packages with `uv add` in a moment — never `pip install`. + +The file you'll actually work in is **`astra.yaml`** — the single source +of truth for your analysis. Inputs, outputs, methodological decisions, +recipes: everything else lightcone-cli does is downstream of this file. + +## 2. Add the data + +A real project starts from a dataset; ours will generate a small one — +200 points on a line, with a few outliers thrown far off it: + +```bash +python3 - <<'EOF' +import random +random.seed(0) +rows = ["x,y"] +for _ in range(200): + x = random.uniform(0, 10) + y = 2.5 * x + 1.0 + random.gauss(0, 1.5) + if random.random() < 0.04: + y += random.gauss(0, 15) + rows.append(f"{x:.6f},{y:.6f}") +open("data/points.csv", "w").write("\n".join(rows) + "\n") +EOF +``` + +`data/` is where declared inputs live. When you commit, the +`.gitattributes` policy routes the file's bytes into git-annex +automatically — the file stays an ordinary readable, writable file in +your tree, and the repository stays light. + +## 3. Write the spec + +`astra.yaml` was scaffolded as an empty analysis. Fill it in with ours: + +```yaml +version: "0.0.13" # ASTRA schema version — keep what the scaffold wrote +name: "line_fit" +description: | + Fit a straight line to a small synthetic dataset and sweep one + methodological decision: whether points far from an initial fit are + kept or clipped before the final fit. + +inputs: + - id: points + type: data + source: data/points.csv + description: "200 synthetic (x, y) points, a few of them far off the line" + +outputs: + - id: fit + type: metric + format: json + description: "Slope and intercept of the least-squares line" + inputs: [points] + decisions: [outliers] + recipe: + command: python src/fit.py --points {inputs.points} --outliers {decisions.outliers} --output {output} + + - id: fit_plot + type: figure + format: png + description: "The points and the fitted line" + inputs: [points, fit] + recipe: + command: python src/plot.py --points {inputs.points} --fit {inputs.fit} --output {output} + +decisions: + outliers: + label: "Outlier handling" + rationale: "A few points sit far off the line; keeping or clipping them shifts the slope." + default: keep + options: + keep: + label: "Keep every point" + clip: + label: "Drop points beyond 3 sigma of an initial fit" +``` + +A few things to notice: + +- Each output declares its full dependency contract: `fit` depends on + the `points` input and the `outliers` decision; `fit_plot` depends on + `points` and on the sibling output `fit`. That contract is how `lc` + orders the build — and how it knows what to rebuild when something + changes. +- Recipes reference those dependencies through placeholders — + `{inputs.points}`, `{decisions.outliers}`, `{output}` — which are + expanded at execution time. `{output}` is the output's own file, + `results//.`; the engine creates the + directory before the recipe runs, and the recipe writes that one path. +- Each output declares a `format` — the extension its artifact is + written with. It is what names the file, so a consumer knows what an + output *is* from the spec alone, and one output is always one file. +- The decision's options aren't hardcoded anywhere in code; the scripts + will take them as command-line arguments. + +`universes/baseline.yaml` was scaffolded empty, so give it a value for +our decision: + +```yaml +id: baseline +description: "Every point kept — the decision defaults." +decisions: + outliers: keep +``` + +Each universe is one complete selection of decision values; its results +materialize to `results//.`. + +Check the spec is well-formed: + +```bash +astra validate astra.yaml +``` + +(`astra` is the spec-side CLI; it ships with `astra-tools`, a dependency +of lightcone-cli.) + +## 4. Write the scripts + +Two short scripts, in a `src/` directory (`mkdir src` — the scaffold +doesn't create it; where code lives is your choice, the recipes above +just happen to point there). First `src/fit.py`: + +```python +import argparse +import json +from pathlib import Path + +import numpy as np + +parser = argparse.ArgumentParser() +parser.add_argument("--points", required=True) +parser.add_argument("--outliers", choices=["keep", "clip"], required=True) +parser.add_argument("--output", required=True) +args = parser.parse_args() + +x, y = np.loadtxt(args.points, delimiter=",", skiprows=1, unpack=True) +if args.outliers == "clip": + slope, intercept = np.polyfit(x, y, 1) + residuals = y - (slope * x + intercept) + mask = np.abs(residuals) < 3 * residuals.std() + x, y = x[mask], y[mask] +slope, intercept = np.polyfit(x, y, 1) + +Path(args.output).write_text( + json.dumps({"slope": slope, "intercept": intercept, "n_used": len(x)}, indent=2) +) +``` + +Then `src/plot.py` — reads the upstream output's file, makes the +figure: + +```python +import argparse +import json +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +parser = argparse.ArgumentParser() +parser.add_argument("--points", required=True) +parser.add_argument("--fit", required=True) +parser.add_argument("--output", required=True) +args = parser.parse_args() + +x, y = np.loadtxt(args.points, delimiter=",", skiprows=1, unpack=True) +fit = json.loads(Path(args.fit).read_text()) + +fig, ax = plt.subplots() +ax.scatter(x, y, s=12) +xs = np.linspace(x.min(), x.max(), 2) +ax.plot(xs, fit["slope"] * xs + fit["intercept"], color="C1") +ax.set_xlabel("x") +ax.set_ylabel("y") +ax.set_title(f"slope = {fit['slope']:.3f}") +fig.savefig(args.output, dpi=150) +``` + +Both scripts import from the project's locked environment, so declare +what they need: + +```bash +uv add numpy matplotlib +``` + +That one command updates `pyproject.toml`, re-locks `uv.lock`, and syncs +`.venv`. It's the only way packages reach a recipe — recipes run +sandboxed in the locked environment, so a stray `pip install` on your +machine changes nothing they can see. That's a feature: the lock *is* +the record of what your results were computed with. + +## 5. Materialize + +Commit, then build: + +```bash +git add -A && git commit -m "Line-fit analysis" +lc materialize +``` + +The commit isn't ceremony — every output is committed together with the +code that produced it, so a build refuses to start from a tree with +uncommitted edits (it wouldn't be able to say what code ran). Then: + +``` + ✓ made baseline/fit + ✓ made baseline/fit_plot + ! no [project].license in pyproject.toml, so no RO-Crate publication + view is maintained — declare one to enable it + +✓ Made 2 output(s) in /home/you/line-fit-demo +``` + +(We'll come back to that license line in step 7.) Each output landed in +`results/baseline/.` next to a +`..manifest.json` — +a manifest recording the recipe, the decisions, the input hashes, the +environment, and the commit — and was committed with a run record that +`datalad rerun` can replay. Look at `git log`: the build wrote history, +not just files. + +Check where things stand any time: + +```bash +lc status +``` + +``` + mode: direct + sandbox: landlock (fs: declared, network: allowed) + crate: not maintained — declare [project].license to enable it + + · current baseline/fit a3f1f11 + · current baseline/fit_plot a3f1f11 + +2 current +``` + +The commit column is the answer to "which code made this?" — for every +output, current or not. And `lc materialize` is idempotent: run it again +and it reports the project is up to date without executing anything. + +## 6. Sweep the decision + +Add the second universe — `universes/robust.yaml`: + +```yaml +id: robust +description: "Points beyond 3 sigma of an initial fit are dropped." +decisions: + outliers: clip +``` + +Commit and materialize again: + +```bash +git add -A && git commit -m "Add the robust universe" +lc materialize +``` + +``` + ✓ made robust/fit + ✓ made robust/fit_plot + · up to date baseline/fit + · up to date baseline/fit_plot + +✓ Made 2 output(s) in /home/you/line-fit-demo +``` + +Only the new universe's outputs ran — `baseline` was already exactly +what the spec asks for, so it wasn't touched. Your comparison is on +disk: with this guide's synthetic dataset, clipping drops 4 points and +moves the slope from 2.414 to 2.450 — visibly closer to the true 2.5 +the data was generated with. + +If a recipe fails, `lc materialize` reports which output failed and why, +and leaves the tree as clean as it found it; fix the script or the spec, +commit, and rerun — only the affected outputs re-execute. + +## 7. Publish + +RO-Crate requires a license, so declaring one is how you tell `lc` the +project is meant for the outside world. Add one line under `[project]` +in `pyproject.toml`: + +```toml +license = "CC-BY-4.0" +``` + +then commit and materialize once more: + +```bash +git add -A && git commit -m "Declare a license" +lc materialize +``` + +Nothing is rebuilt — but `ro-crate-metadata.json` appears at the project +root and is committed automatically. From here on, every materialize +keeps it in line with the repository: the project *is* the crate, and +depositing it is just `git archive` (or `datalad export-archive`) on a +repository you already have. + +## What just happened + +- `astra.yaml` was the only place your analysis was *described* — + inputs, outputs, the decision, and the recipes all live there. +- The scripts take decision values as plain command-line arguments, so + nothing methodological is hardcoded. +- `lc materialize` ran each recipe in the project's locked environment, + sandboxed — free to write the directory its output lands in, and + nothing else — + and committed every output with a manifest and a re-runnable run + record. +- `lc status` and `lc materialize --check` read those manifests — they + don't re-execute anything; they just classify. An output is remade + when the spec defines it differently than it was made, or when its + declared inputs changed; an output whose *environment* has since + moved is reported as `behind` and deliberately left alone — the + manifest records exactly which environment and commit produced it. + +Clone this repository on a fresh machine, run `lc init` (it rebuilds +the two pieces of local state git doesn't carry — the `.venv` and the +annex), then `lc materialize`: it reports up to date without fetching a +single data byte, because the provenance travels in git. The bytes +themselves follow with `git annex get` whenever you actually need them. + +## Where to next + +- [Core Concepts](concepts.md) — the model behind what you just did: + the three states, the commit discipline, the two execution modes. +- [Running on a Cluster](cluster.md) — take the same project to SLURM. +- [Troubleshooting](troubleshooting.md) — when something goes sideways. +- [Glossary](glossary.md) — terms like universe, decision, and manifest + in plain language. +- The [ASTRA docs](https://astra-spec.org/latest/) — the full spec: + sub-analyses, prior insights, findings, and evidence. diff --git a/docs/user/glossary.md b/docs/user/glossary.md new file mode 100644 index 0000000..537ba98 --- /dev/null +++ b/docs/user/glossary.md @@ -0,0 +1,186 @@ +# Glossary + +The terms you'll see all over the docs and the `lc` command output, in +plain language. + +## ASTRA + +**A**gentic **S**chema for **T**ransparent **R**esearch **A**nalysis. +The schema lightcone-cli is built around. ASTRA's job is to capture an +analysis's inputs, outputs, and methodological decisions in a single +file (`astra.yaml`); lightcone-cli's job is to execute that spec +reproducibly. ASTRA ships separately as the `astra-tools` package, and +its `astra` CLI handles the spec itself (validation, universe +management, evidence verification). + +## astra.yaml + +Your project's spec file. The single source of truth — every input, +output, recipe, and decision is declared here. Sub-analyses can be +nested via `analyses:` references. + +## Recipe + +A short shell command that produces an output. Lives inside an output's +`recipe:` block in `astra.yaml`. Outputs declare what they depend on, +and the recipe references those dependencies through placeholders: + +```yaml +outputs: + - id: fit + inputs: [points] + decisions: [outliers] + recipe: + command: python src/fit.py --points {inputs.points} --outliers {decisions.outliers} --output {output} +``` + +## Decision + +A methodological choice with multiple defensible options (e.g. +"standardize features?", "what outlier threshold?"). Decisions live +in the `decisions:` section of `astra.yaml` along with their `default`, +their `options`, and their `rationale`. + +## Universe + +One specific selection of decision values. Universes live as YAML +files in `universes/` (e.g. `universes/baseline.yaml`, +`universes/robust.yaml`). Each universe materializes its results +to its own directory: `results//.`. + +## Sub-analysis + +A nested ASTRA analysis with its own inputs, outputs, and decisions, +referenced from a parent's `analyses:` section. `lc` materializes a flat +analysis: an output id it cannot name a file from is refused, so a +nested spec is not buildable today. + +## Materialize + +Making the outputs the spec declares: `lc materialize` runs each recipe +in dependency order and commits every result as it lands. Idempotent — +a second run remakes only what is `stale`, and a run with nothing to do +says so and touches nothing. + +## Manifest + +The per-output sidecar JSON file, `..manifest.json` beside +the output itself, recording what produced the +output: the recipe, the decisions, `definition_version`, +`env_version`, `data_version`, `input_versions`, the git commit the +run started at, the engine version, what enforcement actually ran +(`hermeticity`), and — for containerized runs — the image. Written by +the run, read by `lc status` and `lc materialize --check`; kept in +plain git so a clone can classify a whole project without fetching any +data. + +## definition_version + +A hash of an output's recipe and decision values — the fingerprint of +"what is this output?". When it drifts, the output is `stale` and the +next run remakes it. + +## env_version + +A hash of the environment — the lock file's bytes, the pinned +interpreter, the install settings, and the image declaration if any. +Deliberately *not* part of an output's definition: when it drifts, the +output is `behind`, reported and left alone. + +## data_version + +A content hash of an output's bytes (or of a declared input). For a +file it is a plain sha256 — the number `sha256sum` prints, and the one +the RO-Crate publishes; a directory-valued declared input is hashed +tree-wise and framed, so the two can never collide. This is what flows +downstream: a dependent is remade when an input's `data_version` +changed, and a rebuild that comes out byte-identical stops the cascade +right there. + +## input_versions + +Inside a manifest, a map from each declared input to the +`data_version` it had when the output was made. Comparing it against +the present is how a change to an input cascades. + +## current / behind / stale + +The three states an output can be in: + +- `current` — exactly what the spec asks for, made from these inputs, + under this environment. Nothing to do. +- `behind` — still what the spec asks for; only the environment moved + since it was made. Reported, left alone; `--refresh` remakes. +- `stale` — contradicts the project: the spec defines it differently, + an input changed, or the output was edited by hand. Remade on the + next run. + +## Direct mode / containerized mode + +How recipes execute, derived from the project rather than configured. +Direct mode (the default): the project's `.venv`, under the OS sandbox. +Containerized mode: declaring `[tool.lightcone.image]` in +`pyproject.toml` switches the project over — recipes run inside a +content-addressed image built from that declaration. + +## Image + +Containerized mode's execution world: a base (digest-pinned), optional +apt packages, and the pinned interpreter. Built by `lc build` and saved +**into the repository** as versioned content, so clones obtain the +exact bytes through git-annex with no registry involved. Execution pins +the image's content id, never a tag. + +## Runtime + +The OCI tool that executes containers. Detected, never configured: +`podman-hpc`, then `podman`, then `docker` (skipped if its daemon is +down). + +## Sandbox + +The isolation every recipe and every `lc run` command executes under — +Landlock on Linux, Seatbelt on macOS, the container boundary in +containerized mode. The project tree is read-only apart from the +directory the output being made lands in; undeclared tools don't +execute. Each +manifest's `hermeticity` field records what was actually enforced, and +a host with no mechanism says so rather than pretending. + +## git-annex + +How the repository carries data: git holds history and small files, +git-annex holds the bytes of `data/` and `results/` behind ordinary +git commands. You never run git-annex yourself except to fetch bytes +on a clone (`git annex get`), and `lc materialize` even does that for +declared inputs it needs. + +## Run record + +The commit message a materialized output is saved under — a +machine-readable record of the exact command that made it, in a format +`datalad rerun` can replay: it reconstructs the engine, the project +environment, and the sandbox, and remakes the output from its spec. +Your `git log` is the build log. + +## RO-Crate + +The publication view. Declare a `license` under `[project]` in +`pyproject.toml` and every materialize maintains +`ro-crate-metadata.json` — a machine-readable description of the +project, its outputs, and the runs that produced them, following the +Provenance Run Crate profile. The repository is the crate; deposit is +`git archive`. + +## Prior insight + +A piece of evidence from the literature that informs a decision. +Lives in the `prior_insights:` section of `astra.yaml`, with a `claim` +and verifiable `evidence` (DOI plus exact quote). + +## Finding + +A conclusion drawn *from* the analysis (as opposed to a prior insight, +which comes *into* it). Findings live in the `findings:` section and +cite specific outputs as evidence — the bridge between materialized +results and the eventual paper. diff --git a/docs/user/index.md b/docs/user/index.md new file mode 100644 index 0000000..c2dc24b --- /dev/null +++ b/docs/user/index.md @@ -0,0 +1,73 @@ +# Welcome to the user guide + +`lightcone-cli` is a small toolchain that turns a research question into +a reproducible analysis. You describe what you're trying to learn as a +precise specification — an `astra.yaml` file following the +[**ASTRA**][astra] schema — and the `lc` command line keeps the +resulting code, environments, decisions, and outputs in sync. + +ASTRA specs are plain YAML, designed to be easy for both humans and AI +assistants to write. However the spec gets written, **you stay in charge +of the scientific choices** — every methodological decision is declared +in the open, and `lc` records exactly what produced every result: the +recipe, the decisions, the input data, the environment, and the commit. + +## What this guide covers + +- [Install](install.md) — get the `lc` command line running on your + machine or on a cluster. +- [Getting Started](getting-started.md) — create your first project, + build it end-to-end, and understand what each piece does. +- [Core Concepts](concepts.md) — the model behind the tool: what the + states mean, why everything is committed, and how the two execution + modes differ. +- [Running on a Cluster](cluster.md) — taking your analysis to a SLURM + HPC system. +- [Troubleshooting](troubleshooting.md) — common issues and how to + unstick them. +- [Glossary](glossary.md) — the terms that show up everywhere + (universe, decision, manifest, …) explained in plain language. + +## What you'll do, in a handful of lines + +!!! tip "Quick start" + + === "uv" + ```bash + uv tool install lightcone-cli + lc init my-analysis && cd my-analysis + # describe your analysis in astra.yaml, write your scripts, + # declare what they import (uv add numpy ...), then: + git add -A && git commit -m "First analysis" + lc materialize + ``` + + === "pip" + ```bash + pip install lightcone-cli + lc init my-analysis && cd my-analysis + # describe your analysis in astra.yaml, write your scripts, + # declare what they import (uv add numpy ...), then: + git add -A && git commit -m "First analysis" + lc materialize + ``` + +That's the shortest possible path. The rest of the guide is the +unhurried version — and the commit is not ceremony: every output is +committed together with the code that produced it, which is why a build +starts from a clean tree. + +## What lightcone-cli is *not* + +- **A statistics package.** It runs your code; it doesn't compute + things itself. +- **A workflow language.** Recipes in `astra.yaml` are short shell + commands, not a DSL. There's no learning curve beyond what's in + [Getting Started](getting-started.md). +- **An IDE.** `lc` is a command-line tool; write `astra.yaml` and your + analysis code with whatever editor or tooling you prefer. + +If you'd rather skim the design and architecture, the +[maintainer docs](../maintainer.md) are the other half of this site. + +[astra]: https://astra-spec.org/latest/ diff --git a/docs/user/install.md b/docs/user/install.md new file mode 100644 index 0000000..48f13ed --- /dev/null +++ b/docs/user/install.md @@ -0,0 +1,124 @@ +# Install + +To work on a lightcone project you need two things on your machine: +[uv](https://docs.astral.sh/uv/) and git. Everything else — Python +itself included — is installed by uv or ships with `lc`. + +!!! note "Supported platforms" + Linux (glibc 2.34+, x86_64 or aarch64) and macOS (14+ on Apple + silicon, 15+ on Intel). On Windows, use WSL. + +## 1. uv and git + +`lc` uses uv as its only environment substrate — projects are +`pyproject.toml` + `uv.lock`, and uv manages the Python interpreters +too, so there is no separate Python install step. + +=== "macOS / Linux" + ```bash + curl -LsSf https://astral.sh/uv/install.sh | sh + ``` + + git is preinstalled on macOS; on Linux use your package manager + (`apt install git`, `dnf install git`, …). + +=== "NERSC Perlmutter" + NERSC doesn't ship `uv`, but it installs into your home directory + with a single curl: + + ```bash + curl -LsSf https://astral.sh/uv/install.sh | sh + ``` + + `uv` lands under `~/.local/bin` — make sure it's on your `PATH`. + git is already on the system. + +## 2. lightcone-cli + +The published name on PyPI is `lightcone-cli`; the command it provides +is `lc`. + +=== "uv" + ```bash + uv tool install lightcone-cli + ``` + +=== "pip" + ```bash + python -m pip install lightcone-cli + ``` + +Get a confirmation of the proper installation by running + + lc --version # → lc, version ... + +> **Note** Some people may have already set a personal shell alias +> `lc='ls --color'`. If that's you, installing lightcone-cli will shadow +> the alias — make sure to rebind it (e.g. `alias l='ls --color'`). + +## 3. Tell git who you are + +Every output `lc` makes is committed, so git needs an identity before +the first build — `lc materialize` checks up front rather than failing +after your recipes have run: + +```bash +git config --global user.name "Ada Lovelace" +git config --global user.email "ada@example.org" +``` + +If you already commit from this machine, you're done. + +## 4. (Optional) Podman or Docker + +Only *containerized* projects need a container runtime — a project opts +in by declaring `[tool.lightcone.image]` in its `pyproject.toml`, and +until it does, recipes run directly on your machine in the project's +own locked environment. + +- Local machine: install [Podman](https://podman.io/) (rootless, no + daemon) or [Docker](https://docs.docker.com/get-docker/). +- HPC login node: see [Running on a Cluster](cluster.md). + +There is nothing to configure: `lc` detects whichever runtime is +available (`podman-hpc`, then `podman`, then `docker` — skipping docker +if its daemon isn't running). + +## Sanity check + + lc --help + lc init --help + +Both should print help text. If `lc` is shadowed by an `ls` alias, +unset it (`unalias lc`) or use the full path (`$(which lc) --version`). + +## Updating + +=== "uv tool" + ```bash + uv tool upgrade lightcone-cli + ``` + +=== "pip" + ```bash + pip install -U lightcone-cli + ``` + +An upgrade never invalidates your results: the engine's version is +recorded in every output's manifest, but it is not part of any output's +identity, so nothing gets rebuilt just because `lc` moved. + +## Uninstalling + +=== "uv tool" + ```bash + uv tool uninstall lightcone-cli + ``` + +=== "pip" + ```bash + pip uninstall lightcone-cli + ``` + +Your projects are untouched — everything `lc` knows about an analysis +lives in the project's own repository, not in any global state. diff --git a/docs/user/troubleshooting.md b/docs/user/troubleshooting.md new file mode 100644 index 0000000..e7e59f1 --- /dev/null +++ b/docs/user/troubleshooting.md @@ -0,0 +1,199 @@ +# Troubleshooting + +Common situations and how to unstick them, roughly ordered by how often +they come up. `lc`'s refusals try to carry their own remedy — this page +adds the context around them. + +## "uncommitted changes in …" + +``` +Error: uncommitted changes in /home/you/my-analysis — every +materialization is committed with the code that produced it, so a run +cannot start from a tree that does not say what that code is. + + commit these: git add -A . && git commit -m "…" + M src/fit.py +``` + +Not an error in your project — just the order of operations: commit, +then materialize. The refusal sorts the paths it found: work you own +gets the `commit these` line, while leftover files under `results/` +(from an interrupted run of an older `lc`, or a hand write) are listed +as wreckage to discard instead — `results/` is `lc`'s to write, and +committing hand-placed files there defeats the provenance the tool +exists for. + +## "… is not a Lightcone project" + +You're outside a project. The current directory *is* the project — `lc` +never walks up to find one, by design — so: + +```bash +cd path/to/your/project +``` + +or, starting fresh, `lc init my-analysis && cd my-analysis`. If you're +in a fresh clone, run `lc init` once — it rebuilds the `.venv` and the +annex, the two pieces of local state git doesn't carry. + +## "lc: command not found" or `lc` prints a directory listing + +Two possibilities: + +1. The tool isn't on `PATH` — with `uv tool install`, that's + `~/.local/bin`; `uv tool update-shell` fixes the profile. +2. Your shell has a personal alias `lc='ls --color'` shadowing the + real command. Run `type lc` to see; `unalias lc` to remove. + +## A recipe fails with "Permission denied" or "No module named …" + +Every sandboxed failure ends with this trailer: + +``` +this ran under the lc sandbox (landlock) — a permissions or missing-file +error can mean the command reached for something outside the declared +environment +``` + +Recipes run in the project's locked environment, with the tree +read-only apart from the directory their output lands in. The common cases: + +- **`ModuleNotFoundError`** — the package isn't in the project's lock. + `uv add `, commit, re-run. (Installing it on the host with + `pip` changes nothing a recipe sees — that's the point.) +- **Reading a file outside the project** — declare it as an ASTRA + input; declared inputs are readable and their content becomes part + of the output's provenance. +- **Writing outside that directory** — a recipe's product + belongs in `{output}`; for true scratch files, use + `tempfile.mkdtemp()`, which lands in the writable temp area. + +To probe interactively, `lc run ` runs any command under +exactly the isolation a recipe gets — if it works there, it works as a +recipe. + +## Everything shows `behind` after a `uv add` + +Not a problem, and nothing was invalidated. `behind` means: the output +is still exactly what the spec asks for, but the environment has moved +since it was made. Environment changes deliberately don't trigger +rebuilds — the manifest records which environment and commit produced +each output, so nothing is lost by leaving it. When you do want them +remade under the current environment: + +```bash +lc materialize --refresh +``` + +See [Core Concepts](concepts.md) for the `stale` / `behind` +distinction. + +## Everything shows `stale` after a spec edit + +`stale` means the spec now defines the output differently than it was +made — you edited its recipe, a decision, or a declared input's +content changed. That's the invalidation model working; the next +`lc materialize` remakes exactly those outputs. + +One edit that deliberately does *not* invalidate: changing your +analysis code (`src/…`). The recipe *string* is the identity, so if +you want code changes to cascade, declare the source file as an ASTRA +input of the outputs it shapes — that choice is yours to make per +output. + +## "the content is not in this clone" + +``` +data/points.csv: the content is not in this clone — git-annex holds a +reference to it, not the data. Fetch it with `git annex get data/points.csv`. +``` + +The clone has the *pointer* to an annexed file but not its bytes. +`lc materialize` fetches the declared inputs it needs by itself; the +read-only verbs (`lc status`, `--check`) never transfer data, so they +report the fact instead. Fetch by hand only when you want the bytes +for your own inspection. + +## "fatal: … clean filter 'annex' failed" + +``` +git-annex filter-process: line 1: git-annex: command not found +error: could not read greeting from subprocess 'git-annex filter-process' +error: initialization for subprocess 'git-annex filter-process' failed +fatal: data/catalog.fits: clean filter 'annex' failed +``` + +Your shell's `PATH` has no `git-annex`, so git could not run the filter +that turns a large file into an annex pointer. **Nothing was staged**, +which is the point: without `filter.annex.required=true` — which +`lc init` sets — git would have exited 0 and committed the raw bytes +into history instead. + +Once a project holds committed annexed content, this is not limited to +`git add`. Any command that has to run the filter over that content +stops the same way, `git status`, `git diff` and `git checkout` +included — so the whole project reads as broken until git-annex is back +on your `PATH`. That is the intended shape of the failure: a repository +you cannot use is recoverable in one command, and one that quietly +absorbed a multi-gigabyte file is not. + +`git-annex` ships with `lc`, so a tool install puts both on your `PATH`: + +```bash +uv tool install lightcone-cli +git-annex version +``` + +If `lc` runs but `git-annex` does not, uv's tool directory is not on +your `PATH` — run `uv tool update-shell` and open a new shell. Running +`lc` through `uvx` puts nothing on your `PATH` at all, so a plain +`git add` cannot work that way. + +This failure is deliberately loud. `lc init` sets +`filter.annex.required=true` in every project precisely because +without it git handles the same situation by printing the error, +**exiting 0, and staging your data's raw bytes into git history** — +committing a multi-gigabyte dataset into git proper, silently, where +every clone carries it forever. A refused `git add` costs you one +`lc init`; the silent version costs you the repository. + +## "… and this is a NERSC login node" + +`lc materialize` executes recipes, and on centers `lc` recognizes it +refuses to do that on a shared login node. The refusal prints the +center's own `salloc` and `sbatch` spellings — copy one, run the same +command inside the allocation. `lc status`, `lc materialize --check`, +`lc build` and `lc run` work anywhere. See +[Running on a Cluster](cluster.md). + +## git doesn't know who you are + +Every output is committed, so a machine that has never committed needs +an identity before the first run — `lc materialize` checks up front, +before any recipe spends time: + +```bash +git config --global user.name "Ada Lovelace" +git config --global user.email "ada@example.org" +``` + +## Containerized projects + +- **"image absent"** — the declared image hasn't been built and + committed yet: `lc build` (announced by materialize too, which + builds it as a preflight when missing). +- **No runtime found** — install [Podman](https://podman.io/) or + [Docker](https://docs.docker.com/get-docker/); detection is + automatic and there is nothing to configure. +- **Architecture mismatch** — the committed archive records the + architecture it was built for, and a host that can't execute it is + refused before the recipe would have died mid-run. Build on a + matching host (on NERSC, a login node), commit, push, and pull on + the other side. + +## Filing a bug + +Open an issue at +[github.com/LightconeResearch/lightcone-cli/issues](https://github.com/LightconeResearch/lightcone-cli/issues). +Include the output of `lc --version`, the command you ran, and the +full message — the refusals are designed to be pasted. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ce7b1b5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "lightcone-docs" +version = "0.0.0" +description = "Documentation for the Lightcone Research stack" +requires-python = ">=3.11" +dependencies = [ + "zensical>=0.0.34", + # squidfunk's mike fork — required by zensical's versioning provider. + # Not on PyPI; install from git. + "mike @ git+https://github.com/squidfunk/mike.git", +] + +# Not a Python package: uv only manages the environment that builds the site. +[tool.uv] +package = false diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..833231c --- /dev/null +++ b/uv.lock @@ -0,0 +1,328 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "deepmerge" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/6e/5cb3548b4d3112fea529375e55e6f3cdc52b8054e3a66f203b1f888ba885/deepmerge-3.0.1.tar.gz", hash = "sha256:35b39a4cb92cf328d6eca61cbbf65f68a37c2ceb3085f0f853cbb2e52a59fc23", size = 22328, upload-time = "2026-09-01T14:09:44.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/91/600003aaad107e27553fbc9cbfc57e96fa37e0223d2ef9d7d3a0e8d8d070/deepmerge-3.0.1-py3-none-any.whl", hash = "sha256:35c96f6a68fcf90719a5b31d9f8042ecef6c00fb56836660d33455d0f5cfda65", size = 14909, upload-time = "2026-09-01T14:09:43.364Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "lightcone-docs" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "mike" }, + { name = "zensical" }, +] + +[package.metadata] +requires-dist = [ + { name = "mike", git = "https://github.com/squidfunk/mike.git" }, + { name = "zensical", specifier = ">=0.0.34" }, +] + +[[package]] +name = "markdown" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/4f/700155c8c20d9e655dd0732b5fc3c7614f291b9148da271d7388e50bf774/markdown-3.11.tar.gz", hash = "sha256:180224db6aed87ba9ce1f2781ebcd5826253de8ff637112090e24b84502bbf9f", size = 485623, upload-time = "2026-09-25T13:46:23.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/1e/32971905a7ab47f8b66866ed949fa48b104ba1c4a6fa57794c4f2c4b2cb8/markdown-3.11-py3-none-any.whl", hash = "sha256:cd6c89e7eb308c8b332ed673215a52d208a43f8bacc030b1419376129408719e", size = 111296, upload-time = "2026-09-25T13:46:22.163Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mike" +version = "2.2.0+zensical.0.1.0" +source = { git = "https://github.com/squidfunk/mike.git#2d4ad799442f4592db8ad53b179bfb33db8c69ac" } +dependencies = [ + { name = "jinja2" }, + { name = "pyparsing" }, + { name = "verspec" }, + { name = "zensical" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/94/858e0163cb4d83d6d8234018a01792c749a56daee41794cac7d6c46661e8/pymdown_extensions-12.1.tar.gz", hash = "sha256:fdb8f47f5d7fd069d10ef2a7d8908e613d7865ff0419d544a0ffd3984e884f11", size = 868245, upload-time = "2026-09-23T00:08:42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d1/98313da89960a604402266a115311b510254900ff1295ea426403f9423cc/pymdown_extensions-12.1-py3-none-any.whl", hash = "sha256:4a254b771acfcddc6c110a7f4590d818f1f849e2d5aacf69b4b07c8dd2a9700d", size = 277069, upload-time = "2026-09-23T00:08:40.069Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "verspec" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/44/8126f9f0c44319b2efc65feaad589cadef4d77ece200ae3c9133d58464d0/verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e", size = 27123, upload-time = "2020-11-30T02:24:09.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31", size = 19640, upload-time = "2020-11-30T02:24:08.387Z" }, +] + +[[package]] +name = "zensical" +version = "0.0.65" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "deepmerge" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "pathspec" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyyaml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/c6/df2df4ae707e45318e7699b7a9341e9130a75c73b51536a9d319ab74de7f/zensical-0.0.65.tar.gz", hash = "sha256:35620265949eb426ebb4339bd091bed9a05648f28d52e61a79922ee1b109d9ab", size = 4228815, upload-time = "2026-09-24T18:07:43.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/18/2cff20ec259ab7577ef17e51c67b63b0989e39b1a469ff4b8ea310f71805/zensical-0.0.65-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:62c3a61be738d147d27c499bd84ee2b9afe5c0b97ca072aad6de28e05122e9ee", size = 15334565, upload-time = "2026-09-24T18:07:17.099Z" }, + { url = "https://files.pythonhosted.org/packages/23/a7/3583b987894dbded915869c7df35016a0e256a7a48627ede009270a8549d/zensical-0.0.65-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:36ccd6f3e2c7e69d35015d4f4c4503146777a1c53d6bd9fbe7adbdc4cbd5a001", size = 15017811, upload-time = "2026-09-24T18:07:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/77/7d/40912425a7a77ed929119768526209eb06792d3e0bd200bfd73c89b0ad6d/zensical-0.0.65-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab497d82ed535a04008f8d12e10f028ce8769f1bac34b19281fa5b99712e34e9", size = 15307106, upload-time = "2026-09-24T18:07:24.232Z" }, + { url = "https://files.pythonhosted.org/packages/05/e3/e65a583f0c7e849ce3298b7463fc4a607a3c242433e62b9bd14364272ddc/zensical-0.0.65-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:187f94c4cdfabad0afcec092af6d5944104efe48c6595ae057d54e3213892068", size = 15373933, upload-time = "2026-09-24T18:07:26.934Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0e/55f64f4ac0d80fdb31a278a3b9434cf84742bfeff81ae9d121f1a35b3351/zensical-0.0.65-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64ff25ec94c16853ab0d9bd3cc4a0a569d1381a116a316bd10daab41ec26b3ff", size = 15646714, upload-time = "2026-09-24T18:07:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fb/7e33ed0bafd8d2803b1bb66a540923a6020ac5dc0f4deee835215a5f3838/zensical-0.0.65-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:24f3d65ebdadc58519b0c72065777ed4767e9b4935a21f471798cdfe96fa948f", size = 15483203, upload-time = "2026-09-24T18:07:32.431Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0f/7b1b9eb2f06ef5aba4efb5060a7289e2082ead11ed372ef10acdcfa5dad4/zensical-0.0.65-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:db9a1b36d08540072e3eb59841b0d6485de51d8c866fb6af8c4e5b771c6b681b", size = 15880927, upload-time = "2026-09-24T18:07:35.258Z" }, + { url = "https://files.pythonhosted.org/packages/57/a3/2a2cd8c4ca853b05c1a5ed1dacf6dc2b20a7de331d55acb1158735795ee5/zensical-0.0.65-cp310-abi3-win_amd64.whl", hash = "sha256:976fefefd5219811db680cd0afc40ec9b14be49da026e31c65c8a4a45e0021a9", size = 15844306, upload-time = "2026-09-24T18:07:38.118Z" }, + { url = "https://files.pythonhosted.org/packages/d7/28/ac8372a5c37eaa0deaef544d5119424fbc7947f8fee18359997adb1d8aac/zensical-0.0.65-cp310-abi3-win_arm64.whl", hash = "sha256:d4b11445ff93a4117e74125d9919516c7d37f63caf9472dd985b39a8871ba649", size = 15434392, upload-time = "2026-09-24T18:07:40.853Z" }, +] diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 0000000..99e4903 --- /dev/null +++ b/zensical.toml @@ -0,0 +1,87 @@ +[project] +site_name = "lightcone-cli" +site_description = "Execution layer for ASTRA research pipelines" +site_author = "Lightcone Research Team" +repo_url = "https://github.com/LightconeResearch/docs" +repo_name = "LightconeResearch/docs" +copyright = "© 2026 Lightcone Research" +docs_dir = "docs" +extra_css = ["stylesheets/extra.css"] + +nav = [ + {"Home" = "index.md"}, + {"User Guide" = [ + {"Welcome" = "user/index.md"}, + {"Install" = "user/install.md"}, + {"Getting Started" = "user/getting-started.md"}, + {"Core Concepts" = "user/concepts.md"}, + {"Running on a Cluster" = "user/cluster.md"}, + {"Troubleshooting" = "user/troubleshooting.md"}, + {"Glossary" = "user/glossary.md"}, + ]}, + {"Developer Corner" = [ + {"Welcome" = "maintainer.md"}, + {"Architecture" = "architecture.md"}, + {"CLI Reference" = [ + {"Overview" = "cli/index.md"}, + {"lc init" = "cli/init.md"}, + {"lc materialize" = "cli/materialize.md"}, + {"lc status" = "cli/status.md"}, + {"lc run" = "cli/run.md"}, + {"lc build" = "cli/build.md"}, + ]}, + {"Engine Internals" = [ + {"Overview" = "api/index.md"}, + {"project" = "api/project.md"}, + {"dataset" = "api/dataset.md"}, + {"identity" = "api/identity.md"}, + {"plan" = "api/plan.md"}, + {"assets" = "api/assets.md"}, + {"worker" = "api/worker.md"}, + {"materialize" = "api/materialize.md"}, + {"venue" = "api/venue.md"}, + {"sandbox" = "api/sandbox.md"}, + {"image & container" = "api/container.md"}, + {"crate" = "api/crate.md"}, + ]}, + {"Contributing" = [ + {"Development Setup" = "contributing/setup.md"}, + {"Testing" = "contributing/testing.md"}, + {"Extending" = "contributing/extending.md"}, + ]}, + ]}, + {"ASTRA docs" = "https://astra-spec.org/latest/"}, +] + +# Versioning is handled by mike (squidfunk's fork; see the docs +# dependency group). Each version of the site is deployed as a +# subdirectory of the gh-pages branch (e.g. /0.4.1/, /latest/). +# The version picker is rendered natively in the header. +[project.extra.version] +provider = "mike" + +[project.theme] +variant = "modern" +logo = "assets/logo.svg" +favicon = "assets/favicon.svg" +features = [ + "navigation.tabs", + "navigation.sections", + "navigation.top", + "search.highlight", + "content.code.copy", +] + +[[project.theme.palette]] +scheme = "default" +primary = "custom" +accent = "custom" +toggle.icon = "lucide/sun" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +scheme = "slate" +primary = "custom" +accent = "custom" +toggle.icon = "lucide/moon" +toggle.name = "Switch to light mode" From 6a0875b3e49db07353ed82b5dd2c0bc5bfd6f33d Mon Sep 17 00:00:00 2001 From: Francois Lanusse Date: Sun, 27 Sep 2026 16:03:52 -0700 Subject: [PATCH 2/2] Drop mike versioning Build a single, unversioned zensical site. Versioning for a multi-component stack is still to be decided; mike was only ever needed for lightcone-cli's release-tied snapshots. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 3 --- uv.lock | 35 +---------------------------------- zensical.toml | 7 ------- 3 files changed, 1 insertion(+), 44 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ce7b1b5..fc6816d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,9 +5,6 @@ description = "Documentation for the Lightcone Research stack" requires-python = ">=3.11" dependencies = [ "zensical>=0.0.34", - # squidfunk's mike fork — required by zensical's versioning provider. - # Not on PyPI; install from git. - "mike @ git+https://github.com/squidfunk/mike.git", ] # Not a Python package: uv only manages the environment that builds the site. diff --git a/uv.lock b/uv.lock index 833231c..47582d7 100644 --- a/uv.lock +++ b/uv.lock @@ -37,15 +37,11 @@ name = "lightcone-docs" version = "0.0.0" source = { virtual = "." } dependencies = [ - { name = "mike" }, { name = "zensical" }, ] [package.metadata] -requires-dist = [ - { name = "mike", git = "https://github.com/squidfunk/mike.git" }, - { name = "zensical", specifier = ">=0.0.34" }, -] +requires-dist = [{ name = "zensical", specifier = ">=0.0.34" }] [[package]] name = "markdown" @@ -130,17 +126,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] -[[package]] -name = "mike" -version = "2.2.0+zensical.0.1.0" -source = { git = "https://github.com/squidfunk/mike.git#2d4ad799442f4592db8ad53b179bfb33db8c69ac" } -dependencies = [ - { name = "jinja2" }, - { name = "pyparsing" }, - { name = "verspec" }, - { name = "zensical" }, -] - [[package]] name = "pathspec" version = "1.1.1" @@ -172,15 +157,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/d1/98313da89960a604402266a115311b510254900ff1295ea426403f9423cc/pymdown_extensions-12.1-py3-none-any.whl", hash = "sha256:4a254b771acfcddc6c110a7f4590d818f1f849e2d5aacf69b4b07c8dd2a9700d", size = 277069, upload-time = "2026-09-23T00:08:40.069Z" }, ] -[[package]] -name = "pyparsing" -version = "3.3.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -290,15 +266,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] -[[package]] -name = "verspec" -version = "0.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/44/8126f9f0c44319b2efc65feaad589cadef4d77ece200ae3c9133d58464d0/verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e", size = 27123, upload-time = "2020-11-30T02:24:09.646Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31", size = 19640, upload-time = "2020-11-30T02:24:08.387Z" }, -] - [[package]] name = "zensical" version = "0.0.65" diff --git a/zensical.toml b/zensical.toml index 99e4903..e4ef834 100644 --- a/zensical.toml +++ b/zensical.toml @@ -53,13 +53,6 @@ nav = [ {"ASTRA docs" = "https://astra-spec.org/latest/"}, ] -# Versioning is handled by mike (squidfunk's fork; see the docs -# dependency group). Each version of the site is deployed as a -# subdirectory of the gh-pages branch (e.g. /0.4.1/, /latest/). -# The version picker is rendered natively in the header. -[project.extra.version] -provider = "mike" - [project.theme] variant = "modern" logo = "assets/logo.svg"