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.
@@ -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