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.
- {statcatlib-0.13.0 → statcatlib-0.14.0}/PKG-INFO +2 -2
- {statcatlib-0.13.0 → statcatlib-0.14.0}/pyproject.toml +2 -2
- {statcatlib-0.13.0 → statcatlib-0.14.0}/pyproject.toml.orig +3 -3
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/EGM.md +18 -5
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/__init__.py +2 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/catalog.py +14 -2
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/cli/__init__.py +9 -3
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/diagnostics.py +27 -6
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/EGM.md +29 -10
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/cross_product.py +2 -15
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/inventory.py +136 -11
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/working_copy.py +70 -5
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/testing.py +175 -19
- {statcatlib-0.13.0 → statcatlib-0.14.0}/LICENSE +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/README.md +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/admin.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/bundle.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/checks.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/cli/EGM.md +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/expr.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/EGM.md +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/__init__.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/agents.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/catalog.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/classification.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/dataset.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/distribution.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/licence.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/quality.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/skos.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/spatial.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/temporal.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/model/types.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/py.typed +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/query.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/sources/EGM.md +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/sources/__init__.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/sources/ckan.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/sources/sdmx.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/__init__.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/lance.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/row.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/schema.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/scopes.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/sentinel.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/store.py +0 -0
- {statcatlib-0.13.0 → statcatlib-0.14.0}/src/statcatlib/store/xhtml.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
30
|
+
# <0.86: every suite library is pre-1.0, so a minor bump may break
|
|
31
31
|
# (GH-67).
|
|
32
|
-
"sdmxlib>=0.
|
|
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`, `
|
|
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`,
|
|
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,
|
|
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
|
|
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,
|
|
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 (
|
|
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
|
|
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
|
|
285
|
-
"""
|
|
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,
|
|
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
|
|
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
|
-
`
|
|
153
|
-
|
|
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
|
|
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
|
|
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`
|
|
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`.
|
|
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)
|
|
421
|
+
- The Inventory writer: `tests/test_inventory_writer.py` (unit),
|
|
405
422
|
`tests/test_inventory_conformance.py` (integration, sdmxlib's conformance
|
|
406
|
-
suite
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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=
|
|
423
|
-
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=
|
|
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`,
|
|
414
|
-
|
|
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(
|
|
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
|
|
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
|
-
) ->
|
|
436
|
-
"""
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
``
|
|
443
|
-
``
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
``
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|