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.
- rdfdiff-0.1.0.dist-info/METADATA +120 -0
- rdfdiff-0.1.0.dist-info/RECORD +16 -0
- rdfdiff-0.1.0.dist-info/WHEEL +4 -0
- rdfdiff-0.1.0.dist-info/entry_points.txt +2 -0
- rdfdiff-0.1.0.dist-info/licenses/LICENSE +21 -0
- semanticdiff/__init__.py +26 -0
- semanticdiff/changeset.py +243 -0
- semanticdiff/cli.py +82 -0
- semanticdiff/git_log.py +160 -0
- semanticdiff/history.py +80 -0
- semanticdiff/loader.py +38 -0
- semanticdiff/rename.py +76 -0
- semanticdiff/render/__init__.py +7 -0
- semanticdiff/render/rows.py +99 -0
- semanticdiff/render/text.py +127 -0
- semanticdiff/vocabulary.py +90 -0
|
@@ -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,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.
|
semanticdiff/__init__.py
ADDED
|
@@ -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()
|
semanticdiff/git_log.py
ADDED
|
@@ -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)
|
semanticdiff/history.py
ADDED
|
@@ -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,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
|
+
)
|