statcatlib 0.16.2__tar.gz → 0.17.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {statcatlib-0.16.2 → statcatlib-0.17.0}/PKG-INFO +2 -2
  2. {statcatlib-0.16.2 → statcatlib-0.17.0}/pyproject.toml +2 -2
  3. {statcatlib-0.16.2 → statcatlib-0.17.0}/pyproject.toml.orig +3 -3
  4. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/EGM.md +214 -33
  5. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/__init__.py +2 -0
  6. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/admin.py +112 -98
  7. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/bundle.py +18 -5
  8. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/catalog.py +218 -124
  9. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/cli/EGM.md +53 -15
  10. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/cli/__init__.py +422 -20
  11. statcatlib-0.17.0/src/statcatlib/embargo.py +253 -0
  12. statcatlib-0.17.0/src/statcatlib/gc.py +697 -0
  13. statcatlib-0.17.0/src/statcatlib/jobs.py +672 -0
  14. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/EGM.md +74 -24
  15. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/blob.py +16 -1
  16. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/cross_product.py +83 -35
  17. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/inventory.py +177 -34
  18. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/working_copy.py +74 -15
  19. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/upload.py +1 -10
  20. {statcatlib-0.16.2 → statcatlib-0.17.0}/LICENSE +0 -0
  21. {statcatlib-0.16.2 → statcatlib-0.17.0}/README.md +0 -0
  22. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/checks.py +0 -0
  23. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/diagnostics.py +0 -0
  24. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/expr.py +0 -0
  25. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/EGM.md +0 -0
  26. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/__init__.py +0 -0
  27. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/agents.py +0 -0
  28. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/catalog.py +0 -0
  29. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/classification.py +0 -0
  30. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/dataset.py +0 -0
  31. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/distribution.py +0 -0
  32. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/licence.py +0 -0
  33. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/quality.py +0 -0
  34. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/skos.py +0 -0
  35. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/spatial.py +0 -0
  36. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/temporal.py +0 -0
  37. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/types.py +0 -0
  38. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/py.typed +0 -0
  39. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/query.py +0 -0
  40. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/sources/EGM.md +0 -0
  41. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/sources/__init__.py +0 -0
  42. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/sources/ckan.py +0 -0
  43. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/sources/sdmx.py +0 -0
  44. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/__init__.py +0 -0
  45. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/lance.py +0 -0
  46. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/row.py +0 -0
  47. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/schema.py +0 -0
  48. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/scopes.py +0 -0
  49. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/sentinel.py +0 -0
  50. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/store.py +0 -0
  51. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/xhtml.py +0 -0
  52. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/testing.py +0 -0
  53. {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/urn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statcatlib
3
- Version: 0.16.2
3
+ Version: 0.17.0
4
4
  Summary: Queryable open data catalog engine for DCAT-AP, StatDCAT-AP, CKAN, and SDMX
5
5
  Keywords: sdmx,dcat,open-data,catalog,duckdb,statistical-data,ckan
6
6
  Author: gabrielgellner
@@ -18,7 +18,7 @@ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
18
18
  Requires-Dist: attrs>=26.1.0
19
19
  Requires-Dist: duckdb>=2.0.0
20
20
  Requires-Dist: httpx>=0.28
21
- Requires-Dist: sdmxlib>=0.87,<0.88
21
+ Requires-Dist: sdmxlib>=0.88,<0.89
22
22
  Requires-Dist: obstore>=0.12,<0.13 ; extra == 'blob'
23
23
  Requires-Dist: azure-identity>=1 ; extra == 'blob'
24
24
  Requires-Dist: typer>=0.15 ; extra == 'blob'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "statcatlib"
3
- version = "0.16.2"
3
+ version = "0.17.0"
4
4
  description = "Queryable open data catalog engine for DCAT-AP, StatDCAT-AP, CKAN, and SDMX"
5
5
  license = "Apache-2.0"
6
6
  license-files = ["LICENSE"]
@@ -29,7 +29,7 @@ dependencies = [
29
29
  "attrs>=26.1.0",
30
30
  "duckdb>=2.0.0",
31
31
  "httpx>=0.28",
32
- "sdmxlib>=0.87,<0.88",
32
+ "sdmxlib>=0.88,<0.89",
33
33
  ]
34
34
 
35
35
  [[project.authors]]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "statcatlib"
3
- version = "0.16.2"
3
+ version = "0.17.0"
4
4
  description = "Queryable open data catalog engine for DCAT-AP, StatDCAT-AP, CKAN, and SDMX"
5
5
  license = "Apache-2.0"
6
6
  license-files = ["LICENSE"]
@@ -27,9 +27,9 @@ dependencies = [
27
27
  # by DuckDB 1.x, so by statcatlib 0.8 or earlier.
28
28
  "duckdb>=2.0.0",
29
29
  "httpx>=0.28",
30
- # <0.88: every suite library is pre-1.0, so a minor bump may break
30
+ # <0.89: every suite library is pre-1.0, so a minor bump may break
31
31
  # (GH-67).
32
- "sdmxlib>=0.87,<0.88",
32
+ "sdmxlib>=0.88,<0.89",
33
33
  ]
34
34
 
35
35
  [project.optional-dependencies]
@@ -3,8 +3,8 @@
3
3
  ## Overview
4
4
 
5
5
  `statcatlib` is the package root. `__init__.py` re-exports the public API of
6
- `model`, `sources` and `store`; `CatalogAdmin` from `admin.py`; `Catalog`, `Product`, `Edition`,
7
- `SealOutcomes` and `NoLiveEditionError` from `catalog.py`; the errors `InventoryConflictError`,
6
+ `model`, `sources` and `store`; `CatalogAdmin` from `admin.py`; `EmbargoReader` from `embargo.py`;
7
+ `Catalog`, `Product`, `Edition`, `SealOutcomes` and `NoLiveEditionError` from `catalog.py`; the errors `InventoryConflictError`,
8
8
  `InventoryContentionError`, `InventoryRuleError`, `InventoryWriteRefusedError`,
9
9
  `NonFastForwardError` and `CrossProductRuleError` from `store/inventory.py`; `BlobLocation` and `TokenIdentity`
10
10
  from `store/blob.py`; `upload_edition`, `UploadReport` and `UploadCheckError` from `upload.py`; `WorkingCopyExistsError`,
@@ -14,10 +14,11 @@ sentinel module; and part of `expr` (listed below). It exposes `checks`,
14
14
  `query` and `urn` as submodules (`sc.checks`, `sc.query`, `sc.urn`). The
15
15
  top-level modules hold the parts that sit across subpackages: the catalog of
16
16
  products (`catalog.py`), the operational changes to its Inventory
17
- (`admin.py`), seal checks (`checks.py`), the `statcat` section of
17
+ (`admin.py`), the reader of embargoed editions (`embargo.py`), seal checks (`checks.py`), the `statcat` section of
18
18
  an sdmxlib edition manifest (`bundle.py`), the integrity and support
19
19
  checks behind `statcat verify-catalog` and `statcat doctor`
20
- (`diagnostics.py`), the copy of a sealed edition to object storage (`upload.py`), composable query filters
20
+ (`diagnostics.py`), the copy of a sealed edition to object storage (`upload.py`), the
21
+ jobs configuration the release and GC jobs read (`jobs.py`), the GC job (`gc.py`), composable query filters
21
22
  (`query.py`), traversal expressions (`expr.py`), SDMX URN builders (`urn.py`)
22
23
  and test catalogs (`testing.py`).
23
24
 
@@ -88,7 +89,8 @@ and test catalogs (`testing.py`).
88
89
  `cat.product(id).view().<name>` for every name in
89
90
  `store.store.VIEW_ATTRIBUTES`.
90
91
  - `Product`: `product_id`; `view(*, embedding_model=None, query_prefix="")
91
- -> EditionView`; `editions() -> list[Edition]` (the live head, with the
92
+ -> EditionView`, which calls `_open_view` with the live head and the stamps
93
+ directory `<root>/verified`; `editions() -> list[Edition]` (the live head, with the
92
94
  `previous` its manifest records; empty without one); `working_copy(*,
93
95
  kind=None, lifecycle="revisable", resume=False, embedding_model=None,
94
96
  document_prefix="", query_prefix="") -> WorkingCopy` (`kind` is `"data"`
@@ -97,6 +99,24 @@ and test catalogs (`testing.py`).
97
99
  `Catalog.product` is the only constructor.
98
100
  - `NoLiveEditionError` (`LookupError`): `view()` on a product with no live
99
101
  edition.
102
+ - `_open_read_only(path, *, opener) -> _Opening`: `Catalog.open`'s checks and
103
+ its `_Opening`, shared with `EmbargoReader.open`; *opener* names the call in
104
+ messages.
105
+ - `_open_view(engine, root, location, product_id, edition_id, *,
106
+ manifest_sha256, stamps, state, embedding_model, query_prefix) ->
107
+ tuple[EditionView, EditionRegistry, _Store]`: the one place an `EditionView`
108
+ of a recorded edition is built, shared by `Product.view` and
109
+ `EmbargoReader.view` (Phase 5 D12). *engine* is a callable that builds the
110
+ caller's engine on first use, so an edition refused before it opens builds none.
111
+ On local disk it attaches
112
+ `<root>/products/<product>/<edition>`; over a `BlobLocation`, `_checked_copy`
113
+ runs `_cached_edition` (the manifest first, then `pull_edition`), refuses an
114
+ edition with data files when the location holds no `secrets` (`ValueError`),
115
+ and runs `check_on_open(stamps=, references=bool(manifest.bindings),
116
+ extensions={"statcat": ...})`. Then `LocalRegistry.edition(...,
117
+ manifest_sha256=)`, its `ManifestError` re-raised as `IncompatibleCatalogError`,
118
+ and a read-only `_Store`. The caller keeps the registry and the store to close.
119
+ *state* names the entry's state in messages.
100
120
  - `SealOutcomes`: a frozen attrs value with `sealed` (product id to
101
121
  `Edition`, all recorded in one version) and `refused` (product id to the
102
122
  error: `SealCheckError`, `NonFastForwardError` or another
@@ -110,11 +130,15 @@ and test catalogs (`testing.py`).
110
130
  `CatalogAdmin` is exported from `statcatlib`; `EditionEntry` and `PruneReport`
111
131
  are imported from `statcatlib.admin`.
112
132
 
113
- - `CatalogAdmin.open(path)`: refuses a legacy layout
133
+ - `CatalogAdmin.open(path, *, actor=None, clock=None)`: *path* is a catalog
134
+ directory or a `BlobLocation`. A directory: refuses a legacy layout
114
135
  (`catalog._refuse_legacy_layout`) and a path with no `catalog.json`
115
- (`FileNotFoundError`), reads `catalog.json`, and holds an
116
- `InventoryWriter`. It writes nothing until a change is made. Direct
117
- construction raises `TypeError`.
136
+ (`FileNotFoundError`), and refuses `actor=` or `clock=` with `TypeError`. A
137
+ `BlobLocation`: builds `BlobInventoryStorage(location, actor=, clock=)`, and a
138
+ store with no `catalog.json` raises `FileNotFoundError`. Either way it reads
139
+ `catalog.json` and holds an `InventoryWriter`, and writes nothing until a
140
+ change is made. `repr` shows `storage.location`. Direct construction raises
141
+ `TypeError`.
118
142
  - Reads, each from a fresh `InventoryWriter.full()`: `embargo`,
119
143
  `products()` (every recorded product, any state) and
120
144
  `editions(product_id) -> list[EditionEntry]` (every entry the newest version
@@ -131,25 +155,28 @@ are imported from `statcatlib.admin`.
131
155
  `embargo-change`) and `describe(description, *, reason)` (sets
132
156
  `format_version` `"3"` and `description` in the root's `extensions.statcat`,
133
157
  keeping its other keys; kind `describe`).
158
+ - `advance_current() -> int | None`: `InventoryWriter.advance_current`; it
159
+ writes `CURRENT` only.
134
160
  - Product changes go through `_commit_product(product_id, edition_id, step,
135
- *, kind, reason)`, which applies one of sdmxlib's pure rules to the head's
136
- leaf with `datetime.now(UTC)` (the local clock, design decision P1) and
137
- wraps a `TransitionError` with `InventoryRuleError.wrapping`. A product the
161
+ *, kind, reason)`, which reads `storage.now()` once per head (the local
162
+ clock on disk, design decision P1; the storage clock on blob), applies one of
163
+ sdmxlib's pure rules to the head's leaf with it, returns `Change(now=)` with
164
+ the same time, and wraps a `TransitionError` with `InventoryRuleError.wrapping`. A product the
138
165
  head does not record raises `InventoryRuleError(rule="unknown")`. The
139
166
  writer's `commit` runs the cross-product rules on a release or a rollback
140
167
  and raises `CrossProductRuleError`; `admin.py` adds no rule of its own.
141
168
  - `prune(*, older_than, reason, dry_run=True) -> PruneReport` (design
142
- decision P7): refuses a blank *reason* or a window that is not positive
143
- (`ValueError`). It skips every product `_busy` names (an unsealed edition
169
+ decision P7): raises `TypeError` on a storage other than
170
+ `LocalInventoryStorage`, naming `statcat gc` and the release job; refuses a
171
+ blank *reason* or a window that is not positive (`ValueError`). Its cutoff
172
+ and tombstone time are `statcatlib.admin.datetime.now(UTC)`. It skips every product `_busy` names (an unsealed edition
144
173
  whose id is not tombstoned, a `journal/*.json` file, or an unreadable
145
- manifest), then runs three steps. `_prune_retire` retires superseded and
146
- withdrawn entries whose `state_changed_at` is before the cutoff, except
147
- the structure editions `_cited` returns (those a live or embargoed data
148
- edition's `partial_of` cites, `cross_product.cited_structures`; every
149
- structure edition when a manifest cannot be read), with
150
- sdmxlib's `retire`, in one `InventoryWriter.commit` of kind `retire`
151
- (`Change(leaves=..., retired=head tombstones + new)`), re-planned against
152
- each head and skipped when nothing qualifies. `_prune_dirs` deletes
174
+ manifest), then runs three steps. `_prune_retire` commits
175
+ `store.inventory.retire_change(head, cutoff, now=, artefacts=writer.artefacts,
176
+ skipped=)` in one `InventoryWriter.commit` of kind `retire`, re-planned
177
+ against each head; `None` writes nothing. A dry run plans the same change
178
+ against the head it read. `_new_tombstones` lists the tombstones the change
179
+ adds. `_prune_dirs` deletes
153
180
  `products/<p>/<edition_id>` for each tombstone whose `retired_at` is
154
181
  before the cutoff. `_prune_data` deletes each `data/` file no on-disk
155
182
  manifest lists (`sdmxlib.edition.edition_files`) whose mtime is before the
@@ -164,6 +191,122 @@ are imported from `statcatlib.admin`.
164
191
  `previous`, `state`, `manifest_sha256`, `schema_version`,
165
192
  `state_changed_at` and `release_at`.
166
193
 
194
+ ### `embargo.py`
195
+
196
+ `EmbargoReader` (Phase 5 D12) is exported from `statcatlib`. statcat-serve's preview
197
+ and pre-materialisation read through it (sdmxlib plan Q8, Q9).
198
+
199
+ - `EmbargoReader.open(location: str | Path | BlobLocation) -> Self`: runs
200
+ `catalog._open_read_only(opener="EmbargoReader.open")`, so it refuses what
201
+ `Catalog.open` refuses, then `refresh()`. Direct construction raises `TypeError`
202
+ naming `open`.
203
+ - `refresh() -> Self`: holds a `FullInventory` of its own over
204
+ `InventoryWriter(...).storage.read`, made once a version exists, and refreshes it.
205
+ - `editions(*, states=("embargoed",)) -> list[EditionEntry]`: the entries in
206
+ *states* of the held version, by product id and then leaf order, through
207
+ `admin._edition_entry`; empty before the first version. A string, or a state
208
+ outside sdmxlib's `State`, raises `ValueError`.
209
+ - `view(product_id, edition_id, *, embedding_model=None, query_prefix="") ->
210
+ EditionView`: an unrecorded product raises `InventoryRuleError(rule="unknown")`
211
+ (`admin._leaf`), as does an unrecorded edition; a `superseded` or `withdrawn`
212
+ entry raises `ValueError`, before anything under the edition is read. Otherwise
213
+ `catalog._open_view` on the reader's own `sdmxlib.EditionEngine`, with stamps in
214
+ `<root>/embargo-verified`.
215
+ - `close()`: closes every view's store and registry, then the engine; idempotent.
216
+ A closed reader's methods and views raise `RuntimeError`.
217
+
218
+ ### `jobs.py`
219
+
220
+ The jobs configuration (Phase 5 D7), imported from `statcatlib.jobs`; nothing is
221
+ re-exported from `statcatlib`. Standard library and `attrs` only.
222
+
223
+ - `load_jobs_config(path) -> JobsConfig`: reads the bytes, parses with
224
+ `tomllib`, decodes, and runs `RetentionPolicy.check()`. Every refusal raises
225
+ `JobsConfigError` (`ValueError`), its message starting with the path: an
226
+ unreadable file, not UTF-8, not TOML, an unknown key at any level (the top
227
+ level, `[retention]`, `[access]`, each allowlist entry, `[expiry]`), a value of
228
+ the wrong type (`bool` is no integer), a missing or other `format_version`
229
+ (`FORMAT_VERSION = 1`), a missing `[retention]` or required key, a bad duration
230
+ or instant, an `[expiry]` with `enabled = true` and no `not_before`,
231
+ `namespace`, `deployments` or `port`, a port outside 1 to 65535, or a failed
232
+ inequality.
233
+ - `JobsConfig(retention, access, expiry, sha256, format_version=1)`: `access` is
234
+ `None` when the file has no `[access]`; `expiry` is `ExpiryPolicy()` (disabled)
235
+ when it has no `[expiry]`; `sha256` is the file's, which each run logs.
236
+ - `RetentionPolicy(backup, gc_window, tombstone_window, inventory_window,
237
+ retire_after, pod_staleness, keep_roots=DEFAULT_KEEP_ROOTS (10),
238
+ upload_grace=DEFAULT_UPLOAD_GRACE (24 h))`, `durations()`, and `check()`,
239
+ which raises `JobsConfigError` naming both values for the first failure of:
240
+ every duration positive; `backup <= gc_window <= tombstone_window`;
241
+ `inventory_window >= gc_window + 1d`; `pod_staleness < inventory_window`;
242
+ `keep_roots >= 2`.
243
+ - `AccessPolicy(storage_account, container, allowlist, sentinels=(), scopes=())`
244
+ with `Principal(object_id, name)`; `storage_account`, `container`,
245
+ `allowlist` and `sentinels` are required keys, `sentinels` may be empty.
246
+ - `ExpiryPolicy(enabled=False, not_before=None, namespace=None, deployments=(),
247
+ port=None)`.
248
+ - `parse_duration(text) -> timedelta` (`^[0-9]+[smhd]$`) and
249
+ `format_duration(value) -> str` (the largest whole unit, as messages write it).
250
+ - `decode_registration(path, raw) -> Registration`: reads
251
+ `pending/<product_id>/<edition_id>.json` as the release job's fold and the GC job
252
+ read it. `ValueError` for a path of another shape, a product id that cannot name a
253
+ directory (`_PRODUCT_ID`, `store/blob.py`'s `_SAFE_ID` pattern), an edition id that
254
+ is no canonical UUIDv7, a document that is not one object with the keys
255
+ `edition_id` and `release_at` in that order, an `edition_id` other than the file
256
+ name's, a `release_at` that is neither `null` nor a zoned time, or bytes that differ
257
+ from `_pending_document` of their own decode.
258
+ - `Registration(product_id, edition_id, release_at)` (frozen): `path`, and
259
+ `edition_time`, the UUIDv7's time, which the tombstone window is measured from.
260
+ - `_pending_document(edition_id, release_at) -> bytes`: the `pending/` document's
261
+ bytes, the contract `docs/catalog-format.md` §1.5 states; `upload.py` writes with
262
+ it. `_stamp(moment)` writes a time in UTC to the millisecond with `Z`.
263
+ - `_emit(logger, level, event, message)`: logs *message* with the JSON object *event*
264
+ as the record's `_EVENT_ATTRIBUTE` (`"statcat_event"`), the D14 event a job command
265
+ prints as one line on standard output.
266
+
267
+ ### `gc.py`
268
+
269
+ The GC job (Phase 5 D10), imported from `statcatlib.gc`; nothing is re-exported from
270
+ `statcatlib`. `statcat gc` runs it.
271
+
272
+ - `collect_garbage(location, policy, *, storage=None, dry_run=False, forecast=None,
273
+ discard=()) -> GcReport`: runs `policy.check()` (`JobsConfigError` before any
274
+ read), parses each *discard* as `<product>/<edition>` (`ValueError`), reads
275
+ `storage.now()` once (`storage` defaults to `BlobInventoryStorage(location)`), then
276
+ `_Run`'s steps: `inventory()` (the head through `FullInventory`, then older roots
277
+ by `seq` until `keep_roots` are read and one created before `now - gc_window`,
278
+ never at or below `HISTORY.expired_through`; leaves by hash on `GC_THREADS`),
279
+ the discard refusal (`ValueError` naming `statcat admin cancel` for an edition the
280
+ head records or a root read keeps), `registrations()` (LIST `pending/`; a
281
+ registration of an edition the head records is `folded` once last modified
282
+ before the cutoff and is never read; any other is decoded with
283
+ `jobs.decode_registration`, and an unreadable one fails its product closed; one
284
+ whose `edition_time` is older than `tombstone_window` is `expired-registration`
285
+ and protects nothing), `manifests()` (every kept and registered manifest on
286
+ `GC_THREADS`; a kept one must match its `manifest_sha256`, a registered one must
287
+ name its path; a failure, or a kept tombstone with no entry read, fails the
288
+ product closed), `products()` (LIST `products/`: `data/` files no manifest lists,
289
+ older than `upload_grace`, outside failed-closed products, are `unreferenced`)
290
+ and `directories()` (`retired` for an edition tombstoned before the cutoff and not
291
+ kept; `discarded`; `gc.unregistered` for a directory nothing keeps or registers,
292
+ older than `upload_grace`). It logs every `gc.planned` line, then each
293
+ `gc.forecast_deviation`, then deletes unless `dry_run`, in three pooled phases:
294
+ registrations and `data/` files; each directory's `manifest.json` once its
295
+ registration, if any, is deleted; the rest once its manifest is deleted or absent.
296
+ With *forecast* only paths in both plans are deleted.
297
+ - `GcReport(planned, deleted, failed, failed_closed, unregistered, deviations,
298
+ dry_run)` and `Deletion(path, reason, edition=None)`, frozen. `Reason` is
299
+ `retired`, `unreferenced`, `folded`, `expired-registration` or `discarded`.
300
+ - `GcStoppedError(RuntimeError)`: no `catalog.json`, an opted-in catalog with no
301
+ `HISTORY`, an unreadable head, any root the reader skipped, a missing or invalid
302
+ root above `expired_through`, a missing or unhashed leaf. Nothing is deleted.
303
+ - `_delete(location, path)` refuses (`ValueError`) any path outside `products/` and
304
+ `pending/`, and returns a `BlobStoreError`'s text in place of raising it.
305
+ - Events go through `jobs._emit` on the `statcatlib.gc` logger: deletion log lines
306
+ (`event`, `path`, `reason`, `edition`, and `error` on `gc.delete_failed`) and D14
307
+ alerts (`event`, `severity`, `at`, `catalog`, and `product`, `edition`, `reason`,
308
+ `detail` when they apply).
309
+
167
310
  ### `checks.py`
168
311
 
169
312
  - `SealCheck`: the type `Callable[[EditionView], Iterable[Finding]]`, passed
@@ -179,16 +322,20 @@ are imported from `statcatlib.admin`.
179
322
  The module keeps its 0.11 name and describes editions. Imported from
180
323
  `statcatlib.bundle`; nothing here is re-exported from `statcatlib`:
181
324
 
182
- - `catalog_section(store: _Store, *, kind="data") -> dict`: builds the
183
- `statcat` manifest section (format 3: `format`, `kind`,
325
+ - `catalog_section(store: _Store, *, kind="data", lifecycle) -> dict`: builds the
326
+ `statcat` manifest section (format 3: `format`, `kind`, `lifecycle`,
184
327
  `statcatlib_version`, `datasets`, `search`) from a working copy's open
185
- store. `WorkingCopy.seal` passes it to
328
+ store. `lifecycle` (`"final"` or `"revisable"`, from 0.17) is the one the
329
+ seal compacts by, which the release job's fold records for a product the
330
+ Inventory does not record (`store.working_copy.fold_lifecycle`). `WorkingCopy.seal` passes it to
186
331
  `sdmxlib.edition.seal(..., extensions={"statcat": ...})`. It raises
187
332
  `ValueError` when `search/` holds files and no `index.json`.
188
333
  - `check_catalog(root, manifest, reg, profile) -> list[Finding]`: the
189
334
  `statcat` check, with sdmxlib 0.80's `Check` signature, run as an extension
190
335
  of `sdmxlib.edition.check_edition`. It refuses a section format other than
191
- 3, a `kind` outside `PRODUCT_KINDS`, and a `structure` edition whose
336
+ 3, a `kind` outside `PRODUCT_KINDS`, a `lifecycle` other than `final` or
337
+ `revisable` (an absent one passes: editions sealed before 0.17 have none),
338
+ and a `structure` edition whose
192
339
  manifest binds a dataflow or whose store holds a dataflow, binding or
193
340
  partial.
194
341
  - `structure_content(conn) -> list[Finding]`: one finding per `Dataflow`,
@@ -230,8 +377,8 @@ The module keeps its 0.11 name and describes editions. Imported from
230
377
  - `UploadCheckError(findings)` (`RuntimeError`): any finding, an oversize file, a
231
378
  path leaving its directory, a rotted local file, a differing manifest, or a
232
379
  differing `pending/` document.
233
- - `_pending_document(edition_id, release_at) -> bytes`: the `pending/` document's
234
- bytes, the contract `docs/catalog-format.md` §1.5 states.
380
+ - `_pending_document` comes from `jobs.py`, which the release job's fold and GC
381
+ decode against.
235
382
  - `_lookup(rest, manifest)`: adapts `rest.head` to sdmxlib's `Lookup`, asking once per
236
383
  file. `_store_path(manifest, entry)` normalises `../data/`, which `obstore` refuses.
237
384
  - `MAX_PUT_BYTES`: Put Blob's limit, 5,000 MiB.
@@ -387,12 +534,21 @@ read.
387
534
  store takes a `BlobLocation` the caller built. `catalog.py` and `store/blob.py` read
388
535
  no environment variable.
389
536
  - Only `CatalogAdmin` changes the Inventory, apart from a seal's record and
390
- the journal repair. No attribute of `Catalog`, `Product` or `EditionView`
391
- returns a `CatalogAdmin` or an embargoed, superseded or withdrawn edition.
537
+ the journal repair. It runs over a catalog directory or a `BlobLocation`,
538
+ `prune` on a directory only. No attribute of `Catalog`, `Product` or `EditionView`
539
+ returns a `CatalogAdmin`, an `EmbargoReader` (spec §6.4) or an embargoed,
540
+ superseded or withdrawn edition. `Catalog`'s reads (`products`, `Product.view`,
541
+ `Product.editions`) go through a `LiveInventory`.
392
542
  - Every `CatalogAdmin` rule is re-evaluated against the head each commit
393
543
  lands on: a rollback whose `if_live` is no longer live, a release that is
394
544
  no longer a fast-forward, and a seal record computed before the embargo was
395
545
  turned on are each refused or recomputed.
546
+ - `EmbargoReader` writes nothing to the catalog. Over a `BlobLocation` it writes to its
547
+ `cache_dir` alone: the files of the embargoed and live editions it views, and their
548
+ receive stamps under `<cache_dir>/<catalog_id>/embargo-verified`. That `cache_dir`
549
+ holds embargoed bytes, so a deployment gives the reader its own. A superseded or
550
+ withdrawn edition, or one the Inventory does not record, is refused before any read
551
+ under it.
396
552
  - `Product.view` passes the Inventory's `manifest_sha256` to
397
553
  `sdmxlib.LocalRegistry.edition`, which hashes the bytes of
398
554
  `manifest.json` it parses. A missing, malformed or different manifest
@@ -409,6 +565,13 @@ read.
409
565
  none. A refusal in one product never stops another's. A crash during the
410
566
  batch leaves each sealed, unrecorded edition's journal entry, so its
411
567
  product's next `working_copy()` records it, in a version of its own.
568
+ - `collect_garbage` deletes nothing when it cannot read the Inventory back through
569
+ the window (`GcStoppedError`), nothing under the `data/` of a product whose
570
+ manifest or registration it cannot read or verify, no file a kept or registered
571
+ manifest lists, no `data/` file younger than `upload_grace`, no edition directory
572
+ whose tombstone is within the window or that no Inventory version read records,
573
+ and nothing under `inventory/` or `history/`. A directory loses `manifest.json`
574
+ before any other file, and keeps every file when it keeps its manifest.
412
575
  - `CatalogAdmin.prune` never retires or deletes anything of a product with
413
576
  an unsealed edition or a seal journal entry, deletes only the directories
414
577
  of tombstoned editions, and deletes no Inventory root, leaf or tombstone.
@@ -423,21 +586,39 @@ read.
423
586
  at INFO, and writes and removes a `.statcat-link-probe.*` file and its hard
424
587
  link.
425
588
  - `Product.view` attaches the edition to the catalog's engine
426
- (`LocalRegistry.edition(path, engine=, manifest_sha256=)`). `Product.working_copy` writes as
589
+ (`LocalRegistry.edition(path, engine=, manifest_sha256=)`), and
590
+ `EmbargoReader.view` to the reader's; over a `BlobLocation` each writes the pulled
591
+ edition and its stamp under its own `cache_dir`. `Product.working_copy` writes as
427
592
  `store/EGM.md` describes.
428
593
  - `synthetic_catalog` writes a whole catalog: `catalog.json`, sdmxlib's
429
594
  synthetic edition, the catalog rows, the search index when lancedb is
430
595
  installed, the manifest and Inventory version 1. `temporary_catalog`
431
596
  deletes its directory on exit.
432
597
  - Each `CatalogAdmin` change writes one Inventory version (a root, and for a
433
- product change one leaf) and `CURRENT`. `prune(dry_run=False)` writes at
598
+ product change one leaf) and `CURRENT`; over a `BlobLocation` it sends one
599
+ anonymous `HEAD` for the storage clock unless given `clock=`.
600
+ `advance_current` writes `CURRENT` alone, or nothing. `prune(dry_run=False)` writes at
434
601
  most one, of kind `retire`, deletes edition directories and `data/` files,
435
602
  and logs each deleted path at INFO and each failed deletion at WARNING on
436
603
  the `statcatlib.admin` logger.
437
604
  - `diagnostics.verify_catalog` opens each edition in a DuckDB engine of
438
605
  `check_edition`'s own; neither it nor `diagnostics.doctor` writes a file.
439
- - These modules make no network calls.
606
+ - These modules make no network calls, except `upload.py`, `admin.py`, `gc.py`,
607
+ `catalog.py` and `embargo.py`, which reach a store only through the `BlobLocation`
608
+ they are given. `jobs.py` reads
609
+ the one file it is given.
610
+ - `gc.collect_garbage` writes nothing. It deletes under `products/` and `pending/`
611
+ only, unless `dry_run`, and logs each event on the `statcatlib.gc` logger at INFO
612
+ (`gc.planned`, `gc.deleted`), WARNING (`gc.failed_closed`, `gc.unregistered`,
613
+ `gc.forecast_deviation`) or ERROR (`gc.delete_failed`). It sends one anonymous
614
+ `HEAD` for the storage clock unless its storage has a `clock=`.
440
615
 
441
616
  ## Verification
442
617
 
443
618
  - Run: `just ci` (unit tier alone: `uv run pytest -m unit`).
619
+ - `EmbargoReader`: `tests/test_embargo_reader.py` (a local catalog), the
620
+ `TestNoOperationalReach` and blob-extra tests in `tests/test_public_api.py`, and,
621
+ against Azurite, `tests/stores/test_embargo_reader.py`.
622
+ - GC: `tests/test_gc.py` (every rule over a `MemoryStore`, with the helpers in
623
+ `tests/_gc.py`), `tests/test_cli_gc.py` and, against Azurite,
624
+ `tests/stores/test_gc.py`; `decode_registration` in `tests/test_jobs_config.py`.
@@ -43,6 +43,7 @@ from statcatlib.sources import (
43
43
  from statcatlib import checks, query, urn
44
44
  from statcatlib.admin import CatalogAdmin
45
45
  from statcatlib.catalog import Catalog, Edition, NoLiveEditionError, Product, SealOutcomes
46
+ from statcatlib.embargo import EmbargoReader
46
47
  from statcatlib.expr import (
47
48
  AliasedExpr,
48
49
  CountExpr,
@@ -151,6 +152,7 @@ __all__ = [
151
152
  "Edition",
152
153
  "EditionCheckError",
153
154
  "EditionView",
155
+ "EmbargoReader",
154
156
  "EmbeddingMetadata",
155
157
  "ExistsExpr",
156
158
  "Expr",