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.
Files changed (66) hide show
  1. sunstone_data/__init__.py +36 -0
  2. sunstone_data/_version.py +24 -0
  3. sunstone_data/auth/__init__.py +43 -0
  4. sunstone_data/auth/client_creds.py +239 -0
  5. sunstone_data/auth/pkce.py +307 -0
  6. sunstone_data/auth/store.py +97 -0
  7. sunstone_data/catalog.py +175 -0
  8. sunstone_data/cli/__init__.py +5 -0
  9. sunstone_data/cli/_app.py +26 -0
  10. sunstone_data/cli/_errors.py +31 -0
  11. sunstone_data/cli/commands/__init__.py +22 -0
  12. sunstone_data/cli/commands/data_auth.py +120 -0
  13. sunstone_data/cli/commands/data_import.py +157 -0
  14. sunstone_data/cli/commands/data_info.py +166 -0
  15. sunstone_data/cli/commands/data_publish.py +69 -0
  16. sunstone_data/cli/commands/ls.py +49 -0
  17. sunstone_data/cli/commands/prov.py +58 -0
  18. sunstone_data/cli/commands/refs.py +142 -0
  19. sunstone_data/cli/commands/show.py +139 -0
  20. sunstone_data/cli/commands/snapshots.py +77 -0
  21. sunstone_data/cli/commands/sparql.py +68 -0
  22. sunstone_data/cli/main.py +30 -0
  23. sunstone_data/config.py +250 -0
  24. sunstone_data/discovery.py +317 -0
  25. sunstone_data/env_commands.py +97 -0
  26. sunstone_data/env_section.py +86 -0
  27. sunstone_data/graph/__init__.py +36 -0
  28. sunstone_data/graph/client.py +328 -0
  29. sunstone_data/graph/dataset.py +438 -0
  30. sunstone_data/graph/terms.py +34 -0
  31. sunstone_data/identifiers.py +155 -0
  32. sunstone_data/import_/__init__.py +28 -0
  33. sunstone_data/import_/fetcher.py +259 -0
  34. sunstone_data/import_/owner.py +130 -0
  35. sunstone_data/import_/parser.py +492 -0
  36. sunstone_data/import_/pipeline.py +275 -0
  37. sunstone_data/import_/storage.py +135 -0
  38. sunstone_data/metadata/__init__.py +41 -0
  39. sunstone_data/metadata/builder.py +276 -0
  40. sunstone_data/metadata/models.py +87 -0
  41. sunstone_data/metadata/provenance.py +171 -0
  42. sunstone_data/metadata/provenance_rdf.py +102 -0
  43. sunstone_data/plugin.py +62 -0
  44. sunstone_data/publish.py +134 -0
  45. sunstone_data/query.py +624 -0
  46. sunstone_data/resolve.py +178 -0
  47. sunstone_data/url.py +78 -0
  48. sunstone_data/utils/__init__.py +12 -0
  49. sunstone_data/utils/checksum.py +14 -0
  50. sunstone_data/utils/encoding.py +16 -0
  51. sunstone_data/utils/naming.py +38 -0
  52. sunstone_data/versioning/__init__.py +17 -0
  53. sunstone_data/versioning/schema_evolution.py +180 -0
  54. sunstone_data_platform-1.0.0.dist-info/METADATA +302 -0
  55. sunstone_data_platform-1.0.0.dist-info/RECORD +66 -0
  56. sunstone_data_platform-1.0.0.dist-info/WHEEL +4 -0
  57. sunstone_data_platform-1.0.0.dist-info/entry_points.txt +2 -0
  58. sunstone_pipeline/__init__.py +1 -0
  59. sunstone_pipeline/assets/__init__.py +11 -0
  60. sunstone_pipeline/assets/mirrors.py +123 -0
  61. sunstone_pipeline/assets/transforms.py +74 -0
  62. sunstone_pipeline/definitions.py +25 -0
  63. sunstone_pipeline/io_manager.py +56 -0
  64. sunstone_pipeline/resources/__init__.py +6 -0
  65. sunstone_pipeline/resources/iceberg.py +29 -0
  66. 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
+ ]