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.
Files changed (50) hide show
  1. {statcatlib-0.12.0 → statcatlib-0.13.0}/PKG-INFO +2 -2
  2. {statcatlib-0.12.0 → statcatlib-0.13.0}/pyproject.toml +2 -2
  3. {statcatlib-0.12.0 → statcatlib-0.13.0}/pyproject.toml.orig +3 -3
  4. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/EGM.md +88 -28
  5. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/__init__.py +6 -0
  6. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/admin.py +66 -7
  7. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/bundle.py +41 -8
  8. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/catalog.py +314 -61
  9. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/cli/EGM.md +2 -2
  10. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/cli/__init__.py +13 -5
  11. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/diagnostics.py +120 -15
  12. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/sources/EGM.md +20 -3
  13. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/sources/__init__.py +9 -1
  14. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/sources/sdmx.py +62 -1
  15. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/EGM.md +162 -40
  16. statcatlib-0.13.0/src/statcatlib/store/cross_product.py +578 -0
  17. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/inventory.py +130 -37
  18. statcatlib-0.13.0/src/statcatlib/store/working_copy.py +1842 -0
  19. statcatlib-0.13.0/src/statcatlib/testing.py +510 -0
  20. statcatlib-0.12.0/src/statcatlib/store/working_copy.py +0 -928
  21. statcatlib-0.12.0/src/statcatlib/testing.py +0 -79
  22. {statcatlib-0.12.0 → statcatlib-0.13.0}/LICENSE +0 -0
  23. {statcatlib-0.12.0 → statcatlib-0.13.0}/README.md +0 -0
  24. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/checks.py +0 -0
  25. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/expr.py +0 -0
  26. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/EGM.md +0 -0
  27. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/__init__.py +0 -0
  28. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/agents.py +0 -0
  29. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/catalog.py +0 -0
  30. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/classification.py +0 -0
  31. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/dataset.py +0 -0
  32. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/distribution.py +0 -0
  33. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/licence.py +0 -0
  34. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/quality.py +0 -0
  35. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/skos.py +0 -0
  36. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/spatial.py +0 -0
  37. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/temporal.py +0 -0
  38. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/model/types.py +0 -0
  39. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/py.typed +0 -0
  40. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/query.py +0 -0
  41. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/sources/ckan.py +0 -0
  42. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/__init__.py +0 -0
  43. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/lance.py +0 -0
  44. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/row.py +0 -0
  45. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/schema.py +0 -0
  46. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/scopes.py +0 -0
  47. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/sentinel.py +0 -0
  48. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/store.py +0 -0
  49. {statcatlib-0.12.0 → statcatlib-0.13.0}/src/statcatlib/store/xhtml.py +0 -0
  50. {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.12.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.81.0,<0.82
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.12.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.81.0,<0.82",
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.12.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.82: every suite library is pre-1.0, so a minor bump may break
30
+ # <0.85: every suite library is pre-1.0, so a minor bump may break
31
31
  # (GH-67).
32
- "sdmxlib>=0.81.0,<0.82",
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` and `NonFastForwardError`
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. Then it runs `WorkingCopy._precheck` (journal,
61
- checks, fast-forward) for every working copy, `_seal_edition` for each
62
- that passed, and records every sealed edition in one
63
- `InventoryWriter.commit`, leaving out each product whose `_next_leaf`
64
- raises `InventoryRuleError`. `RuntimeError` on a catalog from `open`.
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`; `rollback()`, which
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`, `ManifestError`, or `InventoryContentionError`).
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 with
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 `statcat` manifest
154
- section (format 3: `format`, `kind`, `statcatlib_version`, `datasets`,
155
- `search`) from a working copy's open store. `WorkingCopy.seal` passes it to
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")`. Phase 2a seals `data` only.
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": check_catalog})`.
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` and `findings` (`VerifyFinding(file, detail)`, the file a path
190
- from the catalog directory) and `ok`. A path that is no directory, a legacy
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`, `working copy`
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` hashes the live edition's `manifest.json` and raises
279
- `IncompatibleCatalogError` when it differs from the `manifest_sha256` the
280
- Inventory records. Every view a `Catalog` returns shares one
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` runs every pre-check before any sdmxlib seal, and writes at
285
- most one Inventory version; a batch whose products are all refused writes
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(full: FullInventory | None, cutoff: datetime, skipped: Collection[str]) -> dict[str, list[str]]:
150
- """The superseded and withdrawn editions that entered their state before *cutoff*, by product."""
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"} and e.state_changed_at < cutoff
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. An edition retired in
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. Phase 2a seals ``data`` products only;
68
- #: ``structure`` is reserved for structure products (Phase 3), whose editions
69
- #: hold no observations, so `check_catalog` refuses a binding in one.
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 (``data``; Phase 2a seals no other),
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": "data",
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):