statcatlib 0.13.0__tar.gz → 0.14.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 (48) hide show
  1. {statcatlib-0.13.0 → statcatlib-0.14.0}/PKG-INFO +2 -2
  2. {statcatlib-0.13.0 → statcatlib-0.14.0}/pyproject.toml +2 -2
  3. {statcatlib-0.13.0 → statcatlib-0.14.0}/pyproject.toml.orig +3 -3
  4. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/EGM.md +18 -5
  5. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/__init__.py +2 -0
  6. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/catalog.py +14 -2
  7. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/cli/__init__.py +9 -3
  8. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/diagnostics.py +27 -6
  9. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/EGM.md +29 -10
  10. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/cross_product.py +2 -15
  11. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/inventory.py +136 -11
  12. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/working_copy.py +70 -5
  13. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/testing.py +175 -19
  14. {statcatlib-0.13.0 → statcatlib-0.14.0}/LICENSE +0 -0
  15. {statcatlib-0.13.0 → statcatlib-0.14.0}/README.md +0 -0
  16. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/admin.py +0 -0
  17. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/bundle.py +0 -0
  18. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/checks.py +0 -0
  19. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/cli/EGM.md +0 -0
  20. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/expr.py +0 -0
  21. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/EGM.md +0 -0
  22. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/__init__.py +0 -0
  23. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/agents.py +0 -0
  24. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/catalog.py +0 -0
  25. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/classification.py +0 -0
  26. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/dataset.py +0 -0
  27. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/distribution.py +0 -0
  28. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/licence.py +0 -0
  29. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/quality.py +0 -0
  30. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/skos.py +0 -0
  31. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/spatial.py +0 -0
  32. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/temporal.py +0 -0
  33. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/types.py +0 -0
  34. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/py.typed +0 -0
  35. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/query.py +0 -0
  36. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/sources/EGM.md +0 -0
  37. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/sources/__init__.py +0 -0
  38. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/sources/ckan.py +0 -0
  39. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/sources/sdmx.py +0 -0
  40. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/__init__.py +0 -0
  41. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/lance.py +0 -0
  42. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/row.py +0 -0
  43. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/schema.py +0 -0
  44. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/scopes.py +0 -0
  45. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/sentinel.py +0 -0
  46. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/store.py +0 -0
  47. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/xhtml.py +0 -0
  48. {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/urn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statcatlib
3
- Version: 0.13.0
3
+ Version: 0.14.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.84,<0.85
21
+ Requires-Dist: sdmxlib>=0.85,<0.86
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.13.0"
3
+ version = "0.14.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.84,<0.85",
32
+ "sdmxlib>=0.85,<0.86",
33
33
  ]
34
34
 
35
35
  [[project.authors]]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "statcatlib"
3
- version = "0.13.0"
3
+ version = "0.14.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.85: every suite library is pre-1.0, so a minor bump may break
30
+ # <0.86: every suite library is pre-1.0, so a minor bump may break
31
31
  # (GH-67).
32
- "sdmxlib>=0.84,<0.85",
32
+ "sdmxlib>=0.85,<0.86",
33
33
  ]
34
34
 
35
35
  [project.optional-dependencies]
@@ -5,8 +5,8 @@
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`, `NonFastForwardError` and
9
- `CrossProductRuleError` from `store/inventory.py`, and `WorkingCopyExistsError`,
8
+ `InventoryContentionError`, `InventoryRuleError`, `InventoryWriteRefusedError`,
9
+ `NonFastForwardError` and `CrossProductRuleError` from `store/inventory.py`, and `WorkingCopyExistsError`,
10
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`,
@@ -94,8 +94,8 @@ and test catalogs (`testing.py`).
94
94
  `Edition`, all recorded in one version) and `refused` (product id to the
95
95
  error: `SealCheckError`, `NonFastForwardError` or another
96
96
  `InventoryRuleError` (`CrossProductRuleError` from the pre-check or the
97
- record included), `ManifestError`, `EditionCheckError`, or
98
- `InventoryContentionError`).
97
+ record included), `ManifestError`, `EditionCheckError`,
98
+ `InventoryContentionError`, or `InventoryWriteRefusedError`).
99
99
  - `Edition` is re-exported from `store/working_copy.py`.
100
100
 
101
101
  ### `admin.py`
@@ -211,7 +211,9 @@ read.
211
211
  - `verify_catalog(path) -> VerifyReport`: reads `FullInventory`; reports each
212
212
  root the read protocol skipped (`full.skipped`), each leaf that fails its
213
213
  hash or `validate_leaf`, and for every leaf entry a `manifest.json` whose
214
- SHA-256 differs from the entry's, then runs
214
+ SHA-256 differs from the entry's, or, when it matches, each filled routing
215
+ field or artefact record `sdmxlib.inventory.verify_entry` finds differing,
216
+ with the leaf as the finding's file, then runs
215
217
  `check_edition(at="publish", structures=..., extensions={"statcat":
216
218
  check_catalog})`, with `structures=` from `_structures_for`: every recorded
217
219
  edition of every structure product for a data edition, the other
@@ -295,6 +297,17 @@ read.
295
297
  dataflow per table and the rows from `(seed, product_id)`, fills with
296
298
  `ingest_dataflows(..., partials=True)`, indexes `CL_GEO`'s codes with an
297
299
  `fts_only` `CodelistIndexPolicy` when lancedb is installed, and seals.
300
+ `codr_product` is `codr_working_copy(...).seal()`: `codr_working_copy`
301
+ takes the same arguments and returns the filled, indexed working copy,
302
+ unsealed. `codr_corpus(catalog, groups=(CorpusGroup(7000),), *, seed=0,
303
+ shape=..., embargoed=0.1, on_sealed=None) -> list[str]` seals each
304
+ `CorpusGroup(products, tables=1)`'s products, named by
305
+ `corpus_product_id` (`codr-<tables>t-<index:05d>`), through
306
+ `codr_product`, skipping products the Inventory records and discarding a
307
+ working copy a crash left; it seals the share `corpus_embargoed` picks (a
308
+ SplitMix64 draw per product id) between two `CatalogAdmin.set_embargo`
309
+ versions, turns the embargo off first and leaves it off, and returns the
310
+ products it sealed. `bench/corpus.py` (GH-124) drives it.
298
311
  `structure-classifications` holds `CL_CLASS_*`, `CL_UNIT` and the
299
312
  SDMX-agency `CL_OBS_STATUS`, so every codelist a table cuts has a
300
313
  structure home (A5). The draws are SplitMix64 seeded by SHA-256, so they
@@ -93,6 +93,7 @@ from statcatlib.store.inventory import (
93
93
  InventoryConflictError,
94
94
  InventoryContentionError,
95
95
  InventoryRuleError,
96
+ InventoryWriteRefusedError,
96
97
  NonFastForwardError,
97
98
  )
98
99
  from statcatlib.store.sentinel import NotLoadedError, is_unloaded
@@ -159,6 +160,7 @@ __all__ = [
159
160
  "InventoryConflictError",
160
161
  "InventoryContentionError",
161
162
  "InventoryRuleError",
163
+ "InventoryWriteRefusedError",
162
164
  "JournalError",
163
165
  "LicenceDocument",
164
166
  "MicrodataDataset",
@@ -37,7 +37,13 @@ from sdmxlib.edition import Finding, ManifestError, read_manifest
37
37
  from sdmxlib.inventory import CATALOG_NAME, LiveEdition, LiveInventory, decode_root, local_fetch, root_path
38
38
 
39
39
  from statcatlib.bundle import description_from_root
40
- from statcatlib.store.inventory import Change, InventoryContentionError, InventoryRuleError, InventoryWriter
40
+ from statcatlib.store.inventory import (
41
+ Change,
42
+ InventoryContentionError,
43
+ InventoryRuleError,
44
+ InventoryWriter,
45
+ InventoryWriteRefusedError,
46
+ )
41
47
  from statcatlib.store.store import VIEW_ATTRIBUTES, EditionView, IncompatibleCatalogError, _Store
42
48
  from statcatlib.store.working_copy import (
43
49
  Edition,
@@ -289,7 +295,7 @@ def _seal_ready(
289
295
  for wc in working_copies:
290
296
  try:
291
297
  wc._precheck(checks, batch=batch)
292
- except (SealCheckError, InventoryRuleError, InventoryContentionError) as exc:
298
+ except (SealCheckError, InventoryRuleError, InventoryContentionError, InventoryWriteRefusedError) as exc:
293
299
  refused[wc.product_id] = exc
294
300
  else:
295
301
  ready.append(wc)
@@ -479,6 +485,12 @@ def _record_batch(
479
485
  msg = f"{exc}; the edition {done.wc.edition_id} is recorded at the next working_copy()"
480
486
  refused[done.wc.product_id] = InventoryContentionError(msg)
481
487
  return {}
488
+ except InventoryWriteRefusedError as exc:
489
+ # The journal entries stay, so a later working_copy() records each edition.
490
+ for done in sealed:
491
+ msg = f"{exc}; the edition {done.wc.edition_id} stays unrecorded until a working_copy() can record it"
492
+ refused[done.wc.product_id] = InventoryWriteRefusedError(msg)
493
+ return {}
482
494
  recorded: dict[str, Edition] = {}
483
495
  for done in sealed:
484
496
  exc = at_record.get(done.wc.product_id)
@@ -452,8 +452,8 @@ def verify_catalog(
452
452
  """Check a catalog's integrity: its Inventory and every edition it records.
453
453
 
454
454
  It reads the newest valid Inventory version and checks every leaf, every
455
- manifest's SHA-256, every file each manifest lists, the shared ``data/``
456
- files included, each partial against its structure edition, and each
455
+ manifest's SHA-256, each entry's routing fields and artefact records, every
456
+ file each manifest lists, the shared ``data/`` files included, each partial against its structure edition, and each
457
457
  structure edition's references (`statcatlib.diagnostics.verify_catalog`).
458
458
  It writes nothing. It exits 0 when everything checks, 1 on any finding,
459
459
  each printed with the file it names, and 2 when the catalog cannot be
@@ -539,7 +539,13 @@ def prune(
539
539
  try:
540
540
  admin = sc.CatalogAdmin.open(path)
541
541
  report = admin.prune(older_than=timedelta(days=older_than), reason=reason, dry_run=not apply)
542
- except (IncompatibleCatalogError, sc.InventoryRuleError, sc.InventoryContentionError, ValueError) as exc:
542
+ except (
543
+ IncompatibleCatalogError,
544
+ sc.InventoryRuleError,
545
+ sc.InventoryContentionError,
546
+ sc.InventoryWriteRefusedError,
547
+ ValueError,
548
+ ) as exc:
543
549
  _say(f"Cannot prune the catalog: {exc}", style="red")
544
550
  raise typer.Exit(code=1) from None
545
551
  for product_id, why in report.skipped_products.items():
@@ -5,7 +5,8 @@ Two functions, each run by a ``statcat`` command:
5
5
  - `verify_catalog` (``statcat verify-catalog PATH``) reads the newest valid
6
6
  Inventory version with `sdmxlib.inventory.FullInventory`. It checks every
7
7
  root the read protocol skipped, every leaf, and, for every entry the leaves
8
- hold, the ``manifest_sha256`` the Inventory records. It then runs
8
+ hold, the ``manifest_sha256`` the Inventory records and the routing fields
9
+ and artefact records it holds. It then runs
9
10
  `sdmxlib.edition.check_edition` at the ``publish`` profile with
10
11
  `statcatlib.bundle.check_catalog`, which checks every file the manifest
11
12
  lists, the shared ``data/`` files included, each partial against the
@@ -33,7 +34,7 @@ from pathlib import Path
33
34
  from typing import TYPE_CHECKING, Literal
34
35
 
35
36
  from attrs import frozen
36
- from sdmxlib.edition import MANIFEST_NAME, ManifestError, check_edition, list_editions, read_journal
37
+ from sdmxlib.edition import MANIFEST_NAME, ManifestError, check_edition, list_editions, read_journal, read_manifest
37
38
  from sdmxlib.inventory import (
38
39
  CATALOG_NAME,
39
40
  CURRENT_NAME,
@@ -41,6 +42,7 @@ from sdmxlib.inventory import (
41
42
  InventoryError,
42
43
  decode_current,
43
44
  validate_leaf,
45
+ verify_entry,
44
46
  )
45
47
 
46
48
  from statcatlib.bundle import STATCAT_SECTION, check_catalog
@@ -281,8 +283,23 @@ def _structures_for(full: FullInventory, root: Path, product_id: str) -> list[Pa
281
283
  return found
282
284
 
283
285
 
284
- def _verify_edition(root: Path, product_id: str, entry: LeafEntry, structures: list[Path]) -> list[VerifyFinding]:
285
- """Check one recorded edition: its manifest's SHA-256, then `check_edition` at ``publish`` with *structures*."""
286
+ def _routing_findings(leaf_file: str, entry: LeafEntry, edition_dir: Path) -> list[VerifyFinding]:
287
+ """Each filled routing field or artefact record of *entry* that differs from its manifest (`verify_entry`).
288
+
289
+ The caller has checked the manifest's SHA-256. A manifest that cannot be
290
+ parsed is left to `check_edition`, which reports it.
291
+ """
292
+ try:
293
+ manifest = read_manifest(edition_dir)
294
+ except ManifestError:
295
+ return []
296
+ return [VerifyFinding(file=leaf_file, detail=detail) for detail in verify_entry(entry, manifest)]
297
+
298
+
299
+ def _verify_edition(
300
+ root: Path, product_id: str, leaf_file: str, entry: LeafEntry, structures: list[Path]
301
+ ) -> list[VerifyFinding]:
302
+ """Check one edition: its manifest's SHA-256, its entry's fields, then `check_edition` with *structures*."""
286
303
  edition_rel = f"products/{product_id}/{entry.edition_id}"
287
304
  edition_dir = root / edition_rel
288
305
  manifest_rel = f"{edition_rel}/{MANIFEST_NAME}"
@@ -293,6 +310,8 @@ def _verify_edition(root: Path, product_id: str, entry: LeafEntry, structures: l
293
310
  if digest != entry.manifest_sha256:
294
311
  detail = f"SHA-256 {digest}, the Inventory records {entry.manifest_sha256}"
295
312
  found.append(VerifyFinding(file=manifest_rel, detail=detail))
313
+ else:
314
+ found += _routing_findings(leaf_file, entry, edition_dir)
296
315
  report = check_edition(
297
316
  edition_dir, at="publish", structures=structures or None, extensions={STATCAT_SECTION: check_catalog}
298
317
  )
@@ -307,7 +326,9 @@ def verify_catalog(path: str | Path) -> VerifyReport:
307
326
  read protocol skipped is a finding. For each product it checks the leaf
308
327
  against its name and sdmxlib's leaf rules; for each entry of the leaf, in
309
328
  every state but retired, it compares ``manifest.json`` with the
310
- ``manifest_sha256`` the Inventory records, then runs
329
+ ``manifest_sha256`` the Inventory records, compares the entry's filled
330
+ ``dataflows``, ``pull_bytes`` and ``artefacts`` with the manifest's
331
+ (`sdmxlib.inventory.verify_entry`; an entry without them is skipped), then runs
311
332
  ``check_edition(at="publish", extensions={"statcat": check_catalog})``,
312
333
  which checks the size and digests of every file the manifest lists,
313
334
  ``data/`` files included, the lake, the bindings and the ``statcat``
@@ -345,7 +366,7 @@ def verify_catalog(path: str | Path) -> VerifyReport:
345
366
  structures = _structures_for(full, root, product_id)
346
367
  for entry in leaf.editions:
347
368
  editions.append(f"{product_id}/{entry.edition_id}")
348
- findings += _verify_edition(root, product_id, entry, structures)
369
+ findings += _verify_edition(root, product_id, ref.leaf, entry, structures)
349
370
  stale, unread = _stale(full, InventoryWriter(root).artefacts)
350
371
  findings += unread
351
372
  return VerifyReport(
@@ -115,7 +115,9 @@ constants, `IncompatibleCatalogError`, `IncompatibleIndexError`, `Row` and
115
115
  `previous` and `manifest_sha256`.
116
116
  - `settle_journal(writer, product_dir, product_id, lifecycle, *,
117
117
  exclude=())` applies the journal rule catalog-wide (`_Repair` runs the
118
- publish checks and the one commit); `record_journalled(...)` is its pure
118
+ publish checks and the one commit, with each edition's `routing_fields`);
119
+ `recorded_routing(fields, kind)` gives `record` both routing fields, and
120
+ `artefacts` for a structure edition only; `record_journalled(...)` is its pure
119
121
  part per product, taking each entry as `(JournalEntry, manifest_sha256,
120
122
  schema_version, kind)`.
121
123
  - `Planned(product_id, edition_id, leaf, read=None)` and
@@ -149,17 +151,23 @@ constants, `IncompatibleCatalogError`, `IncompatibleIndexError`, `Row` and
149
151
  reader the rules use; `cross_product.ManifestArtefacts(root)` by default),
150
152
  `catalog_document()`, `has_version()`, `full() -> FullInventory | None`,
151
153
  `head() -> Root | None`, `write_catalog_json(*, catalog_id=None)`,
152
- `try_commit(base, leaves, *, retired=None, embargo=None, extensions=None,
153
- kind, reason) -> Root` (no cross-product rule), `refusals(full, leaves, *,
154
+ `check_writable(base, *, now=None) -> CatalogDocument` (the two stage-1
155
+ refusals alone), `try_commit(base, leaves, *, retired=None, embargo=None,
156
+ extensions=None, built_from=None, now=None, kind, reason) -> Root` (no
157
+ cross-product rule), `refusals(full, leaves, *,
154
158
  artefacts=None) -> dict[str, CrossProductRuleError]`, and `commit(change,
155
159
  *, kind, reason) -> Root`, which runs `refusals` on each planned change and
156
160
  raises the first refusal by product id before writing.
157
- - `Change(leaves, retired=None, embargo=None, extensions=None)`: what
161
+ - `Change(leaves, retired=None, embargo=None, extensions=None,
162
+ built_from=None)`: what
158
163
  `commit`'s *change* callable returns for a head.
159
164
  - `MAX_RACES = 8`. `producer()` returns
160
165
  `Producer(name="statcatlib", version=<installed version>)`.
161
166
  - Errors: `InventoryConflictError` (another writer created the version
162
167
  first), `InventoryContentionError` (`MAX_RACES` lost in a row),
168
+ `InventoryWriteRefusedError` (a subclass of sdmxlib's `InventoryError`: a
169
+ `catalog.json` at 2.x, or a base version more than 5 minutes ahead of this
170
+ machine's clock, named in the message),
163
171
  `InventoryRuleError(message, *, rule, product, edition)` with
164
172
  `InventoryRuleError.wrapping(exc, ...)` over sdmxlib's `TransitionError`,
165
173
  and its subclasses `NonFastForwardError` and `CrossProductRuleError(message,
@@ -184,7 +192,7 @@ Imports only sdmxlib, so `inventory.py` can import it.
184
192
  `manifest.json`, checks its SHA-256 (`ManifestError` otherwise) and caches
185
193
  by `(edition_id, sha)`; `StoreArtefacts(edition_id, read, *, fallback)`
186
194
  answers one working copy's edition with `read()` and the rest with
187
- *fallback*; `manifest_records(manifest)` is the shared conversion.
195
+ *fallback*. The conversion is `sdmxlib.inventory.manifest_records`.
188
196
  - `StaleEdition` and `stale_editions(full, artefacts)`: each live or
189
197
  embargoed data edition and URN whose fingerprint (a partial's
190
198
  `partial_of`) no live or embargoed edition of its structure home holds.
@@ -307,7 +315,7 @@ Imports only sdmxlib, so `inventory.py` can import it.
307
315
  writer's journal repair, counts as recorded); clear the journal entry. The steps are split for
308
316
  `Catalog.seal`: `_precheck(checks)` (steps 0 to 2), `_seal_edition()`
309
317
  (steps 3 to 5, returning a private `_Sealed` with the edition, its
310
- manifest's store schema and its `cut_partials`), `_next_leaf(full, sealed)` (the record's leaf;
318
+ manifest's store schema, its `routing_fields` and its `cut_partials`), `_next_leaf(full, sealed)` (the record's leaf, with `recorded_routing`;
311
319
  `InventoryRuleError` on a refusal), `_publish_check(structures)`,
312
320
  `_recorded()` and `_unrecorded(exc)` (clear the journal entry; the second
313
321
  also logs the WARNING, for an `InventoryRuleError` or an
@@ -343,7 +351,8 @@ Imports only sdmxlib, so `inventory.py` can import it.
343
351
  one whose check was given a journalled structure edition not recorded,
344
352
  is dropped. Any other refusal, and a failed check, clears the entry and
345
353
  logs a WARNING.
346
- `InventoryContentionError` keeps the entry for the next working copy. An
354
+ `InventoryContentionError` and `InventoryWriteRefusedError` keep the entry
355
+ for a later working copy. An
347
356
  unreadable entry raises `JournalError`.
348
357
  - A refusal after the manifest (a rollback landing during the seal) raises
349
358
  `NonFastForwardError` from the record; the seal clears the journal entry,
@@ -358,7 +367,15 @@ Imports only sdmxlib, so `inventory.py` can import it.
358
367
  `FileExistsError` becomes `InventoryConflictError`; a leaf that exists already counts as written, since
359
368
  its name is the hash of its bytes. It writes leaves, then the root, then
360
369
  `CURRENT`, and replaces `CURRENT` only with a higher `seq` in the same
361
- `restore_epoch`. `commit` re-reads the head and calls *change* again after
370
+ `restore_epoch`. Every root it writes carries `previous_root_sha256` and
371
+ `changes`, from root `seq - 1`'s stored bytes and the leaves of each product
372
+ whose pointer or tombstones change, also when `built_from` names an older
373
+ version; only those leaves are read. `try_commit` runs `check_writable`
374
+ first: `catalog.json` is decoded with `decoder="stage-1-writer"`, and
375
+ `next_created_at` refuses a base more than 5 minutes ahead. The seal's
376
+ `_precheck` runs it too, so a refused seal writes nothing and leaves the
377
+ working copy open. `catalog_document()` decodes as a reader does.
378
+ `commit` re-reads the head and calls *change* again after
362
379
  each `InventoryConflictError`, so every rule is evaluated against the
363
380
  version the commit lands on, and raises `InventoryContentionError` after
364
381
  `MAX_RACES`. A root records `catalog.json`'s `restore_epoch`, and its
@@ -401,9 +418,11 @@ Imports only sdmxlib, so `inventory.py` can import it.
401
418
 
402
419
  - Run: `just ci`
403
420
  - Unit tier only: `uv run pytest -m unit`
404
- - The Inventory writer: `tests/test_inventory_writer.py` (unit) and
421
+ - The Inventory writer: `tests/test_inventory_writer.py` (unit),
405
422
  `tests/test_inventory_conformance.py` (integration, sdmxlib's conformance
406
- suite). The cross-product rules: `tests/test_cross_product_rules.py` (unit,
423
+ suite and `run_history`) and `tests/test_inventory_history.py` (integration:
424
+ the chain over seals, a correction, a lost race, the fast base, an opted-in
425
+ catalog). The routing fields: `tests/test_inventory_routing.py`. The cross-product rules: `tests/test_cross_product_rules.py` (unit,
407
426
  `refusals` over built leaves) and `tests/test_cross_product.py`
408
427
  (integration: seals, batches, releases, rollbacks, stale editions,
409
428
  partials with no structure home, the committed sabotage test). Seals: `tests/test_seal_checks.py`, `tests/test_seal_crash.py`
@@ -48,14 +48,13 @@ from typing import TYPE_CHECKING, Literal
48
48
 
49
49
  from attrs import frozen
50
50
  from sdmxlib.edition import MANIFEST_NAME, ManifestError, read_manifest
51
- from sdmxlib.inventory import PartialRecord
51
+ from sdmxlib.inventory import manifest_records
52
52
 
53
53
  if TYPE_CHECKING:
54
54
  from collections.abc import Callable, Iterable, Mapping
55
55
  from pathlib import Path
56
56
 
57
- from sdmxlib.edition import Manifest
58
- from sdmxlib.inventory import FullInventory, Kind, Leaf, LeafEntry, State
57
+ from sdmxlib.inventory import FullInventory, Kind, Leaf, LeafEntry, PartialRecord, State
59
58
 
60
59
  __all__ = [
61
60
  "ACTIVE",
@@ -69,7 +68,6 @@ __all__ = [
69
68
  "cited_structures",
70
69
  "homeless_partials",
71
70
  "incoming",
72
- "manifest_records",
73
71
  "refusals",
74
72
  "stale_editions",
75
73
  ]
@@ -124,17 +122,6 @@ class _Held:
124
122
  fingerprint: str
125
123
 
126
124
 
127
- def manifest_records(manifest: Manifest) -> dict[str, PartialRecord]:
128
- """A sealed edition's artefacts as the rules read them (see `Artefacts`)."""
129
- found: dict[str, PartialRecord] = {}
130
- for artefact in manifest.artefacts:
131
- if not artefact.partial:
132
- found[artefact.urn] = PartialRecord(fingerprint=artefact.fingerprint)
133
- elif artefact.partial_of is not None:
134
- found[artefact.urn] = PartialRecord(fingerprint=artefact.fingerprint, partial_of=artefact.partial_of)
135
- return found
136
-
137
-
138
125
  class ManifestArtefacts:
139
126
  """The `Artefacts` reader over sealed manifests, ``products/<product>/<edition>/manifest.json``.
140
127
 
@@ -3,7 +3,7 @@
3
3
  sdmxlib owns the Inventory's documents, their validation and the state
4
4
  transition rules (`sdmxlib.inventory`). statcatlib writes them, and this module
5
5
  is the only code that writes ``catalog.json``, ``inventory/`` and ``CURRENT``.
6
- It adds three things to sdmxlib's helpers:
6
+ It adds these things to sdmxlib's helpers:
7
7
 
8
8
  - **exclusive create**: each document is written by
9
9
  `sdmxlib.storage.create_exclusive`, which stages the file, fsyncs it,
@@ -17,7 +17,14 @@ It adds three things to sdmxlib's helpers:
17
17
  - **the cross-product rules** (`statcatlib.store.cross_product`, Phase 3):
18
18
  `InventoryWriter.commit` refuses a version that breaks specification §6.5
19
19
  item 4, 7 or 8 for an entry that becomes live or embargoed in it, with
20
- `CrossProductRuleError`.
20
+ `CrossProductRuleError`;
21
+ - **the history fields** (Inventory 1.2, GH-125): every root carries
22
+ ``previous_root_sha256`` and ``changes``, computed from root ``seq - 1`` and
23
+ its leaves, whatever version a correction is built from, and again after a
24
+ lost race;
25
+ - **the stage-1 refusals**: every commit decodes ``catalog.json`` as a writer
26
+ without the catalog lock, so it refuses a catalog that has opted in to expiry,
27
+ and refuses a base version more than 5 minutes ahead of this machine's clock.
21
28
  """
22
29
 
23
30
  from __future__ import annotations
@@ -39,6 +46,7 @@ from sdmxlib.inventory import (
39
46
  CATALOG_NAME,
40
47
  CURRENT_NAME,
41
48
  CatalogDocument,
49
+ ChangeItem,
42
50
  Current,
43
51
  Event,
44
52
  EventKind,
@@ -48,24 +56,26 @@ from sdmxlib.inventory import (
48
56
  Root,
49
57
  Tombstone,
50
58
  TransitionError,
59
+ change_list,
51
60
  decode_catalog,
52
61
  decode_current,
62
+ decode_leaf,
53
63
  encode,
54
64
  leaf_ref,
55
65
  local_fetch,
66
+ next_created_at,
56
67
  next_root,
57
68
  root_path,
58
69
  sha256_hex,
59
- validate_root,
60
70
  )
61
71
  from sdmxlib.storage import create_exclusive
62
72
 
63
73
  from statcatlib.store import cross_product
64
74
 
65
75
  if TYPE_CHECKING:
66
- from collections.abc import Callable, Mapping, Sequence
76
+ from collections.abc import Callable, Iterable, Mapping, Sequence
67
77
 
68
- from sdmxlib.inventory import Rule
78
+ from sdmxlib.inventory import LeafRef, Rule
69
79
  from sdmxlib.storage import JsonValue
70
80
 
71
81
  from statcatlib.store.cross_product import Artefacts, CrossRule
@@ -77,6 +87,7 @@ __all__ = [
77
87
  "InventoryConflictError",
78
88
  "InventoryContentionError",
79
89
  "InventoryRuleError",
90
+ "InventoryWriteRefusedError",
80
91
  "InventoryWriter",
81
92
  "NonFastForwardError",
82
93
  "producer",
@@ -100,6 +111,18 @@ class InventoryContentionError(Exception):
100
111
  """A commit lost `MAX_RACES` races in a row to other writers, and gave up."""
101
112
 
102
113
 
114
+ class InventoryWriteRefusedError(InventoryError):
115
+ """This writer must not write the next Inventory version, whatever the version holds.
116
+
117
+ The catalog has opted in to expiry (``catalog.json`` 2.x), which needs a
118
+ writer with the catalog lock, or the base version's ``created_at`` is more
119
+ than 5 minutes ahead of this machine's clock. The message names the
120
+ format, or the base version's time and this machine's. It stays refused
121
+ until the catalog or the clock changes, so a seal refused after its
122
+ manifest keeps its journal entry, as `InventoryContentionError` does.
123
+ """
124
+
125
+
103
126
  class InventoryRuleError(Exception):
104
127
  """A change sdmxlib's Inventory rules refuse, or a lifecycle change statcatlib refuses.
105
128
 
@@ -229,12 +252,16 @@ class Change:
229
252
  retired: The tombstones the next root holds; ``None`` keeps the head's.
230
253
  embargo: The next root's embargo setting; ``None`` keeps the head's.
231
254
  extensions: The next root's extension sections; ``None`` keeps the head's.
255
+ built_from: The older version a correction is built from
256
+ (``previous_inventory``); ``None`` builds from the head. The
257
+ history fields follow the head either way.
232
258
  """
233
259
 
234
260
  leaves: Mapping[str, Leaf]
235
261
  retired: Sequence[Tombstone] | None = None
236
262
  embargo: bool | None = None
237
263
  extensions: Mapping[str, Mapping[str, JsonValue]] | None = None
264
+ built_from: int | None = None
238
265
 
239
266
 
240
267
  def _fsync_dir(directory: Path) -> None:
@@ -313,7 +340,10 @@ class InventoryWriter:
313
340
  # -- reading ---------------------------------------------------------
314
341
 
315
342
  def catalog_document(self) -> CatalogDocument:
316
- """``catalog.json``, decoded; a missing one raises `FileNotFoundError`, a malformed one `InventoryError`."""
343
+ """``catalog.json``, decoded as a reader decodes it, 2.x included.
344
+
345
+ A missing one raises `FileNotFoundError`, a malformed one `InventoryError`.
346
+ """
317
347
  return decode_catalog((self._root / CATALOG_NAME).read_bytes())
318
348
 
319
349
  def has_version(self) -> bool:
@@ -378,6 +408,81 @@ class InventoryWriter:
378
408
  create_exclusive(self._root / CATALOG_NAME, encode(document))
379
409
  return document
380
410
 
411
+ def check_writable(self, base: Root | None, *, now: datetime | None = None) -> CatalogDocument:
412
+ """Refuse a commit this writer must not make on top of *base*, and return ``catalog.json``.
413
+
414
+ Every commit runs it first, and a seal runs it before it writes
415
+ anything.
416
+
417
+ Args:
418
+ base: The newest version, or ``None`` for the catalog's first.
419
+ now: This machine's time; the clock when ``None``.
420
+
421
+ Returns:
422
+ ``catalog.json``, decoded as a writer without the catalog lock.
423
+
424
+ Raises:
425
+ InventoryWriteRefusedError: ``catalog.json`` is malformed or 2.x, or
426
+ *base* was created more than 5 minutes ahead of *now*; the
427
+ message names the format or the base version's time.
428
+
429
+ A missing ``catalog.json`` raises `FileNotFoundError`.
430
+ """
431
+ raw = (self._root / CATALOG_NAME).read_bytes()
432
+ try:
433
+ document = decode_catalog(raw, decoder="stage-1-writer")
434
+ next_created_at(base, now or datetime.now(UTC))
435
+ except InventoryError as exc:
436
+ raise InventoryWriteRefusedError(str(exc)) from exc
437
+ return document
438
+
439
+ def _stored_root(self, base: Root) -> bytes:
440
+ """The stored bytes of *base*; a root a restore removed means another writer moved the head."""
441
+ try:
442
+ return (self._root / root_path(base.seq)).read_bytes()
443
+ except FileNotFoundError as exc:
444
+ msg = f"Inventory version {base.seq} no longer exists"
445
+ raise InventoryConflictError(msg) from exc
446
+
447
+ def _base_leaves(
448
+ self, base: Root, products: Mapping[str, LeafRef], retired: Iterable[Tombstone]
449
+ ) -> dict[str, Leaf]:
450
+ """The leaves of *base* the change list reads: each product whose pointer or tombstones change.
451
+
452
+ Args:
453
+ base: The base version, root ``seq - 1``.
454
+ products: Every product's leaf pointer in the new version.
455
+ retired: The new version's tombstones.
456
+
457
+ Returns:
458
+ Those products' leaves in *base*, by product.
459
+
460
+ Raises:
461
+ InventoryError: A leaf is missing or malformed.
462
+ """
463
+ moved = {t.product_id for t in set(base.retired) ^ set(retired)}
464
+ moved |= {p for p, ref in base.products.items() if products.get(p) != ref}
465
+ found: dict[str, Leaf] = {}
466
+ for product_id in moved & base.products.keys():
467
+ ref = base.products[product_id]
468
+ try:
469
+ found[product_id] = decode_leaf((self._root / ref.leaf).read_bytes())
470
+ except FileNotFoundError as exc:
471
+ msg = f"{product_id}'s leaf {ref.leaf} in Inventory version {base.seq} is missing"
472
+ raise InventoryError(msg) from exc
473
+ return found
474
+
475
+ def _changes(
476
+ self,
477
+ base: Root | None,
478
+ leaves: Mapping[str, Leaf],
479
+ products: Mapping[str, LeafRef],
480
+ retired: Sequence[Tombstone],
481
+ ) -> tuple[ChangeItem, ...]:
482
+ """The change list of the version after *base*, which is root ``seq - 1`` whatever it is built from."""
483
+ base_leaves = {} if base is None else self._base_leaves(base, products, retired)
484
+ return change_list(base, base_leaves, products=products, leaves=leaves, retired=retired)
485
+
381
486
  def try_commit(
382
487
  self,
383
488
  base: Root | None,
@@ -386,11 +491,16 @@ class InventoryWriter:
386
491
  retired: Sequence[Tombstone] | None = None,
387
492
  embargo: bool | None = None,
388
493
  extensions: Mapping[str, Mapping[str, JsonValue]] | None = None,
494
+ built_from: int | None = None,
495
+ now: datetime | None = None,
389
496
  kind: EventKind,
390
497
  reason: str,
391
498
  ) -> Root:
392
499
  """Write the version after *base*, once: its changed leaves, its root, then ``CURRENT``.
393
500
 
501
+ The root carries the history fields, computed from *base*'s stored
502
+ bytes and its leaves, also when *built_from* names an older version.
503
+
394
504
  Args:
395
505
  base: The newest version, read before the change was computed, or
396
506
  ``None`` for the catalog's first.
@@ -399,6 +509,9 @@ class InventoryWriter:
399
509
  retired: The tombstones; *base*'s when ``None``.
400
510
  embargo: The embargo setting; *base*'s when ``None``.
401
511
  extensions: The extension sections; *base*'s when ``None``.
512
+ built_from: The older version a correction is built from; *base*
513
+ when ``None``.
514
+ now: This machine's time; the clock when ``None``.
402
515
  kind: What the version records (``event.kind``).
403
516
  reason: Why, in the caller's words (``event.reason``).
404
517
 
@@ -408,25 +521,34 @@ class InventoryWriter:
408
521
  Raises:
409
522
  InventoryConflictError: Another writer created the version after
410
523
  *base* first.
524
+
525
+ `check_writable` raises `InventoryWriteRefusedError` before anything is
526
+ written.
411
527
  """
528
+ now = now or datetime.now(UTC)
529
+ catalog = self.check_writable(base, now=now)
530
+ previous_bytes = None if base is None else self._stored_root(base)
412
531
  products = dict(base.products) if base is not None else {}
413
532
  for product_id, leaf in leaves.items():
414
533
  data, ref = leaf_ref(leaf)
415
534
  self._write_leaf(self._root / ref.leaf, data)
416
535
  products[product_id] = ref
536
+ tombstones = tuple(retired) if retired is not None else (base.retired if base is not None else ())
417
537
  root = next_root(
418
538
  base,
419
539
  products=products,
420
540
  event=Event(actor=_actor(), reason=reason, kind=kind),
421
541
  producer=producer(),
422
- now=datetime.now(UTC),
423
- retired=retired,
542
+ now=now,
543
+ retired=tombstones,
424
544
  embargo=embargo,
425
545
  # A root records catalog.json's restore_epoch when it is written.
426
- restore_epoch=self.catalog_document().restore_epoch,
546
+ restore_epoch=catalog.restore_epoch,
547
+ built_from=built_from,
427
548
  extensions=extensions,
549
+ previous_bytes=previous_bytes,
550
+ changes=self._changes(base, leaves, products, tombstones),
428
551
  )
429
- validate_root(root)
430
552
  data = encode(root)
431
553
  try:
432
554
  create_exclusive(self._root / root_path(root.seq), data)
@@ -466,7 +588,9 @@ class InventoryWriter:
466
588
  A version that breaks a cross-product rule raises the first refused
467
589
  product's `CrossProductRuleError`, by product id, and a manifest the
468
590
  rules cannot read raises `sdmxlib.edition.ManifestError`; neither
469
- writes anything.
591
+ writes anything. `try_commit` raises `InventoryWriteRefusedError` for a
592
+ catalog or a base version this writer must not write on, and it
593
+ writes nothing either.
470
594
  """
471
595
  for _ in range(MAX_RACES):
472
596
  full = self.full()
@@ -479,6 +603,7 @@ class InventoryWriter:
479
603
  retired=planned.retired,
480
604
  embargo=planned.embargo,
481
605
  extensions=planned.extensions,
606
+ built_from=planned.built_from,
482
607
  kind=kind,
483
608
  reason=reason,
484
609
  )
@@ -38,6 +38,7 @@ version:
38
38
  | The cross-product rules refuse it | Clear the entry, log a WARNING, leave the edition |
39
39
  | Its check was given a structure edition that is not recorded | Clear the entry, log a WARNING, leave the edition |
40
40
  | `InventoryContentionError` | Keep the entry, so the next working copy records it |
41
+ | `InventoryWriteRefusedError` | Keep the entry, so a working copy records it once the catalog or the clock allows |
41
42
  | The entry cannot be read | Raise `JournalError` naming the file and ``statcat doctor`` |
42
43
 
43
44
  Another product's entry is acted on only when its edition has a manifest and
@@ -68,7 +69,7 @@ import sdmxlib.edition as _edition
68
69
  from attrs import frozen
69
70
  from sdmxlib import SdmxUrn
70
71
  from sdmxlib.edition import MANIFEST_NAME, Finding, ManifestError, canonical_json, check_edition
71
- from sdmxlib.inventory import FullInventory, Leaf, PartialRecord, TransitionError, head, record
72
+ from sdmxlib.inventory import FullInventory, Leaf, PartialRecord, TransitionError, head, record, routing_fields
72
73
  from sdmxlib.storage.schema import SCHEMA_VERSION, meta_value
73
74
 
74
75
  from statcatlib.bundle import STATCAT_SECTION, catalog_section, check_catalog, structure_content
@@ -80,6 +81,7 @@ from statcatlib.store.inventory import (
80
81
  InventoryContentionError,
81
82
  InventoryRuleError,
82
83
  InventoryWriter,
84
+ InventoryWriteRefusedError,
83
85
  NonFastForwardError,
84
86
  producer,
85
87
  )
@@ -92,7 +94,7 @@ if TYPE_CHECKING:
92
94
  import duckdb
93
95
  from sdmxlib import Dataflow, EditionRegistry, MaintainableArtefact, RestRegistry, WriteSource
94
96
  from sdmxlib.edition import JournalEntry, Lifecycle, Manifest
95
- from sdmxlib.inventory import Kind
97
+ from sdmxlib.inventory import Kind, RoutingFields
96
98
 
97
99
  from statcatlib.model.dataset import Dataset
98
100
  from statcatlib.model.skos import Concept, ConceptScheme
@@ -317,6 +319,24 @@ def _named(full: FullInventory | None, product_id: str, edition_id: str) -> bool
317
319
  return any(e.edition_id == edition_id for e in full.entries(product_id))
318
320
 
319
321
 
322
+ def recorded_routing(fields: RoutingFields | None, kind: Kind) -> _Routing:
323
+ """What a leaf entry records of *fields*: both routing fields, and the artefact records of a structure edition.
324
+
325
+ Phase 5 extends the artefact records to data editions (GH-125 part 4).
326
+
327
+ Args:
328
+ fields: The edition's `sdmxlib.inventory.routing_fields`, or ``None``
329
+ to record none.
330
+ kind: The product's kind.
331
+
332
+ Returns:
333
+ ``record``'s ``dataflows``, ``pull_bytes`` and ``artefacts``.
334
+ """
335
+ if fields is None:
336
+ return None, None, None
337
+ return fields.dataflows, fields.pull_bytes, fields.artefacts if kind == "structure" else None
338
+
339
+
320
340
  def record_journalled(
321
341
  leaf: Leaf | None,
322
342
  entries: Sequence[tuple[JournalEntry, str, str, Kind]],
@@ -326,6 +346,7 @@ def record_journalled(
326
346
  embargo: bool,
327
347
  retired: Iterable[str],
328
348
  now: datetime,
349
+ routing: Mapping[str, RoutingFields] | None = None,
329
350
  ) -> tuple[Leaf | None, list[str], dict[str, TransitionError]]:
330
351
  """Record each journalled edition in *leaf*, by the journal rule.
331
352
 
@@ -338,6 +359,8 @@ def record_journalled(
338
359
  embargo: The head's embargo setting.
339
360
  retired: The edition ids the head tombstones for the product.
340
361
  now: The record time.
362
+ routing: Each edition's routing fields, by edition id; an edition
363
+ not in it is recorded without them.
341
364
 
342
365
  Returns:
343
366
  The next leaf (``None`` when nothing changed), the edition ids that are
@@ -350,6 +373,7 @@ def record_journalled(
350
373
  refused: dict[str, TransitionError] = {}
351
374
  gone = set(retired)
352
375
  for entry, sha, schema_version, kind in entries:
376
+ dataflows, pull_bytes, artefacts = recorded_routing((routing or {}).get(entry.edition_id), kind)
353
377
  try:
354
378
  current = record(
355
379
  current,
@@ -363,6 +387,9 @@ def record_journalled(
363
387
  embargo=embargo,
364
388
  now=now,
365
389
  retired=gone,
390
+ artefacts=artefacts,
391
+ dataflows=dataflows,
392
+ pull_bytes=pull_bytes,
366
393
  )
367
394
  except TransitionError as exc:
368
395
  held = next((e for e in (current.editions if current else ()) if e.edition_id == entry.edition_id), None)
@@ -410,8 +437,10 @@ def settle_journal(
410
437
 
411
438
  An entry of the product that cannot be read raises `JournalError`;
412
439
  another product's unreadable journal is left to that product. A record
413
- that loses too many races raises `InventoryContentionError`, and the
414
- entries are kept for the next working copy.
440
+ that loses too many races raises `InventoryContentionError`, a catalog or
441
+ base version the writer must not write on raises
442
+ `InventoryWriteRefusedError`, and either way the entries are kept for a
443
+ later working copy.
415
444
  """
416
445
  products_dir = product_dir.parent
417
446
  own = _read_journal(product_dir)
@@ -455,6 +484,10 @@ type _Journalled = tuple[JournalEntry, str, str, Kind]
455
484
  """A journal entry with its edition's ``manifest_sha256``, ``schema_version`` and product kind."""
456
485
 
457
486
 
487
+ type _Routing = tuple[tuple[str, ...] | None, int | None, Mapping[str, PartialRecord] | None]
488
+ """``record``'s ``dataflows``, ``pull_bytes`` and ``artefacts``."""
489
+
490
+
458
491
  def _other_journals(products_dir: Path, product_id: str, exclude: Collection[str]) -> dict[str, list[JournalEntry]]:
459
492
  """The journal entries of every product but *product_id* and *exclude*; an unreadable journal is skipped."""
460
493
  found: dict[str, list[JournalEntry]] = {}
@@ -486,6 +519,11 @@ class _Repair:
486
519
  self._failed: dict[tuple[str, str], Exception] = {}
487
520
  self._refused: dict[tuple[str, str], Exception] = {}
488
521
  self._given: dict[tuple[str, str], frozenset[str]] = {}
522
+ self._routing = {
523
+ entry.edition_id: routing_fields(_edition.read_manifest(self._dir(product_id, entry.edition_id)))
524
+ for product_id, items in pending.items()
525
+ for entry, _, _, _ in items
526
+ }
489
527
  self._structures = self._journalled_structures()
490
528
  self._check(full)
491
529
 
@@ -550,6 +588,7 @@ class _Repair:
550
588
  embargo=head_now.embargo if head_now is not None else False,
551
589
  retired=retired,
552
590
  now=datetime.now(UTC),
591
+ routing=self._routing,
553
592
  )
554
593
  for edition_id, exc in found.items():
555
594
  self._refused[product_id, edition_id] = exc
@@ -594,6 +633,9 @@ class _Repair:
594
633
  except InventoryContentionError as exc:
595
634
  msg = f"{exc}; the journalled edition is recorded at the next working_copy()"
596
635
  raise InventoryContentionError(msg) from exc
636
+ except InventoryWriteRefusedError as exc:
637
+ msg = f"{exc}; the journalled edition stays unrecorded until a working_copy() can record it"
638
+ raise InventoryWriteRefusedError(msg) from exc
597
639
 
598
640
 
599
641
  class _NothingToRepairError(Exception):
@@ -773,11 +815,13 @@ class _Sealed:
773
815
  Attributes:
774
816
  edition: The edition.
775
817
  schema_version: Its store schema.
818
+ routing: Its manifest's routing fields, which the record writes.
776
819
  partials: The partials its manifest records a ``partial_of`` for.
777
820
  """
778
821
 
779
822
  edition: Edition
780
823
  schema_version: str
824
+ routing: RoutingFields
781
825
  partials: tuple[str, ...] = ()
782
826
 
783
827
 
@@ -1443,6 +1487,11 @@ class WorkingCopy:
1443
1487
  disk unrecorded.
1444
1488
  InventoryContentionError: The record lost too many races; the
1445
1489
  product's next ``working_copy()`` records the edition.
1490
+ InventoryWriteRefusedError: The catalog has opted in to expiry, or
1491
+ the newest Inventory version's time is more than 5 minutes
1492
+ ahead of this machine's clock. Raised from the pre-check, it
1493
+ leaves the working copy open; raised from the record, it keeps
1494
+ the journal entry for a later ``working_copy()``.
1446
1495
 
1447
1496
  Before anything is written, a check's finding raises `SealCheckError`
1448
1497
  (for a structure product, also each dataflow, binding or partial it
@@ -1479,6 +1528,9 @@ class WorkingCopy:
1479
1528
  except InventoryContentionError as exc:
1480
1529
  msg = f"{exc}; the edition {self._edition_id} is recorded at the next working_copy()"
1481
1530
  raise InventoryContentionError(msg) from exc
1531
+ except InventoryWriteRefusedError as exc:
1532
+ msg = f"{exc}; the edition {self._edition_id} stays unrecorded until a working_copy() can record it"
1533
+ raise InventoryWriteRefusedError(msg) from exc
1482
1534
  self._recorded()
1483
1535
  return sealed.edition
1484
1536
 
@@ -1501,8 +1553,12 @@ class WorkingCopy:
1501
1553
  Raises:
1502
1554
  SealCheckError: A check found something.
1503
1555
  NonFastForwardError: The base is no longer the live edition.
1556
+
1557
+ A catalog or a base version the writer must not write on raises
1558
+ `InventoryWriteRefusedError` first.
1504
1559
  """
1505
1560
  store = self._live()
1561
+ self._writer.check_writable(self._writer.head())
1506
1562
  settle_journal(
1507
1563
  self._writer,
1508
1564
  self._product_dir,
@@ -1702,7 +1758,12 @@ class WorkingCopy:
1702
1758
  previous=manifest.previous,
1703
1759
  manifest_sha256=manifest_sha256(edition_dir),
1704
1760
  )
1705
- return _Sealed(edition=edition, schema_version=manifest.schema_version, partials=cut_partials(manifest))
1761
+ return _Sealed(
1762
+ edition=edition,
1763
+ schema_version=manifest.schema_version,
1764
+ routing=routing_fields(manifest),
1765
+ partials=cut_partials(manifest),
1766
+ )
1706
1767
 
1707
1768
  def _next_leaf(self, full: FullInventory | None, sealed: _Sealed) -> Leaf:
1708
1769
  """Seal step 6's record: the product's next leaf in *full*, with *sealed* recorded.
@@ -1722,6 +1783,7 @@ class WorkingCopy:
1722
1783
  product_id, edition_id = self._product_id, self._edition_id
1723
1784
  leaf = full.leaf(product_id) if full is not None and product_id in full.products() else None
1724
1785
  retired = {t.edition_id for t in full.retired if t.product_id == product_id} if full else set()
1786
+ dataflows, pull_bytes, artefacts = recorded_routing(sealed.routing, self._kind)
1725
1787
  try:
1726
1788
  return record(
1727
1789
  leaf,
@@ -1735,6 +1797,9 @@ class WorkingCopy:
1735
1797
  embargo=full.embargo if full is not None else False,
1736
1798
  now=datetime.now(UTC),
1737
1799
  retired=retired,
1800
+ artefacts=artefacts,
1801
+ dataflows=dataflows,
1802
+ pull_bytes=pull_bytes,
1738
1803
  )
1739
1804
  except TransitionError as exc:
1740
1805
  raise InventoryRuleError.wrapping(exc, product=product_id, edition=edition_id) from exc
@@ -43,23 +43,30 @@ import sdmxlib as sl
43
43
  from attrs import field, frozen
44
44
  from sdmxlib.testing import synthetic_edition
45
45
 
46
+ from statcatlib.admin import CatalogAdmin
46
47
  from statcatlib.catalog import Catalog
47
48
  from statcatlib.sources.sdmx import ingest_dataflows, prepare_sdmx
48
49
  from statcatlib.store.store import CodelistIndexPolicy
50
+ from statcatlib.store.working_copy import WorkingCopyExistsError
49
51
 
50
52
  if TYPE_CHECKING:
51
- from collections.abc import Generator, Sequence
53
+ from collections.abc import Callable, Generator, Sequence
52
54
 
53
55
  from sdmxlib.testing import Shape
54
56
 
55
- from statcatlib.store.working_copy import Edition
57
+ from statcatlib.store.working_copy import Edition, WorkingCopy
56
58
 
57
59
  __all__ = [
58
60
  "CODR_AGENCY",
59
61
  "STRUCTURE_PRODUCTS",
60
62
  "CodrShape",
63
+ "CorpusGroup",
64
+ "codr_corpus",
61
65
  "codr_product",
62
66
  "codr_structures",
67
+ "codr_working_copy",
68
+ "corpus_embargoed",
69
+ "corpus_product_id",
63
70
  "synthetic_catalog",
64
71
  "temporary_catalog",
65
72
  ]
@@ -425,27 +432,29 @@ def codr_structures(catalog: Catalog, *, seed: int = 0, shape: CodrShape = _DEFA
425
432
  return dict(outcomes.sealed)
426
433
 
427
434
 
428
- def codr_product(
435
+ def codr_working_copy(
429
436
  catalog: Catalog,
430
437
  product_id: str,
431
438
  *,
432
439
  tables: int = 1,
433
440
  seed: int = 0,
434
441
  shape: CodrShape = _DEFAULT_SHAPE,
435
- ) -> Edition:
436
- """Seal a CODR-shaped data product of *tables* tables into *catalog*, with its codelists cut to partials.
437
-
438
- It seals the three structure products first (`codr_structures`) unless all
439
- three are live. Each table ``n`` gets a data structure,
440
- ``DSD_<product_id>_<n>`` (dimensions ``GEO`` and ``CLASS_1`` onward, then
441
- ``TIME_PERIOD``; attributes ``UNIT_MEASURE`` and ``OBS_STATUS``; measure
442
- ``OBS_VALUE``), a dataflow ``DF_<product_id>_<n>``, and
443
- ``shape.observations`` rows over a share of each codelist's codes: 5 % of
444
- the geography's leaves with their ancestors, a quarter of each
445
- classification, two units and two statuses. `ingest_dataflows` fills the
446
- product's working copy with ``partials=True``, and the seal records each
447
- partial's ``partial_of``. No hierarchy association names a table, so
448
- ``CL_GEO`` is cut. The search index takes the geography's codes with
442
+ ) -> WorkingCopy:
443
+ """Fill a CODR-shaped data product's working copy of *tables* tables, with its codelists cut to partials.
444
+
445
+ `codr_product` seals what this returns; a caller sealing several products
446
+ in one `Catalog.seal` takes the working copies from here. It seals the
447
+ three structure products first (`codr_structures`) unless all three are
448
+ live. Each table ``n`` gets a data structure, ``DSD_<product_id>_<n>``
449
+ (dimensions ``GEO`` and ``CLASS_1`` onward, then ``TIME_PERIOD``;
450
+ attributes ``UNIT_MEASURE`` and ``OBS_STATUS``; measure ``OBS_VALUE``), a
451
+ dataflow ``DF_<product_id>_<n>``, and ``shape.observations`` rows over a
452
+ share of each codelist's codes: 5 % of the geography's leaves with their
453
+ ancestors, a quarter of each classification, two units and two statuses.
454
+ `ingest_dataflows` fills the product's working copy with
455
+ ``partials=True``, so the seal records each partial's ``partial_of``. No
456
+ hierarchy association names a table, so ``CL_GEO`` is cut. When lancedb
457
+ is installed, the search index takes the geography's codes with
449
458
  ``CodelistIndexPolicy`` in ``fts_only`` mode.
450
459
 
451
460
  The structures depend on *seed* alone, and the rows on *seed* and
@@ -461,7 +470,7 @@ def codr_product(
461
470
  shape: The sizes.
462
471
 
463
472
  Returns:
464
- The sealed edition.
473
+ The filled working copy, unsealed.
465
474
  """
466
475
  catalog.refresh()
467
476
  if not set(STRUCTURE_PRODUCTS) <= set(catalog.products()):
@@ -480,7 +489,154 @@ def codr_product(
480
489
  # The geography's codes go in the search index whatever their number,
481
490
  # without embeddings, as a CODR producer indexes them.
482
491
  wc.build_index(codelist_policies=[CodelistIndexPolicy(urns=(str(structures.geography.urn),))])
483
- return wc.seal()
492
+ return wc
493
+
494
+
495
+ def codr_product(
496
+ catalog: Catalog,
497
+ product_id: str,
498
+ *,
499
+ tables: int = 1,
500
+ seed: int = 0,
501
+ shape: CodrShape = _DEFAULT_SHAPE,
502
+ ) -> Edition:
503
+ """Seal a CODR-shaped data product of *tables* tables into *catalog*, with its codelists cut to partials.
504
+
505
+ `codr_working_copy` fills the working copy, and this seals it.
506
+
507
+ Args:
508
+ catalog: A writable catalog.
509
+ product_id: The data product.
510
+ tables: The tables, one dataflow each.
511
+ seed: Chooses the structures and the rows.
512
+ shape: The sizes.
513
+
514
+ Returns:
515
+ The sealed edition.
516
+ """
517
+ return codr_working_copy(catalog, product_id, tables=tables, seed=seed, shape=shape).seal()
518
+
519
+
520
+ @frozen
521
+ class CorpusGroup:
522
+ """One group of a `codr_corpus`: *products* data products of *tables* tables each.
523
+
524
+ Attributes:
525
+ products: The data products in the group.
526
+ tables: The tables of each, one dataflow each.
527
+ """
528
+
529
+ products: int
530
+ tables: int = 1
531
+
532
+ def __attrs_post_init__(self) -> None:
533
+ """Refuse a negative product count and a product of no table.
534
+
535
+ Raises:
536
+ ValueError: *products* is negative or *tables* is below 1.
537
+ """
538
+ if self.products < 0 or self.tables < 1:
539
+ msg = f"a corpus group holds zero or more products of one or more tables, not {self!r}"
540
+ raise ValueError(msg)
541
+
542
+
543
+ def corpus_product_id(group: CorpusGroup, index: int) -> str:
544
+ """The id of the *index*-th product of *group*: ``codr-<tables>t-<index, five digits>``.
545
+
546
+ Args:
547
+ group: The group.
548
+ index: The product's place in the group, from 0.
549
+
550
+ Returns:
551
+ The product id.
552
+ """
553
+ return f"codr-{group.tables}t-{index:05d}"
554
+
555
+
556
+ def corpus_embargoed(product_id: str, *, seed: int = 0, embargoed: float = 0.1) -> bool:
557
+ """Whether `codr_corpus` seals *product_id* under embargo: a seeded draw below the share *embargoed*.
558
+
559
+ Args:
560
+ product_id: The product.
561
+ seed: The corpus seed.
562
+ embargoed: The share of products sealed under embargo.
563
+
564
+ Returns:
565
+ Whether the product seals under embargo.
566
+ """
567
+ return _Draws(seed, f"embargo/{product_id}").below(1_000_000) < round(embargoed * 1_000_000)
568
+
569
+
570
+ def _corpus_product(catalog: Catalog, product_id: str, *, tables: int, seed: int, shape: CodrShape) -> Edition:
571
+ """`codr_product`, after discarding an unsealed working copy a crash left."""
572
+ try:
573
+ return codr_product(catalog, product_id, tables=tables, seed=seed, shape=shape)
574
+ except WorkingCopyExistsError:
575
+ catalog.product(product_id).working_copy(resume=True).discard()
576
+ return codr_product(catalog, product_id, tables=tables, seed=seed, shape=shape)
577
+
578
+
579
+ def codr_corpus(
580
+ catalog: Catalog,
581
+ groups: Sequence[CorpusGroup] = (CorpusGroup(7000),),
582
+ *,
583
+ seed: int = 0,
584
+ shape: CodrShape = _DEFAULT_SHAPE,
585
+ embargoed: float = 0.1,
586
+ on_sealed: Callable[[str, Edition], None] | None = None,
587
+ ) -> list[str]:
588
+ """Seal a corpus of CODR data products into *catalog*, with the structure products they are cut from.
589
+
590
+ Each group's products are named by `corpus_product_id` and built by
591
+ `codr_product` with *seed* and *shape*, so every product cuts its
592
+ codelists from the same structure editions, and each product's rows come
593
+ from its id. A product the Inventory records already, in any state, is
594
+ skipped, so a call with larger groups grows a corpus a smaller call
595
+ built, and a call after a crash resumes it, discarding the unsealed
596
+ working copy the crash left. A share *embargoed* of the products, chosen
597
+ by `corpus_embargoed`, seals with the catalog's embargo on, at the cost
598
+ of two ``embargo-change`` versions each, so the corpus holds live and
599
+ embargoed data entries. The embargo is off when the call returns.
600
+
601
+ Args:
602
+ catalog: A writable catalog.
603
+ groups: The groups, sealed in order.
604
+ seed: Chooses the structures and every product's rows.
605
+ shape: The sizes.
606
+ embargoed: The share of products sealed under embargo, from 0 to 1.
607
+ on_sealed: Called with each product id and edition after its seal.
608
+
609
+ Returns:
610
+ The products this call sealed, in order.
611
+
612
+ Raises:
613
+ ValueError: *embargoed* is outside 0 to 1.
614
+ """
615
+ if not 0 <= embargoed <= 1:
616
+ msg = f"embargoed is a share from 0 to 1, not {embargoed}"
617
+ raise ValueError(msg)
618
+ admin = CatalogAdmin.open(catalog._root)
619
+ if admin.embargo:
620
+ admin.set_embargo(on=False, reason="codr_corpus: seal the corpus with the embargo off")
621
+ recorded = set(admin.products())
622
+ sealed: list[str] = []
623
+ for group in groups:
624
+ for index in range(group.products):
625
+ product_id = corpus_product_id(group, index)
626
+ if product_id in recorded:
627
+ continue
628
+ under_embargo = corpus_embargoed(product_id, seed=seed, embargoed=embargoed)
629
+ if under_embargo:
630
+ admin.set_embargo(on=True, reason=f"codr_corpus: seal {product_id} under embargo")
631
+ try:
632
+ edition = _corpus_product(catalog, product_id, tables=group.tables, seed=seed, shape=shape)
633
+ finally:
634
+ if under_embargo:
635
+ admin.set_embargo(on=False, reason=f"codr_corpus: {product_id} sealed")
636
+ sealed.append(product_id)
637
+ if on_sealed is not None:
638
+ on_sealed(product_id, edition)
639
+ return sealed
484
640
 
485
641
 
486
642
  def synthetic_catalog(path: str | Path, shape: Shape, *, product_id: str = "synthetic", seed: int = 0) -> Edition:
File without changes
File without changes