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