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.
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/PKG-INFO +1 -1
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/_version.py +2 -2
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/conceptmap.py +26 -10
- sunstone_data_platform-1.5.0/src/sunstone_data/conceptmap.py +365 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/graph/client.py +31 -2
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/assets/mirrors.py +1 -1
- sunstone_data_platform-1.4.0/src/sunstone_data/conceptmap.py +0 -233
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/.gitignore +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/README.md +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/pyproject.toml +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/__init__.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/auth/__init__.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/auth/client_creds.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/auth/pkce.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/auth/stepup.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/auth/store.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/branch.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/catalog.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/__init__.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/_app.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/_errors.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/__init__.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/data_auth.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/data_import.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/data_info.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/data_publish.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/ls.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/prov.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/refs.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/show.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/snapshots.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/sparql.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/main.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/config.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/discovery.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/env_commands.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/env_section.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/graph/__init__.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/graph/dataset.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/graph/terms.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/identifiers.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/import_/__init__.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/import_/fetcher.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/import_/owner.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/import_/parser.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/import_/pipeline.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/import_/storage.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/metadata/__init__.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/metadata/builder.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/metadata/models.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/metadata/provenance.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/metadata/provenance_rdf.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/plugin.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/publish.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/push.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/queries/conceptmap-mappings.rq +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/query.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/resolve.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/sssom.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/url.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/utils/__init__.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/utils/checksum.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/utils/encoding.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/utils/naming.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/versioning/__init__.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/versioning/schema_evolution.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/__init__.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/assets/__init__.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/assets/transforms.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/definitions.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/io_manager.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/resources/__init__.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/resources/iceberg.py +0 -0
- {sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/resources/refs.py +0 -0
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (1,
|
|
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
|
-
...,
|
|
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
|
+
}
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/graph/client.py
RENAMED
|
@@ -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
|
|
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.
|
|
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
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/auth/__init__.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/auth/client_creds.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/auth/pkce.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/auth/stepup.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/auth/store.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/_errors.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/ls.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/prov.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/refs.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/cli/commands/show.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/discovery.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/env_commands.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/env_section.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/graph/__init__.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/graph/dataset.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/graph/terms.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/identifiers.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/import_/__init__.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/import_/fetcher.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/import_/owner.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/import_/parser.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/import_/pipeline.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/import_/storage.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/metadata/__init__.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/metadata/builder.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/metadata/models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/utils/__init__.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/utils/checksum.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/utils/encoding.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_data/utils/naming.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/definitions.py
RENAMED
|
File without changes
|
{sunstone_data_platform-1.4.0 → sunstone_data_platform-1.5.0}/src/sunstone_pipeline/io_manager.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|