sunstone-data-platform 1.4.0__tar.gz → 1.5.0__tar.gz

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 (74) hide show
  1. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/PKG-INFO +1 -1
  2. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/_version.py +2 -2
  3. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/conceptmap.py +26 -10
  4. sunstone_data_platform-1.5.0/src/sunstone_data/conceptmap.py +365 -0
  5. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/graph/client.py +31 -2
  6. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/assets/mirrors.py +1 -1
  7. sunstone_data_platform-1.4.0/src/sunstone_data/conceptmap.py +0 -233
  8. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/.gitignore +0 -0
  9. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/README.md +0 -0
  10. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/pyproject.toml +0 -0
  11. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/__init__.py +0 -0
  12. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/auth/__init__.py +0 -0
  13. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/auth/client_creds.py +0 -0
  14. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/auth/pkce.py +0 -0
  15. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/auth/stepup.py +0 -0
  16. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/auth/store.py +0 -0
  17. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/branch.py +0 -0
  18. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/catalog.py +0 -0
  19. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/__init__.py +0 -0
  20. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/_app.py +0 -0
  21. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/_errors.py +0 -0
  22. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/__init__.py +0 -0
  23. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/data_auth.py +0 -0
  24. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/data_import.py +0 -0
  25. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/data_info.py +0 -0
  26. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/data_publish.py +0 -0
  27. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/ls.py +0 -0
  28. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/prov.py +0 -0
  29. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/refs.py +0 -0
  30. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/show.py +0 -0
  31. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/snapshots.py +0 -0
  32. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/sparql.py +0 -0
  33. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/main.py +0 -0
  34. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/config.py +0 -0
  35. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/discovery.py +0 -0
  36. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/env_commands.py +0 -0
  37. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/env_section.py +0 -0
  38. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/graph/__init__.py +0 -0
  39. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/graph/dataset.py +0 -0
  40. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/graph/terms.py +0 -0
  41. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/identifiers.py +0 -0
  42. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/import_/__init__.py +0 -0
  43. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/import_/fetcher.py +0 -0
  44. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/import_/owner.py +0 -0
  45. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/import_/parser.py +0 -0
  46. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/import_/pipeline.py +0 -0
  47. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/import_/storage.py +0 -0
  48. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/metadata/__init__.py +0 -0
  49. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/metadata/builder.py +0 -0
  50. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/metadata/models.py +0 -0
  51. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/metadata/provenance.py +0 -0
  52. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/metadata/provenance_rdf.py +0 -0
  53. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/plugin.py +0 -0
  54. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/publish.py +0 -0
  55. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/push.py +0 -0
  56. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/queries/conceptmap-mappings.rq +0 -0
  57. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/query.py +0 -0
  58. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/resolve.py +0 -0
  59. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/sssom.py +0 -0
  60. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/url.py +0 -0
  61. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/utils/__init__.py +0 -0
  62. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/utils/checksum.py +0 -0
  63. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/utils/encoding.py +0 -0
  64. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/utils/naming.py +0 -0
  65. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/versioning/__init__.py +0 -0
  66. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/versioning/schema_evolution.py +0 -0
  67. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/__init__.py +0 -0
  68. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/assets/__init__.py +0 -0
  69. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/assets/transforms.py +0 -0
  70. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/definitions.py +0 -0
  71. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/io_manager.py +0 -0
  72. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/resources/__init__.py +0 -0
  73. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/resources/iceberg.py +0 -0
  74. {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/resources/refs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sunstone-data-platform
3
- Version: 1.4.0
3
+ Version: 1.5.0
4
4
  Summary: Sunstone Data Platform - Data lakehouse with semantic metadata
5
5
  Author-email: Sunstone Institute <stig@sunstone.institute>
6
6
  License-Expression: MIT
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.4.0'
22
- __version_tuple__ = version_tuple = (1, 4, 0)
21
+ __version__ = version = '1.5.0'
22
+ __version_tuple__ = version_tuple = (1, 5, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
 
10
10
  import typer
11
11
 
12
+ from sunstone_data.branch import current_data_branch
12
13
  from sunstone_data.cli._app import console, data_app
13
14
  from sunstone_data.cli._errors import cli_errors
14
15
 
@@ -16,11 +17,18 @@ conceptmap_app = typer.Typer(help="Push concept maps from map-concepts scope dir
16
17
  data_app.add_typer(conceptmap_app, name="conceptmap")
17
18
 
18
19
  _SCOPE_DIR = typer.Argument(
19
- ..., help="Scope directory written by map-concepts (reads scheme/*.ttl and scheme/mappings.sssom.tsv)"
20
+ ...,
21
+ help=(
22
+ "Scope directory written by /kg:map-concepts. Reads scheme/*.ttl (IRIs relative to the concept map) and "
23
+ "scheme/mappings.sssom.tsv, and uploads the files named by cmap:contentPath as private blobs"
24
+ ),
25
+ )
26
+ _BRANCH = typer.Option(
27
+ None,
28
+ "--branch",
29
+ "-b",
30
+ help="Data branch to write, never main (default: the normalized git branch, else the CI ref; fails if neither)",
20
31
  )
21
- # Concept maps are published content, so the default is main, not the git
22
- # branch of the directory the command runs in (DP-REQ-30).
23
- _BRANCH = typer.Option("main", "--branch", "-b", help="Branch to write")
24
32
  _BRANCH_READ = typer.Option("main", "--branch", "-b", help="Branch to read")
25
33
  _JSON = typer.Option(False, "--json", help="Output as JSON")
26
34
 
@@ -42,26 +50,34 @@ def _report(result, json_output: bool) -> None:
42
50
 
43
51
 
44
52
  @conceptmap_app.command("create")
45
- def create_cmd(scope_dir: Path = _SCOPE_DIR, branch: str = _BRANCH, json_output: bool = _JSON) -> None:
46
- """Allocate a new concept map and push the scope directory to it."""
53
+ def create_cmd(scope_dir: Path = _SCOPE_DIR, branch: str | None = _BRANCH, json_output: bool = _JSON) -> None:
54
+ """Allocate a new concept map and push the scope directory to it.
55
+
56
+ Writes to a data branch, never main; merge it with `sunstone data branch merge <branch>`.
57
+ Source content files go to the catalog namespace shared/conceptmap_<n> as non-public blobs,
58
+ linked from their source with cmap:contentBlob.
59
+ """
47
60
  with cli_errors(json_output):
48
61
  from sunstone_data import conceptmap
49
62
 
50
- _report(conceptmap.create(scope_dir, branch), json_output)
63
+ _report(conceptmap.create(scope_dir, branch or current_data_branch()), json_output)
51
64
 
52
65
 
53
66
  @conceptmap_app.command("update")
54
67
  def update_cmd(
55
68
  n: int = typer.Argument(..., help="Concept map id, the <n> in /conceptmap/<n>"),
56
69
  scope_dir: Path = _SCOPE_DIR,
57
- branch: str = _BRANCH,
70
+ branch: str | None = _BRANCH,
58
71
  json_output: bool = _JSON,
59
72
  ) -> None:
60
- """Replace an existing concept map with the scope directory."""
73
+ """Replace an existing concept map with the scope directory, on a data branch (never main).
74
+
75
+ A missing branch is forked from main. Unchanged content files are not uploaded again.
76
+ """
61
77
  with cli_errors(json_output):
62
78
  from sunstone_data import conceptmap
63
79
 
64
- _report(conceptmap.update(n, scope_dir, branch), json_output)
80
+ _report(conceptmap.update(n, scope_dir, branch or current_data_branch()), json_output)
65
81
 
66
82
 
67
83
  @conceptmap_app.command("sssom")
@@ -0,0 +1,365 @@
1
+ """Publish a map-concepts scope directory as a concept map (DP-REQ-323).
2
+
3
+ A scope directory written by `/kg:map-concepts` holds the concept map in
4
+ `scheme/*.ttl` (including the research record, `scheme/research.ttl`). The
5
+ platform mints the IRIs: each file uses relative IRIs and is parsed with base
6
+ `{graph_iri}/`, and `{graph_iri}/` itself (written `<>`) becomes the graph IRI,
7
+ which is also the concept scheme. So `<slug>` is a concept, `<ontology#Term>` an
8
+ ontology term, `<cq/…>`, `<prompt/…>` and `<source/…>` the research record. A
9
+ file may not declare its own base. Absolute IRIs under the local placeholder
10
+ `https://data.sunstone.institute/conceptmap/0` (used by tools before a push, and
11
+ in `mappings.sssom.tsv`) are moved onto the graph IRI too. Older scope dirs with
12
+ absolute `https://sunstone.institute/rdf/scope/<N>/concept…` / `…/ontology…`
13
+ IRIs still push: `…/concept` becomes the graph IRI and `…/ontology`
14
+ `{graph_iri}/ontology`. Local files are never changed, so the same directory
15
+ can be pushed again with `update`.
16
+
17
+ Each `cmap:Source` whose `cmap:contentPath` names a file in the scope dir has
18
+ that file uploaded as a non-public blob dataset in the catalog namespace
19
+ `shared/conceptmap_<n>` (see `content_namespace`), and gets `cmap:contentBlob
20
+ <dataset IRI>` before the graph is written. An unchanged file set is not
21
+ uploaded again.
22
+
23
+ `create` and `update` refuse `main`: the graph and the blobs go to a data
24
+ branch, which `sunstone data branch merge` copies into main. Only the number
25
+ allocation (`POST /conceptmaps/`) is global; it has no branch.
26
+
27
+ - `create` allocates a new concept map (`POST /conceptmaps/`) and PUTs the
28
+ merged files to its named graph.
29
+ - `update` replaces an existing concept map's named graph.
30
+ - `sssom` writes a concept map's mappings back out as an SSSOM file.
31
+
32
+ When the directory has `scheme/mappings.sssom.tsv`, its per-mapping metadata
33
+ is pushed with the Turtle (see `sunstone_data.sssom`).
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import re
39
+ from dataclasses import dataclass
40
+ from pathlib import Path
41
+ from typing import TYPE_CHECKING
42
+ from urllib.parse import urlsplit
43
+
44
+ from sunstone_data.config import get_settings
45
+ from sunstone_data.graph.client import N_TRIPLES, ConflictError, GraphClient, NotFound, get_graph_client
46
+
47
+ if TYPE_CHECKING:
48
+ from rdflib import Graph
49
+
50
+ _SCOPE = re.compile(r"^https://sunstone\.institute/rdf/scope/\d+/(concept|ontology)(.*)$", re.DOTALL)
51
+ _ANY_SCOPE = re.compile(r"^https://sunstone\.institute/rdf/scope/")
52
+ _CONCEPTMAP = re.compile(r"^(https?://[^/]+)/conceptmap/(\d+)(?=[/#]|$)")
53
+ _BASE_DECL = re.compile(r"^\s*(@base|base)\s*<", re.MULTILINE | re.IGNORECASE)
54
+ #: The local stand-in for the graph IRI before a push (scope tools, SSSOM `curie_map`).
55
+ PLACEHOLDER = "https://data.sunstone.institute/conceptmap/0"
56
+ CMAP = "https://sunstone.institute/rdf/conceptmap#"
57
+
58
+
59
+ class ConceptMapError(ValueError):
60
+ """The scope directory cannot be pushed as asked."""
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class ConceptMapPush:
65
+ """The outcome of a push.
66
+
67
+ Attributes:
68
+ conceptmap: The concept map id.
69
+ graph_iri: Its named graph, which is also the concept scheme IRI.
70
+ branch: Branch written.
71
+ triples: Triples in the pushed graph.
72
+ etag: Branch ETag after the write, unquoted.
73
+ """
74
+
75
+ conceptmap: int
76
+ graph_iri: str
77
+ branch: str
78
+ triples: int
79
+ etag: str
80
+
81
+
82
+ def conceptmap_iri(n: int, base_iri: str | None = None) -> str:
83
+ """The graph IRI of concept map `n`: `{origin of BASE_IRI}/conceptmap/{n}`."""
84
+ parts = urlsplit(base_iri or get_settings().base_iri)
85
+ return f"{parts.scheme}://{parts.netloc}/conceptmap/{n}"
86
+
87
+
88
+ def scheme_files(scope_dir: Path) -> list[Path]:
89
+ """The Turtle files of a scope directory: `scheme/*.ttl`, sorted."""
90
+ files = sorted((scope_dir / "scheme").glob("*.ttl"))
91
+ if not files:
92
+ raise ConceptMapError(f"no scheme/*.ttl under {scope_dir}")
93
+ return files
94
+
95
+
96
+ def mappings_file(scope_dir: Path) -> Path | None:
97
+ """The scope directory's `scheme/mappings.sssom.tsv`, if it has one."""
98
+ path = scope_dir / "scheme" / "mappings.sssom.tsv"
99
+ return path if path.is_file() else None
100
+
101
+
102
+ def check_branch(branch: str) -> None:
103
+ """Refuse `main`: a concept map is written to a data branch, then merged with `sunstone data branch merge`."""
104
+ if branch == "main":
105
+ raise ConceptMapError(
106
+ "concept maps are not written to main; push to a data branch (--branch, default: the current git "
107
+ "branch) and merge it with `sunstone data branch merge <branch>`"
108
+ )
109
+
110
+
111
+ def content_namespace(n: int) -> str:
112
+ """The catalog namespace holding concept map `n`'s source content blobs."""
113
+ return f"shared/conceptmap_{n}"
114
+
115
+
116
+ def named_conceptmaps(files: list[Path]) -> set[str]:
117
+ """Concept map graph IRIs the files already use, e.g. after an earlier rewrite."""
118
+ graph = _parse(files, PLACEHOLDER + "/")
119
+ return {m.group(0) for term in _iris(graph) if (m := _CONCEPTMAP.match(term)) and m.group(0) != PLACEHOLDER}
120
+
121
+
122
+ def build(files: list[Path], graph_iri: str, mappings: Path | None = None) -> Graph:
123
+ """Parse `files` with base `{graph_iri}/`, rewrite scope IRIs onto `graph_iri`, and return the merged graph.
124
+
125
+ With `mappings` (a `mappings.sssom.tsv`), its rows and header are added in
126
+ SSSOM's RDF form, rewritten the same way (see `sunstone_data.sssom`).
127
+
128
+ Raises:
129
+ ConceptMapError: A file declares a base or has `file:` IRIs, a file
130
+ names another concept map, a scope IRI is left that the rewrite
131
+ does not cover, or the mappings do not match the `skos:*Match`
132
+ triples.
133
+ """
134
+ from rdflib import Graph, URIRef
135
+
136
+ from sunstone_data import sssom
137
+
138
+ def rewrite_iri(iri: str) -> str:
139
+ if m := _SCOPE.match(iri):
140
+ kind, rest = m.groups()
141
+ return graph_iri + ("" if kind == "concept" else "/ontology") + rest
142
+ if iri == PLACEHOLDER or iri.startswith(PLACEHOLDER + "/"):
143
+ return graph_iri + iri[len(PLACEHOLDER) :]
144
+ return iri
145
+
146
+ def rewrite(term):
147
+ if not isinstance(term, URIRef):
148
+ return term
149
+ iri = rewrite_iri(str(term))
150
+ return URIRef(graph_iri if iri == graph_iri + "/" else iri)
151
+
152
+ out = Graph()
153
+ for s, p, o in _parse(files, graph_iri + "/"):
154
+ out.add((rewrite(s), rewrite(p), rewrite(o)))
155
+ if mappings is not None:
156
+ try:
157
+ sssom.add_to_graph(sssom.read(mappings), out, graph_iri, rewrite_iri)
158
+ except sssom.SSSOMError as e:
159
+ raise ConceptMapError(f"{mappings}: {e}") from e
160
+
161
+ names = _iris(out)
162
+ others = {m.group(0) for t in names if (m := _CONCEPTMAP.match(t)) and m.group(0) != graph_iri}
163
+ if others:
164
+ raise ConceptMapError(f"files name another concept map: {', '.join(sorted(others))}")
165
+ leftover = sorted(t for t in names if _ANY_SCOPE.match(t))
166
+ if leftover:
167
+ raise ConceptMapError(f"scope IRIs with no mapping: {', '.join(leftover[:5])}")
168
+ return out
169
+
170
+
171
+ def create(scope_dir: Path, branch: str, client: GraphClient | None = None) -> ConceptMapPush:
172
+ """Allocate a concept map and push `scope_dir` to it.
173
+
174
+ The files are parsed and checked before the id is allocated, so a bad
175
+ directory does not use one up.
176
+
177
+ Raises:
178
+ ConceptMapError: `branch` is main, or the files already name a
179
+ concept map (use `update`).
180
+ """
181
+ check_branch(branch)
182
+ files = scheme_files(scope_dir)
183
+ named = named_conceptmaps(files)
184
+ if named:
185
+ raise ConceptMapError(f"files already name {', '.join(sorted(named))}; use update")
186
+ mappings = mappings_file(scope_dir)
187
+ content_files(build(files, conceptmap_iri(0), mappings), scope_dir) # validate before allocating
188
+
189
+ client = client or get_graph_client()
190
+ alloc = client.allocate_conceptmap()
191
+ n, graph_iri = alloc["conceptmap"], alloc["graph_iri"]
192
+ graph = build(files, graph_iri, mappings)
193
+ _push_content(client, n, branch, graph, scope_dir)
194
+ etag = _put(client, branch, graph_iri, graph, "*")
195
+ return ConceptMapPush(n, graph_iri, branch, len(graph), etag)
196
+
197
+
198
+ def update(n: int, scope_dir: Path, branch: str, client: GraphClient | None = None) -> ConceptMapPush:
199
+ """Replace concept map `n` with `scope_dir`.
200
+
201
+ Raises:
202
+ ConceptMapError: `branch` is main, the concept map has no graph on
203
+ `branch` (a missing branch is first forked from main), or the
204
+ files name a different concept map.
205
+ """
206
+ check_branch(branch)
207
+ graph_iri = conceptmap_iri(n)
208
+ graph = build(scheme_files(scope_dir), graph_iri, mappings_file(scope_dir))
209
+ content_files(graph, scope_dir)
210
+
211
+ client = client or get_graph_client()
212
+ if branch not in {b["name"] for b in client.list_branches()}:
213
+ client.create_branch(branch) # fork from main, where the concept map was merged
214
+ try:
215
+ _, etag = client.get_graph_with_etag(branch, graph_iri, accept=N_TRIPLES)
216
+ except NotFound:
217
+ raise ConceptMapError(f"concept map {n} has no graph on {branch}; use create") from None
218
+ _push_content(client, n, branch, graph, scope_dir)
219
+ etag = _put(client, branch, graph_iri, graph, etag)
220
+ return ConceptMapPush(n, graph_iri, branch, len(graph), etag)
221
+
222
+
223
+ def sssom(n: int, branch: str, client: GraphClient | None = None) -> str:
224
+ """Concept map `n`'s mappings on `branch` as an SSSOM TSV file.
225
+
226
+ Fetches the named graph and runs the `/conceptmap/{n}/mappings` view
227
+ query on it locally, so any branch works (the view serves `main` only).
228
+
229
+ Raises:
230
+ ConceptMapError: The concept map has no graph on `branch`, or the
231
+ graph has no mapping set.
232
+ """
233
+ from rdflib import Graph
234
+
235
+ from sunstone_data import sssom as sssom_
236
+
237
+ graph_iri = conceptmap_iri(n)
238
+ client = client or get_graph_client()
239
+ try:
240
+ text = client.get_graph(branch, graph_iri, accept=N_TRIPLES)
241
+ except NotFound:
242
+ raise ConceptMapError(f"concept map {n} has no graph on {branch}") from None
243
+ try:
244
+ return sssom_.from_graph(Graph().parse(data=text, format="nt"), n, graph_iri)
245
+ except sssom_.SSSOMError as e:
246
+ raise ConceptMapError(str(e)) from e
247
+
248
+
249
+ def content_files(graph: Graph, scope_dir: Path) -> dict[Path, list]:
250
+ """Content files of the graph's `cmap:Source`s that exist, each with the Sources naming it.
251
+
252
+ Raises:
253
+ ConceptMapError: A `cmap:contentPath` is absolute or leads out of
254
+ `scope_dir` (through `..` or a symlink).
255
+ """
256
+ from rdflib import RDF, URIRef
257
+
258
+ root = scope_dir.resolve()
259
+ out: dict[Path, list] = {}
260
+ for source in graph.subjects(RDF.type, URIRef(CMAP + "Source")):
261
+ for value in graph.objects(source, URIRef(CMAP + "contentPath")):
262
+ rel = Path(str(value))
263
+ path = (root / rel).resolve()
264
+ if rel.is_absolute() or not path.is_relative_to(root):
265
+ raise ConceptMapError(f"cmap:contentPath {str(value)!r} of {source} is outside {scope_dir}")
266
+ if path.is_file():
267
+ out.setdefault(path, []).append(source)
268
+ return out
269
+
270
+
271
+ def _push_content(client: GraphClient, n: int, branch: str, graph: Graph, scope_dir: Path) -> None:
272
+ """Upload the content files as blobs and add `cmap:contentBlob` to their Sources.
273
+
274
+ The push API returns the existing push for the same namespace, branch and
275
+ files, so an unchanged scope uploads nothing.
276
+ """
277
+ import hashlib
278
+
279
+ from rdflib import URIRef
280
+ from sunstone.asset import AssetKind
281
+ from sunstone.push import PushError, PushOptions, PushPackage, PushResource, media_type_for, sanitize_segment
282
+
283
+ from sunstone_data.config import current_environment_name
284
+ from sunstone_data.push import push_package
285
+
286
+ files = content_files(graph, scope_dir)
287
+ if not files:
288
+ return
289
+ root = scope_dir.resolve()
290
+ resources: dict[str, PushResource] = {}
291
+ for path in sorted(files):
292
+ rel = path.relative_to(root).as_posix()
293
+ try:
294
+ name = sanitize_segment(path.stem)
295
+ except PushError as e:
296
+ raise ConceptMapError(f"{rel}: {e}") from e
297
+ if name in resources:
298
+ raise ConceptMapError(f"{rel} and {resources[name].slug} both map to blob name {name!r}")
299
+ data = path.read_bytes()
300
+ resources[name] = PushResource(
301
+ slug=rel,
302
+ name=name,
303
+ path=path,
304
+ kind=AssetKind.BLOB,
305
+ media_type=media_type_for(path),
306
+ sha256=hashlib.sha256(data).hexdigest(),
307
+ size=len(data),
308
+ metadata={"path": rel},
309
+ )
310
+ namespace = content_namespace(n)
311
+ package = PushPackage(f"sunstone:{namespace}", namespace, tuple(resources.values()), public=False)
312
+ # The caller chose the branch for the whole concept map; `yes` carries that to the blobs.
313
+ options = PushOptions(env=current_environment_name(), branch=branch, yes=True)
314
+ result = push_package(package, options, client=client)
315
+ iris = {d.name: d.iri for d in result.datasets if d.status != "failed" and d.iri}
316
+ if not result.ok or set(iris) != set(resources):
317
+ problems = "; ".join(f"{d.name}: {d.message or d.status}" for d in result.datasets if d.name not in iris)
318
+ raise ConceptMapError(f"content upload {result.push_id} to {namespace} failed: {problems or 'no result'}")
319
+ for name, r in resources.items():
320
+ for source in files[r.path]:
321
+ graph.add((source, URIRef(CMAP + "contentBlob"), URIRef(iris[name])))
322
+
323
+
324
+ def _put(client: GraphClient, branch: str, graph_iri: str, graph: Graph, if_match: str) -> str:
325
+ body = graph.serialize(format="nt", encoding="utf-8")
326
+ try:
327
+ return client.put_graph(branch, graph_iri, body, if_match=if_match, content_type=N_TRIPLES)
328
+ except ConflictError as e:
329
+ # `*` is refused on a branch with no commits yet (head txn-0); a
330
+ # concurrent commit also lands here. Retry once with the current head.
331
+ return client.put_graph(branch, graph_iri, body, if_match=e.etag, content_type=N_TRIPLES)
332
+
333
+
334
+ def _parse(files: list[Path], base: str) -> Graph:
335
+ """Parse each file with `base`, refusing files that set their own base or end up with `file:` IRIs."""
336
+ from rdflib import Graph, URIRef
337
+
338
+ graph = Graph()
339
+ for f in files:
340
+ try:
341
+ text = f.read_text(encoding="utf-8")
342
+ if _BASE_DECL.search(text):
343
+ raise ConceptMapError(f"{f}: declares @base/BASE; scope files use IRIs relative to the concept map")
344
+ part = Graph().parse(data=text, format="turtle", publicID=base)
345
+ except ConceptMapError:
346
+ raise
347
+ except Exception as e:
348
+ raise ConceptMapError(f"{f}: {e}") from e
349
+ if any(isinstance(t, URIRef) and str(t).startswith("file:") for triple in part for t in triple):
350
+ raise ConceptMapError(f"{f}: has file: IRIs; scope files use IRIs relative to the concept map")
351
+ graph += part
352
+ return graph
353
+
354
+
355
+ def _iris(graph: Graph) -> set[str]:
356
+ """IRIs in `graph`, including `xsd:anyURI` literals such as SSSOM prefix namespaces."""
357
+ from rdflib import Literal, URIRef
358
+ from rdflib.namespace import XSD
359
+
360
+ return {
361
+ str(t)
362
+ for triple in graph
363
+ for t in triple
364
+ if isinstance(t, URIRef) or (isinstance(t, Literal) and t.datatype == XSD.anyURI)
365
+ }
@@ -14,6 +14,7 @@ or the sentinel ``*`` for "whatever is current".
14
14
  from __future__ import annotations
15
15
 
16
16
  import fnmatch
17
+ import json
17
18
  import re
18
19
  from collections.abc import Callable
19
20
 
@@ -107,11 +108,39 @@ class PushRejected(RuntimeError):
107
108
 
108
109
 
109
110
  class Unavailable(RuntimeError):
110
- """graph SoR unavailable; safe to retry."""
111
+ """graph-server answered 503.
112
+
113
+ With no server message the text is the generic "safe to retry" line. When the
114
+ server gave a reason, the text is "graph-server unavailable: <reason>" and
115
+ makes no retry claim, because some 503s are configuration errors.
116
+ """
111
117
 
112
118
  def __init__(self, message: str = "graph SoR unavailable; safe to retry"):
113
119
  super().__init__(message)
114
120
 
121
+ @classmethod
122
+ def from_response(cls, resp: httpx.Response) -> "Unavailable":
123
+ """Build from a 503 response, using the server's reason when it sent one."""
124
+ detail = _server_message(resp)
125
+ if not detail:
126
+ return cls()
127
+ return cls(f"graph-server unavailable: {detail}")
128
+
129
+
130
+ def _server_message(resp: httpx.Response) -> str:
131
+ """The JSON ``error`` string if the body has one, else the stripped text body."""
132
+ try:
133
+ text = resp.text.strip()
134
+ except Exception:
135
+ return ""
136
+ try:
137
+ body = json.loads(text)
138
+ except ValueError:
139
+ return text
140
+ if isinstance(body, dict) and isinstance(body.get("error"), str) and body["error"].strip():
141
+ return body["error"].strip()
142
+ return text
143
+
115
144
 
116
145
  # graph-server's default protected-ref globs (DP-SPEC-14 D9). The deployed
117
146
  # rules file may differ; this client only uses them to word its 403s.
@@ -447,7 +476,7 @@ class GraphClient:
447
476
  if status == 415:
448
477
  return UnsupportedMedia()
449
478
  if status == 503:
450
- return Unavailable()
479
+ return Unavailable.from_response(resp)
451
480
  return None
452
481
 
453
482
 
@@ -76,7 +76,7 @@ def build_mirror_asset(config: SourceConfig) -> dg.AssetsDefinition:
76
76
  new_hash = compute_checksum_bytes(raw)
77
77
 
78
78
  # Compare with stored hash
79
- current_hash = iceberg.get_table_property(config.table_ref, "sunstone.import.content_hash")
79
+ current_hash = iceberg.get_table_property(config.table_ref, "sunstone.import.checksum")
80
80
 
81
81
  if new_hash == current_hash:
82
82
  context.log.info(f"No change for {config.table_ref} (hash: {new_hash[:12]}...)")
@@ -1,233 +0,0 @@
1
- """Publish a map-concepts scope directory as a concept map (DP-REQ-323).
2
-
3
- A scope directory written by `/ontology-engineering:map-concepts` holds the
4
- concept map in `scheme/*.ttl`. Its subjects use research-stack's
5
- `https://sunstone.institute/rdf/scope/<N>/concept…` and `…/ontology…` IRIs.
6
- The platform names them under the concept map's graph IRI instead, so they are
7
- rewritten on the way in: `…/concept` becomes the graph IRI (the scheme) and
8
- `…/ontology` becomes `{graph_iri}/ontology`. This is a stopgap until
9
- research-stack mints the platform IRIs itself (DP-160). Local files are never
10
- changed, so the same directory can be pushed again with `update`.
11
-
12
- - `create` allocates a new concept map (`POST /conceptmaps/`) and PUTs the
13
- merged files to its named graph.
14
- - `update` replaces an existing concept map's named graph.
15
- - `sssom` writes a concept map's mappings back out as an SSSOM file.
16
-
17
- When the directory has `scheme/mappings.sssom.tsv`, its per-mapping metadata
18
- is pushed with the Turtle (see `sunstone_data.sssom`).
19
- """
20
-
21
- from __future__ import annotations
22
-
23
- import re
24
- from dataclasses import dataclass
25
- from pathlib import Path
26
- from typing import TYPE_CHECKING
27
- from urllib.parse import urlsplit
28
-
29
- from sunstone_data.config import get_settings
30
- from sunstone_data.graph.client import N_TRIPLES, ConflictError, GraphClient, NotFound, get_graph_client
31
-
32
- if TYPE_CHECKING:
33
- from rdflib import Graph
34
-
35
- _SCOPE = re.compile(r"^https://sunstone\.institute/rdf/scope/\d+/(concept|ontology)(.*)$", re.DOTALL)
36
- _ANY_SCOPE = re.compile(r"^https://sunstone\.institute/rdf/scope/")
37
- _CONCEPTMAP = re.compile(r"^(https?://[^/]+)/conceptmap/(\d+)(?=[/#]|$)")
38
-
39
-
40
- class ConceptMapError(ValueError):
41
- """The scope directory cannot be pushed as asked."""
42
-
43
-
44
- @dataclass(frozen=True)
45
- class ConceptMapPush:
46
- """The outcome of a push.
47
-
48
- Attributes:
49
- conceptmap: The concept map id.
50
- graph_iri: Its named graph, which is also the concept scheme IRI.
51
- branch: Branch written.
52
- triples: Triples in the pushed graph.
53
- etag: Branch ETag after the write, unquoted.
54
- """
55
-
56
- conceptmap: int
57
- graph_iri: str
58
- branch: str
59
- triples: int
60
- etag: str
61
-
62
-
63
- def conceptmap_iri(n: int, base_iri: str | None = None) -> str:
64
- """The graph IRI of concept map `n`: `{origin of BASE_IRI}/conceptmap/{n}`."""
65
- parts = urlsplit(base_iri or get_settings().base_iri)
66
- return f"{parts.scheme}://{parts.netloc}/conceptmap/{n}"
67
-
68
-
69
- def scheme_files(scope_dir: Path) -> list[Path]:
70
- """The Turtle files of a scope directory: `scheme/*.ttl`, sorted."""
71
- files = sorted((scope_dir / "scheme").glob("*.ttl"))
72
- if not files:
73
- raise ConceptMapError(f"no scheme/*.ttl under {scope_dir}")
74
- return files
75
-
76
-
77
- def mappings_file(scope_dir: Path) -> Path | None:
78
- """The scope directory's `scheme/mappings.sssom.tsv`, if it has one."""
79
- path = scope_dir / "scheme" / "mappings.sssom.tsv"
80
- return path if path.is_file() else None
81
-
82
-
83
- def named_conceptmaps(files: list[Path]) -> set[str]:
84
- """Concept map graph IRIs the files already use, e.g. after an earlier rewrite."""
85
- graph = _parse(files)
86
- return {m.group(0) for term in _iris(graph) if (m := _CONCEPTMAP.match(term))}
87
-
88
-
89
- def build(files: list[Path], graph_iri: str, mappings: Path | None = None) -> Graph:
90
- """Parse `files`, rewrite scope IRIs onto `graph_iri`, and return the merged rdflib graph.
91
-
92
- With `mappings` (a `mappings.sssom.tsv`), its rows and header are added in
93
- SSSOM's RDF form, rewritten the same way (see `sunstone_data.sssom`).
94
-
95
- Raises:
96
- ConceptMapError: A file names another concept map, a scope IRI is
97
- left that the rewrite does not cover, or the mappings do not
98
- match the `skos:*Match` triples.
99
- """
100
- from rdflib import Graph, URIRef
101
-
102
- from sunstone_data import sssom
103
-
104
- def rewrite_iri(iri: str) -> str:
105
- if m := _SCOPE.match(iri):
106
- kind, rest = m.groups()
107
- return graph_iri + ("" if kind == "concept" else "/ontology") + rest
108
- return iri
109
-
110
- def rewrite(term):
111
- return URIRef(rewrite_iri(str(term))) if isinstance(term, URIRef) else term
112
-
113
- out = Graph()
114
- for s, p, o in _parse(files):
115
- out.add((rewrite(s), rewrite(p), rewrite(o)))
116
- if mappings is not None:
117
- try:
118
- sssom.add_to_graph(sssom.read(mappings), out, graph_iri, rewrite_iri)
119
- except sssom.SSSOMError as e:
120
- raise ConceptMapError(f"{mappings}: {e}") from e
121
-
122
- names = _iris(out)
123
- others = {m.group(0) for t in names if (m := _CONCEPTMAP.match(t)) and m.group(0) != graph_iri}
124
- if others:
125
- raise ConceptMapError(f"files name another concept map: {', '.join(sorted(others))}")
126
- leftover = sorted(t for t in names if _ANY_SCOPE.match(t))
127
- if leftover:
128
- raise ConceptMapError(f"scope IRIs with no mapping: {', '.join(leftover[:5])}")
129
- return out
130
-
131
-
132
- def create(scope_dir: Path, branch: str, client: GraphClient | None = None) -> ConceptMapPush:
133
- """Allocate a concept map and push `scope_dir` to it.
134
-
135
- The files are parsed and checked before the id is allocated, so a bad
136
- directory does not use one up.
137
-
138
- Raises:
139
- ConceptMapError: The files already name a concept map (use `update`).
140
- """
141
- files = scheme_files(scope_dir)
142
- named = named_conceptmaps(files)
143
- if named:
144
- raise ConceptMapError(f"files already name {', '.join(sorted(named))}; use update")
145
- mappings = mappings_file(scope_dir)
146
- build(files, conceptmap_iri(0), mappings) # validate before allocating
147
-
148
- client = client or get_graph_client()
149
- alloc = client.allocate_conceptmap()
150
- graph_iri = alloc["graph_iri"]
151
- graph = build(files, graph_iri, mappings)
152
- etag = _put(client, branch, graph_iri, graph, "*")
153
- return ConceptMapPush(alloc["conceptmap"], graph_iri, branch, len(graph), etag)
154
-
155
-
156
- def update(n: int, scope_dir: Path, branch: str, client: GraphClient | None = None) -> ConceptMapPush:
157
- """Replace concept map `n` with `scope_dir`.
158
-
159
- Raises:
160
- ConceptMapError: The concept map has no graph on `branch`, or the
161
- files name a different concept map.
162
- """
163
- graph_iri = conceptmap_iri(n)
164
- graph = build(scheme_files(scope_dir), graph_iri, mappings_file(scope_dir))
165
-
166
- client = client or get_graph_client()
167
- try:
168
- _, etag = client.get_graph_with_etag(branch, graph_iri, accept=N_TRIPLES)
169
- except NotFound:
170
- raise ConceptMapError(f"concept map {n} has no graph on {branch}; use create") from None
171
- etag = _put(client, branch, graph_iri, graph, etag)
172
- return ConceptMapPush(n, graph_iri, branch, len(graph), etag)
173
-
174
-
175
- def sssom(n: int, branch: str, client: GraphClient | None = None) -> str:
176
- """Concept map `n`'s mappings on `branch` as an SSSOM TSV file.
177
-
178
- Fetches the named graph and runs the `/conceptmap/{n}/mappings` view
179
- query on it locally, so any branch works (the view serves `main` only).
180
-
181
- Raises:
182
- ConceptMapError: The concept map has no graph on `branch`, or the
183
- graph has no mapping set.
184
- """
185
- from rdflib import Graph
186
-
187
- from sunstone_data import sssom as sssom_
188
-
189
- graph_iri = conceptmap_iri(n)
190
- client = client or get_graph_client()
191
- try:
192
- text = client.get_graph(branch, graph_iri, accept=N_TRIPLES)
193
- except NotFound:
194
- raise ConceptMapError(f"concept map {n} has no graph on {branch}") from None
195
- try:
196
- return sssom_.from_graph(Graph().parse(data=text, format="nt"), n, graph_iri)
197
- except sssom_.SSSOMError as e:
198
- raise ConceptMapError(str(e)) from e
199
-
200
-
201
- def _put(client: GraphClient, branch: str, graph_iri: str, graph: Graph, if_match: str) -> str:
202
- body = graph.serialize(format="nt", encoding="utf-8")
203
- try:
204
- return client.put_graph(branch, graph_iri, body, if_match=if_match, content_type=N_TRIPLES)
205
- except ConflictError as e:
206
- # `*` is refused on a branch with no commits yet (head txn-0); a
207
- # concurrent commit also lands here. Retry once with the current head.
208
- return client.put_graph(branch, graph_iri, body, if_match=e.etag, content_type=N_TRIPLES)
209
-
210
-
211
- def _parse(files: list[Path]) -> Graph:
212
- from rdflib import Graph
213
-
214
- graph = Graph()
215
- for f in files:
216
- try:
217
- graph.parse(f, format="turtle")
218
- except Exception as e:
219
- raise ConceptMapError(f"{f}: {e}") from e
220
- return graph
221
-
222
-
223
- def _iris(graph: Graph) -> set[str]:
224
- """IRIs in `graph`, including `xsd:anyURI` literals such as SSSOM prefix namespaces."""
225
- from rdflib import Literal, URIRef
226
- from rdflib.namespace import XSD
227
-
228
- return {
229
- str(t)
230
- for triple in graph
231
- for t in triple
232
- if isinstance(t, URIRef) or (isinstance(t, Literal) and t.datatype == XSD.anyURI)
233
- }