rdfdiff 0.1.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.
@@ -0,0 +1,20 @@
1
+ name: checks
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ strategy:
11
+ matrix:
12
+ python: ["3.12", "3.13"]
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: astral-sh/setup-uv@v5
16
+ - run: uv sync --extra dev
17
+ - run: uv run ruff check .
18
+ - run: uv run ruff format --check .
19
+ - run: uv run mypy semanticdiff
20
+ - run: uv run pytest -q
@@ -0,0 +1,36 @@
1
+ name: publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v5
17
+ - run: uv build
18
+ - uses: actions/upload-artifact@v4
19
+ with:
20
+ name: distributions
21
+ path: dist/
22
+
23
+ publish:
24
+ needs: build
25
+ runs-on: ubuntu-latest
26
+ environment:
27
+ name: pypi
28
+ url: https://pypi.org/p/rdfdiff
29
+ permissions:
30
+ id-token: write
31
+ steps:
32
+ - uses: actions/download-artifact@v4
33
+ with:
34
+ name: distributions
35
+ path: dist/
36
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ .mypy_cache/
4
+ .ruff_cache/
5
+ __pycache__/
6
+ *.py[cod]
7
+ dist/
8
+ build/
9
+ *.egg-info/
rdfdiff-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 gbelbe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
rdfdiff-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.5
2
+ Name: rdfdiff
3
+ Version: 0.1.0
4
+ Summary: Read an RDF file's Git history as semantic change rather than text
5
+ Project-URL: Homepage, https://github.com/gbelbe/semanticdiff
6
+ Project-URL: Repository, https://github.com/gbelbe/semanticdiff
7
+ Project-URL: Issues, https://github.com/gbelbe/semanticdiff/issues
8
+ License: MIT License
9
+
10
+ Copyright (c) 2024 gbelbe
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Keywords: diff,git,ontology,owl,rdf,semantic-web,skos
31
+ Classifier: Development Status :: 4 - Beta
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Operating System :: OS Independent
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.12
37
+ Classifier: Programming Language :: Python :: 3.13
38
+ Classifier: Topic :: Software Development :: Version Control
39
+ Requires-Python: >=3.12
40
+ Requires-Dist: rdflib>=7.0
41
+ Requires-Dist: typer>=0.12
42
+ Provides-Extra: dev
43
+ Requires-Dist: mypy>=1.10; extra == 'dev'
44
+ Requires-Dist: pytest>=9.0; extra == 'dev'
45
+ Requires-Dist: ruff>=0.4; extra == 'dev'
46
+ Description-Content-Type: text/markdown
47
+
48
+ # rdfdiff
49
+
50
+ `rdfdiff` reads an RDF file's Git history as changes to its vocabulary,
51
+ rather than as changed characters. It reports classes, properties, individuals,
52
+
53
+ Equivalent RDF serializations produce no semantic change, so reformatting,
54
+ prefix changes, reordered triples, and blank-node relabeling do not hide actual
55
+ ontology evolution.
56
+
57
+ ## Install
58
+
59
+ ```sh
60
+ uv tool install rdfdiff
61
+ ```
62
+
63
+ Or run the current checkout:
64
+
65
+ ```sh
66
+ uv run semanticdiff log --repo /path/to/ontology-repository
67
+ ```
68
+
69
+ ## Use
70
+
71
+ Summarize every commit that changed an RDF file:
72
+
73
+ ```sh
74
+ semanticdiff log v0.1..v0.2 --repo /path/to/ontology-repository --file ontology.ttl
75
+ ```
76
+
77
+ Trace one entity through a revision range:
78
+
79
+ ```sh
80
+ semanticdiff show ex:Product v0.1..HEAD --repo /path/to/ontology-repository --file ontology.ttl
81
+ ```
82
+
83
+ When a repository has exactly one tracked RDF file, `--file` is optional. Use
84
+ `--text` with `log` to append Git's raw hunks after the semantic report.
85
+
86
+ ## Library API
87
+
88
+ ```python
89
+ from rdflib import Graph
90
+ from semanticdiff import compare
91
+
92
+ changes = compare(before_graph, after_graph)
93
+ ```
94
+
95
+ `read_history(repo, revision_range, path)` pairs each commit touching `path`
96
+ with its semantic change set. The public vocabulary is exported from the package
97
+ root: `Change`, `ChangeKind`, `ChangeSet`, `CommitChanges`, and `EntityKind`.
98
+
99
+ ## Development
100
+
101
+ ```sh
102
+ uv sync --extra dev
103
+ uv run ruff check .
104
+ uv run ruff format --check .
105
+ uv run mypy semanticdiff
106
+ uv run pytest -q
107
+ ```
108
+
109
+ The distribution is named `rdfdiff`; its Python import and command-line command
110
+ remain `semanticdiff`. The project deliberately has no dependency on Ster. Ster
111
+ can consume it as an optional integration, but the diff engine and command-line
112
+ tool remain usable with any RDF repository.
113
+
114
+ ## Releases
115
+
116
+ Releases are published to PyPI by the `pypi-publish.yml` GitHub Actions workflow
117
+ when a `v*` tag is pushed. PyPI trusted publishing must be configured for the
118
+ `gbelbe/semanticdiff` repository, the `pypi-publish.yml` workflow, and the
119
+ `pypi` environment before the first release tag is created. The pending PyPI
120
+ publisher must use the `rdfdiff` project name.
@@ -0,0 +1,73 @@
1
+ # rdfdiff
2
+
3
+ `rdfdiff` reads an RDF file's Git history as changes to its vocabulary,
4
+ rather than as changed characters. It reports classes, properties, individuals,
5
+
6
+ Equivalent RDF serializations produce no semantic change, so reformatting,
7
+ prefix changes, reordered triples, and blank-node relabeling do not hide actual
8
+ ontology evolution.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ uv tool install rdfdiff
14
+ ```
15
+
16
+ Or run the current checkout:
17
+
18
+ ```sh
19
+ uv run semanticdiff log --repo /path/to/ontology-repository
20
+ ```
21
+
22
+ ## Use
23
+
24
+ Summarize every commit that changed an RDF file:
25
+
26
+ ```sh
27
+ semanticdiff log v0.1..v0.2 --repo /path/to/ontology-repository --file ontology.ttl
28
+ ```
29
+
30
+ Trace one entity through a revision range:
31
+
32
+ ```sh
33
+ semanticdiff show ex:Product v0.1..HEAD --repo /path/to/ontology-repository --file ontology.ttl
34
+ ```
35
+
36
+ When a repository has exactly one tracked RDF file, `--file` is optional. Use
37
+ `--text` with `log` to append Git's raw hunks after the semantic report.
38
+
39
+ ## Library API
40
+
41
+ ```python
42
+ from rdflib import Graph
43
+ from semanticdiff import compare
44
+
45
+ changes = compare(before_graph, after_graph)
46
+ ```
47
+
48
+ `read_history(repo, revision_range, path)` pairs each commit touching `path`
49
+ with its semantic change set. The public vocabulary is exported from the package
50
+ root: `Change`, `ChangeKind`, `ChangeSet`, `CommitChanges`, and `EntityKind`.
51
+
52
+ ## Development
53
+
54
+ ```sh
55
+ uv sync --extra dev
56
+ uv run ruff check .
57
+ uv run ruff format --check .
58
+ uv run mypy semanticdiff
59
+ uv run pytest -q
60
+ ```
61
+
62
+ The distribution is named `rdfdiff`; its Python import and command-line command
63
+ remain `semanticdiff`. The project deliberately has no dependency on Ster. Ster
64
+ can consume it as an optional integration, but the diff engine and command-line
65
+ tool remain usable with any RDF repository.
66
+
67
+ ## Releases
68
+
69
+ Releases are published to PyPI by the `pypi-publish.yml` GitHub Actions workflow
70
+ when a `v*` tag is pushed. PyPI trusted publishing must be configured for the
71
+ `gbelbe/semanticdiff` repository, the `pypi-publish.yml` workflow, and the
72
+ `pypi` environment before the first release tag is created. The pending PyPI
73
+ publisher must use the `rdfdiff` project name.
@@ -0,0 +1,64 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "rdfdiff"
7
+ version = "0.1.0"
8
+ description = "Read an RDF file's Git history as semantic change rather than text"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = { file = "LICENSE" }
12
+ keywords = ["rdf", "ontology", "semantic-web", "git", "diff", "skos", "owl"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Topic :: Software Development :: Version Control",
22
+ ]
23
+ dependencies = [
24
+ "rdflib>=7.0",
25
+ "typer>=0.12",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=9.0",
31
+ "ruff>=0.4",
32
+ "mypy>=1.10",
33
+ ]
34
+
35
+ [project.scripts]
36
+ semanticdiff = "semanticdiff.cli:main"
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/gbelbe/semanticdiff"
40
+ Repository = "https://github.com/gbelbe/semanticdiff"
41
+ Issues = "https://github.com/gbelbe/semanticdiff/issues"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["semanticdiff"]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+ addopts = "--tb=short --strict-markers --strict-config"
49
+
50
+ [tool.ruff]
51
+ target-version = "py312"
52
+ line-length = 100
53
+
54
+ [tool.ruff.lint]
55
+ select = ["E", "W", "F", "I", "UP", "B", "C4", "SIM"]
56
+ ignore = [
57
+ # Keep str + Enum for the established serialized public API.
58
+ "UP042",
59
+ ]
60
+
61
+ [tool.mypy]
62
+ python_version = "3.12"
63
+ ignore_missing_imports = true
64
+ pretty = true
@@ -0,0 +1,26 @@
1
+ """semanticdiff — read a git history of an RDF file as semantic change.
2
+
3
+ A git diff reports characters. This package reports the vocabulary: which
4
+ classes, properties and concepts were added, modified, renamed, deprecated or
5
+ removed in each commit, so the raw text only has to be opened when the summary
6
+ is not enough.
7
+
8
+ It deliberately depends on nothing from `ster` — an import contract enforces
9
+ that — so it can be extracted into its own distribution unchanged.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from semanticdiff.changeset import compare
15
+ from semanticdiff.history import CommitChanges, read_history
16
+ from semanticdiff.vocabulary import Change, ChangeKind, ChangeSet, EntityKind
17
+
18
+ __all__ = [
19
+ "Change",
20
+ "ChangeKind",
21
+ "ChangeSet",
22
+ "CommitChanges",
23
+ "EntityKind",
24
+ "compare",
25
+ "read_history",
26
+ ]
@@ -0,0 +1,243 @@
1
+ """The semantic core: two graphs in, a set of change operations out.
2
+
3
+ Pure — no git, no files, no I/O — so the whole vocabulary of change is testable
4
+ from turtle strings. The engine is rdflib's `graph_diff` over the isomorphic
5
+ (blank-node-canonical) form of each graph, which is what makes a re-serialised
6
+ file report as unchanged.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import Counter, defaultdict
12
+ from dataclasses import dataclass
13
+
14
+ from rdflib import BNode, Graph, Literal, URIRef
15
+ from rdflib.compare import graph_diff, to_isomorphic
16
+ from rdflib.namespace import OWL, RDF, RDFS, SKOS
17
+ from rdflib.term import Node
18
+
19
+ from semanticdiff.rename import detect_renames
20
+ from semanticdiff.vocabulary import Change, ChangeKind, ChangeSet, EntityKind
21
+
22
+ # Predicates whose *object* is also touched by the triple. Adding
23
+ # `ex:Vehicle rdfs:subClassOf ex:Product` changes ex:Product too, although
24
+ # ex:Product is never a subject of the added triples; without this table the
25
+ # sentence "class X gained 2 properties" cannot be produced at all. Kept to
26
+ # four entries on purpose — every addition here is a new source of noise.
27
+ _OBJECT_ATTRIBUTION: dict[URIRef, tuple[str, str]] = {
28
+ RDFS.subClassOf: ("subclass", "subclasses"),
29
+ SKOS.broader: ("narrower concept", "narrower concepts"),
30
+ RDFS.domain: ("property", "properties"),
31
+ RDFS.range: ("property range", "property ranges"),
32
+ }
33
+
34
+ # Predicates reported as a value transition on the subject: "domain A → B".
35
+ _TRANSITIONS: dict[URIRef, str] = {RDFS.domain: "domain", RDFS.range: "range"}
36
+
37
+ _TYPE_KINDS: dict[URIRef, EntityKind] = {
38
+ OWL.Class: EntityKind.CLASS,
39
+ RDFS.Class: EntityKind.CLASS,
40
+ OWL.ObjectProperty: EntityKind.PROPERTY,
41
+ OWL.DatatypeProperty: EntityKind.PROPERTY,
42
+ OWL.AnnotationProperty: EntityKind.PROPERTY,
43
+ RDF.Property: EntityKind.PROPERTY,
44
+ SKOS.Concept: EntityKind.CONCEPT,
45
+ OWL.Ontology: EntityKind.ONTOLOGY,
46
+ OWL.NamedIndividual: EntityKind.INDIVIDUAL,
47
+ }
48
+
49
+ _LABEL_PREDICATES = (SKOS.prefLabel, RDFS.label)
50
+
51
+ _DEPRECATED = Literal(True)
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class _Delta:
56
+ """The two delta graphs, plus their incoming edges indexed by object.
57
+
58
+ The index exists because the alternative is quadratic. Attributing
59
+ "+2 properties" to a class means counting the triples pointing *at* it, and
60
+ scanning the delta once per entity costs entities x delta: on a commit
61
+ touching 1 827 entities that was 15s of a 16s diff. Indexed once, it is a
62
+ single pass and a dict lookup each.
63
+ """
64
+
65
+ only_base: Graph
66
+ only_later: Graph
67
+ lost: dict[Node, Counter[Node]]
68
+ gained: dict[Node, Counter[Node]]
69
+
70
+
71
+ def compare(base: Graph, later: Graph) -> ChangeSet:
72
+ """The change operations taking `base` to `later`."""
73
+ _, only_base, only_later = graph_diff(to_isomorphic(base), to_isomorphic(later))
74
+ delta = _Delta(
75
+ only_base=only_base,
76
+ only_later=only_later,
77
+ lost=_incoming_index(only_base),
78
+ gained=_incoming_index(only_later),
79
+ )
80
+ touched = _touched(only_base) | _touched(only_later) | _changed_owners(base, later)
81
+ changes = (_describe(uri, base, later, delta) for uri in sorted(touched))
82
+ return detect_renames(ChangeSet(tuple(c for c in changes if c is not None)))
83
+
84
+
85
+ def _incoming_index(delta: Graph) -> dict[Node, Counter[Node]]:
86
+ """`{object: {predicate: count}}` for the predicates that credit their object."""
87
+ index: dict[Node, Counter[Node]] = defaultdict(Counter)
88
+ for _, predicate, obj in delta:
89
+ if predicate in _OBJECT_ATTRIBUTION:
90
+ index[obj][predicate] += 1
91
+ return index
92
+
93
+
94
+ def _touched(delta: Graph) -> set[URIRef]:
95
+ """Every named entity implicated by the triples in `delta`."""
96
+ touched: set[URIRef] = set()
97
+ for subject, predicate, obj in delta:
98
+ if isinstance(subject, URIRef):
99
+ touched.add(subject)
100
+ if predicate in _OBJECT_ATTRIBUTION and isinstance(obj, URIRef):
101
+ touched.add(obj)
102
+ return touched
103
+
104
+
105
+ def _changed_owners(base: Graph, later: Graph) -> set[URIRef]:
106
+ """Named entities whose blank-node description changed.
107
+
108
+ A change inside a blank node — a restriction's cardinality, a member of an
109
+ anonymous union, an item in an RDF list — has a blank node as its subject, so
110
+ subject attribution alone discards it, and the triple joining the owner to the
111
+ blank node does not itself change. The edit would then be reported nowhere at
112
+ all, which a reader cannot tell apart from "nothing changed".
113
+
114
+ The blank nodes in the diff cannot be traced back: graph_diff canonicalises
115
+ them, so their identity no longer matches either source graph. Instead each
116
+ entity that owns a blank node has its description (itself plus everything
117
+ reachable through blank nodes) compared between the two revisions.
118
+
119
+ Only entities that actually own a blank node are examined, so an ontology
120
+ without any — the common case — pays nothing for this.
121
+ """
122
+ owners = {
123
+ subject
124
+ for graph in (base, later)
125
+ for subject, _, obj in graph
126
+ if isinstance(subject, URIRef) and isinstance(obj, BNode)
127
+ }
128
+ return {uri for uri in owners if not _same_description(base, later, uri)}
129
+
130
+
131
+ def _same_description(base: Graph, later: Graph, uri: URIRef) -> bool:
132
+ """Whether the entity's blank-node closure is the same graph on both sides."""
133
+ return to_isomorphic(base.cbd(uri)) == to_isomorphic(later.cbd(uri))
134
+
135
+
136
+ def _describe(uri: URIRef, base: Graph, later: Graph, delta: _Delta) -> Change | None:
137
+ """One change for `uri`, or None when it is only ever referenced, never defined."""
138
+ in_base = (uri, None, None) in base
139
+ in_later = (uri, None, None) in later
140
+ if not in_base and not in_later:
141
+ return None
142
+ source = later if in_later else base
143
+ label, lang = _label(source, uri)
144
+ return Change(
145
+ kind=_kind(uri, base, later, in_base=in_base, in_later=in_later),
146
+ entity=_entity_kind(source, uri),
147
+ uri=str(uri),
148
+ curie=_curie(source, uri),
149
+ label=label,
150
+ label_lang=lang,
151
+ detail=_detail(uri, base, later, delta),
152
+ of_class=_of_class(source, uri),
153
+ )
154
+
155
+
156
+ def _kind(uri: URIRef, base: Graph, later: Graph, *, in_base: bool, in_later: bool) -> ChangeKind:
157
+ if not in_base:
158
+ return ChangeKind.ADDED
159
+ if not in_later:
160
+ return ChangeKind.REMOVED
161
+ if (uri, OWL.deprecated, _DEPRECATED) in later and (
162
+ uri,
163
+ OWL.deprecated,
164
+ _DEPRECATED,
165
+ ) not in base:
166
+ return ChangeKind.DEPRECATED
167
+ return ChangeKind.MODIFIED
168
+
169
+
170
+ def _entity_kind(graph: Graph, uri: URIRef) -> EntityKind:
171
+ for type_uri in graph.objects(uri, RDF.type):
172
+ if isinstance(type_uri, URIRef) and (kind := _TYPE_KINDS.get(type_uri)) is not None:
173
+ return kind
174
+ if (uri, RDF.type, None) in graph:
175
+ return EntityKind.INDIVIDUAL
176
+ return EntityKind.OTHER
177
+
178
+
179
+ def _of_class(graph: Graph, uri: URIRef) -> str | None:
180
+ """The class an individual instantiates, as the name a reader should see.
181
+
182
+ Sorted so a multi-typed individual always reports the same one, and skipping
183
+ owl:NamedIndividual, which says nothing about what the thing is.
184
+ """
185
+ types = sorted(
186
+ str(t)
187
+ for t in graph.objects(uri, RDF.type)
188
+ if isinstance(t, URIRef) and t != OWL.NamedIndividual and t not in _TYPE_KINDS
189
+ )
190
+ if not types:
191
+ return None
192
+ cls = URIRef(types[0])
193
+ label, _ = _label(graph, cls)
194
+ return label or _curie(graph, cls)
195
+
196
+
197
+ def _label(graph: Graph, uri: URIRef) -> tuple[str | None, str | None]:
198
+ """The entity's preferred label, skos:prefLabel winning over rdfs:label."""
199
+ for predicate in _LABEL_PREDICATES:
200
+ literals = sorted(
201
+ (o for o in graph.objects(uri, predicate) if isinstance(o, Literal)),
202
+ key=lambda lit: (lit.language or "", str(lit)),
203
+ )
204
+ if literals:
205
+ return str(literals[0]), literals[0].language
206
+ return None, None
207
+
208
+
209
+ def _curie(graph: Graph, uri: Node) -> str:
210
+ """The prefixed form, or the full URI when no prefix is bound."""
211
+ try:
212
+ prefix, _, name = graph.namespace_manager.compute_qname(str(uri), generate=False)
213
+ except (KeyError, ValueError):
214
+ return str(uri)
215
+ return f"{prefix}:{name}" if prefix else str(uri)
216
+
217
+
218
+ def _detail(uri: URIRef, base: Graph, later: Graph, delta: _Delta) -> tuple[str, ...]:
219
+ return (*_transitions(uri, base, later, delta), *_attributions(uri, delta))
220
+
221
+
222
+ def _transitions(uri: URIRef, base: Graph, later: Graph, delta: _Delta) -> tuple[str, ...]:
223
+ """Single-valued predicates that moved, read as 'domain ex:Place → ex:Site'."""
224
+ parts = []
225
+ for predicate, name in _TRANSITIONS.items():
226
+ before = list(delta.only_base.objects(uri, predicate))
227
+ after = list(delta.only_later.objects(uri, predicate))
228
+ if len(before) == 1 and len(after) == 1:
229
+ parts.append(f"{name} {_curie(base, before[0])} → {_curie(later, after[0])}")
230
+ return tuple(parts)
231
+
232
+
233
+ def _attributions(uri: URIRef, delta: _Delta) -> tuple[str, ...]:
234
+ """What the entity gained or lost through triples pointing at it."""
235
+ gained = delta.gained.get(uri, Counter())
236
+ lost = delta.lost.get(uri, Counter())
237
+ parts = []
238
+ for predicate, (singular, plural) in _OBJECT_ATTRIBUTION.items():
239
+ for sign, counted in (("+", gained), ("-", lost)):
240
+ total = counted[predicate]
241
+ if total:
242
+ parts.append(f"{sign}{total} {singular if total == 1 else plural}")
243
+ return tuple(parts)