diff --git a/README.md b/README.md index 7fabdd34..ce8a1b98 100644 --- a/README.md +++ b/README.md @@ -269,21 +269,64 @@ type: `http://spdx.invalid./AbstractClass`, but this is not preferred. ### Pre-Release models -`shacl2code` has a custom annotation that can be used to mark an entire -ontology as "pre-release" meaning it is still subject to break changes. In the -python bindings, this will cause a `FutureWarning` to be emitted. In order to -do this, you must declare your ontology, and then use the custom annotation to -mark it as pre-release: +`shacl2code` can detect if an ontology is a "pre-release" version (still +subject to breaking changes). For language bindings that support it (such as +Python), importing a pre-release ontology binding will emit a warning +(e.g. `FutureWarning`). -```ttl +Pre-release status can be specified explicitly via command-line options, +or inferred automatically from various ontology annotations. For example, +```ttl a ow:Ontology ; - sh-to-code:isPreRelease true - . + sh-to-code:isPreRelease true + . ``` +In the event of conflicting annotations, `shacl2code` evaluates pre-release +status using the following order of precedence (1 = the highest priority): + +1. **`--pre-release` or `--no-pre-release` command-line options**: + Explicitly marks the generated bindings as pre-release or stable, + overriding any annotations in the input ontology. +2. **`sh-to-code:isPreRelease`**: + The `shacl2code` custom boolean annotation + (`sh-to-code:isPreRelease true` or `false`). +3. **`adms:status` (EU SEMIC Vocabulary)**: + If the status is + ``, + it is considered a pre-release. + Other values in the dataset-status vocabulary space (e.g. `COMPLETED`) + indicate a stable release. +4. **`adms:status` (Original ADMS Vocabulary)**: + If the status is ``, + it is considered a pre-release. + Other values in the ADMS status namespace (e.g. `Completed`) + indicate a stable release. +5. **`bibo:status` (Bibliographic Ontology)**: + If set to ``, it is + considered a pre-release. + Other values in the BIBO status namespace (e.g. `published`, `legal`) + indicate a stable release. +6. **`schema:creativeWorkStatus`**: + If set to `"Draft"` or `"Incomplete"`, it is considered a pre-release. + Other values (e.g. `"Published"`) indicate a stable release. +7. **`vs:term_status`**: + If set to `"unstable"` or `"testing"`, it is considered a pre-release. + Other values (e.g. `"stable"`) indicate a stable release. +8. **`owl:versionInfo` (pre-release extension)**: + If the version string contains a pre-release extension suffix + (e.g., `-alpha`, `-beta`, `-dev`, `-rc`, `-SNAPSHOT`, `.alpha`, etc.). +9. **`owl:versionInfo` (major version zero)**: + If the version string corresponds to a major version zero in + [Semantic Versioning][semver] (e.g., `0.7.1`). +10. **Default Fallback**: + If none of the above are present, the ontology is assumed to be a stable + release (`false`). + Note that the IRI of the ontology must be the prefix of all IRIs that belong to that ontology. [pytest]: https://www.pytest.org [pytest-cov]: https://pytest-cov.readthedocs.io/en/latest/ +[semver]: https://semver.org/ diff --git a/pyproject.toml b/pyproject.toml index e58cfe30..339a0722 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dev = [ "mypy >= 1.19.1", # latest version for Python 3.9 is 1.19.x "pyrefly >= 0.55.0", "pyright >= 1.1.403", - "pyshacl >= 0.25.0", + "pyshacl >= 0.25.0, < 0.40.0", # 0.40.0 uses Python 3.10+ type union "pytest >= 7.4", "pytest-cov >= 4.1", "pytest-xdist >= 3.5", diff --git a/src/shacl2code/main.py b/src/shacl2code/main.py index bc71a140..9ce7604b 100644 --- a/src/shacl2code/main.py +++ b/src/shacl2code/main.py @@ -51,7 +51,7 @@ def handle_generate(parser, args): data = json.load(f) contexts.append(ContextData(data, url)) - m = Model(graph, UrlContext(contexts)) + m = Model(graph, UrlContext(contexts), is_prerelease=args.pre_release) render = args.lang(args) render.output(m) @@ -120,6 +120,11 @@ def handle_version(parser, args): help="SPDX License Identifier to use for generated source code. Default is %(default)s", default="0BSD", ) + generate_parser.add_argument( + "--pre-release", + action=argparse.BooleanOptionalAction, + help="Mark the generated binding as pre-release. Overrides any ontology annotations", + ) generate_parser.set_defaults(func=handle_generate) lang_subparser = generate_parser.add_subparsers( diff --git a/src/shacl2code/model.py b/src/shacl2code/model.py index 7a2d860e..d327af95 100644 --- a/src/shacl2code/model.py +++ b/src/shacl2code/model.py @@ -5,6 +5,7 @@ # SPDX-License-Identifier: MIT """SHACL model parsing and data class definitions""" +import re from dataclasses import dataclass, field from typing import List, Optional @@ -19,6 +20,8 @@ XSD, ) +from .util import convert_version_string + PATTERN_DATATYPES = [ str(XSD.string), str(XSD.dateTime), @@ -114,7 +117,7 @@ class Class: class Model(object): - def __init__(self, graph, context=None): + def __init__(self, graph, context=None, is_prerelease=None): self.model = graph self.context = context self.compact_ids = {} @@ -197,6 +200,109 @@ def is_abstract(s): return False + def is_semver_prerelease(version_str): + # Only treat a hyphen as a semver pre-release marker when it + # directly follows a dotted numeric core (e.g. "1.2.3-beta"), + # not e.g. a date-like version such as "2024-01-15". + if re.match(r"^\d+\.\d+(?:\.\d+)?-[0-9A-Za-z]", version_str): + return True + if re.search( + r"\b(alpha|beta|dev|pre|rc|snapshot|test)\b", version_str, re.IGNORECASE + ): + return True + return False + + def get_is_prerelease(onto_iri): + # 1) --pre-release command line option + if is_prerelease is not None: + return is_prerelease + + # 2) sh-to-code:isPreRelease + val = self.model.value(onto_iri, SHACL2CODE.isPreRelease) + if val is not None: + return bool(val) + + adms_statuses = list( + self.model.objects(onto_iri, URIRef("http://www.w3.org/ns/adms#status")) + ) + if adms_statuses: + semic = [ + str(s) + for s in adms_statuses + if str(s).startswith( + "http://publications.europa.eu/resource/authority/dataset-status/" + ) + ] + # 3) adms:status (EU SEMIC vocab) + if semic: + return any( + s + == "http://publications.europa.eu/resource/authority/dataset-status/DEVELOP" + for s in semic + ) + original = [ + str(s) + for s in adms_statuses + if str(s).startswith("http://purl.org/adms/status/") + ] + # 4) adms:status (Original ADMS vocab) + if original: + return any( + s == "http://purl.org/adms/status/UnderDevelopment" + for s in original + ) + + # 5) bibo:status (Bibliographic Ontology) + bibo_statuses = list( + self.model.objects( + onto_iri, URIRef("http://purl.org/ontology/bibo/status") + ) + ) + if bibo_statuses: + return any( + str(s) == "http://purl.org/ontology/bibo/status/draft" + for s in bibo_statuses + ) + + # 6) schema:creativeWorkStatus + schema_statuses = list( + self.model.objects( + onto_iri, URIRef("http://schema.org/creativeWorkStatus") + ) + ) or list( + self.model.objects( + onto_iri, URIRef("https://schema.org/creativeWorkStatus") + ) + ) + if schema_statuses: + return any(str(s) in ("Draft", "Incomplete") for s in schema_statuses) + + # 7) vs:term_status + vs_statuses = list( + self.model.objects( + onto_iri, + URIRef("http://www.w3.org/2003/06/sw-vocab-status/ns#term_status"), + ) + ) + if vs_statuses: + return any(str(s) in ("unstable", "testing") for s in vs_statuses) + + # 8) & 9) owl:versionInfo + versions = list(self.model.objects(onto_iri, OWL.versionInfo)) + if versions: + for version in versions: + version_str = str(version) + # 8) owl:versionInfo (pre-release extension e.g., "-beta", "-alpha", "-rc" etc) + if is_semver_prerelease(version_str): + return True + # 9) owl:versionInfo (major version zero) + parts = convert_version_string(version_str) + if parts and parts[0] == 0: + return True + return False + + return False + for onto_iri in self.model.subjects(RDF.type, OWL.Ontology): label = str(self.model.value(onto_iri, RDFS.label, default="")) o = Ontology( @@ -205,9 +311,7 @@ def is_abstract(s): label=label, comment=str(self.model.value(onto_iri, RDFS.comment, default="")), version=str(self.model.value(onto_iri, OWL.versionInfo, default="")), - is_prerelease=bool( - self.model.value(onto_iri, SHACL2CODE.isPreRelease, default=False) - ), + is_prerelease=get_is_prerelease(onto_iri), ) self.ontologies.append(o) diff --git a/tests/test_python.py b/tests/test_python.py index 18aed753..c77fe94c 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -2306,3 +2306,497 @@ def test_prerelease_warning(model): with pytest.warns(FutureWarning): model.test_class() + + +def test_pre_release_cli_option(tmp_path_factory, model_context_url): + tmp_directory = tmp_path_factory.mktemp("prerelease_test") + module_name = "pymodel_prerelease" + output_dir = tmp_directory / module_name + shacl2code_generate( + [ + "--input", + str(TEST_MODEL), + "--context", + model_context_url, + "--pre-release", + ], + [ + "--version", + MODEL_VERSION, + ], + output_dir, + ) + + sys.path.append(str(tmp_directory)) + try: + m = importlib.import_module(module_name) + assert m.SHACL2CODE_TEST.is_prerelease is True + finally: + sys.path.remove(str(tmp_directory)) + + +def test_no_pre_release_cli_option(tmp_path, model_context_url): + ttl_content = """ +@base . +@prefix rdf: . +@prefix rdfs: . +@prefix owl: . +@prefix sh-to-code: . + + a owl:Ontology ; + rdfs:comment "A test ontology" ; + rdfs:label "shacl2code-test" ; + owl:versionInfo "1.0.0" ; + sh-to-code:isPreRelease true . +""" + ttl_file = tmp_path / "prerelease.ttl" + ttl_file.write_text(ttl_content) + + module_name = "pymodel_default_true" + output_dir = tmp_path / module_name + shacl2code_generate( + [ + "--input", + str(ttl_file), + "--context", + model_context_url, + ], + [ + "--version", + MODEL_VERSION, + ], + output_dir, + ) + + sys.path.append(str(tmp_path)) + try: + m = importlib.import_module(module_name) + assert m.SHACL2CODE_TEST.is_prerelease is True + finally: + sys.path.remove(str(tmp_path)) + + module_name_no = "pymodel_no_prerelease" + output_dir_no = tmp_path / module_name_no + shacl2code_generate( + [ + "--input", + str(ttl_file), + "--context", + model_context_url, + "--no-pre-release", + ], + [ + "--version", + MODEL_VERSION, + ], + output_dir_no, + ) + + sys.path.append(str(tmp_path)) + try: + m = importlib.import_module(module_name_no) + assert m.SHACL2CODE_TEST.is_prerelease is False + finally: + sys.path.remove(str(tmp_path)) + + +def test_pre_release_annotations_cases(tmp_path, model_context_url): + # The number in the comment indicates the precedence of the annotation. + # 1 is the highest precedence (force by command line option) + cases = [ + # 2) sh-to-code:isPreRelease + ("sh-to-code:isPreRelease true .", True), + ("sh-to-code:isPreRelease false .", False), + # 3) adms:status (EU SEMIC vocab) + ( + "adms:status .", + True, + ), + ( + "adms:status .", + False, + ), + # 4) adms:status (Original ADMS vocab) + ("adms:status .", True), + ("adms:status .", False), + # 5) bibo:status (Bibliographic Ontology) + ("bibo:status .", True), + ("bibo:status .", False), + ("bibo:status .", False), + # 6) schema:creativeWorkStatus + ('schema:creativeWorkStatus "Draft" .', True), + ('schema:creativeWorkStatus "Incomplete" .', True), + ('schema:creativeWorkStatus "Published" .', False), + # 7) vs:term_status + ('vs:term_status "testing" .', True), + ('vs:term_status "unstable" .', True), + ('vs:term_status "stable" .', False), + # 8) owl:versionInfo (pre-release extension) + ('owl:versionInfo "3.1.0-rc2" .', True), + ('owl:versionInfo "1.2.1-SNAPSHOT" .', True), + ('owl:versionInfo "1.0.0.alpha" .', True), + ('owl:versionInfo "1.0.0" .', False), + # 9) owl:versionInfo (major version zero) + ('owl:versionInfo "0.7.1" .', True), + ('owl:versionInfo "0.0.1" .', True), + # date-like version must not be mistaken for a semver pre-release + # suffix (no dotted numeric core precedes the hyphen) + ('owl:versionInfo "2024-01-15" .', False), + # Fallback (no annotations or versionInfo at all) + ('rdfs:comment "A test ontology" .', False), + ] + + for idx, (annotations, expected) in enumerate(cases): + ttl_content = f""" +@base . +@prefix rdf: . +@prefix rdfs: . +@prefix owl: . +@prefix sh-to-code: . +@prefix adms: . +@prefix schema: . +@prefix vs: . +@prefix bibo: . + + a owl:Ontology ; + rdfs:comment "A test ontology" ; + rdfs:label "shacl2code-test" ; + {annotations} +""" + ttl_file = tmp_path / f"case_{idx}.ttl" + ttl_file.write_text(ttl_content) + + module_name = f"pymodel_case_{idx}" + output_dir = tmp_path / module_name + shacl2code_generate( + [ + "--input", + str(ttl_file), + "--context", + model_context_url, + ], + [ + "--version", + MODEL_VERSION, + ], + output_dir, + ) + + sys.path.append(str(tmp_path)) + try: + m = importlib.import_module(module_name) + assert ( + m.SHACL2CODE_TEST.is_prerelease is expected + ), f"Failed for case {idx}: {annotations}" + finally: + sys.path.remove(str(tmp_path)) + + +def test_pre_release_precedence(tmp_path, model_context_url): + # Example 1: + # 2) sh-to-code:isPreRelease false (False) + # 3) adms:status EU SEMIC DEVELOP (True) + # Expected: False (sh-to-code has higher precedence) + ttl_content_1 = """ +@base . +@prefix rdf: . +@prefix rdfs: . +@prefix owl: . +@prefix sh-to-code: . +@prefix adms: . + + a owl:Ontology ; + rdfs:label "shacl2code-test" ; + sh-to-code:isPreRelease false ; + adms:status . +""" + ttl_file_1 = tmp_path / "prec_1.ttl" + ttl_file_1.write_text(ttl_content_1) + + module_name_1 = "pymodel_prec_1" + output_dir_1 = tmp_path / module_name_1 + shacl2code_generate( + [ + "--input", + str(ttl_file_1), + "--context", + model_context_url, + ], + [ + "--version", + MODEL_VERSION, + ], + output_dir_1, + ) + + sys.path.append(str(tmp_path)) + try: + m = importlib.import_module(module_name_1) + assert m.SHACL2CODE_TEST.is_prerelease is False + finally: + sys.path.remove(str(tmp_path)) + + # Example 2: + # 6) schema:creativeWorkStatus "Published" (False) + # 7) vs:term_status "testing" (True) + # Expected: False (creativeWorkStatus has higher precedence) + ttl_content_2 = """ +@base . +@prefix rdf: . +@prefix rdfs: . +@prefix owl: . +@prefix schema: . +@prefix vs: . + + a owl:Ontology ; + rdfs:label "shacl2code-test" ; + schema:creativeWorkStatus "Published" ; + vs:term_status "testing" . +""" + ttl_file_2 = tmp_path / "prec_2.ttl" + ttl_file_2.write_text(ttl_content_2) + + module_name_2 = "pymodel_prec_2" + output_dir_2 = tmp_path / module_name_2 + shacl2code_generate( + [ + "--input", + str(ttl_file_2), + "--context", + model_context_url, + ], + [ + "--version", + MODEL_VERSION, + ], + output_dir_2, + ) + + sys.path.append(str(tmp_path)) + try: + m = importlib.import_module(module_name_2) + assert m.SHACL2CODE_TEST.is_prerelease is False + finally: + sys.path.remove(str(tmp_path)) + + # Example 3: + # 3) adms:status EU SEMIC DEVELOP (True) + # 6) schema:creativeWorkStatus "Published" (False) + # Expected: True (adms:status has higher precedence) + ttl_content_3 = """ +@base . +@prefix rdf: . +@prefix rdfs: . +@prefix owl: . +@prefix adms: . +@prefix schema: . + + a owl:Ontology ; + rdfs:label "shacl2code-test" ; + adms:status ; + schema:creativeWorkStatus "Published" . +""" + ttl_file_3 = tmp_path / "prec_3.ttl" + ttl_file_3.write_text(ttl_content_3) + + module_name_3 = "pymodel_prec_3" + output_dir_3 = tmp_path / module_name_3 + shacl2code_generate( + [ + "--input", + str(ttl_file_3), + "--context", + model_context_url, + ], + [ + "--version", + MODEL_VERSION, + ], + output_dir_3, + ) + + sys.path.append(str(tmp_path)) + try: + m = importlib.import_module(module_name_3) + assert m.SHACL2CODE_TEST.is_prerelease is True + finally: + sys.path.remove(str(tmp_path)) + + # Example 4: + # 4) adms:status Original ADMS status Completed (False) + # 5) bibo:status draft (True) + # Expected: False (adms:status has higher precedence) + ttl_content_4 = """ +@base . +@prefix rdf: . +@prefix rdfs: . +@prefix owl: . +@prefix adms: . +@prefix bibo: . + + a owl:Ontology ; + rdfs:label "shacl2code-test" ; + adms:status ; + bibo:status . +""" + ttl_file_4 = tmp_path / "prec_4.ttl" + ttl_file_4.write_text(ttl_content_4) + + module_name_4 = "pymodel_prec_4" + output_dir_4 = tmp_path / module_name_4 + shacl2code_generate( + [ + "--input", + str(ttl_file_4), + "--context", + model_context_url, + ], + [ + "--version", + MODEL_VERSION, + ], + output_dir_4, + ) + + sys.path.append(str(tmp_path)) + try: + m = importlib.import_module(module_name_4) + assert m.SHACL2CODE_TEST.is_prerelease is False + finally: + sys.path.remove(str(tmp_path)) + + # Example 5: + # 5) bibo:status published (False) + # 6) schema:creativeWorkStatus Draft (True) + # Expected: False (bibo:status has higher precedence) + ttl_content_5 = """ +@base . +@prefix rdf: . +@prefix rdfs: . +@prefix owl: . +@prefix schema: . +@prefix bibo: . + + a owl:Ontology ; + rdfs:label "shacl2code-test" ; + bibo:status ; + schema:creativeWorkStatus "Draft" . +""" + ttl_file_5 = tmp_path / "prec_5.ttl" + ttl_file_5.write_text(ttl_content_5) + + module_name_5 = "pymodel_prec_5" + output_dir_5 = tmp_path / module_name_5 + shacl2code_generate( + [ + "--input", + str(ttl_file_5), + "--context", + model_context_url, + ], + [ + "--version", + MODEL_VERSION, + ], + output_dir_5, + ) + + sys.path.append(str(tmp_path)) + try: + m = importlib.import_module(module_name_5) + assert m.SHACL2CODE_TEST.is_prerelease is False + finally: + sys.path.remove(str(tmp_path)) + + +def test_pre_release_multi_valued_annotations(tmp_path, model_context_url): + # Each of these predicates can legally repeat. + # A stable-looking value listed first must not hide + # a pre-release-indicating value listed after it. + cases = [ + # owl:versionInfo: first value stable, second is a semver + # pre-release extension. + ( + """ +owl:versionInfo "1.0.0" ; +owl:versionInfo "2.0.0-beta" . +""", + True, + ), + # adms:status (EU SEMIC vocab): first value stable, second under + # development. + ( + """ +adms:status ; +adms:status . +""", + True, + ), + # schema:creativeWorkStatus: first value stable, second draft. + ( + """ +schema:creativeWorkStatus "Published" ; +schema:creativeWorkStatus "Draft" . +""", + True, + ), + # vs:term_status: first value stable, second testing. + ( + """ +vs:term_status "stable" ; +vs:term_status "testing" . +""", + True, + ), + # bibo:status: first value published, second draft. + ( + """ +bibo:status ; +bibo:status . +""", + True, + ), + ] + + for idx, (annotations, expected) in enumerate(cases): + ttl_content = f""" +@base . +@prefix rdf: . +@prefix rdfs: . +@prefix owl: . +@prefix adms: . +@prefix schema: . +@prefix vs: . +@prefix bibo: . + + a owl:Ontology ; + rdfs:label "shacl2code-test" ; + {annotations} +""" + ttl_file = tmp_path / f"multi_{idx}.ttl" + ttl_file.write_text(ttl_content) + + module_name = f"pymodel_multi_{idx}" + output_dir = tmp_path / module_name + shacl2code_generate( + [ + "--input", + str(ttl_file), + "--context", + model_context_url, + ], + [ + "--version", + MODEL_VERSION, + ], + output_dir, + ) + + sys.path.append(str(tmp_path)) + try: + m = importlib.import_module(module_name) + assert ( + m.SHACL2CODE_TEST.is_prerelease is expected + ), f"Failed for case {idx}: {annotations}" + finally: + sys.path.remove(str(tmp_path))