statcatlib 0.8.0__py3-none-any.whl

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/expr.py ADDED
@@ -0,0 +1,167 @@
1
+ """Sub-traversal expression language for scope-based graph queries.
2
+
3
+ Expressions describe computations over related graph data without executing
4
+ any SQL. They are context-free objects — like ``pl.col()`` in Polars — and
5
+ can be reused across ``.enrich()``, ``.filter()``, and ``.sort_by()`` on any
6
+ collection scope.
7
+
8
+ Usage::
9
+
10
+ import statcatlib as sc
11
+
12
+ n_datasets = sc.each("datasets").count()
13
+ has_data = sc.each("datasets").exists()
14
+
15
+ store.themes["subjects"].enrich(n=n_datasets).filter(has_data).collect()
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from attrs import define
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Base expression types
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ @define(frozen=True)
28
+ class Expr:
29
+ """Base class for all expression nodes."""
30
+
31
+ def alias(self, name: str) -> AliasedExpr:
32
+ """Name this expression result (like Polars ``.alias()``)."""
33
+ return AliasedExpr(expr=self, name=name)
34
+
35
+ def count(self) -> CountExpr:
36
+ """Count items at the far end of the traversal."""
37
+ return CountExpr(source=self)
38
+
39
+ def exists(self) -> ExistsExpr:
40
+ """Check if any items exist at the far end."""
41
+ return ExistsExpr(source=self)
42
+
43
+ def sum(self, field: str) -> SumExpr:
44
+ """Sum a numeric field at the far end."""
45
+ return SumExpr(source=self, field=field)
46
+
47
+ def ids(self) -> IdsExpr:
48
+ """Collect identifiers at the far end."""
49
+ return IdsExpr(source=self)
50
+
51
+ def distinct(self, field: str) -> DistinctExpr:
52
+ """Select distinct values of a field at the far end."""
53
+ return DistinctExpr(source=self, field=field)
54
+
55
+ def filter(self, **kwargs: object) -> FilteredExpr:
56
+ """Filter far-side nodes by attribute values."""
57
+ return FilteredExpr(source=self, conditions=kwargs)
58
+
59
+ def count_distinct(self) -> CountDistinctExpr:
60
+ """Count distinct items at the far end."""
61
+ return CountDistinctExpr(source=self)
62
+
63
+
64
+ @define(frozen=True)
65
+ class TraversalExpr(Expr):
66
+ """A traversal along one or more edges from the current scope position.
67
+
68
+ Created by ``sc.each("edge1", "edge2", ...)``. Strings are always
69
+ edge labels (table names or relationship names in the graph).
70
+ """
71
+
72
+ edges: tuple[str, ...]
73
+
74
+
75
+ @define(frozen=True)
76
+ class AliasedExpr:
77
+ """An expression with a named output column.
78
+
79
+ Created by ``expr.alias("name")`` or via ``**kwargs`` in ``.enrich()``.
80
+ """
81
+
82
+ expr: Expr
83
+ name: str
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Terminal reduction expressions
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ @define(frozen=True)
92
+ class CountExpr(Expr):
93
+ """COUNT(*) at the far end of a traversal."""
94
+
95
+ source: Expr
96
+
97
+
98
+ @define(frozen=True)
99
+ class ExistsExpr(Expr):
100
+ """EXISTS(...) — boolean, true if any far-side items exist."""
101
+
102
+ source: Expr
103
+
104
+
105
+ @define(frozen=True)
106
+ class SumExpr(Expr):
107
+ """SUM(field) at the far end of a traversal."""
108
+
109
+ source: Expr
110
+ field: str
111
+
112
+
113
+ @define(frozen=True)
114
+ class IdsExpr(Expr):
115
+ """LIST(id) — collect identifiers at the far end."""
116
+
117
+ source: Expr
118
+
119
+
120
+ @define(frozen=True)
121
+ class DistinctExpr(Expr):
122
+ """SELECT DISTINCT field — narrows to unique values."""
123
+
124
+ source: Expr
125
+ field: str
126
+
127
+
128
+ @define(frozen=True)
129
+ class CountDistinctExpr(Expr):
130
+ """COUNT(DISTINCT ...) at the far end."""
131
+
132
+ source: Expr
133
+
134
+
135
+ @define(frozen=True)
136
+ class FilteredExpr(Expr):
137
+ """A traversal with far-side filtering applied.
138
+
139
+ Created by ``sc.each("datasets").filter(status="current")``.
140
+ """
141
+
142
+ source: Expr
143
+ conditions: dict[str, object]
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Public entry point
148
+ # ---------------------------------------------------------------------------
149
+
150
+
151
+ def each(*edges: str) -> TraversalExpr:
152
+ """Create a sub-traversal expression starting from the current scope.
153
+
154
+ Each argument is an edge label (relationship name). Multiple arguments
155
+ describe a multi-hop path::
156
+
157
+ sc.each("datasets") # one hop
158
+ sc.each("datasets", "dimensions") # two hops
159
+ sc.each("datasets", "dimensions", "codelist") # three hops
160
+
161
+ The returned expression is context-free and can be used in ``.enrich()``,
162
+ ``.filter()``, and ``.sort_by()`` on any collection scope.
163
+ """
164
+ if not edges:
165
+ msg = "each() requires at least one edge label"
166
+ raise ValueError(msg)
167
+ return TraversalExpr(edges=edges)
@@ -0,0 +1,56 @@
1
+ """Domain model for statcatlib."""
2
+
3
+ from statcatlib.model.agents import Agent, ContactPoint
4
+ from statcatlib.model.catalog import CatalogDescription, CatalogRecord
5
+ from statcatlib.model.classification import Standard
6
+ from statcatlib.model.dataset import (
7
+ AggregateDataset,
8
+ BaseDataset,
9
+ Dataset,
10
+ DatasetT,
11
+ GeospatialDataset,
12
+ MicrodataDataset,
13
+ OpenDataset,
14
+ PublicationDataset,
15
+ StatisticalDataset,
16
+ Variable,
17
+ )
18
+ from statcatlib.model.distribution import DataService, Distribution
19
+ from statcatlib.model.licence import LicenceDocument
20
+ from statcatlib.model.quality import ProvenanceStatement, QualityAnnotation
21
+ from statcatlib.model.spatial import SpatialCoverage
22
+ from statcatlib.model.temporal import Frequency, TemporalCoverage
23
+ from statcatlib.model.skos import Concept, ConceptRef, ConceptScheme, ConceptSchemeRef, is_concept_in_scheme
24
+ from statcatlib.model.types import InternationalString
25
+
26
+ __all__ = [
27
+ "Agent",
28
+ "AggregateDataset",
29
+ "BaseDataset",
30
+ "CatalogDescription",
31
+ "CatalogRecord",
32
+ "Concept",
33
+ "ConceptRef",
34
+ "ConceptScheme",
35
+ "ConceptSchemeRef",
36
+ "ContactPoint",
37
+ "DataService",
38
+ "Dataset",
39
+ "DatasetT",
40
+ "Distribution",
41
+ "Frequency",
42
+ "GeospatialDataset",
43
+ "InternationalString",
44
+ "LicenceDocument",
45
+ "MicrodataDataset",
46
+ "OpenDataset",
47
+ "ProvenanceStatement",
48
+ "PublicationDataset",
49
+ "QualityAnnotation",
50
+ "SpatialCoverage",
51
+ "Standard",
52
+ "StatisticalDataset",
53
+ "TemporalCoverage",
54
+ "Variable",
55
+ "is_concept_in_scheme",
56
+ ]
@@ -0,0 +1,32 @@
1
+ """Agent and contact types for the statcatlib domain model."""
2
+
3
+ from attrs import define
4
+ from sdmxlib import InternationalString
5
+
6
+ __all__ = ["Agent", "ContactPoint"]
7
+
8
+
9
+ @define
10
+ class Agent:
11
+ """An organization or person responsible for a dataset.
12
+
13
+ Maps to foaf:Agent / dct:publisher / dct:creator in DCAT-AP.
14
+ """
15
+
16
+ name: InternationalString
17
+ id: str | None = None
18
+ type: str | None = None
19
+ homepage: str | None = None
20
+
21
+
22
+ @define
23
+ class ContactPoint:
24
+ """Contact information for a dataset.
25
+
26
+ Maps to dcat:contactPoint → vcard:Kind in DCAT-AP.
27
+ """
28
+
29
+ name: InternationalString | None = None
30
+ email: str | None = None
31
+ phone: str | None = None
32
+ url: str | None = None
@@ -0,0 +1,71 @@
1
+ """CatalogDescription and CatalogRecord types for the statcatlib domain model.
2
+
3
+ DCAT-AP describes a catalog as a top-level container of datasets. In the
4
+ statcatlib runtime, a *catalog* is the DuckDB-backed `statcatlib.Catalog`
5
+ store. The DCAT-style metadata record that describes such a catalog (its
6
+ title, publisher, list of datasets…) is `CatalogDescription` here.
7
+ This split avoids the name collision between "DCAT Catalog the model
8
+ record" and "Catalog the store".
9
+ """
10
+
11
+ from datetime import datetime
12
+
13
+ from attrs import Factory, define
14
+ from sdmxlib import InternationalString
15
+
16
+ from statcatlib.model.agents import Agent
17
+ from statcatlib.model.classification import Standard
18
+ from statcatlib.model.dataset import BaseDataset
19
+ from statcatlib.model.distribution import DataService
20
+ from statcatlib.model.licence import LicenceDocument
21
+ from statcatlib.model.skos import ConceptSchemeRef
22
+ from statcatlib.model.spatial import SpatialCoverage
23
+ from statcatlib.model.temporal import TemporalCoverage
24
+
25
+ __all__ = ["CatalogDescription", "CatalogRecord"]
26
+
27
+
28
+ @define
29
+ class CatalogRecord:
30
+ """Metadata about a dataset's entry in the catalog.
31
+
32
+ Maps to dcat:CatalogRecord in DCAT-AP. This is metadata about
33
+ the catalog entry itself — when it was listed, where it was
34
+ harvested from, etc. Optional — not all catalogs use this.
35
+ """
36
+
37
+ identifier: str
38
+ primary_topic: BaseDataset
39
+ listing_date: datetime | None = None
40
+ modified: datetime | None = None
41
+ source_catalog: str | None = None
42
+ conforms_to: Standard | None = None
43
+
44
+
45
+ @define
46
+ class CatalogDescription:
47
+ """A DCAT-style description record for a catalog of datasets.
48
+
49
+ Maps to dcat:Catalog in DCAT-AP. This is metadata describing the
50
+ catalog as a published artefact — title, publisher, list of datasets
51
+ and services, licence, etc. The runtime store with the same
52
+ DCAT-shaped role is `statcatlib.Catalog`; the two are separate
53
+ concerns. Use this when serialising to / from DCAT-AP RDF or when
54
+ a catalog needs a self-describing manifest record.
55
+ """
56
+
57
+ identifier: str
58
+ title: InternationalString
59
+ description: InternationalString
60
+ publisher: Agent
61
+ datasets: list[BaseDataset] = Factory(list)
62
+ services: list[DataService] = Factory(list)
63
+ records: list[CatalogRecord] = Factory(list)
64
+ homepage: str | None = None
65
+ language: list[str] = Factory(list)
66
+ licence: LicenceDocument | None = None
67
+ spatial_coverage: list[SpatialCoverage] = Factory(list)
68
+ temporal_coverage: TemporalCoverage | None = None
69
+ theme_taxonomy: list[ConceptSchemeRef] = Factory(list)
70
+ issued: datetime | None = None
71
+ modified: datetime | None = None
@@ -0,0 +1,19 @@
1
+ """Classification types for the statcatlib domain model."""
2
+
3
+ from attrs import define
4
+ from sdmxlib import InternationalString
5
+
6
+ __all__ = ["Standard"]
7
+
8
+
9
+ @define
10
+ class Standard:
11
+ """A standard or specification that the dataset conforms to.
12
+
13
+ Maps to dct:conformsTo in DCAT-AP. Used to link to SDMX DSDs,
14
+ CSV schemas, API specifications, etc.
15
+ """
16
+
17
+ id: str
18
+ label: InternationalString | None = None
19
+ uri: str | None = None
@@ -0,0 +1,198 @@
1
+ """Core dataset types for the statcatlib domain model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import ClassVar, Literal, TypeVar
7
+
8
+ from attrs import Factory, define
9
+ from sdmxlib import InternationalString
10
+
11
+ from statcatlib.model.agents import Agent, ContactPoint
12
+ from statcatlib.model.classification import Standard
13
+ from statcatlib.model.distribution import Distribution
14
+ from statcatlib.model.licence import LicenceDocument
15
+ from statcatlib.model.quality import ProvenanceStatement, QualityAnnotation
16
+ from statcatlib.model.skos import ConceptRef
17
+ from statcatlib.model.spatial import SpatialCoverage
18
+ from statcatlib.model.temporal import Frequency, TemporalCoverage
19
+
20
+ __all__ = [
21
+ "DATASET_KINDS",
22
+ "AggregateDataset",
23
+ "BaseDataset",
24
+ "Dataset",
25
+ "DatasetKind",
26
+ "DatasetT",
27
+ "GeospatialDataset",
28
+ "MicrodataDataset",
29
+ "OpenDataset",
30
+ "PublicationDataset",
31
+ "StatisticalDataset",
32
+ "Variable",
33
+ ]
34
+
35
+
36
+ #: The domain of the ``dataset.kind`` column. `BaseDataset` deliberately has
37
+ #: no ``kind``: it is never instantiated, so it carries no tag — only the five
38
+ #: concrete variants do, each annotated with its own single-member `Literal`.
39
+ #: Annotating a variant with `DatasetKind` itself would widen it back to "any
40
+ #: of the five" and lose the narrowing that makes the tag useful.
41
+ type DatasetKind = Literal["open", "aggregate", "microdata", "geospatial", "publication"]
42
+
43
+
44
+ @define
45
+ class BaseDataset:
46
+ """Common fields for all dataset variants. Maps to DCAT-AP 3.0.
47
+
48
+ Not intended for direct construction — use one of the five concrete
49
+ variants: OpenDataset, AggregateDataset, MicrodataDataset,
50
+ GeospatialDataset, PublicationDataset.
51
+ """
52
+
53
+ # --- Identity (always required) ---
54
+ identifier: str
55
+ title: InternationalString
56
+ description: InternationalString
57
+
58
+ # --- Core catalog metadata ---
59
+ publisher: Agent | None = None
60
+ contact_point: ContactPoint | None = None
61
+ keywords: list[InternationalString] = Factory(list)
62
+ themes: list[ConceptRef] = Factory(list) # dcat:theme — URI refs, not owned
63
+ subject: list[ConceptRef] = Factory(list) # dcterms:subject — URI refs
64
+ dataset_type: ConceptRef | None = None # dcterms:type — URI ref
65
+ issued: datetime | None = None
66
+ modified: datetime | None = None
67
+ frequency: Frequency | None = None
68
+ spatial_coverage: list[SpatialCoverage] = Factory(list)
69
+ temporal_coverage: TemporalCoverage | None = None
70
+ distributions: list[Distribution] = Factory(list)
71
+ licence: LicenceDocument | None = None
72
+ language: list[str] = Factory(list)
73
+
74
+ # --- Extended metadata ---
75
+ landing_page: str | None = None
76
+ version: str | None = None
77
+ version_notes: InternationalString | None = None
78
+ conforms_to: list[Standard] = Factory(list)
79
+ creator: Agent | None = None
80
+ access_rights: str | None = None
81
+ status: str | None = None
82
+ provenance: list[ProvenanceStatement] = Factory(list)
83
+ quality_annotations: list[QualityAnnotation] = Factory(list)
84
+
85
+ # --- Extensibility ---
86
+ #: Free-form key/value pairs, both sides strings.
87
+ #:
88
+ #: Stored in a ``MAP(VARCHAR, VARCHAR)`` column, and declared to match
89
+ #: rather than to flatter: as ``dict[str, object]`` it accepted values
90
+ #: it could not return, so `None` read back as the *string* ``"None"``
91
+ #: — present and truthy where an absence was stored (#114). This is
92
+ #: also the shape the vocabulary uses; CKAN's package extras are
93
+ #: string-valued key/value pairs.
94
+ #:
95
+ #: Structured values are the caller's to encode — ``json.dumps`` on
96
+ #: the way in, ``json.loads`` on the way out — so that the lossy step
97
+ #: is one they chose and can reverse.
98
+ extras: dict[str, str] = Factory(dict)
99
+
100
+
101
+ @define
102
+ class OpenDataset(BaseDataset):
103
+ """Generic open dataset — the default bucket for non-specialized data."""
104
+
105
+ kind: ClassVar[Literal["open"]] = "open"
106
+
107
+
108
+ @define
109
+ class AggregateDataset(BaseDataset):
110
+ """A statistical aggregate dataset (tabular, with dimensions and measures).
111
+
112
+ Extends BaseDataset with StatDCAT-AP discovery fields and an optional link
113
+ to the full SDMX structural record in sdmxlib's shared DuckDB tables.
114
+ dimension_names carries enough info for catalog search without a JOIN.
115
+ """
116
+
117
+ kind: ClassVar[Literal["aggregate"]] = "aggregate"
118
+ dimension_names: list[InternationalString] = Factory(list)
119
+ num_series: int | None = None
120
+ # SDMX link: FK into sdmxlib's dataflows.urn in the shared DuckDB
121
+ sdmx_dataflow_urn: str | None = None
122
+ # Pivot axis dimension for ingest_data(). None = auto-detect from DSD.
123
+ measure_dim: str | None = None
124
+
125
+
126
+ @define
127
+ class Variable:
128
+ """A variable in a microdata dataset."""
129
+
130
+ id: str
131
+ label: InternationalString
132
+ description: InternationalString | None = None
133
+ value_type: str | None = None
134
+ classification_id: str | None = None
135
+ universe: str | None = None
136
+ question_text: str | None = None
137
+ valid_range: str | None = None
138
+ derivation: str | None = None
139
+
140
+
141
+ @define
142
+ class MicrodataDataset(BaseDataset):
143
+ """A microdata (survey/record-level) dataset."""
144
+
145
+ kind: ClassVar[Literal["microdata"]] = "microdata"
146
+ variables: list[Variable] = Factory(list)
147
+ sample_size: int | None = None
148
+ universe: str | None = None
149
+ collection_method: str | None = None
150
+ anonymization: str | None = None
151
+ survey_id: str | None = None
152
+
153
+
154
+ @define
155
+ class GeospatialDataset(BaseDataset):
156
+ """A geospatial dataset."""
157
+
158
+ kind: ClassVar[Literal["geospatial"]] = "geospatial"
159
+ crs: str | None = None
160
+ spatial_resolution: str | None = None
161
+ feature_types: list[str] = Factory(list)
162
+ scale: str | None = None
163
+
164
+
165
+ @define
166
+ class PublicationDataset(BaseDataset):
167
+ """A publication (article, report, monograph)."""
168
+
169
+ kind: ClassVar[Literal["publication"]] = "publication"
170
+ authors: list[str] = Factory(list)
171
+ doi: str | None = None
172
+ isbn: str | None = None
173
+ publication_type: str | None = None
174
+ pages: int | None = None
175
+ series_title: InternationalString | None = None
176
+
177
+
178
+ #: Every tag, in declaration order. Built from the classes rather than from
179
+ #: `DatasetKind`, so each variant's `Literal` is checked to be a member of the
180
+ #: alias — a typo'd tag is a type error here, not a silent mismatch with the
181
+ #: ``dataset.kind`` column.
182
+ DATASET_KINDS: tuple[DatasetKind, ...] = (
183
+ OpenDataset.kind,
184
+ AggregateDataset.kind,
185
+ MicrodataDataset.kind,
186
+ GeospatialDataset.kind,
187
+ PublicationDataset.kind,
188
+ )
189
+
190
+ # Public union type alias — the type you work with day-to-day.
191
+ # Use BaseDataset as the generic bound in query() and other generic contexts.
192
+ type Dataset = OpenDataset | AggregateDataset | MicrodataDataset | GeospatialDataset | PublicationDataset
193
+
194
+ # TypeVar for pre-PEP-695 generic code
195
+ DatasetT = TypeVar("DatasetT", bound=BaseDataset)
196
+
197
+ # Backward compatibility alias — AggregateDataset is the successor
198
+ StatisticalDataset = AggregateDataset
@@ -0,0 +1,50 @@
1
+ """Distribution and DataService types for the statcatlib domain model."""
2
+
3
+ from datetime import datetime
4
+
5
+ from attrs import Factory, define
6
+ from sdmxlib import InternationalString
7
+
8
+ from statcatlib.model.classification import Standard
9
+ from statcatlib.model.licence import LicenceDocument
10
+
11
+ __all__ = ["DataService", "Distribution"]
12
+
13
+
14
+ @define
15
+ class DataService:
16
+ """An API endpoint that serves one or more datasets.
17
+
18
+ Maps to dcat:DataService in DCAT-AP. For SDMX sources, the
19
+ endpoint_url is the SDMX REST base URL.
20
+ """
21
+
22
+ id: str
23
+ title: InternationalString
24
+ endpoint_url: str
25
+ description: InternationalString | None = None
26
+ serves_datasets: list[str] = Factory(list)
27
+ conforms_to: list[Standard] = Factory(list)
28
+
29
+
30
+ @define
31
+ class Distribution:
32
+ """A specific available form of a dataset.
33
+
34
+ Maps to dcat:Distribution in DCAT-AP. A dataset may have
35
+ multiple distributions: CSV download, API endpoint, visualization, etc.
36
+ """
37
+
38
+ id: str
39
+ title: InternationalString | None = None
40
+ description: InternationalString | None = None
41
+ access_url: str | None = None
42
+ download_url: str | None = None
43
+ media_type: str | None = None
44
+ format: str | None = None
45
+ byte_size: int | None = None
46
+ issued: datetime | None = None
47
+ modified: datetime | None = None
48
+ licence: LicenceDocument | None = None
49
+ access_service: DataService | None = None
50
+ distribution_type: str | None = None
@@ -0,0 +1,18 @@
1
+ """Licence types for the statcatlib domain model."""
2
+
3
+ from attrs import define
4
+ from sdmxlib import InternationalString
5
+
6
+ __all__ = ["LicenceDocument"]
7
+
8
+
9
+ @define
10
+ class LicenceDocument:
11
+ """A licence under which the dataset is made available.
12
+
13
+ Maps to dct:license → dct:LicenseDocument in DCAT-AP.
14
+ """
15
+
16
+ id: str
17
+ label: InternationalString | None = None
18
+ uri: str | None = None
@@ -0,0 +1,31 @@
1
+ """Quality and provenance types for the statcatlib domain model."""
2
+
3
+ from attrs import define
4
+ from sdmxlib import InternationalString
5
+
6
+ __all__ = ["ProvenanceStatement", "QualityAnnotation"]
7
+
8
+
9
+ @define
10
+ class QualityAnnotation:
11
+ """A quality statement about the dataset.
12
+
13
+ Maps to dqv:hasQualityAnnotation in StatDCAT-AP. Intentionally
14
+ simple — a label + optional body. Richer quality modelling
15
+ can be added later.
16
+ """
17
+
18
+ dimension: str | None = None
19
+ label: InternationalString | None = None
20
+ body: InternationalString | None = None
21
+
22
+
23
+ @define
24
+ class ProvenanceStatement:
25
+ """A statement about the origin/methodology of the dataset.
26
+
27
+ Maps to dct:provenance in DCAT-AP.
28
+ """
29
+
30
+ label: InternationalString
31
+ uri: str | None = None