diff --git a/poetry.lock b/poetry.lock index c6d4a47..297d54c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "aiofiles" @@ -474,6 +474,27 @@ wcwidth = "*" [package.extras] tests = ["pytest", "pytest-cov", "pytest-lazy-fixtures"] +[[package]] +name = "protobuf" +version = "6.33.2" +description = "" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"jelly\"" +files = [ + {file = "protobuf-6.33.2-cp310-abi3-win32.whl", hash = "sha256:87eb388bd2d0f78febd8f4c8779c79247b26a5befad525008e49a6955787ff3d"}, + {file = "protobuf-6.33.2-cp310-abi3-win_amd64.whl", hash = "sha256:fc2a0e8b05b180e5fc0dd1559fe8ebdae21a27e81ac77728fb6c42b12c7419b4"}, + {file = "protobuf-6.33.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d9b19771ca75935b3a4422957bc518b0cecb978b31d1dd12037b088f6bcc0e43"}, + {file = "protobuf-6.33.2-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:b5d3b5625192214066d99b2b605f5783483575656784de223f00a8d00754fc0e"}, + {file = "protobuf-6.33.2-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8cd7640aee0b7828b6d03ae518b5b4806fdfc1afe8de82f79c3454f8aef29872"}, + {file = "protobuf-6.33.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:1f8017c48c07ec5859106533b682260ba3d7c5567b1ca1f24297ce03384d1b4f"}, + {file = "protobuf-6.33.2-cp39-cp39-win32.whl", hash = "sha256:7109dcc38a680d033ffb8bf896727423528db9163be1b6a02d6a49606dcadbfe"}, + {file = "protobuf-6.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:2981c58f582f44b6b13173e12bb8656711189c2a70250845f264b877f00b1913"}, + {file = "protobuf-6.33.2-py3-none-any.whl", hash = "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c"}, + {file = "protobuf-6.33.2.tar.gz", hash = "sha256:56dc370c91fbb8ac85bc13582c9e373569668a290aa2e66a590c2a0d35ddb9e4"}, +] + [[package]] name = "pyduktape2" version = "0.4.6" @@ -551,6 +572,26 @@ files = [ {file = "pyduktape2-0.4.6.tar.gz", hash = "sha256:c84674e202ef4901bca8f6ea8b40197259bf44656167a1106ef076a491421bec"}, ] +[[package]] +name = "pyjelly" +version = "0.6.2" +description = "Jelly-RDF implementation for Python" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"jelly\"" +files = [ + {file = "pyjelly-0.6.2-py3-none-any.whl", hash = "sha256:ba22053fced114541dc45a1c7ffcca57d02a47caba9ecd156430614e55cda9d5"}, + {file = "pyjelly-0.6.2.tar.gz", hash = "sha256:ad7254cc2f0b9d3b9a18059d7096669d86f9510f7bf658f0011ca1ea43bc5f7f"}, +] + +[package.dependencies] +protobuf = ">=6.30.0" +typing-extensions = ">=4.12.2" + +[package.extras] +rdflib = ["rdflib (>=7.1.4)"] + [[package]] name = "pyparsing" version = "3.2.1" @@ -1168,9 +1209,10 @@ dev-coverage = ["coverage", "platformdirs", "pytest-cov"] dev-lint = ["platformdirs", "ruff"] dev-type-checking = ["mypy", "platformdirs", "types-setuptools"] http = ["sanic", "sanic-cors", "sanic-ext"] +jelly = ["pyjelly"] js = ["pyduktape2"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<4" -content-hash = "963662cab3640d90efe7a54ea856e095ef47ee8c25a8333c8fb0ade73e162a59" +content-hash = "cba69d0ea51b06c182f37ac7b5edd104855521390ce832496e50f6352c159195" diff --git a/pyproject.toml b/pyproject.toml index cef0921..02da460 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,9 @@ http = [ "sanic-ext<23.6,>=23.3", "sanic-cors==2.2.0" ] +jelly = [ + "pyjelly>=0.6.2" +] dev-lint = [ "ruff<0.10,>=0.9.3", "platformdirs" diff --git a/pyshacl/cli.py b/pyshacl/cli.py index 98e7166..5ca0aa1 100644 --- a/pyshacl/cli.py +++ b/pyshacl/cli.py @@ -177,7 +177,7 @@ def str_is_true(s_var: str): action='store', help='Choose an output format. Default is "human".', default='human', - choices=('human', 'table', 'turtle', 'xml', 'json-ld', 'nt', 'n3'), + choices=('human', 'table', 'turtle', 'xml', 'json-ld', 'nt', 'n3', 'jelly'), ) parser.add_argument( '-df', @@ -186,7 +186,7 @@ def str_is_true(s_var: str): action='store', help='Explicitly state the RDF File format of the input DataGraph file. Default="auto".', default='auto', - choices=('auto', 'turtle', 'xml', 'json-ld', 'nt', 'n3'), + choices=('auto', 'turtle', 'xml', 'json-ld', 'nt', 'n3', 'jelly'), ) parser.add_argument( '-sf', @@ -195,7 +195,7 @@ def str_is_true(s_var: str): action='store', help='Explicitly state the RDF File format of the input SHACL file. Default="auto".', default='auto', - choices=('auto', 'turtle', 'xml', 'json-ld', 'nt', 'n3'), + choices=('auto', 'turtle', 'xml', 'json-ld', 'nt', 'n3', 'jelly'), ) parser.add_argument( '-ef', @@ -204,7 +204,7 @@ def str_is_true(s_var: str): action='store', help='Explicitly state the RDF File format of the extra ontology file. Default="auto".', default='auto', - choices=('auto', 'turtle', 'xml', 'json-ld', 'nt', 'n3'), + choices=('auto', 'turtle', 'xml', 'json-ld', 'nt', 'n3', 'jelly'), ) parser.add_argument('-V', '--version', action=ShowVersion, help='Show PySHACL version and exit.') parser.add_argument( @@ -422,10 +422,14 @@ def col_widther(s, w): args.output.write(str(t2)) else: if isinstance(v_graph, bytes): - v_graph = v_graph.decode('utf-8') + if args.output is not None and args.output != sys.stdout: + args.output.close() + with open(args.output.name, "wb") as f: + f.write(v_graph) + else: + sys.stdout.buffer.write(v_graph) + sys.exit(0 if is_conform else 1) args.output.write(v_graph) - args.output.close() - sys.exit(0 if is_conform else 1) if __name__ == "__main__": diff --git a/pyshacl/cli_rules.py b/pyshacl/cli_rules.py index dfbbb2a..320f145 100644 --- a/pyshacl/cli_rules.py +++ b/pyshacl/cli_rules.py @@ -108,7 +108,7 @@ action='store', help='Choose an output format. Default is "trig" for Datasets and "turtle" for Graphs.', default='auto', - choices=('auto', 'turtle', 'xml', 'trig', 'json-ld', 'nt', 'n3', 'nquads'), + choices=('auto', 'turtle', 'xml', 'trig', 'json-ld', 'nt', 'n3', 'nquads', 'jelly'), ) parser.add_argument( '-df', @@ -117,7 +117,7 @@ action='store', help='Explicitly state the RDF File format of the input DataGraph file. Default="auto".', default='auto', - choices=('auto', 'turtle', 'xml', 'trig', 'json-ld', 'nt', 'n3', 'nquads'), + choices=('auto', 'turtle', 'xml', 'trig', 'json-ld', 'nt', 'n3', 'nquads', 'jelly'), ) parser.add_argument( '-sf', @@ -126,7 +126,7 @@ action='store', help='Explicitly state the RDF File format of the input SHACL file. Default="auto".', default='auto', - choices=('auto', 'turtle', 'xml', 'trig', 'json-ld', 'nt', 'n3', 'nquads'), + choices=('auto', 'turtle', 'xml', 'trig', 'json-ld', 'nt', 'n3', 'nquads', 'jelly'), ) parser.add_argument( '-ef', @@ -135,7 +135,7 @@ action='store', help='Explicitly state the RDF File format of the extra ontology file. Default="auto".', default='auto', - choices=('auto', 'turtle', 'xml', 'trig', 'json-ld', 'nt', 'n3', 'nquads'), + choices=('auto', 'turtle', 'xml', 'trig', 'json-ld', 'nt', 'n3', 'nquads', 'jelly'), ) parser.add_argument('-V', '--version', action=ShowVersion, help='Show PySHACL version and exit.') parser.add_argument( diff --git a/pyshacl/sh_http.py b/pyshacl/sh_http.py index 06abe00..b11ba52 100644 --- a/pyshacl/sh_http.py +++ b/pyshacl/sh_http.py @@ -41,6 +41,7 @@ class InputRDFFormat(Enum): JSONLD = "json-ld" NT = "nt" N3 = "n3" + JELLY = "jelly" @dataclass diff --git a/test/issues/test_jelly_support.py b/test/issues/test_jelly_support.py new file mode 100644 index 0000000..82b9f00 --- /dev/null +++ b/test/issues/test_jelly_support.py @@ -0,0 +1,135 @@ +import subprocess +from rdflib import Graph +from pyshacl import validate + +def test_jelly_api_valid(tmp_path): + data_ttl = """ + @prefix ex: . + ex:Alice ex:name "Alice" . + """ + + shapes_ttl = """ + @prefix sh: . + @prefix ex: . + @prefix xsd: . + + ex:PersonShape a sh:NodeShape ; + sh:targetClass ex:Alice ; + sh:property [ + sh:path ex:name ; + sh:datatype xsd:string ; + ] . + """ + + # save ttl + data_ttl_path = tmp_path / "data.ttl" + shapes_ttl_path = tmp_path / "shapes.ttl" + data_ttl_path.write_text(data_ttl) + shapes_ttl_path.write_text(shapes_ttl) + + # convert to jelly + g_data = Graph().parse(data_ttl_path) + g_shapes = Graph().parse(shapes_ttl_path) + data_jelly_path = tmp_path / "data.jelly" + shapes_jelly_path = tmp_path / "shapes.jelly" + g_data.serialize(data_jelly_path, format="jelly") + g_shapes.serialize(shapes_jelly_path, format="jelly") + + # API validation + conforms, report_graph, _ = validate( + data_graph=g_data, + shacl_graph=g_shapes, + data_graph_format="jelly", + shacl_graph_format="jelly", + ) + + assert conforms + assert len(report_graph) > 0 + + +def test_jelly_api_invalid(tmp_path): + data_ttl = """ + @prefix ex: . + ex:Bob ex:age "notANumber" . + """ + + shapes_ttl = """ + @prefix sh: . + @prefix ex: . + @prefix xsd: . + + ex:PersonShape a sh:NodeShape ; + sh:targetNode ex:Bob ; + sh:property [ + sh:path ex:age ; + sh:datatype xsd:integer ; + ] . + """ + + data_ttl_path = tmp_path / "data.ttl" + shapes_ttl_path = tmp_path / "shapes.ttl" + data_ttl_path.write_text(data_ttl) + shapes_ttl_path.write_text(shapes_ttl) + + g_data = Graph().parse(data_ttl_path) + g_shapes = Graph().parse(shapes_ttl_path) + data_jelly_path = tmp_path / "data.jelly" + shapes_jelly_path = tmp_path / "shapes.jelly" + g_data.serialize(data_jelly_path, format="jelly") + g_shapes.serialize(shapes_jelly_path, format="jelly") + + conforms, report_graph, _ = validate( + data_graph=g_data, + shacl_graph=g_shapes, + data_graph_format="jelly", + shacl_graph_format="jelly", + ) + + assert not conforms + assert len(report_graph) > 0 + + +def test_jelly_cli_valid(tmp_path): + data_ttl = "@prefix ex: . ex:X ex:name \"X\" ." + shapes_ttl = """ + @prefix sh: . + @prefix ex: . + @prefix xsd: . + + ex:Shape a sh:NodeShape ; + sh:targetClass ex:X ; + sh:property [ + sh:path ex:name ; + sh:datatype xsd:string ; + ] . + """ + + data_ttl_path = tmp_path / "d.ttl" + shapes_ttl_path = tmp_path / "s.ttl" + data_ttl_path.write_text(data_ttl) + shapes_ttl_path.write_text(shapes_ttl) + + g_data = Graph().parse(data_ttl_path) + g_shapes = Graph().parse(shapes_ttl_path) + + data_jelly = tmp_path / "d.jelly" + shapes_jelly = tmp_path / "s.jelly" + g_data.serialize(data_jelly, format="jelly") + g_shapes.serialize(shapes_jelly, format="jelly") + + # run CLI + result = subprocess.run( + [ + "pyshacl", + str(data_jelly), + "-s", str(shapes_jelly), + "-df", "jelly", + "-sf", "jelly" + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0 + assert "Conforms: True" in result.stdout