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,492 @@
1
+ """CSV and Excel parsing to PyArrow, with Iceberg table creation."""
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ import fastexcel
9
+ import pyarrow as pa
10
+ import pyarrow.csv as pa_csv
11
+ from pyiceberg.exceptions import BadRequestError
12
+ from pyiceberg.io.fsspec import FsspecFileIO
13
+ from pyiceberg.table import Table
14
+
15
+ from sunstone_data.catalog import create_namespace_if_not_exists, get_catalog
16
+ from sunstone_data.config import Settings, get_settings
17
+ from sunstone_data.import_.fetcher import FileType, SourceInfo
18
+ from sunstone_data.import_.pipeline import ImportResult
19
+ from sunstone_data.metadata.provenance import (
20
+ make_activity_uri,
21
+ make_file_uri,
22
+ make_table_uri,
23
+ make_url_uri,
24
+ )
25
+ from sunstone_data.utils import sanitize_table_name
26
+
27
+ if TYPE_CHECKING:
28
+ from sunstone_data.metadata import MetadataBuilder
29
+
30
+
31
+ def _fix_table_io_endpoint(table: Table, settings: Settings) -> Table:
32
+ """Fix table IO endpoint for local development.
33
+
34
+ When running PyIceberg on the host against Nessie in Docker, Nessie vends
35
+ the internal Docker endpoint. This function replaces the table's IO with
36
+ one using the host-accessible endpoint.
37
+
38
+ Args:
39
+ table: PyIceberg Table object loaded from REST catalog.
40
+ settings: Platform settings with correct endpoint.
41
+
42
+ Returns:
43
+ The same table with IO endpoint fixed.
44
+ """
45
+ props = dict(table.io.properties)
46
+ changed = False
47
+
48
+ if settings.storage_backend == "gcs":
49
+ # Fix fake-gcs-server internal endpoint
50
+ gcs_host = props.get("gcs.service.host", "")
51
+ if "fake-gcs-server" in gcs_host and settings.gcs_endpoint:
52
+ props["gcs.service.host"] = settings.gcs_endpoint
53
+ changed = True
54
+ else:
55
+ # Fix the object store's internal endpoint
56
+ s3_endpoint = props.get("s3.endpoint", "")
57
+ if "object-store:9000" in s3_endpoint:
58
+ props["s3.endpoint"] = settings.s3_endpoint
59
+ changed = True
60
+
61
+ if changed:
62
+ new_io = FsspecFileIO(properties=props)
63
+ object.__setattr__(table, "io", new_io)
64
+
65
+ return table
66
+
67
+
68
+ def parse_csv(file_path: Path, encoding: str | None = None) -> pa.Table:
69
+ """Parse CSV file to PyArrow table with streaming.
70
+
71
+ Args:
72
+ file_path: Path to CSV file.
73
+ encoding: File encoding (auto-detect if None).
74
+
75
+ Returns:
76
+ PyArrow Table with parsed data.
77
+ """
78
+ read_options = pa_csv.ReadOptions(
79
+ encoding=encoding or "utf-8",
80
+ block_size=64 * 1024 * 1024, # 64MB chunks for type inference
81
+ )
82
+ parse_options = pa_csv.ParseOptions()
83
+ convert_options = pa_csv.ConvertOptions()
84
+
85
+ # Use streaming reader
86
+ reader = pa_csv.open_csv(
87
+ file_path,
88
+ read_options=read_options,
89
+ parse_options=parse_options,
90
+ convert_options=convert_options,
91
+ )
92
+
93
+ return reader.read_all()
94
+
95
+
96
+ def parse_excel(file_path: Path) -> dict[str, pa.Table]:
97
+ """Parse Excel file, returning dict of sheet_name -> PyArrow table.
98
+
99
+ Args:
100
+ file_path: Path to Excel file.
101
+
102
+ Returns:
103
+ Dict mapping sheet names to PyArrow tables.
104
+ """
105
+ excel_file = fastexcel.read_excel(file_path)
106
+ sheet_names = excel_file.sheet_names
107
+
108
+ tables = {}
109
+ for sheet_name in sheet_names:
110
+ sheet = excel_file.load_sheet_by_name(sheet_name)
111
+ arrow_data = sheet.to_arrow()
112
+
113
+ # fastexcel may return RecordBatch - convert to Table
114
+ if isinstance(arrow_data, pa.RecordBatch):
115
+ arrow_table = pa.Table.from_batches([arrow_data])
116
+ else:
117
+ arrow_table = arrow_data
118
+
119
+ # Skip empty sheets
120
+ if arrow_table.num_rows > 0:
121
+ tables[sheet_name] = arrow_table
122
+
123
+ return tables
124
+
125
+
126
+ def create_iceberg_table(
127
+ namespace: str,
128
+ table_name: str,
129
+ arrow_table: pa.Table,
130
+ properties: dict[str, str],
131
+ branch: str = "main",
132
+ force_schema: bool = False,
133
+ settings: Settings | None = None,
134
+ builder: "MetadataBuilder | None" = None,
135
+ ) -> tuple[int | None, bool]:
136
+ """Create Iceberg table from Arrow table with metadata.
137
+
138
+ If table exists with same checksum, returns without modification.
139
+ If table exists with different checksum, appends as new snapshot.
140
+
141
+ Args:
142
+ namespace: Target namespace.
143
+ table_name: Target table name.
144
+ arrow_table: Data to write.
145
+ properties: Metadata properties to set.
146
+ branch: Branch to import to (default: "main").
147
+ force_schema: Allow unsafe schema changes (default: False).
148
+ settings: Optional settings override.
149
+ builder: Optional MetadataBuilder. On a same-checksum no-op its
150
+ ``activity_id`` is reset to the one the original committing import
151
+ recorded on the table, so a retry does not mint a second activity
152
+ for a snapshot that never changed (DP-27).
153
+
154
+ Returns:
155
+ ``(snapshot_id, already_existed)``. The snapshot id is the table's
156
+ current one after the operation — for an unchanged (same-checksum)
157
+ table, the existing one; None only if the table somehow carries no
158
+ snapshot. ``already_existed`` is True only when the same-checksum
159
+ short-circuit fired and nothing was written.
160
+
161
+ Raises:
162
+ ValueError: The schema is not flat, or an unsafe schema change was
163
+ detected without ``force_schema``.
164
+ """
165
+ settings = settings or get_settings()
166
+ catalog = get_catalog(branch=branch)
167
+
168
+ # Ensure namespace exists
169
+ create_namespace_if_not_exists(catalog, namespace)
170
+
171
+ identifier = f"{namespace}.{table_name}"
172
+
173
+ # Check if table exists
174
+ existing_tables = [t for _, t in catalog.list_tables(namespace)]
175
+
176
+ if table_name in existing_tables:
177
+ # Table exists - check checksum
178
+ table = catalog.load_table(identifier)
179
+ existing_checksum = table.metadata.properties.get("sunstone.import.checksum")
180
+
181
+ if existing_checksum == properties.get("sunstone.import.checksum"):
182
+ # Same checksum - already imported, skip. Adopt the committing
183
+ # import's activity id so provenance for this unchanged snapshot
184
+ # keeps pointing at the activity that actually produced it.
185
+ if builder is not None:
186
+ committed_activity_id = table.metadata.properties.get("sunstone.import.activity_id")
187
+ if committed_activity_id:
188
+ builder.activity_id = committed_activity_id
189
+ return table.metadata.current_snapshot_id, True
190
+
191
+ # Fix IO endpoint for local development (the catalog vends object-store:9000)
192
+ table = _fix_table_io_endpoint(table, settings)
193
+
194
+ # Check for schema changes
195
+ existing_schema = table.schema().as_arrow()
196
+ new_schema = arrow_table.schema
197
+
198
+ if not existing_schema.equals(new_schema):
199
+ # Schema differs - validate evolution
200
+ from sunstone_data.versioning.schema_evolution import (
201
+ detect_schema_changes,
202
+ validate_schema_evolution,
203
+ )
204
+
205
+ changes = detect_schema_changes(existing_schema, new_schema)
206
+
207
+ try:
208
+ validate_schema_evolution(changes, force=force_schema)
209
+ except ValueError as e:
210
+ # Unsafe schema changes detected
211
+ raise ValueError(
212
+ f"Schema change detected that could cause data loss. {e}\nUse --force-schema to override."
213
+ ) from e
214
+
215
+ # Different checksum - append as new snapshot (version)
216
+ table.append(arrow_table)
217
+
218
+ # Update properties (a property-only commit adds no snapshot)
219
+ with table.transaction() as txn:
220
+ for key, value in properties.items():
221
+ txn.set_properties({key: value})
222
+ return table.metadata.current_snapshot_id, False
223
+
224
+ # Create new table. The catalog enforces the flat-schema rule server-side.
225
+ try:
226
+ table = catalog.create_table(
227
+ identifier=identifier,
228
+ schema=arrow_table.schema,
229
+ properties=properties,
230
+ )
231
+ except BadRequestError as e:
232
+ raise ValueError(
233
+ f"Cannot create table {identifier!r}: Iceberg tables must have a flat schema "
234
+ f"(no struct, list, or map columns). {e}"
235
+ ) from e
236
+
237
+ # Fix IO endpoint for local development (the catalog vends object-store:9000)
238
+ table = _fix_table_io_endpoint(table, settings)
239
+
240
+ table.append(arrow_table)
241
+ return table.metadata.current_snapshot_id, False
242
+
243
+
244
+ def schema_summary(arrow_table: pa.Table) -> str:
245
+ """Create human-readable schema summary.
246
+
247
+ Args:
248
+ arrow_table: PyArrow table.
249
+
250
+ Returns:
251
+ String like "col1 (int64), col2 (string), col3 (float64)"
252
+ """
253
+ parts = []
254
+ for field in arrow_table.schema:
255
+ parts.append(f"{field.name} ({field.type})")
256
+ return ", ".join(parts[:5]) + ("..." if len(parts) > 5 else "")
257
+
258
+
259
+ def _extract_schema_columns(arrow_table: pa.Table) -> list[dict]:
260
+ """Extract column metadata from PyArrow schema.
261
+
262
+ Args:
263
+ arrow_table: PyArrow table.
264
+
265
+ Returns:
266
+ List of {name: str, type: str} for each column.
267
+ """
268
+ return [{"name": field.name, "type": str(field.type)} for field in arrow_table.schema]
269
+
270
+
271
+ def _build_extended_properties(
272
+ base_properties: dict[str, str],
273
+ arrow_table: pa.Table,
274
+ namespace: str,
275
+ table_name: str,
276
+ builder: "MetadataBuilder | None",
277
+ ) -> dict[str, str]:
278
+ """Build extended Iceberg properties including PROV-DM URIs.
279
+
280
+ Args:
281
+ base_properties: Base properties dict to extend.
282
+ arrow_table: Parsed PyArrow table.
283
+ namespace: Target namespace.
284
+ table_name: Target table name.
285
+ builder: Optional MetadataBuilder with source info.
286
+
287
+ Returns:
288
+ Extended properties dict including sunstone.prov.* fields.
289
+ """
290
+ properties = dict(base_properties)
291
+
292
+ if builder is None:
293
+ return properties
294
+
295
+ # Schema columns (same for original and iceberg in direct import)
296
+ columns = _extract_schema_columns(arrow_table)
297
+
298
+ # Extended sunstone.import.* properties
299
+ properties.update(
300
+ {
301
+ "sunstone.import.activity_id": builder.activity_id,
302
+ "sunstone.import.user": builder.user,
303
+ "sunstone.import.system_version": builder.system_version,
304
+ "sunstone.import.row_count": str(arrow_table.num_rows),
305
+ "sunstone.import.column_count": str(arrow_table.num_columns),
306
+ "sunstone.import.schema_original": json.dumps(columns),
307
+ "sunstone.import.schema_iceberg": json.dumps(columns),
308
+ }
309
+ )
310
+
311
+ # Add source_normalized and file_size if source info is available
312
+ if builder.source_info is not None:
313
+ properties["sunstone.import.source_normalized"] = builder.source_info.normalized_path
314
+ properties["sunstone.import.file_size"] = str(builder.source_info.file_size)
315
+
316
+ # Determine source entity URI
317
+ original_path = builder.source_info.original_path
318
+ if original_path.startswith(("http://", "https://")):
319
+ source_uri = make_url_uri(original_path)
320
+ else:
321
+ source_uri = make_file_uri(builder.source_info.normalized_path)
322
+
323
+ # PROV-DM properties
324
+ properties.update(
325
+ {
326
+ "sunstone.prov.activity_uri": make_activity_uri(builder.activity_id),
327
+ "sunstone.prov.source_entity_uri": source_uri,
328
+ "sunstone.prov.table_entity_uri": make_table_uri(namespace, table_name),
329
+ "sunstone.prov.user_agent_uri": f"urn:user:{builder.user}",
330
+ "sunstone.prov.system_agent_uri": f"urn:system:sunstone-data:{builder.system_version}",
331
+ }
332
+ )
333
+
334
+ return properties
335
+
336
+
337
+ def parse_and_create_tables(
338
+ source_info: SourceInfo,
339
+ base_name: str,
340
+ namespace: str,
341
+ checksum: str,
342
+ source: str,
343
+ tags: list[str] | None = None,
344
+ branch: str = "main",
345
+ force_schema: bool = False,
346
+ settings: Settings | None = None,
347
+ builder: "MetadataBuilder | None" = None,
348
+ owner_org: str | None = None,
349
+ ) -> list[ImportResult]:
350
+ """Parse file and create Iceberg tables.
351
+
352
+ Args:
353
+ source_info: Source information from fetcher.
354
+ base_name: Base table name.
355
+ namespace: Target namespace.
356
+ checksum: File checksum.
357
+ source: Original source path/URL.
358
+ tags: Optional tags.
359
+ branch: Branch to import to (default: "main").
360
+ force_schema: Allow unsafe schema changes (default: False).
361
+ settings: Optional settings override.
362
+ builder: Optional MetadataBuilder for extended metadata capture.
363
+
364
+ Returns:
365
+ List of ImportResult (one for CSV, multiple for Excel sheets).
366
+ """
367
+ settings = settings or get_settings()
368
+ now = datetime.now(timezone.utc).isoformat()
369
+
370
+ # Build base properties
371
+ base_properties = {
372
+ "sunstone.import.checksum": checksum,
373
+ "sunstone.import.source": source,
374
+ "sunstone.import.timestamp": now,
375
+ "sunstone.import.format": source_info.file_type.value,
376
+ }
377
+ if source_info.encoding:
378
+ base_properties["sunstone.import.encoding"] = source_info.encoding
379
+ if tags:
380
+ base_properties["sunstone.import.tags"] = ",".join(tags)
381
+ if owner_org:
382
+ base_properties["sunstone.owner.org"] = owner_org
383
+
384
+ results = []
385
+
386
+ if source_info.file_type == FileType.CSV:
387
+ # Parse CSV
388
+ arrow_table = parse_csv(source_info.local_path, source_info.encoding)
389
+
390
+ # Extract schema columns for metadata
391
+ columns = _extract_schema_columns(arrow_table)
392
+
393
+ # Capture schema info in builder
394
+ if builder is not None:
395
+ builder.set_schema_info(
396
+ original_columns=columns,
397
+ iceberg_columns=columns,
398
+ row_count=arrow_table.num_rows,
399
+ column_count=arrow_table.num_columns,
400
+ )
401
+
402
+ # Build properties with extended metadata
403
+ properties = {
404
+ **base_properties,
405
+ "sunstone.import.original_filename": source_info.filename,
406
+ }
407
+ properties = _build_extended_properties(properties, arrow_table, namespace, base_name, builder)
408
+
409
+ snapshot_id, already_existed = create_iceberg_table(
410
+ namespace=namespace,
411
+ table_name=base_name,
412
+ arrow_table=arrow_table,
413
+ properties=properties,
414
+ branch=branch,
415
+ force_schema=force_schema,
416
+ settings=settings,
417
+ builder=builder,
418
+ )
419
+
420
+ results.append(
421
+ ImportResult(
422
+ table_name=base_name,
423
+ namespace=namespace,
424
+ row_count=arrow_table.num_rows,
425
+ column_count=arrow_table.num_columns,
426
+ checksum=checksum,
427
+ original_path="", # Will be filled by pipeline
428
+ schema_summary=schema_summary(arrow_table),
429
+ already_existed=already_existed,
430
+ snapshot_id=snapshot_id,
431
+ )
432
+ )
433
+
434
+ elif source_info.file_type == FileType.EXCEL:
435
+ # Parse Excel - each sheet becomes a table
436
+ sheets = parse_excel(source_info.local_path)
437
+
438
+ # Track if this is first sheet (for schema metadata)
439
+ first_sheet = True
440
+
441
+ for sheet_name, arrow_table in sheets.items():
442
+ # Sanitize sheet name for table suffix
443
+ safe_sheet = sanitize_table_name(sheet_name)
444
+ table_name = f"{base_name}_{safe_sheet}" if len(sheets) > 1 else base_name
445
+
446
+ # Extract schema columns
447
+ columns = _extract_schema_columns(arrow_table)
448
+
449
+ # Capture schema info from first sheet (representative)
450
+ if builder is not None and first_sheet:
451
+ builder.set_schema_info(
452
+ original_columns=columns,
453
+ iceberg_columns=columns,
454
+ row_count=arrow_table.num_rows,
455
+ column_count=arrow_table.num_columns,
456
+ )
457
+ first_sheet = False
458
+
459
+ # Build properties with extended metadata
460
+ properties = {
461
+ **base_properties,
462
+ "sunstone.import.original_filename": source_info.filename,
463
+ "sunstone.import.sheet_name": sheet_name,
464
+ }
465
+ properties = _build_extended_properties(properties, arrow_table, namespace, table_name, builder)
466
+
467
+ snapshot_id, already_existed = create_iceberg_table(
468
+ namespace=namespace,
469
+ table_name=table_name,
470
+ arrow_table=arrow_table,
471
+ properties=properties,
472
+ branch=branch,
473
+ force_schema=force_schema,
474
+ settings=settings,
475
+ builder=builder,
476
+ )
477
+
478
+ results.append(
479
+ ImportResult(
480
+ table_name=table_name,
481
+ namespace=namespace,
482
+ row_count=arrow_table.num_rows,
483
+ column_count=arrow_table.num_columns,
484
+ checksum=checksum,
485
+ original_path="", # Will be filled by pipeline
486
+ schema_summary=schema_summary(arrow_table),
487
+ already_existed=already_existed,
488
+ snapshot_id=snapshot_id,
489
+ )
490
+ )
491
+
492
+ return results