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.
- {statcatlib-0.16.2 → statcatlib-0.17.0}/PKG-INFO +2 -2
- {statcatlib-0.16.2 → statcatlib-0.17.0}/pyproject.toml +2 -2
- {statcatlib-0.16.2 → statcatlib-0.17.0}/pyproject.toml.orig +3 -3
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/EGM.md +214 -33
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/__init__.py +2 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/admin.py +112 -98
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/bundle.py +18 -5
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/catalog.py +218 -124
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/cli/EGM.md +53 -15
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/cli/__init__.py +422 -20
- statcatlib-0.17.0/src/statcatlib/embargo.py +253 -0
- statcatlib-0.17.0/src/statcatlib/gc.py +697 -0
- statcatlib-0.17.0/src/statcatlib/jobs.py +672 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/EGM.md +74 -24
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/blob.py +16 -1
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/cross_product.py +83 -35
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/inventory.py +177 -34
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/working_copy.py +74 -15
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/upload.py +1 -10
- {statcatlib-0.16.2 → statcatlib-0.17.0}/LICENSE +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/README.md +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/checks.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/diagnostics.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/expr.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/EGM.md +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/__init__.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/agents.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/catalog.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/classification.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/dataset.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/distribution.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/licence.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/quality.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/skos.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/spatial.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/temporal.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/model/types.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/py.typed +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/query.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/sources/EGM.md +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/sources/__init__.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/sources/ckan.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/sources/sdmx.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/__init__.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/lance.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/row.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/schema.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/scopes.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/sentinel.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/store.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/store/xhtml.py +0 -0
- {statcatlib-0.16.2 → statcatlib-0.17.0}/src/statcatlib/testing.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
30
|
+
# <0.89: every suite library is pre-1.0, so a minor bump may break
|
|
31
31
|
# (GH-67).
|
|
32
|
-
"sdmxlib>=0.
|
|
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`; `
|
|
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`),
|
|
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
|
|
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)`:
|
|
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`),
|
|
116
|
-
`
|
|
117
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
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):
|
|
143
|
-
|
|
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`
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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. `
|
|
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`,
|
|
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
|
|
234
|
-
|
|
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.
|
|
391
|
-
|
|
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=)`)
|
|
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
|
|
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",
|