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,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)