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/__init__.py +187 -0
- statcatlib/bundle.py +565 -0
- statcatlib/cli/__init__.py +546 -0
- statcatlib/expr.py +167 -0
- statcatlib/model/__init__.py +56 -0
- statcatlib/model/agents.py +32 -0
- statcatlib/model/catalog.py +71 -0
- statcatlib/model/classification.py +19 -0
- statcatlib/model/dataset.py +198 -0
- statcatlib/model/distribution.py +50 -0
- statcatlib/model/licence.py +18 -0
- statcatlib/model/quality.py +31 -0
- statcatlib/model/skos.py +141 -0
- statcatlib/model/spatial.py +19 -0
- statcatlib/model/temporal.py +33 -0
- statcatlib/model/types.py +5 -0
- statcatlib/py.typed +0 -0
- statcatlib/query.py +924 -0
- statcatlib/sources/__init__.py +13 -0
- statcatlib/sources/ckan.py +490 -0
- statcatlib/sources/sdmx.py +321 -0
- statcatlib/store/__init__.py +38 -0
- statcatlib/store/lance.py +1494 -0
- statcatlib/store/row.py +30 -0
- statcatlib/store/schema.py +403 -0
- statcatlib/store/scopes.py +1419 -0
- statcatlib/store/sentinel.py +99 -0
- statcatlib/store/store.py +6836 -0
- statcatlib/store/xhtml.py +206 -0
- statcatlib/urn.py +108 -0
- statcatlib-0.8.0.dist-info/METADATA +429 -0
- statcatlib-0.8.0.dist-info/RECORD +35 -0
- statcatlib-0.8.0.dist-info/WHEEL +4 -0
- statcatlib-0.8.0.dist-info/entry_points.txt +3 -0
- statcatlib-0.8.0.dist-info/licenses/LICENSE +180 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Data source connectors for statcatlib."""
|
|
2
|
+
|
|
3
|
+
from statcatlib.sources.ckan import ingest_ckan
|
|
4
|
+
from statcatlib.sources.sdmx import dataset_from_dataflow, ingest_data, ingest_sdmx, ingest_sdmx_registry, prepare_sdmx
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"dataset_from_dataflow",
|
|
8
|
+
"ingest_ckan",
|
|
9
|
+
"ingest_data",
|
|
10
|
+
"ingest_sdmx",
|
|
11
|
+
"ingest_sdmx_registry",
|
|
12
|
+
"prepare_sdmx",
|
|
13
|
+
]
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
"""Generic CKAN source connector for statcatlib.
|
|
2
|
+
|
|
3
|
+
Ingests datasets from any CKAN portal (open.canada.ca, data.gov, etc.)
|
|
4
|
+
into the catalog as OpenDataset objects.
|
|
5
|
+
|
|
6
|
+
The open.canada.ca portal stores many metadata fields as JSON-encoded
|
|
7
|
+
strings rather than native JSON objects. The helpers here handle both
|
|
8
|
+
representations defensively.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import TYPE_CHECKING, Self
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from sdmxlib import InternationalString
|
|
20
|
+
|
|
21
|
+
from statcatlib.model.agents import Agent
|
|
22
|
+
from statcatlib.model.dataset import OpenDataset
|
|
23
|
+
from statcatlib.model.distribution import Distribution
|
|
24
|
+
from statcatlib.model.licence import LicenceDocument
|
|
25
|
+
from statcatlib.model.skos import ConceptRef
|
|
26
|
+
from statcatlib.model.spatial import SpatialCoverage
|
|
27
|
+
from statcatlib.model.temporal import Frequency, TemporalCoverage
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from statcatlib.store.store import Catalog
|
|
31
|
+
|
|
32
|
+
__all__ = ["CkanClient", "ingest_ckan"]
|
|
33
|
+
|
|
34
|
+
# Frequency label (lowercase English) → normalized catalog ID
|
|
35
|
+
_FREQ_LABEL_TO_ID: dict[str, str] = {
|
|
36
|
+
"daily": "DAILY",
|
|
37
|
+
"weekly": "WEEKLY",
|
|
38
|
+
"biweekly": "BIWEEKLY",
|
|
39
|
+
"bi-weekly": "BIWEEKLY",
|
|
40
|
+
"monthly": "MONTHLY",
|
|
41
|
+
"quarterly": "QUARTERLY",
|
|
42
|
+
"semi-annual": "SEMIANNUAL",
|
|
43
|
+
"semi-annually": "SEMIANNUAL",
|
|
44
|
+
"annual": "ANNUAL",
|
|
45
|
+
"annually": "ANNUAL",
|
|
46
|
+
"occasional": "OCCASIONAL",
|
|
47
|
+
"as needed": "IRREGULAR",
|
|
48
|
+
"irregular": "IRREGULAR",
|
|
49
|
+
"not planned": "NOT_PLANNED",
|
|
50
|
+
"unknown": "UNKNOWN",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_ROWS_PER_PAGE = 100
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CkanClient:
|
|
57
|
+
"""HTTP client for CKAN portals.
|
|
58
|
+
|
|
59
|
+
Uses the CKAN Action API v3 (``/api/3/action/``). Handles pagination
|
|
60
|
+
internally — callers see a flat list up to their requested limit.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, base_url: str, *, timeout: float = 30.0) -> None:
|
|
64
|
+
self._base = base_url.rstrip("/")
|
|
65
|
+
self._client = httpx.Client(timeout=timeout)
|
|
66
|
+
|
|
67
|
+
def package_search(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
organization: str | None = None,
|
|
71
|
+
subject: str | None = None,
|
|
72
|
+
rows: int = 100,
|
|
73
|
+
start: int = 0,
|
|
74
|
+
) -> list[dict[str, object]]:
|
|
75
|
+
"""Search for packages (datasets) and return raw CKAN package dicts.
|
|
76
|
+
|
|
77
|
+
Paginates automatically until ``rows`` results are collected or
|
|
78
|
+
the portal has no more results.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
organization: Filter by CKAN organization name slug.
|
|
82
|
+
subject: Additional free-text filter appended to the query.
|
|
83
|
+
rows: Maximum total results to return.
|
|
84
|
+
start: Offset into the result set (for external pagination).
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The raw ``result.results`` list from CKAN's ``package_search``
|
|
88
|
+
endpoint — package dicts, unmodelled.
|
|
89
|
+
"""
|
|
90
|
+
parts: list[str] = []
|
|
91
|
+
if organization:
|
|
92
|
+
parts.append(f"organization:{organization}")
|
|
93
|
+
if subject:
|
|
94
|
+
parts.append(subject)
|
|
95
|
+
q = " ".join(parts) if parts else "*:*"
|
|
96
|
+
|
|
97
|
+
results: list[dict[str, object]] = []
|
|
98
|
+
offset = start
|
|
99
|
+
remaining = rows
|
|
100
|
+
|
|
101
|
+
while remaining > 0:
|
|
102
|
+
page_size = min(_ROWS_PER_PAGE, remaining)
|
|
103
|
+
page = self._get("package_search", {"q": q, "rows": page_size, "start": offset})
|
|
104
|
+
if not isinstance(page, Mapping):
|
|
105
|
+
break
|
|
106
|
+
batch = [pkg for pkg in _seq(page.get("results")) if isinstance(pkg, dict)]
|
|
107
|
+
if not batch:
|
|
108
|
+
break
|
|
109
|
+
results.extend(batch)
|
|
110
|
+
remaining -= len(batch)
|
|
111
|
+
offset += len(batch)
|
|
112
|
+
# Stop if we've exhausted the portal's result set
|
|
113
|
+
if len(results) + start >= (_i(page.get("count")) or 0):
|
|
114
|
+
break
|
|
115
|
+
|
|
116
|
+
return results
|
|
117
|
+
|
|
118
|
+
def _get(self, action: str, params: dict[str, str | int]) -> object:
|
|
119
|
+
url = f"{self._base}/api/3/action/{action}"
|
|
120
|
+
r = self._client.get(url, params=params)
|
|
121
|
+
r.raise_for_status()
|
|
122
|
+
# `r.json()` is `Any`; annotating the receiving local is what keeps it
|
|
123
|
+
# from riding out through the return.
|
|
124
|
+
body: object = r.json()
|
|
125
|
+
if not isinstance(body, Mapping):
|
|
126
|
+
msg = f"CKAN API returned {type(body).__name__} for {action!r}, expected an object"
|
|
127
|
+
raise TypeError(msg)
|
|
128
|
+
if not body.get("success"):
|
|
129
|
+
msg = f"CKAN API error for {action!r}: {body.get('error', body)}"
|
|
130
|
+
raise RuntimeError(msg)
|
|
131
|
+
return body.get("result")
|
|
132
|
+
|
|
133
|
+
def close(self) -> None:
|
|
134
|
+
"""Close the underlying HTTP client."""
|
|
135
|
+
self._client.close()
|
|
136
|
+
|
|
137
|
+
def __enter__(self) -> Self:
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def __exit__(self, *_: object) -> None:
|
|
141
|
+
self.close()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Internal mapper helpers
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
# ── Narrowing accessors for the untyped shell ─────────────────────────────
|
|
149
|
+
#
|
|
150
|
+
# CKAN's payloads are `object` at this boundary, deliberately: a raw JSON dict
|
|
151
|
+
# typed `Any` would hand an unchecked value to every mapper below and out into
|
|
152
|
+
# the model. These narrow instead — "narrow, don't cast" — so a portal that
|
|
153
|
+
# returns a number where a string is expected produces `None` here rather than
|
|
154
|
+
# a `TypeError` three frames away.
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _s(d: Mapping[str, object], key: str) -> str | None:
|
|
158
|
+
"""The `str` at *key*, or ``None`` when absent, empty, or another type."""
|
|
159
|
+
v = d.get(key)
|
|
160
|
+
return v if isinstance(v, str) and v else None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _i(v: object) -> int | None:
|
|
164
|
+
"""*v* as an `int`, or ``None`` when it is neither an int nor an int-ish str."""
|
|
165
|
+
if isinstance(v, bool): # bool is an int; a flag is not a size
|
|
166
|
+
return None
|
|
167
|
+
if isinstance(v, int):
|
|
168
|
+
return v
|
|
169
|
+
if isinstance(v, str):
|
|
170
|
+
try:
|
|
171
|
+
return int(v)
|
|
172
|
+
except ValueError:
|
|
173
|
+
return None
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _d(d: Mapping[str, object], key: str) -> Mapping[str, object]:
|
|
178
|
+
"""The nested mapping at *key*, or an empty one."""
|
|
179
|
+
v = d.get(key)
|
|
180
|
+
return v if isinstance(v, Mapping) else {}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _seq(v: object) -> list[object]:
|
|
184
|
+
"""*v* as a list, or empty. A `str` is not a sequence of items here."""
|
|
185
|
+
return list(v) if isinstance(v, list) else []
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _parse_istring(
|
|
189
|
+
raw: object,
|
|
190
|
+
*,
|
|
191
|
+
en_key: str = "en",
|
|
192
|
+
fr_key: str = "fr",
|
|
193
|
+
fallback_en: str = "",
|
|
194
|
+
) -> InternationalString:
|
|
195
|
+
"""Parse a bilingual field that may be a dict, a JSON-encoded string, or a plain string."""
|
|
196
|
+
if isinstance(raw, dict):
|
|
197
|
+
kw: dict[str, str] = {"en": raw.get(en_key) or fallback_en}
|
|
198
|
+
if fr_val := raw.get(fr_key):
|
|
199
|
+
kw["fr"] = fr_val
|
|
200
|
+
return InternationalString(**kw)
|
|
201
|
+
if isinstance(raw, str):
|
|
202
|
+
try:
|
|
203
|
+
parsed = json.loads(raw)
|
|
204
|
+
if isinstance(parsed, dict):
|
|
205
|
+
kw2: dict[str, str] = {"en": parsed.get(en_key) or fallback_en}
|
|
206
|
+
if fr_val2 := parsed.get(fr_key):
|
|
207
|
+
kw2["fr"] = fr_val2
|
|
208
|
+
return InternationalString(**kw2)
|
|
209
|
+
except (json.JSONDecodeError, ValueError):
|
|
210
|
+
pass
|
|
211
|
+
return InternationalString(en=raw or fallback_en)
|
|
212
|
+
return InternationalString(en=fallback_en)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _parse_dt(raw: object) -> datetime | None:
|
|
216
|
+
"""Parse an ISO 8601 date/datetime string, returning None on failure."""
|
|
217
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
218
|
+
return None
|
|
219
|
+
s = raw.strip()
|
|
220
|
+
# Try common formats
|
|
221
|
+
for fmt in ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
|
|
222
|
+
try:
|
|
223
|
+
return datetime.strptime(s, fmt) # noqa: DTZ007
|
|
224
|
+
except ValueError:
|
|
225
|
+
continue
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _parse_keywords(raw: object) -> list[InternationalString]:
|
|
230
|
+
"""Parse CKAN keywords field: {'en': [...], 'fr': [...]} or JSON-encoded."""
|
|
231
|
+
if isinstance(raw, str):
|
|
232
|
+
try:
|
|
233
|
+
raw = json.loads(raw)
|
|
234
|
+
except (json.JSONDecodeError, ValueError):
|
|
235
|
+
return []
|
|
236
|
+
if not isinstance(raw, dict):
|
|
237
|
+
return []
|
|
238
|
+
en_words: list[str] = raw.get("en") or []
|
|
239
|
+
fr_words: list[str] = raw.get("fr") or []
|
|
240
|
+
# Zip together paired en/fr keywords; extras get en-only entries
|
|
241
|
+
result: list[InternationalString] = []
|
|
242
|
+
for i, en_kw in enumerate(en_words):
|
|
243
|
+
kw_args: dict[str, str] = {"en": en_kw}
|
|
244
|
+
if i < len(fr_words):
|
|
245
|
+
kw_args["fr"] = fr_words[i]
|
|
246
|
+
result.append(InternationalString(**kw_args))
|
|
247
|
+
result.extend(InternationalString(fr=fr_kw) for fr_kw in fr_words[len(en_words) :])
|
|
248
|
+
return result
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _parse_frequency(raw: object) -> Frequency | None:
|
|
252
|
+
"""Parse a CKAN frequency field to a Frequency object."""
|
|
253
|
+
if isinstance(raw, str):
|
|
254
|
+
try:
|
|
255
|
+
parsed = json.loads(raw)
|
|
256
|
+
if isinstance(parsed, dict):
|
|
257
|
+
raw = parsed
|
|
258
|
+
except (json.JSONDecodeError, ValueError):
|
|
259
|
+
pass
|
|
260
|
+
if isinstance(raw, dict):
|
|
261
|
+
en_label = (raw.get("en") or "").strip()
|
|
262
|
+
elif isinstance(raw, str):
|
|
263
|
+
en_label = raw.strip()
|
|
264
|
+
else:
|
|
265
|
+
return None
|
|
266
|
+
if not en_label:
|
|
267
|
+
return None
|
|
268
|
+
freq_id = _FREQ_LABEL_TO_ID.get(en_label.lower(), en_label.upper().replace(" ", "_"))
|
|
269
|
+
freq_kw: dict[str, str] = {"en": en_label}
|
|
270
|
+
if isinstance(raw, dict) and (label_fr := raw.get("fr")):
|
|
271
|
+
freq_kw["fr"] = label_fr
|
|
272
|
+
return Frequency(
|
|
273
|
+
id=freq_id,
|
|
274
|
+
label=InternationalString(**freq_kw),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _parse_subjects(raw: object) -> list[ConceptRef]:
|
|
279
|
+
"""Parse CKAN subject/topic_category field to a list of ConceptRef objects.
|
|
280
|
+
|
|
281
|
+
CKAN tags don't carry vocabulary URIs, so a synthetic ``urn:ckan:tag:{slug}``
|
|
282
|
+
URI is generated for each tag. These are permissive (unvalidated) refs —
|
|
283
|
+
they won't match any stored ConceptScheme unless one is explicitly ingested.
|
|
284
|
+
"""
|
|
285
|
+
if isinstance(raw, str):
|
|
286
|
+
try:
|
|
287
|
+
raw = json.loads(raw)
|
|
288
|
+
except (json.JSONDecodeError, ValueError):
|
|
289
|
+
raw = [raw]
|
|
290
|
+
if not isinstance(raw, list):
|
|
291
|
+
return []
|
|
292
|
+
refs: list[ConceptRef] = []
|
|
293
|
+
for item in raw:
|
|
294
|
+
if isinstance(item, str) and item.strip():
|
|
295
|
+
slug = item.strip().lower().replace(" ", "_")
|
|
296
|
+
refs.append(ConceptRef(uri=f"urn:ckan:tag:{slug}", label=InternationalString(en=item.strip())))
|
|
297
|
+
return refs
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _parse_spatial(raw: object) -> list[SpatialCoverage]:
|
|
301
|
+
"""Parse CKAN geographic_region field to SpatialCoverage objects."""
|
|
302
|
+
if isinstance(raw, str):
|
|
303
|
+
try:
|
|
304
|
+
raw = json.loads(raw)
|
|
305
|
+
except (json.JSONDecodeError, ValueError):
|
|
306
|
+
raw = [raw]
|
|
307
|
+
if not isinstance(raw, list):
|
|
308
|
+
return []
|
|
309
|
+
regions: list[SpatialCoverage] = []
|
|
310
|
+
for item in raw:
|
|
311
|
+
if isinstance(item, str) and item.strip():
|
|
312
|
+
regions.append(SpatialCoverage(label=InternationalString(en=item.strip())))
|
|
313
|
+
elif isinstance(item, dict):
|
|
314
|
+
label = _parse_istring(item) if item else None
|
|
315
|
+
regions.append(SpatialCoverage(label=label))
|
|
316
|
+
return regions
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _map_resource(res: dict[str, object], package_id: str) -> Distribution:
|
|
320
|
+
"""Map a CKAN resource dict to a Distribution."""
|
|
321
|
+
name_raw = res.get("name_translated") or res.get("name") or ""
|
|
322
|
+
title = _parse_istring(name_raw) if name_raw else None
|
|
323
|
+
url = _s(res, "url")
|
|
324
|
+
url_type = _s(res, "url_type") or ""
|
|
325
|
+
# download_url for uploaded files; access_url for links
|
|
326
|
+
download_url = url if url_type in ("upload", "download") else None
|
|
327
|
+
access_url = url if not download_url else None
|
|
328
|
+
return Distribution(
|
|
329
|
+
id=_s(res, "id") or f"{package_id}/{_s(res, 'name') or 'resource'}",
|
|
330
|
+
title=title,
|
|
331
|
+
access_url=access_url,
|
|
332
|
+
download_url=download_url,
|
|
333
|
+
media_type=_s(res, "mimetype"),
|
|
334
|
+
format=_s(res, "format"),
|
|
335
|
+
byte_size=_i(res.get("size")),
|
|
336
|
+
issued=_parse_dt(res.get("created")),
|
|
337
|
+
modified=_parse_dt(res.get("last_modified") or res.get("metadata_modified")),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _ckan_package_to_dataset(raw: dict[str, object]) -> OpenDataset:
|
|
342
|
+
"""Map a raw CKAN package dict to a statcatlib OpenDataset.
|
|
343
|
+
|
|
344
|
+
This is a pure function: no network calls, no store access.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
raw: One package dict as CKAN's ``package_search`` returned it.
|
|
348
|
+
Values are `object`; every field is narrowed before use.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
The mapped `OpenDataset`.
|
|
352
|
+
|
|
353
|
+
Raises:
|
|
354
|
+
ValueError: The package carries no string ``id``. Every other field
|
|
355
|
+
degrades to a default; the identifier cannot, since it is the
|
|
356
|
+
catalog key.
|
|
357
|
+
"""
|
|
358
|
+
pkg_id = _s(raw, "id")
|
|
359
|
+
if pkg_id is None:
|
|
360
|
+
msg = f"CKAN package has no string 'id': {sorted(raw)[:8]}"
|
|
361
|
+
raise ValueError(msg)
|
|
362
|
+
|
|
363
|
+
# Title and description — prefer bilingual translated fields
|
|
364
|
+
title = _parse_istring(
|
|
365
|
+
raw.get("title_translated") or raw.get("title") or pkg_id,
|
|
366
|
+
fallback_en=pkg_id,
|
|
367
|
+
)
|
|
368
|
+
description = _parse_istring(
|
|
369
|
+
raw.get("notes_translated") or raw.get("notes") or "",
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# Publisher from CKAN organization
|
|
373
|
+
org = _d(raw, "organization")
|
|
374
|
+
publisher: Agent | None = None
|
|
375
|
+
if org:
|
|
376
|
+
org_name = _s(org, "name") or _s(org, "id") or ""
|
|
377
|
+
org_title_raw = org.get("title") or org.get("name") or org_name
|
|
378
|
+
org_desc_raw = org.get("description") or None
|
|
379
|
+
pub_name = _parse_istring(org_title_raw, fallback_en=org_name)
|
|
380
|
+
# Some CKAN portals store org title/description as bilingual JSON strings
|
|
381
|
+
if org_desc_raw:
|
|
382
|
+
desc_parsed = _parse_istring(org_desc_raw)
|
|
383
|
+
if fr_name := desc_parsed.get("fr"):
|
|
384
|
+
pub_name = InternationalString(
|
|
385
|
+
en=pub_name.get("en") or org_name,
|
|
386
|
+
fr=fr_name,
|
|
387
|
+
)
|
|
388
|
+
publisher = Agent(id=org_name or None, name=pub_name)
|
|
389
|
+
|
|
390
|
+
# Temporal
|
|
391
|
+
issued = _parse_dt(raw.get("metadata_created") or raw.get("date_published"))
|
|
392
|
+
modified = _parse_dt(raw.get("metadata_modified") or raw.get("date_modified"))
|
|
393
|
+
|
|
394
|
+
tc_start = _parse_dt(raw.get("time_period_coverage_start"))
|
|
395
|
+
tc_end = _parse_dt(raw.get("time_period_coverage_end"))
|
|
396
|
+
temporal_coverage = TemporalCoverage(start=tc_start, end=tc_end) if (tc_start or tc_end) else None
|
|
397
|
+
|
|
398
|
+
# Frequency
|
|
399
|
+
frequency = _parse_frequency(raw.get("frequency"))
|
|
400
|
+
|
|
401
|
+
# Themes from subject
|
|
402
|
+
themes = _parse_subjects(raw.get("subject") or raw.get("topic_category"))
|
|
403
|
+
|
|
404
|
+
# Spatial
|
|
405
|
+
spatial_coverage = _parse_spatial(raw.get("geographic_region"))
|
|
406
|
+
|
|
407
|
+
# Keywords
|
|
408
|
+
keywords = _parse_keywords(raw.get("keywords"))
|
|
409
|
+
|
|
410
|
+
# Distributions from resources
|
|
411
|
+
distributions = [_map_resource(res, pkg_id) for res in _seq(raw.get("resources")) if isinstance(res, dict)]
|
|
412
|
+
|
|
413
|
+
# Licence
|
|
414
|
+
licence: LicenceDocument | None = None
|
|
415
|
+
lic_id = _s(raw, "license_id") or ""
|
|
416
|
+
lic_title = _s(raw, "license_title") or lic_id
|
|
417
|
+
lic_url = _s(raw, "license_url")
|
|
418
|
+
if lic_id or lic_title:
|
|
419
|
+
licence = LicenceDocument(
|
|
420
|
+
id=lic_id or lic_title,
|
|
421
|
+
label=InternationalString(en=lic_title) if lic_title else None,
|
|
422
|
+
uri=lic_url,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
# Language
|
|
426
|
+
lang_raw: object = raw.get("language") or []
|
|
427
|
+
if isinstance(lang_raw, str):
|
|
428
|
+
try:
|
|
429
|
+
lang_raw = json.loads(lang_raw)
|
|
430
|
+
except (json.JSONDecodeError, ValueError):
|
|
431
|
+
lang_raw = [lang_raw]
|
|
432
|
+
language: list[str] = [str(lang) for lang in _seq(lang_raw) if lang]
|
|
433
|
+
|
|
434
|
+
return OpenDataset(
|
|
435
|
+
identifier=pkg_id,
|
|
436
|
+
title=title,
|
|
437
|
+
description=description,
|
|
438
|
+
publisher=publisher,
|
|
439
|
+
issued=issued,
|
|
440
|
+
modified=modified,
|
|
441
|
+
frequency=frequency,
|
|
442
|
+
themes=themes,
|
|
443
|
+
keywords=keywords,
|
|
444
|
+
spatial_coverage=spatial_coverage,
|
|
445
|
+
temporal_coverage=temporal_coverage,
|
|
446
|
+
distributions=distributions,
|
|
447
|
+
licence=licence,
|
|
448
|
+
language=language,
|
|
449
|
+
landing_page=_s(raw, "url"),
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ---------------------------------------------------------------------------
|
|
454
|
+
# Public ingest function
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def ingest_ckan(
|
|
459
|
+
store: Catalog,
|
|
460
|
+
base_url: str,
|
|
461
|
+
*,
|
|
462
|
+
organization: str | None = None,
|
|
463
|
+
subject: str | None = None,
|
|
464
|
+
limit: int = 100,
|
|
465
|
+
) -> list[OpenDataset]:
|
|
466
|
+
"""Ingest datasets from a CKAN portal into the catalog.
|
|
467
|
+
|
|
468
|
+
Fetches up to ``limit`` packages from the CKAN Action API, maps each
|
|
469
|
+
to a statcatlib ``OpenDataset``, stores them all, and returns the list.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
store: Target Catalog.
|
|
473
|
+
base_url: CKAN portal base URL, e.g. ``"https://open.canada.ca/data"``.
|
|
474
|
+
organization: Optional CKAN organization name slug filter,
|
|
475
|
+
e.g. ``"statcan-statcan"``.
|
|
476
|
+
subject: Optional free-text subject filter appended to the search query.
|
|
477
|
+
limit: Maximum number of datasets to ingest.
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
List of OpenDataset objects ingested.
|
|
481
|
+
"""
|
|
482
|
+
with CkanClient(base_url) as client:
|
|
483
|
+
raw_packages = client.package_search(
|
|
484
|
+
organization=organization,
|
|
485
|
+
subject=subject,
|
|
486
|
+
rows=limit,
|
|
487
|
+
)
|
|
488
|
+
datasets = [_ckan_package_to_dataset(pkg) for pkg in raw_packages]
|
|
489
|
+
store.put(datasets)
|
|
490
|
+
return datasets
|