rdfdiff 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,16 @@
1
+ semanticdiff/__init__.py,sha256=MAAV8z_tBFUgbniDPwKrSv_wrStKCkEOCyOu763LrYI,831
2
+ semanticdiff/changeset.py,sha256=99ULB_VhFHo8BItWDsANpeVo2u0reIqiyH2xlHumqFw,9577
3
+ semanticdiff/cli.py,sha256=6fE9T00f_lQHRhsc1IwhVMKM6DSFvr96OUIyP7r2CLc,2722
4
+ semanticdiff/git_log.py,sha256=2YT9xXUQIgaYfXITndI3b6UjHYNSFW3U-Q2J8y29nJA,5302
5
+ semanticdiff/history.py,sha256=kWfChXupb5N1USHhcYOkt120P1rjE1-6pbcr2CEYv04,2764
6
+ semanticdiff/loader.py,sha256=qACunIcTLS8dkKMazKLs_6MSHWfIZgG2Z415OPOnidE,990
7
+ semanticdiff/rename.py,sha256=iGuweAn4VcZXg_lkq7oJGyk7Bt9X-LX7gu6fKct6Gcc,2534
8
+ semanticdiff/vocabulary.py,sha256=stgZTFkZwCpfr3vmICM3HIHxrfRp-MzyZC9F6C8_5e8,2738
9
+ semanticdiff/render/__init__.py,sha256=Iv7PBMJRRXp77JUqe6yfZAKj0rRWe-YjrEAaIPLzpLg,221
10
+ semanticdiff/render/rows.py,sha256=fl9w_HckXPEkVbgFmgosDns_LZ4D5QClqL618m8GPfE,3450
11
+ semanticdiff/render/text.py,sha256=ceoj8VGf-RbRxRSsBZtUjpjBwOVdxOBktW8JB9XZiHo,4921
12
+ rdfdiff-0.1.0.dist-info/METADATA,sha256=wOyUf0-T-T9s0U40HY31qwomevZcnT-4FkIk7DQKghg,4403
13
+ rdfdiff-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
14
+ rdfdiff-0.1.0.dist-info/entry_points.txt,sha256=vo1WoU7qzzOXe8eq2ZkMPu4zV18AXDBS4hMLKuUYExI,55
15
+ rdfdiff-0.1.0.dist-info/licenses/LICENSE,sha256=4V9QsmJCQc_Zmtcr2VMGiIEtCVI6ALbQxCzYpzUusvQ,1063
16
+ rdfdiff-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ semanticdiff = semanticdiff.cli:main
@@ -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.
@@ -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)
semanticdiff/cli.py ADDED
@@ -0,0 +1,82 @@
1
+ """The semanticdiff command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from semanticdiff.git_log import RDF_SUFFIXES, GitError, list_tracked
10
+ from semanticdiff.history import read_history
11
+ from semanticdiff.render.text import render_commits, render_entity, render_summary
12
+
13
+ app = typer.Typer(
14
+ help="Read a git history of an ontology as semantic change, not as text.",
15
+ no_args_is_help=True,
16
+ add_completion=False,
17
+ )
18
+
19
+ _REPO = typer.Option(Path("."), "--repo", help="Repository to read.")
20
+ _FILE = typer.Option(None, "--file", help="Tracked RDF file. Autodetected when omitted.")
21
+ _RANGE = typer.Argument("HEAD", help="Revision range, e.g. v0.1..v0.2.")
22
+
23
+
24
+ @app.command()
25
+ def log(
26
+ rev_range: str = _RANGE,
27
+ repo: Path = _REPO,
28
+ file: str | None = _FILE,
29
+ text: bool = typer.Option(False, "--text", help="Also print the raw diff hunks."),
30
+ ) -> None:
31
+ """Summarise every commit in the range as semantic change."""
32
+ path, history = _load(repo, rev_range, file, with_text=text)
33
+ typer.echo(path)
34
+ typer.echo(render_summary(history, rev_range))
35
+ typer.echo("")
36
+ typer.echo(render_commits(history, show_text=text))
37
+
38
+
39
+ @app.command()
40
+ def show(
41
+ uri: str = typer.Argument(..., help="Entity to trace, as a CURIE or a full URI."),
42
+ rev_range: str = _RANGE,
43
+ repo: Path = _REPO,
44
+ file: str | None = _FILE,
45
+ ) -> None:
46
+ """Trace one entity through the history."""
47
+ _, history = _load(repo, rev_range, file, with_text=False)
48
+ typer.echo(render_entity(history, uri))
49
+
50
+
51
+ def _load(repo: Path, rev_range: str, file: str | None, *, with_text: bool) -> tuple[str, list]:
52
+ if not (repo / ".git").exists():
53
+ raise _fail(f"{repo} is not a git repository")
54
+ path = file or _detect(repo)
55
+ try:
56
+ return path, read_history(repo, rev_range, path, with_text=with_text)
57
+ except (GitError, FileNotFoundError) as exc:
58
+ raise _fail(str(exc)) from exc
59
+
60
+
61
+ def _detect(repo: Path) -> str:
62
+ """The repository's one tracked RDF file, or an error naming the alternatives."""
63
+ try:
64
+ tracked = list_tracked(repo)
65
+ except GitError as exc:
66
+ raise _fail(str(exc)) from exc
67
+ candidates = [f for f in tracked if Path(f).suffix.lower() in RDF_SUFFIXES]
68
+ if not candidates:
69
+ raise _fail("no RDF file is tracked in this repository; pass --file")
70
+ if len(candidates) > 1:
71
+ raise _fail(f"several RDF files tracked ({', '.join(candidates)}); pass --file")
72
+ return candidates[0]
73
+
74
+
75
+ def _fail(message: str) -> typer.Exit:
76
+ """Report on stdout and exit non-zero — errors are part of the output, not a traceback."""
77
+ typer.echo(message)
78
+ return typer.Exit(1)
79
+
80
+
81
+ def main() -> None:
82
+ app()
@@ -0,0 +1,160 @@
1
+ """The git adapter — the only module that shells out to git.
2
+
3
+ Everything above this file works on graphs and dataclasses, so a change of
4
+ version-control backend touches one module.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import subprocess
11
+ from collections.abc import Iterator
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ RDF_SUFFIXES = frozenset({".ttl", ".rdf", ".owl", ".jsonld", ".nt", ".n3", ".trig", ".nq"})
16
+
17
+ _FIELD = "\x1f"
18
+ _RECORD = "\x1e"
19
+ _FORMAT = f"{_RECORD}%H{_FIELD}%h{_FIELD}%an{_FIELD}%aI{_FIELD}%s"
20
+
21
+
22
+ class GitError(RuntimeError):
23
+ """git refused the command."""
24
+
25
+
26
+ class UnknownRevisionError(GitError):
27
+ """The revision or range does not resolve in this repository."""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Commit:
32
+ """One commit that touched the tracked ontology."""
33
+
34
+ sha: str
35
+ short_sha: str
36
+ author: str
37
+ date: str
38
+ subject: str
39
+ other_files: int = 0
40
+
41
+
42
+ def walk(repo: Path, rev_range: str, path: str) -> list[Commit]:
43
+ """Commits in `rev_range` that touched `path`, oldest first."""
44
+ _require_tracked(repo, path)
45
+ out = _git(repo, "log", "--reverse", f"--format={_FORMAT}", "--name-only", rev_range)
46
+ return list(_records(out, path))
47
+
48
+
49
+ def raw_diff(repo: Path, sha: str, path: str) -> str:
50
+ """The unified diff hunk for `path` in one commit — the last layer of the drill-down."""
51
+ return _git(repo, "show", "--format=", sha, "--", path)
52
+
53
+
54
+ def read_blob(repo: Path, rev: str, path: str) -> str | None:
55
+ """The file's content at a revision, or None when it does not exist there."""
56
+ result = _run(repo, "show", f"{rev}:{path}")
57
+ return None if result.returncode != 0 else result.stdout
58
+
59
+
60
+ def list_tracked(repo: Path) -> list[str]:
61
+ """Every path git tracks at HEAD."""
62
+ return [line for line in _git(repo, "ls-files").splitlines() if line]
63
+
64
+
65
+ def resolve(repo: Path, rev: str) -> str | None:
66
+ """`rev` as a commit sha, or None when it does not exist (a root's parent).
67
+
68
+ Used to key a parse cache: `<sha>^` and the sha of the commit before it name
69
+ the same revision, and only the resolved form makes them share an entry.
70
+ """
71
+ result = _run(repo, "rev-parse", "--verify", "--quiet", f"{rev}^{{commit}}")
72
+ return result.stdout.strip() or None
73
+
74
+
75
+ def repo_root(path: Path) -> Path | None:
76
+ """The repository `path` lives in, or None when it is not tracked anywhere."""
77
+ result = _run(path.parent if path.is_file() else path, "rev-parse", "--show-toplevel")
78
+ if result.returncode != 0:
79
+ return None
80
+ return Path(result.stdout.strip())
81
+
82
+
83
+ def tags_by_commit(repo: Path) -> dict[str, tuple[str, ...]]:
84
+ """`{sha: tags}` — the release markers a history list shows beside a commit."""
85
+ out = _git(repo, "for-each-ref", "--format=%(objectname) %(refname:short)", "refs/tags")
86
+ tags: dict[str, list[str]] = {}
87
+ for line in out.splitlines():
88
+ sha, _, name = line.partition(" ")
89
+ if name:
90
+ tags.setdefault(_peel(repo, sha), []).append(name)
91
+ return {sha: tuple(sorted(names)) for sha, names in tags.items()}
92
+
93
+
94
+ def _peel(repo: Path, sha: str) -> str:
95
+ """The commit a tag points at, following an annotated tag's own object."""
96
+ return _git(repo, "rev-list", "-n", "1", sha).strip() or sha
97
+
98
+
99
+ def _require_tracked(repo: Path, path: str) -> None:
100
+ """Fail loudly when the path was never in this repository at all.
101
+
102
+ Checked over the whole history rather than the requested range, so that an
103
+ empty range reports no commits instead of a missing file.
104
+ """
105
+ if not _git(repo, "log", "--all", "--format=%H", "-n", "1", "--", path).strip():
106
+ raise FileNotFoundError(f"{path} is not tracked in {repo}")
107
+
108
+
109
+ def _records(out: str, path: str) -> Iterator[Commit]:
110
+ for record in out.split(_RECORD):
111
+ if not record.strip():
112
+ continue
113
+ commit = _record(record, path)
114
+ if commit is not None:
115
+ yield commit
116
+
117
+
118
+ def _record(record: str, path: str) -> Commit | None:
119
+ header, _, body = record.strip("\n").partition("\n")
120
+ sha, short_sha, author, date, subject = header.split(_FIELD)
121
+ files = [line for line in body.splitlines() if line.strip()]
122
+ if path not in files:
123
+ return None
124
+ return Commit(
125
+ sha=sha,
126
+ short_sha=short_sha,
127
+ author=author,
128
+ date=date,
129
+ subject=subject,
130
+ other_files=sum(1 for f in files if Path(f).suffix.lower() not in RDF_SUFFIXES),
131
+ )
132
+
133
+
134
+ # git translates its messages, so a French machine reports "révision inconnue"
135
+ # where CI reports "unknown revision". Pin the locale rather than the wording.
136
+ _C_LOCALE = {"LC_ALL": "C", "LANG": "C"}
137
+
138
+
139
+ def _run(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
140
+ return subprocess.run(
141
+ ["git", "-C", str(repo), *args],
142
+ capture_output=True,
143
+ text=True,
144
+ check=False,
145
+ env={**os.environ, **_C_LOCALE},
146
+ )
147
+
148
+
149
+ def _git(repo: Path, *args: str) -> str:
150
+ result = _run(repo, *args)
151
+ if result.returncode != 0:
152
+ raise _translate(result.stderr)
153
+ return result.stdout
154
+
155
+
156
+ def _translate(stderr: str) -> GitError:
157
+ message = stderr.strip() or "git failed"
158
+ if "unknown revision" in stderr or "bad revision" in stderr:
159
+ return UnknownRevisionError(message)
160
+ return GitError(message)
@@ -0,0 +1,80 @@
1
+ """Orchestration: pair every commit in a range with its semantic change set."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from rdflib import Graph
9
+
10
+ from semanticdiff.changeset import compare
11
+ from semanticdiff.git_log import Commit, raw_diff, resolve, walk
12
+ from semanticdiff.loader import UnreadableRevisionError, graph_at_rev
13
+ from semanticdiff.vocabulary import ChangeSet
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class CommitChanges:
18
+ """One commit and what it did to the vocabulary."""
19
+
20
+ commit: Commit
21
+ changes: ChangeSet
22
+ unreadable: bool = False
23
+ raw_text: str | None = None
24
+
25
+
26
+ def read_history(
27
+ repo: Path,
28
+ rev_range: str,
29
+ path: str,
30
+ *,
31
+ with_text: bool = False,
32
+ graphs: dict[str, Graph] | None = None,
33
+ ) -> list[CommitChanges]:
34
+ """Every commit in `rev_range` touching `path`, oldest first, with its changes.
35
+
36
+ `graphs` memoises parsed revisions by revision name. Consecutive commits share
37
+ one — the parent of each is the one before it — so walking N commits parses
38
+ N+1 revisions rather than 2N, and a caller stepping through a history one
39
+ commit at a time can hand the same dict back to keep what it already read.
40
+ """
41
+ memo: dict[str, Graph] = {} if graphs is None else graphs
42
+ return [
43
+ _entry(repo, commit, path, with_text=with_text, graphs=memo)
44
+ for commit in walk(repo, rev_range, path)
45
+ ]
46
+
47
+
48
+ def _entry(
49
+ repo: Path, commit: Commit, path: str, *, with_text: bool, graphs: dict[str, Graph]
50
+ ) -> CommitChanges:
51
+ try:
52
+ later = _at(repo, commit.sha, path, graphs)
53
+ except UnreadableRevisionError:
54
+ # One bad revision must not end the walk — flag it and keep going.
55
+ return CommitChanges(commit=commit, changes=ChangeSet(()), unreadable=True)
56
+ return CommitChanges(
57
+ commit=commit,
58
+ changes=compare(_parent(repo, commit.sha, path, graphs), later),
59
+ raw_text=raw_diff(repo, commit.sha, path) if with_text else None,
60
+ )
61
+
62
+
63
+ def _at(repo: Path, rev: str, path: str, graphs: dict[str, Graph]) -> Graph:
64
+ """The graph at `rev`, parsed once per *commit*, not once per name.
65
+
66
+ Keyed on the resolved sha: `<sha>^` and the commit before it are the same
67
+ revision under two names, and only resolving makes them share an entry.
68
+ """
69
+ key = resolve(repo, rev) or rev
70
+ if key not in graphs:
71
+ graphs[key] = graph_at_rev(repo, rev, path) or Graph()
72
+ return graphs[key]
73
+
74
+
75
+ def _parent(repo: Path, sha: str, path: str, graphs: dict[str, Graph]) -> Graph:
76
+ """The graph one commit earlier — empty at the first commit, or if it was broken."""
77
+ try:
78
+ return _at(repo, f"{sha}^", path, graphs)
79
+ except UnreadableRevisionError:
80
+ return Graph()
semanticdiff/loader.py ADDED
@@ -0,0 +1,38 @@
1
+ """Reading the tracked RDF file as it stood at a revision."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from rdflib import Graph
8
+
9
+ from semanticdiff.git_log import read_blob
10
+
11
+ _FORMATS = {
12
+ ".ttl": "turtle",
13
+ ".n3": "n3",
14
+ ".nt": "nt",
15
+ ".nq": "nquads",
16
+ ".trig": "trig",
17
+ ".rdf": "xml",
18
+ ".owl": "xml",
19
+ ".xml": "xml",
20
+ ".jsonld": "json-ld",
21
+ }
22
+
23
+
24
+ class UnreadableRevisionError(RuntimeError):
25
+ """The file exists at that revision but does not parse as RDF."""
26
+
27
+
28
+ def graph_at_rev(repo: Path, rev: str, path: str) -> Graph | None:
29
+ """The parsed graph at `rev`, or None when the file does not exist there."""
30
+ blob = read_blob(repo, rev, path)
31
+ if blob is None:
32
+ return None
33
+ graph = Graph()
34
+ try:
35
+ graph.parse(data=blob, format=_FORMATS.get(Path(path).suffix.lower(), "turtle"))
36
+ except Exception as exc:
37
+ raise UnreadableRevisionError(f"{path} at {rev} does not parse: {exc}") from exc
38
+ return graph
semanticdiff/rename.py ADDED
@@ -0,0 +1,76 @@
1
+ """Rename detection.
2
+
3
+ A URI change under an unchanged label is a rename, not a delete plus an add.
4
+ Left undetected it is the ugliest false signal a graph diff produces, so it is
5
+ the one derived operation worth paying for here.
6
+
7
+ The rule is deliberately conservative: a removal and an addition pair only when
8
+ they agree on entity kind, label text *and* language tag, and when that key is
9
+ unique on both sides. Anything ambiguous stays reported as a delete and an add,
10
+ because a wrong pairing is worse than a missed one.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections import defaultdict
16
+ from collections.abc import Iterator
17
+
18
+ from semanticdiff.vocabulary import Change, ChangeKind, ChangeSet
19
+
20
+ _Key = tuple[object, str, str | None]
21
+
22
+
23
+ def detect_renames(changes: ChangeSet) -> ChangeSet:
24
+ """Fold matching removal/addition pairs into single rename operations."""
25
+ removed = _by_label(changes, ChangeKind.REMOVED)
26
+ added = _by_label(changes, ChangeKind.ADDED)
27
+ pairs = {
28
+ key: (removed[key][0], added[key][0])
29
+ for key in removed.keys() & added.keys()
30
+ if len(removed[key]) == 1 and len(added[key]) == 1
31
+ }
32
+ if not pairs:
33
+ return changes
34
+ return ChangeSet(tuple(_rewrite(changes, pairs)))
35
+
36
+
37
+ def _by_label(changes: ChangeSet, kind: ChangeKind) -> dict[_Key, list[Change]]:
38
+ grouped: dict[_Key, list[Change]] = defaultdict(list)
39
+ for change in changes:
40
+ if change.kind is kind and change.label is not None:
41
+ grouped[_key(change)].append(change)
42
+ return grouped
43
+
44
+
45
+ def _key(change: Change) -> _Key:
46
+ assert change.label is not None
47
+ return (change.entity, change.label, change.label_lang)
48
+
49
+
50
+ def _rewrite(changes: ChangeSet, pairs: dict[_Key, tuple[Change, Change]]) -> Iterator[Change]:
51
+ """Emit the changes with each paired removal/addition replaced by one rename."""
52
+ consumed = {change for pair in pairs.values() for change in pair}
53
+ emitted: set[_Key] = set()
54
+ for change in changes:
55
+ if change not in consumed:
56
+ yield change
57
+ continue
58
+ key = _key(change)
59
+ if key in emitted:
60
+ continue
61
+ emitted.add(key)
62
+ yield _renamed(*pairs[key])
63
+
64
+
65
+ def _renamed(old: Change, new: Change) -> Change:
66
+ return Change(
67
+ kind=ChangeKind.RENAMED,
68
+ entity=new.entity,
69
+ uri=new.uri,
70
+ curie=new.curie,
71
+ label=new.label,
72
+ label_lang=new.label_lang,
73
+ previous_uri=old.uri,
74
+ previous_curie=old.curie,
75
+ detail=new.detail,
76
+ )
@@ -0,0 +1,7 @@
1
+ """Renderers for a read history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from semanticdiff.render.text import render_commits, render_entity, render_summary
6
+
7
+ __all__ = ["render_commits", "render_entity", "render_summary"]
@@ -0,0 +1,99 @@
1
+ """One commit's changes as rows, before anything decides how to print them.
2
+
3
+ The tally — bulk individuals counted against their class — used to live inside
4
+ the text renderer, which meant a second front end had to either re-derive it or
5
+ parse text back. It belongs here: the text renderer formats these rows, and so
6
+ can anything else.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import defaultdict
12
+ from collections.abc import Iterable
13
+ from dataclasses import dataclass
14
+
15
+ from semanticdiff.vocabulary import Change, ChangeKind, EntityKind
16
+
17
+ # Above this many individuals of one class, in one commit, they are counted
18
+ # rather than named. On a real ontology individuals are ~99% of every commit and
19
+ # bury the class and property changes the reader came for. Below it nothing
20
+ # changes: three names say more than the number three.
21
+ TALLY_ABOVE = 5
22
+
23
+ # A rename or a deprecation is rare and is the most interesting thing that can
24
+ # happen to an individual, so it is named however many of its siblings counted.
25
+ _NEVER_TALLIED = (ChangeKind.RENAMED, ChangeKind.DEPRECATED)
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Row:
30
+ """A line of the change list: either one named entity, or a count of many."""
31
+
32
+ kind: ChangeKind
33
+ entity: EntityKind
34
+ name: str
35
+ # The identifier, kept beside the readable name rather than folded into it,
36
+ # so a front end can show them apart — "Vehicle" large, "ex:Vehicle" dim.
37
+ curie: str | None = None
38
+ detail: tuple[str, ...] = ()
39
+ previous: str | None = None
40
+ count: int = 1
41
+ of_class: str | None = None
42
+
43
+ @property
44
+ def counted(self) -> bool:
45
+ """Whether this row stands for many entities rather than one."""
46
+ return self.count > 1
47
+
48
+
49
+ def summarise(changes: Iterable[Change]) -> list[Row]:
50
+ """The rows for one commit: entities worth naming, then the counts."""
51
+ named, counted = _partition(changes)
52
+ return [_named_row(c) for c in named] + _counted_rows(counted)
53
+
54
+
55
+ def _partition(changes: Iterable[Change]) -> tuple[list[Change], dict]:
56
+ buckets: dict[tuple[ChangeKind, str | None], list[Change]] = defaultdict(list)
57
+ named: list[Change] = []
58
+ for change in changes:
59
+ key = _tally_key(change)
60
+ if key is None:
61
+ named.append(change)
62
+ else:
63
+ buckets[key].append(change)
64
+ counted = {k: g for k, g in buckets.items() if len(g) > TALLY_ABOVE}
65
+ for key, group in buckets.items():
66
+ if key not in counted:
67
+ named.extend(group)
68
+ return named, counted
69
+
70
+
71
+ def _tally_key(change: Change) -> tuple[ChangeKind, str | None] | None:
72
+ """The bucket a change is counted in, or None when it must be named."""
73
+ if change.entity is not EntityKind.INDIVIDUAL or change.kind in _NEVER_TALLIED:
74
+ return None
75
+ return (change.kind, change.of_class)
76
+
77
+
78
+ def _named_row(change: Change) -> Row:
79
+ return Row(
80
+ kind=change.kind,
81
+ entity=change.entity,
82
+ name=change.display,
83
+ curie=change.curie,
84
+ detail=change.detail,
85
+ previous=change.previous_curie,
86
+ )
87
+
88
+
89
+ def _counted_rows(counted: dict[tuple[ChangeKind, str | None], list[Change]]) -> list[Row]:
90
+ return [
91
+ Row(
92
+ kind=kind,
93
+ entity=EntityKind.INDIVIDUAL,
94
+ name=f"{len(group)} individuals",
95
+ count=len(group),
96
+ of_class=of_class,
97
+ )
98
+ for (kind, of_class), group in sorted(counted.items(), key=lambda kv: str(kv[0]))
99
+ ]
@@ -0,0 +1,127 @@
1
+ """Terminal rendering — the three layers of the drill-down.
2
+
3
+ Layer 0 is the summary over a range, layer 1 the per-commit change list, layer 2
4
+ one entity's story. Layer 3, the raw hunk, is only ever printed on request:
5
+ the whole point is that the text is the escape hatch, not the default.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import Counter
11
+ from collections.abc import Sequence
12
+
13
+ from semanticdiff.history import CommitChanges
14
+ from semanticdiff.render.rows import Row, summarise
15
+ from semanticdiff.vocabulary import Change, ChangeKind
16
+
17
+ _MARKERS = {
18
+ ChangeKind.ADDED: "+",
19
+ ChangeKind.REMOVED: "-",
20
+ ChangeKind.MODIFIED: "~",
21
+ ChangeKind.RENAMED: "»",
22
+ ChangeKind.DEPRECATED: "⊘",
23
+ }
24
+
25
+ _ORDER = (
26
+ ChangeKind.ADDED,
27
+ ChangeKind.MODIFIED,
28
+ ChangeKind.RENAMED,
29
+ ChangeKind.DEPRECATED,
30
+ ChangeKind.REMOVED,
31
+ )
32
+
33
+ _NO_COMMITS = "no commits touching this file in this range"
34
+ _INDENT = " "
35
+
36
+ # Above this many individuals of one class, in one commit, the report counts them
37
+ # instead of naming them. On a real ontology individuals are the overwhelming
38
+ # majority of every commit — 9 301 of 9 414 lines on adeo-retail-geography, 98.8%
39
+ # — and they bury the class and property changes the reader came for. Below it
40
+ # nothing changes: three names say more than the number three.
41
+ _TALLY_ABOVE = 5
42
+
43
+ # A rename or a deprecation is rare and is the most interesting thing that can
44
+ # happen to an individual, so it is named however many of its siblings are
45
+ # counted. "city-ro-sibiu → region-ro-sibiu" is the whole point of the report.
46
+ _NEVER_TALLIED = (ChangeKind.RENAMED, ChangeKind.DEPRECATED)
47
+
48
+
49
+ def render_summary(history: Sequence[CommitChanges], rev_range: str) -> str:
50
+ """Layer 0 — the headline: how many commits, and how much moved."""
51
+ if not history:
52
+ return f"{rev_range} — {_NO_COMMITS}"
53
+ counts: Counter[ChangeKind] = Counter()
54
+ for entry in history:
55
+ counts.update(entry.changes.counts())
56
+ tallies = [f"{counts[kind]} {kind.value}" for kind in _ORDER if counts[kind]]
57
+ headline = f"{rev_range} — {_plural(len(history), 'commit')}"
58
+ return f"{headline}\n{' '.join(tallies)}" if tallies else headline
59
+
60
+
61
+ def render_commits(history: Sequence[CommitChanges], *, show_text: bool = False) -> str:
62
+ """Layer 1 — every commit, and what it did to the vocabulary."""
63
+ if not history:
64
+ return _NO_COMMITS
65
+ return "\n\n".join(_block(entry, show_text=show_text) for entry in history)
66
+
67
+
68
+ def render_entity(history: Sequence[CommitChanges], uri: str) -> str:
69
+ """Layer 2 — only the commits that touched one entity."""
70
+ blocks = [
71
+ _entity_block(entry, hits) for entry in history if (hits := entry.changes.for_uri(uri))
72
+ ]
73
+ if not blocks:
74
+ return f"{uri} never changed in this range"
75
+ return "\n\n".join(blocks)
76
+
77
+
78
+ def _block(entry: CommitChanges, *, show_text: bool) -> str:
79
+ lines = [_header(entry), *_change_lines(entry)]
80
+ if entry.commit.other_files:
81
+ lines.append(f"{_INDENT}({_plural(entry.commit.other_files, 'non-RDF file')} changed)")
82
+ if show_text and entry.raw_text:
83
+ lines.append(entry.raw_text)
84
+ return "\n".join(lines)
85
+
86
+
87
+ def _entity_block(entry: CommitChanges, hits: tuple[Change, ...]) -> str:
88
+ return "\n".join([_header(entry), *(_row_line(r) for r in summarise(hits))])
89
+
90
+
91
+ def _header(entry: CommitChanges) -> str:
92
+ commit = entry.commit
93
+ return f"● {commit.short_sha} {commit.subject} — {commit.author} {commit.date[:10]}"
94
+
95
+
96
+ def _change_lines(entry: CommitChanges) -> list[str]:
97
+ if entry.unreadable:
98
+ return [f"{_INDENT}(ontology unreadable at this revision)"]
99
+ if not entry.changes:
100
+ # The walk only lists commits that touched the file, so an empty
101
+ # changeset means the bytes moved and the graph did not: the file was
102
+ # re-spelled, not edited. Say that outright — "no semantic change" alone
103
+ # reads like the tool gave up, when it is in fact the answer.
104
+ return [f"{_INDENT}(no semantic change \u2014 pure formatting)"]
105
+ return [_row_line(row) for row in summarise(entry.changes)]
106
+
107
+
108
+ def _row_line(row: Row) -> str:
109
+ where = f" ({row.of_class})" if row.counted and row.of_class else ""
110
+ if row.counted:
111
+ return f"{_INDENT}{_MARKERS[row.kind]}{row.name}{where}"
112
+ detail = f" {' \u00b7 '.join(row.detail)}" if row.detail else ""
113
+ name = _row_name(row)
114
+ return f"{_INDENT}{_MARKERS[row.kind]} {row.entity.value:<10} {name}{detail}"
115
+
116
+
117
+ def _row_name(row: Row) -> str:
118
+ """The readable name, with the identifier beside it when they differ."""
119
+ if row.kind is ChangeKind.RENAMED:
120
+ return f"{row.previous} \u2192 {row.curie}"
121
+ if row.curie and row.curie != row.name:
122
+ return f"{row.name} ({row.curie})"
123
+ return row.name
124
+
125
+
126
+ def _plural(count: int, noun: str) -> str:
127
+ return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
@@ -0,0 +1,90 @@
1
+ """The change vocabulary — what semanticdiff can say about an edit.
2
+
3
+ The operation names follow the basic half of COnto-Diff (Hartung, Groß & Rahm):
4
+ insert / delete / update, plus the two derived operations worth the cost here —
5
+ a rename, and a deprecation. Complex operations (merge, split, subtree move) are
6
+ deliberately out of scope.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import Counter
12
+ from collections.abc import Iterator
13
+ from dataclasses import dataclass
14
+ from enum import Enum
15
+
16
+
17
+ class EntityKind(str, Enum):
18
+ """What sort of thing changed."""
19
+
20
+ CLASS = "class"
21
+ PROPERTY = "property"
22
+ INDIVIDUAL = "individual"
23
+ CONCEPT = "concept"
24
+ ONTOLOGY = "ontology"
25
+ OTHER = "other"
26
+
27
+
28
+ class ChangeKind(str, Enum):
29
+ """What happened to it."""
30
+
31
+ ADDED = "added"
32
+ REMOVED = "removed"
33
+ MODIFIED = "modified"
34
+ RENAMED = "renamed"
35
+ DEPRECATED = "deprecated"
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Change:
40
+ """One change operation on one entity.
41
+
42
+ `uri` is the full URI and `curie` its prefixed form (falling back to the full
43
+ URI when no prefix is bound), because the summary reads in CURIEs but the
44
+ caller may address an entity either way.
45
+ """
46
+
47
+ kind: ChangeKind
48
+ entity: EntityKind
49
+ uri: str
50
+ curie: str
51
+ label: str | None = None
52
+ label_lang: str | None = None
53
+ previous_uri: str | None = None
54
+ previous_curie: str | None = None
55
+ detail: tuple[str, ...] = ()
56
+ # For an individual, the class it instantiates, already resolved to the name
57
+ # a reader should see (label when there is one, else the CURIE) by the same
58
+ # rule as `display`. Carried on the change so a summary can count individuals
59
+ # against their class without a second pass over the graph.
60
+ of_class: str | None = None
61
+
62
+ @property
63
+ def display(self) -> str:
64
+ """The name a human should see — the label when there is one."""
65
+ return self.label or self.curie
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class ChangeSet:
70
+ """The changes between two versions of a graph."""
71
+
72
+ changes: tuple[Change, ...] = ()
73
+
74
+ def __iter__(self) -> Iterator[Change]:
75
+ return iter(self.changes)
76
+
77
+ def __len__(self) -> int:
78
+ return len(self.changes)
79
+
80
+ def counts(self) -> Counter[ChangeKind]:
81
+ """How many changes of each kind — the material for the summary line."""
82
+ return Counter(change.kind for change in self.changes)
83
+
84
+ def for_uri(self, uri: str) -> tuple[Change, ...]:
85
+ """Changes touching `uri`, given either its full URI or its CURIE."""
86
+ return tuple(
87
+ change
88
+ for change in self.changes
89
+ if uri in (change.uri, change.curie, change.previous_uri, change.previous_curie)
90
+ )