sdmxlib 0.26.0__tar.gz → 0.27.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/PKG-INFO +1 -1
  2. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/pyproject.toml +1 -1
  3. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/catalog.py +7 -6
  4. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/data_store.py +40 -3
  5. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/local/_data_store.py +224 -13
  6. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/binding.py +93 -5
  7. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/storage/readers.py +3 -1
  8. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/storage/schema.py +1 -0
  9. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/storage/writers.py +16 -3
  10. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/README.md +0 -0
  11. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/__init__.py +0 -0
  12. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/api/__init__.py +0 -0
  13. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/api/client.py +0 -0
  14. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/api/federated.py +0 -0
  15. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/api/filters.py +0 -0
  16. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/api/policy.py +0 -0
  17. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/api/providers.py +0 -0
  18. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/api/query.py +0 -0
  19. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/api/registry.py +0 -0
  20. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/api/session.py +0 -0
  21. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/formats/__init__.py +0 -0
  22. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
  23. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
  24. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/formats/sdmx_csv/writer.py +0 -0
  25. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
  26. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
  27. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
  28. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
  29. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
  30. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
  31. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
  32. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
  33. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
  34. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
  35. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
  36. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
  37. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
  38. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/local/__init__.py +0 -0
  39. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/local/_registry.py +0 -0
  40. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/__init__.py +0 -0
  41. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/annotations.py +0 -0
  42. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/base.py +0 -0
  43. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/category.py +0 -0
  44. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/code_query.py +0 -0
  45. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/codelist.py +0 -0
  46. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/collections.py +0 -0
  47. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/concept.py +0 -0
  48. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/constraint.py +0 -0
  49. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/convert.py +0 -0
  50. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/dataflow.py +0 -0
  51. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/dataset.py +0 -0
  52. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/datastructure.py +0 -0
  53. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/expr.py +0 -0
  54. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/hierarchy.py +0 -0
  55. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/hierarchy_query.py +0 -0
  56. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/in_memory_registry.py +0 -0
  57. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/istring.py +0 -0
  58. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/mapping.py +0 -0
  59. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/message.py +0 -0
  60. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/metadataflow.py +0 -0
  61. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/metadataset.py +0 -0
  62. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/metadatastructure.py +0 -0
  63. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/organisation.py +0 -0
  64. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/provision.py +0 -0
  65. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/ref.py +0 -0
  66. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/registry.py +0 -0
  67. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/representation.py +0 -0
  68. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/urn.py +0 -0
  69. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/model/validation.py +0 -0
  70. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/polars.py +0 -0
  71. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/py.typed +0 -0
  72. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/rest.py +0 -0
  73. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/sql.py +0 -0
  74. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/storage/__init__.py +0 -0
  75. {sdmxlib-0.26.0 → sdmxlib-0.27.0}/src/sdmxlib/storage/lazy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sdmxlib
3
- Version: 0.26.0
3
+ Version: 0.27.0
4
4
  Summary: SDMX structural metadata library for Python
5
5
  Keywords: sdmx,statistics,metadata,datastructure
6
6
  Author: gabrielgellner
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sdmxlib"
3
- version = "0.26.0"
3
+ version = "0.27.0"
4
4
  description = "SDMX structural metadata library for Python"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
@@ -21,9 +21,9 @@ Two construction paths:
21
21
  Standard layout::
22
22
 
23
23
  <bundle>/
24
- registry.duckdb # sdmx.* schema
24
+ meta.duckdb # sdmx.* + pinax.* schemas
25
25
  lake.ducklake # DuckLake catalog
26
- parquet/ # DuckLake-managed data
26
+ parquet/ # DuckLake-managed data (DATA_PATH)
27
27
 
28
28
  2. ``FederatedCatalog.from_parts(structural=, bindings=, data=)`` —
29
29
  composition. For federated topologies (remote structural registry +
@@ -136,21 +136,22 @@ class FederatedCatalog:
136
136
  def open(self) -> Self:
137
137
  """Open the bundle's Registry + DuckDBDataStore against the standard layout.
138
138
 
139
- Looks for ``<uri>/registry.duckdb`` (sdmx artefacts) and
139
+ Looks for ``<uri>/meta.duckdb`` (sdmx + pinax artefacts) and
140
140
  ``<uri>/lake.ducklake`` + ``<uri>/parquet/`` (DuckLake-managed
141
141
  observations). The Registry serves both ``structural`` and
142
142
  ``bindings`` roles. Idempotent within a single open/close cycle.
143
143
  """
144
144
  if self._structural is not None:
145
145
  return self
146
- registry_path = self._uri / "registry.duckdb"
146
+ registry_path = self._uri / "meta.duckdb"
147
147
  lake_path = self._uri / "lake.ducklake"
148
148
  parquet_path = self._uri / "parquet"
149
149
  registry = Registry(registry_path).open()
150
- attach_spec = f"ducklake:'{lake_path}' (DATA_PATH '{parquet_path}/')"
150
+ attach_url = f"'ducklake:{lake_path}'"
151
+ attach_options = f"(DATA_PATH '{parquet_path}/')"
151
152
  data = DuckDBDataStore.from_registry(
152
153
  registry,
153
- attach={"lake": attach_spec},
154
+ attach={"lake": (attach_url, attach_options)},
154
155
  extensions=["ducklake"],
155
156
  )
156
157
  self._owned_registry = registry
@@ -19,20 +19,24 @@ structural side.
19
19
  from __future__ import annotations
20
20
 
21
21
  from collections.abc import Iterator
22
- from typing import TYPE_CHECKING, Protocol, runtime_checkable
22
+ from typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable
23
23
 
24
24
  import polars as pl
25
25
 
26
26
  from sdmxlib.model.binding import DataflowBinding
27
27
 
28
+ if TYPE_CHECKING:
29
+ import duckdb
30
+ import pyarrow as pa
31
+
32
+ WriteSource = pl.DataFrame | pl.LazyFrame | pa.Table | duckdb.DuckDBPyRelation
33
+
28
34
  # SqlRelation lives in sdmxlib.sql now (suite-level public contract);
29
35
  # re-exported here for backwards compatibility with code that imports
30
36
  # ``from sdmxlib.data_store import SqlRelation``.
31
37
  from sdmxlib.sql import SqlRelation as SqlRelation # noqa: PLC0414 — public re-export
32
38
 
33
39
  if TYPE_CHECKING:
34
- import pyarrow as pa
35
-
36
40
  from sdmxlib.rest import ComponentFilter
37
41
 
38
42
 
@@ -115,3 +119,36 @@ class DataStore(Protocol):
115
119
  One :class:`pyarrow.RecordBatch` at a time, in scan order.
116
120
  """
117
121
  ...
122
+
123
+ def write_observations(
124
+ self,
125
+ *,
126
+ schema: str,
127
+ table: str,
128
+ source: WriteSource,
129
+ partition_by: tuple[str, ...] = (),
130
+ mode: Literal["overwrite", "append"] = "overwrite",
131
+ lake_alias: str = "lake",
132
+ ) -> None:
133
+ """Materialise observation rows into a backend-managed table.
134
+
135
+ Implementations route the write to whatever physical storage they
136
+ manage (DuckLake, Iceberg, Postgres, raw Parquet). The caller
137
+ side is uniform: schema + table + a source that can be turned
138
+ into rows.
139
+
140
+ Args:
141
+ schema: Target schema.
142
+ table: Target table name.
143
+ source: Rows to write. Accepted shapes vary by implementation
144
+ but Polars frames and Arrow tables are the portable
145
+ lowest common denominator.
146
+ partition_by: SDMX dimension ids the table is partitioned on.
147
+ Recorded on the matching :class:`DataflowBinding`.
148
+ mode: ``"overwrite"`` replaces atomically; ``"append"``
149
+ requires the table to already exist.
150
+ lake_alias: For ATTACH-based backends, the alias of the
151
+ target catalog. Implementations that don't use ATTACH
152
+ may ignore this argument.
153
+ """
154
+ ...
@@ -17,7 +17,7 @@ from __future__ import annotations
17
17
 
18
18
  from collections.abc import Iterator
19
19
  from pathlib import Path
20
- from typing import TYPE_CHECKING, Self
20
+ from typing import TYPE_CHECKING, Literal, Self
21
21
 
22
22
  import duckdb
23
23
  import polars as pl
@@ -30,6 +30,11 @@ if TYPE_CHECKING:
30
30
  from sdmxlib.local._registry import Registry
31
31
  from sdmxlib.rest import ComponentFilter
32
32
 
33
+ # Sources that ``write_observations`` accepts. Type-checking only:
34
+ # the runtime path branches on isinstance(), so a runtime alias isn't
35
+ # needed (and pyarrow is an optional dependency).
36
+ WriteSource = pl.DataFrame | pl.LazyFrame | pa.Table | duckdb.DuckDBPyRelation
37
+
33
38
 
34
39
  class DuckDBDataStore:
35
40
  """A :class:`~sdmxlib.data_store.DataStore` backed by DuckDB.
@@ -45,10 +50,27 @@ class DuckDBDataStore:
45
50
  Lazy codelists keep working across queries, and queries can join
46
51
  across the two without cross-connection plumbing.
47
52
 
48
- The ``attach`` / ``extensions`` knobs configure the DuckDB session
49
- at open time. ``attach`` maps ``alias → ATTACH spec`` (each executed
50
- as ``ATTACH <spec> AS <alias>``); ``extensions`` INSTALL+LOAD the
51
- listed extensions before any ATTACH.
53
+ The ``attach`` / ``extensions`` / ``azure_credentials`` knobs
54
+ configure the DuckDB session at open time. ``attach`` maps
55
+ ``alias ATTACH spec`` (each executed as
56
+ ``ATTACH <spec> AS <alias>``); ``extensions`` INSTALL+LOAD the
57
+ listed extensions before any ATTACH. ``azure_credentials`` is a
58
+ dict that drives a DuckDB ``CREATE SECRET`` for ADLSv2 access — see
59
+ below.
60
+
61
+ Azure credential dicts accept one of three shapes:
62
+
63
+ * ``{"connection_string": "..."}`` → ``PROVIDER 'config'`` with
64
+ ``CONNECTION_STRING``.
65
+ * ``{"account_name": ..., "tenant_id": ..., "client_id": ...,
66
+ "client_secret": ...}`` → ``PROVIDER 'service_principal'``.
67
+ * ``{"chain": "cli"}`` (or ``"env"``, ``"managed_identity"``) →
68
+ ``PROVIDER 'credential_chain'`` with the named chain.
69
+
70
+ When ``azure_credentials`` is set, or any ATTACH spec contains
71
+ ``abfss://``, the ``azure`` extension is auto-INSTALL+LOADed so
72
+ DATA_PATH URIs like ``abfss://acct.dfs.core.windows.net/c/p/`` work
73
+ out of the box.
52
74
 
53
75
  Example::
54
76
 
@@ -65,12 +87,14 @@ class DuckDBDataStore:
65
87
  self,
66
88
  uri: str | Path,
67
89
  *,
68
- attach: dict[str, str] | None = None,
90
+ attach: dict[str, str | tuple[str, str]] | None = None,
69
91
  extensions: list[str] | None = None,
92
+ azure_credentials: dict[str, str] | None = None,
70
93
  ) -> None:
71
94
  self._uri: Path = Path(uri) if isinstance(uri, str) else uri
72
95
  self._attach = attach
73
96
  self._extensions = extensions
97
+ self._azure_credentials = azure_credentials
74
98
  self._registry: Registry | None = None
75
99
  self._conn: duckdb.DuckDBPyConnection | None = None
76
100
  self._owns_connection = True
@@ -122,8 +146,9 @@ class DuckDBDataStore:
122
146
  cls,
123
147
  registry: Registry,
124
148
  *,
125
- attach: dict[str, str] | None = None,
149
+ attach: dict[str, str | tuple[str, str]] | None = None,
126
150
  extensions: list[str] | None = None,
151
+ azure_credentials: dict[str, str] | None = None,
127
152
  ) -> Self:
128
153
  """Wrap a :class:`Registry`'s open connection.
129
154
 
@@ -135,11 +160,14 @@ class DuckDBDataStore:
135
160
  registry: An open :class:`Registry`. Its connection is reused.
136
161
  attach: ``alias → ATTACH spec`` mapping. Executed at open time.
137
162
  extensions: DuckDB extensions to INSTALL+LOAD before any ATTACH.
163
+ azure_credentials: Optional Azure secret config. See
164
+ :meth:`__init__` for accepted keys.
138
165
  """
139
166
  instance = cls.__new__(cls)
140
167
  instance._uri = registry.uri # noqa: SLF001 — alternate constructor sets private state
141
168
  instance._attach = attach # noqa: SLF001
142
169
  instance._extensions = extensions # noqa: SLF001
170
+ instance._azure_credentials = azure_credentials # noqa: SLF001
143
171
  instance._registry = registry # noqa: SLF001
144
172
  instance._conn = registry.connection # noqa: SLF001
145
173
  instance._owns_connection = False # noqa: SLF001
@@ -150,18 +178,42 @@ class DuckDBDataStore:
150
178
  """Apply the configured extensions + ATTACH specs to the live connection."""
151
179
  if self._conn is None:
152
180
  return
153
- if self._extensions:
154
- for ext in self._extensions:
181
+ extensions = list(self._extensions or [])
182
+ # Auto-load the azure extension if we need to talk to abfss:// or
183
+ # have credentials configured. Callers can override by listing
184
+ # "azure" in extensions themselves — INSTALL/LOAD are idempotent.
185
+ if self._needs_azure() and "azure" not in extensions:
186
+ extensions.append("azure")
187
+ if extensions:
188
+ for ext in extensions:
155
189
  self._conn.execute(f"INSTALL {_sql_identifier(ext)}")
156
190
  self._conn.execute(f"LOAD {_sql_identifier(ext)}")
157
191
  # Loading extensions can change the search_path; reset to
158
192
  # ``sdmx`` so any colocated Registry's internal queries stay clean.
159
193
  self._conn.execute("SET search_path = 'sdmx'")
194
+ if self._azure_credentials:
195
+ sql = _azure_create_secret_sql(self._azure_credentials)
196
+ self._conn.execute(sql)
160
197
  if self._attach:
161
198
  for alias, spec in self._attach.items():
162
- self._conn.execute(f"ATTACH {spec} AS {_sql_identifier(alias)}")
199
+ url, options = _split_attach_spec(spec)
200
+ sql = f"ATTACH {url} AS {_sql_identifier(alias)}"
201
+ if options:
202
+ sql += f" {options}"
203
+ self._conn.execute(sql)
163
204
  self._conn.execute("SET search_path = 'sdmx'")
164
205
 
206
+ def _needs_azure(self) -> bool:
207
+ """True if Azure secret/extension setup is required for this session."""
208
+ if self._azure_credentials:
209
+ return True
210
+ if self._attach:
211
+ for spec in self._attach.values():
212
+ spec_text = spec if isinstance(spec, str) else " ".join(s for s in spec if s)
213
+ if "abfss://" in spec_text:
214
+ return True
215
+ return False
216
+
165
217
  def __enter__(self) -> Self:
166
218
  if self._conn is None:
167
219
  self.open()
@@ -222,6 +274,69 @@ class DuckDBDataStore:
222
274
  return pl.DataFrame(schema=col_names)
223
275
  return pl.from_records(rows, schema=col_names, orient="row")
224
276
 
277
+ def write_observations(
278
+ self,
279
+ *,
280
+ schema: str,
281
+ table: str,
282
+ source: WriteSource,
283
+ partition_by: tuple[str, ...] = (),
284
+ mode: Literal["overwrite", "append"] = "overwrite",
285
+ lake_alias: str = "lake",
286
+ ) -> None:
287
+ """Materialise observation rows into a DuckLake-managed table.
288
+
289
+ Routes through the ATTACHed lake (``lake_alias``, default ``lake``).
290
+ Schema is created if missing. ``mode="overwrite"`` uses
291
+ ``CREATE OR REPLACE TABLE``; ``mode="append"`` uses ``INSERT INTO``
292
+ and requires the target table to already exist with a compatible
293
+ schema. ``partition_by`` is currently informational — DuckLake
294
+ infers partition columns from table definition; we record the
295
+ layout on the matching :class:`DataflowBinding`.
296
+
297
+ Args:
298
+ schema: Target schema inside the lake (created if absent).
299
+ table: Target table name. Together with ``schema`` and
300
+ ``lake_alias`` this resolves to
301
+ ``<lake_alias>.<schema>.<table>``.
302
+ source: Rows to write. Anything DuckDB can register as a
303
+ virtual relation — Polars frames, Arrow tables, or an
304
+ existing DuckDB relation.
305
+ partition_by: SDMX dimension ids the table is partitioned on.
306
+ mode: ``"overwrite"`` replaces the table atomically;
307
+ ``"append"`` inserts into an existing table.
308
+ lake_alias: ATTACH alias of the target lake. Defaults to
309
+ ``"lake"`` to match the conventional bundle layout.
310
+ """
311
+ if not _is_valid_identifier(schema):
312
+ msg = f"Invalid schema identifier: {schema!r}"
313
+ raise ValueError(msg)
314
+ if not _is_valid_identifier(table):
315
+ msg = f"Invalid table identifier: {table!r}"
316
+ raise ValueError(msg)
317
+ if not _is_valid_identifier(lake_alias):
318
+ msg = f"Invalid lake alias: {lake_alias!r}"
319
+ raise ValueError(msg)
320
+
321
+ conn = self.connection
322
+ # Register the source as a temporary view the CTAS can scan.
323
+ view_name = f"__sdmxlib_write_obs_{id(source):x}"
324
+ rel = _as_relation(conn, source)
325
+ rel.create_view(view_name, replace=True)
326
+ try:
327
+ target = f"{lake_alias}.{schema}.{table}"
328
+ conn.execute(f"CREATE SCHEMA IF NOT EXISTS {lake_alias}.{schema}")
329
+ if mode == "overwrite":
330
+ conn.execute(f"CREATE OR REPLACE TABLE {target} AS SELECT * FROM {view_name}")
331
+ elif mode == "append":
332
+ conn.execute(f"INSERT INTO {target} SELECT * FROM {view_name}")
333
+ else:
334
+ msg = f"Unknown mode: {mode!r} (expected 'overwrite' or 'append')"
335
+ raise ValueError(msg)
336
+ finally:
337
+ conn.execute(f"DROP VIEW IF EXISTS {view_name}")
338
+ _ = partition_by # silence unused — see docstring
339
+
225
340
  def query_stream(
226
341
  self,
227
342
  binding: DataflowBinding,
@@ -282,8 +397,9 @@ def _build_query(
282
397
  attribute column maps and ANDed in.
283
398
  """
284
399
  cols_sql = ", ".join(columns) if columns else "*"
285
- main = binding.table_location
286
- sidecar = binding.series_attributes_location
400
+ main = f"{binding.database_role}.{binding.table_location}"
401
+ sidecar_loc = binding.series_attributes_location
402
+ sidecar = f"{binding.database_role}.{sidecar_loc}" if sidecar_loc is not None else None
287
403
  if sidecar is None:
288
404
  sql = f"SELECT {cols_sql} FROM {main}"
289
405
  else:
@@ -335,7 +451,102 @@ def _sql_identifier(name: str) -> str:
335
451
  and underscores. Reject anything else to avoid SQL injection through
336
452
  extension/alias names.
337
453
  """
338
- if not name or not all(c.isalnum() or c == "_" for c in name):
454
+ if not _is_valid_identifier(name):
339
455
  msg = f"Invalid SQL identifier: {name!r}"
340
456
  raise ValueError(msg)
341
457
  return name
458
+
459
+
460
+ def _is_valid_identifier(name: str) -> bool:
461
+ """True if ``name`` is a safe SQL identifier (alphanumeric + underscore)."""
462
+ return bool(name) and all(c.isalnum() or c == "_" for c in name)
463
+
464
+
465
+ def _split_attach_spec(spec: object) -> tuple[str, str]:
466
+ """Normalise an ATTACH spec into ``(url, options)``.
467
+
468
+ Two shapes accepted:
469
+
470
+ - ``"'ducklake:/path'"`` — connection URL only; no options.
471
+ - ``("'ducklake:/path'", "(DATA_PATH '/p/')")`` — URL + trailing
472
+ options clause that goes after ``AS alias``.
473
+
474
+ Returns ``(url, options)`` strings; ``options`` is empty when none.
475
+ """
476
+ if isinstance(spec, str):
477
+ return spec, ""
478
+ if isinstance(spec, tuple) and len(spec) == 2:
479
+ url, options = spec
480
+ return str(url), str(options or "")
481
+ msg = f"Unsupported ATTACH spec shape: {type(spec).__name__}"
482
+ raise TypeError(msg)
483
+
484
+
485
+ def _azure_create_secret_sql(creds: dict[str, str]) -> str:
486
+ """Render a DuckDB ``CREATE SECRET`` statement for an Azure credential dict.
487
+
488
+ Three accepted shapes:
489
+
490
+ * ``{"connection_string": "..."}`` → ``PROVIDER 'config'`` with
491
+ ``CONNECTION_STRING``.
492
+ * ``{"account_name": ..., "tenant_id": ..., "client_id": ...,
493
+ "client_secret": ...}`` → ``PROVIDER 'service_principal'``.
494
+ * ``{"chain": "cli"|"env"|"managed_identity"}`` →
495
+ ``PROVIDER 'credential_chain'``.
496
+ """
497
+ name = "sdmxlib_azure"
498
+ if "connection_string" in creds:
499
+ cs = _sql_string_literal(creds["connection_string"])
500
+ return f"CREATE OR REPLACE SECRET {name} (TYPE AZURE, PROVIDER 'config', CONNECTION_STRING {cs})"
501
+ if "chain" in creds:
502
+ chain = _sql_string_literal(creds["chain"])
503
+ clauses = ["TYPE AZURE", "PROVIDER 'credential_chain'", f"CHAIN {chain}"]
504
+ if "account_name" in creds:
505
+ clauses.append(f"ACCOUNT_NAME {_sql_string_literal(creds['account_name'])}")
506
+ return f"CREATE OR REPLACE SECRET {name} ({', '.join(clauses)})"
507
+ required = {"account_name", "tenant_id", "client_id", "client_secret"}
508
+ if required.issubset(creds):
509
+ return (
510
+ f"CREATE OR REPLACE SECRET {name} ("
511
+ f"TYPE AZURE, PROVIDER 'service_principal', "
512
+ f"TENANT_ID {_sql_string_literal(creds['tenant_id'])}, "
513
+ f"CLIENT_ID {_sql_string_literal(creds['client_id'])}, "
514
+ f"CLIENT_SECRET {_sql_string_literal(creds['client_secret'])}, "
515
+ f"ACCOUNT_NAME {_sql_string_literal(creds['account_name'])}"
516
+ f")"
517
+ )
518
+ msg = (
519
+ "azure_credentials must be one of: "
520
+ "{'connection_string': ...}, "
521
+ "{'account_name': ..., 'tenant_id': ..., 'client_id': ..., 'client_secret': ...}, "
522
+ "or {'chain': 'cli'|'env'|'managed_identity'}. "
523
+ f"Got keys: {sorted(creds.keys())}"
524
+ )
525
+ raise ValueError(msg)
526
+
527
+
528
+ def _sql_string_literal(value: str) -> str:
529
+ """Quote a value as a SQL string literal — single-quotes, doubled internals."""
530
+ escaped = value.replace("'", "''")
531
+ return f"'{escaped}'"
532
+
533
+
534
+ def _as_relation(
535
+ conn: duckdb.DuckDBPyConnection,
536
+ source: WriteSource,
537
+ ) -> duckdb.DuckDBPyRelation:
538
+ """Materialise ``source`` as a DuckDB relation the connection can scan.
539
+
540
+ Handles Polars frames (eager + lazy), Arrow tables, and existing
541
+ DuckDB relations uniformly. ``LazyFrame`` is collected to a DataFrame
542
+ first — callers that need streaming behaviour for very large frames
543
+ can pass an Arrow ``RecordBatchReader`` via :func:`pyarrow.Table.from_batches`.
544
+ """
545
+ if isinstance(source, duckdb.DuckDBPyRelation):
546
+ return source
547
+ if isinstance(source, pl.LazyFrame):
548
+ source = source.collect()
549
+ if isinstance(source, pl.DataFrame):
550
+ return conn.from_arrow(source.to_arrow())
551
+ # Last branch: pyarrow.Table. Imported lazily to keep pyarrow optional.
552
+ return conn.from_arrow(source)
@@ -10,10 +10,18 @@ The binding artefact is local-store-owned: a Dataflow may live in a
10
10
  remote registry (FMR, ESTAT, ECB), but the binding to a Parquet/DuckLake
11
11
  table lives in the local registry that knows about the data.
12
12
 
13
+ The bundle architecture splits the physical address of a table into
14
+ ``database_role`` (ATTACH alias of the catalog database) and
15
+ ``table_location`` (``schema.table`` inside that database). The resolver
16
+ re-joins them at query time as ``<database_role>.<table_location>``;
17
+ this keeps bindings portable across bundles that may attach the lake
18
+ under different aliases.
19
+
13
20
  Example:
14
21
  binding = DataflowBinding(
15
22
  dataflow=Ref[Dataflow].unresolved(dataflow_urn),
16
- table_location="lake.census_2021.observations",
23
+ database_role="lake",
24
+ table_location="census_2021.observations",
17
25
  partition_layout=("GEO_LEVEL",),
18
26
  dimension_column_map={"REF_AREA": "REF_AREA", "TIME_PERIOD": "TIME_PERIOD"},
19
27
  measure_column_map={"OBS_COUNT": "OBS_COUNT"},
@@ -26,7 +34,7 @@ Example:
26
34
  local.add(binding)
27
35
  """
28
36
 
29
- from datetime import datetime
37
+ from datetime import UTC, datetime
30
38
  from typing import TYPE_CHECKING, ClassVar, Self
31
39
 
32
40
  from attrs import define, field, frozen
@@ -36,6 +44,7 @@ from sdmxlib.model.ref import Ref
36
44
  from sdmxlib.model.urn import SdmxUrn
37
45
 
38
46
  if TYPE_CHECKING:
47
+ from sdmxlib.model.datastructure import DataStructure
39
48
  from sdmxlib.model.registry import RegistryReader
40
49
 
41
50
 
@@ -45,9 +54,15 @@ class DataflowBinding:
45
54
 
46
55
  Attributes:
47
56
  dataflow: Ref to the bound dataflow (may be remote).
48
- table_location: Backend-specific table identifier for DuckLake
49
- this is ``catalog.schema.table``; for raw Parquet this might
50
- be a path or URI.
57
+ database_role: ATTACH alias of the catalog database that owns the
58
+ table. Defaults to ``"lake"`` to match the conventional
59
+ bundle layout (``meta.duckdb`` + ``lake.ducklake`` attached
60
+ as ``lake``). Combined with :attr:`table_location` at query
61
+ time as ``<database_role>.<table_location>``.
62
+ table_location: ``schema.table`` inside the database identified
63
+ by :attr:`database_role`. Does NOT include the database
64
+ alias; the resolver prepends it. For raw Parquet stores
65
+ without an ATTACH this can be a path or URI.
51
66
  partition_layout: SDMX dimension ids the table is partitioned on,
52
67
  in partition order. Drives partition pruning at query time.
53
68
  dimension_column_map: SDMX dimension id → table column name.
@@ -73,6 +88,7 @@ class DataflowBinding:
73
88
 
74
89
  dataflow: Ref[Dataflow]
75
90
  table_location: str
91
+ database_role: str = "lake"
76
92
  partition_layout: tuple[str, ...] = field(factory=tuple, converter=tuple)
77
93
  dimension_column_map: dict[str, str] = field(factory=dict)
78
94
  measure_column_map: dict[str, str] = field(factory=dict)
@@ -124,6 +140,78 @@ class DataflowBinding:
124
140
  version=version,
125
141
  )
126
142
 
143
+ # ── Convenience constructor ──────────────────────────────────────────
144
+
145
+ @classmethod
146
+ def from_dsd(
147
+ cls,
148
+ dsd: "DataStructure",
149
+ *,
150
+ dataflow: Ref[Dataflow],
151
+ schema: str,
152
+ table: str,
153
+ partition_layout: tuple[str, ...] = (),
154
+ source_registry: str = "local",
155
+ materialized_at: datetime | None = None,
156
+ bound_at: datetime | None = None,
157
+ database_role: str = "lake",
158
+ series_attributes_location: str | None = None,
159
+ ) -> "DataflowBinding":
160
+ """Build a DataflowBinding from a DSD with identity column mapping.
161
+
162
+ Derives ``dimension_column_map`` / ``measure_column_map`` /
163
+ ``attribute_column_map`` from the DSD's components (SDMX id →
164
+ SDMX id), pins ``structural_version`` as
165
+ ``f"{dsd.agency_id}:{dsd.id}({dsd.version})"``, and addresses the
166
+ target table via ``database_role`` + ``schema.table`` (no
167
+ database alias prefix is written into ``table_location``).
168
+
169
+ ``materialized_at`` and ``bound_at`` default to ``datetime.now(UTC)``.
170
+
171
+ Args:
172
+ dsd: The :class:`DataStructure` whose components define the
173
+ column maps.
174
+ dataflow: Ref to the bound dataflow.
175
+ schema: Schema portion of ``table_location``.
176
+ table: Table portion of ``table_location``.
177
+ partition_layout: SDMX dimension ids the table is partitioned
178
+ on, in partition order.
179
+ source_registry: Provenance label for the structural side.
180
+ materialized_at: When the data was last refreshed. Defaults
181
+ to ``datetime.now(UTC)``.
182
+ bound_at: When this binding record was created. Defaults to
183
+ ``datetime.now(UTC)``.
184
+ database_role: ATTACH alias of the database that owns the
185
+ table. Defaults to ``"lake"``.
186
+ series_attributes_location: Optional sidecar table for
187
+ series-level attributes (same shape as
188
+ ``schema.table`` — no database alias prefix).
189
+
190
+ Returns:
191
+ A :class:`DataflowBinding` ready to be added to a registry.
192
+ """
193
+ now = datetime.now(UTC)
194
+ dim_map = {d.id: d.id for d in dsd.dimensions}
195
+ if dsd.time_dimension is not None:
196
+ dim_map[dsd.time_dimension.id] = dsd.time_dimension.id
197
+ measure_map = {m.id: m.id for m in dsd.measures}
198
+ attr_map = {a.id: a.id for a in dsd.attributes}
199
+ structural_version = f"{dsd.agency_id}:{dsd.id}({dsd.version})"
200
+ return cls(
201
+ dataflow=dataflow,
202
+ database_role=database_role,
203
+ table_location=f"{schema}.{table}",
204
+ partition_layout=partition_layout,
205
+ dimension_column_map=dim_map,
206
+ measure_column_map=measure_map,
207
+ attribute_column_map=attr_map,
208
+ structural_version=structural_version,
209
+ source_registry=source_registry,
210
+ materialized_at=materialized_at if materialized_at is not None else now,
211
+ bound_at=bound_at if bound_at is not None else now,
212
+ series_attributes_location=series_attributes_location,
213
+ )
214
+
127
215
 
128
216
  # ── Drift detection ─────────────────────────────────────────────────────
129
217
 
@@ -1176,7 +1176,7 @@ def _read_dataflow_binding(conn: duckdb.DuckDBPyConnection, urn: str) -> Dataflo
1176
1176
 
1177
1177
  row = conn.execute(
1178
1178
  """
1179
- SELECT dataflow_urn, table_location, partition_layout,
1179
+ SELECT dataflow_urn, table_location, database_role, partition_layout,
1180
1180
  dimension_column_map, measure_column_map, attribute_column_map,
1181
1181
  structural_version, binding_source_registry,
1182
1182
  materialized_at, bound_at, series_attributes_location
@@ -1190,6 +1190,7 @@ def _read_dataflow_binding(conn: duckdb.DuckDBPyConnection, urn: str) -> Dataflo
1190
1190
  (
1191
1191
  dataflow_urn_str,
1192
1192
  table_location,
1193
+ database_role,
1193
1194
  partition_layout,
1194
1195
  dim_map,
1195
1196
  meas_map,
@@ -1204,6 +1205,7 @@ def _read_dataflow_binding(conn: duckdb.DuckDBPyConnection, urn: str) -> Dataflo
1204
1205
  return DataflowBinding(
1205
1206
  dataflow=Ref[Dataflow].unresolved(SdmxUrn.parse(dataflow_urn_str)),
1206
1207
  table_location=table_location,
1208
+ database_role=database_role,
1207
1209
  partition_layout=tuple(partition_layout or ()),
1208
1210
  dimension_column_map=dict(dim_map or {}),
1209
1211
  measure_column_map=dict(meas_map or {}),
@@ -361,6 +361,7 @@ CREATE TABLE IF NOT EXISTS dataflow_binding (
361
361
  urn TEXT PRIMARY KEY,
362
362
  dataflow_urn TEXT NOT NULL,
363
363
  table_location TEXT NOT NULL,
364
+ database_role TEXT NOT NULL DEFAULT 'lake',
364
365
  partition_layout TEXT[],
365
366
  dimension_column_map MAP(VARCHAR, VARCHAR),
366
367
  measure_column_map MAP(VARCHAR, VARCHAR),
@@ -296,7 +296,18 @@ def _put_refs(
296
296
  conn: duckdb.DuckDBPyConnection,
297
297
  refs: list[tuple[str, str, str, int | None]],
298
298
  ) -> None:
299
- """Insert artefact_ref rows."""
299
+ """Insert artefact_ref rows.
300
+
301
+ Treats ``(source_urn, target_urn, role)`` as set semantics: a DSD
302
+ that references the same codelist via multiple dimensions yields a
303
+ single dependency edge here. The per-dimension ordinal (position) is
304
+ recoverable from the matching component table
305
+ (``dsd_component.position`` / ``msd_component.position``), so we
306
+ don't lose information by collapsing duplicates. ``ON CONFLICT DO
307
+ NOTHING`` keeps the first ordinal we saw — arbitrary, but ordinal on
308
+ this table is only a hint for callers that want positional iteration
309
+ through the ref graph without a join.
310
+ """
300
311
  if not refs:
301
312
  return
302
313
  for source_urn, target_urn, role, ordinal in refs:
@@ -304,6 +315,7 @@ def _put_refs(
304
315
  """
305
316
  INSERT INTO artefact_ref (source_urn, target_urn, role, ordinal)
306
317
  VALUES (?, ?, ?, ?)
318
+ ON CONFLICT (source_urn, target_urn, role) DO NOTHING
307
319
  """,
308
320
  [source_urn, target_urn, role, ordinal],
309
321
  )
@@ -1390,17 +1402,18 @@ def put_dataflow_binding(
1390
1402
  conn.execute(
1391
1403
  """
1392
1404
  INSERT INTO dataflow_binding (
1393
- urn, dataflow_urn, table_location, partition_layout,
1405
+ urn, dataflow_urn, table_location, database_role, partition_layout,
1394
1406
  dimension_column_map, measure_column_map, attribute_column_map,
1395
1407
  structural_version, binding_source_registry,
1396
1408
  materialized_at, bound_at, series_attributes_location
1397
1409
  )
1398
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1410
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1399
1411
  """,
1400
1412
  [
1401
1413
  urn,
1402
1414
  str(binding.dataflow.urn),
1403
1415
  binding.table_location,
1416
+ binding.database_role,
1404
1417
  list(binding.partition_layout),
1405
1418
  dict(binding.dimension_column_map),
1406
1419
  dict(binding.measure_column_map),
File without changes
File without changes
File without changes
File without changes
File without changes