statcatlib 0.9.0__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.
- {statcatlib-0.9.0 → statcatlib-0.10.0}/LICENSE +78 -56
- {statcatlib-0.9.0 → statcatlib-0.10.0}/PKG-INFO +5 -3
- {statcatlib-0.9.0 → statcatlib-0.10.0}/README.md +3 -1
- {statcatlib-0.9.0 → statcatlib-0.10.0}/pyproject.toml +2 -2
- {statcatlib-0.9.0 → statcatlib-0.10.0}/pyproject.toml.orig +6 -3
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/__init__.py +2 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/bundle.py +322 -1
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/cli/__init__.py +33 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/sources/sdmx.py +5 -1
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/store/__init__.py +1 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/store/lance.py +67 -1
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/store/store.py +116 -1
- statcatlib-0.10.0/src/statcatlib/testing.py +72 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/expr.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/model/__init__.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/model/agents.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/model/catalog.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/model/classification.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/model/dataset.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/model/distribution.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/model/licence.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/model/quality.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/model/skos.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/model/spatial.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/model/temporal.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/model/types.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/py.typed +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/query.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/sources/__init__.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/sources/ckan.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/store/row.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/store/schema.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/store/scopes.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/store/sentinel.py +0 -0
- {statcatlib-0.9.0 → statcatlib-0.10.0}/src/statcatlib/store/xhtml.py +0 -0
- {statcatlib-0.9.0 → 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
|
|
36
|
-
the License, as indicated by a
|
|
37
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
or
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
|
80
|
-
or counterclaim in a lawsuit) alleging that the Work
|
|
81
|
-
Contribution
|
|
82
|
-
patent infringement, then any patent licenses
|
|
83
|
-
this License for that Work shall terminate
|
|
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
|
|
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,
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
the
|
|
109
|
-
Derivative Works;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
may
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
143
|
-
|
|
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
|
|
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 (
|
|
156
|
-
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
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.
|
|
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.
|
|
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 `
|
|
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 `
|
|
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.
|
|
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.
|
|
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.
|
|
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"]
|
|
@@ -28,8 +28,11 @@ dependencies = [
|
|
|
28
28
|
"duckdb>=2.0.0",
|
|
29
29
|
"httpx>=0.28",
|
|
30
30
|
# >=0.70: needs Python 3.14 and DuckDB 2.0; `reg.data()` is gone, and a
|
|
31
|
-
# component without a concept is refused as it is built.
|
|
32
|
-
|
|
31
|
+
# component without a concept is refused as it is built. >=0.70.1:
|
|
32
|
+
# `reg.obs()` asks Eurostat for CSV it serves, so `ingest_data()` can
|
|
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",
|
|
33
36
|
]
|
|
34
37
|
|
|
35
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):
|
|
@@ -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(
|
|
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
|
|
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})
|
|
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
|