sunstone-data-platform 1.0.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.
- sunstone_data/__init__.py +36 -0
- sunstone_data/_version.py +24 -0
- sunstone_data/auth/__init__.py +43 -0
- sunstone_data/auth/client_creds.py +239 -0
- sunstone_data/auth/pkce.py +307 -0
- sunstone_data/auth/store.py +97 -0
- sunstone_data/catalog.py +175 -0
- sunstone_data/cli/__init__.py +5 -0
- sunstone_data/cli/_app.py +26 -0
- sunstone_data/cli/_errors.py +31 -0
- sunstone_data/cli/commands/__init__.py +22 -0
- sunstone_data/cli/commands/data_auth.py +120 -0
- sunstone_data/cli/commands/data_import.py +157 -0
- sunstone_data/cli/commands/data_info.py +166 -0
- sunstone_data/cli/commands/data_publish.py +69 -0
- sunstone_data/cli/commands/ls.py +49 -0
- sunstone_data/cli/commands/prov.py +58 -0
- sunstone_data/cli/commands/refs.py +142 -0
- sunstone_data/cli/commands/show.py +139 -0
- sunstone_data/cli/commands/snapshots.py +77 -0
- sunstone_data/cli/commands/sparql.py +68 -0
- sunstone_data/cli/main.py +30 -0
- sunstone_data/config.py +250 -0
- sunstone_data/discovery.py +317 -0
- sunstone_data/env_commands.py +97 -0
- sunstone_data/env_section.py +86 -0
- sunstone_data/graph/__init__.py +36 -0
- sunstone_data/graph/client.py +328 -0
- sunstone_data/graph/dataset.py +438 -0
- sunstone_data/graph/terms.py +34 -0
- sunstone_data/identifiers.py +155 -0
- sunstone_data/import_/__init__.py +28 -0
- sunstone_data/import_/fetcher.py +259 -0
- sunstone_data/import_/owner.py +130 -0
- sunstone_data/import_/parser.py +492 -0
- sunstone_data/import_/pipeline.py +275 -0
- sunstone_data/import_/storage.py +135 -0
- sunstone_data/metadata/__init__.py +41 -0
- sunstone_data/metadata/builder.py +276 -0
- sunstone_data/metadata/models.py +87 -0
- sunstone_data/metadata/provenance.py +171 -0
- sunstone_data/metadata/provenance_rdf.py +102 -0
- sunstone_data/plugin.py +62 -0
- sunstone_data/publish.py +134 -0
- sunstone_data/query.py +624 -0
- sunstone_data/resolve.py +178 -0
- sunstone_data/url.py +78 -0
- sunstone_data/utils/__init__.py +12 -0
- sunstone_data/utils/checksum.py +14 -0
- sunstone_data/utils/encoding.py +16 -0
- sunstone_data/utils/naming.py +38 -0
- sunstone_data/versioning/__init__.py +17 -0
- sunstone_data/versioning/schema_evolution.py +180 -0
- sunstone_data_platform-1.0.0.dist-info/METADATA +302 -0
- sunstone_data_platform-1.0.0.dist-info/RECORD +66 -0
- sunstone_data_platform-1.0.0.dist-info/WHEEL +4 -0
- sunstone_data_platform-1.0.0.dist-info/entry_points.txt +2 -0
- sunstone_pipeline/__init__.py +1 -0
- sunstone_pipeline/assets/__init__.py +11 -0
- sunstone_pipeline/assets/mirrors.py +123 -0
- sunstone_pipeline/assets/transforms.py +74 -0
- sunstone_pipeline/definitions.py +25 -0
- sunstone_pipeline/io_manager.py +56 -0
- sunstone_pipeline/resources/__init__.py +6 -0
- sunstone_pipeline/resources/iceberg.py +29 -0
- sunstone_pipeline/resources/refs.py +88 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""The dataset graph: canonical RDF serialization, and the `Metadata` mapping.
|
|
2
|
+
|
|
3
|
+
The serializer is the only place that turns a set of triples into bytes.
|
|
4
|
+
Two rules make the output byte-for-byte reproducible:
|
|
5
|
+
|
|
6
|
+
- Triples are deduplicated into a set and the output lines are sorted, so
|
|
7
|
+
the byte order never depends on the order a graph was built in.
|
|
8
|
+
- Nested or anonymous structure gets a deterministic fragment IRI
|
|
9
|
+
(`fragment_iri`) instead of a blank node.
|
|
10
|
+
|
|
11
|
+
The second half of the module maps a `sunstone.Metadata` object plus this
|
|
12
|
+
package's version and snapshot facts to and from that graph, following the
|
|
13
|
+
shape in spec 180 §6. See `DatasetDescription` for what the mapping carries.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Iterable, Sequence
|
|
21
|
+
|
|
22
|
+
from sunstone.lineage import Metadata
|
|
23
|
+
|
|
24
|
+
from sunstone_data.graph import terms
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class IRI:
|
|
29
|
+
"""A named node: an RDF term identified by an IRI."""
|
|
30
|
+
|
|
31
|
+
value: str
|
|
32
|
+
|
|
33
|
+
def to_ntriples(self) -> str:
|
|
34
|
+
"""Render as an N-Triples IRIREF, e.g. ``<https://example.org/x>``."""
|
|
35
|
+
return f"<{self.value}>"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_ESCAPES = {
|
|
39
|
+
"\\": "\\\\",
|
|
40
|
+
'"': '\\"',
|
|
41
|
+
"\n": "\\n",
|
|
42
|
+
"\r": "\\r",
|
|
43
|
+
"\t": "\\t",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Literal:
|
|
49
|
+
"""A literal term: a plain, language-tagged, or typed value."""
|
|
50
|
+
|
|
51
|
+
value: str
|
|
52
|
+
datatype: str | None = None
|
|
53
|
+
language: str | None = None
|
|
54
|
+
|
|
55
|
+
def to_ntriples(self) -> str:
|
|
56
|
+
"""Render as an N-Triples literal, with the required escaping."""
|
|
57
|
+
escaped = "".join(_ESCAPES.get(c, c) for c in self.value)
|
|
58
|
+
if self.language is not None:
|
|
59
|
+
return f'"{escaped}"@{self.language}'
|
|
60
|
+
if self.datatype is not None:
|
|
61
|
+
return f'"{escaped}"^^<{self.datatype}>'
|
|
62
|
+
return f'"{escaped}"'
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
Term = IRI | Literal
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Triple:
|
|
70
|
+
"""One RDF triple. The subject is always an IRI: this module never emits
|
|
71
|
+
a blank-node subject."""
|
|
72
|
+
|
|
73
|
+
subject: IRI
|
|
74
|
+
predicate: IRI
|
|
75
|
+
obj: Term
|
|
76
|
+
|
|
77
|
+
def to_ntriples(self) -> str:
|
|
78
|
+
"""Render as one N-Triples statement line, without the trailing newline."""
|
|
79
|
+
return f"{self.subject.to_ntriples()} {self.predicate.to_ntriples()} {self.obj.to_ntriples()} ."
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
Graph = frozenset[Triple]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build_graph(triples: Iterable[Triple]) -> Graph:
|
|
86
|
+
"""Build a graph from triples in any order.
|
|
87
|
+
|
|
88
|
+
An RDF graph is a set, so duplicates collapse; `serialize` sorts its
|
|
89
|
+
output, so insertion order never reaches the caller.
|
|
90
|
+
"""
|
|
91
|
+
return frozenset(triples)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def serialize(graph: Graph) -> bytes:
|
|
95
|
+
"""Serialize a graph to canonical N-Triples (UTF-8, one sorted line per triple)."""
|
|
96
|
+
lines = sorted(t.to_ntriples() for t in graph)
|
|
97
|
+
if not lines:
|
|
98
|
+
return b""
|
|
99
|
+
return ("\n".join(lines) + "\n").encode("utf-8")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_UNESCAPES = {"\\": "\\", '"': '"', "'": "'", "n": "\n", "r": "\r", "t": "\t", "b": "\b", "f": "\f"}
|
|
103
|
+
|
|
104
|
+
_TRIPLE_RE = re.compile(
|
|
105
|
+
r"""^<(?P<subject>[^>]*)>\s+<(?P<predicate>[^>]*)>\s+(?:"""
|
|
106
|
+
r"""<(?P<iri>[^>]*)>"""
|
|
107
|
+
r"""|"(?P<literal>(?:[^"\\]|\\.)*)"(?:\^\^<(?P<datatype>[^>]*)>|@(?P<language>[A-Za-z0-9-]+))?"""
|
|
108
|
+
r""")\s*\.$"""
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _unescape(text: str) -> str:
|
|
113
|
+
"""Undo N-Triples string escaping (the inverse of ``_ESCAPES``, plus ``\\uXXXX``)."""
|
|
114
|
+
out: list[str] = []
|
|
115
|
+
i = 0
|
|
116
|
+
while i < len(text):
|
|
117
|
+
if text[i] != "\\":
|
|
118
|
+
out.append(text[i])
|
|
119
|
+
i += 1
|
|
120
|
+
continue
|
|
121
|
+
code = text[i + 1]
|
|
122
|
+
if code in ("u", "U"):
|
|
123
|
+
width = 4 if code == "u" else 8
|
|
124
|
+
out.append(chr(int(text[i + 2 : i + 2 + width], 16)))
|
|
125
|
+
i += 2 + width
|
|
126
|
+
else:
|
|
127
|
+
out.append(_UNESCAPES.get(code, code))
|
|
128
|
+
i += 2
|
|
129
|
+
return "".join(out)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_ntriples(text: str) -> Graph:
|
|
133
|
+
"""Parse N-Triples text into a graph — the inverse of `serialize`.
|
|
134
|
+
|
|
135
|
+
Handles the subset the RDF 1.1 N-Triples grammar allows for graphs this
|
|
136
|
+
package writes and graph-server returns: IRI subjects and predicates, an
|
|
137
|
+
IRI or (plain, typed, or language-tagged) literal object, one statement
|
|
138
|
+
per line. Blank nodes are not supported — graph-server skolemizes them
|
|
139
|
+
into IRIs before storing, so they never come back over the wire.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
ValueError: A non-empty, non-comment line does not match the grammar.
|
|
143
|
+
"""
|
|
144
|
+
triples: list[Triple] = []
|
|
145
|
+
for raw in text.splitlines():
|
|
146
|
+
line = raw.strip()
|
|
147
|
+
if not line or line.startswith("#"):
|
|
148
|
+
continue
|
|
149
|
+
match = _TRIPLE_RE.match(line)
|
|
150
|
+
if match is None:
|
|
151
|
+
raise ValueError(f"not a supported N-Triples statement: {line!r}")
|
|
152
|
+
parts = match.groupdict()
|
|
153
|
+
if parts["iri"] is not None:
|
|
154
|
+
obj: Term = IRI(parts["iri"])
|
|
155
|
+
else:
|
|
156
|
+
obj = Literal(
|
|
157
|
+
_unescape(parts["literal"]),
|
|
158
|
+
datatype=parts["datatype"],
|
|
159
|
+
language=parts["language"],
|
|
160
|
+
)
|
|
161
|
+
triples.append(Triple(IRI(parts["subject"]), IRI(parts["predicate"]), obj))
|
|
162
|
+
return build_graph(triples)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def fragment_iri(base_iri: str, fragment: str) -> str:
|
|
166
|
+
"""Mint a deterministic child IRI under `base_iri`, e.g. ``{base}#{fragment}``.
|
|
167
|
+
|
|
168
|
+
Use this instead of a blank node for nested or anonymous structure.
|
|
169
|
+
graph-server skolemizes blank nodes from a hash of the raw request
|
|
170
|
+
bytes (`crates/graph-core/src/canon.rs`), so the same graph submitted
|
|
171
|
+
with its triples in a different order hashes differently and mints a
|
|
172
|
+
new blank-node IRI — tombstoning the old node on every publish. A
|
|
173
|
+
fragment IRI built from stable inputs (the parent IRI plus a role or
|
|
174
|
+
id) does not depend on request bytes, so it stays the same across
|
|
175
|
+
publishes.
|
|
176
|
+
"""
|
|
177
|
+
return f"{base_iri}#{fragment}"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
RDF_TYPE = IRI("http://www.w3.org/1999/02/22-rdf-syntax-ns#type")
|
|
181
|
+
|
|
182
|
+
_DCT_IDENTIFIER = IRI(terms.DCT + "identifier")
|
|
183
|
+
_DCT_TITLE = IRI(terms.DCT + "title")
|
|
184
|
+
_DCT_DESCRIPTION = IRI(terms.DCT + "description")
|
|
185
|
+
_DCAT_DATASET = IRI(terms.DCAT + "Dataset")
|
|
186
|
+
_DCAT_HAS_VERSION = IRI(terms.DCAT + "hasVersion")
|
|
187
|
+
_DCAT_HAS_CURRENT_VERSION = IRI(terms.DCAT + "hasCurrentVersion")
|
|
188
|
+
_DCAT_VERSION = IRI(terms.DCAT + "version")
|
|
189
|
+
_DCAT_PREVIOUS_VERSION = IRI(terms.DCAT + "previousVersion")
|
|
190
|
+
_SI_SNAPSHOT = IRI(terms.DCAT_SI + "Snapshot")
|
|
191
|
+
_SI_HAS_SNAPSHOT = IRI(terms.DCAT_SI + "hasSnapshot")
|
|
192
|
+
_SI_SNAPSHOT_ID = IRI(terms.DCAT_SI + "snapshotId")
|
|
193
|
+
_SI_METADATA_LOCATION = IRI(terms.DCAT_SI + "metadataLocation")
|
|
194
|
+
_PROV_GENERATION = IRI(terms.PROV + "Generation")
|
|
195
|
+
_PROV_WAS_GENERATED_BY = IRI(terms.PROV + "wasGeneratedBy")
|
|
196
|
+
_PROV_QUALIFIED_GENERATION = IRI(terms.PROV + "qualifiedGeneration")
|
|
197
|
+
_PROV_ACTIVITY = IRI(terms.PROV + "activity")
|
|
198
|
+
_PROV_AT_TIME = IRI(terms.PROV + "atTime")
|
|
199
|
+
_XSD_DATETIME = terms.XSD + "dateTime"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class DatasetVersion:
|
|
204
|
+
"""One named version of a dataset and the Iceberg snapshot it pins.
|
|
205
|
+
|
|
206
|
+
`sunstone.Metadata` has no notion of versions or snapshots — those are
|
|
207
|
+
this package's own facts, taken from Iceberg table history and from an
|
|
208
|
+
`ImportMetadata` record (`activity_id`, `timestamp_end`). This carries
|
|
209
|
+
them alongside the `Metadata` object instead of inventing fields on it.
|
|
210
|
+
|
|
211
|
+
Every field but `version` is optional, so a first publish with no
|
|
212
|
+
snapshot yet still produces a valid version node.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
version: str
|
|
216
|
+
"""The version label, e.g. ``v1.0``. Also the node's IRI fragment."""
|
|
217
|
+
|
|
218
|
+
previous_version: str | None = None
|
|
219
|
+
"""Label of the version this one revises, if any."""
|
|
220
|
+
|
|
221
|
+
snapshot_id: str | None = None
|
|
222
|
+
"""Iceberg snapshot id this version pins."""
|
|
223
|
+
|
|
224
|
+
metadata_location: str | None = None
|
|
225
|
+
"""Location of the Iceberg metadata file for that snapshot."""
|
|
226
|
+
|
|
227
|
+
import_activity_id: str | None = None
|
|
228
|
+
"""UUID of the import that produced the snapshot. Carried only when
|
|
229
|
+
`snapshot_id` is set, because the link hangs off the snapshot node."""
|
|
230
|
+
|
|
231
|
+
generated_at: str | None = None
|
|
232
|
+
"""ISO 8601 instant the snapshot was generated. Carried only when both
|
|
233
|
+
`snapshot_id` and `import_activity_id` are set."""
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass(frozen=True)
|
|
237
|
+
class DatasetDescription:
|
|
238
|
+
"""Everything the graph mapping carries about one dataset.
|
|
239
|
+
|
|
240
|
+
Carried from `Metadata`: `slug` (``dct:identifier``), `name`
|
|
241
|
+
(``dct:title``) and `description` (``dct:description``) — the same term
|
|
242
|
+
choices `Metadata.to_jsonld()` already makes.
|
|
243
|
+
|
|
244
|
+
Deliberately not carried: `lineage`, `field_metadata`,
|
|
245
|
+
`component_metadata`, `custom_properties` and `rdf_prefixes`. Import
|
|
246
|
+
provenance as RDF and the CSVW schema projection are separate tasks;
|
|
247
|
+
round-tripping a description leaves those fields at their defaults.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
dataset_iri: str
|
|
251
|
+
"""IRI of the dataset the graph is rooted at."""
|
|
252
|
+
|
|
253
|
+
metadata: Metadata = field(default_factory=Metadata)
|
|
254
|
+
"""The descriptive metadata."""
|
|
255
|
+
|
|
256
|
+
versions: tuple[DatasetVersion, ...] = ()
|
|
257
|
+
"""Named versions, ordered by version label."""
|
|
258
|
+
|
|
259
|
+
current_version: str | None = None
|
|
260
|
+
"""Label of the version ``dcat:hasCurrentVersion`` points at."""
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def description_to_graph(description: DatasetDescription) -> Graph:
|
|
264
|
+
"""Build the dataset graph for a description (spec 180 §6).
|
|
265
|
+
|
|
266
|
+
Only the DCAT versioning terms are asserted. `dcat:hasVersion` and
|
|
267
|
+
`dcat:previousVersion` are sub-properties of `prov:generalizationOf`
|
|
268
|
+
and `prov:wasRevisionOf`, so asserting the PROV forms too would store
|
|
269
|
+
an entailed triple, which ADR 0002 §1 forbids. `prov:wasGeneratedBy`
|
|
270
|
+
and the qualified `prov:Generation` node are not entailed by anything,
|
|
271
|
+
so they are asserted.
|
|
272
|
+
"""
|
|
273
|
+
root = IRI(description.dataset_iri)
|
|
274
|
+
triples: list[Triple] = []
|
|
275
|
+
|
|
276
|
+
metadata = description.metadata
|
|
277
|
+
for predicate, value in (
|
|
278
|
+
(_DCT_IDENTIFIER, metadata.slug),
|
|
279
|
+
(_DCT_TITLE, metadata.name),
|
|
280
|
+
(_DCT_DESCRIPTION, metadata.description),
|
|
281
|
+
):
|
|
282
|
+
if value is not None:
|
|
283
|
+
triples.append(Triple(root, predicate, Literal(value)))
|
|
284
|
+
|
|
285
|
+
for version in description.versions:
|
|
286
|
+
triples.extend(_version_triples(description.dataset_iri, root, version))
|
|
287
|
+
|
|
288
|
+
if description.current_version is not None:
|
|
289
|
+
current = IRI(fragment_iri(description.dataset_iri, description.current_version))
|
|
290
|
+
triples.append(Triple(root, _DCAT_HAS_CURRENT_VERSION, current))
|
|
291
|
+
|
|
292
|
+
return build_graph(triples)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _version_triples(dataset_iri: str, root: IRI, version: DatasetVersion) -> list[Triple]:
|
|
296
|
+
node = IRI(fragment_iri(dataset_iri, version.version))
|
|
297
|
+
triples = [
|
|
298
|
+
Triple(root, _DCAT_HAS_VERSION, node),
|
|
299
|
+
Triple(node, RDF_TYPE, _DCAT_DATASET),
|
|
300
|
+
Triple(node, _DCAT_VERSION, Literal(version.version)),
|
|
301
|
+
]
|
|
302
|
+
if version.previous_version is not None:
|
|
303
|
+
previous = IRI(fragment_iri(dataset_iri, version.previous_version))
|
|
304
|
+
triples.append(Triple(node, _DCAT_PREVIOUS_VERSION, previous))
|
|
305
|
+
if version.snapshot_id is None:
|
|
306
|
+
return triples
|
|
307
|
+
|
|
308
|
+
# `snapshot-{id}`, never a bare `snapshot`: the catalog rewrites the bare
|
|
309
|
+
# `#snapshot` node on every table commit, so a version pin named that way
|
|
310
|
+
# would not survive the next commit (spec 180 §10).
|
|
311
|
+
snapshot = IRI(fragment_iri(dataset_iri, f"snapshot-{version.snapshot_id}"))
|
|
312
|
+
triples += [
|
|
313
|
+
Triple(node, _SI_HAS_SNAPSHOT, snapshot),
|
|
314
|
+
Triple(snapshot, RDF_TYPE, _SI_SNAPSHOT),
|
|
315
|
+
Triple(snapshot, _SI_SNAPSHOT_ID, Literal(version.snapshot_id)),
|
|
316
|
+
]
|
|
317
|
+
if version.metadata_location is not None:
|
|
318
|
+
triples.append(Triple(snapshot, _SI_METADATA_LOCATION, Literal(version.metadata_location)))
|
|
319
|
+
if version.import_activity_id is None:
|
|
320
|
+
return triples
|
|
321
|
+
|
|
322
|
+
activity = IRI(fragment_iri(dataset_iri, f"import-{version.import_activity_id}"))
|
|
323
|
+
triples.append(Triple(snapshot, _PROV_WAS_GENERATED_BY, activity))
|
|
324
|
+
if version.generated_at is not None:
|
|
325
|
+
generation = IRI(fragment_iri(dataset_iri, f"gen-{version.snapshot_id}"))
|
|
326
|
+
triples += [
|
|
327
|
+
Triple(snapshot, _PROV_QUALIFIED_GENERATION, generation),
|
|
328
|
+
Triple(generation, RDF_TYPE, _PROV_GENERATION),
|
|
329
|
+
Triple(generation, _PROV_ACTIVITY, activity),
|
|
330
|
+
Triple(generation, _PROV_AT_TIME, Literal(version.generated_at, datatype=_XSD_DATETIME)),
|
|
331
|
+
]
|
|
332
|
+
return triples
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def snapshot_generation_triples(
|
|
336
|
+
dataset_iri: str,
|
|
337
|
+
snapshot_id: str,
|
|
338
|
+
import_activity_id: str,
|
|
339
|
+
generated_at: str,
|
|
340
|
+
metadata_location: str | None = None,
|
|
341
|
+
) -> list[Triple]:
|
|
342
|
+
"""Link an Iceberg snapshot to the import activity that produced it (spec 180 §9 step 4).
|
|
343
|
+
|
|
344
|
+
Emits the ``#snapshot-{id}`` node — never the bare ``#snapshot``, which
|
|
345
|
+
the catalog rewrites on every commit (spec §10) — with its type and
|
|
346
|
+
``dcat-si:snapshotId``, plus ``prov:wasGeneratedBy`` and a qualified
|
|
347
|
+
``prov:Generation`` node carrying the generation instant. No
|
|
348
|
+
``dcat:hasVersion`` wrapper: an import does not mint a named version
|
|
349
|
+
label (``sunstone data publish --version`` does that separately,
|
|
350
|
+
spec §8) — contrast with ``_version_triples``, which does add that
|
|
351
|
+
wrapper for the publish path.
|
|
352
|
+
"""
|
|
353
|
+
snapshot = IRI(fragment_iri(dataset_iri, f"snapshot-{snapshot_id}"))
|
|
354
|
+
activity = IRI(fragment_iri(dataset_iri, f"import-{import_activity_id}"))
|
|
355
|
+
generation = IRI(fragment_iri(dataset_iri, f"gen-{snapshot_id}"))
|
|
356
|
+
triples = [
|
|
357
|
+
Triple(snapshot, RDF_TYPE, _SI_SNAPSHOT),
|
|
358
|
+
Triple(snapshot, _SI_SNAPSHOT_ID, Literal(snapshot_id)),
|
|
359
|
+
Triple(snapshot, _PROV_WAS_GENERATED_BY, activity),
|
|
360
|
+
Triple(snapshot, _PROV_QUALIFIED_GENERATION, generation),
|
|
361
|
+
Triple(generation, RDF_TYPE, _PROV_GENERATION),
|
|
362
|
+
Triple(generation, _PROV_ACTIVITY, activity),
|
|
363
|
+
Triple(generation, _PROV_AT_TIME, Literal(generated_at, datatype=_XSD_DATETIME)),
|
|
364
|
+
]
|
|
365
|
+
if metadata_location is not None:
|
|
366
|
+
triples.append(Triple(snapshot, _SI_METADATA_LOCATION, Literal(metadata_location)))
|
|
367
|
+
return triples
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def snapshot_already_has_generation(graph: Graph, dataset_iri: str, snapshot_id: str) -> bool:
|
|
371
|
+
"""True if ``#snapshot-{snapshot_id}`` already has a ``prov:wasGeneratedBy`` link.
|
|
372
|
+
|
|
373
|
+
A cheap idempotence check: a retried import whose Iceberg write
|
|
374
|
+
short-circuited (identical checksum) should not re-assert provenance if
|
|
375
|
+
the snapshot it points at is already linked to an activity — only when a
|
|
376
|
+
prior crash left it orphaned (spec 180 §9; DP-27).
|
|
377
|
+
"""
|
|
378
|
+
snapshot = IRI(fragment_iri(dataset_iri, f"snapshot-{snapshot_id}"))
|
|
379
|
+
return any(t.subject == snapshot and t.predicate == _PROV_WAS_GENERATED_BY for t in graph)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def graph_to_description(graph: Graph, dataset_iri: str) -> DatasetDescription:
|
|
383
|
+
"""Read a dataset graph back into a `DatasetDescription`.
|
|
384
|
+
|
|
385
|
+
The inverse of `description_to_graph` for every field that mapping
|
|
386
|
+
emits. A graph is a set, so version order is not carried: versions come
|
|
387
|
+
back sorted by label. Triples the mapping does not claim are ignored.
|
|
388
|
+
"""
|
|
389
|
+
root = IRI(dataset_iri)
|
|
390
|
+
metadata = Metadata(
|
|
391
|
+
slug=_literal(graph, root, _DCT_IDENTIFIER),
|
|
392
|
+
name=_literal(graph, root, _DCT_TITLE),
|
|
393
|
+
description=_literal(graph, root, _DCT_DESCRIPTION),
|
|
394
|
+
)
|
|
395
|
+
versions = tuple(
|
|
396
|
+
sorted(
|
|
397
|
+
(_read_version(graph, dataset_iri, node) for node in _iris(graph, root, _DCAT_HAS_VERSION)),
|
|
398
|
+
key=lambda v: v.version,
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
current = next(iter(_iris(graph, root, _DCAT_HAS_CURRENT_VERSION)), None)
|
|
402
|
+
return DatasetDescription(
|
|
403
|
+
dataset_iri=dataset_iri,
|
|
404
|
+
metadata=metadata,
|
|
405
|
+
versions=versions,
|
|
406
|
+
current_version=_fragment(current.value, dataset_iri) if current else None,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _read_version(graph: Graph, dataset_iri: str, node: IRI) -> DatasetVersion:
|
|
411
|
+
previous = next(iter(_iris(graph, node, _DCAT_PREVIOUS_VERSION)), None)
|
|
412
|
+
snapshot = next(iter(_iris(graph, node, _SI_HAS_SNAPSHOT)), None)
|
|
413
|
+
generation = next(iter(_iris(graph, snapshot, _PROV_QUALIFIED_GENERATION)), None) if snapshot else None
|
|
414
|
+
activity = next(iter(_iris(graph, snapshot, _PROV_WAS_GENERATED_BY)), None) if snapshot else None
|
|
415
|
+
return DatasetVersion(
|
|
416
|
+
version=_literal(graph, node, _DCAT_VERSION) or _fragment(node.value, dataset_iri),
|
|
417
|
+
previous_version=_fragment(previous.value, dataset_iri) if previous else None,
|
|
418
|
+
snapshot_id=_literal(graph, snapshot, _SI_SNAPSHOT_ID) if snapshot else None,
|
|
419
|
+
metadata_location=(_literal(graph, snapshot, _SI_METADATA_LOCATION) if snapshot else None),
|
|
420
|
+
import_activity_id=(_fragment(activity.value, dataset_iri).removeprefix("import-") if activity else None),
|
|
421
|
+
generated_at=_literal(graph, generation, _PROV_AT_TIME) if generation else None,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _matches(graph: Graph, subject: IRI, predicate: IRI) -> Iterable[Term]:
|
|
426
|
+
return (t.obj for t in graph if t.subject == subject and t.predicate == predicate)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _literal(graph: Graph, subject: IRI, predicate: IRI) -> str | None:
|
|
430
|
+
return next((o.value for o in _matches(graph, subject, predicate) if isinstance(o, Literal)), None)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _iris(graph: Graph, subject: IRI, predicate: IRI) -> Sequence[IRI]:
|
|
434
|
+
return [o for o in _matches(graph, subject, predicate) if isinstance(o, IRI)]
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _fragment(iri: str, dataset_iri: str) -> str:
|
|
438
|
+
return iri.removeprefix(f"{dataset_iri}#")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Namespace IRIs for the RDF vocabularies this project uses.
|
|
2
|
+
|
|
3
|
+
These constants must match `PREFIXES` in
|
|
4
|
+
`crates/iceberg-catalog/src/descriptor.rs` byte for byte. Both sides read
|
|
5
|
+
and write the same graph; a mismatched namespace IRI would silently break
|
|
6
|
+
CURIE reflection between RDF predicates and Iceberg property keys
|
|
7
|
+
(`sunstone.{prefix}.{local}`, spec §10.1) instead of failing loudly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
DCAT = "http://www.w3.org/ns/dcat#"
|
|
11
|
+
DCT = "http://purl.org/dc/terms/"
|
|
12
|
+
FOAF = "http://xmlns.com/foaf/0.1/"
|
|
13
|
+
PROV = "http://www.w3.org/ns/prov#"
|
|
14
|
+
VCARD = "http://www.w3.org/2006/vcard/ns#"
|
|
15
|
+
XSD = "http://www.w3.org/2001/XMLSchema#"
|
|
16
|
+
DCAT_SI = "https://sunstone.institute/rdf/data#"
|
|
17
|
+
ORG = "http://www.w3.org/ns/org#"
|
|
18
|
+
SKOS = "http://www.w3.org/2004/02/skos/core#"
|
|
19
|
+
RDFS = "http://www.w3.org/2000/01/rdf-schema#"
|
|
20
|
+
CSVW = "http://www.w3.org/ns/csvw#"
|
|
21
|
+
|
|
22
|
+
PREFIXES: dict[str, str] = {
|
|
23
|
+
"dcat": DCAT,
|
|
24
|
+
"dct": DCT,
|
|
25
|
+
"foaf": FOAF,
|
|
26
|
+
"prov": PROV,
|
|
27
|
+
"vcard": VCARD,
|
|
28
|
+
"xsd": XSD,
|
|
29
|
+
"dcat-si": DCAT_SI,
|
|
30
|
+
"org": ORG,
|
|
31
|
+
"skos": SKOS,
|
|
32
|
+
"rdfs": RDFS,
|
|
33
|
+
"csvw": CSVW,
|
|
34
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Identifier validation and quoting for safe SQL construction.
|
|
2
|
+
|
|
3
|
+
This module provides centralized validation and quoting for all identifiers
|
|
4
|
+
(namespace, table, branch, tag names) that get interpolated into DuckDB SQL
|
|
5
|
+
or used as catalog references. It prevents SQL injection by:
|
|
6
|
+
|
|
7
|
+
1. Validating identifiers against a strict allowlist pattern.
|
|
8
|
+
2. Quoting identifiers using DuckDB's double-quote escaping.
|
|
9
|
+
3. Rejecting any input containing SQL metacharacters.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
from sunstone_data.identifiers import validate_sql_identifier, quote_identifier
|
|
13
|
+
|
|
14
|
+
validate_sql_identifier(namespace, "namespace")
|
|
15
|
+
validate_sql_identifier(table, "table")
|
|
16
|
+
quoted = quote_identifier(branch)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
# SQL identifiers: letters, digits, underscores. Must start with letter or underscore.
|
|
24
|
+
# Max 128 characters.
|
|
25
|
+
_SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
|
|
26
|
+
|
|
27
|
+
# Branch and tag names may include hyphens (e.g., feature-branch, v1.0-rc1).
|
|
28
|
+
# Also allows dots for version-style tags (v1.0, 1.0.0).
|
|
29
|
+
_REF_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_\-.]{0,127}$")
|
|
30
|
+
|
|
31
|
+
# Characters that must never appear in any identifier
|
|
32
|
+
_DANGEROUS_CHARS = re.compile(r"[;'\"\\`\s/\x00]|--|/\*|\*/")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_sql_identifier(value: str, label: str) -> str:
|
|
36
|
+
"""Validate a value as a safe SQL identifier (namespace or table name).
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
value: The identifier string to validate.
|
|
40
|
+
label: Human-readable label for error messages (e.g., "namespace", "table").
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The validated identifier string (unchanged).
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ValueError: If the identifier is empty, too long, or contains unsafe characters.
|
|
47
|
+
"""
|
|
48
|
+
if not value:
|
|
49
|
+
raise ValueError(f"Empty {label} name is not allowed.")
|
|
50
|
+
|
|
51
|
+
if _DANGEROUS_CHARS.search(value):
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Invalid {label} name: {value!r}. "
|
|
54
|
+
f"Contains disallowed characters (semicolons, quotes, whitespace, "
|
|
55
|
+
f"comments, or slashes are not permitted)."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if not _SQL_IDENTIFIER_RE.match(value):
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"Invalid {label} name: {value!r}. "
|
|
61
|
+
f"Must start with a letter or underscore, contain only letters, "
|
|
62
|
+
f"digits, and underscores, and be at most 128 characters."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _reject_dangerous_chars(value: str, label: str) -> None:
|
|
69
|
+
"""Raise if value is empty or contains characters unsafe for SQL interpolation."""
|
|
70
|
+
if not value:
|
|
71
|
+
raise ValueError(f"Empty {label} name is not allowed.")
|
|
72
|
+
|
|
73
|
+
if _DANGEROUS_CHARS.search(value):
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"Invalid {label} name: {value!r}. "
|
|
76
|
+
f"Contains disallowed characters (semicolons, quotes, whitespace, "
|
|
77
|
+
f"comments, or slashes are not permitted)."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def validate_ref_name(value: str, label: str = "ref") -> str:
|
|
82
|
+
"""Validate a branch or tag name.
|
|
83
|
+
|
|
84
|
+
Branch and tag names follow a slightly more permissive pattern than SQL
|
|
85
|
+
identifiers, allowing hyphens and dots (e.g., feature-branch, v1.0).
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
value: The branch or tag name to validate.
|
|
89
|
+
label: Human-readable label for error messages.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The validated name (unchanged).
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ValueError: If the name is empty or contains unsafe characters.
|
|
96
|
+
"""
|
|
97
|
+
_reject_dangerous_chars(value, label)
|
|
98
|
+
|
|
99
|
+
if not _REF_NAME_RE.match(value):
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Invalid {label} name: {value!r}. "
|
|
102
|
+
f"Must start with a letter or underscore, contain only letters, "
|
|
103
|
+
f"digits, underscores, hyphens, and dots, and be at most 128 characters."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def validate_ref_string(value: str, label: str = "ref") -> str:
|
|
110
|
+
"""Validate an arbitrary, unclassified version ref (branch, tag, snapshot id, or timestamp).
|
|
111
|
+
|
|
112
|
+
Unlike `validate_ref_name`, this does not constrain the ref's format — a
|
|
113
|
+
ref can be a snapshot id or timestamp, which legitimately starts with a
|
|
114
|
+
digit and would fail `validate_ref_name`'s branch/tag naming pattern.
|
|
115
|
+
It only rejects characters that are unsafe for SQL interpolation.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
value: The ref string to validate.
|
|
119
|
+
label: Human-readable label for error messages.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
The validated ref string (unchanged).
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
ValueError: If the ref is empty or contains unsafe characters.
|
|
126
|
+
"""
|
|
127
|
+
_reject_dangerous_chars(value, label)
|
|
128
|
+
return value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def quote_identifier(name: str) -> str:
|
|
132
|
+
"""Quote a validated identifier for safe use in DuckDB SQL.
|
|
133
|
+
|
|
134
|
+
Escapes internal double quotes by doubling them and wraps the identifier
|
|
135
|
+
in double quotes. The identifier should already be validated before calling
|
|
136
|
+
this function, but this provides defense-in-depth.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
name: The identifier to quote.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Double-quoted identifier safe for SQL interpolation.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ValueError: If the name is empty or contains null bytes.
|
|
146
|
+
"""
|
|
147
|
+
if not name:
|
|
148
|
+
raise ValueError("Cannot quote an empty identifier.")
|
|
149
|
+
|
|
150
|
+
if "\x00" in name:
|
|
151
|
+
raise ValueError("Identifier contains null bytes.")
|
|
152
|
+
|
|
153
|
+
# Escape any internal double quotes by doubling them
|
|
154
|
+
escaped = name.replace('"', '""')
|
|
155
|
+
return f'"{escaped}"'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Import pipeline for Sunstone Data Platform."""
|
|
2
|
+
|
|
3
|
+
from sunstone_data.import_.fetcher import FileType, SourceInfo, fetch_source
|
|
4
|
+
from sunstone_data.import_.parser import (
|
|
5
|
+
parse_and_create_tables,
|
|
6
|
+
parse_csv,
|
|
7
|
+
parse_excel,
|
|
8
|
+
)
|
|
9
|
+
from sunstone_data.import_.pipeline import ImportResult, run_import
|
|
10
|
+
from sunstone_data.import_.storage import (
|
|
11
|
+
file_exists_by_checksum,
|
|
12
|
+
get_original,
|
|
13
|
+
store_original,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"FileType",
|
|
18
|
+
"ImportResult",
|
|
19
|
+
"SourceInfo",
|
|
20
|
+
"fetch_source",
|
|
21
|
+
"file_exists_by_checksum",
|
|
22
|
+
"get_original",
|
|
23
|
+
"parse_and_create_tables",
|
|
24
|
+
"parse_csv",
|
|
25
|
+
"parse_excel",
|
|
26
|
+
"run_import",
|
|
27
|
+
"store_original",
|
|
28
|
+
]
|