statcatlib 0.9.1__tar.gz → 0.10.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 (36) hide show
  1. {statcatlib-0.9.1 → statcatlib-0.10.0}/LICENSE +78 -56
  2. {statcatlib-0.9.1 → statcatlib-0.10.0}/PKG-INFO +5 -3
  3. {statcatlib-0.9.1 → statcatlib-0.10.0}/README.md +3 -1
  4. {statcatlib-0.9.1 → statcatlib-0.10.0}/pyproject.toml +2 -2
  5. {statcatlib-0.9.1 → statcatlib-0.10.0}/pyproject.toml.orig +4 -3
  6. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/__init__.py +2 -0
  7. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/bundle.py +322 -1
  8. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/cli/__init__.py +33 -0
  9. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/sources/sdmx.py +5 -1
  10. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/store/__init__.py +1 -0
  11. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/store/lance.py +67 -1
  12. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/store/store.py +116 -1
  13. statcatlib-0.10.0/src/statcatlib/testing.py +72 -0
  14. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/expr.py +0 -0
  15. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/model/__init__.py +0 -0
  16. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/model/agents.py +0 -0
  17. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/model/catalog.py +0 -0
  18. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/model/classification.py +0 -0
  19. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/model/dataset.py +0 -0
  20. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/model/distribution.py +0 -0
  21. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/model/licence.py +0 -0
  22. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/model/quality.py +0 -0
  23. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/model/skos.py +0 -0
  24. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/model/spatial.py +0 -0
  25. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/model/temporal.py +0 -0
  26. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/model/types.py +0 -0
  27. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/py.typed +0 -0
  28. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/query.py +0 -0
  29. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/sources/__init__.py +0 -0
  30. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/sources/ckan.py +0 -0
  31. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/store/row.py +0 -0
  32. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/store/schema.py +0 -0
  33. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/store/scopes.py +0 -0
  34. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/store/sentinel.py +0 -0
  35. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/store/xhtml.py +0 -0
  36. {statcatlib-0.9.1 → statcatlib-0.10.0}/src/statcatlib/urn.py +0 -0
@@ -1,3 +1,4 @@
1
+
1
2
  Apache License
2
3
  Version 2.0, January 2004
3
4
  http://www.apache.org/licenses/
@@ -32,9 +33,10 @@
32
33
  not limited to compiled object code, generated documentation,
33
34
  and conversions to other media types.
34
35
 
35
- "Work" shall mean the work of authorship made available under
36
- the License, as indicated by a copyright notice that is included in
37
- or attached to the work (an example is provided in the Appendix below).
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
38
40
 
39
41
  "Derivative Works" shall mean any work, whether in Source or Object
40
42
  form, that is based on (or derived from) the Work and for which the
@@ -44,21 +46,23 @@
44
46
  separable from, or merely link (or bind by name) to the interfaces of,
45
47
  the Work and Derivative Works thereof.
46
48
 
47
- "Contribution" shall mean, as submitted to the Licensor for inclusion
48
- in the Work by the copyright owner or by an individual or Legal Entity
49
- authorized to submit on behalf of the copyright owner. For the purposes
50
- of this definition, "submitted" means any form of electronic, verbal,
51
- or written communication sent to the Licensor or its representatives,
52
- including but not limited to communication on electronic mailing lists,
53
- source code control systems, and issue tracking systems that are managed
54
- by, or on behalf of, the Licensor for the purpose of discussing and
55
- improving the Work, but excluding communication that is conspicuously
56
- marked or designated in writing by the copyright owner as "Not a
57
- Contribution."
58
-
59
- "Contributor" shall mean Licensor and any Legal Entity on behalf of
60
- whom a Contribution has been received by the Licensor and included
61
- within the Work.
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
62
66
 
63
67
  2. Grant of Copyright License. Subject to the terms and conditions of
64
68
  this License, each Contributor hereby grants to You a perpetual,
@@ -74,22 +78,22 @@
74
78
  use, offer to sell, sell, import, and otherwise transfer the Work,
75
79
  where such license applies only to those patent claims licensable
76
80
  by such Contributor that are necessarily infringed by their
77
- Contribution(s) alone or by the combination of their Contribution(s)
81
+ Contribution(s) alone or by combination of their Contribution(s)
78
82
  with the Work to which such Contribution(s) was submitted. If You
79
- institute patent litigation against any entity (including a cross-claim
80
- or counterclaim in a lawsuit) alleging that the Work or any other
81
- Contribution embodied in the Work constitutes direct or contributory
82
- patent infringement, then any patent licenses granted to You under
83
- this License for that Work shall terminate as of the date such
84
- litigation is filed.
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
85
89
 
86
90
  4. Redistribution. You may reproduce and distribute copies of the
87
91
  Work or Derivative Works thereof in any medium, with or without
88
92
  modifications, and in Source or Object form, provided that You
89
93
  meet the following conditions:
90
94
 
91
- (a) You must give any other recipients of the Work or Derivative
92
- Works a copy of this License; and
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
93
97
 
94
98
  (b) You must cause any modified files to carry prominent notices
95
99
  stating that You changed the files; and
@@ -101,26 +105,28 @@
101
105
  the Derivative Works; and
102
106
 
103
107
  (d) If the Work includes a "NOTICE" text file as part of its
104
- distribution, You must include a readable copy of the
105
- attribution notices contained within such NOTICE file, in
106
- at least one of the following places: within a NOTICE text
107
- file distributed as part of the Derivative Works; within
108
- the Source form or documentation, if provided along with the
109
- Derivative Works; or, within a display generated by the
110
- Derivative Works, if and wherever such third-party notices
111
- normally appear. The contents of the NOTICE file are for
112
- informational purposes only and do not modify the License.
113
- You may add Your own attribution notices within Derivative
114
- Works that You distribute, alongside or in addition to the
115
- NOTICE text from the Work, provided that such additional
116
- attribution notices cannot be construed as modifying the
117
- License.
118
-
119
- You may add Your own license statement for Your modifications and
120
- may provide additional grant of rights to use, copy, modify, merge,
121
- publish, distribute, sublicense, and/or sell copies of the
122
- Contribution, either on its own behalf or on behalf of the Licensor,
123
- and only if You accept these terms.
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
124
130
 
125
131
  5. Submission of Contributions. Unless You explicitly state otherwise,
126
132
  any Contribution intentionally submitted for inclusion in the Work
@@ -139,9 +145,9 @@
139
145
  agreed to in writing, Licensor provides the Work (and each
140
146
  Contributor provides its Contributions) on an "AS IS" BASIS,
141
147
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
142
- implied, including, without limitation, any conditions of TITLE,
143
- NONINFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR
144
- PURPOSE. You are solely responsible for determining the
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
145
151
  appropriateness of using or redistributing the Work and assume any
146
152
  risks associated with Your exercise of permissions under this License.
147
153
 
@@ -150,21 +156,37 @@
150
156
  unless required by applicable law (such as deliberate and grossly
151
157
  negligent acts) or agreed to in writing, shall any Contributor be
152
158
  liable to You for damages, including any direct, indirect, special,
153
- incidental, or exemplary damages of any character arising as a
159
+ incidental, or consequential damages of any character arising as a
154
160
  result of this License or out of the use or inability to use the
155
- Work (even if such Contributor has been advised of the possibility
156
- of such damages).
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
157
165
 
158
166
  9. Accepting Warranty or Additional Liability. While redistributing
159
167
  the Work or Derivative Works thereof, You may choose to offer,
160
168
  and charge a fee for, acceptance of support, warranty, indemnity,
161
169
  or other liability obligations and/or rights consistent with this
162
- License. However, in accepting such obligations, You may offer only
163
- conditions consistent with this License, and charge a fee for,
164
- such acceptance.
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
165
176
 
166
177
  END OF TERMS AND CONDITIONS
167
178
 
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
168
190
  Copyright 2026 gabrielgellner
169
191
 
170
192
  Licensed under the Apache License, Version 2.0 (the "License");
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statcatlib
3
- Version: 0.9.1
3
+ Version: 0.10.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.70.1
21
+ Requires-Dist: sdmxlib>=0.72.0
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'
@@ -39,7 +39,9 @@ Description-Content-Type: text/markdown
39
39
  > take that into account before adopting it.
40
40
 
41
41
  **statcatlib** is a Python library for building, managing, and querying
42
- statistical metadata catalogs. Formerly published as `statcatlib`.
42
+ statistical metadata catalogs. Formerly published as `pinaxlib`.
43
+
44
+ **Documentation:** <https://statcat-suite.github.io/statcatlib/>
43
45
 
44
46
  Inspired by DCAT and the broader landscape of metadata standards (SDMX, DDI),
45
47
  statcatlib provides a general-purpose engine for catalog storage, discovery, and
@@ -5,7 +5,9 @@
5
5
  > take that into account before adopting it.
6
6
 
7
7
  **statcatlib** is a Python library for building, managing, and querying
8
- statistical metadata catalogs. Formerly published as `statcatlib`.
8
+ statistical metadata catalogs. Formerly published as `pinaxlib`.
9
+
10
+ **Documentation:** <https://statcat-suite.github.io/statcatlib/>
9
11
 
10
12
  Inspired by DCAT and the broader landscape of metadata standards (SDMX, DDI),
11
13
  statcatlib provides a general-purpose engine for catalog storage, discovery, and
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "statcatlib"
3
- version = "0.9.1"
3
+ version = "0.10.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.70.1",
32
+ "sdmxlib>=0.72.0",
33
33
  ]
34
34
 
35
35
  [[project.authors]]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "statcatlib"
3
- version = "0.9.1"
3
+ version = "0.10.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"]
@@ -30,8 +30,9 @@ dependencies = [
30
30
  # >=0.70: needs Python 3.14 and DuckDB 2.0; `reg.data()` is gone, and a
31
31
  # component without a concept is refused as it is built. >=0.70.1:
32
32
  # `reg.obs()` asks Eurostat for CSV it serves, so `ingest_data()` can
33
- # fetch from it (sdmxlib GH-50).
34
- "sdmxlib>=0.70.1",
33
+ # fetch from it (sdmxlib GH-50). >=0.72: `sdmxlib.bundle`, the manifest
34
+ # and checker `publish_bundle` and `check_bundle` build on (GH-3).
35
+ "sdmxlib>=0.72.0",
35
36
  ]
36
37
 
37
38
  [project.optional-dependencies]
@@ -81,6 +81,7 @@ from statcatlib.store import (
81
81
  DimensionsScope,
82
82
  EmbeddingMetadata,
83
83
  Hit,
84
+ SEARCH_ANNOTATION_TYPES,
84
85
  SEARCH_CONTEXT_ANNOTATION_TYPE,
85
86
  SEARCH_EXCLUDE_ANNOTATION_TYPE,
86
87
  IncompatibleCatalogError,
@@ -98,6 +99,7 @@ from statcatlib.store import (
98
99
  from statcatlib.store.sentinel import NotLoadedError, is_unloaded
99
100
 
100
101
  __all__ = [
102
+ "SEARCH_ANNOTATION_TYPES",
101
103
  "SEARCH_CONTEXT_ANNOTATION_TYPE",
102
104
  "SEARCH_EXCLUDE_ANNOTATION_TYPE",
103
105
  "VALID_INCLUDE_RELS",
@@ -33,25 +33,50 @@ assumed (#120):
33
33
  stamp resolves rather than leaving it to DuckDB to discover.
34
34
 
35
35
  See #94, #98 and #120 for the design history.
36
+
37
+ A bundle is published by writing sdmxlib's ``bundle.manifest.json`` last.
38
+ `publish_bundle` writes it with a ``statcat`` section describing the catalog
39
+ and its search index, and `check_bundle` runs sdmxlib's checks and then
40
+ `check_catalog` against that section (GH-3).
36
41
  """
37
42
 
38
43
  from __future__ import annotations
39
44
 
40
45
  import errno
46
+ import json
41
47
  import shutil
48
+ from collections.abc import Collection, Mapping
42
49
  from contextlib import contextmanager
50
+ from importlib import metadata
43
51
  from pathlib import Path
44
52
  from typing import TYPE_CHECKING, Literal
45
53
 
46
54
  import duckdb
47
55
  from attrs import field, frozen
56
+ from sdmxlib.bundle import (
57
+ MANIFEST_NAME,
58
+ Finding,
59
+ Manifest,
60
+ Overlay,
61
+ OverlayEntry,
62
+ Producer,
63
+ Provenance,
64
+ Report,
65
+ write_manifest,
66
+ )
67
+ from sdmxlib.bundle import check_bundle as sdmx_check_bundle
48
68
 
49
69
  if TYPE_CHECKING:
50
- from collections.abc import Generator
70
+ from collections.abc import Generator, Iterator
71
+
72
+ from sdmxlib import LocalRegistry
51
73
 
52
74
  from statcatlib.store import Catalog
75
+ from statcatlib.store.lance import LanceIndex, LanguageLayout
53
76
 
54
77
  __all__ = [
78
+ "STATCAT_SECTION",
79
+ "STATCAT_SECTION_FORMAT",
55
80
  "At",
56
81
  "Auto",
57
82
  "BundleDataPathError",
@@ -59,9 +84,14 @@ __all__ = [
59
84
  "Colocated",
60
85
  "DataPolicy",
61
86
  "Embedded",
87
+ "catalog_section",
88
+ "check_bundle",
89
+ "check_catalog",
62
90
  "inspect_bundle",
63
91
  "normalize_lake_data_path",
64
92
  "open_bundle",
93
+ "overlay_affects_search",
94
+ "publish_bundle",
65
95
  "read_current_data_path",
66
96
  ]
67
97
 
@@ -563,3 +593,294 @@ def open_bundle(
563
593
  )
564
594
  with catalog.open() as opened:
565
595
  yield opened
596
+
597
+
598
+ # -- the published bundle: statcatlib's part of sdmxlib's contract -------
599
+ #
600
+ # sdmxlib owns ``bundle.manifest.json`` (sdmxlib GH-56): the token caches key
601
+ # on, a sha256 per file, and the checker. A statcat bundle is an sdmxlib
602
+ # bundle plus the ``statcat`` schema and ``search/``, so statcatlib adds what
603
+ # only it can describe: a ``statcat`` section in the manifest, and checks
604
+ # that the catalog and its search index are what that section says (GH-3).
605
+
606
+ #: The manifest's extension section statcatlib owns.
607
+ STATCAT_SECTION = "statcat"
608
+
609
+ #: Version of the ``statcat`` section. Bumped when its fields change meaning.
610
+ #: 2 added ``search.annotations``, the annotations the index was built from.
611
+ STATCAT_SECTION_FORMAT = 2
612
+
613
+ _SEARCH_DIRNAME = "search"
614
+ _INDEX_MANIFEST = "index.json"
615
+
616
+
617
+ def _has_index(search_dir: Path) -> bool:
618
+ """Whether ``search/`` holds anything. Opening a folder catalog creates it empty."""
619
+ return search_dir.is_dir() and any(search_dir.iterdir())
620
+
621
+
622
+ def _search_section(cat: Catalog, search_dir: Path) -> dict[str, object] | None:
623
+ """The ``search`` part of the section.
624
+
625
+ Args:
626
+ cat: The bundle's catalog, open.
627
+ search_dir: Its ``search/`` directory.
628
+
629
+ Returns:
630
+ The index format version, embedding model and per-language layout,
631
+ or ``None`` when no index is built.
632
+
633
+ Raises:
634
+ ValueError: ``search/`` holds an index from before per-language
635
+ tables, which has no layout to record.
636
+ """
637
+ if not _has_index(search_dir):
638
+ return None
639
+ layout = cat.index_layout
640
+ if not layout:
641
+ msg = f"{search_dir} holds a search index with no {_INDEX_MANIFEST}: rebuild it with build_index() first"
642
+ raise ValueError(msg)
643
+ index_manifest = json.loads((search_dir / _INDEX_MANIFEST).read_text())
644
+ return {
645
+ "index_version": index_manifest.get("version"),
646
+ "embedding_model": cat.embedding_model_name,
647
+ "languages": {lang: {"rows": entry["rows"], "stemmer": entry["stemmer"]} for lang, entry in layout.items()},
648
+ "annotations": cat.index_annotations,
649
+ }
650
+
651
+
652
+ def catalog_section(path: str | Path) -> dict[str, object]:
653
+ """Describe the catalog in a bundle, as its manifest's ``statcat`` section.
654
+
655
+ Opens the bundle read-only. The section records the number of catalog
656
+ datasets and, when a search index is built, its index format version,
657
+ embedding model, and each language's row count and stemmer.
658
+
659
+ Args:
660
+ path: The bundle directory.
661
+
662
+ Returns:
663
+ The section, ready for ``write_manifest(extensions=...)``.
664
+ """
665
+ from statcatlib.store import Catalog # noqa: PLC0415
666
+
667
+ root = Path(path)
668
+ with Catalog(root, read_only=True, data_path=Embedded()) as cat:
669
+ row = cat.connection.execute("""SELECT count(*) FROM statcat.dataset""").fetchone()
670
+ return {
671
+ "format": STATCAT_SECTION_FORMAT,
672
+ "statcatlib_version": metadata.version("statcatlib"),
673
+ "datasets": row[0] if row else 0,
674
+ "search": _search_section(cat, root / _SEARCH_DIRNAME),
675
+ }
676
+
677
+
678
+ def publish_bundle(
679
+ path: str | Path,
680
+ *,
681
+ bundle_id: str,
682
+ producer: Producer,
683
+ product: dict[str, object] | None = None,
684
+ extensions: dict[str, dict[str, object]] | None = None,
685
+ provenance: Provenance | None = None,
686
+ ) -> Manifest:
687
+ """Publish a statcat bundle: sdmxlib's manifest, with the ``statcat`` section.
688
+
689
+ `sdmxlib.bundle.write_manifest` with `catalog_section` added to its
690
+ extensions. Call it **last**, after `Catalog.build_index`, once nothing
691
+ in the directory will change: the manifest lists every file with its
692
+ sha256, so a file written afterwards fails `check_bundle`. Publishing
693
+ again mints a new token, which is what tells caches to refresh.
694
+
695
+ Args:
696
+ path: The bundle directory.
697
+ bundle_id: The producer's stable name for the bundle, kept across rebuilds.
698
+ producer: What built it.
699
+ product: Producer-specific fields, carried and not interpreted.
700
+ extensions: Other packages' sections; ``statcat`` is filled in here.
701
+ provenance: Where a fetched bundle came from.
702
+
703
+ Returns:
704
+ The manifest written.
705
+
706
+ Raises:
707
+ ValueError: *extensions* already has a ``statcat`` section, or the
708
+ search index predates per-language tables and must be rebuilt.
709
+ """
710
+ if extensions and STATCAT_SECTION in extensions:
711
+ msg = f"the {STATCAT_SECTION!r} section is written by publish_bundle, not passed to it"
712
+ raise ValueError(msg)
713
+ section = catalog_section(path)
714
+ return write_manifest(
715
+ path,
716
+ bundle_id=bundle_id,
717
+ producer=producer,
718
+ product=product,
719
+ extensions={**(extensions or {}), STATCAT_SECTION: section},
720
+ provenance=provenance,
721
+ )
722
+
723
+
724
+ def _check_catalog_rows(conn: duckdb.DuckDBPyConnection, section: Mapping[str, object]) -> Iterator[Finding]:
725
+ """The catalog's datasets: counted as declared, each linked flow stored."""
726
+ row = conn.execute("""SELECT count(*) FROM statcat.dataset""").fetchone()
727
+ found = row[0] if row else 0
728
+ if found != section.get("datasets"):
729
+ detail = f"{found} datasets, the manifest says {section.get('datasets')}"
730
+ yield Finding(STATCAT_SECTION, "statcat.dataset", detail)
731
+ dangling = conn.execute(
732
+ """
733
+ SELECT d.identifier, d.sdmx_dataflow_urn
734
+ FROM statcat.dataset d
735
+ WHERE d.sdmx_dataflow_urn IS NOT NULL
736
+ AND NOT EXISTS (SELECT 1 FROM sdmx.dataflow f WHERE f.urn = d.sdmx_dataflow_urn)
737
+ ORDER BY d.identifier
738
+ """
739
+ ).fetchall()
740
+ for identifier, urn in dangling:
741
+ yield Finding(STATCAT_SECTION, identifier, f"links {urn}, which the registry does not hold")
742
+
743
+
744
+ def _check_index_annotations(conn: duckdb.DuckDBPyConnection, declared: object) -> Iterator[Finding]:
745
+ """The store holds the annotations the index was built from: no overlay folded in since."""
746
+ from statcatlib.store.store import annotation_fingerprint # noqa: PLC0415
747
+
748
+ subject = f"{_SEARCH_DIRNAME} annotations"
749
+ if not isinstance(declared, Mapping) or not isinstance(types := declared.get("types"), list):
750
+ yield Finding(
751
+ STATCAT_SECTION, subject, "the index records no annotation fingerprint: rebuild with build_index()"
752
+ )
753
+ return
754
+ if annotation_fingerprint(conn, [str(t) for t in types]) != declared.get("fingerprint"):
755
+ detail = (
756
+ f"annotations of type {', '.join(map(str, types))} differ from those the index was built from "
757
+ "(an overlay folded in?): rebuild with build_index()"
758
+ )
759
+ yield Finding(STATCAT_SECTION, subject, detail)
760
+
761
+
762
+ def _check_search(root: Path, declared: object) -> Iterator[Finding]:
763
+ """The search index: present exactly when declared, with the declared layout."""
764
+ search_dir = root / _SEARCH_DIRNAME
765
+ if declared is None:
766
+ if _has_index(search_dir):
767
+ yield Finding(STATCAT_SECTION, _SEARCH_DIRNAME, "a search index the manifest does not declare")
768
+ return
769
+ if not isinstance(declared, Mapping) or not isinstance(languages := declared.get("languages"), Mapping):
770
+ yield Finding(STATCAT_SECTION, MANIFEST_NAME, "the search section has no languages")
771
+ return
772
+ if not (search_dir / _INDEX_MANIFEST).is_file():
773
+ yield Finding(STATCAT_SECTION, f"{_SEARCH_DIRNAME}/{_INDEX_MANIFEST}", "the manifest declares a search index")
774
+ return
775
+ try:
776
+ from statcatlib.store.lance import LanceIndex # noqa: PLC0415
777
+ except ImportError:
778
+ yield Finding(STATCAT_SECTION, _SEARCH_DIRNAME, "cannot check the search index without statcatlib[search]")
779
+ return
780
+ index = LanceIndex(search_dir)
781
+ layout = index.describe()
782
+ for lang in sorted(set(languages) | set(layout)):
783
+ yield from _check_language(index, lang, layout.get(lang), languages.get(lang))
784
+
785
+
786
+ def _check_language(index: LanceIndex, lang: str, found: LanguageLayout | None, want: object) -> Iterator[Finding]:
787
+ """One language's table: declared, stemmed as declared, holding the declared rows."""
788
+ subject = f"{_SEARCH_DIRNAME} [{lang}]"
789
+ if found is None:
790
+ yield Finding(STATCAT_SECTION, subject, "declared in the manifest, absent from the index")
791
+ return
792
+ if not isinstance(want, Mapping):
793
+ yield Finding(STATCAT_SECTION, subject, "in the index, not declared in the manifest")
794
+ return
795
+ if found["stemmer"] != want.get("stemmer"):
796
+ yield Finding(
797
+ STATCAT_SECTION, subject, f"stemmed with {found['stemmer']}, the manifest says {want.get('stemmer')}"
798
+ )
799
+ table = index.table_for(lang)
800
+ rows = table.count_rows() if table is not None else 0
801
+ if rows != want.get("rows"):
802
+ yield Finding(STATCAT_SECTION, subject, f"{rows} rows, the manifest says {want.get('rows')}")
803
+
804
+
805
+ def check_catalog(root: Path, manifest: Manifest, reg: LocalRegistry) -> list[Finding]:
806
+ """The ``statcat`` check `check_bundle` adds to sdmxlib's.
807
+
808
+ - the ``statcat`` schema is in ``meta.duckdb``, and the manifest has a
809
+ ``statcat`` section in a format this build reads;
810
+ - the catalog holds the datasets the section counts, and every dataset
811
+ linked to a dataflow names one the registry holds;
812
+ - ``search/`` is present exactly when the section declares an index,
813
+ with the declared languages, each stemmed as declared and holding the
814
+ declared rows;
815
+ - the store's annotations of the types the index read are the ones it was
816
+ built from, so an overlay folded in since shows up as a stale index.
817
+
818
+ Args:
819
+ root: The bundle directory.
820
+ manifest: Its manifest.
821
+ reg: sdmxlib's registry over the bundle.
822
+
823
+ Returns:
824
+ Everything found wrong with the catalog's part of the bundle.
825
+ """
826
+ conn = reg.connection
827
+ has_schema = conn.execute(
828
+ """SELECT count(*) FROM information_schema.tables WHERE table_schema = 'statcat' AND table_name = 'dataset'"""
829
+ ).fetchone()
830
+ if not has_schema or not has_schema[0]:
831
+ return [Finding(STATCAT_SECTION, "meta.duckdb", "no statcat schema: not a statcat catalog")]
832
+ section = manifest.extensions.get(STATCAT_SECTION)
833
+ if section is None:
834
+ return [Finding(STATCAT_SECTION, MANIFEST_NAME, "no statcat section: publish with statcatlib's publish_bundle")]
835
+ if section.get("format") != STATCAT_SECTION_FORMAT:
836
+ detail = f"statcat section format {section.get('format')!r}, this build reads {STATCAT_SECTION_FORMAT}"
837
+ return [Finding(STATCAT_SECTION, MANIFEST_NAME, detail)]
838
+ findings = [*_check_catalog_rows(conn, section), *_check_search(root, section.get("search"))]
839
+ search = section.get("search")
840
+ if isinstance(search, Mapping):
841
+ findings += _check_index_annotations(conn, search.get("annotations"))
842
+ return findings
843
+
844
+
845
+ def check_bundle(path: str | Path, *, references: bool = True) -> Report:
846
+ """Check a statcat bundle: sdmxlib's checks, then the catalog's.
847
+
848
+ sdmxlib's `sdmxlib.bundle.check_bundle` checks the manifest, the files
849
+ against their checksums, the lake, the bindings and the structural
850
+ closure; `check_catalog` runs after them as the ``statcat`` check. Run
851
+ it where a bundle is built or received, never on the path that serves
852
+ it. ``statcat bundle check`` runs it from a shell.
853
+
854
+ Args:
855
+ path: The bundle directory.
856
+ references: Whether to check the structural closure, the costly check.
857
+
858
+ Returns:
859
+ What was checked and what was found.
860
+ """
861
+ return sdmx_check_bundle(path, references=references, extensions={STATCAT_SECTION: check_catalog})
862
+
863
+
864
+ def overlay_affects_search(overlay: Overlay, types: Collection[str] | None = None) -> tuple[OverlayEntry, ...]:
865
+ """The entries of *overlay* that change what the search index should hold.
866
+
867
+ An overlay applied at serve time never reaches statcatlib's index, and one
868
+ folded into a bundle needs ``build_index()`` before search reflects it.
869
+ Either way, an entry acting on an annotation type the index reads is a
870
+ change search will not see on its own. Call this where overlays are
871
+ loaded, to log or refuse them. Static: it needs no bundle.
872
+
873
+ Args:
874
+ overlay: The overlay document.
875
+ types: The annotation types the index reads. ``None`` means
876
+ `SEARCH_ANNOTATION_TYPES`, what `Catalog.build_index` reads by
877
+ default; pass ``cat.index_annotations["types"]`` for an index
878
+ built otherwise.
879
+
880
+ Returns:
881
+ Those entries, in document order; empty, and so falsy, when there are none.
882
+ """
883
+ from statcatlib.store.store import SEARCH_ANNOTATION_TYPES # noqa: PLC0415
884
+
885
+ wanted = set(SEARCH_ANNOTATION_TYPES if types is None else types)
886
+ return tuple(entry for entry in overlay.entries if entry.type in wanted)
@@ -225,6 +225,39 @@ def bundle_explain(
225
225
  raise typer.Exit(code=1)
226
226
 
227
227
 
228
+ @bundle_app.command("check")
229
+ def bundle_check(
230
+ bundle: Path = typer.Argument(
231
+ ...,
232
+ help="Path to a published statcatlib Catalog bundle (the folder containing bundle.manifest.json).",
233
+ exists=True,
234
+ file_okay=False,
235
+ dir_okay=True,
236
+ ),
237
+ references: bool = typer.Option(
238
+ True,
239
+ "--references/--no-references",
240
+ help="Check that every structural reference resolves in the bundle. The costly check.",
241
+ ),
242
+ ) -> None:
243
+ """Check a published bundle against its manifest: sdmxlib's checks, then the catalog's.
244
+
245
+ Run it where a bundle is built or received, not on the serving path.
246
+ Lists everything found wrong and exits 1 if anything was.
247
+ """
248
+ from statcatlib.bundle import check_bundle # noqa: PLC0415
249
+
250
+ report = check_bundle(bundle.resolve(), references=references)
251
+ for finding in report.findings:
252
+ # The finding's own "[check]" prefix is text, not Rich markup.
253
+ console.print(str(finding), style="red", markup=False, highlight=False)
254
+ checked = ", ".join(report.checked)
255
+ if not report.ok:
256
+ console.print(f"[red]{len(report.findings)} finding(s)[/] — checked {checked}")
257
+ raise typer.Exit(code=1)
258
+ console.print(f"[green]ok[/] — checked {checked}")
259
+
260
+
228
261
  @bundle_app.command("attach-cmd")
229
262
  def bundle_attach_cmd(
230
263
  bundle: Path = typer.Argument(
@@ -17,6 +17,7 @@ from statcatlib.model.dataset import AggregateDataset
17
17
 
18
18
  if TYPE_CHECKING:
19
19
  from sdmxlib.api.registry import RestRegistry
20
+ from sdmxlib.local.registry import LocalRegistry
20
21
  from sdmxlib.model.in_memory_registry import InMemoryRegistry
21
22
 
22
23
  from statcatlib.model.skos import ConceptRef
@@ -133,7 +134,7 @@ def dataset_from_dataflow(
133
134
 
134
135
 
135
136
  def prepare_sdmx(
136
- registry: InMemoryRegistry,
137
+ registry: InMemoryRegistry | LocalRegistry,
137
138
  *,
138
139
  publisher: Agent | None = None,
139
140
  themes: list[ConceptRef] | None = None,
@@ -148,6 +149,9 @@ def prepare_sdmx(
148
149
 
149
150
  Code and codelist information is resolved at query time via the SDMX
150
151
  structural tables once ``local.ingest(registry)`` has been called.
152
+
153
+ *registry* can also be a `LocalRegistry` whose store already holds the
154
+ structures, such as a bundle's: each flow's DSD is read from the store.
151
155
  """
152
156
  results: list[AggregateDataset] = []
153
157
  for flow in registry.get_all(Dataflow):
@@ -26,6 +26,7 @@ from statcatlib.store.store import (
26
26
  DatasetsScope,
27
27
  EmbeddingMetadata,
28
28
  Hit,
29
+ SEARCH_ANNOTATION_TYPES,
29
30
  SEARCH_CONTEXT_ANNOTATION_TYPE,
30
31
  SEARCH_EXCLUDE_ANNOTATION_TYPE,
31
32
  IncompatibleCatalogError,
@@ -147,6 +147,17 @@ def _as_opt_str(value: object) -> str | None:
147
147
  return value if isinstance(value, str) else None
148
148
 
149
149
 
150
+ def _as_annotations(entry: object) -> IndexAnnotations | None:
151
+ """Narrow the manifest's ``annotations`` entry, or ``None`` if absent or malformed."""
152
+ if not isinstance(entry, Mapping):
153
+ return None
154
+ types = entry.get("types")
155
+ fingerprint = _as_opt_str(entry.get("fingerprint"))
156
+ if not isinstance(types, list) or fingerprint is None:
157
+ return None
158
+ return {"types": [str(t) for t in types], "fingerprint": fingerprint}
159
+
160
+
150
161
  def _as_layout(entry: object) -> LanguageLayout | None:
151
162
  """Narrow one manifest ``languages`` entry, or ``None`` if it is not one.
152
163
 
@@ -263,6 +274,20 @@ class LanguageLayout(TypedDict):
263
274
  stemmer: str | None
264
275
 
265
276
 
277
+ class IndexAnnotations(TypedDict):
278
+ """The annotations an index was built from, as `Catalog.index_annotations` reports it.
279
+
280
+ `types` are the annotation types `build_index` read (what to exclude, what
281
+ context and synonyms to fold in); `fingerprint` is a digest of every
282
+ annotation of those types in the store at the time. An overlay folded
283
+ into the store afterwards changes the fingerprint the store yields, which
284
+ is how a stale index is detected without rebuilding it (GH-3).
285
+ """
286
+
287
+ types: list[str]
288
+ fingerprint: str
289
+
290
+
266
291
  class _Manifest(TypedDict):
267
292
  """``index.json``, as written.
268
293
 
@@ -277,6 +302,7 @@ class _Manifest(TypedDict):
277
302
  written_by: str
278
303
  generation: int
279
304
  languages: dict[str, LanguageLayout]
305
+ annotations: NotRequired[IndexAnnotations]
280
306
 
281
307
 
282
308
  class SearchRow(TypedDict):
@@ -723,6 +749,27 @@ class _LanguageIndex:
723
749
  self._ann_index_type: str | None = None
724
750
  if name in db.list_tables().tables:
725
751
  self._tbl = db.open_table(name)
752
+ self._has_fts = self._fts_covers_table()
753
+
754
+ def _fts_covers_table(self) -> bool:
755
+ """Whether the opened table already has an FTS index over every row.
756
+
757
+ Without this, the first BM25 query in each process rebuilt the
758
+ index with ``replace=True``: every serving worker re-indexed on
759
+ startup, and a read-only open wrote new index files into a
760
+ published bundle, breaking the checksums in its manifest.
761
+ Rows added after the index was built leave it partial, and then
762
+ a rebuild is still due.
763
+ """
764
+ assert self._tbl is not None # noqa: S101
765
+ try:
766
+ indices = self._tbl.list_indices()
767
+ except Exception: # noqa: BLE001
768
+ return False
769
+ return any(
770
+ i.index_type == "FTS" and list(i.columns) == ["text"] and not getattr(i, "num_unindexed_rows", 0)
771
+ for i in indices
772
+ )
726
773
 
727
774
  @property
728
775
  def table(self) -> lancedb.table.Table | None:
@@ -964,11 +1011,13 @@ class LanceIndex:
964
1011
  self._langs: dict[str, _LanguageIndex] = {}
965
1012
  self._legacy: lancedb.table.Table | None = None
966
1013
  self._generation = 0
1014
+ self._annotations: IndexAnnotations | None = None
967
1015
 
968
1016
  names = set(self._db.list_tables().tables)
969
1017
  manifest = self._read_manifest()
970
1018
  if manifest is not None:
971
1019
  self._generation = _as_int(manifest.get("generation"))
1020
+ self._annotations = _as_annotations(manifest.get("annotations"))
972
1021
  languages = manifest.get("languages")
973
1022
  entries = languages.items() if isinstance(languages, Mapping) else ()
974
1023
  for lang, entry in entries:
@@ -1088,6 +1137,8 @@ class LanceIndex:
1088
1137
  for lang, index in sorted(indexes.items())
1089
1138
  },
1090
1139
  }
1140
+ if self._annotations is not None:
1141
+ payload["annotations"] = self._annotations
1091
1142
  self._path.mkdir(parents=True, exist_ok=True)
1092
1143
  tmp = self._path / f"{_MANIFEST_FILE}.tmp"
1093
1144
  tmp.write_text(json.dumps(payload, indent=2, sort_keys=True))
@@ -1105,6 +1156,11 @@ class LanceIndex:
1105
1156
  return _LanguageIndex(self._db, slug, _TABLE, filter_lang=True)
1106
1157
  return None
1107
1158
 
1159
+ @property
1160
+ def annotations(self) -> IndexAnnotations | None:
1161
+ """The annotations this index was built from; ``None`` for one built before they were recorded."""
1162
+ return self._annotations
1163
+
1108
1164
  @property
1109
1165
  def languages(self) -> list[str]:
1110
1166
  """Languages with their own table, sorted."""
@@ -1182,7 +1238,13 @@ class LanceIndex:
1182
1238
  if added_language:
1183
1239
  self._write_manifest(self._langs, self._generation)
1184
1240
 
1185
- def add_bulk(self, rows: list[SearchRow], *, embedding_dim: int | None = None) -> None:
1241
+ def add_bulk(
1242
+ self,
1243
+ rows: list[SearchRow],
1244
+ *,
1245
+ embedding_dim: int | None = None,
1246
+ annotations: IndexAnnotations | None = None,
1247
+ ) -> None:
1186
1248
  """Bulk-load rows, replacing every language's table.
1187
1249
 
1188
1250
  More efficient than per-row ``add()`` for full rebuilds — avoids
@@ -1203,6 +1265,9 @@ class LanceIndex:
1203
1265
  null-typed embedding column, and the ANN index would
1204
1266
  silently never build. ``None`` falls back to scanning the
1205
1267
  rows, which is correct whenever at least one is embedded.
1268
+ annotations: The annotations *rows* were built from. Recorded in
1269
+ the same manifest write that commits the new generation, so
1270
+ the two cannot disagree after a crash.
1206
1271
 
1207
1272
  Raises:
1208
1273
  Exception: Whatever the underlying build raised. Every table
@@ -1233,6 +1298,7 @@ class LanceIndex:
1233
1298
  index.drop()
1234
1299
  raise
1235
1300
 
1301
+ self._annotations = annotations
1236
1302
  self._write_manifest(built, generation)
1237
1303
 
1238
1304
  # Past this point the rebuild has committed; everything below is
@@ -18,7 +18,14 @@ from typing import TYPE_CHECKING, Any, NamedTuple, Protocol, Self, TypedDict, ov
18
18
  if TYPE_CHECKING:
19
19
  from collections.abc import Callable, Generator, Sequence
20
20
 
21
- from statcatlib.store.lance import Bm25HitRow, LanceIndex, LanguageLayout, SearchRow, VectorHitRow
21
+ from statcatlib.store.lance import (
22
+ Bm25HitRow,
23
+ IndexAnnotations,
24
+ LanceIndex,
25
+ LanguageLayout,
26
+ SearchRow,
27
+ VectorHitRow,
28
+ )
22
29
 
23
30
  import duckdb
24
31
  import sdmxlib
@@ -1597,6 +1604,75 @@ SEARCH_CONTEXT_ANNOTATION_TYPE = "statcat:searchContext"
1597
1604
  #: structural tables — only its search rows are skipped.
1598
1605
  SEARCH_EXCLUDE_ANNOTATION_TYPE = "statcat:searchExclude"
1599
1606
 
1607
+ #: The SKOS synonym annotation `build_index` folds into code rows
1608
+ #: (``include_alt_labels``).
1609
+ _ALT_LABEL_ANNOTATION_TYPE = "skos:altLabel"
1610
+
1611
+ #: Every annotation type `build_index` reads by default. An annotation
1612
+ #: overlay touching one of these changes what the index should hold, so it
1613
+ #: needs ``build_index()`` to take effect in search (GH-3).
1614
+ SEARCH_ANNOTATION_TYPES: tuple[str, ...] = (
1615
+ SEARCH_CONTEXT_ANNOTATION_TYPE,
1616
+ SEARCH_EXCLUDE_ANNOTATION_TYPE,
1617
+ _ALT_LABEL_ANNOTATION_TYPE,
1618
+ )
1619
+
1620
+
1621
+ def annotation_fingerprint(conn: duckdb.DuckDBPyConnection, types: Sequence[str]) -> str:
1622
+ """A digest of every annotation of *types* in the ``sdmx`` store.
1623
+
1624
+ What `build_index` records, and what a stale-index check recomputes: it
1625
+ changes when an annotation of one of *types* is added, removed or edited
1626
+ anywhere in the store — on any owner, which over-approximates the codes
1627
+ the index reads and so never misses a change. Each annotation row is
1628
+ hashed with its owner, position, id, title and value, and each of its
1629
+ localised texts with its owner, position and language.
1630
+
1631
+ The row hashes are summed, not sorted and concatenated: order-independent
1632
+ without a sort, so it costs one scan (about 140 ms for 400,000
1633
+ annotations with 400,000 texts). md5 rather than DuckDB's ``hash``, which
1634
+ is not stable across DuckDB versions. NULLs are spelled out, since
1635
+ ``concat_ws`` skips them and would let a title and a value trade places.
1636
+
1637
+ Args:
1638
+ conn: A connection whose ``sdmx`` schema holds the annotations.
1639
+ types: The annotation types to cover.
1640
+
1641
+ Returns:
1642
+ A hex digest. With *types* empty, or no ``sdmx`` schema, the digest
1643
+ of nothing.
1644
+ """
1645
+ nothing = hashlib.md5(b"", usedforsecurity=False).hexdigest()
1646
+ has_sdmx = conn.execute(
1647
+ """SELECT count(*) FROM information_schema.tables WHERE table_schema = 'sdmx' AND table_name = 'annotation'"""
1648
+ ).fetchone()
1649
+ if not types or not has_sdmx or not has_sdmx[0]:
1650
+ return nothing
1651
+ row = conn.execute(
1652
+ """
1653
+ WITH typed AS (
1654
+ SELECT id, owner_urn, ordinal, annotation_id, annotation_type, title, value
1655
+ FROM sdmx.annotation
1656
+ WHERE annotation_type IN (SELECT UNNEST($types))
1657
+ )
1658
+ SELECT
1659
+ (SELECT count(*) FROM typed),
1660
+ (SELECT sum(md5_number_lower(concat_ws(chr(31),
1661
+ owner_urn, ordinal, coalesce(annotation_id, chr(0)), annotation_type,
1662
+ coalesce(title, chr(0)), coalesce(value, chr(0))))) FROM typed),
1663
+ (SELECT count(*) FROM typed a JOIN sdmx.annotation_text t ON t.annotation_id = a.id),
1664
+ (SELECT sum(md5_number_lower(concat_ws(chr(31),
1665
+ a.owner_urn, a.ordinal, coalesce(t.lang, chr(0)), coalesce(t.text, chr(0)))))
1666
+ FROM typed a JOIN sdmx.annotation_text t ON t.annotation_id = a.id)
1667
+ """,
1668
+ {"types": sorted(types)},
1669
+ ).fetchone()
1670
+ if row is None:
1671
+ return nothing
1672
+ folded = "|".join(str(part) for part in (sorted(types), *row))
1673
+ return hashlib.md5(folded.encode(), usedforsecurity=False).hexdigest()
1674
+
1675
+
1600
1676
  #: Bucket key for search text that carries no language. Not a language
1601
1677
  #: tag: it means "applies to every language", which is the only thing
1602
1678
  #: untagged text can mean. Text bucketed here folds into every language's
@@ -2785,6 +2861,7 @@ class Catalog:
2785
2861
  self._data = sdmxlib.DuckDBDataStore.from_registry(self._registry)
2786
2862
  if self._bundle_dir is not None:
2787
2863
  self._init_lance()
2864
+ self._warn_if_index_annotations_changed()
2788
2865
  # Restore embedding metadata written by a previous build_index()
2789
2866
  # so the caller doesn't have to remember which model + prefixes
2790
2867
  # match this index. Missing/malformed metadata leaves the store
@@ -2982,6 +3059,35 @@ class Catalog:
2982
3059
  except ImportError:
2983
3060
  return
2984
3061
 
3062
+ def _warn_if_index_annotations_changed(self) -> None:
3063
+ """Warn if the store's search annotations are not the ones the index was built from.
3064
+
3065
+ A warning, never an error and never a rebuild: opening a bundle in a
3066
+ serving pod must not fail or write because an overlay was folded in.
3067
+ It fires on every open until ``build_index()`` is run (GH-3).
3068
+ """
3069
+ recorded = self._lance.annotations if self._lance is not None else None
3070
+ if recorded is None or self._conn is None:
3071
+ return
3072
+ if annotation_fingerprint(self._conn, recorded["types"]) == recorded["fingerprint"]:
3073
+ return
3074
+ warnings.warn(
3075
+ f"search index is out of date: annotations of type {', '.join(recorded['types'])} have changed "
3076
+ "since it was built (an overlay folded into the store?), so search does not reflect them. "
3077
+ "Rebuild with build_index() to fix. (statcatlib GH-3)",
3078
+ stacklevel=3,
3079
+ )
3080
+
3081
+ @property
3082
+ def index_annotations(self) -> IndexAnnotations | None:
3083
+ """The annotation types the search index was built from, and their fingerprint.
3084
+
3085
+ ``None`` when no index is built, or for one built before statcatlib
3086
+ recorded them. Compare ``types`` with an annotation overlay's entries
3087
+ to tell whether applying it needs a ``build_index()`` to reach search.
3088
+ """
3089
+ return self._lance.annotations if self._lance is not None else None
3090
+
2985
3091
  @property
2986
3092
  def connection(self) -> duckdb.DuckDBPyConnection:
2987
3093
  """The underlying DuckDB connection (for sharing with sdmxlib LocalRegistry).
@@ -4625,9 +4731,18 @@ class Catalog:
4625
4731
  )
4626
4732
 
4627
4733
  embedding_dim = _infer_embedding_dim(self._embedding_model, dataset_rows)
4734
+ # The annotation types this build read, and a digest of them, so a
4735
+ # later change to them (a folded overlay) is detected, not missed.
4736
+ annotation_types: set[str] = set()
4737
+ if include_codes:
4738
+ annotation_types |= {*context_annotation_types, *exclude_annotation_types}
4739
+ if include_alt_labels:
4740
+ annotation_types.add(_ALT_LABEL_ANNOTATION_TYPE)
4741
+ types = sorted(annotation_types)
4628
4742
  self._lance.add_bulk(
4629
4743
  dataset_rows + code_rows + concept_rows + refmeta_rows,
4630
4744
  embedding_dim=embedding_dim,
4745
+ annotations={"types": types, "fingerprint": annotation_fingerprint(self._c, types)},
4631
4746
  )
4632
4747
  self._lance.build_index()
4633
4748
  if progress is not None:
@@ -0,0 +1,72 @@
1
+ """Synthetic statcat bundles, for tests and benchmarks at any shape (GH-3).
2
+
3
+ `sdmxlib.testing.synthetic_bundle` builds a conforming sdmxlib bundle from a
4
+ `sdmxlib.testing.Shape` and a seed. `synthetic_catalog` makes it a statcat
5
+ bundle: a catalog dataset per dataflow, a search index over it, and a
6
+ manifest with the ``statcat`` section, so it passes
7
+ `statcatlib.bundle.check_bundle`. The same seed builds the same structures and
8
+ observations, so a failure reproduces.
9
+
10
+ Example:
11
+ from sdmxlib.testing import Dim, Shape
12
+
13
+ from statcatlib.testing import synthetic_catalog
14
+
15
+ shape = Shape(dimensions=(Dim(codes=5000, depth=4), Dim(codes=12)), observations=100_000, languages=("en", "fr"))
16
+ synthetic_catalog("out/stress", shape, seed=7)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from importlib import metadata
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING
24
+
25
+ from sdmxlib.bundle import Producer
26
+ from sdmxlib.testing import synthetic_bundle
27
+
28
+ from statcatlib.bundle import Embedded, publish_bundle
29
+ from statcatlib.sources.sdmx import prepare_sdmx
30
+ from statcatlib.store import Catalog
31
+
32
+ if TYPE_CHECKING:
33
+ from sdmxlib.bundle import Manifest
34
+ from sdmxlib.testing import Shape
35
+
36
+ __all__ = ["synthetic_catalog"]
37
+
38
+
39
+ def synthetic_catalog(
40
+ path: str | Path,
41
+ shape: Shape,
42
+ *,
43
+ seed: int = 0,
44
+ index: bool = True,
45
+ bundle_id: str = "synthetic",
46
+ ) -> Manifest:
47
+ """Build a published statcat bundle of the given shape at *path*.
48
+
49
+ The structures and observations are `sdmxlib.testing.synthetic_bundle`'s.
50
+ Each dataflow becomes an `AggregateDataset`, the search index is built
51
+ unless *index* is false (it needs ``statcatlib[search]``), and the bundle
52
+ is published last with `statcatlib.bundle.publish_bundle`.
53
+
54
+ Args:
55
+ path: The bundle directory to create. It must not hold a bundle already.
56
+ shape: Codelist sizes, hierarchy depth, observation count and languages.
57
+ seed: What makes the bundle reproducible.
58
+ index: Whether to build the search index.
59
+ bundle_id: The manifest's ``bundle_id``.
60
+
61
+ Returns:
62
+ The manifest written.
63
+ """
64
+ root = Path(path)
65
+ synthetic_bundle(root, shape, seed=seed)
66
+ with Catalog(root, data_path=Embedded()) as cat:
67
+ with cat.bulk_load():
68
+ cat.put(prepare_sdmx(cat.registry))
69
+ if index:
70
+ cat.build_index()
71
+ producer = Producer(name="statcatlib.testing", version=metadata.version("statcatlib"))
72
+ return publish_bundle(root, bundle_id=bundle_id, producer=producer, product={"seed": seed})