Skip to content

Commit 93d51f6

Browse files
authored
Merge pull request #60 from bci-oss/feature/check-samm-version
Detect, Validate & Upgrade SAMM files via CLI prettyprint
2 parents f82f8fe + a4f9f59 commit 93d51f6

File tree

99 files changed

+1542
-625
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

99 files changed

+1542
-625
lines changed

.github/workflows/push_request_check.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ jobs:
3535
cd core/esmf-aspect-meta-model-python
3636
poetry install
3737
poetry run download-samm-release
38+
poetry run download-samm-cli
3839
poetry build
3940
4041
- name: run tests

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,5 @@ dmypy.json
6969

7070
# SAMM
7171
core/esmf-aspect-meta-model-python/esmf_aspect_meta_model_python/samm_aspect_meta_model/samm/
72-
/core/esmf-aspect-meta-model-python/samm-cli/
72+
core/esmf-aspect-meta-model-python/esmf_aspect_meta_model_python/samm_cli/samm-cli/
7373
/core/esmf-aspect-meta-model-python/tests/integration/aspect_model_loader/java_models/resources/
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import pathlib
2+
import subprocess
3+
import tempfile
4+
5+
from typing import Optional, Union
6+
7+
from rdflib import Graph
8+
9+
from esmf_aspect_meta_model_python import utils
10+
from esmf_aspect_meta_model_python.constants import SAMM_VERSION
11+
from esmf_aspect_meta_model_python.samm_cli import SammCli
12+
13+
14+
class AdaptiveGraph(Graph): # TODO: avoid double parsing when an upgrade is not performed
15+
"""An RDF graph that can adaptively upgrade SAMM files using the SAMM CLI."""
16+
17+
_samm_cli = SammCli()
18+
19+
def __init__(self, samm_version: str = SAMM_VERSION, *args, **kwargs) -> None:
20+
super().__init__(*args, **kwargs)
21+
22+
self._samm_version = samm_version
23+
24+
def _upgrade_ttl_file(self, file_path: pathlib.Path) -> str:
25+
"""Run SAMM CLI prettyprint to upgrade a TTL file to the latest version."""
26+
try:
27+
return self._samm_cli.prettyprint(str(file_path), capture=True)
28+
except subprocess.CalledProcessError as e:
29+
raise RuntimeError(f"SAMM CLI failed for {file_path}:\n{e.stdout}\n{e.stderr}") from e
30+
31+
def _upgrade_source(self, source_path: pathlib.Path) -> str:
32+
print(f"[INFO] SAMM version mismatch detected in {source_path}. Upgrading...")
33+
34+
return self._upgrade_ttl_file(source_path)
35+
36+
def _upgrade_data(self, data: str | bytes) -> str:
37+
print( # TODO: improve logging
38+
f"[INFO] SAMM version mismatch detected in provided data (target v{self._samm_version}) Upgrading..."
39+
)
40+
41+
with tempfile.NamedTemporaryFile("wb", suffix=".ttl", delete=False) as tmp:
42+
tmp.write(data.encode("utf-8") if isinstance(data, str) else data)
43+
tmp_path = pathlib.Path(tmp.name)
44+
45+
try:
46+
return self._upgrade_ttl_file(tmp_path)
47+
finally:
48+
tmp_path.unlink(missing_ok=True)
49+
50+
def set_samm_version(self, samm_version: str) -> None:
51+
"""Set the SAMM version for this graph."""
52+
self._samm_version = samm_version
53+
54+
def parse( # type: ignore[override]
55+
self,
56+
*,
57+
source: Optional[str | pathlib.Path] = None,
58+
data: Optional[str | bytes] = None,
59+
**kwargs,
60+
) -> "AdaptiveGraph":
61+
"""
62+
Parse a TTL file into this graph, upgrading via SAMM CLI if version mismatch detected.
63+
64+
If a SAMM version mismatch is detected, the TTL file will be upgraded using the SAMM CLI prettyprint
65+
before parsing into this graph.
66+
67+
Args:
68+
source: Path to the TTL file as pathlib.Path or str.
69+
data: RDF content as string or bytes.
70+
**kwargs: Additional arguments passed to rdflib.Graph.parse().
71+
72+
Returns:
73+
self (AdaptiveGraph): The current graph instance with parsed data.
74+
75+
Raises:
76+
RuntimeError: If the SAMM CLI fails during the upgrade process.
77+
ValueError: If neither 'source' nor 'data' is provided, or if both are provided.
78+
"""
79+
if (source is None) == (data is None):
80+
raise ValueError("Either 'source' or 'data' must be provided.")
81+
82+
if source:
83+
input_source = source = pathlib.Path(source)
84+
upgrade_method = self._upgrade_source
85+
else:
86+
input_source = data # type: ignore[assignment]
87+
upgrade_method = self._upgrade_data # type: ignore[assignment]
88+
89+
if utils.has_version_mismatch_from_input(input_source, samm_version=self._samm_version):
90+
data = upgrade_method(input_source)
91+
source = None
92+
93+
super().parse(source=source, data=data, **kwargs)
94+
95+
return self
96+
97+
def __add__(self, other: Union["Graph", "AdaptiveGraph"]) -> "AdaptiveGraph":
98+
"""Override addition to propagate SAMM version to the resulting graph."""
99+
retval = super().__add__(other)
100+
101+
if isinstance(retval, AdaptiveGraph):
102+
if isinstance(other, AdaptiveGraph) and other._samm_version != self._samm_version:
103+
raise ValueError("SAMM version mismatch during addition.")
104+
105+
retval.set_samm_version(self._samm_version)
106+
107+
return retval # type: ignore[return-value]
108+
109+
def __sub__(self, other: Union["Graph", "AdaptiveGraph"]) -> "AdaptiveGraph":
110+
"""Override subtraction to propagate SAMM version to the resulting graph."""
111+
retval = super().__sub__(other)
112+
113+
if isinstance(retval, AdaptiveGraph):
114+
if isinstance(other, AdaptiveGraph) and other._samm_version != self._samm_version:
115+
raise ValueError("SAMM version mismatch during subtraction.")
116+
117+
retval.set_samm_version(self._samm_version)
118+
119+
return retval # type: ignore[return-value]
120+
121+
def __mul__(self, other: Union["Graph", "AdaptiveGraph"]) -> "AdaptiveGraph":
122+
"""Override multiplication to propagate SAMM version to the resulting graph."""
123+
retval = super().__mul__(other)
124+
125+
if isinstance(retval, AdaptiveGraph):
126+
if isinstance(other, AdaptiveGraph) and other._samm_version != self._samm_version:
127+
raise ValueError("SAMM version mismatch during multiplication.")
128+
129+
retval.set_samm_version(self._samm_version)
130+
131+
return retval # type: ignore[return-value]

core/esmf-aspect-meta-model-python/esmf_aspect_meta_model_python/constants.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,7 @@
1111

1212

1313
SAMM_VERSION = "2.2.0"
14-
JAVA_CLI_VERSION = "2.11.1"
14+
JAVA_CLI_VERSION = "2.12.0"
15+
16+
SAMM_NAMESPACE_PREFIX = "samm"
17+
SAMM_ORG_IDENTIFIER = "org.eclipse.esmf.samm"

core/esmf-aspect-meta-model-python/esmf_aspect_meta_model_python/loader/samm_graph.py

Lines changed: 25 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
99
#
1010
# SPDX-License-Identifier: MPL-2.0
11-
1211
from pathlib import Path
1312
from typing import List, Optional, Union
1413

1514
from rdflib import RDF, Graph, Node
1615

16+
import esmf_aspect_meta_model_python.constants as const
17+
18+
from esmf_aspect_meta_model_python import utils
19+
from esmf_aspect_meta_model_python.adaptive_graph import AdaptiveGraph
1720
from esmf_aspect_meta_model_python.base.aspect import Aspect
1821
from esmf_aspect_meta_model_python.base.base import Base
1922
from esmf_aspect_meta_model_python.base.property import Property
@@ -28,26 +31,20 @@
2831
class SAMMGraph:
2932
"""Class representing the SAMM graph and its operations."""
3033

31-
samm_namespace_prefix = "samm"
32-
3334
def __init__(self):
34-
self.rdf_graph = Graph()
35+
self.rdf_graph = AdaptiveGraph()
3536
self.samm_graph = Graph()
3637
self._cache = DefaultElementCache()
3738

38-
self.samm_version = None
39+
self.samm_version = const.SAMM_VERSION
3940
self.aspect = None
4041
self.model_elements = None
4142
self._samm = None
4243
self._reader = None
4344

4445
def __str__(self) -> str:
4546
"""Object string representation."""
46-
str_data = "SAMMGraph"
47-
if self.samm_version:
48-
str_data += f" v{self.samm_version}"
49-
50-
return str_data
47+
return f"SAMMGraph v{self.samm_version}"
5148

5249
def __repr__(self) -> str:
5350
"""Object representation."""
@@ -71,40 +68,6 @@ def _get_rdf_graph(self, input_data: Union[str, Path], input_type: Optional[str]
7168
self._reader = InputHandler(input_data, input_type).get_reader()
7269
self.rdf_graph = self._reader.read(input_data)
7370

74-
def _get_samm_version_from_rdf_graph(self) -> str:
75-
"""Extracts the SAMM version from the RDF graph.
76-
77-
This method searches through the RDF graph namespaces to find a prefix that indicates the SAMM version.
78-
79-
Returns:
80-
str: The SAMM version as a string extracted from the graph. Returns an empty string if no version
81-
can be conclusively identified.
82-
"""
83-
version = ""
84-
85-
for prefix, namespace in self.rdf_graph.namespace_manager.namespaces():
86-
if prefix == self.samm_namespace_prefix:
87-
urn_parts = namespace.split(":")
88-
version = urn_parts[-1].replace("#", "")
89-
90-
return version
91-
92-
def _get_samm_version(self):
93-
"""Retrieve and set the SAMM version from the RDF graph.
94-
95-
This method extracts the SAMM version from the RDF graph and assigns it to the `samm_version` attribute.
96-
If the SAMM version is not found, it raises a ValueError.
97-
98-
Raises:
99-
ValueError: If the SAMM version is not found in the RDF graph.
100-
"""
101-
self.samm_version = self._get_samm_version_from_rdf_graph()
102-
103-
if not self.samm_version:
104-
raise ValueError(
105-
f"SAMM version number was not found in graph. Could not process RDF graph {self.rdf_graph}."
106-
)
107-
10871
def _get_samm(self):
10972
"""Initialize the SAMM object with the current SAMM version."""
11073
self._samm = SAMM(self.samm_version)
@@ -132,7 +95,6 @@ def parse(self, input_data: Union[str, Path], input_type: Optional[str] = None):
13295
SAMMGraph: The instance of the SAMMGraph with the parsed data.
13396
"""
13497
self._get_rdf_graph(input_data, input_type)
135-
self._get_samm_version()
13698
self._get_samm()
13799
self._get_samm_graph()
138100

@@ -213,12 +175,30 @@ def load_aspect_model(self) -> Aspect:
213175

214176
graph = self.rdf_graph + self.samm_graph
215177
self._reader.prepare_aspect_model(graph)
178+
self._validate_samm_namespace_version(graph)
216179

217180
model_element_factory = ModelElementFactory(self.samm_version, graph, self._cache)
218181
self.aspect = model_element_factory.create_element(aspect_urn)
219182

220183
return self.aspect
221184

185+
def _validate_samm_namespace_version(self, graph: AdaptiveGraph) -> None:
186+
"""
187+
Validate that the SAMM version in the graph's namespace matches the detected SAMM version.
188+
189+
Args:
190+
graph: The RDF graph whose namespaces are to be validated.
191+
192+
Raises:
193+
ValueError: If the SAMM version in the graph's namespace does not match the detected SAMM version.
194+
"""
195+
for version in utils.get_samm_versions_from_graph(graph):
196+
if version != self.samm_version:
197+
raise ValueError(
198+
f"SAMM version mismatch. Found '{version}', but expected '{self.samm_version}'. "
199+
"Ensure all RDF files use a single, consistent SAMM version"
200+
)
201+
222202
def _get_aspect_from_elements(self):
223203
"""Geta and save the Aspect element from the model elements."""
224204
if self.model_elements:

core/esmf-aspect-meta-model-python/esmf_aspect_meta_model_python/resolver/base.py

Lines changed: 5 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
from pathlib import Path
1414
from typing import Union
1515

16-
from rdflib import Graph
16+
import esmf_aspect_meta_model_python.constants as const
1717

18+
from esmf_aspect_meta_model_python.adaptive_graph import AdaptiveGraph
1819
from esmf_aspect_meta_model_python.samm_meta_model import SammUnitsGraph
1920

2021

@@ -33,9 +34,9 @@ class ResolverInterface(ABC):
3334
"""
3435

3536
def __init__(self):
36-
self.graph = Graph()
37+
self.graph = AdaptiveGraph()
3738
self.samm_graph = None
38-
self.samm_version = ""
39+
self.samm_version = const.SAMM_VERSION
3940

4041
@abstractmethod
4142
def read(self, input_data: Union[str, Path]):
@@ -70,46 +71,5 @@ def _validate_samm_version(samm_version: str):
7071
elif samm_version > SammUnitsGraph.SAMM_VERSION:
7172
raise ValueError(f"{samm_version} is not supported SAMM version.")
7273

73-
def _get_samm_version_from_graph(self) -> str:
74-
"""
75-
Extracts the SAMM version from the RDF graph.
76-
77-
This method searches through the RDF graph namespaces to find a prefix that indicate the SAMM version.
78-
79-
Returns:
80-
str: The SAMM version as a string extracted from the graph. Returns an empty string if no version
81-
can be conclusively identified.
82-
"""
83-
version = ""
84-
85-
for prefix, namespace in self.graph.namespace_manager.namespaces():
86-
if prefix == "samm":
87-
urn_parts = namespace.split(":")
88-
version = urn_parts[-1].replace("#", "")
89-
90-
return version
91-
92-
def get_samm_version(self) -> str:
93-
"""
94-
Retrieves and validates the specified SAMM version from the provided Aspect model graph.
95-
96-
This method attempts to extract the version information of the SAMM from a graph. There is also a validation
97-
against known SAMM versions to ensure the version is supported and recognized.
98-
99-
100-
Returns:
101-
str: The validated version of SAMM if it is recognized and supported. If the version is not valid,
102-
an appropriate message or value indicating non-recognition is returned.
103-
104-
Raises:
105-
ValueError: If the extracted version is not supported or if it is not found in the Graph.
106-
107-
"""
108-
version = self._get_samm_version_from_graph()
109-
self._validate_samm_version(version)
110-
self.samm_version = version
111-
112-
return version
113-
114-
def prepare_aspect_model(self, graph: Graph):
74+
def prepare_aspect_model(self, graph: AdaptiveGraph):
11575
"""Resolve all additional graph elements if needed."""

core/esmf-aspect-meta-model-python/esmf_aspect_meta_model_python/resolver/data_string.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@
1212
from pathlib import Path
1313
from typing import Union
1414

15-
from rdflib import Graph
16-
15+
from esmf_aspect_meta_model_python.adaptive_graph import AdaptiveGraph
1716
from esmf_aspect_meta_model_python.resolver.base import ResolverInterface
1817

1918

@@ -33,7 +32,7 @@ def read(self, data_string: Union[str, Path]):
3332
Returns:
3433
RDFGraph: An object representing the RDF graph constructed from the input data.
3534
"""
36-
self.graph = Graph()
35+
self.graph = AdaptiveGraph()
3736
self.graph.parse(data=str(data_string) if isinstance(data_string, Path) else data_string)
3837

3938
return self.graph

0 commit comments

Comments
 (0)