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,166 @@
|
|
|
1
|
+
"""``sunstone data info`` — view metadata for imported datasets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from sunstone_data.cli._app import console, data_app
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@data_app.command("info")
|
|
13
|
+
def info_cmd(
|
|
14
|
+
table: str = typer.Argument(None, help="Table identifier (namespace.table) or omit to list all"),
|
|
15
|
+
minimal: bool = typer.Option(False, "--minimal", "-m", help="Minimal output (source, date, rows, checksum)"),
|
|
16
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all metadata including PROV URIs"),
|
|
17
|
+
namespace: str | None = typer.Option(None, "--namespace", "-n", help="Filter by namespace when listing"),
|
|
18
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
19
|
+
) -> None:
|
|
20
|
+
"""View metadata for imported datasets.
|
|
21
|
+
|
|
22
|
+
Without TABLE argument, lists all datasets in catalog.
|
|
23
|
+
With TABLE argument, shows detailed metadata for that dataset.
|
|
24
|
+
"""
|
|
25
|
+
try:
|
|
26
|
+
if table:
|
|
27
|
+
_show_table_info(table, minimal, verbose, json_output)
|
|
28
|
+
else:
|
|
29
|
+
_show_table_list(namespace, json_output)
|
|
30
|
+
except ValueError as e:
|
|
31
|
+
if json_output:
|
|
32
|
+
print(json.dumps({"error": str(e)}))
|
|
33
|
+
else:
|
|
34
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
35
|
+
raise typer.Exit(1)
|
|
36
|
+
except Exception as e:
|
|
37
|
+
if json_output:
|
|
38
|
+
print(json.dumps({"error": str(e), "type": type(e).__name__}))
|
|
39
|
+
else:
|
|
40
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
41
|
+
if console.is_terminal:
|
|
42
|
+
console.print_exception()
|
|
43
|
+
raise typer.Exit(1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _show_table_info(table: str, minimal: bool, verbose: bool, json_output: bool) -> None:
|
|
47
|
+
"""Show metadata for a single table."""
|
|
48
|
+
from rich.panel import Panel
|
|
49
|
+
from rich.table import Table
|
|
50
|
+
|
|
51
|
+
from sunstone_data.catalog import get_table_metadata
|
|
52
|
+
|
|
53
|
+
props = get_table_metadata(table)
|
|
54
|
+
|
|
55
|
+
if json_output:
|
|
56
|
+
print(json.dumps(props, indent=2))
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
if minimal:
|
|
60
|
+
print(f"Source: {props.get('sunstone.import.source', 'unknown')}")
|
|
61
|
+
print(f"Imported: {props.get('sunstone.import.timestamp', 'unknown')}")
|
|
62
|
+
row_count = props.get("sunstone.import.row_count", "?")
|
|
63
|
+
col_count = props.get("sunstone.import.column_count", "?")
|
|
64
|
+
print(f"Rows: {row_count}, Columns: {col_count}")
|
|
65
|
+
print(f"Checksum: {props.get('sunstone.import.checksum', 'unknown')}")
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
info_table = Table(show_header=False, box=None, padding=(0, 1))
|
|
69
|
+
info_table.add_column("Key", style="cyan")
|
|
70
|
+
info_table.add_column("Value")
|
|
71
|
+
|
|
72
|
+
info_table.add_row("Source", props.get("sunstone.import.source", "unknown"))
|
|
73
|
+
info_table.add_row(
|
|
74
|
+
"Imported by",
|
|
75
|
+
f"{props.get('sunstone.import.user', 'unknown')} at {props.get('sunstone.import.timestamp', 'unknown')}",
|
|
76
|
+
)
|
|
77
|
+
info_table.add_row(
|
|
78
|
+
"Format",
|
|
79
|
+
f"{props.get('sunstone.import.format', 'unknown')}, Encoding: {props.get('sunstone.import.encoding', 'unknown')}",
|
|
80
|
+
)
|
|
81
|
+
info_table.add_row(
|
|
82
|
+
"Size",
|
|
83
|
+
f"{props.get('sunstone.import.file_size', '?')} bytes, "
|
|
84
|
+
f"Rows: {props.get('sunstone.import.row_count', '?')}, "
|
|
85
|
+
f"Columns: {props.get('sunstone.import.column_count', '?')}",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
schema_str = props.get("sunstone.import.schema_original", "")
|
|
89
|
+
if schema_str:
|
|
90
|
+
columns = schema_str.split(", ")
|
|
91
|
+
if len(columns) > 5 and not verbose:
|
|
92
|
+
schema_preview = ", ".join(columns[:5]) + f", ... ({len(columns)} total)"
|
|
93
|
+
else:
|
|
94
|
+
schema_preview = schema_str
|
|
95
|
+
info_table.add_row("Schema", schema_preview)
|
|
96
|
+
|
|
97
|
+
checksum = props.get("sunstone.import.checksum", "unknown")
|
|
98
|
+
if not verbose and len(checksum) > 16:
|
|
99
|
+
checksum = checksum[:16] + "..."
|
|
100
|
+
info_table.add_row("Checksum", checksum)
|
|
101
|
+
|
|
102
|
+
if verbose:
|
|
103
|
+
info_table.add_row("", "")
|
|
104
|
+
info_table.add_row("[bold]Extended Info[/bold]", "")
|
|
105
|
+
|
|
106
|
+
if "sunstone.import.source_normalized" in props:
|
|
107
|
+
info_table.add_row("Normalized source", props["sunstone.import.source_normalized"])
|
|
108
|
+
|
|
109
|
+
if "sunstone.import.activity_id" in props:
|
|
110
|
+
info_table.add_row("Activity ID", props["sunstone.import.activity_id"])
|
|
111
|
+
|
|
112
|
+
if "sunstone.import.system_version" in props:
|
|
113
|
+
info_table.add_row("System version", props["sunstone.import.system_version"])
|
|
114
|
+
|
|
115
|
+
if "sunstone.import.parameters" in props:
|
|
116
|
+
info_table.add_row("Parameters", props["sunstone.import.parameters"])
|
|
117
|
+
|
|
118
|
+
info_table.add_row("", "")
|
|
119
|
+
info_table.add_row("[bold]PROV URIs[/bold]", "")
|
|
120
|
+
|
|
121
|
+
prov_keys = sorted([k for k in props if k.startswith("sunstone.prov.")])
|
|
122
|
+
for key in prov_keys:
|
|
123
|
+
short_key = key.replace("sunstone.prov.", "")
|
|
124
|
+
info_table.add_row(f" {short_key}", props[key])
|
|
125
|
+
|
|
126
|
+
panel = Panel(info_table, title=f"[bold]{table}[/bold]", border_style="blue")
|
|
127
|
+
console.print(panel)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _show_table_list(namespace: str | None, json_output: bool) -> None:
|
|
131
|
+
"""Show list of tables with metadata."""
|
|
132
|
+
from rich.tree import Tree
|
|
133
|
+
|
|
134
|
+
from sunstone_data.catalog import list_tables_with_metadata
|
|
135
|
+
|
|
136
|
+
tables = list_tables_with_metadata(namespace)
|
|
137
|
+
|
|
138
|
+
if not tables:
|
|
139
|
+
if json_output:
|
|
140
|
+
print(json.dumps([]))
|
|
141
|
+
else:
|
|
142
|
+
console.print("[yellow]No datasets found[/yellow]")
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
if json_output:
|
|
146
|
+
print(json.dumps(tables, indent=2))
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
by_namespace: dict[str, list[dict]] = {}
|
|
150
|
+
for t in tables:
|
|
151
|
+
ns, _ = t["identifier"].split(".", 1)
|
|
152
|
+
if ns not in by_namespace:
|
|
153
|
+
by_namespace[ns] = []
|
|
154
|
+
by_namespace[ns].append(t)
|
|
155
|
+
|
|
156
|
+
tree = Tree("[bold]Datasets[/bold]")
|
|
157
|
+
for ns in sorted(by_namespace.keys()):
|
|
158
|
+
ns_branch = tree.add(f"[cyan]{ns}[/cyan]")
|
|
159
|
+
for t in by_namespace[ns]:
|
|
160
|
+
_, table_name = t["identifier"].split(".", 1)
|
|
161
|
+
ts = t["timestamp"]
|
|
162
|
+
if "T" in ts:
|
|
163
|
+
ts = ts.split("T")[0]
|
|
164
|
+
ns_branch.add(f"{table_name} ({ts}, {t['row_count']} rows)")
|
|
165
|
+
|
|
166
|
+
console.print(tree)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""``sunstone data publish`` — publish a dataset's metadata to the graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from sunstone_data.cli._app import console, data_app
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@data_app.command("publish")
|
|
11
|
+
def publish_cmd(
|
|
12
|
+
table: str = typer.Argument(..., help="Table identifier (namespace.table)"),
|
|
13
|
+
branch: str = typer.Option("main", "--branch", "-b", help="Branch to publish on"),
|
|
14
|
+
version: str | None = typer.Option(
|
|
15
|
+
None, "--version", help="Publish the table's current snapshot under this version label, e.g. v1.0"
|
|
16
|
+
),
|
|
17
|
+
replace: bool = typer.Option(
|
|
18
|
+
False, "--replace", help="Retract previously published triples instead of merging into what is there"
|
|
19
|
+
),
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Publish metadata for a dataset into its named graph.
|
|
22
|
+
|
|
23
|
+
The default merges, so nothing already in the graph is lost. `--replace`
|
|
24
|
+
retracts what this command published before — and only that.
|
|
25
|
+
"""
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
|
|
28
|
+
from sunstone.lineage import Metadata
|
|
29
|
+
|
|
30
|
+
from sunstone_data.catalog import get_catalog
|
|
31
|
+
from sunstone_data.graph.dataset import DatasetDescription, DatasetVersion
|
|
32
|
+
from sunstone_data.publish import publish
|
|
33
|
+
from sunstone_data.query import TableRef
|
|
34
|
+
from sunstone_data.resolve import dataset_iri
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
ref = TableRef.parse(table)
|
|
38
|
+
identifier = f"{ref.namespace}.{ref.table}"
|
|
39
|
+
versions: tuple[DatasetVersion, ...] = ()
|
|
40
|
+
|
|
41
|
+
if version is not None:
|
|
42
|
+
iceberg = get_catalog(branch).load_table(identifier)
|
|
43
|
+
snapshot = iceberg.current_snapshot()
|
|
44
|
+
if snapshot is None:
|
|
45
|
+
raise ValueError(f"{identifier} has no snapshot to publish as {version}")
|
|
46
|
+
versions = (
|
|
47
|
+
DatasetVersion(
|
|
48
|
+
version=version,
|
|
49
|
+
snapshot_id=str(snapshot.snapshot_id),
|
|
50
|
+
metadata_location=iceberg.metadata_location,
|
|
51
|
+
import_activity_id=iceberg.properties.get("sunstone.import.activity_id"),
|
|
52
|
+
generated_at=datetime.fromtimestamp(snapshot.timestamp_ms / 1000, tz=timezone.utc).isoformat(),
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
etag = publish(
|
|
57
|
+
DatasetDescription(
|
|
58
|
+
dataset_iri=dataset_iri(ref),
|
|
59
|
+
metadata=Metadata(slug=identifier),
|
|
60
|
+
versions=versions,
|
|
61
|
+
current_version=version,
|
|
62
|
+
),
|
|
63
|
+
branch=branch,
|
|
64
|
+
replace=replace,
|
|
65
|
+
)
|
|
66
|
+
console.print(f"Published [cyan]{dataset_iri(ref)}[/cyan] on {branch} (ETag {etag})")
|
|
67
|
+
except Exception as e:
|
|
68
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
69
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""``sunstone data ls`` — list namespaces and tables (spec 180 §8)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from sunstone_data.cli._app import console, data_app
|
|
10
|
+
from sunstone_data.cli._errors import cli_errors
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@data_app.command("ls")
|
|
14
|
+
def ls_cmd(
|
|
15
|
+
namespace: str = typer.Argument(None, help="Only list tables in this namespace"),
|
|
16
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
17
|
+
) -> None:
|
|
18
|
+
"""List namespaces and the tables in them.
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
sunstone data ls
|
|
22
|
+
sunstone data ls un.health
|
|
23
|
+
"""
|
|
24
|
+
with cli_errors(json_output):
|
|
25
|
+
from sunstone_data.catalog import list_tables_with_metadata
|
|
26
|
+
|
|
27
|
+
tables = list_tables_with_metadata(namespace)
|
|
28
|
+
|
|
29
|
+
if json_output:
|
|
30
|
+
print(json.dumps(tables, indent=2))
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
if not tables:
|
|
34
|
+
console.print("[yellow]No datasets found[/yellow]")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
from rich.tree import Tree
|
|
38
|
+
|
|
39
|
+
by_namespace: dict[str, list[tuple[str, dict]]] = {}
|
|
40
|
+
for entry in tables:
|
|
41
|
+
ns, name = entry["identifier"].rsplit(".", 1)
|
|
42
|
+
by_namespace.setdefault(ns, []).append((name, entry))
|
|
43
|
+
|
|
44
|
+
tree = Tree("[bold]Datasets[/bold]")
|
|
45
|
+
for ns in sorted(by_namespace):
|
|
46
|
+
node = tree.add(f"[cyan]{ns}[/cyan]")
|
|
47
|
+
for name, entry in sorted(by_namespace[ns]):
|
|
48
|
+
node.add(f"{name} ({entry.get('row_count', '?')} rows)")
|
|
49
|
+
console.print(tree)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""``sunstone data lineage`` — a SPARQL query over PROV (spec 180 §8).
|
|
2
|
+
|
|
3
|
+
The facts this asks for are the ones `graph.dataset.description_to_graph`
|
|
4
|
+
writes when a version is published: each version pins a snapshot, the
|
|
5
|
+
snapshot was generated by an import activity, and the qualified generation
|
|
6
|
+
carries the instant. Until import provenance is asserted end to end (a
|
|
7
|
+
separate task), a dataset published without generation facts answers with
|
|
8
|
+
the version rows and empty activity columns.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from sunstone_data.cli._app import data_app
|
|
16
|
+
from sunstone_data.cli._errors import cli_errors
|
|
17
|
+
|
|
18
|
+
_QUERY = """PREFIX dcat: <{dcat}>
|
|
19
|
+
PREFIX prov: <{prov}>
|
|
20
|
+
PREFIX si: <{si}>
|
|
21
|
+
|
|
22
|
+
SELECT ?version ?snapshot ?activity ?generatedAt WHERE {{
|
|
23
|
+
GRAPH <{iri}> {{
|
|
24
|
+
<{iri}> dcat:hasVersion ?node .
|
|
25
|
+
?node dcat:version ?version .
|
|
26
|
+
OPTIONAL {{
|
|
27
|
+
?node si:hasSnapshot ?snapshot .
|
|
28
|
+
OPTIONAL {{ ?snapshot prov:wasGeneratedBy ?activity }}
|
|
29
|
+
OPTIONAL {{ ?snapshot prov:qualifiedGeneration/prov:atTime ?generatedAt }}
|
|
30
|
+
}}
|
|
31
|
+
}}
|
|
32
|
+
}}
|
|
33
|
+
ORDER BY ?version
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@data_app.command("lineage")
|
|
38
|
+
def lineage_cmd(
|
|
39
|
+
table: str = typer.Argument(..., help="Table reference (namespace.table)"),
|
|
40
|
+
json_output: bool = typer.Option(False, "--json", help="Print the raw SPARQL response body"),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Show where a dataset's versions came from.
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
sunstone data lineage un.health.cases
|
|
46
|
+
sunstone data lineage un.health.cases --json
|
|
47
|
+
"""
|
|
48
|
+
with cli_errors(json_output):
|
|
49
|
+
from sunstone_data.cli.commands.sparql import execute_query
|
|
50
|
+
from sunstone_data.graph import terms
|
|
51
|
+
from sunstone_data.query import TableRef
|
|
52
|
+
from sunstone_data.resolve import dataset_iri
|
|
53
|
+
|
|
54
|
+
# The dataset IRI is minted from the ref's namespace and table name,
|
|
55
|
+
# both already validated by TableRef.parse, so it is safe to inline.
|
|
56
|
+
iri = dataset_iri(TableRef.parse(table))
|
|
57
|
+
query = _QUERY.format(dcat=terms.DCAT, prov=terms.PROV, si=terms.DCAT_SI, iri=iri)
|
|
58
|
+
execute_query(query, json_output, empty_message="No lineage recorded for this dataset")
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""graph-server refs: ``branches``, ``branch create|delete``, ``tags``,
|
|
2
|
+
``tag create|delete`` (spec 180 §8).
|
|
3
|
+
|
|
4
|
+
Branch and tag names go into a URL path, so they are validated here before
|
|
5
|
+
the request is built.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from sunstone_data.cli._app import console, data_app
|
|
15
|
+
from sunstone_data.cli._errors import cli_errors
|
|
16
|
+
|
|
17
|
+
branch_app = typer.Typer(help="Manage graph-server branches", no_args_is_help=True)
|
|
18
|
+
tag_app = typer.Typer(help="Manage graph-server tags", no_args_is_help=True)
|
|
19
|
+
data_app.add_typer(branch_app, name="branch")
|
|
20
|
+
data_app.add_typer(tag_app, name="tag")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _client():
|
|
24
|
+
from sunstone_data.graph.client import get_graph_client
|
|
25
|
+
|
|
26
|
+
return get_graph_client()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _print_rows(rows: list[dict], columns: list[str], empty: str, json_output: bool) -> None:
|
|
30
|
+
if json_output:
|
|
31
|
+
print(json.dumps(rows, indent=2))
|
|
32
|
+
return
|
|
33
|
+
if not rows:
|
|
34
|
+
console.print(f"[yellow]{empty}[/yellow]")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
from rich.table import Table
|
|
38
|
+
|
|
39
|
+
out = Table(box=None, pad_edge=False)
|
|
40
|
+
for column in columns:
|
|
41
|
+
out.add_column(column.replace("_", " ").capitalize(), style="cyan" if column == "name" else None)
|
|
42
|
+
for row in rows:
|
|
43
|
+
out.add_row(*("" if row.get(c) is None else str(row.get(c)) for c in columns))
|
|
44
|
+
console.print(out)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@data_app.command("branches")
|
|
48
|
+
def branches_cmd(
|
|
49
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
50
|
+
) -> None:
|
|
51
|
+
"""List graph-server branches."""
|
|
52
|
+
with cli_errors(json_output):
|
|
53
|
+
rows = _client().list_branches()
|
|
54
|
+
_print_rows(rows, ["name", "parent", "head_txn", "created_by"], "No branches found", json_output)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@branch_app.command("create")
|
|
58
|
+
def branch_create_cmd(
|
|
59
|
+
name: str = typer.Argument(..., help="Name of the branch to create"),
|
|
60
|
+
parent: str = typer.Option(None, "--parent", "-p", help="Branch to fork from (server default: main)"),
|
|
61
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Create a branch."""
|
|
64
|
+
with cli_errors(json_output):
|
|
65
|
+
from sunstone_data.identifiers import validate_ref_name
|
|
66
|
+
|
|
67
|
+
validate_ref_name(name, "branch")
|
|
68
|
+
if parent is not None:
|
|
69
|
+
validate_ref_name(parent, "branch")
|
|
70
|
+
_client().create_branch(name, parent)
|
|
71
|
+
|
|
72
|
+
if json_output:
|
|
73
|
+
print(json.dumps({"name": name, "parent": parent}))
|
|
74
|
+
else:
|
|
75
|
+
console.print(f"[green]Created branch[/green] {name}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@branch_app.command("delete")
|
|
79
|
+
def branch_delete_cmd(
|
|
80
|
+
name: str = typer.Argument(..., help="Name of the branch to delete"),
|
|
81
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Delete a branch."""
|
|
84
|
+
with cli_errors(json_output):
|
|
85
|
+
from sunstone_data.identifiers import validate_ref_name
|
|
86
|
+
|
|
87
|
+
validate_ref_name(name, "branch")
|
|
88
|
+
_client().delete_branch(name)
|
|
89
|
+
|
|
90
|
+
if json_output:
|
|
91
|
+
print(json.dumps({"name": name, "deleted": True}))
|
|
92
|
+
else:
|
|
93
|
+
console.print(f"[green]Deleted branch[/green] {name}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@data_app.command("tags")
|
|
97
|
+
def tags_cmd(
|
|
98
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
99
|
+
) -> None:
|
|
100
|
+
"""List graph-server tags."""
|
|
101
|
+
with cli_errors(json_output):
|
|
102
|
+
rows = _client().list_tags()
|
|
103
|
+
_print_rows(rows, ["name", "branch", "at_txn"], "No tags found", json_output)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@tag_app.command("create")
|
|
107
|
+
def tag_create_cmd(
|
|
108
|
+
name: str = typer.Argument(..., help="Name of the tag to create"),
|
|
109
|
+
branch: str = typer.Option("main", "--branch", "-b", help="Branch to tag"),
|
|
110
|
+
at_txn: int = typer.Option(None, "--at-txn", help="Txn to pin (default: the branch head)"),
|
|
111
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Tag a branch at a txn."""
|
|
114
|
+
with cli_errors(json_output):
|
|
115
|
+
from sunstone_data.identifiers import validate_ref_name
|
|
116
|
+
|
|
117
|
+
validate_ref_name(name, "tag")
|
|
118
|
+
validate_ref_name(branch, "branch")
|
|
119
|
+
created = _client().create_tag(name, branch, at_txn)
|
|
120
|
+
|
|
121
|
+
if json_output:
|
|
122
|
+
print(json.dumps(created))
|
|
123
|
+
else:
|
|
124
|
+
console.print(f"[green]Created tag[/green] {name} on {branch} at txn {created.get('at_txn')}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@tag_app.command("delete")
|
|
128
|
+
def tag_delete_cmd(
|
|
129
|
+
name: str = typer.Argument(..., help="Name of the tag to delete"),
|
|
130
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Delete a tag."""
|
|
133
|
+
with cli_errors(json_output):
|
|
134
|
+
from sunstone_data.identifiers import validate_ref_name
|
|
135
|
+
|
|
136
|
+
validate_ref_name(name, "tag")
|
|
137
|
+
_client().delete_tag(name)
|
|
138
|
+
|
|
139
|
+
if json_output:
|
|
140
|
+
print(json.dumps({"name": name, "deleted": True}))
|
|
141
|
+
else:
|
|
142
|
+
console.print(f"[green]Deleted tag[/green] {name}")
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""``sunstone data show`` — dataset metadata as a table, Turtle or JSON-LD (spec 180 §8).
|
|
2
|
+
|
|
3
|
+
Turtle comes straight off graph-server, which serializes it. The table and
|
|
4
|
+
JSON-LD renderings are built here from the N-Triples form of the same graph.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from sunstone_data.cli._app import console, data_app
|
|
14
|
+
from sunstone_data.cli._errors import cli_errors
|
|
15
|
+
|
|
16
|
+
FORMATS = ("table", "turtle", "jsonld")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@data_app.command("show")
|
|
20
|
+
def show_cmd(
|
|
21
|
+
table: str = typer.Argument(..., help="Table reference (namespace.table[@branch])"),
|
|
22
|
+
output_format: str = typer.Option("table", "--format", "-f", help=f"One of: {', '.join(FORMATS)}"),
|
|
23
|
+
branch: str = typer.Option("main", "--branch", "-b", help="Branch to read the graph from"),
|
|
24
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON-LD (same as --format jsonld)"),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Show a dataset's metadata graph.
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
sunstone data show un.health.cases
|
|
30
|
+
sunstone data show un.health.cases --format turtle
|
|
31
|
+
sunstone data show un.health.cases@experiment --json
|
|
32
|
+
"""
|
|
33
|
+
with cli_errors(json_output):
|
|
34
|
+
chosen = "jsonld" if json_output else output_format
|
|
35
|
+
if chosen not in FORMATS:
|
|
36
|
+
raise ValueError(f"Unknown format {output_format!r}. Use one of: {', '.join(FORMATS)}")
|
|
37
|
+
|
|
38
|
+
from sunstone_data.graph.client import N_TRIPLES, get_graph_client
|
|
39
|
+
from sunstone_data.query import TableRef
|
|
40
|
+
from sunstone_data.resolve import dataset_iri
|
|
41
|
+
|
|
42
|
+
ref = TableRef.parse(table)
|
|
43
|
+
iri = dataset_iri(ref)
|
|
44
|
+
client = get_graph_client()
|
|
45
|
+
# A ref after `@` names a graph-server branch here: the graph is
|
|
46
|
+
# per-branch, and versions/snapshots are inside it, not selectors on it.
|
|
47
|
+
source_branch = ref.version_ref or branch
|
|
48
|
+
|
|
49
|
+
if chosen == "turtle":
|
|
50
|
+
print(client.get_graph(source_branch, iri))
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
from sunstone_data.graph.dataset import parse_ntriples
|
|
54
|
+
|
|
55
|
+
graph = parse_ntriples(client.get_graph(source_branch, iri, accept=N_TRIPLES))
|
|
56
|
+
|
|
57
|
+
if chosen == "jsonld":
|
|
58
|
+
print(json.dumps(to_jsonld(graph), indent=2))
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
_print_table(graph, iri)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def to_jsonld(graph) -> dict:
|
|
65
|
+
"""Render a graph as a small JSON-LD document.
|
|
66
|
+
|
|
67
|
+
One node object per subject, keyed by ``@id``, with predicates as CURIEs
|
|
68
|
+
where the namespace is known. Deliberately not a JSON-LD processor: no
|
|
69
|
+
framing, no nesting, no blank nodes (this project's serializer emits
|
|
70
|
+
none). Values stay expanded — ``{"@id": ...}`` for IRIs, ``{"@value":
|
|
71
|
+
...}`` with ``@type``/``@language`` for literals — so nothing is lost.
|
|
72
|
+
"""
|
|
73
|
+
from sunstone_data.graph.dataset import IRI, RDF_TYPE
|
|
74
|
+
from sunstone_data.graph.terms import PREFIXES
|
|
75
|
+
|
|
76
|
+
nodes: dict[str, dict] = {}
|
|
77
|
+
for triple in sorted(graph, key=lambda t: (t.subject.value, t.predicate.value, t.obj.value)):
|
|
78
|
+
node = nodes.setdefault(triple.subject.value, {"@id": triple.subject.value})
|
|
79
|
+
if triple.predicate == RDF_TYPE:
|
|
80
|
+
node.setdefault("@type", []).append(triple.obj.value)
|
|
81
|
+
continue
|
|
82
|
+
value = {"@id": triple.obj.value} if isinstance(triple.obj, IRI) else _literal(triple.obj)
|
|
83
|
+
node.setdefault(_curie(triple.predicate.value), []).append(value)
|
|
84
|
+
|
|
85
|
+
return {"@context": dict(PREFIXES), "@graph": [nodes[k] for k in sorted(nodes)]}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _literal(term) -> dict:
|
|
89
|
+
value = {"@value": term.value}
|
|
90
|
+
if term.language is not None:
|
|
91
|
+
value["@language"] = term.language
|
|
92
|
+
elif term.datatype is not None:
|
|
93
|
+
value["@type"] = term.datatype
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _curie(iri: str) -> str:
|
|
98
|
+
"""Shorten `iri` to ``prefix:local`` for a known namespace, else leave it."""
|
|
99
|
+
from sunstone_data.graph.terms import PREFIXES
|
|
100
|
+
|
|
101
|
+
for prefix, namespace in sorted(PREFIXES.items(), key=lambda kv: -len(kv[1])):
|
|
102
|
+
if iri.startswith(namespace):
|
|
103
|
+
return f"{prefix}:{iri[len(namespace) :]}"
|
|
104
|
+
return iri
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _relative(iri: str, base: str) -> str:
|
|
108
|
+
"""Show an IRI inside the dataset's graph relative to the dataset itself."""
|
|
109
|
+
if iri == base:
|
|
110
|
+
return "<>"
|
|
111
|
+
if iri.startswith(base):
|
|
112
|
+
return iri[len(base) :]
|
|
113
|
+
return _curie(iri)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _print_table(graph, iri: str) -> None:
|
|
117
|
+
from rich.table import Table
|
|
118
|
+
|
|
119
|
+
from sunstone_data.graph.dataset import IRI
|
|
120
|
+
|
|
121
|
+
if not graph:
|
|
122
|
+
console.print("[yellow]No metadata found[/yellow]")
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
out = Table(box=None, pad_edge=False)
|
|
126
|
+
out.add_column("Subject", style="cyan", overflow="fold")
|
|
127
|
+
out.add_column("Predicate", style="magenta", overflow="fold")
|
|
128
|
+
out.add_column("Object", overflow="fold")
|
|
129
|
+
for triple in sorted(graph, key=lambda t: (t.subject.value, t.predicate.value, t.obj.value)):
|
|
130
|
+
if isinstance(triple.obj, IRI):
|
|
131
|
+
obj = _relative(triple.obj.value, iri)
|
|
132
|
+
else:
|
|
133
|
+
obj = triple.obj.value
|
|
134
|
+
if triple.obj.language:
|
|
135
|
+
obj += f"@{triple.obj.language}"
|
|
136
|
+
elif triple.obj.datatype:
|
|
137
|
+
obj += f" ({_curie(triple.obj.datatype)})"
|
|
138
|
+
out.add_row(_relative(triple.subject.value, iri), _curie(triple.predicate.value), obj)
|
|
139
|
+
console.print(out)
|