statcatlib 0.17.0__tar.gz → 0.18.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 (55) hide show
  1. {statcatlib-0.17.0 → statcatlib-0.18.0}/PKG-INFO +1 -1
  2. {statcatlib-0.17.0 → statcatlib-0.18.0}/pyproject.toml +1 -1
  3. {statcatlib-0.17.0 → statcatlib-0.18.0}/pyproject.toml.orig +1 -1
  4. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/EGM.md +150 -9
  5. statcatlib-0.18.0/src/statcatlib/access.py +435 -0
  6. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/cli/EGM.md +30 -10
  7. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/cli/__init__.py +151 -8
  8. statcatlib-0.18.0/src/statcatlib/release.py +1612 -0
  9. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/store/EGM.md +4 -2
  10. {statcatlib-0.17.0 → statcatlib-0.18.0}/LICENSE +0 -0
  11. {statcatlib-0.17.0 → statcatlib-0.18.0}/README.md +0 -0
  12. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/__init__.py +0 -0
  13. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/admin.py +0 -0
  14. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/bundle.py +0 -0
  15. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/catalog.py +0 -0
  16. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/checks.py +0 -0
  17. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/diagnostics.py +0 -0
  18. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/embargo.py +0 -0
  19. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/expr.py +0 -0
  20. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/gc.py +0 -0
  21. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/jobs.py +0 -0
  22. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/model/EGM.md +0 -0
  23. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/model/__init__.py +0 -0
  24. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/model/agents.py +0 -0
  25. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/model/catalog.py +0 -0
  26. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/model/classification.py +0 -0
  27. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/model/dataset.py +0 -0
  28. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/model/distribution.py +0 -0
  29. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/model/licence.py +0 -0
  30. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/model/quality.py +0 -0
  31. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/model/skos.py +0 -0
  32. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/model/spatial.py +0 -0
  33. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/model/temporal.py +0 -0
  34. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/model/types.py +0 -0
  35. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/py.typed +0 -0
  36. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/query.py +0 -0
  37. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/sources/EGM.md +0 -0
  38. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/sources/__init__.py +0 -0
  39. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/sources/ckan.py +0 -0
  40. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/sources/sdmx.py +0 -0
  41. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/store/__init__.py +0 -0
  42. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/store/blob.py +0 -0
  43. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/store/cross_product.py +0 -0
  44. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/store/inventory.py +0 -0
  45. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/store/lance.py +0 -0
  46. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/store/row.py +0 -0
  47. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/store/schema.py +0 -0
  48. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/store/scopes.py +0 -0
  49. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/store/sentinel.py +0 -0
  50. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/store/store.py +0 -0
  51. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/store/working_copy.py +0 -0
  52. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/store/xhtml.py +0 -0
  53. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/testing.py +0 -0
  54. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/upload.py +0 -0
  55. {statcatlib-0.17.0 → statcatlib-0.18.0}/src/statcatlib/urn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statcatlib
3
- Version: 0.17.0
3
+ Version: 0.18.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "statcatlib"
3
- version = "0.17.0"
3
+ version = "0.18.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"]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "statcatlib"
3
- version = "0.17.0"
3
+ version = "0.18.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"]
@@ -18,7 +18,8 @@ products (`catalog.py`), the operational changes to its Inventory
18
18
  an sdmxlib edition manifest (`bundle.py`), the integrity and support
19
19
  checks behind `statcat verify-catalog` and `statcat doctor`
20
20
  (`diagnostics.py`), the copy of a sealed edition to object storage (`upload.py`), the
21
- jobs configuration the release and GC jobs read (`jobs.py`), the GC job (`gc.py`), composable query filters
21
+ jobs configuration the release and GC jobs read (`jobs.py`), the GC job (`gc.py`), the release job
22
+ (`release.py`) and its access precondition (`access.py`), composable query filters
22
23
  (`query.py`), traversal expressions (`expr.py`), SDMX URN builders (`urn.py`)
23
24
  and test catalogs (`testing.py`).
24
25
 
@@ -231,8 +232,9 @@ re-exported from `statcatlib`. Standard library and `attrs` only.
231
232
  `namespace`, `deployments` or `port`, a port outside 1 to 65535, or a failed
232
233
  inequality.
233
234
  - `JobsConfig(retention, access, expiry, sha256, format_version=1)`: `access` is
234
- `None` when the file has no `[access]`; `expiry` is `ExpiryPolicy()` (disabled)
235
- when it has no `[expiry]`; `sha256` is the file's, which each run logs.
235
+ `None` when the file has no `[access]`, which `ReleaseJob` refuses; `expiry` is
236
+ `ExpiryPolicy()` (disabled) when it has no `[expiry]`; `sha256` is the file's,
237
+ which each run logs.
236
238
  - `RetentionPolicy(backup, gc_window, tombstone_window, inventory_window,
237
239
  retire_after, pod_staleness, keep_roots=DEFAULT_KEEP_ROOTS (10),
238
240
  upload_grace=DEFAULT_UPLOAD_GRACE (24 h))`, `durations()`, and `check()`,
@@ -307,6 +309,116 @@ The GC job (Phase 5 D10), imported from `statcatlib.gc`; nothing is re-exported
307
309
  alerts (`event`, `severity`, `at`, `catalog`, and `product`, `edition`, `reason`,
308
310
  `detail` when they apply).
309
311
 
312
+ ### `release.py`
313
+
314
+ The release job (Phase 5 Part 3), imported from `statcatlib.release`; nothing is
315
+ re-exported from `statcatlib`. `statcat release` runs it.
316
+
317
+ - `ReleaseJob(location, config, *, rest, listing, storage=None)`: *config* is a
318
+ `JobsConfig` whose `access` is set and names the container of `location.uri`
319
+ (`az://<container>/...`) and, when the store's config names `account_name`, that
320
+ account (`JobsConfigError` otherwise); *rest* has `head(path) -> RemoteFile | None`
321
+ (`AzureBlobRest`); *listing* is an `access.ReaderListing`; *storage* defaults to
322
+ `BlobInventoryStorage(location)`. It holds one `InventoryWriter` over the storage.
323
+ - `run(*, dry_run=False) -> ReleaseReport`: step 0 reads `catalog.json`
324
+ (`FileNotFoundError` when absent), refuses an opted-in catalog with
325
+ `InventoryWriteRefusedError` naming Part 6, and reads `storage.now()`. Then, each
326
+ through `_step`, which catches `_STEP_ERRORS` (`BlobStoreError`, `ManifestError`,
327
+ `InventoryError`, `InventoryConflictError`, `InventoryContentionError`,
328
+ `InventoryRuleError`, `IncompatibleCatalogError`, `OSError`), logs `step.failed`
329
+ and goes on: `_advance` (`InventoryWriter.advance_current`, `current.behind`; left
330
+ out of a dry run); `_access`, outside `_step` (`listing.readers()` and
331
+ `access.check_access`; an `AccessListingError` becomes a failed `AccessResult`;
332
+ any other error stops the run); `_fold`; `_release`; `_retire`. Each of the last
333
+ three reads `storage.now()` once and commits through `InventoryWriter.commit` with
334
+ a *change* that plans on the head it is given and returns `None` when nothing
335
+ changes; a dry run calls the plan on `writer.full()` and commits nothing.
336
+ - The fold (`_plan_fold`, kind `seal`, reason `fold of pending/ registrations`):
337
+ `full.leaves()`, one LIST of `pending/` a run, then for each
338
+ `pending/<p>/<e>.json` the head neither records in a leaf nor tombstones, in path
339
+ order, `_judge`: `_read` (kept by path for the run: the document through
340
+ `jobs.decode_registration`, `too-old` against `tombstone_window`, the manifest
341
+ through `decode_manifest` and `blob._refuse_unsafe_files`, `canonical-form`,
342
+ `files` through `verify_remote` over `upload._ALL_TIERS` and `upload._lookup`),
343
+ then `fold_lifecycle`, `_kind`, `chain` and `_refuse_changed_final`. A recorded
344
+ registration is skipped by its path and never read; an object of another shape
345
+ under `pending/` is ignored. A `BlobStoreError` is the registration's `store`
346
+ refusal. `_check_structures` runs `_reference_check` for each candidate with no
347
+ binding (`pull_edition` into the run's temporary directory, each home through
348
+ `structure_entries`, `check_edition(at="receive", remote=, lookup=, structures=,
349
+ extensions={"statcat": ...})`), the fold's structure editions together until a
350
+ pass refuses none, and keeps each answer by edition and homes given.
351
+ `_refuse_forks` refuses passing registrations of one product, `previous` and
352
+ schema (`pending-fork`). `_record_stable` records per product in id order
353
+ (`_record`: `fold_lifecycle` on the evolving leaf, sdmxlib's `record(embargo=True)`
354
+ with `recorded_routing(routing_fields(manifest))`, `supersede_pending` for each
355
+ embargoed entry with its `previous` and schema), runs `drop_until_stable`, refuses
356
+ every registration of a dropped product, and refuses with `references` a product
357
+ whose check was given a fold edition that is refused, until a pass refuses none.
358
+ `_release_at` records `None` for a `release_at` before the registration's
359
+ last-modified time (`fold.release_at_past`) and, on a restore epoch above 0, for a
360
+ registration last modified before the epoch's first root (`fold.restored`), which
361
+ `_find_epoch` takes from the head's record or finds by reading roots back from the
362
+ head while they carry the epoch.
363
+ - The release step (`_plan_release`, kind `release`, reason `release_due at <time>`):
364
+ `full.leaves()`, then, when the head's `embargo` is off and the access result
365
+ passed, sdmxlib's `release_due` and `drop_until_stable`; the record from
366
+ `refusal_record`; a `Change` when a leaf or the record differs. It logs
367
+ `release.refused`, `release.held` (embargo on), `release.unprepared` (released and
368
+ folded by this run) and `release.overdue` (every entry still embargoed with a
369
+ `release_at` at or before the step's time). A failed fold makes the record keep
370
+ the head's fold refusals.
371
+ - Retirement (`_retire`, kind `retire`): `inventory.retire_change(full, now -
372
+ retire_after, now=, artefacts=writer.artefacts)`.
373
+ - `ReleaseReport(folded, released, retired, refused, skipped, failed, versions,
374
+ access, dry_run)` and `ReleaseRefusal(product_id, edition_id, step, rule, reason,
375
+ since)`, frozen; `Step` is `fold` or `release`.
376
+ - `refusal_record(full, refusals, now, *, epoch=(0, None))`: the next root's
377
+ `extensions`, with `statcat.release` set to `{refused, omitted, epoch_started_at,
378
+ restore_epoch}` and `format_version` to `bundle.ROOT_SECTION_FORMAT`; `since`
379
+ carried from the head's record by `(product_id, edition_id, step)`; at most
380
+ `MAX_REFUSALS` (500) kept, the oldest `since` first; the head's sections unchanged
381
+ when there is no refusal, no epoch time and no record. `current_record(full)` is
382
+ the head's `extensions`, and `current_refusals(full)` the refusals its record
383
+ holds, leaving out an entry it cannot read. `RELEASE_KEY` is `"release"`.
384
+ - `drop_until_stable(writer, full, leaves) -> (kept, dropped)`: reads each incoming
385
+ entry's manifest through `writer.artefacts` (`ManifestError` drops the product with
386
+ `manifest`, `BlobStoreError` with `store`), then `writer.refusals`, dropping each
387
+ refused product's whole change until a pass refuses none. `Dropped(edition_id,
388
+ rule, reason)`. A manifest of another product's unfilled entry that cannot be
389
+ read propagates.
390
+ - `structure_entries(full, *, product_id, batch) -> list[StructureEntry]`:
391
+ `working_copy.structure_editions`'s choice as `StructureEntry(product_id,
392
+ edition_id, manifest_sha256)` values.
393
+ - Events go through `jobs._emit` on the `statcatlib.release` logger: `event`,
394
+ `severity`, `at` (the step's storage time), `catalog`, and `product`, `edition`,
395
+ `rule`, `reason`, `detail` where they apply.
396
+
397
+ ### `access.py`
398
+
399
+ The release job's access precondition (Phase 5 D8), imported from
400
+ `statcatlib.access`. `httpx`, the standard library and `attrs` only.
401
+
402
+ - `ReaderListing` (a `Protocol`): `readers() -> ReaderReport`, raising
403
+ `AccessListingError` (`RuntimeError`) when the listing cannot be made.
404
+ - `ReaderReport(assignment_ids, grants)` and `ReaderGrant(assignment_id,
405
+ principal_id, principal_type, role, scope)`, frozen.
406
+ - `check_access(report, allowlist, sentinels) -> AccessResult(passed, outsiders,
407
+ unused, missing_sentinels, errors)`: passed when every grant's principal is on
408
+ the allowlist and every sentinel id is listed; ids compare without regard to case.
409
+ - `AzureReaderListing(storage_account, container, *, arm_token, scopes=())`:
410
+ `readers()` sends, with the bearer token *arm_token* returns for `ARM_SCOPE`,
411
+ `GET https://management.azure.com{scope}/providers/Microsoft.Authorization/roleAssignments?$filter=atScope()&api-version=2022-04-01`
412
+ at `{storage_account}/blobServices/default/containers/{container}` and at each of
413
+ *scopes*, following each `nextLink` on that host and refusing one on another;
414
+ then one `GET {storage_account}/providers/Microsoft.Authorization/roleDefinitions/{guid}`
415
+ per distinct role. A 429 or 5xx is tried again after each of `_BACKOFF` (1, 2 and
416
+ 4 s); any other failure raises `AccessListingError`. A resource id that does not
417
+ start with `/`, or a container that is empty or holds `/`, raises `ValueError`.
418
+ - `reads_blobs(permissions) -> bool`: whether one permission block's `dataActions`
419
+ match `BLOB_READ` and none of its `notDataActions` does, `*` matching any run of
420
+ characters, case ignored.
421
+
310
422
  ### `checks.py`
311
423
 
312
424
  - `SealCheck`: the type `Callable[[EditionView], Iterable[Finding]]`, passed
@@ -369,8 +481,9 @@ The module keeps its 0.11 name and describes editions. Imported from
369
481
  refuses a store other than `AzureStore` (`TypeError`, naming W3) and a store with
370
482
  no `TokenIdentity` (`ValueError`), then runs `_upload` with an `AzureBlobRest`.
371
483
  - `_upload(edition_dir, location, *, release_at=None, rest)`: the steps, over any
372
- store and any digest client with `head(path) -> RemoteFile | None`. Tests and
373
- `record_uploaded` call it. A naive `release_at` raises `ValueError`.
484
+ store and any digest client with `head(path) -> RemoteFile | None`. Tests call it,
485
+ through `tests/stores/_stores.py`'s `upload` and `record_uploaded`. A naive
486
+ `release_at` raises `ValueError`.
374
487
  - `UploadReport(edition_id, product_id, sent, kept, registered)`: `sent` and `kept`
375
488
  hold store paths, and `registered` is advisory, since a retried create can report
376
489
  false against its own earlier success.
@@ -533,8 +646,9 @@ read.
533
646
  - A library call never takes a credential from the environment: `Catalog.open` over a
534
647
  store takes a `BlobLocation` the caller built. `catalog.py` and `store/blob.py` read
535
648
  no environment variable.
536
- - Only `CatalogAdmin` changes the Inventory, apart from a seal's record and
537
- the journal repair. It runs over a catalog directory or a `BlobLocation`,
649
+ - Only `CatalogAdmin` and, on a catalog in object storage, the release job
650
+ (`release.ReleaseJob`) change the Inventory, apart from a seal's record and
651
+ the journal repair. `CatalogAdmin` runs over a catalog directory or a `BlobLocation`,
538
652
  `prune` on a directory only. No attribute of `Catalog`, `Product` or `EditionView`
539
653
  returns a `CatalogAdmin`, an `EmbargoReader` (spec §6.4) or an embargoed,
540
654
  superseded or withdrawn edition. `Catalog`'s reads (`products`, `Product.view`,
@@ -565,6 +679,18 @@ read.
565
679
  none. A refusal in one product never stops another's. A crash during the
566
680
  batch leaves each sealed, unrecorded edition's journal entry, so its
567
681
  product's next `working_copy()` records it, in a version of its own.
682
+ - `ReleaseJob.run` writes nothing under `products/` or `pending/` and deletes
683
+ nothing in the store. It releases no edition while the access result failed or the
684
+ head's `embargo` is on, and each retry of the release commit checks both again on
685
+ the new head. Every entry the fold records is `embargoed`, carries its manifest's
686
+ SHA-256 as read and its artefact records, and passed every check of its own; no
687
+ entry goes live whose manifest differs from the one its entry records. A refusal
688
+ in one registration or product never stops another's, and a failure of one step
689
+ never stops the next. A run against an unchanged catalog writes nothing. A dry
690
+ run writes nothing, `CURRENT` included.
691
+ - `check_access` fails closed: a listing error, a missing sentinel and a principal
692
+ off the allowlist each fail it, and `AzureReaderListing` sends its token to
693
+ `management.azure.com` alone.
568
694
  - `collect_garbage` deletes nothing when it cannot read the Inventory back through
569
695
  the window (`GcStoppedError`), nothing under the `data/` of a product whose
570
696
  manifest or registration it cannot read or verify, no file a kept or registered
@@ -604,9 +730,17 @@ read.
604
730
  - `diagnostics.verify_catalog` opens each edition in a DuckDB engine of
605
731
  `check_edition`'s own; neither it nor `diagnostics.doctor` writes a file.
606
732
  - These modules make no network calls, except `upload.py`, `admin.py`, `gc.py`,
607
- `catalog.py` and `embargo.py`, which reach a store only through the `BlobLocation`
608
- they are given. `jobs.py` reads
733
+ `release.py`, `catalog.py` and `embargo.py`, which reach a store only through the
734
+ `BlobLocation` they are given, and `access.py`, whose `AzureReaderListing` sends
735
+ GET requests to Azure Resource Manager. `jobs.py` reads
609
736
  the one file it is given.
737
+ - `ReleaseJob.run` writes at most three Inventory versions (kinds `seal`, `release`
738
+ and `retire`) and `CURRENT`, creates and deletes one temporary directory holding
739
+ the pulled files of the editions the fold's reference check reads, and logs each
740
+ event on the `statcatlib.release` logger at WARNING, or ERROR for `step.failed`,
741
+ `access.mismatch` and `access.unchecked`. It sends one anonymous `HEAD` for the
742
+ storage clock at its start and at each of its three committing steps, unless its
743
+ storage has a `clock=`.
610
744
  - `gc.collect_garbage` writes nothing. It deletes under `products/` and `pending/`
611
745
  only, unless `dry_run`, and logs each event on the `statcatlib.gc` logger at INFO
612
746
  (`gc.planned`, `gc.deleted`), WARNING (`gc.failed_closed`, `gc.unregistered`,
@@ -619,6 +753,13 @@ read.
619
753
  - `EmbargoReader`: `tests/test_embargo_reader.py` (a local catalog), the
620
754
  `TestNoOperationalReach` and blob-extra tests in `tests/test_public_api.py`, and,
621
755
  against Azurite, `tests/stores/test_embargo_reader.py`.
756
+ - The release job: `tests/test_release_fold.py`, `tests/test_release_step.py` and
757
+ `tests/test_release_job.py` (every step over a `MemoryStore`, with the catalog of
758
+ `tests/_release.py` and the fakes of `tests/_fakes.py`),
759
+ `tests/test_access_listing.py` (`AzureReaderListing` through `pytest-httpx`, and
760
+ `check_access`), `tests/test_cli_release.py` and, against Azurite,
761
+ `tests/stores/test_release_job.py` (the 200-table release, the reference check, a
762
+ crash, a fork and its discard, a restore).
622
763
  - GC: `tests/test_gc.py` (every rule over a `MemoryStore`, with the helpers in
623
764
  `tests/_gc.py`), `tests/test_cli_gc.py` and, against Azurite,
624
765
  `tests/stores/test_gc.py`; `decode_registration` in `tests/test_jobs_config.py`.