Skip to content

Commit 6455d5d

Browse files
committed
Merge branch 'develop' into release/3.2.0
2 parents 43aba9b + f633dd1 commit 6455d5d

15 files changed

Lines changed: 196196 additions & 8 deletions

Makefile

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,8 @@ convert-rdf-to-rdf:
433433
# OUTPUT_FOLDER_PATH: (Optional) Directory where a ReSpec data JSON file
434434
# should be stored (if not given).
435435
generate-respec:
436-
@## Add a key-value artefact entry to the metadata JSON file. \
436+
@set -eo pipefail; \
437+
## Add a key-value artefact entry to the metadata JSON file. \
437438
extend_metadata_json() { \
438439
local json_file="$$1"; \
439440
local key="$$2"; \
@@ -523,7 +524,8 @@ generate-respec:
523524
# then the default directory is used.
524525
#
525526
generate-asciidoc-glossary:
526-
@mkdir -p "${OUTPUT_GLOSSARY_PATH}"; \
527+
@set -eo pipefail; \
528+
mkdir -p "${OUTPUT_GLOSSARY_PATH}"; \
527529
## generate a model data JSON if not provided \
528530
GEN_MODEL_DATA_JSON=0; \
529531
if [ ! -e ${MODEL_DATA_JSON_PATH} ]; then \
@@ -644,7 +646,7 @@ merge-owl-shacl: get-jena-cli-tools get-rdf-differ
644646
# Get rdf-differ-ws repository
645647
get-rdf-differ:
646648
@if [ ! -d "rdf-differ-ws" ]; then \
647-
git clone --depth 1 --branch 2.1.0-beta https://github.com/meaningfy-ws/rdf-differ-ws.git; \
649+
git clone --depth 1 --branch 2.1.0 https://github.com/OP-TED/rdf-differ-ws.git; \
648650
rm -rf rdf-differ-ws/.git; \
649651
if ! grep -q "^rdf-differ-ws/" .gitignore 2>/dev/null; then \
650652
echo "rdf-differ-ws/" >> .gitignore; \

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,15 +380,15 @@ make owl-core XMI_INPUT_FILE_PATH=/home/mypc/work/model2owl/file1.xml OUTPUT_FOL
380380
```
381381

382382
### Generating diff reports
383-
Model2owl uses the [RDF Differ](https://meaningfy-ws.github.io/rdf-differ-ws/)
383+
Model2owl uses the [RDF Differ](https://github.com/OP-TED/rdf-differ-ws)
384384
tool to calculate differences between two RDF graphs and to generate diff
385385
reports in AsciiDoc and JSON formats. It compares either two OWL core files or
386386
two pairs consisting of an OWL core file and a SHACL shapes file. When SHACL
387387
files are provided, the comparison scope additionally covers domain, range, and
388388
cardinality properties. The comparison scope is defined in an application
389389
profile suitable for comparing OWL ontologies. Details on how the RDF Differ
390390
tool works, produced reports, and how to interpret them can be found in the [project
391-
documentation](https://github.com/meaningfy-ws/rdf-differ-ws/blob/master/README.md).
391+
documentation](https://github.com/OP-TED/rdf-differ-ws/blob/2.1.0/README.md).
392392

393393
Model2owl integrates the tool (via its CLI client) and provides a dedicated set
394394
of commands to interact with it (see the descriptions of the `run-rdf-diff` and

requirements-test.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ lxml
33
pandas
44
pyld
55
pytest
6+
pytest_bdd

src/rspec/rspec-generation.xsl

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@
273273
array{
274274
for $classParentName in $classParentsNames
275275
return
276-
let $parentElement := root($classElement)//element[@xmi:type = 'uml:Class' and @name = $classParentName],
276+
let $parentElement := (root($classElement)//element[@xmi:type = 'uml:Class' and @name = $classParentName])[1],
277277
$defaultLabel := f:lexicalQNameToWords($classParentName, fn:true()),
278278
$parentLabel := if ($parentElement) then f:getCustomLabelOrDefault($parentElement, $defaultLabel) else $defaultLabel
279279
return map{
@@ -290,6 +290,7 @@
290290
<xsl:template name="classProprietiesFromAttributes" as="array(*)">
291291
<xsl:param name="classElement" as="element()"/>
292292
<xsl:variable name="attributes" select="$classElement/attributes/attribute"/>
293+
<xsl:variable name="root" select="root($classElement)"/>
293294

294295
<xsl:sequence
295296
select="
@@ -301,7 +302,13 @@
301302
$propertyPrefix := fn:substring-before($attribute/@name, ':'),
302303
$attributeRangeCurie := $attribute/properties/@type,
303304
$attributeRangeDefaultLabel := f:lexicalQNameToWords($attributeRangeCurie, fn:true()),
304-
$attributeRangeLabel := f:getCustomLabelOrDefault(root($classElement)//element[@name = $attributeRangeCurie], $attributeRangeDefaultLabel)
305+
$targetClassElement := ($root//element[@name = $attributeRangeCurie])[1],
306+
$attributeRangeLabel := (
307+
if ($targetClassElement) then
308+
f:getCustomLabelOrDefault($targetClassElement, $attributeRangeDefaultLabel)
309+
else
310+
$attributeRangeDefaultLabel
311+
)
305312
return map{
306313
'uri': f:buildURIfromLexicalQName($attribute/@name),
307314
'name': string($attribute/@name),
@@ -364,7 +371,7 @@
364371
$propertyPrefix := fn:substring-before($association/@name, ':'),
365372
$associationRangeCurie := $association/target/@name,
366373
$associationRangeDefaultLabel := f:lexicalQNameToWords($associationRangeCurie, fn:true()),
367-
$targetClassElement := $root//element[@name = $associationRangeCurie],
374+
$targetClassElement := ($root//element[@name = $associationRangeCurie])[1],
368375
$associationRangeLabel := (if ($targetClassElement)
369376
then f:getCustomLabelOrDefault($targetClassElement, $associationRangeDefaultLabel)
370377
else $associationRangeDefaultLabel)

test/diffTests/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import pathlib
2+
3+
TEST_FOLDER = pathlib.Path(__file__).parent.parent
4+
PROJECT_DIR_PATH = TEST_FOLDER.parent
5+
TEST_DATA_DIR = TEST_FOLDER / "testData" / "rdf-differ-data"
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
Feature: OWL diffing
2+
3+
Background:
4+
Given the OWL files "tests/test_data/owl/ePO_sample-4.0.0.orig.ttl" and "tests/test_data/owl/ePO_sample-4.0.0.upd.ttl"
5+
And the test prefixes are defined
6+
7+
Scenario Outline: Diffing example resources in the OWL sample
8+
When the diff is run
9+
Then the report should contain the change for "<resource_type>","<instance>","<operation>","<predicate>","<old_value>","<new_value>"
10+
11+
Examples:
12+
| resource_type | instance | operation | predicate | old_value | new_value |
13+
| class | epo:AwardCriterion | added | | | |
14+
| class | epo:AdHocChannel | deleted | | | |
15+
| class | epo:AcquiringCentralPurchasingBody | changed | skos:prefLabel | | rdfs:label |
16+
| class | epo:AwardCriteriaSummary | updated | skos:prefLabel | Award criteria summary | Award criteria summarization |
17+
| datatype_property | epo:describesObjectiveParticipationRules | added | | | |
18+
| datatype_property | epo:describesProfessionRelevantLaw | deleted | | | |
19+
| object_property | epo:followsRulesSetBy | added | | | |
20+
| object_property | epo:exposesChannel | deleted | | | |

test/diffTests/steps/__init__.py

Whitespace-only changes.
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
from enum import Enum
2+
import json
3+
import os
4+
import subprocess
5+
from pathlib import Path
6+
7+
import pytest
8+
from pytest_bdd import given, when, then, scenario, parsers
9+
10+
from diffTests import PROJECT_DIR_PATH, TEST_DATA_DIR
11+
12+
13+
SCRIPT_PATH = PROJECT_DIR_PATH / "rdf-differ-ws" / "bash" / "rdf-differ.sh"
14+
BASE_URL = os.environ.get("RDF_DIFFER_BASE_URL", "http://localhost:4030")
15+
SAVED_REPORT = TEST_DATA_DIR / "ePO_sample-4.0.0-upd_diff-report.json"
16+
REUSE_SAVED_REPORT = os.environ.get(
17+
"RDF_DIFFER_REUSE_SAVED_REPORT", "true"
18+
).lower() in ["1", "true", "yes"]
19+
20+
# trick to run diffing only once and not for all scenarios
21+
_diff_cache = {}
22+
23+
SUPPORTED_TYPES = ("class", "datatype_property", "object_property")
24+
25+
@scenario("../features/owl_diff.feature", "Diffing example resources in the OWL sample")
26+
def test_owl_diff_feature():
27+
pass
28+
29+
30+
@pytest.fixture
31+
def ctx(tmp_path):
32+
"""Context fixture to store state between steps."""
33+
return {"tmpdir": tmp_path}
34+
35+
36+
@given("the test prefixes are defined")
37+
def prefixes(ctx):
38+
# Hardcoded prefixes for converting between the feature file
39+
# and the diff reports which are RDF/JSON with no prefixes
40+
ctx["prefixes"] = {
41+
"epo": "http://data.europa.eu/a4g/ontology#",
42+
"skos": "http://www.w3.org/2004/02/skos/core#",
43+
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
44+
}
45+
return ctx["prefixes"]
46+
47+
48+
@given(parsers.parse('the OWL files "{old}" and "{new}"'))
49+
def owl_files(ctx, old, new):
50+
# store absolute paths
51+
ctx["old"] = str(Path(old))
52+
ctx["new"] = str(Path(new))
53+
return ctx
54+
55+
56+
@when("the diff is run")
57+
def run_diff(ctx):
58+
script = os.path.abspath(os.path.join(os.path.dirname(__file__), SCRIPT_PATH))
59+
outdir = str(ctx["tmpdir"])
60+
old = ctx["old"]
61+
new = ctx["new"]
62+
profile = "owl-core-en-only"
63+
64+
# we keep a record of already run diffs to speed up tests (we set the cache at the end of this function)
65+
key = (old, new)
66+
if key in _diff_cache:
67+
ctx["report"] = _diff_cache[key]
68+
return
69+
70+
if REUSE_SAVED_REPORT:
71+
# use pre-existing report -- for faster testing/debugging of this test skipping the building of the report
72+
report_file = Path(
73+
os.path.abspath(os.path.join(os.path.dirname(__file__), SAVED_REPORT))
74+
)
75+
else:
76+
# run full workflow producing JSON output into temporary dir -- this should be the normal way
77+
# WARNING: as this runs an async call, sometimes this can fail due to race conditions
78+
# (the Celery task queue may be empty if called too fast or too late)
79+
result = subprocess.run(
80+
[
81+
script,
82+
"--base-url",
83+
BASE_URL,
84+
"--old",
85+
old,
86+
"--new",
87+
new,
88+
"--ap",
89+
profile,
90+
"--template",
91+
"json",
92+
"--output",
93+
outdir,
94+
"full",
95+
],
96+
capture_output=False,
97+
text=True,
98+
)
99+
100+
assert (
101+
result.returncode == 0
102+
), f"Diff script failed: {result.stderr}\n{result.stdout}"
103+
report_file = Path(outdir) / "diff.json"
104+
105+
assert report_file.exists(), f"Report file not found: {report_file}"
106+
with open(report_file) as fh:
107+
report = json.load(fh)
108+
ctx["report"] = report
109+
_diff_cache[key] = report
110+
111+
112+
def expand(prefixed, prefixes):
113+
if prefixed is None:
114+
return None
115+
if ":" not in prefixed:
116+
return prefixed
117+
p, local = prefixed.split(":", 1)
118+
if p not in prefixes:
119+
raise ValueError(f"Unknown prefix: {p}")
120+
return prefixes[p] + local
121+
122+
123+
def camel_to_snake(name: str) -> str:
124+
# Convert camelCase or mixed to snake_case (prefLabel -> pref_label)
125+
out = ""
126+
for ch in name:
127+
if ch.isupper():
128+
out += "_" + ch.lower()
129+
else:
130+
out += ch
131+
return out
132+
133+
def build_query_key(operation: str, resource_type: str, prop_snake: str) -> str:
134+
normalized = resource_type.replace("datatype_", "").replace("object_", "")
135+
return f"{operation}_property_{normalized}_{prop_snake}.rq"
136+
137+
# this is only possible in Behave (e.g. {predicate:NullableString})
138+
# @parse.with_pattern(r'.*')
139+
# def parse_nullable_string(text):
140+
# return text
141+
# register_type(NullableString=parse_nullable_string)
142+
143+
144+
# pytest-bdd currently lacks support for optional parameters (empty cells in the feature) in parse, so we use a regex trick
145+
# @then(parsers.parse('the report should contain the change for "{type}","{instance}","{operation}","{predicate}","{old_value}","{new_value}"'))
146+
@then(
147+
parsers.re(
148+
r'the report should contain the change for "(?P<resource_type>[^"]*)","(?P<instance>[^"]*)","(?P<operation>[^"]*)","(?P<predicate>[^"]*)","(?P<old_value>[^"]*)","(?P<new_value>[^"]*)"'
149+
)
150+
)
151+
def assert_report_contains(ctx, resource_type, instance, operation, predicate, old_value, new_value):
152+
report = ctx.get("report")
153+
prefixes = ctx.get("prefixes")
154+
155+
assert report is not None, "Report not found in context"
156+
157+
# normalize inputs
158+
predicate = predicate.strip() or None
159+
new_value = new_value.strip() or None
160+
old_value = old_value.strip() or None
161+
if resource_type == "data_property":
162+
resource_type = "datatype_property"
163+
164+
if operation in ("added", "deleted") and resource_type in SUPPORTED_TYPES:
165+
# unified handling for added/deleted instances
166+
key = f"{operation}_instance_{resource_type}.rq"
167+
assert key in report, f"Missing key {key} in report"
168+
full_instance = expand(instance, prefixes)
169+
bindings = report[key].get("results", {}).get("bindings", [])
170+
assert any(
171+
b.get("resource", {}).get("value") == full_instance for b in bindings
172+
), f"{operation.capitalize()} {resource_type} {full_instance} not found in {key}"
173+
174+
elif operation == "changed" and resource_type in SUPPORTED_TYPES:
175+
prop_prefix, prop_local = predicate.split(":", 1)
176+
prop_snake = camel_to_snake(prop_local)
177+
key = build_query_key(operation, resource_type, prop_snake)
178+
assert key in report, f"Missing key {key} in report"
179+
full_instance = expand(instance, prefixes)
180+
bindings = report[key].get("results", {}).get("bindings", [])
181+
binding = next(
182+
(b for b in bindings if b.get("resource", {}).get("value") == full_instance),
183+
None,
184+
)
185+
assert binding is not None, f"No binding for instance {full_instance} in {key}"
186+
# check oldProperty and newProperty values for the given instance
187+
# where the given predicate is oldProperty
188+
# and the given newValue is newProperty
189+
expected_old = expand(predicate, prefixes)
190+
expected_new = expand(new_value, prefixes)
191+
assert (
192+
binding.get("oldProperty", {}).get("value") == expected_old
193+
), f"oldProperty mismatch: expected {expected_old}, got {binding.get('oldProperty', {}).get('value')}"
194+
assert (
195+
binding.get("newProperty", {}).get("value") == expected_new
196+
), f"newProperty mismatch: expected {expected_new}, got {binding.get('newProperty', {}).get('value')}"
197+
elif operation == "updated" and resource_type in SUPPORTED_TYPES:
198+
prop_prefix, prop_local = predicate.split(":", 1)
199+
prop_snake = camel_to_snake(prop_local)
200+
key = build_query_key(operation, resource_type, prop_snake)
201+
assert key in report, f"Missing key {key} in report"
202+
full_instance = expand(instance, prefixes)
203+
bindings = report[key].get("results", {}).get("bindings", [])
204+
binding = next(
205+
(b for b in bindings if b.get("resource", {}).get("value") == full_instance),
206+
None,
207+
)
208+
assert binding is not None, f"No binding for instance {full_instance} in {key}"
209+
# check oldValue and newValue values for the given predicate of the given instance
210+
expected_old = old_value.strip() if old_value else None
211+
expected_new = new_value.strip() if new_value else None
212+
assert (
213+
binding.get("oldValue", {}).get("value") == expected_old
214+
), f"oldValue mismatch: expected {expected_old}, got {binding.get('oldValue', {}).get('value')}"
215+
assert (
216+
binding.get("newValue", {}).get("value") == expected_new
217+
), f"newValue mismatch: expected {expected_new}, got {binding.get('newValue', {}).get('value')}"
218+
else:
219+
raise AssertionError(
220+
f"Unsupported combination: resource_type={resource_type}, operation={operation}"
221+
)

0 commit comments

Comments
 (0)