statcatlib 0.12.0__tar.gz → 0.13.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.12.0 → statcatlib-0.13.0}/PKG-INFO +2 -2
- {statcatlib-0.12.0 → statcatlib-0.13.0}/pyproject.toml +2 -2
- {statcatlib-0.12.0 → statcatlib-0.13.0}/pyproject.toml.orig +3 -3
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/EGM.md +88 -28
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/__init__.py +6 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/admin.py +66 -7
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/bundle.py +41 -8
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/catalog.py +314 -61
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/cli/EGM.md +2 -2
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/cli/__init__.py +13 -5
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/diagnostics.py +120 -15
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/sources/EGM.md +20 -3
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/sources/__init__.py +9 -1
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/sources/sdmx.py +62 -1
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/EGM.md +162 -40
- statcatlib-0.13.0/src/statcatlib/store/cross_product.py +578 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/inventory.py +130 -37
- statcatlib-0.13.0/src/statcatlib/store/working_copy.py +1842 -0
- statcatlib-0.13.0/src/statcatlib/testing.py +510 -0
- statcatlib-0.12.0/src/statcatlib/store/working_copy.py +0 -928
- statcatlib-0.12.0/src/statcatlib/testing.py +0 -79
- {statcatlib-0.12.0 → statcatlib-0.13.0}/LICENSE +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/README.md +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/checks.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/expr.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/EGM.md +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/__init__.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/agents.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/catalog.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/classification.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/dataset.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/distribution.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/licence.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/quality.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/skos.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/spatial.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/temporal.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/types.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/py.typed +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/query.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/sources/ckan.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/__init__.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/lance.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/row.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/schema.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/scopes.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/sentinel.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/store.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/xhtml.py +0 -0
- {statcatlib-0.12.0 → statcatlib-0.13.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.13.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.84,<0.85
|
|
22
22
|
Requires-Dist: lancedb>=0.39 ; extra == 'search'
|
|
23
23
|
Requires-Dist: sentence-transformers>=3.0 ; extra == 'search'
|
|
24
24
|
Requires-Dist: typer>=0.15 ; extra == 'search'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "statcatlib"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.13.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.84,<0.85",
|
|
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.13.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.85: every suite library is pre-1.0, so a minor bump may break
|
|
31
31
|
# (GH-67).
|
|
32
|
-
"sdmxlib>=0.
|
|
32
|
+
"sdmxlib>=0.84,<0.85",
|
|
33
33
|
]
|
|
34
34
|
|
|
35
35
|
[project.optional-dependencies]
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
`statcatlib` is the package root. `__init__.py` re-exports the public API of
|
|
6
6
|
`model`, `sources` and `store`; `CatalogAdmin` from `admin.py`; `Catalog`, `Product`, `Edition`,
|
|
7
7
|
`SealOutcomes` and `NoLiveEditionError` from `catalog.py`; the errors `InventoryConflictError`,
|
|
8
|
-
`InventoryContentionError`, `InventoryRuleError
|
|
9
|
-
from `store/inventory.py`, and `WorkingCopyExistsError`,
|
|
10
|
-
`WorkingCopyClosedError`, `SealCheckError` and `JournalError` from
|
|
8
|
+
`InventoryContentionError`, `InventoryRuleError`, `NonFastForwardError` and
|
|
9
|
+
`CrossProductRuleError` from `store/inventory.py`, and `WorkingCopyExistsError`,
|
|
10
|
+
`WorkingCopyClosedError`, `SealCheckError`, `EditionCheckError` and `JournalError` from
|
|
11
11
|
`store/working_copy.py`; `NotLoadedError` and `is_unloaded` from the store's
|
|
12
12
|
sentinel module; and part of `expr` (listed below). It exposes `checks`,
|
|
13
13
|
`query` and `urn` as submodules (`sc.checks`, `sc.query`, `sc.urn`). The
|
|
@@ -57,11 +57,25 @@ and test catalogs (`testing.py`).
|
|
|
57
57
|
- `seal(working_copies, *, checks=()) -> SealOutcomes`: the batched seal.
|
|
58
58
|
It refuses, with `ValueError`, a working copy of another catalog or two
|
|
59
59
|
of one product, and raises `WorkingCopyClosedError` for a closed one,
|
|
60
|
-
before sealing anything.
|
|
61
|
-
checks,
|
|
62
|
-
|
|
63
|
-
`
|
|
64
|
-
|
|
60
|
+
before sealing anything. `_seal_each` then runs the structure pass
|
|
61
|
+
(`_seal_ready`: `WorkingCopy._precheck(checks, batch=)` for each
|
|
62
|
+
structure working copy, whose journal repair leaves the batch's other
|
|
63
|
+
products out; `_cross_ready`, the cross-product pre-check of the pass's
|
|
64
|
+
working copies together through `precheck_refusals`; then
|
|
65
|
+
`_seal_edition` for each), the structure-check pass
|
|
66
|
+
(`_check_structures`: each structure edition's `_publish_check`, given
|
|
67
|
+
the batch's other structure editions, repeated until it drops none) and
|
|
68
|
+
the data pass (the same `_seal_ready`, its cross-product pre-check given
|
|
69
|
+
the checked structure editions as `Planned` records (`_fixed`), then
|
|
70
|
+
`_check_partials`: the `_publish_check` of each data edition whose
|
|
71
|
+
partials record a `partial_of`, given every structure product's editions
|
|
72
|
+
with the batch's structure editions in their place). `_record_batch`
|
|
73
|
+
records every sealed edition in one `InventoryWriter.commit`;
|
|
74
|
+
`_batch_leaves` leaves out each product whose `_next_leaf` raises
|
|
75
|
+
`InventoryRuleError`, each the cross-product rules refuse, and each
|
|
76
|
+
edition whose publish check was given a left-out one (`_drop_dependents`),
|
|
77
|
+
running the rules again until a pass drops none, and counts an edition a
|
|
78
|
+
journal repair recorded already as recorded. `RuntimeError` on a catalog from `open`.
|
|
65
79
|
- `search` and `facets` raise `NotImplementedError` naming
|
|
66
80
|
`product(id).view()`. `__getattr__` raises `AttributeError` naming
|
|
67
81
|
`cat.product(id).view().<name>` for every name in
|
|
@@ -69,8 +83,9 @@ and test catalogs (`testing.py`).
|
|
|
69
83
|
- `Product`: `product_id`; `view(*, embedding_model=None, query_prefix="")
|
|
70
84
|
-> EditionView`; `editions() -> list[Edition]` (the live head, with the
|
|
71
85
|
`previous` its manifest records; empty without one); `working_copy(*,
|
|
72
|
-
lifecycle="revisable", resume=False, embedding_model=None,
|
|
73
|
-
document_prefix="", query_prefix="") -> WorkingCopy
|
|
86
|
+
kind=None, lifecycle="revisable", resume=False, embedding_model=None,
|
|
87
|
+
document_prefix="", query_prefix="") -> WorkingCopy` (`kind` is `"data"`
|
|
88
|
+
or `"structure"`; `None` takes the recorded one, or `"data"`); `rollback()`, which
|
|
74
89
|
raises `AttributeError` naming `CatalogAdmin.rollback`.
|
|
75
90
|
`Catalog.product` is the only constructor.
|
|
76
91
|
- `NoLiveEditionError` (`LookupError`): `view()` on a product with no live
|
|
@@ -78,7 +93,9 @@ and test catalogs (`testing.py`).
|
|
|
78
93
|
- `SealOutcomes`: a frozen attrs value with `sealed` (product id to
|
|
79
94
|
`Edition`, all recorded in one version) and `refused` (product id to the
|
|
80
95
|
error: `SealCheckError`, `NonFastForwardError` or another
|
|
81
|
-
`InventoryRuleError
|
|
96
|
+
`InventoryRuleError` (`CrossProductRuleError` from the pre-check or the
|
|
97
|
+
record included), `ManifestError`, `EditionCheckError`, or
|
|
98
|
+
`InventoryContentionError`).
|
|
82
99
|
- `Edition` is re-exported from `store/working_copy.py`.
|
|
83
100
|
|
|
84
101
|
### `admin.py`
|
|
@@ -111,13 +128,18 @@ are imported from `statcatlib.admin`.
|
|
|
111
128
|
*, kind, reason)`, which applies one of sdmxlib's pure rules to the head's
|
|
112
129
|
leaf with `datetime.now(UTC)` (the local clock, design decision P1) and
|
|
113
130
|
wraps a `TransitionError` with `InventoryRuleError.wrapping`. A product the
|
|
114
|
-
head does not record raises `InventoryRuleError(rule="unknown")`.
|
|
131
|
+
head does not record raises `InventoryRuleError(rule="unknown")`. The
|
|
132
|
+
writer's `commit` runs the cross-product rules on a release or a rollback
|
|
133
|
+
and raises `CrossProductRuleError`; `admin.py` adds no rule of its own.
|
|
115
134
|
- `prune(*, older_than, reason, dry_run=True) -> PruneReport` (design
|
|
116
135
|
decision P7): refuses a blank *reason* or a window that is not positive
|
|
117
136
|
(`ValueError`). It skips every product `_busy` names (an unsealed edition
|
|
118
137
|
whose id is not tombstoned, a `journal/*.json` file, or an unreadable
|
|
119
138
|
manifest), then runs three steps. `_prune_retire` retires superseded and
|
|
120
|
-
withdrawn entries whose `state_changed_at` is before the cutoff
|
|
139
|
+
withdrawn entries whose `state_changed_at` is before the cutoff, except
|
|
140
|
+
the structure editions `_cited` returns (those a live or embargoed data
|
|
141
|
+
edition's `partial_of` cites, `cross_product.cited_structures`; every
|
|
142
|
+
structure edition when a manifest cannot be read), with
|
|
121
143
|
sdmxlib's `retire`, in one `InventoryWriter.commit` of kind `retire`
|
|
122
144
|
(`Change(leaves=..., retired=head tombstones + new)`), re-planned against
|
|
123
145
|
each head and skipped when nothing qualifies. `_prune_dirs` deletes
|
|
@@ -150,16 +172,22 @@ are imported from `statcatlib.admin`.
|
|
|
150
172
|
The module keeps its 0.11 name and describes editions. Imported from
|
|
151
173
|
`statcatlib.bundle`; nothing here is re-exported from `statcatlib`:
|
|
152
174
|
|
|
153
|
-
- `catalog_section(store: _Store) -> dict`: builds the
|
|
154
|
-
section (format 3: `format`, `kind`,
|
|
155
|
-
`search`) from a working copy's open
|
|
175
|
+
- `catalog_section(store: _Store, *, kind="data") -> dict`: builds the
|
|
176
|
+
`statcat` manifest section (format 3: `format`, `kind`,
|
|
177
|
+
`statcatlib_version`, `datasets`, `search`) from a working copy's open
|
|
178
|
+
store. `WorkingCopy.seal` passes it to
|
|
156
179
|
`sdmxlib.edition.seal(..., extensions={"statcat": ...})`. It raises
|
|
157
180
|
`ValueError` when `search/` holds files and no `index.json`.
|
|
158
181
|
- `check_catalog(root, manifest, reg, profile) -> list[Finding]`: the
|
|
159
182
|
`statcat` check, with sdmxlib 0.80's `Check` signature, run as an extension
|
|
160
183
|
of `sdmxlib.edition.check_edition`. It refuses a section format other than
|
|
161
184
|
3, a `kind` outside `PRODUCT_KINDS`, and a `structure` edition whose
|
|
162
|
-
manifest binds a dataflow
|
|
185
|
+
manifest binds a dataflow or whose store holds a dataflow, binding or
|
|
186
|
+
partial.
|
|
187
|
+
- `structure_content(conn) -> list[Finding]`: one finding per `Dataflow`,
|
|
188
|
+
`DataflowBinding` and partial artefact in `sdmx.artefact`. `check_catalog`
|
|
189
|
+
runs it on a structure edition, and `WorkingCopy._precheck` on a
|
|
190
|
+
structure working copy's store.
|
|
163
191
|
- `overlay_affects_search(overlay, types=None)`: lists overlay entries that
|
|
164
192
|
change search results.
|
|
165
193
|
- `description_to_json(description) -> dict[str, object]` and
|
|
@@ -172,7 +200,7 @@ The module keeps its 0.11 name and describes editions. Imported from
|
|
|
172
200
|
of an Inventory root, or returns `None`.
|
|
173
201
|
- `ROOT_SECTION_FORMAT = "3"`: the root section's `format_version`.
|
|
174
202
|
- `STATCAT_SECTION = "statcat"`, `STATCAT_SECTION_FORMAT = 3`,
|
|
175
|
-
`PRODUCT_KINDS = ("data", "structure")`.
|
|
203
|
+
`PRODUCT_KINDS = ("data", "structure")`.
|
|
176
204
|
|
|
177
205
|
### `diagnostics.py`
|
|
178
206
|
|
|
@@ -184,16 +212,27 @@ read.
|
|
|
184
212
|
root the read protocol skipped (`full.skipped`), each leaf that fails its
|
|
185
213
|
hash or `validate_leaf`, and for every leaf entry a `manifest.json` whose
|
|
186
214
|
SHA-256 differs from the entry's, then runs
|
|
187
|
-
`check_edition(at="publish", extensions={"statcat":
|
|
215
|
+
`check_edition(at="publish", structures=..., extensions={"statcat":
|
|
216
|
+
check_catalog})`, with `structures=` from `_structures_for`: every recorded
|
|
217
|
+
edition of every structure product for a data edition, the other
|
|
218
|
+
structure products' live and embargoed editions for a structure edition,
|
|
219
|
+
`None` when empty. Last it lists the stale data editions
|
|
220
|
+
(`cross_product.stale_editions`, re-exported here as `StaleEdition`).
|
|
188
221
|
`VerifyReport` has `root`, `seq` (`None` before the first version),
|
|
189
|
-
`editions
|
|
190
|
-
from the catalog directory)
|
|
222
|
+
`editions`, `findings` (`VerifyFinding(file, detail)`, the file a path
|
|
223
|
+
from the catalog directory), `stale` (no finding; `ok` ignores it) and
|
|
224
|
+
`ok`. A path that is no directory, a legacy
|
|
191
225
|
layout, a missing or malformed `catalog.json`, or no valid root raise
|
|
192
226
|
`CatalogUnreadableError`.
|
|
193
227
|
- `doctor(path) -> DoctorReport`: `layout` (`catalog`, `legacy`, `unknown`)
|
|
194
228
|
and `items`, each a `DoctorItem(topic, level, detail)` with level `info` or
|
|
195
229
|
`warning`; `warnings` and `about(topic)` filter them. Topics: `synced
|
|
196
|
-
folder`, `layout`, `catalog.json`, `CURRENT`, `inventory`, `
|
|
230
|
+
folder`, `layout`, `catalog.json`, `CURRENT`, `inventory`, `stale data
|
|
231
|
+
editions` (an item per stale data edition and URN: `info` for a live
|
|
232
|
+
edition, `warning` for an embargoed one), `partials with no structure
|
|
233
|
+
home` (a `warning` per live or embargoed data edition and partial whose
|
|
234
|
+
URN no live or embargoed structure edition holds full,
|
|
235
|
+
`cross_product.homeless_partials`), `working copy`
|
|
197
236
|
(`list_editions`), `journal` (empty entries included), `unrecorded edition`
|
|
198
237
|
(sealed, and neither named nor tombstoned by the newest version) and
|
|
199
238
|
`leftover file` (`*.tmp` in `inventory/` and `.*.tmp` in the root).
|
|
@@ -242,6 +281,24 @@ read.
|
|
|
242
281
|
product's directory, resumes it as a working copy, stores one
|
|
243
282
|
`AggregateDataset` per dataflow (`prepare_sdmx(wc.registry)`), and seals.
|
|
244
283
|
The seal builds the search index when `statcatlib[search]` is installed.
|
|
284
|
+
- The CODR generator (design decision O6): `CodrShape(geography_codes=6000,
|
|
285
|
+
geography_depth=5, classification_codes=(900, 500, 300),
|
|
286
|
+
observations=2000)`; `STRUCTURE_PRODUCTS` (`structure-geo`,
|
|
287
|
+
`structure-classifications`, `structure-concepts`); `CODR_AGENCY`;
|
|
288
|
+
`codr_structures(catalog, *, seed=0, shape=...) -> dict[str, Edition]`,
|
|
289
|
+
which builds the structures from the seed alone (agency `CODR`, version
|
|
290
|
+
`1.0`, names `"<id> en"` and `"<id> fr"`) into a scratch `LocalRegistry`,
|
|
291
|
+
fills the three structure working copies by URN and seals them in one
|
|
292
|
+
`Catalog.seal`, raising `RuntimeError` on a refusal; and `codr_product(catalog,
|
|
293
|
+
product_id, *, tables=1, seed=0, shape=...) -> Edition`, which seals the
|
|
294
|
+
structure products first unless all three are live, writes one DSD and
|
|
295
|
+
dataflow per table and the rows from `(seed, product_id)`, fills with
|
|
296
|
+
`ingest_dataflows(..., partials=True)`, indexes `CL_GEO`'s codes with an
|
|
297
|
+
`fts_only` `CodelistIndexPolicy` when lancedb is installed, and seals.
|
|
298
|
+
`structure-classifications` holds `CL_CLASS_*`, `CL_UNIT` and the
|
|
299
|
+
SDMX-agency `CL_OBS_STATUS`, so every codelist a table cuts has a
|
|
300
|
+
structure home (A5). The draws are SplitMix64 seeded by SHA-256, so they
|
|
301
|
+
do not depend on Python's `random`.
|
|
245
302
|
|
|
246
303
|
## Invariants
|
|
247
304
|
|
|
@@ -275,14 +332,17 @@ read.
|
|
|
275
332
|
lands on: a rollback whose `if_live` is no longer live, a release that is
|
|
276
333
|
no longer a fast-forward, and a seal record computed before the embargo was
|
|
277
334
|
turned on are each refused or recomputed.
|
|
278
|
-
- `Product.view`
|
|
279
|
-
`
|
|
280
|
-
|
|
335
|
+
- `Product.view` passes the Inventory's `manifest_sha256` to
|
|
336
|
+
`sdmxlib.LocalRegistry.edition`, which hashes the bytes of
|
|
337
|
+
`manifest.json` it parses. A missing, malformed or different manifest
|
|
338
|
+
raises sdmxlib's `ManifestError`, which `view` re-raises as
|
|
339
|
+
`IncompatibleCatalogError` naming `statcat verify-catalog`, chained. Every view a `Catalog` returns shares one
|
|
281
340
|
`sdmxlib.EditionEngine`, and `Catalog.close` closes them all.
|
|
282
341
|
- A seal check only reads: `seal` runs every check against `wc.view()`
|
|
283
342
|
before writing anything, and any finding raises `SealCheckError`.
|
|
284
|
-
- `Catalog.seal`
|
|
285
|
-
|
|
343
|
+
- `Catalog.seal` seals and checks the batch's structure products before
|
|
344
|
+
any data product, runs every data pre-check before any data seal, and
|
|
345
|
+
writes at most one Inventory version; a batch whose products are all refused writes
|
|
286
346
|
none. A refusal in one product never stops another's. A crash during the
|
|
287
347
|
batch leaves each sealed, unrecorded edition's journal entry, so its
|
|
288
348
|
product's next `working_copy()` records it, in a version of its own.
|
|
@@ -300,7 +360,7 @@ read.
|
|
|
300
360
|
at INFO, and writes and removes a `.statcat-link-probe.*` file and its hard
|
|
301
361
|
link.
|
|
302
362
|
- `Product.view` attaches the edition to the catalog's engine
|
|
303
|
-
(`LocalRegistry.edition`). `Product.working_copy` writes as
|
|
363
|
+
(`LocalRegistry.edition(path, engine=, manifest_sha256=)`). `Product.working_copy` writes as
|
|
304
364
|
`store/EGM.md` describes.
|
|
305
365
|
- `synthetic_catalog` writes a whole catalog: `catalog.json`, sdmxlib's
|
|
306
366
|
synthetic edition, the catalog rows, the search index when lancedb is
|
|
@@ -35,6 +35,7 @@ from statcatlib.sources import (
|
|
|
35
35
|
dataset_from_dataflow,
|
|
36
36
|
ingest_ckan,
|
|
37
37
|
ingest_data,
|
|
38
|
+
ingest_dataflows,
|
|
38
39
|
ingest_sdmx,
|
|
39
40
|
ingest_sdmx_registry,
|
|
40
41
|
prepare_sdmx,
|
|
@@ -88,6 +89,7 @@ from statcatlib.store import (
|
|
|
88
89
|
xhtml_to_markdown,
|
|
89
90
|
)
|
|
90
91
|
from statcatlib.store.inventory import (
|
|
92
|
+
CrossProductRuleError,
|
|
91
93
|
InventoryConflictError,
|
|
92
94
|
InventoryContentionError,
|
|
93
95
|
InventoryRuleError,
|
|
@@ -95,6 +97,7 @@ from statcatlib.store.inventory import (
|
|
|
95
97
|
)
|
|
96
98
|
from statcatlib.store.sentinel import NotLoadedError, is_unloaded
|
|
97
99
|
from statcatlib.store.working_copy import (
|
|
100
|
+
EditionCheckError,
|
|
98
101
|
JournalError,
|
|
99
102
|
SealCheckError,
|
|
100
103
|
WorkingCopyClosedError,
|
|
@@ -131,6 +134,7 @@ __all__ = [
|
|
|
131
134
|
"ConceptsScope",
|
|
132
135
|
"ContactPoint",
|
|
133
136
|
"CountExpr",
|
|
137
|
+
"CrossProductRuleError",
|
|
134
138
|
"DataService",
|
|
135
139
|
"Dataset",
|
|
136
140
|
"DatasetScope",
|
|
@@ -141,6 +145,7 @@ __all__ = [
|
|
|
141
145
|
"DimensionsScope",
|
|
142
146
|
"Distribution",
|
|
143
147
|
"Edition",
|
|
148
|
+
"EditionCheckError",
|
|
144
149
|
"EditionView",
|
|
145
150
|
"EmbeddingMetadata",
|
|
146
151
|
"ExistsExpr",
|
|
@@ -189,6 +194,7 @@ __all__ = [
|
|
|
189
194
|
"each",
|
|
190
195
|
"ingest_ckan",
|
|
191
196
|
"ingest_data",
|
|
197
|
+
"ingest_dataflows",
|
|
192
198
|
"ingest_sdmx",
|
|
193
199
|
"ingest_sdmx_registry",
|
|
194
200
|
"is_concept_in_scheme",
|
|
@@ -24,7 +24,14 @@ skewed times.
|
|
|
24
24
|
2a S4). It also deletes files: the directories of editions tombstoned longer
|
|
25
25
|
than its window, and ``data/`` files no manifest on disk lists. It never
|
|
26
26
|
touches a product with a working copy or a seal journal entry, and deletes no
|
|
27
|
-
Inventory root or leaf.
|
|
27
|
+
Inventory root or leaf. It keeps a structure edition that a live or embargoed
|
|
28
|
+
data edition's ``partial_of`` cites (specification §11.4), so a stale data
|
|
29
|
+
edition's partials still verify.
|
|
30
|
+
|
|
31
|
+
`CatalogAdmin.release` and `CatalogAdmin.rollback` make an edition live, so
|
|
32
|
+
the Inventory writer runs the cross-product rules on their version
|
|
33
|
+
(`statcatlib.store.cross_product`): a refusal raises `CrossProductRuleError`
|
|
34
|
+
and writes nothing.
|
|
28
35
|
"""
|
|
29
36
|
|
|
30
37
|
from __future__ import annotations
|
|
@@ -42,6 +49,7 @@ from sdmxlib.storage.schema import SCHEMA_VERSION
|
|
|
42
49
|
|
|
43
50
|
from statcatlib.bundle import ROOT_SECTION_FORMAT, STATCAT_SECTION, description_to_json
|
|
44
51
|
from statcatlib.catalog import _refuse_legacy_layout
|
|
52
|
+
from statcatlib.store.cross_product import cited_structures
|
|
45
53
|
from statcatlib.store.inventory import Change, InventoryRuleError, InventoryWriter, NonFastForwardError
|
|
46
54
|
|
|
47
55
|
if TYPE_CHECKING:
|
|
@@ -146,8 +154,21 @@ def _busy(product_dir: Path, tombstoned: Collection[str]) -> str | None:
|
|
|
146
154
|
return None
|
|
147
155
|
|
|
148
156
|
|
|
149
|
-
def _retirable(
|
|
150
|
-
|
|
157
|
+
def _retirable(
|
|
158
|
+
full: FullInventory | None, cutoff: datetime, skipped: Collection[str], kept: Collection[tuple[str, str]]
|
|
159
|
+
) -> dict[str, list[str]]:
|
|
160
|
+
"""The superseded and withdrawn editions that entered their state before *cutoff*, by product.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
full: The head, or ``None``.
|
|
164
|
+
cutoff: The window's start; older entries retire.
|
|
165
|
+
skipped: The products prune leaves alone.
|
|
166
|
+
kept: ``(product, edition)`` pairs to keep: the structure editions
|
|
167
|
+
a live or embargoed data edition's ``partial_of`` cites.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
The edition ids to retire, by product.
|
|
171
|
+
"""
|
|
151
172
|
if full is None:
|
|
152
173
|
return {}
|
|
153
174
|
found: dict[str, list[str]] = {}
|
|
@@ -157,7 +178,9 @@ def _retirable(full: FullInventory | None, cutoff: datetime, skipped: Collection
|
|
|
157
178
|
ids = [
|
|
158
179
|
e.edition_id
|
|
159
180
|
for e in full.entries(product_id)
|
|
160
|
-
if e.state in {"superseded", "withdrawn"}
|
|
181
|
+
if e.state in {"superseded", "withdrawn"}
|
|
182
|
+
and e.state_changed_at < cutoff
|
|
183
|
+
and (product_id, e.edition_id) not in kept
|
|
161
184
|
]
|
|
162
185
|
if ids:
|
|
163
186
|
found[product_id] = ids
|
|
@@ -331,6 +354,13 @@ class CatalogAdmin:
|
|
|
331
354
|
other than *if_live* (``rule="if-live"``), or *to* is not a
|
|
332
355
|
superseded edition (``rule="state"`` or ``"unknown"``).
|
|
333
356
|
|
|
357
|
+
An edition made live that breaks a cross-product rule raises
|
|
358
|
+
`CrossProductRuleError` and writes nothing: a data edition cut from a
|
|
359
|
+
structure edition that is neither live nor embargoed
|
|
360
|
+
(``rule="full-artefacts"``), or a structure edition holding a full
|
|
361
|
+
artefact another structure product holds live
|
|
362
|
+
(``rule="structure-home"``).
|
|
363
|
+
|
|
334
364
|
An empty *reason* raises `ValueError`. It acts on the live edition at
|
|
335
365
|
this sdmxlib's store schema; rolling back another schema's live
|
|
336
366
|
edition arrives in Phase 4.
|
|
@@ -364,6 +394,15 @@ class CatalogAdmin:
|
|
|
364
394
|
Returns:
|
|
365
395
|
The Inventory version written (``event.kind`` ``release``).
|
|
366
396
|
|
|
397
|
+
A release that breaks a cross-product rule raises
|
|
398
|
+
`CrossProductRuleError` and writes nothing: a data edition whose
|
|
399
|
+
partial or full artefact matches only an embargoed structure edition
|
|
400
|
+
(``rule="publish-order"``; release that one first); a stale data
|
|
401
|
+
edition, or one whose full artefact with no structure home differs
|
|
402
|
+
from another data product's live copy (``rule="full-artefacts"``); a
|
|
403
|
+
structure edition holding a full artefact another structure product
|
|
404
|
+
holds live (``rule="structure-home"``).
|
|
405
|
+
|
|
367
406
|
An edition built on an edition that is no longer live raises
|
|
368
407
|
`NonFastForwardError`, whose message names both and the fix. An edition
|
|
369
408
|
that is not recorded (``rule="unknown"``) or not embargoed
|
|
@@ -498,7 +537,11 @@ class CatalogAdmin:
|
|
|
498
537
|
so is one with an edition whose manifest cannot be read. Prune
|
|
499
538
|
deletes no Inventory root or leaf, never expires a tombstone, and
|
|
500
539
|
never deletes an edition the Inventory does not tombstone, so a
|
|
501
|
-
live, embargoed or unrecorded edition stays.
|
|
540
|
+
live, embargoed or unrecorded edition stays. It never retires a
|
|
541
|
+
structure edition that a live or embargoed data edition's
|
|
542
|
+
``partial_of`` cites (specification §11.4); when a manifest that
|
|
543
|
+
decision needs cannot be read, it retires no structure edition. An
|
|
544
|
+
edition retired in
|
|
502
545
|
step 1 keeps its directory until a run after its tombstone has aged
|
|
503
546
|
past the window.
|
|
504
547
|
|
|
@@ -596,12 +639,12 @@ class CatalogAdmin:
|
|
|
596
639
|
"""
|
|
597
640
|
stones: tuple[Tombstone, ...] = () if full is None else full.root.retired
|
|
598
641
|
if dry_run:
|
|
599
|
-
planned = _retirable(full, cutoff, skipped)
|
|
642
|
+
planned = _retirable(full, cutoff, skipped, self._cited(full))
|
|
600
643
|
return [f"{p}/{e}" for p, ids in planned.items() for e in ids], None, stones
|
|
601
644
|
retired: list[str] = []
|
|
602
645
|
|
|
603
646
|
def change(head_now: FullInventory | None) -> Change:
|
|
604
|
-
planned = _retirable(head_now, cutoff, skipped)
|
|
647
|
+
planned = _retirable(head_now, cutoff, skipped, self._cited(head_now))
|
|
605
648
|
if head_now is None or not planned:
|
|
606
649
|
raise _NothingToRetireError
|
|
607
650
|
now = datetime.now(UTC)
|
|
@@ -622,6 +665,22 @@ class CatalogAdmin:
|
|
|
622
665
|
return [], None, stones
|
|
623
666
|
return retired, written.seq, written.retired
|
|
624
667
|
|
|
668
|
+
def _cited(self, full: FullInventory | None) -> set[tuple[str, str]]:
|
|
669
|
+
"""The structure editions prune keeps: those a live or embargoed data edition's ``partial_of`` cites.
|
|
670
|
+
|
|
671
|
+
When a manifest the answer needs cannot be read, every structure
|
|
672
|
+
edition is kept.
|
|
673
|
+
"""
|
|
674
|
+
try:
|
|
675
|
+
return cited_structures(full, self._writer.artefacts)
|
|
676
|
+
except ManifestError as exc:
|
|
677
|
+
log.warning("prune keeps every structure edition: a manifest cannot be read (%s)", exc)
|
|
678
|
+
if full is None:
|
|
679
|
+
return set()
|
|
680
|
+
return {
|
|
681
|
+
(p, e.edition_id) for p in full.products() if full.leaf(p).kind == "structure" for e in full.entries(p)
|
|
682
|
+
}
|
|
683
|
+
|
|
625
684
|
def _prune_data(
|
|
626
685
|
self, product_dir: Path, cutoff: datetime, gone: Collection[str], *, dry_run: bool, failed: dict[str, str]
|
|
627
686
|
) -> list[str]:
|
|
@@ -39,6 +39,7 @@ if TYPE_CHECKING:
|
|
|
39
39
|
import duckdb
|
|
40
40
|
from sdmxlib import EditionRegistry
|
|
41
41
|
from sdmxlib.edition import Profile
|
|
42
|
+
from sdmxlib.inventory import Kind
|
|
42
43
|
|
|
43
44
|
from statcatlib.store.lance import LanceIndex, LanguageLayout
|
|
44
45
|
from statcatlib.store.store import _Store
|
|
@@ -54,6 +55,7 @@ __all__ = [
|
|
|
54
55
|
"description_from_root",
|
|
55
56
|
"description_to_json",
|
|
56
57
|
"overlay_affects_search",
|
|
58
|
+
"structure_content",
|
|
57
59
|
]
|
|
58
60
|
|
|
59
61
|
#: The manifest's extension section statcatlib owns.
|
|
@@ -64,9 +66,9 @@ STATCAT_SECTION = "statcat"
|
|
|
64
66
|
#: 3 added ``kind``, the product's kind (Phase 2a).
|
|
65
67
|
STATCAT_SECTION_FORMAT = 3
|
|
66
68
|
|
|
67
|
-
#: The product kinds format 3 names.
|
|
68
|
-
#:
|
|
69
|
-
#:
|
|
69
|
+
#: The product kinds format 3 names. A ``structure`` product's editions hold
|
|
70
|
+
#: full artefacts and no dataflow, binding or partial (`structure_content`), so
|
|
71
|
+
#: `check_catalog` refuses each of those, and a manifest binding, in one.
|
|
70
72
|
PRODUCT_KINDS: tuple[str, ...] = ("data", "structure")
|
|
71
73
|
|
|
72
74
|
_SEARCH_DIRNAME = "search"
|
|
@@ -108,11 +110,10 @@ def _search_section(store: _Store, search_dir: Path) -> dict[str, object] | None
|
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
|
|
111
|
-
def catalog_section(store: _Store) -> dict[str, object]:
|
|
113
|
+
def catalog_section(store: _Store, *, kind: Kind = "data") -> dict[str, object]:
|
|
112
114
|
"""Describe a working copy's edition, as its manifest's ``statcat`` section (format 3).
|
|
113
115
|
|
|
114
|
-
The section records the product's kind
|
|
115
|
-
the number of catalog datasets and, when a search index is built, its
|
|
116
|
+
The section records the product's kind, the number of catalog datasets and, when a search index is built, its
|
|
116
117
|
index format version, embedding model, each language's row count and
|
|
117
118
|
stemmer, and the annotations it was built from. `WorkingCopy.seal`
|
|
118
119
|
computes it after rebuilding the index and before closing the store, and
|
|
@@ -120,6 +121,7 @@ def catalog_section(store: _Store) -> dict[str, object]:
|
|
|
120
121
|
|
|
121
122
|
Args:
|
|
122
123
|
store: The working copy's store, open.
|
|
124
|
+
kind: The product's kind, ``data`` or ``structure``.
|
|
123
125
|
|
|
124
126
|
Returns:
|
|
125
127
|
The section, ready for ``seal(extensions=...)``.
|
|
@@ -131,13 +133,42 @@ def catalog_section(store: _Store) -> dict[str, object]:
|
|
|
131
133
|
search_dir = store._search_dir
|
|
132
134
|
return {
|
|
133
135
|
"format": STATCAT_SECTION_FORMAT,
|
|
134
|
-
"kind":
|
|
136
|
+
"kind": kind,
|
|
135
137
|
"statcatlib_version": metadata.version("statcatlib"),
|
|
136
138
|
"datasets": row[0] if row else 0,
|
|
137
139
|
"search": _search_section(store, search_dir) if search_dir is not None else None,
|
|
138
140
|
}
|
|
139
141
|
|
|
140
142
|
|
|
143
|
+
def structure_content(conn: duckdb.DuckDBPyConnection) -> list[Finding]:
|
|
144
|
+
"""What a structure product's edition may not hold: each dataflow, binding and partial in its store.
|
|
145
|
+
|
|
146
|
+
A structure product holds full artefacts only. `check_catalog` runs this
|
|
147
|
+
on a sealed structure edition, and `WorkingCopy.seal` on a structure
|
|
148
|
+
working copy before anything is sealed.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
conn: A connection whose current catalog is the edition's, so
|
|
152
|
+
``sdmx.artefact`` names its table.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
One finding per such artefact, by URN.
|
|
156
|
+
"""
|
|
157
|
+
rows = conn.execute(
|
|
158
|
+
"""
|
|
159
|
+
SELECT urn, artefact_type, is_partial FROM sdmx.artefact
|
|
160
|
+
WHERE artefact_type IN ('Dataflow', 'DataflowBinding') OR is_partial
|
|
161
|
+
ORDER BY urn
|
|
162
|
+
"""
|
|
163
|
+
).fetchall()
|
|
164
|
+
findings: list[Finding] = []
|
|
165
|
+
for urn, artefact_type, is_partial in rows:
|
|
166
|
+
held = f"a partial {artefact_type}" if is_partial else f"a {artefact_type}"
|
|
167
|
+
detail = f"{held}, which a structure product does not hold: it holds full artefacts only"
|
|
168
|
+
findings.append(Finding(STATCAT_SECTION, urn, detail))
|
|
169
|
+
return findings
|
|
170
|
+
|
|
171
|
+
|
|
141
172
|
def _check_catalog_rows(conn: duckdb.DuckDBPyConnection, section: Mapping[str, object]) -> Iterator[Finding]:
|
|
142
173
|
"""The catalog's datasets: counted as declared, each linked flow stored."""
|
|
143
174
|
from statcatlib.store.schema import first_row # noqa: PLC0415
|
|
@@ -244,7 +275,8 @@ def check_catalog(root: Path, manifest: Manifest, reg: EditionRegistry, profile:
|
|
|
244
275
|
reads, and the manifest has a ``statcat`` section in a format this
|
|
245
276
|
build reads;
|
|
246
277
|
- the section's ``kind`` is one of `PRODUCT_KINDS`, and a ``structure``
|
|
247
|
-
edition binds no dataflow to observations
|
|
278
|
+
edition binds no dataflow to observations and holds no dataflow,
|
|
279
|
+
binding or partial (`structure_content`);
|
|
248
280
|
- the catalog holds the datasets the section counts, and every dataset
|
|
249
281
|
linked to a dataflow names one the registry holds;
|
|
250
282
|
- ``search/`` is present exactly when the section declares an index;
|
|
@@ -299,6 +331,7 @@ def check_catalog(root: Path, manifest: Manifest, reg: EditionRegistry, profile:
|
|
|
299
331
|
Finding(STATCAT_SECTION, binding.dataflow, "a structure product's edition binds a dataflow to observations")
|
|
300
332
|
for binding in manifest.bindings
|
|
301
333
|
]
|
|
334
|
+
findings += structure_content(conn)
|
|
302
335
|
findings += [*_check_catalog_rows(conn, section), *_check_search(root, section.get("search"))]
|
|
303
336
|
search = section.get("search")
|
|
304
337
|
if isinstance(search, Mapping):
|