diff --git a/.agents/README.md b/.agents/README.md index a7ed1f5..e383558 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -8,7 +8,7 @@ Everything an AI coding agent needs to operate inside this repo. The `.claude |---|---| | [`settings.json`](settings.json) | **Tracked** allow-list of safe, read-only Bash / git / WebFetch / pipeline commands. A fresh-clone agent gets these pre-approved so it doesn't burn turns on permission prompts. Side-effecting stages (build / fetch / extract / convert / tighten_variant) are intentionally *not* pre-approved here. | | `settings.local.json` | **Gitignored** per-machine override — additional permissions specific to the local agent's session. Don't commit. | -| [`skills/`](skills/) | 16 invokable skills following the [Agent Skills](https://agentskills.io) standard — wrappers around pipeline entrypoints (`/raincloud-build`, `/raincloud-fetch`, `/raincloud-status`, `/raincloud-validate-manifest`, `/raincloud-list-datasets`, …) and procedural playbooks (`/raincloud-add-dataset`, `/raincloud-add-handler`, `/raincloud-debug-build`, …). See [`skills/README.md`](skills/README.md). | +| [`skills/`](skills/) | 21 invokable skills following the [Agent Skills](https://agentskills.io) standard — wrappers around pipeline entrypoints (`/raincloud-build`, `/raincloud-fetch`, `/raincloud-status`, `/raincloud-validate-manifest`, `/raincloud-list-datasets`, `/raincloud-load`, `/raincloud-publish`, …) and procedural playbooks (`/raincloud-add-dataset`, `/raincloud-add-handler`, `/raincloud-debug-build`, …). See [`skills/README.md`](skills/README.md). | | [`context/`](context/) | Symlinks back to the repo-root canonical docs (`AGENTS.md`, `SKILLS.md`, `README.md`, `sources.schema.md`) so each `SKILL.md` can pull authoritative guidance via a stable relative path without copying. | | `scheduled_tasks.lock` | Gitignored — agent-runtime state. | diff --git a/.agents/skills/README.md b/.agents/skills/README.md index afd92e3..d254491 100644 --- a/.agents/skills/README.md +++ b/.agents/skills/README.md @@ -20,6 +20,10 @@ Wrappers around `python -m scripts.pipeline.`. Side-effecting ones set ` | `/raincloud-status` | `scripts.pipeline.status` | Per-slug filesystem state (raw / workdir / parquet / vortex / variant-pending). *(read-only, model-invocable.)* | | `/raincloud-validate-manifest` | `scripts.pipeline.validate_manifest` | Static checks for `sources.json` — JSON Schema + handler-registry / slug-uniqueness / fetch-auth cross-checks. *(read-only, model-invocable.)* | | `/raincloud-list-datasets` | `scripts.pipeline.list_datasets` | Filter/list slugs by handler / license / fetch-type / reader / vortex / tag / showcase / size / regex. *(read-only, model-invocable.)* | +| `/raincloud-discover` | `scripts.pipeline.list_datasets` | Find "interesting" datasets via the discoverability flags — tag / showcase / size / trait / view. *(read-only, model-invocable.)* | +| `/raincloud-profile` | `scripts.pipeline.profile` | Compute per-column statistics → `outputs/v1//profile.json` (opt-in; feeds the TUI detail pane + `list_datasets --inspect`). *(writes `profile.json`; model-invocable.)* | +| `/raincloud-load` | `raincloud.load` (loader API) | Load a prepared dataset (cache → mirror → local build) as a lazy `Dataset`; inspect metadata or materialize. *(`disable-model-invocation: true`.)* | +| `/raincloud-publish` | `scripts.pipeline.publish` | Sync built `outputs/v1/...` artefacts to a mirror, gated on the snapshot sha256. *(side-effecting — `disable-model-invocation: true`.)* | ## Procedural playbooks (model-invocable) diff --git a/.agents/skills/raincloud-add-dataset/SKILL.md b/.agents/skills/raincloud-add-dataset/SKILL.md index a1e4be3..0d14b79 100644 --- a/.agents/skills/raincloud-add-dataset/SKILL.md +++ b/.agents/skills/raincloud-add-dataset/SKILL.md @@ -13,7 +13,7 @@ Steps: - The license — must permit redistribution-of-derivatives. Check SPDX ID and `source_url`. - Approximate row count (used for `expect.rows`; can be `null` on first build). -2. **Append a `DatasetSpec` to `sources.json`** using the Python load-edit-dump pattern from [AGENTS.md](../../context/AGENTS.md#safe-ways-to-edit-sourcesjson) — never `sed`. Start from [`examples/minimal_spec.json`](../../../examples/minimal_spec.json) (every field present with placeholder values) rather than typing one from scratch. Minimal direct-HTTP shape: +2. **Append a `DatasetSpec` to `sources.json`** using the Python load-edit-dump pattern from [AGENTS.md](../../context/AGENTS.md#safe-ways-to-edit-sourcesjson) — never `sed`. Start from [`templates/minimal_spec.json`](../../../templates/minimal_spec.json) (every field present with placeholder values) rather than typing one from scratch. Minimal direct-HTTP shape: ```jsonc { diff --git a/.agents/skills/raincloud-add-handler/SKILL.md b/.agents/skills/raincloud-add-handler/SKILL.md index 39428fd..713b721 100644 --- a/.agents/skills/raincloud-add-handler/SKILL.md +++ b/.agents/skills/raincloud-add-handler/SKILL.md @@ -20,7 +20,7 @@ Steps: - `parsed` contains one `(path, table)` tuple per parsed file; `table` is `None` when `parse.reader = "custom"`. - Return `[(output_slug, table), ...]` — one tuple per output parquet. Multi-output handlers emit several slugs from one source (see `glove_split`, `osm_pbf_split`, `stack_exchange_split`). - - **Streaming handlers** (write direct to parquet, bypass the write stage) return `[]`. Copy [`examples/streaming_handler.py.tmpl`](../../../examples/streaming_handler.py.tmpl) as the starting point — it has the `outputs_root()` / `duckdb_connect()` / cleanup wiring already shaped — and study `factbook_variant_parse`, `wikipedia_variant_parse`, `lichess_pgn_parse` for upstream-shape variations. + - **Streaming handlers** (write direct to parquet, bypass the write stage) return `[]`. Copy [`templates/streaming_handler.py.tmpl`](../../../templates/streaming_handler.py.tmpl) as the starting point — it has the `outputs_root()` / `duckdb_connect()` / cleanup wiring already shaped — and study `factbook_variant_parse`, `wikipedia_variant_parse`, `lichess_pgn_parse` for upstream-shape variations. 2. **Register** in `scripts/pipeline/handlers/__init__.py`: diff --git a/.agents/skills/raincloud-load/SKILL.md b/.agents/skills/raincloud-load/SKILL.md new file mode 100644 index 0000000..61bec29 --- /dev/null +++ b/.agents/skills/raincloud-load/SKILL.md @@ -0,0 +1,73 @@ +--- +name: raincloud-load +description: Load a Raincloud dataset via the lightweight Python loader API (`raincloud.load(slug)`). Use when the user wants to read a prepared parquet/vortex artifact, inspect a slug's metadata, or pull a dataset for downstream analysis from cache → mirror → local build. +argument-hint: [--format vortex|parquet] [--materialize to_arrow|scan|to_pandas] +disable-model-invocation: true +allowed-tools: Bash(python -c *), Bash(python -m raincloud *), Bash(python examples/use_loader.py *) +--- + +The Raincloud loader is a separate, lightweight Python package (`raincloud`) for reading **already-prepared** artifacts. Resolution order is `local cache → mirror → local build`; nothing is fetched until you call an accessor. + +## Most common shape + +```bash +python -c " +import raincloud +ds = raincloud.load('$ARGUMENTS') # default format='vortex' with parquet fallback +print('rows :', ds.num_rows) +print('cols :', ds.column_names[:5]) +print('source :', ds.info.get('source_url')) +print('path :', ds.path()) # triggers cache/mirror/build resolution +" +``` + +## Materialization + +| Accessor | Returns | Notes | +|---|---|---| +| `ds.path()` | `pathlib.Path` to the on-disk artifact | First call resolves; subsequent calls are cache hits. | +| `ds.to_arrow()` | `pyarrow.Table` | Materializes the whole table; expensive on multi-GB slugs. | +| `ds.to_vortex()` | `vortex.VortexFile` (lazy) | Vortex-native handle. | +| `ds.scan()` | `duckdb.DuckDBPyRelation` | Requires `raincloud[duckdb]`. Always reads the parquet sibling — if the slug was loaded as vortex, you'll see a `[raincloud]` stderr note before the parquet is resolved. | +| `ds.to_pandas()` | `pandas.DataFrame` | Requires `raincloud[pandas]`. | +| `ds.schema` | `pyarrow.Schema` | Footer-only read for parquet; opens the file for vortex. | + +## Config via env vars + +| Env var | Effect | +|---|---| +| `RAINCLOUD_CACHE` | Local artifact cache dir (default `~/.cache/raincloud`). | +| `RAINCLOUD_MIRROR` | Mirror URL (`s3://…`, `https://…`, `file://…`). Tried before the local build. | +| `RAINCLOUD_OFFLINE=1` | Block mirror + build; raise `OfflineMiss` on cache miss. | +| `RAINCLOUD_SNAPSHOT` | Override `docs/v1/snapshot.json` (catalog). | +| `RAINCLOUD_MANIFEST` | Override `sources.json`. | + +## Drift semantics + +When a slug has a `sha256` pinned in the snapshot and the mirror or local build produces bytes that disagree, the loader prints a `[raincloud] WARN: from sha256 drifted ...` to stderr and adopts the new bytes anyway. Upstream content drifts; that's not a panic case. Catch + escalate via `raincloud.ChecksumMismatch` only if you want a hard gate (e.g. `_cache.adopt(..., strict=True)` — used by `python -m scripts.pipeline.publish`). + +## Errors (all subclass `raincloud.RaincloudError`) + +`UnknownSlug`, `FormatUnavailable`, `ArtifactNotFound`, `OfflineMiss`, `BuildToolingMissing`, `MissingDependency`, `ChecksumMismatch`. + +## Worked example + +```bash +python examples/use_loader.py --slug $ARGUMENTS # metadata only +python examples/use_loader.py --slug $ARGUMENTS --materialize # full path +``` + +[`examples/use_loader.py`](../../../examples/use_loader.py) walks the full API end-to-end against the packaged catalog with no network. + +## Install tiers + +```bash +pip install raincloud # lightweight loader +pip install 'raincloud[duckdb]' # adds .scan() +pip install 'raincloud[pandas]' # adds .to_pandas() +pip install 'raincloud[s3]' # s3:// mirror transport +pip install 'raincloud[http]' # https:// mirror transport +pip install 'raincloud[build]' # adds local-build fallback (heavyweight) +``` + +Context: [AGENTS.md "The loader package"](../../../AGENTS.md), [`examples/use_loader.py`](../../../examples/use_loader.py). diff --git a/.agents/skills/raincloud-publish/SKILL.md b/.agents/skills/raincloud-publish/SKILL.md new file mode 100644 index 0000000..0f3afd5 --- /dev/null +++ b/.agents/skills/raincloud-publish/SKILL.md @@ -0,0 +1,56 @@ +--- +name: raincloud-publish +description: Push locally-built Raincloud artifacts to a mirror (`scripts.pipeline.publish`). Use when the maintainer wants to upload one or more slugs' parquet/vortex bytes to a configured mirror after a successful build, gated on the snapshot's recorded sha256. +argument-hint: ... | --all --mirror [--dry-run] +disable-model-invocation: true +allowed-tools: Bash(python -m scripts.pipeline.publish *) +--- + +Sync locally-built artifacts to a mirror so downstream `raincloud.load()` users can fetch them. The CLI verifies each artifact's sha256 against `docs/v1/snapshot.json` before upload — mismatches block the push (publish is the one place an integrity gate is correct: corrupted bytes should never reach a shared mirror). + +## Most common shape + +```bash +python -m scripts.pipeline.publish $ARGUMENTS +``` + +Selection (one required): +- `...` — positional dataset slugs (any number). +- `--all` — every slug whose parquet OR vortex artifact exists locally under `outputs/v{n}//`. + +Required: +- `--mirror ` — destination root. Examples: + - `s3://my-bucket/raincloud` (needs `pip install raincloud[s3]`) + - `https://artifacts.example.com/raincloud` (needs `raincloud[http]`) + - `file:///mnt/shared/raincloud-mirror` (built-in) +- `RAINCLOUD_MIRROR=` env var works as an alternative to the flag. + +Modifiers: +- `--dry-run` — print the upload plan (paths + keys) without writing. Always preview large publishes this way first. + +## Before publishing + +1. **Build the slug locally first** (`/raincloud-build ` or `python -m scripts.pipeline.build `). Publish does not build; it only pushes what's already in `outputs/v{n}//`. +2. **Regenerate the snapshot** (`/raincloud-docs` or `python -m scripts.pipeline.docs`). The publish gate compares local bytes against `parquet_sha256` / `vortex_sha256` in `docs/v1/snapshot.json` — a stale snapshot causes false `PublishMismatch` failures. +3. **Verify the mirror URL** with a dry-run first. + +## What gets uploaded + +For each slug × format pair (`parquet`, `vortex`) where a local artifact exists: +- Key: `v1///.` +- Body: the raw artifact bytes +- Gate: `sha256(local) == snapshot[_sha256]` (raises `PublishMismatch` on disagree; an unpinned slug — `sha256` is `null` — uploads without verification, so prefer to regen the snapshot first). + +## Failure modes + +| Error | Meaning | Fix | +|---|---|---| +| `PublishMismatch: ...` | Local bytes diverge from the snapshot's recorded sha. | Re-run `/raincloud-docs` to refresh the snapshot, OR confirm the local artifact is correct and commit the new snapshot. | +| `FileNotFoundError: outputs/v1//...` | Slug isn't built locally. | Run `/raincloud-build ` first. | +| `ImportError: Install s3fs ...` | `s3://` mirror without `[s3]` extra. | `pip install 'raincloud[s3]'`. | + +## After publishing + +Downstream users can now `raincloud.load()` and the loader will pull from the configured mirror (cache → mirror → build). + +Context: [AGENTS.md "The loader package"](../../../AGENTS.md), [`scripts/pipeline/publish.py`](../../../scripts/pipeline/publish.py). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa673c0..a633ca3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,12 @@ on: pull_request: branches: [develop] +# Minimal token scope: every job is read-only (checkout + deps + lint/tests); +# none posts comments, uploads artifacts, or writes to the repo. Without this, +# the GITHUB_TOKEN defaults to broad write scope (flagged by CodeQL). +permissions: + contents: read + # Cancel in-progress runs when a new commit lands on the same branch. concurrency: group: ci-${{ github.ref }} @@ -26,11 +32,14 @@ jobs: run: uv python install 3.11 # `--extra dev` brings in pytest + ruff. `--extra tui` brings in - # textual so the test_browse suite runs instead of skipping. Skip - # kaggle/huggingface — those deps are only needed at fetch time and - # neither test path imports them. + # textual so the test_browse suite runs instead of skipping. `--extra + # build` brings in the heavy pipeline deps (duckdb, osmium, pyreadstat, + # zstandard, jsonschema, …) — required because validate_manifest + + # pytest collection import the handler registry, which transitively + # pulls those. Skip kaggle/huggingface — those deps are only needed at + # fetch time and neither test path imports them. - name: Install dependencies - run: uv sync --extra dev --extra tui + run: uv sync --extra dev --extra tui --extra build - name: Lint (ruff) run: uv run ruff check @@ -40,3 +49,38 @@ jobs: - name: Test (pytest) run: uv run pytest -q + + wheel: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Pin Python + run: uv python install 3.11 + - name: Install dev deps (for pytest + vortex base dep used by fixtures) + run: uv sync --extra dev + # Scope collection to test_wheel.py: this env intentionally omits the + # [build] extra, but pytest imports every collected module before -m + # filtering, and test_manifest/test_profile import jsonschema (a [build] + # dep) at module top. All wheel-marked tests live in test_wheel.py. + - name: Run wheel tests + run: uv run pytest --run-wheel -m wheel -v tests/test_wheel.py + + realbuild: + runs-on: ubuntu-latest + continue-on-error: true # non-blocking: upstream flakiness never reds the build + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Pin Python + run: uv python install 3.11 + - name: Install dev + build deps + run: uv sync --extra dev --extra build + - name: Run real-build network tests + run: uv run pytest --run-network -m network -v diff --git a/AGENTS.md b/AGENTS.md index c89a9d4..66651e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ On a fresh clone `outputs/` is empty — that's expected. The `outputs/v1/ python -m scripts.pipeline.status --fast --missing-only ``` -It loads `sources.json`, walks the manifest, and prints per-slug filesystem state in seconds with no side effects. If it errors, fix the env (`uv sync --inexact`) before running any build. Always pass `--inexact` to `uv sync`: without it, syncing one extra (e.g. `--extra dev`) silently uninstalls the others (kaggle, huggingface, tui), so a subsequent build of an HF/Kaggle slug will fail. +It loads `sources.json`, walks the manifest, and prints per-slug filesystem state in seconds with no side effects. A bare `uv sync --inexact` (or `pip install "raincloud @ git+https://github.com/spiraldb/raincloud"` from GitHub — not PyPI) installs only the lightweight loader; running any **build** needs the heavy toolchain, so fix the env with `uv sync --extra build --inexact` before invoking `scripts.pipeline.build`. Always pass `--inexact` to `uv sync`: without it, syncing one extra (e.g. `--extra dev`) silently uninstalls the others (build, kaggle, huggingface, tui), so a subsequent build of an HF/Kaggle slug will fail. For a manifest sanity check that doesn't touch the filesystem at all: @@ -42,9 +42,9 @@ uv sync --extra dev --inexact # one-time — installs pytest, preserves other pytest ``` -Copy-pasteable templates for the two most common edits live under [`examples/`](examples/) — `minimal_spec.json` for new manifest entries, `streaming_handler.py.tmpl` for memory-constrained transform handlers. +Copy-pasteable templates for the two most common edits live under [`templates/`](templates/) — `minimal_spec.json` for new manifest entries, `streaming_handler.py.tmpl` for memory-constrained transform handlers. (Runnable demos of the `raincloud.load` API live under [`examples/`](examples/).) -If your agent harness supports the [Agent Skills](https://agentskills.io) standard (Claude Code, Codex, etc.), the `.agents/skills/` directory carries 16 invokable skills wrapping every pipeline entry point and procedural playbook — see [`.agents/skills/README.md`](.agents/skills/README.md). `.claude → .agents` is a symlink so both naming conventions resolve. The `.agents/settings.json` at the same level is a tracked allow-list of safe, read-only commands so a fresh-clone agent doesn't burn turns on permission prompts; per-machine overrides go in the gitignored `.agents/settings.local.json`. +If your agent harness supports the [Agent Skills](https://agentskills.io) standard (Claude Code, Codex, etc.), the `.agents/skills/` directory carries 21 invokable skills wrapping every pipeline entry point and procedural playbook — see [`.agents/skills/README.md`](.agents/skills/README.md). `.claude → .agents` is a symlink so both naming conventions resolve. The `.agents/settings.json` at the same level is a tracked allow-list of safe, read-only commands so a fresh-clone agent doesn't burn turns on permission prompts; per-machine overrides go in the gitignored `.agents/settings.local.json`. ## Don't read the giant derived docs cover-to-cover @@ -67,6 +67,31 @@ Raincloud is a **client-reproducible pipeline** for building a curated catalog o The pipeline flow is: **fetch → extract → parse → transform → write → validate → convert** (stage 7 opt-in per-spec), orchestrated by `scripts.pipeline.build`. +## The loader package (`raincloud`) + +Separate from the build pipeline under `scripts/`, the repo also ships an importable **`raincloud`** package — a lightweight loader for *already-prepared* artefacts. `raincloud.load("")` (alias `load_dataset`) returns a lazy `Dataset` handle; nothing is fetched until you call `.path()` / `.to_arrow()` / `.scan()` / `.to_pandas()`. Resolution order is **local cache → mirror → local build** (`raincloud/_resolve.py`): a cache hit short-circuits, otherwise it pulls from the configured mirror, and only on a cache+mirror miss does it shell out to `scripts.pipeline.build` as a last resort. + +The install is **layered** — this is a behaviour change from earlier releases: + +- A bare `uv sync --inexact` (or a `pip install` from the GitHub repo) installs only the lightweight loader: base deps are `pyarrow`, `numpy`, `vortex-data`, `fsspec`. Transport backends are per-scheme extras (`[s3]` → s3fs, `[http]` → aiohttp; `file://` needs neither); `[duckdb]` / `[pandas]` back `Dataset.scan()` / `.to_pandas()`. +- **Building datasets now requires `uv sync --extra build --inexact`** — the heavy toolchain (duckdb, osmium, pyreadstat, pandas, openpyxl, py7zr, unlzw3, zstandard, jsonschema) moved behind the `[build]` extra. A bare sync no longer pulls these, so any `scripts.pipeline.build` / handler work needs `--extra build` first. + +The mirror is a **private/internal artefact store** — a bucket a team points its own CI at, configured via the `RAINCLOUD_MIRROR` env var (`s3://bucket/prefix`, `file:///path`, etc.); there is no public Raincloud-hosted endpoint, and this does not change the no-redistribution posture in [`DISCLAIMER.md`](DISCLAIMER.md). `RAINCLOUD_CACHE` overrides the cache dir and `RAINCLOUD_OFFLINE` forces cache-only (mirror/build misses raise). Maintainers publish built `outputs/v1/...` to a mirror with `python -m scripts.pipeline.publish --mirror `, gated on a snapshot sha256 match. Integrity: `docs/v1/snapshot.json` carries per-slug `parquet_sha256` / `vortex_sha256` (recorded only for slugs already built + hashed locally — today a minority of the catalog) plus a byte size for *every* slug. `publish` refuses to upload an artifact whose on-disk sha disagrees with the snapshot (slugs with no recorded sha are uploaded ungated). The loader, by default, **warns-and-adopts** on a checksum mismatch (drift is an alert, not a blocker — upstream data shifts) and falls back to the byte size as a cheap corruption check when no sha is pinned; set `RAINCLOUD_STRICT_CHECKSUM=1` to turn a mismatch on mirror bytes into a hard `ChecksumMismatch`. Locally-built artefacts are never strict-gated against the maintainer's sha (a client build legitimately differs); instead they're trusted via a provenance pin (`origin=build` + the snapshot pin they were built against) and served from cache until that snapshot pin changes — so a strict, mirror-less deployment rebuilds a slug once when the source of truth moves, not on every load. To backfill checksums for the rest of the catalog, build the slugs and run `python -m scripts.pipeline.docs snapshot --rehash`. + +**Build data-area env vars** (separate from the loader's cache vars above): the build pipeline writes artefacts under a configurable root. In a checkout, that root is the repo directory; in a `pip install raincloud[build]` wheel install, it defaults to `~/.cache/raincloud` (XDG-aware, no init step). The resolution logic lives in `scripts/pipeline/spec.py:data_root()`. + +| Env var | Controls | Default | +|---|---|---| +| `RAINCLOUD_HOME` | build data-area root | checkout root (if `sources.json` present), else `~/.cache/raincloud` | +| `RAINCLOUD_OUTPUTS` | built-artifact base (`/v{n}` under it) | `$RAINCLOUD_HOME/outputs` | +| `RAINCLOUD_RAW_DOWNLOADS` | cached raw upstream bytes | `$RAINCLOUD_OUTPUTS/raw_downloads` | +| `RAINCLOUD_WORKDIR` | extract/scratch space | `$RAINCLOUD_HOME/_workdir` | +| `RAINCLOUD_MANIFEST` | `sources.json` path | checkout copy, else the wheel-packaged copy | + +In the defaults above, `$RAINCLOUD_HOME` / `$RAINCLOUD_OUTPUTS` mean the *resolved* roots — when those vars are unset they fall back to the checkout (or `~/.cache/raincloud`) and `/outputs` respectively. + +From an agent context: on a fresh clone all five default to the repo tree (existing behaviour). On a wheel install with no checkout present, builds silently use `~/.cache/raincloud` (honoring `XDG_CACHE_HOME`) — same root `RAINCLOUD_CACHE` defaults to — so the loader's cache-hit path fires after the first build without any extra config. + ## Invariants (don't break these) 1. **`sources.json` is authoritative.** Every row of every derived artefact maps back to a `DatasetSpec` here. If you're tempted to hand-edit `docs/*.md` or drop a parquet into `outputs/v1//` by hand — stop, fix the manifest, re-run the build, re-run `docs.py`. diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6f156..68558bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,194 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-05-29 + +### Added + +- **`raincloud` loader package.** A new importable package + (separate from the `scripts/` build pipeline) for loading + *already-prepared* artefacts. `raincloud.load("")` (alias + `load_dataset`) returns a lazy `Dataset` handle — nothing is fetched + until you call `.path()` / `.to_arrow()` / `.scan()` / `.to_pandas()`. + Resolution order is **local cache → mirror → local build**: a cache + hit short-circuits, otherwise it pulls from the configured mirror, + and only on a cache+mirror miss does it shell out to + `scripts.pipeline.build`. Configured via env vars: `RAINCLOUD_MIRROR` + (an `fsspec` base such as `s3://bucket/prefix` or `file:///path` — + a private/internal artefact store, not a public Raincloud endpoint), + `RAINCLOUD_CACHE` (cache dir override), `RAINCLOUD_OFFLINE` + (cache-only; mirror/build misses raise), `RAINCLOUD_STRICT_CHECKSUM` + (opt-in hard integrity gate; see below). When the snapshot records a + checksum, a drift from it warns-and-adopts by default (see "Drift is an + alert"); where no checksum is recorded yet — most of the catalog today — + the pinned byte size is used as a cheap corruption check instead. +- **`scripts.pipeline.publish` mirror-sync CLI.** + `python -m scripts.pipeline.publish --mirror ` + uploads built `outputs/v1/...` artefacts to a mirror, gated on each + artefact's on-disk sha256 matching `docs/v1/snapshot.json` (slugs with + no recorded sha are uploaded ungated). Each upload streams to a + `..part` temp key and renames into place, so a mid-stream + crash never leaves a truncated object at the canonical key. The + snapshot is resolved via the same `RAINCLOUD_SNAPSHOT` → checkout → + wheel precedence the loader uses, not a hardcoded checkout path. + `--dry-run` previews the upload plan. +- **`parquet_sha256` / `vortex_sha256` in `docs/v1/snapshot.json`** — + per-slug artefact checksums, used by both the loader (download + integrity) and `publish` (the snapshot-match gate). +- **`examples/use_loader.py`** — runnable walkthrough of the loader API + (metadata access, `.to_arrow` / `.scan` / `.to_pandas` materialization, + format override, env-var configuration, the full + `RaincloudError` hierarchy). Runs against the packaged catalog with + no network; `--materialize` exercises the full resolution path. +- **Code-path example scripts in `examples/`.** Single-file demos that + `load()` a real catalog dataset and run a query: `nyc_taxi_tip_rate.py` + (DuckDB over `.scan()` on 48.7M yellow-cab trips — what share left no + recorded tip, by `payment_type`), `kepler_exoplanets.py` (pandas + disposition counts + smallest confirmed planet), `wine_quality_correlations.py` + (feature↔quality correlations), and `olympic_medals.py` (medals by NOC / + decade). `tests/test_examples.py` byte-compiles every example and (under + `--run-network`) runs the kepler one end-to-end. +- **New agent skills: `raincloud-load` and `raincloud-publish`.** Wrap + the loader API and the publish CLI respectively, matching the + existing `raincloud-*` skill conventions (name-only, + `disable-model-invocation: true`). + +### Changed + +- **`examples/` is now runnable demos; authoring templates moved to + `templates/`.** `minimal_spec.json` and `streaming_handler.py.tmpl` (config + templates for adding a source) live under the new top-level `templates/`; + `examples/` is reserved for code-path scripts that use the `raincloud.load` + API. Doc and skill references updated accordingly. +- **Packaging: the project is now a hatchling-built, installable + package** (installed from GitHub: `pip install "raincloud @ git+https://github.com/spiraldb/raincloud"`, not PyPI). The wheel force-includes + `docs/v1/snapshot.json` and `sources.json` as packaged data under + `raincloud/_data/`, so the catalog resolves with no repo checkout. +- **BREAKING (install): the heavy build toolchain moved out of the base + dependency set into the `[build]` extra.** A bare `uv sync --inexact` + (or a `pip install` from the GitHub repo) now installs only the lightweight loader + (`pyarrow`, `numpy`, `vortex-data`, `fsspec`); **building datasets + requires `uv sync --extra build --inexact`** (duckdb, pandas, osmium, + pyreadstat, openpyxl, py7zr, unlzw3, zstandard, jsonschema). Transport + backends are per-scheme extras (`[s3]` → s3fs, `[http]` → aiohttp; + `file://` needs neither); `[duckdb]` / `[pandas]` back + `Dataset.scan()` / `.to_pandas()`. This does not change the + no-redistribution posture in [`DISCLAIMER.md`](DISCLAIMER.md). +- **Drift is an alert, not a blocker.** When a slug's sha256 is pinned + in the snapshot and the mirror or local build produces different + bytes, the loader now prints `[raincloud] WARN: from + sha256 drifted ...` to stderr and adopts the new bytes anyway. + Upstream content changes are common and benign; the build should + still work, with the user informed. The loader's mirror-fetch path is + the strict-capable caller — under `RAINCLOUD_STRICT_CHECKSUM` it passes + `_cache.adopt(..., strict=True)` so a mirror mismatch raises + `ChecksumMismatch`. (`scripts.pipeline.publish` is a *separate* gate: it + refuses to upload via its own `PublishMismatch`, not through `adopt`.) + Adopted bytes are recorded in a `..pin` sidecar — the snapshot sha + reconciled against, the on-disk size, and the origin (`mirror`/`build`) + — so later loads serve them straight from cache; a genuine snapshot + revision (the pinned sha changed) still re-fetches, and a post-adoption + size change still falls through to a fresh fetch. In the default + (non-strict) mode a sha-present cache hit is served on a byte-size match + without rehashing (the multi-GB rehash-avoidance fast path), so a + same-size on-disk content swap isn't caught until strict mode forces a + rehash. + + Set `RAINCLOUD_STRICT_CHECKSUM=1` to opt the loader into a hard gate: + for a sha-pinned slug from the mirror, a mismatch on download AND on a + cache hit raises `ChecksumMismatch` (the cached file is rehashed each + load, catching even same-size tampering). Sha-less slugs have nothing to + rehash against, so strict leaves their size/pin corruption check + unchanged. The **local build path is never strict-gated against the + maintainer's sha**: a client's rebuild legitimately differs (columnar + output is rarely bit-reproducible), so the built artefact is tagged + `origin=build` in its pin and served from cache by that provenance — + even under strict — instead of being rebuilt every load. It is rebuilt + only when the snapshot pin it was built against changes (the source of + truth moved) or the cached file is corrupted. For full cryptographic + integrity, point strict deployments at a mirror. + +### Fixed + +- **Wheel-install path crashes after long-running work.** + `scripts/pipeline/hydrate.py` (success-log + the FileNotFoundError + message), + `scripts/pipeline/tighten_variant.py` (workdir + + three log lines), + `scripts/pipeline/overnight_profile.py` + (`LOG_PATH`, `STATE_PATH`, `_slug_already_built`, `_wipe_slug`, + manifest loader), + `scripts/pipeline/list_datasets.py`, and + `scripts/pipeline/browse.py` all routed through + `REPO_ROOT / "outputs/..."` or `.relative_to(REPO_ROOT)` — fragile + under wheel installs and any `RAINCLOUD_HOME` / `RAINCLOUD_OUTPUTS` / + `RAINCLOUD_WORKDIR` redirect, where it raised `ValueError` at the + tail of a multi-hour build. All call sites now use the env-aware + `display_path()` / `outputs_root()` / `raw_downloads_root()` / + `workdir_root()` helpers. New + `tests/test_pipeline_path_hermeticity.py` greps the pipeline package + on every test run and fails on regressions. +- **Build-availability now distinguishes a missing extra from a broken + install.** `_resolve` captures *why* `scripts.pipeline.build` can't be + imported: a plain `ImportError`/`ModuleNotFoundError` (the `[build]` + extra isn't installed) still yields the "install `raincloud[build]`" + hint, but any other module-init failure (a handler raising at top + level, a malformed packaged manifest) now surfaces the real exception + in the `BuildToolingMissing` message instead of misdirecting the user + to a `pip install` they've already done. +- **Local build failures are typed.** A non-zero + `scripts.pipeline.build` subprocess now raises `BuildFailed` + (a `RaincloudError`) instead of leaking a raw + `subprocess.CalledProcessError` past the loader's typed-error contract. +- **`read_pin` rejects non-object JSON.** A torn/partial or tampered + `.pin` sidecar containing valid-but-non-dict JSON (`42`, `[...]`) now + returns `None` rather than a value whose later `.get(...)` would raise + `AttributeError` inside `resolve()`. +- **Sha-less cache files are size-checked, not trusted on existence.** + For a slug with no pinned sha (most of the catalog), a cache hit serves + via the adoption pin or a snapshot-byte-size match; a cached file whose + size diverges from the snapshot with no pin vouching for it is treated + as corruption and re-fetched, rather than served on mere existence. +- **Catalog parquet visibility for snapshot-only slugs.** When a slug + is in `docs/v1/snapshot.json` but absent from `sources.json` + (legacy / deprecated entry still on a mirror), `Catalog.entry()` now + exposes the parquet format via the same + `snap.get('parquet_bytes') is not None` clause that already covered + vortex. Loadable now; previously raised `FormatUnavailable`. +- **`Dataset.scan()` stderr note.** When a slug was loaded as vortex + but `scan()` needs the parquet sibling (DuckDB has no Vortex + reader), the loader prints `[raincloud] scan() needs parquet but + was loaded as 'vortex'; resolving parquet sibling ...` before + the resolve, so an implicit mirror fetch isn't a surprise. +- **`.part` tmp race fixed.** `_resolve.resolve` now writes to + `f"..-.part"` (per-process unique) and sweeps + stale `.part` siblings older than six hours on each resolve, so + concurrent loaders no longer clobber each other's in-flight writes + and SIGKILL leftovers don't accumulate. + +### Performance + +- **Cache-hit skips sha256 rehash when artifact size matches the + snapshot.** `_resolve.resolve` uses + `dest.stat().st_size == FormatInfo.nbytes` as the fast path — a full + sha256 over multi-GB artifacts on every load defeated the cache. + Cuts repeat-load cost on the 34 GB Wikipedia parquet from minutes + to milliseconds. The full rehash only runs when size disagrees *and* + the pin sidecar doesn't already vouch for the file; a same-size, + different-content snapshot revision is the one case the size fast-path + can't distinguish (an accepted blind spot, same as before). +- **Snapshot regen reuses prior sha when size unchanged.** `docs.py` + snapshot regen (`_sha256_or_reuse`) skips re-streaming an artifact + when its bytes-on-disk match the prior snapshot's recorded size and + the prior sha is known. A full-catalog regen on 250 slugs + (including the multi-GB heavyweights) drops from hours to seconds + when nothing's changed. Pass `--rehash` (`python -m + scripts.pipeline.docs snapshot --rehash`) to force a full recompute — + needed only in the rare case a rebuild changed an artifact's content + without changing its byte length, which would otherwise wedge + `publish`'s checksum gate; unlike `--overwrite-missing` it preserves + prior data for slugs not built this run. + ## [0.1.5] - 2026-05-17 ### Fixed @@ -303,6 +491,8 @@ This release bundles: this repository" button in the repo sidebar with BibTeX / APA / Chicago exports. +[0.2.0]: https://github.com/spiraldb/raincloud/releases/tag/v0.2.0 +[0.1.5]: https://github.com/spiraldb/raincloud/releases/tag/v0.1.5 [0.1.4]: https://github.com/spiraldb/raincloud/releases/tag/v0.1.4 [0.1.3]: https://github.com/spiraldb/raincloud/releases/tag/v0.1.3 [0.1.2]: https://github.com/spiraldb/raincloud/releases/tag/v0.1.2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 80d263e..6b1ba66 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,11 +13,15 @@ cd raincloud uv sync --extra dev --inexact ``` -`--extra dev` pulls in `pytest`. Add `--extra kaggle` or `--extra huggingface` -if your work touches those upstream types, or `--extra all` for everything. -Always pass `--inexact` — without it, each `uv sync --extra X` removes the -extras from the previous one (e.g. syncing `--extra dev` after `--extra -huggingface` uninstalls `huggingface_hub`). +`--extra dev` pulls in `pytest`. The base install is the lightweight loader +(`pyarrow`, `numpy`, `vortex-data`, `fsspec`); the heavy build toolchain +(duckdb, pandas, osmium, pyreadstat, openpyxl, …) now lives behind the `build` +extra, so **add `--extra build` when working on the pipeline or handlers** — +otherwise the build stages fail on a missing import. Add `--extra kaggle` or +`--extra huggingface` if your work touches those upstream types, or `--extra +all` for everything. Always pass `--inexact` — without it, each `uv sync +--extra X` removes the extras from the previous one (e.g. syncing `--extra dev` +after `--extra huggingface` uninstalls `huggingface_hub`). ## Before you open a PR @@ -26,13 +30,17 @@ Three sub-second checks are the minimum gate (CI runs all three): ```bash ruff check # lint (pyflakes + pycodestyle + isort) python -m scripts.pipeline.validate_manifest # JSON Schema + cross-checks on sources.json -pytest # smoke regression net (manifest, schema, registry, examples) +pytest # smoke regression net (manifest, schema, registry, examples, loader) ``` -If you touched the build pipeline, also run a small end-to-end build to make -sure it still produces the expected output: +`pytest` now also covers the `raincloud` loader package (catalog resolution, +cache/mirror/build dispatch, sha256 integrity) alongside the manifest checks. + +If you touched the build pipeline, install the build extra and run a small +end-to-end build to make sure it still produces the expected output: ```bash +uv sync --extra build --inexact python -m scripts.pipeline.build countries-of-the-world # ~200 ms, 227 rows ``` @@ -41,7 +49,7 @@ For larger builds, see [`SKILLS.md`](SKILLS.md#running-a-large-build-safely). ## What to send a PR for - **New datasets** — see [`SKILLS.md`](SKILLS.md#adding-a-new-dataset). Most - entries copy [`examples/minimal_spec.json`](examples/minimal_spec.json) and + entries copy [`templates/minimal_spec.json`](templates/minimal_spec.json) and pick an existing handler from [`docs/v1/handlers.md`](docs/v1/handlers.md). - **New transform handlers** — see [`SKILLS.md`](SKILLS.md#adding-a-new-transform-handler). One handler per diff --git a/README.md b/README.md index 0633874..7bdca9a 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ python -m scripts.pipeline.browse A read-only Textual TUI over `sources.json`. Click any column header to sort; right pane shows description, license, fetch URL, and on-disk state for the highlighted slug. Press `q` to quit. -**Tell Raincloud which dataset you want; get back a Parquet + Vortex file on disk.** +**Tell Raincloud which dataset you want; get back a Parquet + Vortex file on disk.** Building needs the heavy toolchain behind the `build` extra (a bare `uv sync --inexact` installs only the lightweight loader — see [Upstream-specific extras](#upstream-specific-extras)): ```bash -uv sync --inexact +uv sync --extra build --inexact python -m scripts.pipeline.status --fast --missing-only # read-only env check python -m scripts.pipeline.build countries-of-the-world ``` @@ -45,6 +45,36 @@ outputs/v1/countries-of-the-world/vortex/countries-of-the-world.vortex The command runs every pipeline stage — fetch, extract, parse, transform, write, validate, convert — and leaves both a Parquet file and its converted Vortex sibling under per-format subdirectories of `outputs/v1//`. +### Load prepared files in your own scripts / CI + +Install just the loader (no build toolchain): + +```bash +# from GitHub (not published to PyPI); pin a tag like @v0.2.0 for reproducible CI +pip install "raincloud @ git+https://github.com/spiraldb/raincloud" +# remote-mirror backends: "raincloud[s3] @ git+https://github.com/spiraldb/raincloud" +``` + +```python +import raincloud +ds = raincloud.load("countries-of-the-world") # lazy handle, default Vortex +table = ds.to_arrow() # materialize when you want it +path = ds.path() # or just the cached file path +``` + +Resolution is **cache → mirror → local build**. Point CI at a prepared-artifact +bucket and loads become downloads, not multi-hour rebuilds: + +```bash +export RAINCLOUD_MIRROR=s3://your-bucket/raincloud # or file:///path +``` + +When the catalog records a checksum for an artifact, a drift from it warns and +adopts by default (`RAINCLOUD_STRICT_CHECKSUM=1` makes it a hard failure); where +no checksum is recorded yet, the pinned byte size is used as a cheap corruption +check. The `raincloud[build]` extra adds the full pipeline for the local-build +fallback on a cache + mirror miss. + ### Discover Run `python -m scripts.pipeline.browse` and click the **Encoding** preset. The left panel filters by domain, size, shape traits, license, or fetch type; click a slug to see its description, on-disk state, and per-column profile (run `python -m scripts.pipeline.profile ` first to populate it). @@ -68,6 +98,14 @@ python -m scripts.pipeline.build clickbench-hits # 100 M rows, ~10 GB ### Upstream-specific extras +A bare `uv sync --inexact` (or a `pip install` from the GitHub URL above) installs only the lightweight loader — `pyarrow`, `numpy`, `vortex-data`, `fsspec`. **Building datasets requires the heavy toolchain behind the `build` extra:** + +```bash +uv sync --extra build --inexact # duckdb, pandas, osmium, pyreadstat, openpyxl, … +``` + +Add `--extra build` before running `python -m scripts.pipeline.build` (or working on a handler); without it the build stages fail on a missing import. + 157 of 249 manifest entries fetch from direct HTTPS endpoints and need no additional setup. The rest: ```bash @@ -92,8 +130,8 @@ If you're an AI coding agent landing in this repo: 2. Run `python -m scripts.pipeline.status --fast --missing-only` to verify the env, then `python -m scripts.pipeline.validate_manifest` to confirm `sources.json` is well-formed. Both are sub-second and side-effect-free. 3. Run `pytest` (after `uv sync --extra dev --inexact`) for a regression net before any non-trivial change to the manifest, schema, or handler registry. 4. For catalog questions ("which slugs use handler X", "what's CC0-licensed"), use `python -m scripts.pipeline.list_datasets` rather than greping `sources.json` or scrolling [`docs/v1/datasets.md`](docs/v1/datasets.md). -5. Copy-pasteable templates for new manifest entries and streaming handlers live in [`examples/`](examples/). -6. Harnesses that follow the [Agent Skills](https://agentskills.io) standard get 16 invokable skills under [`.agents/skills/`](.agents/skills/) (the `.claude → .agents` symlink means Claude Code sees the same files). Tracked safe-default permissions in [`.agents/settings.json`](.agents/settings.json) — see [`.agents/README.md`](.agents/README.md) for the full layout. +5. Copy-pasteable templates for new manifest entries and streaming handlers live in [`templates/`](templates/); runnable demos of the `raincloud.load` API are in [`examples/`](examples/). +6. Harnesses that follow the [Agent Skills](https://agentskills.io) standard get 21 invokable skills under [`.agents/skills/`](.agents/skills/) (the `.claude → .agents` symlink means Claude Code sees the same files). Tracked safe-default permissions in [`.agents/settings.json`](.agents/settings.json) — see [`.agents/README.md`](.agents/README.md) for the full layout. ## Repository layout @@ -105,6 +143,7 @@ AGENTS.md # invariants + first-contact guide for AI coding SKILLS.md # narrative playbooks HYDRATING.md # hand-maintained hydration policy / philosophy DISCLAIMER.md # AS IS posture, content/license disclaimers, dataset-removal reporting +raincloud/ # importable loader package — raincloud.load("") → lazy Dataset (cache → mirror → build) scripts/ pipeline/ build.py # orchestrator — ties the 7 stages together @@ -118,6 +157,7 @@ scripts/ convert.py # stage 7 (optional): emit sibling .vortex per spec's convert.vortex flag hydrate.py # stage 8 (optional, opt-in): dereference URL columns into parquet-hydrated/ docs.py # regenerate docs/datasets.md + handlers.md (other catalog views live in list_datasets / TUI) + publish.py # sync built outputs/v1/ artifacts to a mirror (snapshot-sha256-gated) tighten_variant.py # in-place JSON → VARIANT pass validate_manifest.py # static checks on sources.json (schema + cross-checks) list_datasets.py # filter/list slugs by handler / license / tag / size / etc. @@ -126,8 +166,9 @@ scripts/ spec.py # manifest loader, path helpers, duckdb_connect handlers/ # named transform handlers tests/ # pytest smoke suite (manifest, schema, handler registry, examples) -examples/ # copy-pasteable templates (minimal_spec.json, streaming_handler.py.tmpl) -.agents/ # tracked agent allow-list (settings.json) + 16 invokable skills (.claude → .agents) +templates/ # copy-pasteable authoring templates (minimal_spec.json, streaming_handler.py.tmpl) +examples/ # runnable code-path demos of raincloud.load (use_loader.py, nyc_taxi_tip_rate.py, …) +.agents/ # tracked agent allow-list (settings.json) + 21 invokable skills (.claude → .agents) outputs/ raw_downloads// # stage 1 output — unversioned, cached v{schema_version}// # stage 5 output — version-scoped @@ -214,6 +255,24 @@ RAINCLOUD_DUCKDB_TEMP_DIRECTORY=/mnt/scratch/duckdb-tmp \ Persistent DuckDB databases are opened with `storage_compatibility_version=v1.5.0` automatically (required for VARIANT columns). +### Data locations + +By default, builds write under the repo root (if `sources.json` is present — the checkout case) or under `~/.cache/raincloud` (wheel-install case). All five paths are overridable: + +| Env var | Controls | Default | +|---|---|---| +| `RAINCLOUD_HOME` | build data-area root | checkout root (if `sources.json` is present), else `~/.cache/raincloud` | +| `RAINCLOUD_OUTPUTS` | built-artifact base (`/v{n}` under it) | `$RAINCLOUD_HOME/outputs` | +| `RAINCLOUD_RAW_DOWNLOADS` | cached raw upstream bytes | `$RAINCLOUD_OUTPUTS/raw_downloads` | +| `RAINCLOUD_WORKDIR` | extract/scratch space | `$RAINCLOUD_HOME/_workdir` | +| `RAINCLOUD_MANIFEST` | `sources.json` path | checkout copy, else the wheel-packaged copy | + +In the defaults above, `$RAINCLOUD_HOME` / `$RAINCLOUD_OUTPUTS` mean the *resolved* roots — when those vars are unset they fall back to the checkout (or `~/.cache/raincloud`) and `/outputs` respectively. + +In a **checkout** (`uv sync --extra build --inexact`), builds write to `/outputs/` as before — unchanged. In a **wheel install** (`pip install raincloud[build]`), builds default to `~/.cache/raincloud` (same cache tier as Hugging Face `datasets`, honoring `XDG_CACHE_HOME`) — no init step required. + +`RAINCLOUD_CACHE` (the loader's *download* cache for already-built artefacts fetched via mirror) is independent of the above and always defaults to `~/.cache/raincloud` (honoring `XDG_CACHE_HOME`). + ## The manifest (`sources.json`) See [`sources.schema.md`](sources.schema.md) for the human-friendly reference and [`sources.schema.json`](sources.schema.json) for the machine-readable JSON Schema (Draft 2020-12). After editing the manifest, run diff --git a/SKILLS.md b/SKILLS.md index b91b128..f1372df 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -2,10 +2,12 @@ Playbooks for common operations in this repo. Each section is a self-contained recipe — copy and adapt. -Prereqs: Python 3.11+ and [uv](https://docs.astral.sh/uv/). Run `uv sync --inexact` in the repo root to install the pinned core deps (`pyarrow`, `duckdb`, `vortex-data`, `zstandard`, `py7zr`, `unlzw3`, `pandas`, `openpyxl`, `pyreadstat`, `osmium`, `jsonschema`). Add `--extra kaggle` for Kaggle-hosted datasets, `--extra huggingface` for Hugging Face ones, or `--extra dev` for `pytest` — see [`README.md`](README.md#upstream-specific-extras). Always pass `--inexact` so subsequent extras accumulate instead of overwriting prior ones (uv's default is "exact" sync, which removes anything not requested by the current invocation). Invoke Python as `.venv/bin/python` (or activate the venv). +Prereqs: Python 3.11+ and [uv](https://docs.astral.sh/uv/). A bare `uv sync --inexact` installs only the lightweight loader (`pyarrow`, `numpy`, `vortex-data`, `fsspec`); **running the build pipeline needs the heavy toolchain behind the `build` extra** — `uv sync --extra build --inexact` (pulls `duckdb`, `zstandard`, `py7zr`, `unlzw3`, `pandas`, `openpyxl`, `pyreadstat`, `osmium`, `jsonschema`). Add `--extra kaggle` for Kaggle-hosted datasets, `--extra huggingface` for Hugging Face ones, or `--extra dev` for `pytest` — see [`README.md`](README.md#upstream-specific-extras). Always pass `--inexact` so subsequent extras accumulate instead of overwriting prior ones (uv's default is "exact" sync, which removes anything not requested by the current invocation). Invoke Python as `.venv/bin/python` (or activate the venv). ## Index +- [Loading prepared datasets](#loading-prepared-datasets) — `raincloud.load` from cache / mirror +- [Publishing artefacts to a mirror](#publishing-artefacts-to-a-mirror) — `scripts.pipeline.publish` - [Opening a DuckDB connection](#opening-a-duckdb-connection) - [Running the test suite](#running-the-test-suite) — pytest smoke regression net - [Querying the catalog](#querying-the-catalog) — `list_datasets` filters @@ -23,7 +25,37 @@ Prereqs: Python 3.11+ and [uv](https://docs.astral.sh/uv/). Run `uv sync --inexa - [Regenerating specific docs](#regenerating-specific-docs) - [Removing a dataset](#removing-a-dataset) -Templates referenced by the playbooks: [`examples/minimal_spec.json`](examples/minimal_spec.json) (new manifest entry), [`examples/streaming_handler.py.tmpl`](examples/streaming_handler.py.tmpl) (memory-constrained handler). +Templates referenced by the playbooks: [`templates/minimal_spec.json`](templates/minimal_spec.json) (new manifest entry), [`templates/streaming_handler.py.tmpl`](templates/streaming_handler.py.tmpl) (memory-constrained handler). + +## Loading prepared datasets + +Use the importable `raincloud` loader to pull an *already-prepared* artefact instead of rebuilding it. The base install (a `pip install` from the GitHub repo, or a bare `uv sync --inexact`) is the lightweight loader only — add `[s3]` / `[http]` for a remote mirror, `[duckdb]` / `[pandas]` for `.scan()` / `.to_pandas()`. + +```python +import raincloud +ds = raincloud.load("countries-of-the-world") # lazy handle, default Vortex +table = ds.to_arrow() # materialize when you want it +path = ds.path() # or just the cached file path +``` + +Resolution is **cache → mirror → local build**: a local cache hit short-circuits; otherwise it pulls from `RAINCLOUD_MIRROR` (a private/internal artefact store — `s3://bucket/prefix`, `file:///path`); only on a cache+mirror miss does it shell out to `scripts.pipeline.build` (which needs the `[build]` extra). When `docs/v1/snapshot.json` records a checksum, a drift warns-and-adopts by default (`RAINCLOUD_STRICT_CHECKSUM=1` for a hard gate); otherwise the pinned byte size is the corruption check. + +```bash +export RAINCLOUD_MIRROR=s3://your-bucket/raincloud # point CI at a prepared-artefact bucket +export RAINCLOUD_CACHE=/var/cache/raincloud # optional cache-dir override +export RAINCLOUD_OFFLINE=1 # cache-only; mirror/build misses raise OfflineMiss +``` + +## Publishing artefacts to a mirror + +Maintainers sync built `outputs/v1/...` artefacts up to a mirror bucket with the `publish` CLI. Each artefact's on-disk sha256 must match `docs/v1/snapshot.json` (regenerate the snapshot via `python -m scripts.pipeline.docs` after a build first), otherwise the upload is gated off with a `PublishMismatch`. + +```bash +python -m scripts.pipeline.publish countries-of-the-world --mirror s3://your-bucket/raincloud +python -m scripts.pipeline.publish --all --mirror file:///tmp/mirror --dry-run # preview the plan +``` + +The mirror is a private/internal store you control — there is no public Raincloud-hosted endpoint, and publishing here does not change the no-redistribution posture in [`DISCLAIMER.md`](DISCLAIMER.md). The `--mirror` base is an `fsspec` URL; `s3://` needs `[s3]`, `http(s)://` needs `[http]`, `file://` needs neither. ## Opening a DuckDB connection @@ -55,7 +87,7 @@ uv sync --extra dev --inexact # one-time — installs pytest, preserves other pytest # ~0.5 s on the full suite ``` -`tests/` carries a sub-second smoke suite for the manifest, the schema, the handler registry, and the example templates. No fetch, no build, no filesystem writes. Run after any change to `sources.json`, `sources.schema.json`, `scripts/pipeline/handlers/__init__.py`, or `examples/`. Tests exercise the same `validate_manifest` codepath the `/raincloud-validate-manifest` skill runs. +`tests/` carries a sub-second smoke suite for the manifest, the schema, the handler registry, and the example templates. No fetch, no build, no filesystem writes. Run after any change to `sources.json`, `sources.schema.json`, `scripts/pipeline/handlers/__init__.py`, `templates/`, or `examples/`. Tests exercise the same `validate_manifest` codepath the `/raincloud-validate-manifest` skill runs. ## Querying the catalog diff --git a/docs/v1/snapshot.json b/docs/v1/snapshot.json index e2f636c..d3e7eba 100644 --- a/docs/v1/snapshot.json +++ b/docs/v1/snapshot.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "generated_at": "2026-05-17T01:07:13Z", + "generated_at": "2026-05-27T18:14:16Z", "note": "Auto-generated by scripts/pipeline/docs.py. Read by the TUI as a fallback when a local parquet isn't built. Regenerate after any build / schema change with `python -m scripts.pipeline.docs snapshot`.", "slugs": { "clickbench-hits": { @@ -1201,6 +1201,8 @@ "last_built_row_groups": 1, "parquet_bytes": 73701591, "vortex_bytes": 118973792, + "parquet_sha256": "ab7ab80dd49faba78e8321b1454fc2ff6cdbb5b5e872848e362853105e78c394", + "vortex_sha256": "d149b58bd8d59b157c68520d5de756cdd5755c08eeb9e25d9221ac7fcab7ef56", "columns": [ { "name": "Country Name", @@ -1795,6 +1797,8 @@ "last_built_row_groups": 21, "parquet_bytes": 1558628820, "vortex_bytes": 2112544400, + "parquet_sha256": "fc7c1413ea8eeed4a8993888ea2cd3511513037b294478aa74d07891aaa91cab", + "vortex_sha256": "aeffbfe87959f63ca38b03ef832d121a4f362377d0040822380f873d1221dc84", "columns": [ { "name": "Unique Key", @@ -2437,6 +2441,8 @@ "last_built_row_groups": 701, "parquet_bytes": 9976102966, "vortex_bytes": 15508324928, + "parquet_sha256": "18749a23f34f9f8ff4d9341d695ad6c22154d604ccd31ca3cbc739efaaf53d21", + "vortex_sha256": "37c93f284ea84be60ca53242454cd02eaef3542634560e9003f20fa54da95b11", "columns": [ { "name": "id", @@ -2499,22 +2505,25 @@ }, "osm-germany-relations": { "expected_rows": null, - "last_built_rows": 889712, - "parquet_bytes": 95642737, - "vortex_bytes": 155687156, + "last_built_rows": 890059, + "last_built_row_groups": 9, + "parquet_bytes": 95699411, + "vortex_bytes": 155627324, + "parquet_sha256": "0203b9678f731279507b2bf06047331081617779c4a496cb98ffe0f166d8842d", + "vortex_sha256": "4f7646e656293d31a83278a76ae17e36ae0a3d4ae242b8477945faf319e1057b", "columns": [ { "name": "id", "type": "int64", - "length": 2775802, + "length": 2776912, "null_count": 0, "min": 882, - "max": 20620429 + "max": 20631777 }, { "name": "version", "type": "int32", - "length": 494032, + "length": 488930, "null_count": 0, "min": 1, "max": 3154 @@ -2522,15 +2531,15 @@ { "name": "timestamp", "type": "string", - "length": 4659107, + "length": 4663690, "null_count": 0, "min": "2007-10-17T19:07:38+00:00", - "max": "2026-05-03T20:14:56+00:00" + "max": "2026-05-05T20:18:32+00:00" }, { "name": "members", "type": "list>", - "length": 65420639, + "length": 65466557, "null_count": null, "min": null, "max": null @@ -2538,12 +2547,21 @@ { "name": "tags", "type": "list>", - "length": 22281959, + "length": 22292153, "null_count": null, "min": null, "max": null } - ] + ], + "size_bucket": "s", + "shape_traits": { + "has_nested": true, + "has_timestamp": false, + "has_variant": false, + "string_heavy": false, + "wide_row": false, + "high_cardinality_present": null + } }, "hacker-news": { "expected_rows": null, @@ -3585,6 +3603,8 @@ "last_built_row_groups": 8, "parquet_bytes": 150924485, "vortex_bytes": 134835552, + "parquet_sha256": "2c39fae00b1dee42909f942299c0ddcfd35b5c3aca428a953e3fb6c15641a8d6", + "vortex_sha256": "873bae0250a5c1a86c4cd6bd6503d8580ca6ad21fee7bbc6515407a656a2fa26", "columns": [ { "name": "word", @@ -30061,6 +30081,8 @@ "last_built_row_groups": 1, "parquet_bytes": 2732, "vortex_bytes": 10056, + "parquet_sha256": "72ed0dd70848acef0c2c94ad58385144a50f21484fa012c18b15dfa53ed25538", + "vortex_sha256": "4d3e683f6d911a2d3876e28cda03e22d029b051427ef3cfd65e3e367de4d5300", "columns": [ { "name": "sepal_length", @@ -30239,6 +30261,8 @@ "last_built_row_groups": 1, "parquet_bytes": 82836, "vortex_bytes": 112248, + "parquet_sha256": "74b2cce0f54f063391e4d3812bc11afa86bf4f8bdd1885dce327f49ab8d27ded", + "vortex_sha256": "289df4c7c09e02ac190a4d23187f4bbbbfdcdde2cc562de0b444fd9cd6c35f7f", "columns": [ { "name": "fixed_acidity", @@ -31169,6 +31193,8 @@ "last_built_row_groups": 1, "parquet_bytes": 3085946, "vortex_bytes": 3470324, + "parquet_sha256": "c0d99b2907203d9d4e495967ff2555b9346118013df67a8c44484269237e9dcc", + "vortex_sha256": "1dc992e33ecba1a8b7c0b3b7282a8baa37bc1c6e8069760c9f1b3c9c0488ab4a", "columns": [ { "name": "invoiceno", @@ -32075,6 +32101,8 @@ "last_built_row_groups": 1, "parquet_bytes": 90533, "vortex_bytes": 131396, + "parquet_sha256": "0cc25809dfa9aee37a9e0703f1951568287f6d321994965417c25ccf6a7aaab9", + "vortex_sha256": "ead9e91500dfd44271d9f2119b76ce6004a4ebb7ce2bd281b496798eea94c0a4", "columns": [ { "name": "patient_id", @@ -37469,6 +37497,8 @@ "last_built_row_groups": 1, "parquet_bytes": 195535, "vortex_bytes": 158968, + "parquet_sha256": "151fd25027af4d233c47b90e60923f9ad824f50976a28a835f190ead0ffc015e", + "vortex_sha256": "5ef0007940e02b7ddce829a32094bc3198219c151242e1466e94f8bf40a4f4d5", "columns": [ { "name": "instant", @@ -38228,8 +38258,11 @@ "uci-spambase": { "expected_rows": 4601, "last_built_rows": 4601, + "last_built_row_groups": 1, "parquet_bytes": 169587, "vortex_bytes": 377572, + "parquet_sha256": "0e2e06c5444e8db618fd6fd26048733c592982c0557224497c5fe7f6e0819005", + "vortex_sha256": "f56e3bf6c7b8e87969400bd7d2056a830df1a492d5a74e2c8deb2cd7dfd17290", "columns": [ { "name": "word_freq_make", @@ -38695,7 +38728,16 @@ "min": 0, "max": 1 } - ] + ], + "size_bucket": "xs", + "shape_traits": { + "has_nested": false, + "has_timestamp": false, + "has_variant": false, + "string_heavy": false, + "wide_row": true, + "high_cardinality_present": null + } }, "uci-magic-gamma-telescope": { "expected_rows": 19020, @@ -41231,6 +41273,8 @@ "last_built_row_groups": 1, "parquet_bytes": 10725, "vortex_bytes": 20992, + "parquet_sha256": "59639068c918f2f3f46532b3b2ee47605d1d3a59580cbb3872259f1c5becd886", + "vortex_sha256": "ca0daddc46e7db5934e7655b42fa5be57eed3b22b9a7629ca53d25b9bf1c9ad0", "columns": [ { "name": "col_0", @@ -48369,6 +48413,8 @@ "last_built_row_groups": 2, "parquet_bytes": 62325617, "vortex_bytes": 53679308, + "parquet_sha256": "7db14cf55edb24cc4fd4befaa07b0f5e767e3bc9d0724d435e83dd928583fd7d", + "vortex_sha256": "10a8f8953cc87f36b0156ec06fe03b657c6340f6ecab2a617e7a313e4a45bd20", "columns": [ { "name": "Organization Group Code", @@ -52699,6 +52745,8 @@ "last_built_row_groups": 1, "parquet_bytes": 4637420, "vortex_bytes": 5825840, + "parquet_sha256": "c39dfd34997d7ebeea5ec7389d2f22e28eadb143c43bc06ab81a2410433d3bb1", + "vortex_sha256": "aa8953e69d38d2765f4d2c62af08aaa7336e9c6bb5eefc65bd949f223656b080", "columns": [ { "name": "ID", @@ -102741,6 +102789,8 @@ "last_built_row_groups": 1, "parquet_bytes": 930532, "vortex_bytes": 1577848, + "parquet_sha256": "402ea0920378fa61637c8e4047cbb2461d418ac26e833002ac58c3eef8eb0ac9", + "vortex_sha256": "01e1aa77bac5296c7cf72693024f3323d56756bdcaf7415c0083ebd0a0bc7efa", "columns": [ { "name": "hotel", @@ -103335,6 +103385,8 @@ "last_built_row_groups": 1, "parquet_bytes": 3322173, "vortex_bytes": 3315556, + "parquet_sha256": "a9bf0bddc568e719ab789f341cc6805d79eaddb2ea779bdef593ab9fbc1f4d5d", + "vortex_sha256": "9a57a490f584e08dd8f943fc8e31f0907c6a81d14807770bc9c96800ed19e8d9", "columns": [ { "name": "kepid", @@ -112465,6 +112517,8 @@ "last_built_row_groups": 1, "parquet_bytes": 29295473, "vortex_bytes": 90073420, + "parquet_sha256": "a079040cebf707cc6ae94cb36880ecf5562350e2c4fda5e9b94e6b61613a1708", + "vortex_sha256": "c4fe5dd81cd9a10f171942c8c2cd0c4f0582b6108d525ae23b223fd318c3f849", "columns": [ { "name": "answer", @@ -112723,6 +112777,8 @@ "last_built_row_groups": 1, "parquet_bytes": 1106214411, "vortex_bytes": 1652406076, + "parquet_sha256": "2f917f7469b6f43e28d5680b72ed84f43a7b3e06927e7553b95eac7099767a3c", + "vortex_sha256": "f0007332c751b9a9bf502bed47fb75acd02c1354cc2d65d14b140ef6d5f303b0", "columns": [ { "name": "prompt", @@ -113533,6 +113589,8 @@ "last_built_row_groups": 7, "parquet_bytes": 8130454405, "vortex_bytes": 11784725216, + "parquet_sha256": "4f46f3efd2aae547a4ad19a35be0c0801bf74cbc67ae1cd19965a12681b44664", + "vortex_sha256": "a8e3b94b68a33fe0c0b67335ea47289592cf780dea66b319dd76c8153a0c124b", "columns": [ { "name": "id", @@ -113591,6 +113649,8 @@ "last_built_row_groups": 3, "parquet_bytes": 664283863, "vortex_bytes": 962524448, + "parquet_sha256": "4edd26c9198483aab39e58c14f4f7f86285151c417423bbf6de5dc80c4a6e033", + "vortex_sha256": "6fa4dc3b21e27e8f4c0229607aa0f07e8cde62c0e6dfbbba044b87202734d142", "columns": [ { "name": "text", @@ -113625,6 +113685,8 @@ "last_built_row_groups": 4, "parquet_bytes": 4806167375, "vortex_bytes": 6089862740, + "parquet_sha256": "a0c3ab9b0b050b3e882711fcea7f705e567ffc3f9a9c16aad8dc8845ea76d6c7", + "vortex_sha256": "636cdea4268af7f5b6e1c2c4ef423326151dd1903546b182322afea164584bd9", "columns": [ { "name": "text", @@ -113739,6 +113801,8 @@ "last_built_row_groups": 1, "parquet_bytes": 7294437, "vortex_bytes": 13304260, + "parquet_sha256": "b54e8f4ab980f80ebe6be48356a6dd904dadfc10e4d35dde8a14e3f800dc2792", + "vortex_sha256": "96043c7527071d73a7d88621fa00fbd4d3cd92d6e014a78e8a8d3d0c2254af09", "columns": [ { "name": "text", @@ -113949,6 +114013,8 @@ "last_built_row_groups": 1, "parquet_bytes": 2121169743, "vortex_bytes": 3268285484, + "parquet_sha256": "52428549cbc520a0a12cb475d2582f6999ac271dc3b5a9d0b3d1773c0f2be9e9", + "vortex_sha256": "f9493ed6a760c4cb304b7229c3ffc2b842cdd0dc1712d6de72601eecbd34a1f7", "columns": [ { "name": "text_token_length", @@ -114023,6 +114089,8 @@ "last_built_row_groups": 1, "parquet_bytes": 22934195, "vortex_bytes": 38332972, + "parquet_sha256": "3b9023a42c3aed1d8e5e4f3de8c8bff433dbb667be8a687a6e4522d21928a804", + "vortex_sha256": "b7b3b0156c0c87baf1776951057d4c0d6d541acb60af088d2df4b01cedbea486", "columns": [ { "name": "id", @@ -114137,6 +114205,8 @@ "last_built_row_groups": 1, "parquet_bytes": 1520738958, "vortex_bytes": 1943240616, + "parquet_sha256": "e0fa14e94a3a8b13b189e18fd2309fa2cc3a3f1bada5f5a9cc37496f46571a29", + "vortex_sha256": "4f24e6f06b90b8c4cbbb7ebe891273c253d28a554aa7e4c201b5dd144d19e010", "columns": [ { "name": "id", @@ -114487,6 +114557,8 @@ "last_built_row_groups": 1, "parquet_bytes": 633824764, "vortex_bytes": 645264768, + "parquet_sha256": "e8f14b8ce7e80b6c2f2de9c7c06676d90901b89b06b8afb7af1e40a3aa8831e7", + "vortex_sha256": "9e007f67c6043d028640cdf2e3c6091f16218dd9760163fb93f72fc79adad849", "columns": [ { "name": "images", @@ -114529,6 +114601,8 @@ "last_built_row_groups": 1, "parquet_bytes": 60551, "vortex_bytes": 103864, + "parquet_sha256": "bd65bb1e42c0f3eea4f5e41629271abc4f42b37d68b8cff96a6d032b5329bd11", + "vortex_sha256": "b6adfd55352022ec7c462182e96d8ac9c8a997b46705b75e0ac0a5a7fcaa86c7", "columns": [ { "name": "task_id", @@ -114595,6 +114669,8 @@ "last_built_row_groups": 1, "parquet_bytes": 187862, "vortex_bytes": 331456, + "parquet_sha256": "cc02b069245fbade0263629f7a23b16f199643a756127eb94ef9ae2cc52537ff", + "vortex_sha256": "9b41c6bd7c9c846392c18745d0ead6bd60cbb17c3b551455f14dd7466a4126fd", "columns": [ { "name": "question", @@ -114645,6 +114721,8 @@ "last_built_row_groups": 1, "parquet_bytes": 3633313707, "vortex_bytes": 3675267604, + "parquet_sha256": "a72bafdbec981a21cf5b850f561db97b3902c5a28cf9119e6b8b3411f7c3359f", + "vortex_sha256": "b7ea53a0d1e5566d946f26bf7d2af198d5a5965a7440f4464e1cbdbbccdcc42a", "columns": [ { "name": "id", @@ -114799,6 +114877,8 @@ "last_built_row_groups": 1, "parquet_bytes": 793523, "vortex_bytes": 1103536, + "parquet_sha256": "85acd4636fd9468718e66f795bb33f32d16709dee50ab9105c2f286efd1e04e6", + "vortex_sha256": "e56c0c71829bb4e2b42edab83c1868e632f3f57979c6841f4cfab2ff9a61b4b3", "columns": [ { "name": "id", @@ -114857,6 +114937,8 @@ "last_built_row_groups": 1, "parquet_bytes": 751960, "vortex_bytes": 1055580, + "parquet_sha256": "55ae2ffe9b74516d7668efb73dd5e84e8c046bcf68233b9f320d49ed2d5e6a85", + "vortex_sha256": "6135bc9f6e6f4424b21c479138fb82391800598afdab284909c2ea01f5cb208b", "columns": [ { "name": "pubid", @@ -114923,6 +115005,8 @@ "last_built_row_groups": 1, "parquet_bytes": 56312765, "vortex_bytes": 74216104, + "parquet_sha256": "2f99c4dab111c4c8b7d0a9715ea68df56fcc3ac7c0fe930d65426791d6f8dbb8", + "vortex_sha256": "375462e51d41963e0e7d9536987ff3e3ac6b166579af276fe47485249b2a7590", "columns": [ { "name": "id", @@ -115037,6 +115121,8 @@ "last_built_row_groups": 1, "parquet_bytes": 275730042, "vortex_bytes": 359519012, + "parquet_sha256": "b6756a27e19367d4f37adbba86cf9ee4572730445eaec637674687b2c98cf1d1", + "vortex_sha256": "8ee29f0c19bf44e33f866961444cf5337efeff3860930cb5a930cceafd611f19", "columns": [ { "name": "id", @@ -115119,6 +115205,8 @@ "last_built_row_groups": 1, "parquet_bytes": 569684836, "vortex_bytes": 748493424, + "parquet_sha256": "492009418c2afbd12fd18596f3ac32ede9f4455dcb6912cbacb01e2fce5654d1", + "vortex_sha256": "99b800dbd9448a3df1a7a2efa063725ba742c0c824f0460ff703154a6010b6d6", "columns": [ { "name": "article", @@ -115169,6 +115257,8 @@ "last_built_row_groups": 1, "parquet_bytes": 15873453, "vortex_bytes": 20423644, + "parquet_sha256": "48533689b58e91740da03c67114473d38c61d1c31a8f47d12514b24f5857cec4", + "vortex_sha256": "00955c30842af9d7890fe52364e9b16b05fb3e21fd6d7c8347f08d06ac066fa6", "columns": [ { "name": "image", @@ -115211,6 +115301,8 @@ "last_built_row_groups": 1, "parquet_bytes": 152938, "vortex_bytes": 242304, + "parquet_sha256": "e8f476fc08222703d6f77da35178c1888b06e0a3f814c161293688fb8b16d9b2", + "vortex_sha256": "d8d664d93a91691aa02262069a3109c17f44f5abe103acb695ebda8796747528", "columns": [ { "name": "task_id", @@ -115285,6 +115377,8 @@ "last_built_row_groups": 1, "parquet_bytes": 4307899453, "vortex_bytes": null, + "parquet_sha256": "029e8c97e41a971d5d6bc91dd1dde7c934a7f9abfdbc05842b25b0a84885526d", + "vortex_sha256": null, "columns": [ { "name": "name", @@ -115471,6 +115565,8 @@ "last_built_row_groups": 1, "parquet_bytes": 346051024, "vortex_bytes": 367513008, + "parquet_sha256": "9902c1b94252b424dc361bf5fd7354e8ebff610ea53d22150f92b8cbbbf66386", + "vortex_sha256": "ed724fd452bca8579d2bb70fb7f11697881f2a6d9e9533d67bfa07cd4db9da77", "columns": [ { "name": "file", @@ -115545,6 +115641,8 @@ "last_built_row_groups": 1, "parquet_bytes": 2306373926, "vortex_bytes": 2458934716, + "parquet_sha256": "19fd17c4ab293450bd635e0a7b14237edf19fb5d4fdf99f68d73b16db3c78429", + "vortex_sha256": "dfe64038a4f814d13fd3aa94a4f4f2bceae7d420d147390b8e5652a150de942d", "columns": [ { "name": "id", @@ -115603,6 +115701,8 @@ "last_built_row_groups": 1, "parquet_bytes": 562911303, "vortex_bytes": 571225152, + "parquet_sha256": "eae8a9cb5a57711580c0e374bd14fe901aaa5c903cd5ce29dbdf078cef32b20f", + "vortex_sha256": "76a96582a6ddcf66c84f9a36f14b838276eaf3cc532fcd7e3d1f8d1481f35eb6", "columns": [ { "name": "id", @@ -115677,6 +115777,8 @@ "last_built_row_groups": 1, "parquet_bytes": 165968, "vortex_bytes": 256980, + "parquet_sha256": "941578c6cc5e69739cfb8db43ce4055902b949927aa4b28dc91c34c2b5028e1c", + "vortex_sha256": "1e1c3d4f4a345eb859d1ed978d8be34696e8780f85fdac1fb5cb06e290615281", "columns": [ { "name": "Unnamed: 0", @@ -115831,6 +115933,8 @@ "last_built_row_groups": 1, "parquet_bytes": 265872790, "vortex_bytes": 429778304, + "parquet_sha256": "63bd6e330c8a356a026a08abf8a1b6c74c7683114b0b5ac21f0699fe8f716955", + "vortex_sha256": "4604c8573de84acb62eb61d056de9d57841a5fa7eb3235c526b1f4921a2b83e5", "columns": [ { "name": "video_id", @@ -115961,6 +116065,8 @@ "last_built_row_groups": 1, "parquet_bytes": 1641372431, "vortex_bytes": 2257731372, + "parquet_sha256": "c8250d75e6f5fab02cb576111aae982b46eb85d8691bb4735648881170b0b05c", + "vortex_sha256": "271bba9a5c2c8c06cc60e87f4408dd33063faa220aa69899ac59a7e3e989afc4", "columns": [ { "name": "synth_id", @@ -116099,6 +116205,8 @@ "last_built_row_groups": 1, "parquet_bytes": 13207283, "vortex_bytes": 21735488, + "parquet_sha256": "eef8dd021b7ec65fe5ae5dfaa7359746a25085b32db507d3c6f6b935d1fc533b", + "vortex_sha256": "0ed4dbc889df05c9f0e28256ffbc339188c6a1679c98c56db67cc716a92ebd45", "columns": [ { "name": "prompt", @@ -116181,6 +116289,8 @@ "last_built_row_groups": 1, "parquet_bytes": 3190047, "vortex_bytes": 5669032, + "parquet_sha256": "63ce777948587a5b82a98b80cccc448bfaf4a44d4d285a0b5f38abbbb91c7c54", + "vortex_sha256": "b5c2d7bf860455a435c69bc551f4bf079e3d2289baa3c031baf7df259aeeb20e", "columns": [ { "name": "transcript_id", @@ -116223,6 +116333,8 @@ "last_built_row_groups": 1, "parquet_bytes": 104135565, "vortex_bytes": 170067388, + "parquet_sha256": "39ce8864e3b1d19de6976da4a19133ddd27261071e8522ada64e8e0db2ff1a88", + "vortex_sha256": "b534a6f2db918cb994ad8362808a25ad9c2afa6b5d4304fe449a8df4f29ba8fb", "columns": [ { "name": "inputs", @@ -116297,6 +116409,8 @@ "last_built_row_groups": 1, "parquet_bytes": 103944240, "vortex_bytes": 168069836, + "parquet_sha256": "9b42772dc63b9508f5e63334d9d5253455bf9dc50c89240b50e31cd4fabaa079", + "vortex_sha256": "c513d9f234b94f69b1b9a11a6b4aa331cc732e9db7612fb4054701f0553232f0", "columns": [ { "name": "id", @@ -116403,6 +116517,8 @@ "last_built_row_groups": 1, "parquet_bytes": 58178780, "vortex_bytes": 106617292, + "parquet_sha256": "35516084f5f51199e1459eb1ffba0e8d9dcdbb64b2dd9e2248a85dd276adf941", + "vortex_sha256": "75cd4bcc086c0643b9edd0420c0f3bdd64ef4635d5183bbd6ce35fabb5af8dd3", "columns": [ { "name": "instruction", @@ -116445,6 +116561,8 @@ "last_built_row_groups": 2, "parquet_bytes": 19485316, "vortex_bytes": 21301348, + "parquet_sha256": "63402d6ee1adba5117d54549d776ba566528a247b8fa335aa2598ed1de195659", + "vortex_sha256": "5e4c2c6cb932eaae0bd40b4af2d21459d638f5623b5682cf0ce3cbe363fb7698", "columns": [ { "name": "unix_ts", @@ -116559,6 +116677,8 @@ "last_built_row_groups": 1, "parquet_bytes": 263543, "vortex_bytes": 426464, + "parquet_sha256": "fd894bd09380724d679cb64a8e713891d233d50cca2522216323ae91058d9d32", + "vortex_sha256": "33da5a1918a80a5e896702796f14e48f6d8449ada6978527cb4fe633b2549a18", "columns": [ { "name": "f0", @@ -116625,6 +116745,8 @@ "last_built_row_groups": 1, "parquet_bytes": 5624277, "vortex_bytes": 7621292, + "parquet_sha256": "12da59ce3b1895a968f8d2e708246fa1df210ced3e6986395e91a0ab107d1dde", + "vortex_sha256": "7e19e46cf134805c3e376d2ebffb8cac5a85d2d392a0f3a440a00d2b2daa96be", "columns": [ { "name": "No", @@ -116787,6 +116909,8 @@ "last_built_row_groups": 1, "parquet_bytes": 3747845, "vortex_bytes": 6080116, + "parquet_sha256": "e54895929c1f8e54f31a59d061f476aa2f2cfd28da1928af9b3e34c6501271c1", + "vortex_sha256": "d39b7c358464a299cc43d83650d4361fe532fae635f56274729403ab687709ec", "columns": [ { "name": "geo_id", @@ -116885,6 +117009,8 @@ "last_built_row_groups": 1, "parquet_bytes": 28713325, "vortex_bytes": 41394396, + "parquet_sha256": "4a0b6e364f0271940bb6c4182602890491b41516c87cc0e4e043905b196bee90", + "vortex_sha256": "a0abe7c51aefd60ef7f93db053ddc07eb86f79b2b6e0c8fd421b14113174cc89", "columns": [ { "name": "answer_choices", diff --git a/examples/README.md b/examples/README.md index 478327b..e0a23ac 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,12 +1,27 @@ # examples -Copy-pasteable templates for the two most common tasks against this repo. +Runnable, single-file demos of the `raincloud.load(slug)` datasets-like API — +load a real catalog dataset and run an actual query. (For *authoring* templates — +new manifest entries and streaming handlers — see [`../templates/`](../templates/).) -| File | Purpose | -|---|---| -| [`minimal_spec.json`](minimal_spec.json) | Full `DatasetSpec` with every field present and placeholder values. Validates against [`../sources.schema.json`](../sources.schema.json). Use as the starting point for a new entry in `sources.json`. | -| [`streaming_handler.py.tmpl`](streaming_handler.py.tmpl) | Template for a streaming transform handler — the pattern for datasets that don't fit in RAM. Copy into `scripts/pipeline/handlers/`, fill in the ingest loop, register in `scripts/pipeline/handlers/__init__.py`. | +| File | What it does | Engine | Cost on first run | +|---|---|---|---| +| [`use_loader.py`](use_loader.py) | API basics: metadata, format override, `.to_arrow` / `.scan` / `.to_pandas`, env vars, the typed exception hierarchy. | — | none (metadata is network-free; `--materialize` resolves one artifact) | +| [`nyc_taxi_tip_rate.py`](nyc_taxi_tip_rate.py) | Of "probably-valid" 2025 yellow-cab trips, what % left no recorded tip? Broken down by `payment_type` to expose that the TLC only records *card* tips. | DuckDB over `.scan()` | ~900 MB (48.7M rows, 12 monthly parquets) | +| [`kepler_exoplanets.py`](kepler_exoplanets.py) | How many Kepler candidates are CONFIRMED vs FALSE POSITIVE, and what's the smallest confirmed planet? | pandas | ~3 MB, seconds | +| [`wine_quality_correlations.py`](wine_quality_correlations.py) | Which physicochemical features correlate with a wine's quality score? | pandas `.corr()` | ~80 KB, instant | +| [`olympic_medals.py`](olympic_medals.py) | Top medal-winning nations and medals per decade across 120 years of the Games. | DuckDB over `.scan()` | ~5 MB, seconds | -These mirror the inline templates in [`../SKILLS.md`](../SKILLS.md) but live as real files so `git grep`, IDE schema validation, and copy-paste-without-markdown-quirks all work. +## Running them -After dropping a new entry into `sources.json`, run [`/raincloud-validate-manifest`](../.agents/skills/raincloud-validate-manifest/SKILL.md) to confirm the shape is correct before paying for a fetch. +```bash +pip install "raincloud[build,duckdb,pandas] @ git+https://github.com/spiraldb/raincloud" +python examples/kepler_exoplanets.py +``` + +There is **no public Raincloud mirror**, so the first run of an example +fetches the upstream data and builds the artifact locally (this is what the +`[build]` extra is for); subsequent runs hit the local cache and are fast. If +your team runs a private mirror, set `RAINCLOUD_MIRROR=s3://bucket/prefix` (or +`file:///path`) and the examples pull from it instead of building. `[duckdb]` +backs `.scan()`, `[pandas]` backs `.to_pandas()`. diff --git a/examples/kepler_exoplanets.py b/examples/kepler_exoplanets.py new file mode 100644 index 0000000..3a0927f --- /dev/null +++ b/examples/kepler_exoplanets.py @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: 2026 Raincloud Maintainers +# SPDX-License-Identifier: Apache-2.0 +"""How many Kepler candidates panned out — and what's the smallest one? + +Loads the Kepler Objects of Interest table (`kepler-exoplanet-search-results`, +~9.5k rows) into a pandas DataFrame and does two quick passes: the +CONFIRMED / CANDIDATE / FALSE POSITIVE disposition breakdown, and the +smallest-radius CONFIRMED planet. Small enough that pandas is the right tool — +no SQL engine needed. + +Run it: + + python examples/kepler_exoplanets.py + +Install (raincloud is not on PyPI — install from GitHub): + + pip install "raincloud[build,pandas] @ git+https://github.com/spiraldb/raincloud" # build: first-run fetch; pandas: .to_pandas() + +First run fetches ~3 MB from upstream (or a configured RAINCLOUD_MIRROR) and is +cached; it runs in seconds thereafter. +""" +from __future__ import annotations + +import sys + +import raincloud + +SLUG = "kepler-exoplanet-search-results" + + +def main(argv: list[str] | None = None) -> int: + try: + df = raincloud.load(SLUG).to_pandas() + except raincloud.MissingDependency as e: + print(f"this example needs pandas: {e}\n" + ' pip install "raincloud[pandas] @ git+https://github.com/spiraldb/raincloud"') + return 1 + except raincloud.RaincloudError as e: + print(f"could not load {SLUG}: {type(e).__name__}: {e}") + print(' hint: pip install "raincloud[build] @ git+https://github.com/spiraldb/raincloud" ' + "(first run fetches ~3 MB) or set RAINCLOUD_MIRROR=") + return 1 + + import pandas as pd + + print(f"Kepler Objects of Interest: {len(df):,}\n") + print("disposition breakdown:") + for disp, n in df["koi_disposition"].value_counts().items(): + print(f" {disp:<15}{n:>7,} ({100 * n / len(df):.1f}%)") + + confirmed = df[df["koi_disposition"] == "CONFIRMED"].dropna(subset=["koi_prad"]) + smallest = confirmed.nsmallest(1, "koi_prad").iloc[0] + name = smallest["kepler_name"] + if pd.isna(name): + name = smallest["kepoi_name"] + print("\nsmallest CONFIRMED planet by radius:") + print(f" {name}: {smallest['koi_prad']:.2f} Earth radii, " + f"{smallest['koi_period']:.2f}-day orbit") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + + +# --- Sample output (captured 2026-05-29 against live upstream; exact numbers +# drift as the archive is revised) --- +# +# Kepler Objects of Interest: 9,564 +# +# disposition breakdown: +# FALSE POSITIVE 4,839 (50.6%) +# CONFIRMED 2,747 (28.7%) +# CANDIDATE 1,978 (20.7%) +# +# smallest CONFIRMED planet by radius: +# Kepler-37 b: 0.27 Earth radii, 13.37-day orbit diff --git a/examples/nyc_taxi_tip_rate.py b/examples/nyc_taxi_tip_rate.py new file mode 100644 index 0000000..bfcf91a --- /dev/null +++ b/examples/nyc_taxi_tip_rate.py @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: 2026 Raincloud Maintainers +# SPDX-License-Identifier: Apache-2.0 +"""How many NYC yellow-cab riders left no tip in 2025? + +Loads the full 2025 yellow-taxi trip record (`yellow_tripdata_2025`, +~48.7M rows) and asks: of the *probably-valid* trips — those whose metered +fare cleared the city's minimum (the flag-drop initial charge) — what share +left no recorded tip? The answer is then broken down by `payment_type`, which +reveals the catch: the TLC only records tips paid by **card**, so cash trips +always show a $0 tip. The headline "no tip" rate is really a "paid cash" rate. + +This is the OLAP-on-a-big-dataset showcase: the query runs in DuckDB directly +over the parquet via `Dataset.scan()`, so nothing materializes 48M rows into +memory — only the small grouped result comes back as a DataFrame. + +Run it: + + python examples/nyc_taxi_tip_rate.py + +Install (raincloud is not on PyPI — install from GitHub): + + pip install "raincloud[build,duckdb] @ git+https://github.com/spiraldb/raincloud" # build: first-run fetch; duckdb: .scan() + +First run downloads ~900 MB (12 monthly parquet files, merged) unless a mirror +is configured via RAINCLOUD_MIRROR; subsequent runs hit the local cache. +""" +from __future__ import annotations + +import sys + +import raincloud + +SLUG = "yellow_tripdata_2025" + +# NYC yellow-cab initial charge ("flag drop") for 2025 — the smallest metered +# fare a real trip can record. Filtering above it drops $0 / negative / voided +# rows. See https://www.nyc.gov/site/tlc/passengers/taxi-fare.page +BASE_FARE_2025 = 3.00 + +# TLC trip-record `payment_type` codes (data dictionary). +PAYMENT_TYPES = {1: "Credit card", 2: "Cash", 3: "No charge", + 4: "Dispute", 5: "Unknown", 6: "Voided trip"} + +QUERY = f""" + SELECT + payment_type, + count(*) AS trips, + count(*) FILTER (WHERE tip_amount <= 0) AS no_tip + FROM taxi + WHERE fare_amount > {BASE_FARE_2025} + GROUP BY payment_type + ORDER BY trips DESC +""" + + +def main(argv: list[str] | None = None) -> int: + try: + # Load as parquet (not the vortex default): DuckDB reads parquet, and a + # vortex handle's .scan() would resolve the parquet sibling anyway. + rel = raincloud.load(SLUG, format="parquet").scan() + except raincloud.MissingDependency as e: + print(f"this example needs DuckDB: {e}\n" + ' pip install "raincloud[duckdb] @ git+https://github.com/spiraldb/raincloud"') + return 1 + except raincloud.RaincloudError as e: + print(f"could not load {SLUG}: {type(e).__name__}: {e}") + print(' hint: pip install "raincloud[build] @ git+https://github.com/spiraldb/raincloud" ' + "(first run fetches ~900 MB) or set RAINCLOUD_MIRROR=") + return 1 + + df = rel.query("taxi", QUERY).df() + total = int(df["trips"].sum()) + no_tip = int(df["no_tip"].sum()) + + print(f"NYC yellow cab 2025 — valid trips (fare > ${BASE_FARE_2025:.2f}): {total:,}") + print(f" left no recorded tip: {no_tip:,} ({100 * no_tip / total:.1f}%)\n") + print(f" {'payment type':<14}{'trips':>14}{'no tip':>14}{'% no tip':>10}") + for r in df.itertuples(): + label = PAYMENT_TYPES.get(int(r.payment_type), f"code {r.payment_type}") + pct = 100 * r.no_tip / r.trips if r.trips else 0.0 + print(f" {label:<14}{r.trips:>14,}{r.no_tip:>14,}{pct:>9.1f}%") + + print("\nThe TLC records tips only for card payments, so cash trips always show") + print("a $0 tip — most of the headline 'no tip' rate is really 'paid cash'.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + + +# --- Sample output (captured 2026-05-29 against live upstream; exact numbers +# drift as the TLC revises the year's data) --- +# +# NYC yellow cab 2025 — valid trips (fare > $3.00): 45,387,796 +# left no recorded tip: 15,836,419 (34.9%) +# +# payment type trips no tip % no tip +# Credit card 31,025,313 2,282,009 7.4% +# code 0 9,280,676 8,473,528 91.3% # an unmapped code that +# Cash 4,380,579 4,380,337 100.0% # appeared in 2025 data; +# Dispute 534,317 533,846 99.9% # the fallback labels it +# No charge 166,910 166,698 99.9% # gracefully rather than +# Unknown 1 1 100.0% # guessing its meaning +# +# The TLC records tips only for card payments, so cash trips always show +# a $0 tip — most of the headline 'no tip' rate is really 'paid cash'. diff --git a/examples/olympic_medals.py b/examples/olympic_medals.py new file mode 100644 index 0000000..f0e5c94 --- /dev/null +++ b/examples/olympic_medals.py @@ -0,0 +1,109 @@ +# SPDX-FileCopyrightText: 2026 Raincloud Maintainers +# SPDX-License-Identifier: Apache-2.0 +"""Who wins the most Olympic medals, and how has the haul grown over time? + +Loads 120 years of Olympic athlete results +(`120-years-of-olympic-history-athletes-and-results`, ~271k athlete-event rows) +and runs two DuckDB group-bys over `Dataset.scan()`: the top medal-winning +national committees (NOC), and medals awarded per decade. Each row is one +athlete in one event, so a medal row is a medal won (team events count once +per athlete — a known quirk of this dataset). + +Run it: + + python examples/olympic_medals.py + +Install (raincloud is not on PyPI — install from GitHub): + + pip install "raincloud[build,duckdb] @ git+https://github.com/spiraldb/raincloud" # build: first-run fetch; duckdb: .scan() + +First run fetches ~5 MB from upstream (or a configured RAINCLOUD_MIRROR), then +it's cached. +""" +from __future__ import annotations + +import sys + +import raincloud + +SLUG = "120-years-of-olympic-history-athletes-and-results" + +TOP_NOCS = """ + SELECT NOC, count(*) AS medals + FROM games + WHERE Medal IS NOT NULL + GROUP BY NOC + ORDER BY medals DESC + LIMIT 10 +""" + +PER_DECADE = """ + SELECT (Year // 10) * 10 AS decade, count(*) AS medals + FROM games + WHERE Medal IS NOT NULL + GROUP BY decade + ORDER BY decade +""" + + +def main(argv: list[str] | None = None) -> int: + try: + rel = raincloud.load(SLUG, format="parquet").scan() + except raincloud.MissingDependency as e: + print(f"this example needs DuckDB: {e}\n" + ' pip install "raincloud[duckdb] @ git+https://github.com/spiraldb/raincloud"') + return 1 + except raincloud.RaincloudError as e: + print(f"could not load {SLUG}: {type(e).__name__}: {e}") + print(' hint: pip install "raincloud[build] @ git+https://github.com/spiraldb/raincloud" ' + "(first run fetches ~5 MB) or set RAINCLOUD_MIRROR=") + return 1 + + top = rel.query("games", TOP_NOCS).df() + decades = rel.query("games", PER_DECADE).df() + + print("top 10 medal-winning national committees (1896-2016):") + for r in top.itertuples(): + print(f" {r.NOC:<5}{r.medals:>8,}") + + print("\nmedals awarded per decade:") + peak = decades["medals"].max() + for r in decades.itertuples(): + bar = "#" * round(40 * r.medals / peak) + print(f" {int(r.decade)}s {r.medals:>6,} {bar}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + + +# --- Sample output (captured 2026-05-29 against live upstream; exact numbers +# drift as the dataset is revised) --- +# +# top 10 medal-winning national committees (1896-2016): +# USA 5,637 +# URS 2,503 +# GER 2,165 +# GBR 2,068 +# FRA 1,777 +# ITA 1,637 +# SWE 1,536 +# CAN 1,352 +# AUS 1,320 +# RUS 1,165 +# +# medals awarded per decade: +# 1890s 143 # +# 1900s 2,379 ############# +# 1910s 941 ##### +# 1920s 3,093 ################## +# 1930s 1,764 ########## +# 1940s 987 ###### +# 1950s 2,076 ############ +# 1960s 3,529 #################### +# 1970s 2,945 ################# +# 1980s 5,145 ############################# +# 1990s 4,643 ########################## +# 2000s 7,057 ######################################## +# 2010s 5,081 ############################# diff --git a/examples/use_loader.py b/examples/use_loader.py new file mode 100644 index 0000000..6f84e9c --- /dev/null +++ b/examples/use_loader.py @@ -0,0 +1,159 @@ +# SPDX-FileCopyrightText: 2026 Raincloud Maintainers +# SPDX-License-Identifier: Apache-2.0 +"""Worked example: the Raincloud loader API. + +Demonstrates the `raincloud.load(slug)` flow end-to-end: + + * cheap, network-free metadata access (rows, columns, license, source URL) + * format selection: vortex (default) vs parquet + * materialization: .to_arrow(), .to_vortex(), .scan() (DuckDB), + .to_pandas() + * configuration via env vars: RAINCLOUD_CACHE, RAINCLOUD_MIRROR, + RAINCLOUD_OFFLINE, RAINCLOUD_STRICT_CHECKSUM (hard integrity gate) + * the typed exception hierarchy (UnknownSlug, FormatUnavailable, + OfflineMiss, BuildToolingMissing, ChecksumMismatch, MissingDependency) + +Run it: + + # Metadata-only demo against the packaged catalog (no network): + python examples/use_loader.py + + # Pick a specific slug (any slug name in the catalog): + python examples/use_loader.py --slug clickbench-hits + + # Full materialization demo (needs RAINCLOUD_MIRROR or local cache hit): + RAINCLOUD_MIRROR=file:///path/to/mirror \\ + python examples/use_loader.py --materialize + +Install (raincloud is not on PyPI — install from GitHub): + + pip install "raincloud @ git+https://github.com/spiraldb/raincloud" # loader only + pip install "raincloud[duckdb] @ git+https://github.com/spiraldb/raincloud" # Dataset.scan() + pip install "raincloud[pandas] @ git+https://github.com/spiraldb/raincloud" # Dataset.to_pandas() + pip install "raincloud[build] @ git+https://github.com/spiraldb/raincloud" # local-build fallback +""" +from __future__ import annotations + +import argparse +import os +import sys + +import raincloud + + +def show_metadata(slug: str) -> None: + """Pure-metadata path: walks the packaged catalog, no I/O beyond it.""" + print(f"\n== metadata for {slug!r} ==") + ds = raincloud.load(slug) # default format='vortex' with parquet fallback + print(f" repr : {ds!r}") + print(f" format : {ds.format}") + print(f" num_rows : {ds.num_rows}") + print(f" column count : {len(ds.column_names)}") + print(f" first 5 columns : {ds.column_names[:5]}") + info = ds.info + print(f" short_name : {info.get('short_name')}") + print(f" license (SPDX) : {(info.get('license') or {}).get('spdx')}") + print(f" source_url : {info.get('source_url')}") + + +def show_materialization(slug: str) -> None: + """The four materialization shapes. Resolves the artifact on first call.""" + print(f"\n== materialize {slug!r} ==") + ds = raincloud.load(slug) + print(" .path() ->", ds.path(), "(cache or mirror or local build)") + if ds.format == "parquet": + schema = ds.schema # parquet footer read — cheap + print(f" .schema : {len(schema)} fields") + table = ds.to_arrow() + print(f" .to_arrow().num_rows : {table.num_rows}") + + # .to_vortex() opens the vortex artifact (vortex-data is a base dep). + # Guarded for parquet-only slugs that have no vortex sibling. + try: + vf = ds.to_vortex() + print(f" .to_vortex() : {type(vf).__name__}") + except raincloud.FormatUnavailable as e: + print(f" .to_vortex() : skipped ({e})") + + # Optional backends — guarded so the demo runs without [duckdb]/[pandas]. + try: + rel = ds.scan() + # `.scan()` always resolves the parquet sibling; if you loaded as + # vortex, you'll see a [raincloud] note on stderr before resolution. + head = rel.limit(3).fetchall() + print(f" .scan() head : {head}") + except raincloud.MissingDependency as e: + print(f" .scan() : skipped ({e})") + try: + df = ds.to_pandas() + print(f" .to_pandas() rows: {len(df)}") + except raincloud.MissingDependency as e: + print(f" .to_pandas() : skipped ({e})") + + +def show_format_override(slug: str) -> None: + """Pick a format explicitly. Falls back from vortex -> parquet automatically.""" + print(f"\n== format override for {slug!r} ==") + for fmt in ("vortex", "parquet"): + try: + ds = raincloud.load(slug, format=fmt) + print(f" format={fmt!r:>8} -> resolved as {ds.format!r}") + except raincloud.FormatUnavailable as e: + print(f" format={fmt!r:>8} -> unavailable ({e})") + + +def show_error_handling() -> None: + """Every loader error is a RaincloudError subclass.""" + print("\n== error handling ==") + try: + raincloud.load("definitely-not-a-real-slug") + except raincloud.UnknownSlug as e: + print(f" UnknownSlug caught: {e}") + + # Offline mode blocks mirror+build fallback. + os.environ["RAINCLOUD_OFFLINE"] = "1" + try: + # Pick a slug that's unlikely to be cached locally. + from raincloud._catalog import load_catalog + load_catalog.cache_clear() + ds = raincloud.load(next(iter(load_catalog().slugs()))) + ds.path() + except raincloud.OfflineMiss as e: + print(f" OfflineMiss caught: {e}") + except raincloud.RaincloudError as e: + # Any other RaincloudError is fine for the demo — it's the catch-all. + print(f" {type(e).__name__} caught: {e}") + finally: + del os.environ["RAINCLOUD_OFFLINE"] + from raincloud._catalog import load_catalog + load_catalog.cache_clear() + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + p.add_argument("--slug", help="specific slug to load (default: catalog's first)") + p.add_argument("--materialize", action="store_true", + help="exercise .to_arrow/.scan/.to_pandas (needs cache or mirror)") + args = p.parse_args(argv) + + from raincloud._catalog import load_catalog + slugs = load_catalog().slugs() + slug = args.slug or slugs[0] + print(f"raincloud {raincloud.__version__} — {len(slugs)} slugs in catalog") + print(f"using slug: {slug}") + + show_metadata(slug) + show_format_override(slug) + if args.materialize: + try: + show_materialization(slug) + except (raincloud.OfflineMiss, raincloud.BuildToolingMissing) as e: + print(f"\n[materialize] skipped: {type(e).__name__}: {e}") + print(' hint: set RAINCLOUD_MIRROR=, or pip install ' + '"raincloud[build] @ git+https://github.com/spiraldb/raincloud"') + show_error_handling() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/wine_quality_correlations.py b/examples/wine_quality_correlations.py new file mode 100644 index 0000000..4ef91d6 --- /dev/null +++ b/examples/wine_quality_correlations.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: 2026 Raincloud Maintainers +# SPDX-License-Identifier: Apache-2.0 +"""Which measurable properties of a wine track its quality score? + +Loads the UCI wine-quality table (`uci-wine-quality`, 6,497 red + white wines +with 11 physicochemical measurements plus a 0-10 sensory `quality` score) into +pandas and correlates each numeric feature against `quality`, sorted. A tiny, +instant example of the load -> DataFrame -> explore loop. + +Run it: + + python examples/wine_quality_correlations.py + +Install (raincloud is not on PyPI — install from GitHub): + + pip install "raincloud[build,pandas] @ git+https://github.com/spiraldb/raincloud" # build: first-run fetch; pandas: .to_pandas() + +First run fetches ~80 KB from upstream (or a configured RAINCLOUD_MIRROR), then +it's cached. +""" +from __future__ import annotations + +import sys + +import raincloud + +SLUG = "uci-wine-quality" + + +def main(argv: list[str] | None = None) -> int: + try: + df = raincloud.load(SLUG).to_pandas() + except raincloud.MissingDependency as e: + print(f"this example needs pandas: {e}\n" + ' pip install "raincloud[pandas] @ git+https://github.com/spiraldb/raincloud"') + return 1 + except raincloud.RaincloudError as e: + print(f"could not load {SLUG}: {type(e).__name__}: {e}") + print(' hint: pip install "raincloud[build] @ git+https://github.com/spiraldb/raincloud" ' + "(first run fetches ~80 KB) or set RAINCLOUD_MIRROR=") + return 1 + + # numeric_only drops the non-numeric `color` (red/white) column. + corr = df.corr(numeric_only=True)["quality"].drop("quality").sort_values() + + print(f"{SLUG}: {len(df):,} wines (red + white)\n") + print("correlation of each feature with the quality score:") + for feat, c in corr.items(): + print(f" {feat:<22}{c:+.3f}") + print(f"\nstrongest positive: {corr.index[-1]} ({corr.iloc[-1]:+.3f})") + print(f"strongest negative: {corr.index[0]} ({corr.iloc[0]:+.3f})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + + +# --- Sample output (captured 2026-05-29 against live upstream; exact numbers +# drift as the dataset is revised) --- +# +# uci-wine-quality: 6,497 wines (red + white) +# +# correlation of each feature with the quality score: +# density -0.306 +# volatile_acidity -0.266 +# chlorides -0.201 +# fixed_acidity -0.077 +# total_sulfur_dioxide -0.041 +# residual_sugar -0.037 +# ph +0.020 +# sulphates +0.038 +# free_sulfur_dioxide +0.055 +# citric_acid +0.086 +# alcohol +0.444 +# +# strongest positive: alcohol (+0.444) +# strongest negative: density (-0.306) diff --git a/pyproject.toml b/pyproject.toml index 42fb770..96e687e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "raincloud" -version = "0.1.5" +version = "0.2.0" description = "Client-reproducible pipeline for building a curated catalog of public datasets as Parquet + Vortex files." readme = "README.md" requires-python = ">=3.11" @@ -9,44 +9,31 @@ license = "Apache-2.0" license-files = ["LICENSE"] dependencies = [ - # Core Arrow / Parquet handling — used by every stage. "pyarrow>=23.0", "numpy>=2.0", - "duckdb>=1.5.0", # VARIANT type requires storage_compatibility_version ≥ v1.5.0 - # Vortex conversion. The package was renamed from `vortex-array` to - # `vortex-data` in the 0.32 → 0.69 move and the API isn't yet stable - # across minor versions, so we cap at the next minor. Loosen once the - # public API (`vxio.write(pa_table, path)` is what we depend on) is - # backed by a stability guarantee. "vortex-data>=0.69.0,<0.70.0", - # Streaming decompression formats surfaced in specific upstreams. - "zstandard>=0.25.0", # lichess .pgn.zst monthly dumps - "py7zr>=1.1.0", # 7z archives - "unlzw3>=0.2.0", # UCI diabetes .tar.Z (Unix compress / LZW) - # Format-specific parsers. - "pandas>=2.0", # xlsx_parse (online-retail-ii) - "openpyxl>=3.1", # pandas xlsx backend - "pyreadstat>=1.3", # sas_xpt_parse (CDC BRFSS .xpt) - "osmium>=4.3", # osm_pbf_split (OSM Germany Geofabrik extract) - "jsonschema>=4.0", # validate_manifest.py — sources.json structural checks + "fsspec>=2024.0", ] [project.optional-dependencies] -# Only needed for fetch.type = "kaggle" (≈9 slugs). Requires ~/.kaggle/kaggle.json -# credentials to be set up once per machine. +s3 = ["s3fs>=2024.0"] +http = ["aiohttp>=3.9"] +duckdb = ["duckdb>=1.5.0"] +pandas = ["pandas>=2.0"] +# Full build pipeline (enables the local-build fallback). Mirrors the +# pre-split heavy dependency set. +build = [ + "duckdb>=1.5.0", "zstandard>=0.25.0", "py7zr>=1.1.0", "unlzw3>=0.2.0", + "pandas>=2.0", "openpyxl>=3.1", "pyreadstat>=1.3", "osmium>=4.3", + "jsonschema>=4.0", +] kaggle = ["kaggle>=2.0"] -# Only needed for fetch.type = "huggingface" (1 slug: dbpedia-embeddings). huggingface = ["huggingface-hub>=0.25"] -# Read-only TUI browser for sources.json — `python -m scripts.pipeline.browse`. tui = ["textual>=0.80"] -# Convenience alias. +dev = ["pytest>=8.0", "ruff>=0.13"] all = [ - "kaggle>=2.0", - "huggingface-hub>=0.25", - "textual>=0.80", + "raincloud[s3,http,duckdb,pandas,build,kaggle,huggingface,tui]", ] -# Test runner + linter — install with `uv sync --extra dev`. -dev = ["pytest>=8.0", "ruff>=0.13"] [tool.ruff] line-length = 120 @@ -64,9 +51,20 @@ select = ["E", "F", "W", "I"] # better as one-liners than split across multiple lines. ignore = ["E501", "E701", "E702"] -[tool.uv] -# Currently consumed in-place from a clone, but the dependency posture -# (loose lower-bound constraints, no committed uv.lock) is set up for -# library publication. Flip `package = false` to `true` and add a build -# backend ([build-system]) when you're ready to ship. -package = false +[tool.pytest.ini_options] +markers = [ + "wheel: builds the wheel + spins venvs (slow; gated by --run-wheel)", + "network: hits real upstream sources (flaky; gated by --run-network)", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["raincloud", "scripts"] + +[tool.hatch.build.targets.wheel.force-include] +"docs/v1/snapshot.json" = "raincloud/_data/snapshot.json" +"sources.json" = "raincloud/_data/sources.json" +"sources.schema.json" = "raincloud/_data/sources.schema.json" diff --git a/raincloud/__init__.py b/raincloud/__init__.py new file mode 100644 index 0000000..277ac59 --- /dev/null +++ b/raincloud/__init__.py @@ -0,0 +1,151 @@ +# SPDX-FileCopyrightText: 2026 Raincloud Maintainers +# SPDX-License-Identifier: Apache-2.0 +"""Raincloud loader: datasets-style access to prepared Vortex/Parquet files.""" +from __future__ import annotations + +from pathlib import Path + +from . import _resolve +from ._catalog import load_catalog +from .exceptions import ( # noqa: F401 + ArtifactNotFound, + BuildFailed, + BuildToolingMissing, + ChecksumMismatch, + FormatUnavailable, + MissingDependency, + OfflineMiss, + RaincloudError, + UnknownSlug, +) + +__version__ = "0.2.0" + +_DEFAULT_FORMAT = "vortex" + + +class Dataset: + """Lazy handle to a prepared artifact. Nothing is fetched until you ask.""" + + def __init__(self, slug: str, fmt: str, *, mirror: str | None, + offline: bool | None, entry=None): + self.slug = slug + self.format = fmt + self._mirror = mirror + self._offline = offline + # `entry` is passed in by load() (which already resolved it) to avoid + # rebuilding the Entry; direct construction falls back to a lookup. + self._entry = entry if entry is not None else load_catalog().entry(slug) + + def __repr__(self) -> str: + return f"Dataset(slug={self.slug!r}, format={self.format!r})" + + # --- cheap metadata (no I/O beyond the in-memory catalog) --- + @property + def num_rows(self) -> int | None: + return self._entry.rows + + @property + def column_names(self) -> list[str]: + return self._entry.column_names + + @property + def info(self) -> dict: + return self._entry.info + + # --- resolution --- + def path(self) -> Path: + return self.path_for(self.format) + + def path_for(self, fmt: str) -> Path: + # Reuse the already-resolved Entry (covers every format of this slug) + # so resolve() doesn't rebuild it a third time. + return _resolve.resolve(self.slug, fmt, mirror=self._mirror, + offline=self._offline, entry=self._entry) + + # --- materialization --- + def to_arrow(self): + import pyarrow.parquet as pq + if self.format == "parquet": + return pq.read_table(self.path()) + import vortex + return vortex.open(str(self.path())).to_arrow().read_all() + + def to_vortex(self): + import vortex + return vortex.open(str(self.path_for("vortex"))) + + @property + def schema(self): + import pyarrow.parquet as pq + if self.format == "parquet": + return pq.read_schema(self.path()) # footer-only, cheap + import vortex + # vortex path must open the file (heavier than the parquet footer read) + return vortex.open(str(self.path())).to_arrow().schema + + def scan(self): + """Return a DuckDB relation over the dataset. + + Always reads the parquet artifact (DuckDB has no native Vortex + reader), resolving the parquet sibling even when this handle's + format is vortex. + """ + try: + import duckdb + except ImportError as e: + raise MissingDependency( + "scan() needs DuckDB — install `raincloud[duckdb]`" + ) from e + # Only warn about resolving the sibling when one actually exists; for a + # vortex-only slug, path_for("parquet") raises FormatUnavailable and the + # note would be misleading. + if self.format != "parquet" and "parquet" in self._entry.formats: + import sys + print( + f"[raincloud] scan() needs parquet but {self.slug} was loaded as " + f"{self.format!r}; resolving parquet sibling (may trigger a " + f"cache/mirror fetch).", + file=sys.stderr, + ) + pq_path = self.path_for("parquet") + return duckdb.connect().read_parquet(str(pq_path)) + + def to_pandas(self): + try: + import pandas # noqa: F401 + except ImportError as e: + raise MissingDependency( + "to_pandas() needs pandas — install `raincloud[pandas]`" + ) from e + return self.to_arrow().to_pandas() + + +def load(slug: str, *, format: str = _DEFAULT_FORMAT, + offline: bool | None = None, mirror: str | None = None) -> Dataset: + """Return a lazy Dataset handle for `slug`. + + `format` defaults to "vortex" and falls back to "parquet" when the slug + has no vortex artifact. Raises UnknownSlug for an unknown slug and + FormatUnavailable when neither the requested nor a fallback format exists. + """ + entry = load_catalog().entry(slug) # raises UnknownSlug + fmt = format + if fmt not in entry.formats: + if fmt == "vortex" and "parquet" in entry.formats: + fmt = "parquet" + else: + raise FormatUnavailable( + f"{slug}: format {format!r} unavailable; have {sorted(entry.formats)}" + ) + return Dataset(slug, fmt, mirror=mirror, offline=offline, entry=entry) + + +load_dataset = load # datasets-muscle-memory alias + +__all__ = [ + "load", "load_dataset", "Dataset", "__version__", + "RaincloudError", "UnknownSlug", "FormatUnavailable", "ArtifactNotFound", + "ChecksumMismatch", "BuildToolingMissing", "BuildFailed", "OfflineMiss", + "MissingDependency", +] diff --git a/raincloud/_cache.py b/raincloud/_cache.py new file mode 100644 index 0000000..ca90a9a --- /dev/null +++ b/raincloud/_cache.py @@ -0,0 +1,188 @@ +# SPDX-FileCopyrightText: 2026 Raincloud Maintainers +# SPDX-License-Identifier: Apache-2.0 +"""Local artifact cache: paths, sha256 verification, atomic adoption.""" +from __future__ import annotations + +import hashlib +import json +import os +import sys +from pathlib import Path + +from .exceptions import ChecksumMismatch + +# Format -> on-disk file extension. Identity for today's two formats, but kept +# as a map so a future format whose extension differs from its name (e.g. a +# "parquet-hydrated" tier -> "parquet") slots in without touching call sites. +EXT = {"parquet": "parquet", "vortex": "vortex"} + +_TRUTHY = {"1", "true", "yes", "on"} + + +def cache_root() -> Path: + env = os.environ.get("RAINCLOUD_CACHE") + if env: + # .expanduser() for parity with _catalog._data_file and + # scripts.pipeline.spec._env_path so `RAINCLOUD_CACHE=~/foo` resolves + # to the home dir rather than a literal ./~/foo. + return Path(env).expanduser() + xdg = os.environ.get("XDG_CACHE_HOME") + base = Path(xdg) if xdg else Path.home() / ".cache" + return base / "raincloud" + + +def cache_path(slug: str, fmt: str) -> Path: + # schema_version is 1 today; hardcoded to match the loader's artifact_key + the pipeline's outputs/v1 layout + return cache_root() / "v1" / slug / fmt / f"{slug}.{EXT[fmt]}" + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): # 1 MiB chunks + h.update(chunk) + return h.hexdigest() + + +def is_offline() -> bool: + return os.environ.get("RAINCLOUD_OFFLINE", "").lower() in _TRUTHY + + +def strict_checksum() -> bool: + """True if `RAINCLOUD_STRICT_CHECKSUM` opts into hard checksum failures. + + Default (off) is the 'drift is an alert' policy: a sha mismatch warns and + adopts. Strict (on) is for security-sensitive deployments / CI: + + - A slug that HAS a pinned sha and comes from the mirror is rehashed on + every cache hit and on download; a mismatch raises ChecksumMismatch + (catches even same-size on-disk tampering of a previously-verified + file). This is the integrity property strict buys, at the cost of a + rehash per load. + - A slug with NO pinned sha has nothing to rehash against, so strict + changes nothing for it — the size/pin corruption check still applies. + - A LOCALLY-BUILT artifact can't be verified against the maintainer's + sha (a non-reproducible build legitimately differs), so it is trusted + via its provenance pin (origin=build + the snapshot pin it was built + against) rather than rebuilt every load. It is rebuilt only when that + snapshot pin changes (the source of truth moved) — see _resolve. + For full cryptographic integrity, point strict deployments at a mirror. + """ + return os.environ.get("RAINCLOUD_STRICT_CHECKSUM", "").lower() in _TRUTHY + + +def pin_path(dest: Path) -> Path: + """Sidecar recording which snapshot pin a cached file was reconciled against.""" + return dest.parent / f".{dest.name}.pin" + + +def read_pin(dest: Path) -> dict | None: + """Return {"snap_sha": str|None, "size": int, "origin": str|None} for + `dest`, or None. + + Returns None when the sidecar is absent, unreadable, or contains valid JSON + that isn't an object (e.g. a torn/partial write leaving `42` or `[...]`) — + callers do `pin.get(...)`, so a non-dict must not slip through and raise + AttributeError inside resolve(). `origin` is absent on pins written before + it was added; callers must treat a missing `origin` as untrusted (None). + """ + try: + pin = json.loads(pin_path(dest).read_text()) + except (OSError, ValueError): + return None + return pin if isinstance(pin, dict) else None + + +def _write_pin(dest: Path, snap_sha: str | None, size: int, + origin: str | None = None) -> None: + """Best-effort: record the snapshot sha (may be None), on-disk size, and + origin (`build` / `mirror` / None) of the bytes we just adopted. + + Lets a later resolve() recognise bytes it deliberately adopted and serve + them from cache instead of re-fetching/rebuilding every load. This covers + cases the snapshot size alone can't bless: (a) already-adopted *drift* + (bytes diverging from a pinned sha), (b) a slug with no pinned sha at all + (`snap_sha=None`), and (c) a locally-built artifact whose bytes legitimately + differ from the maintainer's snapshot. `origin` is what lets strict mode + distinguish "my own local build against this snapshot pin" (trust the + provenance) from "mirror bytes" (must rehash). A pin is hygiene, not + correctness — a write failure just costs a rehash on the next load. + """ + p = pin_path(dest) + tmp = p.parent / f"{p.name}.{os.getpid()}.tmp" + try: + tmp.write_text(json.dumps({"snap_sha": snap_sha, "size": size, "origin": origin})) + os.replace(tmp, p) # atomic: a concurrent reader never sees a torn pin + except OSError: + try: + if tmp.exists(): + tmp.unlink() + except OSError: + pass + + +def adopt( + tmp: Path, + dest: Path, + expected_sha256: str | None, + *, + strict: bool = False, + slug: str | None = None, + origin: str | None = None, +) -> Path: + """Atomically move tmp -> dest, optionally checking against a known sha. + + Default semantics (`strict=False`): if `expected_sha256` is set and the + bytes disagree, print a `[raincloud]` warning to stderr identifying the + slug + origin, then adopt anyway. Upstream data drifts; we want the user + informed, not blocked. The loader's mirror-fetch path passes + `strict=strict` (i.e. `strict=True` under RAINCLOUD_STRICT_CHECKSUM) to + turn a mismatch into a `ChecksumMismatch`; the local-build path always + passes `strict=False` (a client build legitimately differs from the + maintainer's bytes). NOTE: `scripts.pipeline.publish` does NOT go through + adopt — it gates uploads with its own `PublishMismatch` in `plan_uploads`. + + When `expected_sha256` is None, verification is skipped — there's nothing + pinned to alert against. + + `origin` (`build` / `mirror`) is recorded in the pin so a later strict + resolve can trust a locally-built artifact by provenance instead of + rebuilding it every load. + + On any failure, the tmp file is removed and dest is left untouched. + """ + try: + if expected_sha256 is not None: + actual = sha256_file(tmp) + if actual != expected_sha256: + if strict: + raise ChecksumMismatch( + f"{tmp}: expected sha256 {expected_sha256}, got {actual}" + ) + label = slug or dest.name + where = f" from {origin}" if origin else "" + # A local build legitimately differs from the maintainer's + # snapshot bytes (parquet/vortex output is rarely bit-stable + # across library versions), so don't cry "upstream changed". + why = ("locally built; differs from the maintainer's snapshot " + "(expected for non-reproducible formats)" + if origin == "build" + else "adopting anyway — upstream may have changed") + print( + f"[raincloud] WARN: {label}{where} sha256 drifted " + f"(got {actual[:12]}…, snapshot expected {expected_sha256[:12]}…); " + f"{why}.", + file=sys.stderr, + ) + dest.parent.mkdir(parents=True, exist_ok=True) + os.replace(tmp, dest) # atomic within a filesystem + # Always record (snap_sha, size, origin) we just adopted so a later + # resolve() can serve these exact bytes from cache without a rehash — + # see _write_pin for the cases this covers (drift, sha-less slugs, + # locally-built artifacts). origin lets strict mode trust a local + # build by provenance instead of rebuilding it every load. + _write_pin(dest, expected_sha256, dest.stat().st_size, origin) + return dest + finally: + if tmp.exists(): + tmp.unlink() diff --git a/raincloud/_catalog.py b/raincloud/_catalog.py new file mode 100644 index 0000000..e9fad3c --- /dev/null +++ b/raincloud/_catalog.py @@ -0,0 +1,126 @@ +# SPDX-FileCopyrightText: 2026 Raincloud Maintainers +# SPDX-License-Identifier: Apache-2.0 +"""Catalog: per-slug metadata + checksums from the shipped snapshot + manifest.""" +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from functools import lru_cache +from importlib import resources +from pathlib import Path + +from .exceptions import UnknownSlug + + +def _repo_root() -> Path: + # raincloud/_catalog.py -> repo root is two parents up in a source checkout. + return Path(__file__).resolve().parent.parent + + +def _data_file(kind: str) -> Path: + """Locate a data file. kind in {"snapshot", "manifest"}. + + Precedence: env override -> repo source copy -> wheel-packaged copy. This + matches `scripts.pipeline.spec._default_manifest` and the documented + intent ("checkout copy, else the wheel-packaged copy") so the loader and + the build pipeline never read different copies in an editable install. + """ + env = {"snapshot": "RAINCLOUD_SNAPSHOT", "manifest": "RAINCLOUD_MANIFEST"}[kind] + override = os.environ.get(env) + if override: + # `.expanduser()` matches scripts.pipeline.spec._env_path so a + # `RAINCLOUD_MANIFEST=~/x` resolves identically for loader and build. + return Path(override).expanduser() + repo_name = {"snapshot": "docs/v1/snapshot.json", "manifest": "sources.json"}[kind] + repo = _repo_root() / repo_name + if repo.is_file(): + return repo + packaged_name = {"snapshot": "snapshot.json", "manifest": "sources.json"}[kind] + try: + p = resources.files("raincloud").joinpath("_data", packaged_name) + if p.is_file(): + return Path(str(p)) + except FileNotFoundError: + pass + return repo # let open() error point at the expected checkout path + + +@dataclass(frozen=True) +class FormatInfo: + sha256: str | None + nbytes: int | None + + +@dataclass(frozen=True) +class Entry: + slug: str + rows: int | None + columns: list[dict] = field(default_factory=list) + formats: dict[str, FormatInfo] = field(default_factory=dict) + info: dict = field(default_factory=dict) + + @property + def column_names(self) -> list[str]: + return [c["name"] for c in self.columns] + + +class Catalog: + def __init__(self, snapshot: dict, manifest: dict): + self._slugs = snapshot.get("slugs", {}) + self._specs = {d["slug"]: d for d in manifest.get("datasets", [])} + + def __contains__(self, slug: str) -> bool: + return slug in self._slugs or slug in self._specs + + def slugs(self) -> list[str]: + return sorted(set(self._slugs) | set(self._specs)) + + def entry(self, slug: str) -> Entry: + if slug not in self: + raise UnknownSlug(slug) + snap = self._slugs.get(slug, {}) + spec = self._specs.get(slug, {}) + formats: dict[str, FormatInfo] = {} + # Parquet: produced for every manifest slug, OR already recorded in the + # snapshot (covers slugs the maintainer dropped from sources.json but + # still publishes — e.g. legacy/deprecated mirror entries). + if slug in self._specs or snap.get("parquet_bytes") is not None: + formats["parquet"] = FormatInfo( + sha256=snap.get("parquet_sha256"), + nbytes=snap.get("parquet_bytes"), + ) + # Vortex: produced when convert.vortex is true in the manifest, OR when + # already present in the snapshot (same legacy-slug rationale). + wants_vortex = bool((spec.get("convert") or {}).get("vortex")) + if wants_vortex or snap.get("vortex_bytes") is not None: + formats["vortex"] = FormatInfo( + sha256=snap.get("vortex_sha256"), + nbytes=snap.get("vortex_bytes"), + ) + lic = spec.get("license", {}) or {} + urls = (spec.get("fetch", {}) or {}).get("urls") or [] + info = { + "short_name": spec.get("short_name"), + "full_name": spec.get("full_name"), + "description": spec.get("description"), + "license": lic, + "source_url": urls[0] if urls else lic.get("source_url"), + } + return Entry( + slug=slug, + # Prefer last_built_rows, fall back to expected_rows. Truthiness + # means rows==0 falls through to expected_rows — benign: no slug + # has last_built_rows==0. + rows=snap.get("last_built_rows") or snap.get("expected_rows"), + columns=snap.get("columns") or [], + formats=formats, + info=info, + ) + + +@lru_cache(maxsize=1) +def load_catalog() -> Catalog: + snapshot = json.loads(_data_file("snapshot").read_text()) + manifest = json.loads(_data_file("manifest").read_text()) + return Catalog(snapshot, manifest) diff --git a/raincloud/_resolve.py b/raincloud/_resolve.py new file mode 100644 index 0000000..99abfe1 --- /dev/null +++ b/raincloud/_resolve.py @@ -0,0 +1,285 @@ +# SPDX-FileCopyrightText: 2026 Raincloud Maintainers +# SPDX-License-Identifier: Apache-2.0 +"""Resolution order: local cache -> mirror -> local build.""" +from __future__ import annotations + +import importlib +import os +import shutil +import subprocess +import sys +import time +import uuid +from pathlib import Path + +from . import _cache, _transport +from ._catalog import load_catalog +from .exceptions import ( + ArtifactNotFound, + BuildFailed, + BuildToolingMissing, + FormatUnavailable, + OfflineMiss, +) + +# Stale .part files (crash / SIGKILL leftovers) older than this get swept on +# the next resolve() attempt for the same dest. Long enough that an actively +# running multi-hour download is safe; short enough that orphans don't pile up. +_STALE_PART_SECONDS = 6 * 3600 + + +def _tmp_path(dest: Path) -> Path: + """Per-process-unique .part path so concurrent loaders don't race.""" + return dest.parent / f".{dest.name}.{os.getpid()}-{uuid.uuid4().hex[:8]}.part" + + +def _sweep_stale_parts(dest: Path) -> None: + """Best-effort cleanup of orphaned .part files in dest.parent. + + Matches `..*.part` siblings whose mtime is older than the + stale threshold. Silently ignores errors — sweep is hygiene, not + correctness. + """ + if not dest.parent.exists(): + return + cutoff = time.time() - _STALE_PART_SECONDS + prefix = f".{dest.name}." + suffix = ".part" + try: + for p in dest.parent.iterdir(): + n = p.name + if not (n.startswith(prefix) and n.endswith(suffix)): + continue + try: + if p.stat().st_mtime < cutoff: + p.unlink() + except OSError: + pass + except OSError: + pass + + +def artifact_key(slug: str, fmt: str) -> str: + # v1 is hardcoded across the loader; revisit at a schema_version bump + return f"v1/{slug}/{fmt}/{slug}.{_cache.EXT[fmt]}" + + +def _mirror_base(mirror: str | None) -> str | None: + base = mirror if mirror is not None else os.environ.get("RAINCLOUD_MIRROR") + return base.rstrip("/") if base else None + + +def _build_import_error() -> BaseException | None: + """Return the exception that blocks importing the build pipeline, or None. + + `scripts.pipeline.build` is packaged into the wheel even in a loader-only + install, so `find_spec` is insufficient (it only checks the file exists). + The module must be actually importable, which requires the `[build]` extra. + + We distinguish two failure classes so resolve() can give the right hint: + - ImportError (incl. ModuleNotFoundError): the `[build]` extra isn't + installed → "install raincloud[build]". + - any other exception at module-init (a handler raising at top level, a + malformed packaged manifest): the toolchain IS present but broken → + surface the actual error rather than misdirecting to a pip install. + Either way the build is unavailable; the caller decides the message. + """ + try: + importlib.import_module("scripts.pipeline.build") + return None + except Exception as e: # noqa: BLE001 — both classes mean "can't build" + return e + + +def _build_available() -> bool: + # Boolean convenience wrapper. resolve() uses _build_import_error() directly + # (it needs the exception to craft the right message); this stays as the + # readable predicate exercised by the test suite. + return _build_import_error() is None + + +def resolve( + slug: str, + fmt: str, + *, + mirror: str | None = None, + offline: bool | None = None, + allow_build: bool = True, + entry=None, +) -> Path: + # `entry` is passed by Dataset.path_for (already resolved); fall back to a + # lookup for direct callers. load_catalog().entry(slug) raises UnknownSlug. + if entry is None: + entry = load_catalog().entry(slug) + if fmt not in entry.formats: + raise FormatUnavailable( + f"{slug}: format {fmt!r} not available; have {sorted(entry.formats)}" + ) + dest = _cache.cache_path(slug, fmt) + expected = entry.formats[fmt].sha256 + expected_size = entry.formats[fmt].nbytes + + # 1) cache hit. Several short-circuits, cheapest first, so a full sha256 + # over multi-GB artifacts never runs on the common load: + # + # No pinned sha (the ~80% of the catalog with only a byte size): a pin + # matching the on-disk size serves immediately (covers deliberately + # adopted / locally-built bytes); else the snapshot byte size is used as + # a cheap corruption check — a size mismatch with no vouching pin warns + # and re-fetches rather than serving possibly-truncated bytes on trust. + # + # With a pinned sha: + # a) size matches the pin -> treat as the blessed artifact. (A + # same-size, different-content snapshot revision is the one case + # this can't tell apart — an accepted, pre-existing blind spot.) + # b) bytes are drift we already adopted against THIS snapshot pin + # (pin sidecar records snap sha + adopted size, and the file is + # still that size) -> serve it. Without this, knowingly-adopted + # drift matches neither (a) nor a sha match and would be + # re-fetched/rebuilt on *every* load. + # c) full sha matches the pin -> serve (legacy cache w/o a pin, or a + # post-revision re-download landing here). + # Otherwise warn and fall through to re-fetch (genuine snapshot revision + # or external corruption). + # + # Strict mode (RAINCLOUD_STRICT_CHECKSUM) only changes behavior for a + # slug that HAS a pinned sha: it rehashes such a cache hit and serves + # only a sha match (so mirror drift adopted by a prior non-strict run + # can't slip through) — with ONE provenance exception: a locally-built + # artifact (origin=build pin) adopted against the current snapshot pin + # is served without rehashing, since there's no maintainer sha a + # non-reproducible build could match. Sha-less slugs have nothing to + # rehash against, so strict leaves their size/pin path unchanged. + strict = _cache.strict_checksum() + if dest.exists(): + cached_size = dest.stat().st_size + if expected is None: + # No pinned sha (true for ~80% of the catalog today). We can't + # rehash, so the snapshot byte size is the cheap corruption check — + # but mirror semantics mirror the strict branch: only a + # *build-origin* pin overrides a size disagreement (a local build + # legitimately differs from the maintainer's size, so its provenance + # is the trust signal). A mirror/pin-less artifact must still match + # the snapshot size, so a snapshot revision that ships a new + # (still-sha-less) size is detected as stale and re-fetched instead + # of serving the old cache forever. + pin = _cache.read_pin(dest) + if (pin is not None and pin.get("origin") == "build" + and pin.get("size") == cached_size): + return dest + if expected_size is None or cached_size == expected_size: + return dest + print( + f"[raincloud] WARN: cached {slug}/{fmt} size {cached_size} != snapshot " + f"{expected_size} and no local-build pin vouches for it; re-fetching.", + file=sys.stderr, + ) + elif strict: + # Strict verifies UNTRUSTED bytes (mirror / pin-less) against the + # snapshot sha on every load — this catches even same-size on-disk + # tampering of a previously-verified file. But a locally-built + # artifact has no maintainer sha to match (a non-reproducible build + # legitimately differs), so trust its provenance pin instead of + # rebuilding every load: an origin=build artifact adopted against + # THIS snapshot pin (snap_sha == expected) and unchanged on disk + # (size match) is served. A snapshot revision (expected changes) + # makes the pin stale, so it falls through and the slug is rebuilt + # — the cache is trusted until the source of truth moves. + pin = _cache.read_pin(dest) + if (pin is not None and pin.get("origin") == "build" + and pin.get("snap_sha") == expected + and pin.get("size") == cached_size): + return dest + if _cache.sha256_file(dest) == expected: + return dest + print( + f"[raincloud] WARN: cached {slug}/{fmt} sha256 != snapshot (strict); " + f"re-fetching from mirror/build.", + file=sys.stderr, + ) + else: + if expected_size is not None and cached_size == expected_size: + return dest + pin = _cache.read_pin(dest) + if pin is not None and pin.get("snap_sha") == expected and pin.get("size") == cached_size: + return dest + if _cache.sha256_file(dest) == expected: + return dest + print( + f"[raincloud] WARN: cached {slug}/{fmt} sha256 drifted from snapshot; " + f"re-fetching from mirror/build.", + file=sys.stderr, + ) + + is_offline = _cache.is_offline() if offline is None else offline + if is_offline: + raise OfflineMiss(f"{slug}/{fmt} not cached and offline mode is on") + + # 2) mirror. Drifted bytes warn-and-adopt (upstream changes are not panic + # cases); a clean miss falls through to local build. + base = _mirror_base(mirror) + if base is not None: + url = f"{base}/{artifact_key(slug, fmt)}" + dest.parent.mkdir(parents=True, exist_ok=True) + _sweep_stale_parts(dest) + tmp = _tmp_path(dest) + try: + _transport.fetch(url, tmp) + return _cache.adopt(tmp, dest, expected, strict=strict, slug=slug, origin="mirror") + except ArtifactNotFound: + pass # fall through to build + + # 3) local build. Works from a wheel install too: scripts.pipeline reads the + # packaged manifest and writes under data_root() (~/.cache/raincloud) when + # there's no checkout. Requires the [build] extra (see _build_import_error). + # Only probe importability when we'd actually build — the import is wasted + # work when allow_build is False. + if allow_build: + build_err = _build_import_error() + if build_err is None: + try: + subprocess.run( + [sys.executable, "-m", "scripts.pipeline.build", slug], check=True + ) + except (subprocess.CalledProcessError, OSError) as e: + # Honour the typed-error contract — callers catch RaincloudError, + # not raw subprocess errors. CalledProcessError = non-zero exit; + # OSError = couldn't even spawn (e.g. a bogus sys.executable). + raise BuildFailed(f"build of {slug} failed: {e}") from e + from scripts.pipeline.spec import output_format_dir # type: ignore + + built = output_format_dir(slug, fmt) / f"{slug}.{_cache.EXT[fmt]}" + if not built.exists(): + raise ArtifactNotFound(f"build produced no {fmt} for {slug}") + dest.parent.mkdir(parents=True, exist_ok=True) + _sweep_stale_parts(dest) + tmp = _tmp_path(dest) + try: + shutil.copyfile(built, tmp) + # adopt(strict=False, origin="build"): a client's local build + # legitimately differs from the maintainer's snapshot bytes + # (columnar output is rarely bit-reproducible), so we never + # strict-gate it on the maintainer's sha. `expected` is still + # passed so the pin records (snap_sha, size, origin=build) — + # that provenance lets BOTH non-strict and strict later loads + # serve these bytes straight from cache (no rebuild loop). The + # slug is rebuilt only when the snapshot pin changes (the source + # of truth moved) or the cached file is corrupted (size drift). + return _cache.adopt(tmp, dest, expected, strict=False, slug=slug, origin="build") + except Exception: + if tmp.exists(): + tmp.unlink() + raise + if not isinstance(build_err, ImportError): + # The [build] subtree is present but failed to import for a + # non-import reason (broken handler, malformed manifest). Surface + # the real cause rather than telling the user to `pip install` + # something they already have. + raise BuildToolingMissing( + f"{slug}/{fmt} not cached and not in mirror; the build pipeline is " + f"installed but failed to import: {type(build_err).__name__}: {build_err}" + ) + raise BuildToolingMissing( + f"{slug}/{fmt} not cached and not in mirror; " + f"install `raincloud[build]` or set RAINCLOUD_MIRROR" + ) diff --git a/raincloud/_transport.py b/raincloud/_transport.py new file mode 100644 index 0000000..30b9034 --- /dev/null +++ b/raincloud/_transport.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: 2026 Raincloud Maintainers +# SPDX-License-Identifier: Apache-2.0 +"""fsspec transport-only: copy a remote URL to a local temp file.""" +from __future__ import annotations + +from pathlib import Path + +import fsspec + +from .exceptions import ArtifactNotFound + + +def fetch(url: str, dest: Path) -> None: + """Stream the object at `url` into `dest`. Raise ArtifactNotFound on a miss. + + `url` is any fsspec-understood URL (file://, s3://, https://, ...). The + matching backend extra (raincloud[s3]/[http]) must be installed for + non-local schemes; fsspec raises ImportError otherwise, which we let + propagate as an actionable message. + """ + dest.parent.mkdir(parents=True, exist_ok=True) + try: + with fsspec.open(url, "rb") as src, open(dest, "wb") as out: + for chunk in iter(lambda: src.read(1024 * 1024), b""): + out.write(chunk) + except FileNotFoundError as e: + if dest.exists(): + dest.unlink() + raise ArtifactNotFound(url) from e + except Exception: + if dest.exists(): + dest.unlink() + raise diff --git a/raincloud/exceptions.py b/raincloud/exceptions.py new file mode 100644 index 0000000..d877c4e --- /dev/null +++ b/raincloud/exceptions.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2026 Raincloud Maintainers +# SPDX-License-Identifier: Apache-2.0 +"""Typed error hierarchy for the raincloud loader.""" +from __future__ import annotations + + +class RaincloudError(Exception): + """Base class for all loader errors.""" + + +class UnknownSlug(RaincloudError): + """Requested slug is not in the catalog.""" + + +class FormatUnavailable(RaincloudError): + """Requested format is not available for this slug.""" + + +class ArtifactNotFound(RaincloudError): + """Artifact key was not present at the transport source (clean miss).""" + + +class ChecksumMismatch(RaincloudError): + """Downloaded artifact's sha256 did not match the catalog.""" + + +class BuildToolingMissing(RaincloudError): + """Local build was needed but `raincloud[build]` is not installed (or the + build subtree failed to import for another reason — the message says which).""" + + +class BuildFailed(RaincloudError): + """The local build subprocess ran but exited non-zero.""" + + +class OfflineMiss(RaincloudError): + """Offline mode is on and the artifact is not in the local cache.""" + + +class MissingDependency(RaincloudError): + """An optional convenience dependency (duckdb/pandas) is not installed.""" diff --git a/scripts/pipeline/browse.py b/scripts/pipeline/browse.py index e6bcd34..3bbc269 100644 --- a/scripts/pipeline/browse.py +++ b/scripts/pipeline/browse.py @@ -65,6 +65,7 @@ REPO_ROOT, iter_datasets, load_manifest, + outputs_base, outputs_root, prepared_parquet, prepared_parquet_hydrated, @@ -1571,9 +1572,9 @@ def on_input_changed(self, event: Input.Changed) -> None: import traceback tb = traceback.format_exc() try: - (REPO_ROOT / "outputs" / "_browse_search_error.log").parent.mkdir( - parents=True, exist_ok=True) - (REPO_ROOT / "outputs" / "_browse_search_error.log").write_text(tb) + err_log = outputs_base() / "_browse_search_error.log" + err_log.parent.mkdir(parents=True, exist_ok=True) + err_log.write_text(tb) except Exception: pass try: diff --git a/scripts/pipeline/build.py b/scripts/pipeline/build.py index 6b7eec3..ccb545b 100644 --- a/scripts/pipeline/build.py +++ b/scripts/pipeline/build.py @@ -26,7 +26,7 @@ from .extract import extract from .fetch import fetch from .parse import parse -from .spec import REPO_ROOT, iter_datasets, load_manifest +from .spec import display_path, iter_datasets, load_manifest, workdir_root from .transform import transform from .validate import validate from .write import write @@ -45,10 +45,10 @@ def run_one(spec: dict, *, strict: bool, clean_workdir: bool = False) -> bool: validate(spec, written, strict=strict) convert(spec) # no-op unless spec sets convert.vortex = true if clean_workdir: - wd = REPO_ROOT / "_workdir" / spec["slug"] + wd = workdir_root() / spec["slug"] if wd.exists(): shutil.rmtree(wd, ignore_errors=True) - print(f" [clean] removed {wd.relative_to(REPO_ROOT)}") + print(f" [clean] removed {display_path(wd)}") return True except NotImplementedError as e: print(f" SKIP (not yet implemented): {e}") diff --git a/scripts/pipeline/convert.py b/scripts/pipeline/convert.py index 586a655..01d960a 100644 --- a/scripts/pipeline/convert.py +++ b/scripts/pipeline/convert.py @@ -54,7 +54,7 @@ import pyarrow.parquet as pq from .spec import ( - REPO_ROOT, + display_path, iter_datasets, load_manifest, prepared_parquet, @@ -140,10 +140,7 @@ def batches(): sz_p = parquet.stat().st_size sz_v = vortex_path.stat().st_size - try: - log_path = vortex_path.relative_to(REPO_ROOT) - except ValueError: - log_path = vortex_path + log_path = display_path(vortex_path) print( f" wrote {log_path} " f"{sz_v / 1e6:.1f} MB (ratio {sz_v / sz_p:.3f}) in {elapsed:.1f}s" @@ -163,7 +160,7 @@ def convert(spec: dict) -> Path | None: out_slug = spec["slug"] parquet = prepared_parquet(out_slug) if not parquet.exists(): - raise FileNotFoundError(f"no parquet at {parquet.relative_to(REPO_ROOT)}") + raise FileNotFoundError(f"no parquet at {display_path(parquet)}") return _convert_one(parquet, prepared_vortex(out_slug), out_slug) diff --git a/scripts/pipeline/custom_fetch.py b/scripts/pipeline/custom_fetch.py index b7ef119..ef07c2d 100644 --- a/scripts/pipeline/custom_fetch.py +++ b/scripts/pipeline/custom_fetch.py @@ -15,7 +15,7 @@ from pathlib import Path from .fetch import slug_dir -from .spec import REPO_ROOT, spec_field +from .spec import display_path, spec_field def _http_download(url: str, dest: Path, timeout: int = 300) -> None: @@ -68,11 +68,11 @@ def public_bi_fetch(spec: dict) -> list[Path]: name = url.rsplit("/", 1)[-1] dest = target_dir / name if dest.exists(): - print(f" [cached] {dest.relative_to(REPO_ROOT)} ({dest.stat().st_size:,} B)") + print(f" [cached] {display_path(dest)} ({dest.stat().st_size:,} B)") else: print(f" fetching {url}") _http_download(url, dest) - print(f" -> {dest.relative_to(REPO_ROOT)} ({dest.stat().st_size:,} B)") + print(f" -> {display_path(dest)} ({dest.stat().st_size:,} B)") out.append(dest) # 3. Download each partition's `_N.table.sql`. The handler needs them @@ -86,7 +86,7 @@ def public_bi_fetch(spec: dict) -> list[Path]: ) schema_dest = target_dir / f"{workload}_{n}.table.sql" if schema_dest.exists(): - print(f" [cached] {schema_dest.relative_to(REPO_ROOT)}") + print(f" [cached] {display_path(schema_dest)}") else: print(f" fetching {schema_url}") try: diff --git a/scripts/pipeline/docs.py b/scripts/pipeline/docs.py index b677e5b..61e726e 100644 --- a/scripts/pipeline/docs.py +++ b/scripts/pipeline/docs.py @@ -57,6 +57,49 @@ ) +def _sha256_or_reuse( + path, + current_size: int | None, + prior_size: int | None, + prior_sha: str | None, + *, + force: bool = False, +) -> str | None: + """Reuse `prior_sha` when size is unchanged + prior sha is known; else hash. + + Bytes-on-disk are content-addressed in the snapshot, so a matching size + is a near-perfect indicator the content is unchanged. Avoids re-streaming + multi-GB artifacts on every snapshot regen. + + The size-only reuse has one blind spot: a rebuild that produces + *different content at the same byte length* keeps the stale sha, which then + permanently fails `publish`'s integrity gate (re-running plain `docs + snapshot` reuses the same stale sha). `force=True` (the `--rehash` flag) + recomputes every present file's sha to break out of that, while still + preserving the prior sha for files that are missing this run. + """ + if current_size is None: + # File missing; preserve the prior sha so partial regens don't dash + # out tracked ground truth (existing fallback semantics). + return prior_sha + if not force and prior_sha is not None and prior_size == current_size: + return prior_sha + return _sha256_for_path(path) + + +def _sha256_for_path(path) -> str | None: + """Stream a file's sha256, or None if it doesn't exist. + + Delegates to the loader's single sha256 implementation so the pipeline and + the loader can't drift on chunk size / semantics. + """ + from raincloud._cache import sha256_file + p = Path(path) + if not p.exists(): + return None + return sha256_file(p) + + def _generation_header(kind: str) -> str: ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") return (f"