From 5d91642e3513c1d5312dd8d09bd4c1f9dfce81a9 Mon Sep 17 00:00:00 2001 From: Joshua Watt Date: Mon, 2 Feb 2026 02:08:54 -0700 Subject: [PATCH 01/11] Reformat with latest version of black Reformats code with the latest version of black --- src/shacl2code/lang/cpp.py | 24 ++--- tests/test_common_jinja.py | 1 - tests/test_cpp.py | 188 ++++++++++++------------------------- tests/test_golang.py | 34 ++----- 4 files changed, 76 insertions(+), 171 deletions(-) diff --git a/src/shacl2code/lang/cpp.py b/src/shacl2code/lang/cpp.py index 947e18d8..1e7b1ce6 100644 --- a/src/shacl2code/lang/cpp.py +++ b/src/shacl2code/lang/cpp.py @@ -144,14 +144,10 @@ def suffix(s): for s in self.HEADERS: guard = f"_{self.macro_prefix}_{id_str(s).upper()}" yield self.basename.parent / s, t / (s + ".j2"), { - "guard_begin": comment_wrap( - textwrap.dedent( - f"""\ + "guard_begin": comment_wrap(textwrap.dedent(f"""\ #ifndef {guard} #define {guard} - """ - ) - ), + """)), "guard_end": comment_wrap(f"#endif // {guard}"), } @@ -188,26 +184,18 @@ def get_extra_env(self): "prop_is_list": prop_is_list, "parent_cpp_classes": parent_cpp_classes, "macro_prefix": self.macro_prefix, - "api_def_begin": comment_wrap( - textwrap.dedent( - f"""\ + "api_def_begin": comment_wrap(textwrap.dedent(f"""\ #ifndef DOXYGEN_SKIP #include "api.hpp" // These are so that we don't have to use Jinja templates below since that messes up the formatting #define EXPORT {self.macro_prefix}_API #define LOCAL {self.macro_prefix}_LOCAL #endif // DOXYGEN_SKIP - """ - ) - ), - "api_def_end": comment_wrap( - textwrap.dedent( - """\ + """)), + "api_def_end": comment_wrap(textwrap.dedent("""\ #undef EXPORT #undef LOCAL - """ - ) - ), + """)), "ns_begin": comment_wrap( "\n".join(f"namespace {n} {{" for n in self.namespace.split("::")) ), diff --git a/tests/test_common_jinja.py b/tests/test_common_jinja.py index ae1ed763..4c05d06c 100644 --- a/tests/test_common_jinja.py +++ b/tests/test_common_jinja.py @@ -6,7 +6,6 @@ import subprocess from pathlib import Path - THIS_DIR = Path(__file__).parent TEST_MODEL = THIS_DIR / "data" / "model" / "test.ttl" diff --git a/tests/test_cpp.py b/tests/test_cpp.py index 688ffcb9..f9a8737c 100644 --- a/tests/test_cpp.py +++ b/tests/test_cpp.py @@ -108,15 +108,11 @@ def build_lib(tmp_path_factory, model_server, tmpname, *, namespace=None): cwd=tmp_directory, ) pkg_config = tmp_directory / "pkg-config" - pkg_config.write_text( - textwrap.dedent( - f"""\ + pkg_config.write_text(textwrap.dedent(f"""\ #! /bin/sh export PKG_CONFIG_PATH="{str(install_dir / 'lib' / 'pkgconfig')}" exec pkg-config "$@" - """ - ) - ) + """)) pkg_config.chmod(0o755) return Lib( @@ -149,8 +145,7 @@ def compile_test(test_lib, tmp_path): def f(code_fragment, *, progress=Progress.RUNS, static=False): src = tmp_path / "test.cpp" src.write_text( - textwrap.dedent( - f"""\ + textwrap.dedent(f"""\ #include "{test_lib.basename}/{test_lib.basename}.hpp" #include "{test_lib.basename}/{test_lib.basename}-jsonld.hpp" #include @@ -161,29 +156,24 @@ def f(code_fragment, *, progress=Progress.RUNS, static=False): int main(int argc, char** argv) {{ try {{ - """ - ) + """) + textwrap.dedent(code_fragment) + "".join( - textwrap.dedent( - f"""\ + textwrap.dedent(f"""\ }} catch ({exc}& e) {{ std::cout << " {enum.name} " << e.what() << std::endl; return 1; - """ - ) + """) for exc, enum in ( ("ValidationError", Progress.VALIDATION_FAILS), ("std::bad_cast", Progress.CAST_FAILS), ) ) - + textwrap.dedent( - """\ + + textwrap.dedent("""\ } return 0; } - """ - ) + """) ) prog = tmp_path / "prog" @@ -231,14 +221,10 @@ def f(code_fragment, *, progress=Progress.RUNS, static=False): ) compile_script = tmp_path / "compile.sh" - compile_script.write_text( - textwrap.dedent( - f"""\ + compile_script.write_text(textwrap.dedent(f"""\ #! /bin/sh exec {" ".join(str(s) for s in compile_cmd)} - """ - ) - ) + """)) compile_script.chmod(0o755) p = subprocess.run( @@ -404,17 +390,13 @@ def test_compile(compile_test): def test_headers(test_lib, tmp_path): for h in test_lib.directory.glob("*.hpp"): src = tmp_path / (h.name + ".cpp") - src.write_text( - textwrap.dedent( - f"""\ + src.write_text(textwrap.dedent(f"""\ #include <{test_lib.basename}/{h.name}> int main(int argc, char** argv) {{ return 0; }} - """ - ) - ) + """)) prog = tmp_path / "prog" pkg_config_cmd = f"$({test_lib.pkg_config} --cflags --libs {test_lib.basename})" @@ -433,14 +415,10 @@ def test_headers(test_lib, tmp_path): ] compile_script = tmp_path / "compile.sh" - compile_script.write_text( - textwrap.dedent( - f"""\ + compile_script.write_text(textwrap.dedent(f"""\ #! /bin/sh exec {" ".join(str(s) for s in compile_cmd)} - """ - ) - ) + """)) compile_script.chmod(0o755) subprocess.run( @@ -758,12 +736,10 @@ def test_class_prop_validation(compile_test, prop, value, expect): ) def test_ref_implicit_cast(compile_test, A, B, progress): # Check types are valid - compile_test( - f"""\ + compile_test(f"""\ auto a = make_obj<{A}>(); auto b = make_obj<{B}>(); - """ - ) + """) output = compile_test( f"""\ @@ -796,32 +772,27 @@ def test_ref_implicit_cast(compile_test, A, B, progress): assert output.rstrip() == "_:foo" if progress == Progress.RUNS: - output = compile_test( - f"""\ + output = compile_test(f"""\ Ref<{A}> a("_:foo"); Ref<{B}> b(a); std::cout << b.iri() << std::endl; - """ - ) + """) assert output.rstrip() == "_:foo" - output = compile_test( - f"""\ + output = compile_test(f"""\ Ref<{A}> a("_:foo"); Ref<{B}> b("_:bar"); b = a; a.isObj(); std::cout << b.iri() << std::endl; - """ - ) + """) assert output.rstrip() == "_:foo" def test_ref_prop_assignment(compile_test): - output = compile_test( - """\ + output = compile_test("""\ auto c = make_obj(); c->_test_class_class_prop = make_obj(); c->_test_class_class_prop->_id = "_:foo"; @@ -830,32 +801,27 @@ def test_ref_prop_assignment(compile_test): d->_test_class_class_prop = c->_test_class_class_prop; std::cout << d->_test_class_class_prop->_id.get() << std::endl; - """ - ) + """) assert output.rstrip() == "_:foo" def test_ref_implicit_cast_to_abstract(compile_test): - output = compile_test( - """\ + output = compile_test("""\ auto r = make_obj(); r->_id = "_:foo"; Ref p(r); std::cout << p->_id.get() << std::endl; - """ - ) + """) assert output.rstrip() == "_:foo" - output = compile_test( - """\ + output = compile_test("""\ Ref a("_:foo"); Ref b(a); std::cout << b.iri() << std::endl; - """ - ) + """) assert output.rstrip() == "_:foo" @@ -872,12 +838,10 @@ def test_ref_implicit_cast_to_abstract(compile_test): ) def test_ref_explicit_cast(compile_test, A, B, progress): # Check types are valid - compile_test( - f"""\ + compile_test(f"""\ auto a = make_obj<{A}>(); auto b = make_obj<{B}>(); - """ - ) + """) output = compile_test( f"""\ @@ -907,24 +871,20 @@ def test_ref_explicit_cast(compile_test, A, B, progress): assert output.rstrip() == "_:foo" if progress == Progress.RUNS: - output = compile_test( - f"""\ + output = compile_test(f"""\ Ref<{A}> a("_:foo"); auto b = a.asTypeRef<{B}>(); std::cout << b.iri() << std::endl; - """ - ) + """) assert output.rstrip() == "_:foo" - output = compile_test( - f"""\ + output = compile_test(f"""\ Ref<{A}> a("_:foo"); Ref<{B}> b("bar"); b = a.asTypeRef<{B}>(); std::cout << b.iri() << std::endl; - """ - ) + """) assert output.rstrip() == "_:foo" @@ -939,45 +899,37 @@ def test_ref_explicit_cast_to_derived(compile_test): ) # Passes because it is a string reference - output = compile_test( - """\ + output = compile_test("""\ Ref r("_:foo"); auto p = r.asTypeRef(); std::cout << p.iri() << std::endl; - """ - ) + """) assert output.rstrip() == "_:foo" # Passes because r is actually a test_derived_class - compile_test( - """\ + compile_test("""\ auto r = make_obj(); auto i = r.asTypeRef(); auto p = i.asTypeRef(); - """ - ) + """) - output = compile_test( - """\ + output = compile_test("""\ Ref r("_:foo"); auto i = r.asTypeRef(); auto p = i.asTypeRef(); std::cout << p.iri() << std::endl; - """ - ) + """) assert output.rstrip() == "_:foo" def test_ref_explicit_cast_to_abstract(compile_test): - output = compile_test( - """\ + output = compile_test("""\ auto r = make_obj(); r->_id = "_:foo"; auto p = r.asTypeRef(); std::cout << r->_id.get() << std::endl; - """ - ) + """) assert output.rstrip() == "_:foo" @@ -994,21 +946,17 @@ def test_ref_explicit_cast_to_abstract(compile_test): ) def test_DateTime_toString(compile_test, create_args, expect, tzoffset): args = ", ".join(repr(r) for r in create_args) - output = compile_test( - f"""\ + output = compile_test(f"""\ std::cout << DateTime({args}).toString() << std::endl; - """ - ) + """) assert ( output.rstrip() == expect ), f"Bad string result for DateTime({args}).toString(). Expected {expect!r}. Got {output.rstrip()!r}" - output = compile_test( - f"""\ + output = compile_test(f"""\ std::cout << DateTime({args}).tzOffsetSeconds() << std::endl; - """ - ) + """) assert ( int(output.rstrip()) == tzoffset @@ -1042,8 +990,7 @@ def test_DateTime_toString(compile_test, create_args, expect, tzoffset): ], ) def test_DateTime_fromString(compile_test, s, valid, time, tzoffset): - output = compile_test( - f""" + output = compile_test(f""" auto d = DateTime::fromString("{s}", true); if (d) {{ auto dt = d.value(); @@ -1052,8 +999,7 @@ def test_DateTime_fromString(compile_test, s, valid, time, tzoffset): }} else {{ std::cout << "INVALID" << std::endl; }} - """ - ) + """) if valid: expect = [str(time), str(tzoffset)] @@ -1073,8 +1019,7 @@ def test_abstract_class(compile_test): def test_id_alias(compile_test): - output = compile_test( - """\ + output = compile_test("""\ { auto c = make_obj(); std::cout << c->_id.getIRI() << std::endl; @@ -1095,8 +1040,7 @@ def test_id_alias(compile_test): auto b = c.asTypeRef(); std::cout << b->_id.getIRI() << std::endl; } - """ - ) + """) expected = ["testid"] * 3 + ["@id"] * 3 assert ( output.splitlines() == expected @@ -1104,8 +1048,7 @@ def test_id_alias(compile_test): def test_mandatory_properties(compile_test, tmp_path): - CODE = textwrap.dedent( - """\ + CODE = textwrap.dedent("""\ auto c = make_obj(); c->_id = "_:blank"; c->_test_class_required_string_scalar_prop = "foo"; @@ -1122,8 +1065,7 @@ def test_mandatory_properties(compile_test, tmp_path): JSONLDInlineSerializer s; s.write(outfile, o); outfile.close(); - """ - ) + """) # Verify that the base code works compile_test(CODE.format(code="", fn=tmp_path / "pass.json")) @@ -1165,8 +1107,7 @@ def test_mandatory_properties(compile_test, tmp_path): @pytest.fixture def iterator_test(compile_test): def f(code, expect): - output = compile_test( - f"""\ + output = compile_test(f"""\ auto c = make_obj(); auto& p = c->_test_class_string_list_prop; {textwrap.dedent(code)} @@ -1174,8 +1115,7 @@ def f(code, expect): for (auto&& i : p) {{ std::cout << i << std::endl; }} - """ - ) + """) assert output.splitlines() == expect return f @@ -1305,16 +1245,14 @@ def test_pointer(self, iterator_test): ) def test_std_find(self, compile_test): - output = compile_test( - """\ + output = compile_test("""\ auto c = make_obj(); auto& p = c->_test_class_string_list_prop; p.insert(p.begin(), {"A", "B", "C"}); auto it = std::find(p.begin(), p.end(), "A"); std::cout << *it << std::endl; - """ - ) + """) assert output.splitlines() + ["A"] def test_std_remove(self, iterator_test): @@ -1362,8 +1300,7 @@ def test_std_sort(self, iterator_test): def test_roundtrip(compile_test, tmp_path, roundtrip): out_file = tmp_path / "out.json" - compile_test( - f"""\ + compile_test(f"""\ SHACLObjectSet objs; {{ std::ifstream infile; @@ -1381,8 +1318,7 @@ def test_roundtrip(compile_test, tmp_path, roundtrip): s.write(outfile, objs); outfile.close(); }} - """ - ) + """) with roundtrip.open("r") as f: expect = json.load(f) @@ -1421,22 +1357,19 @@ def test_static(compile_test, tmp_path, roundtrip): def test_set(compile_test): - output = compile_test( - """ + output = compile_test(""" auto c = make_obj(); c->set<&SHACLObject::_id>("_:foo").set<&test_class::_test_class_string_scalar_prop>("b"); std::cout << c->_id.get() << std::endl; std::cout << c->_test_class_string_scalar_prop.get() << std::endl; - """ - ) + """) assert output.splitlines() == ["_:foo", "b"] def test_add(compile_test): - output = compile_test( - """ + output = compile_test(""" auto c = make_obj(); c->add<&test_class::_test_class_string_list_prop>("a").add<&test_class::_test_class_string_list_prop>("b"); @@ -1444,8 +1377,7 @@ def test_add(compile_test): std::cout << i << std::endl; } - """ - ) + """) assert output.splitlines() == ["a", "b"] diff --git a/tests/test_golang.py b/tests/test_golang.py index 8950f979..7cc36306 100644 --- a/tests/test_golang.py +++ b/tests/test_golang.py @@ -84,8 +84,7 @@ def f(code_fragment, *, progress=Progress.RUNS, static=False, imports=[]): src = tmp_path / "test.go" import_str = "\n".join(f' "{i}"' for i in imports) src.write_text( - textwrap.dedent( - f"""\ + textwrap.dedent(f"""\ package main import ( @@ -96,11 +95,9 @@ def f(code_fragment, *, progress=Progress.RUNS, static=False, imports=[]): ) func test() error {{ - """ - ) + """) + textwrap.dedent(code_fragment) - + textwrap.dedent( - """\ + + textwrap.dedent("""\ return nil } @@ -129,8 +126,7 @@ def f(code_fragment, *, progress=Progress.RUNS, static=False, imports=[]): } os.Exit(1) } - """ - ) + """) ) subprocess.run(["go", "mod", "tidy"], cwd=tmp_path, check=True) @@ -236,9 +232,7 @@ def validate_test(test_lib, tmp_path_factory): ) src = tmp_path / "validate.go" - src.write_text( - textwrap.dedent( - """\ + src.write_text(textwrap.dedent("""\ package main import ( @@ -271,9 +265,7 @@ def validate_test(test_lib, tmp_path_factory): } os.Exit(0) } - """ - ) - ) + """)) subprocess.run(["go", "mod", "tidy"], cwd=tmp_path, check=True) prog = tmp_path / "validate" @@ -313,9 +305,7 @@ def roundtrip_test(test_lib, tmp_path_factory): ) src = tmp_path / "roundtrip.go" - src.write_text( - textwrap.dedent( - """\ + src.write_text(textwrap.dedent("""\ package main import ( @@ -355,9 +345,7 @@ def roundtrip_test(test_lib, tmp_path_factory): os.Exit(1) } } - """ - ) - ) + """)) subprocess.run(["go", "mod", "tidy"], cwd=tmp_path, check=True) prog = tmp_path / "roundtrip" @@ -384,8 +372,7 @@ def link_test(test_lib, tmp_path_factory): test_lib, tmp_path_factory, "link", - textwrap.dedent( - """\ + textwrap.dedent("""\ package main import ( @@ -480,8 +467,7 @@ def link_test(test_lib, tmp_path_factory): checkObject("LinkClassLinkListProp[0]", check.LinkClassLinkListProp().Get()[0]) checkObject("LinkClassLinkListProp[1]", check.LinkClassLinkListProp().Get()[1]) } - """ - ), + """), ) From ed1e09141ac012ee5b59d8c5b2b513e841e1438b Mon Sep 17 00:00:00 2001 From: Joshua Watt Date: Tue, 10 Feb 2026 08:09:52 -0700 Subject: [PATCH 02/11] testfixtures: Add context tests Adds tests for context expansion and compaction. Language bindings can use these test to ensure that their expansion and compaction code works properly --- testfixtures/testfixtures/jsonvalidation.py | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/testfixtures/testfixtures/jsonvalidation.py b/testfixtures/testfixtures/jsonvalidation.py index 4cd3f992..3bb74667 100644 --- a/testfixtures/testfixtures/jsonvalidation.py +++ b/testfixtures/testfixtures/jsonvalidation.py @@ -924,3 +924,51 @@ def link_tests(): ), ], ) + + +def context_tests(): + return pytest.mark.parametrize( + "context,expanded,compacted", + [ + param( + dict(), + "http://expanded", + "http://expanded", + id="No change with empty context", + ), + param( + { + "prefix": "http://expanded/", + }, + "http://expanded/foo", + "prefix:foo", + id="Single prefix from context", + ), + param( + { + "prefixa": "prefixb:foo/", + "prefixb": "http://expanded/", + }, + "http://expanded/foo/bar", + "prefixa:bar", + id="Nested prefixes", + ), + param( + { + "full": "http://expanded/foo", + }, + "http://expanded/foo", + "full", + id="Full prefix match", + ), + param( + { + "full": "prefix:foo", + "prefix": "http://expanded/", + }, + "http://expanded/foo", + "full", + id="Nested full match", + ), + ], + ) From 24b7e0c00ecbf64dbdb9fc3bfbb25560b4174ddd Mon Sep 17 00:00:00 2001 From: Joshua Watt Date: Sun, 8 Feb 2026 12:26:43 -0700 Subject: [PATCH 03/11] jsonschema: Add custom context support Adds support for documents to support additional local context with a dictionary of key/value pairs in the "@context". This allows users to shorten the IRIs in their documents. --- src/shacl2code/lang/templates/jsonschema.j2 | 48 +++++++++++++-------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/src/shacl2code/lang/templates/jsonschema.j2 b/src/shacl2code/lang/templates/jsonschema.j2 index 4b359956..a43ff374 100644 --- a/src/shacl2code/lang/templates/jsonschema.j2 +++ b/src/shacl2code/lang/templates/jsonschema.j2 @@ -10,25 +10,26 @@ "type": "object", "properties": { - {%- if context.urls %} - {%- if context.urls | length == 1 %} "@context": { - "const": "{{ context.urls[0] }}" - } - {%- else %} - "@context": { - "type": "array", - "prefixItems": { - {%- for url in context.urls %} - { "const": "{{ url }}" }{% if not loop.last %},{% endif %} - {%- endfor %} - }, - "minContains": "{{ context.urls | length }}", - "maxContains": "{{ context.urls | length }}", - "items": false + "anyOf": [ + { + "type": "array", + "allOf": [ + {%- for url in context.urls %} + { + "contains": { "const": "{{ url }}" } + }, + {%- endfor %} + { + "items": { "$ref": "#/$defs/Context" } + } + ] + }, + { + "$ref": "#/$defs/Context" + } + ] } - {%- endif %} - {%- endif %} }, {%- if context.urls %} "required": ["@context"], @@ -314,6 +315,19 @@ { "$ref": "#/$defs/{{ varname(*class.clsname) }}" }{% if not loop.last %},{% endif %} {%- endfor %} ] + }, + "Context": { + "anyOf": [ + {%- for url in context.urls %} + { "const": "{{ url }}" }, + {%- endfor %} + { + "type": "object", + "patternProperties": { + ".*": { "type": "string" } + } + } + ] } } } From c29847643ab32f3959fb958f0b232821b34d10b9 Mon Sep 17 00:00:00 2001 From: Joshua Watt Date: Sun, 8 Feb 2026 12:26:43 -0700 Subject: [PATCH 04/11] python: Add custom context support Adds support for documents to support additional local context with a dictionary of key/value pairs in the "@context". This allows users to shorten the IRIs in their documents. --- src/shacl2code/lang/templates/python.j2 | 347 +++++++++++++++--------- tests/test_python.py | 15 + 2 files changed, 230 insertions(+), 132 deletions(-) diff --git a/src/shacl2code/lang/templates/python.j2 b/src/shacl2code/lang/templates/python.j2 index 017b4ecd..4e916288 100644 --- a/src/shacl2code/lang/templates/python.j2 +++ b/src/shacl2code/lang/templates/python.j2 @@ -103,11 +103,11 @@ class Property(ABC): return str(value) @abstractmethod - def encode(self, encoder, value, state) -> None: + def encode(self, encoder, value, state: EncodeState) -> None: raise NotImplementedError("Subclasses must implement encode method") @abstractmethod - def decode(self, decoder, *, objectset: Optional[SHACLObjectSet] = None) -> Any: + def decode(self, decoder, state: DecodeState) -> Any: raise NotImplementedError("Subclasses must implement decode method") @@ -121,22 +121,18 @@ class StringProp(Property): def set(self, value) -> str: return str(value) - def encode(self, encoder, value, state) -> None: + def encode(self, encoder, value, state: EncodeState) -> None: encoder.write_string(value) - def decode( - self, decoder, *, objectset: Optional[SHACLObjectSet] = None - ) -> Optional[str]: + def decode(self, decoder, state: DecodeState) -> Optional[str]: return decoder.read_string() class AnyURIProp(StringProp): - def encode(self, encoder, value, state) -> None: + def encode(self, encoder, value, state: EncodeState) -> None: encoder.write_iri(value) - def decode( - self, decoder, *, objectset: Optional[SHACLObjectSet] = None - ) -> Optional[str]: + def decode(self, decoder, state: DecodeState) -> Optional[str]: return decoder.read_iri() @@ -152,12 +148,10 @@ class DateTimeProp(Property): def set(self, value) -> datetime: return self._normalize(value) - def encode(self, encoder, value, state) -> None: + def encode(self, encoder, value, state: EncodeState) -> None: encoder.write_datetime(self.to_string(value)) - def decode( - self, decoder, *, objectset: Optional[SHACLObjectSet] = None - ) -> Optional[datetime]: + def decode(self, decoder, state: DecodeState) -> Optional[datetime]: s = decoder.read_datetime() if s is None: return None @@ -222,12 +216,10 @@ class IntegerProp(Property): def set(self, value) -> int: return int(value) - def encode(self, encoder, value, state) -> None: + def encode(self, encoder, value, state: EncodeState) -> None: encoder.write_integer(value) - def decode( - self, decoder, *, objectset: Optional[SHACLObjectSet] = None - ) -> Optional[int]: + def decode(self, decoder, state: DecodeState) -> Optional[int]: return decoder.read_integer() @@ -251,12 +243,10 @@ class BooleanProp(Property): def set(self, value) -> bool: return bool(value) - def encode(self, encoder, value, state) -> None: + def encode(self, encoder, value, state: EncodeState) -> None: encoder.write_bool(value) - def decode( - self, decoder, *, objectset: Optional[SHACLObjectSet] = None - ) -> Optional[bool]: + def decode(self, decoder, state: DecodeState) -> Optional[bool]: return decoder.read_bool() @@ -266,12 +256,10 @@ class FloatProp(Property): def set(self, value) -> float: return float(value) - def encode(self, encoder, value, state) -> None: + def encode(self, encoder, value, state: EncodeState) -> None: encoder.write_float(value) - def decode( - self, decoder, *, objectset: Optional[SHACLObjectSet] = None - ) -> Optional[float]: + def decode(self, decoder, state: DecodeState) -> Optional[float]: return decoder.read_float() @@ -287,17 +275,17 @@ class IRIProp(Property): super().__init__(pattern=pattern) self.context = context - def compact(self, value) -> Optional[str]: + def compact(self, value, dflt=None) -> Optional[str]: for iri, compact in self.context: if value == iri: return compact - return None + return dflt - def expand(self, value) -> Optional[str]: + def expand(self, value, dflt=None) -> Optional[str]: for iri, compact in self.context: if value == compact: return iri - return None + return dflt def iri_values(self): return (iri for iri, _ in self.context) @@ -341,28 +329,32 @@ class ObjectProp(IRIProp): for c in value.iter_objects(recursive=True, visited=visited): yield c - def encode(self, encoder, value, state) -> None: + def encode(self, encoder, value, state: EncodeState) -> None: if value is None: raise ValueError("Object cannot be None") if isinstance(value, str): - encoder.write_iri(value, self.compact(value)) + encoder.write_iri( + value, self.compact(value, state.objectset.compact_iri(value)) + ) return return value.encode(encoder, state) - def decode(self, decoder, *, objectset: Optional[SHACLObjectSet] = None): + def decode(self, decoder, state: DecodeState): if decoder.is_object(): - return self.cls.decode(decoder, objectset=objectset) + return self.cls.decode(decoder, state) iri = decoder.read_iri() iri = self.expand(iri) or iri - if objectset is None: + if state.objectset is None: return iri - obj = objectset.find_by_id(iri) + iri = state.objectset.expand_iri(iri) + + obj = state.objectset.find_by_id(iri) if obj is None: return iri @@ -513,7 +505,7 @@ class ListProp(Property): return ListProxy(self.prop, data=data) - def encode(self, encoder, value, state) -> None: + def encode(self, encoder, value, state: EncodeState) -> None: check_type(value, ListProxy) with encoder.write_list() as list_s: @@ -521,12 +513,10 @@ class ListProp(Property): with list_s.write_list_item() as item_s: self.prop.encode(item_s, v, state) - def decode( - self, decoder, *, objectset: Optional[SHACLObjectSet] = None - ) -> ListProxy: + def decode(self, decoder, state: DecodeState) -> ListProxy: data = [] for val_d in decoder.read_list(): - v = self.prop.decode(val_d, objectset=objectset) + v = self.prop.decode(val_d, state) self.prop.validate(v) data.append(v) @@ -550,7 +540,7 @@ class EnumProp(IRIProp): def encode(self, encoder, value, state) -> None: encoder.write_enum(value, self, self.compact(value)) - def decode(self, decoder, *, objectset: Optional[SHACLObjectSet] = None) -> str: + def decode(self, decoder, state: DecodeState) -> str: v = decoder.read_enum(self) return self.expand(v) or v @@ -843,22 +833,25 @@ class SHACLObject(object): ): yield c - def encode(self, encoder, state) -> None: + def encode(self, encoder, state: EncodeState) -> None: idname = self.ID_ALIAS or self._OBJ_IRIS["_id"] if not self._id and self.NODE_KIND == NodeKind.IRI: raise ValueError( f"{self.__class__.__name__} ({id(self)}) must have a IRI for property '{idname}'" ) + iri = state.objectset.compact_iri(state.get_object_id(self)) + if state.is_written(self): - encoder.write_iri(state.get_object_id(self)) + encoder.write_iri(iri) return state.add_written(self) with encoder.write_object( self, - state.get_object_id(self), + self.COMPACT_TYPE or state.objectset.compact_iri(self.TYPE), + iri, bool(self._id) or state.is_refed(self), ) as obj_s: self._encode_properties(obj_s, state) @@ -899,38 +892,37 @@ class SHACLObject(object): return cls.CLASSES[typ]() @classmethod - def decode(cls, decoder, *, objectset: Optional[SHACLObjectSet] = None): + def decode(cls, decoder, state: DecodeState): typ, obj_d = decoder.read_object() if typ is None: raise TypeError("Unable to determine type for object") + typ = state.objectset.expand_iri(typ) obj = cls._make_object(typ) _id = obj_d.read_object_id(obj.ID_ALIAS) if _id is not None: - obj._id = _id + obj._id = state.objectset.expand_iri(_id) if obj.NODE_KIND == NodeKind.IRI and not obj._id: raise ValueError("Object is missing required IRI") - if objectset is not None: + if state.objectset is not None: if obj._id: - v = objectset.find_by_id(obj._id) + v = state.objectset.find_by_id(obj._id) if v is not None: return v - objectset.add_index(obj) + state.objectset.add_index(obj) - obj._decode_properties(obj_d, objectset=objectset) + obj._decode_properties(obj_d, state) return obj - def _decode_properties( - self, decoder, objectset: Optional[SHACLObjectSet] = None - ) -> None: + def _decode_properties(self, decoder, state: DecodeState) -> None: for key in decoder.object_keys(): - if not self._decode_prop(decoder, key, objectset=objectset): + if not self._decode_prop(decoder, key, state): raise KeyError(f"Unknown property '{key}'") - def _decode_prop(self, decoder, key, objectset: Optional[SHACLObjectSet] = None): + def _decode_prop(self, decoder, key, state: DecodeState): if key in (self._OBJ_IRIS["_id"], self.ID_ALIAS): return True @@ -943,7 +935,7 @@ class SHACLObject(object): continue with decoder.read_property(read_key) as prop_d: - v = prop.decode(prop_d, objectset=objectset) + v = prop.decode(prop_d, state) prop.validate(v) self.__dict__["_obj_data"][iri] = v return True @@ -1023,9 +1015,7 @@ class SHACLExtensibleObject(SHACLObject): obj = cls(typ) return obj - def _decode_properties( - self, decoder, objectset: Optional[SHACLObjectSet] = None - ) -> None: + def _decode_properties(self, decoder, state: DecodeState) -> None: def decode_value(d): if not d.is_list(): return d.read_value() @@ -1033,11 +1023,11 @@ class SHACLExtensibleObject(SHACLObject): return [decode_value(val_d) for val_d in d.read_list()] if self.CLOSED: - super()._decode_properties(decoder, objectset=objectset) + super()._decode_properties(decoder, state) return for key in decoder.object_keys(): - if self._decode_prop(decoder, key, objectset=objectset): + if self._decode_prop(decoder, key, state): continue if not is_IRI(key): @@ -1129,6 +1119,7 @@ class SHACLObjectSet(object): self.obj_by_id: Dict[str, SHACLObject] = {} self.obj_by_type: Dict[str, Set[Tuple[bool, SHACLObject]]] = {} self.create_index() + self.context: Dict[str, str] = {} if link: self._link() @@ -1370,14 +1361,15 @@ class SHACLObjectSet(object): self.objects = new_objects - def encode(self, encoder, force_list: bool = False, *, key=None) -> None: + def encode( + self, encoder, state: EncodeState, force_list: bool = False, *, key=None + ) -> None: """ Serialize a list of objects to a serialization encoder If force_list is true, a list will always be written using the encoder. """ ref_counts: Dict[SHACLObject, int] = {} - state = EncodeState() def walk_callback(value: SHACLObject, path: List[str]) -> bool: nonlocal state @@ -1431,7 +1423,7 @@ class SHACLObjectSet(object): for o in objects: state.written_objects.add(o) - with encoder.write_list() as list_s: + with encoder.write_object_list() as list_s: for o in objects: # Allow this specific object to be written now state.written_objects.remove(o) @@ -1441,21 +1433,37 @@ class SHACLObjectSet(object): elif objects: objects[0].encode(encoder, state) - def decode(self, decoder): + def decode(self, decoder, state: DecodeState): self.create_index() - for obj_d in decoder.read_list(): - o = SHACLExtensibleObject.decode(obj_d, objectset=self) + o = SHACLExtensibleObject.decode(obj_d, state) self.objects.add(o) self._link() + def expand_iri(self, iri): + for k, v in self.context.items(): + if iri == k: + return self.expand_iri(v) + if iri.startswith(k + ":"): + return self.expand_iri(v + iri[len(k) + 1 :]) + return iri + + def compact_iri(self, iri): + for k, v in self.context.items(): + if iri == v: + return self.compact_iri(k) + if iri.startswith(v): + return self.compact_iri(k + ":" + iri[len(v) :]) + return iri + class EncodeState(object): - def __init__(self): - self.ref_objects = set() - self.written_objects = set() - self.blank_objects = {} + def __init__(self, objectset: SHACLObjectSet): + self.ref_objects: set[SHACLObject] = set() + self.written_objects: set[SHACLObject] = set() + self.blank_objects: Dict[SHACLObject, str] = {} + self.objectset = objectset def get_object_id(self, o): if o._id: @@ -1480,6 +1488,11 @@ class EncodeState(object): self.written_objects.add(o) +class DecodeState(object): + def __init__(self, objectset): + self.objectset = objectset + + class Decoder(ABC): @abstractmethod def read_value(self) -> Optional[Any]: @@ -1745,12 +1758,15 @@ class JSONLDDecoder(Decoder): class JSONLDDeserializer(object): def deserialize_data(self, data, objectset: SHACLObjectSet) -> None: - if "@graph" in data: - h = JSONLDDecoder(data["@graph"], True) - else: - h = JSONLDDecoder(data, True) + h = JSONLDDecoder(data, True) + + with h.read_property("@context") as context_prop: + if context_prop: + decode_context(context_prop, objectset) - objectset.decode(h) + state = DecodeState(objectset) + with h.read_property("@graph") as graph_prop: + objectset.decode(graph_prop if graph_prop else h, state) def read(self, f, objectset: SHACLObjectSet) -> None: data = json.load(f) @@ -1830,7 +1846,7 @@ class Encoder(ABC): @abstractmethod @contextmanager - def write_object(self, o: SHACLObject, _id: str, needs_id: bool): + def write_object(self, o: SHACLObject, typ: str, _id: str, needs_id: bool): """ Write object @@ -1889,6 +1905,19 @@ class Encoder(ABC): """ raise NotImplementedError("Subclasses must implement write_list_item method") + @abstractmethod + @contextmanager + def write_object_list(self): + """ + Write top level object list + + A context manager that yields an `Encoder` that encodes the top level + list of objects. + + Each object in the list will be added using `write_list_item` + """ + raise NotImplementedError("Subclasses must implement write_object_list method") + class JSONLDEncoder(Encoder): def __init__(self, data=None): @@ -1923,14 +1952,19 @@ class JSONLDEncoder(Encoder): self.data[compact or iri] = s.data # type: ignore # within write_object() context, self.data is always dict or None @contextmanager - def write_object(self, o: SHACLObject, _id: str, needs_id: bool): - self.data = { - "{{ context.compact_iri('@type') }}": o.COMPACT_TYPE or o.TYPE, - } - if needs_id: - self.data[o.ID_ALIAS or "@id"] = _id + def write_dict(self): + if not self.data: + self.data = {} yield self + @contextmanager + def write_object(self, o: SHACLObject, typ: str, _id: str, needs_id: bool): + with self.write_dict() as obj_dict: + obj_dict.data["{{ context.compact_iri('@type') }}"] = typ # type: ignore + if needs_id: + obj_dict.data[o.ID_ALIAS or "@id"] = _id # type: ignore + yield obj_dict + @contextmanager def write_list(self): self.data = [] @@ -1945,6 +1979,12 @@ class JSONLDEncoder(Encoder): if s.data is not None: self.data.append(s.data) # type: ignore # within write_list() context, self.data is always list or None + @contextmanager + def write_object_list(self): + with self.write_property("@graph") as graph_prop: + with graph_prop.write_list() as graph_list: + yield graph_list + class JSONLDSerializer(object): def __init__(self, **args): @@ -1956,22 +1996,11 @@ class JSONLDSerializer(object): force_at_graph: bool = False, ): h = JSONLDEncoder() - objectset.encode(h, force_at_graph) - data: Dict[str, Any] = {} - if len(CONTEXT_URLS) == 1: - data["@context"] = CONTEXT_URLS[0] - elif CONTEXT_URLS: - data["@context"] = CONTEXT_URLS - - if isinstance(h.data, list): - data["@graph"] = h.data - elif isinstance(h.data, dict): - for k, v in h.data.items(): - data[k] = v - # elif h.data is not None: # str, int, float, bool - # data["value"] = h.data - - return data + state = EncodeState(objectset) + with h.write_dict() as doc_s: + encode_context(doc_s, objectset) + objectset.encode(doc_s, state, force_at_graph) + return h.data def write( self, @@ -2002,10 +2031,11 @@ class JSONLDSerializer(object): class JSONLDInlineEncoder(Encoder): - def __init__(self, f, sha1): + def __init__(self, f, sha1, in_dict=False): self.f = f self.comma = False self.sha1 = sha1 + self.in_dict = in_dict def write(self, s): s = s.encode("utf-8") @@ -2046,31 +2076,34 @@ class JSONLDInlineEncoder(Encoder): self._write_comma() self.write_string(compact or iri) self.write(":") - yield self + yield self.__class__(self.f, self.sha1) self.comma = True @contextmanager - def write_object(self, o: SHACLObject, _id: str, needs_id: bool): + def write_dict(self): self._write_comma() + if self.in_dict: + yield self + return self.write("{") - self.write_string("{{ context.compact_iri('@type') }}") - self.write(":") - self.write_string(o.COMPACT_TYPE or o.TYPE) + yield self.__class__(self.f, self.sha1, True) + self.write("}") self.comma = True - if needs_id: - self._write_comma() - self.write_string(o.ID_ALIAS or "@id") - self.write(":") - self.write_string(_id) - self.comma = True + @contextmanager + def write_object(self, o: SHACLObject, typ: str, _id: str, needs_id: bool): + with self.write_dict() as obj_dict: + with obj_dict.write_property( + "{{ context.compact_iri('@type') }}" + ) as type_prop: + type_prop.write_string(typ) - self.comma = True - yield self + if needs_id: + with obj_dict.write_property(o.ID_ALIAS or "@id") as id_prop: + id_prop.write_string(_id) - self.write("}") - self.comma = True + yield obj_dict @contextmanager def write_list(self): @@ -2086,6 +2119,12 @@ class JSONLDInlineEncoder(Encoder): yield self.__class__(self.f, self.sha1) self.comma = True + @contextmanager + def write_object_list(self): + with self.write_property("@graph") as graph_prop: + with graph_prop.write_list() as graph_list: + yield graph_list + class JSONLDInlineSerializer(object): def write( @@ -2102,22 +2141,60 @@ class JSONLDInlineSerializer(object): """ sha1 = hashlib.sha1() h = JSONLDInlineEncoder(f, sha1) - h.write('{"@context":') - if len(CONTEXT_URLS) == 1: - h.write(f'"{CONTEXT_URLS[0]}"') - elif CONTEXT_URLS: - h.write('["') - h.write('","'.join(CONTEXT_URLS)) - h.write('"]') - h.write(",") - - h.write('"@graph":') - - objectset.encode(h, True) - h.write("}") + state = EncodeState(objectset) + with h.write_dict() as doc_s: + encode_context(doc_s, objectset) + objectset.encode(doc_s, state, True) + return sha1.hexdigest() +def encode_context(encoder: Encoder, objectset: SHACLObjectSet): + context: List[Union[str, Dict[str, str]]] = list(CONTEXT_URLS) + if objectset.context: + context.append(objectset.context) + + if not context: + return + + def write_context(e, ctx): + if isinstance(ctx, str): + e.write_string(ctx) + else: + with e.write_dict() as dict_s: + for k, v in ctx.items(): + with dict_s.write_property(k) as ctx_prop: + ctx_prop.write_string(v) + + with encoder.write_property("@context") as context_prop: + if len(context) == 1: + write_context(context_prop, context[0]) + else: + with context_prop.write_list() as context_list: + for ctx in context: + with context_list.write_list_item() as context_list_item: + write_context(context_list_item, ctx) + + +def decode_context(decoder: Decoder, objectset: SHACLObjectSet): + def _decode_ctx(d: Decoder): + if not d.is_object(): + return + + for k in d.object_keys(): + with d.read_property(k) as prop_d: + if prop_d: + if s := prop_d.read_string(): + objectset.context[k] = s + + if decoder.is_list(): + for ctx_d in decoder.read_list(): + _decode_ctx(ctx_d) + pass + else: + _decode_ctx(decoder) + + try: import rdflib import rdflib.term @@ -2277,7 +2354,8 @@ try: class RDFDeserializer(object): def read(self, graph: rdflib.Graph, objset: SHACLObjectSet) -> None: d = RDFDecoder(graph) - objset.decode(d) + state = DecodeState(objset) + objset.decode(d, state) objset.inline_blank_nodes() class RDFEncoder(Encoder): @@ -2327,7 +2405,7 @@ try: yield self.__class__(self.graph, self.subject, rdflib.URIRef(iri)) @contextmanager - def write_object(self, o, _id, needs_id: bool): + def write_object(self, o, typ, _id, needs_id: bool): obj: rdflib.term.Node if _id.startswith("_:"): obj = rdflib.BNode(_id[2:]) @@ -2349,13 +2427,18 @@ try: def write_list_item(self): yield self + @contextmanager + def write_object_list(self): + yield self + class RDFSerializer(object): def write(self, objset: SHACLObjectSet, g: rdflib.Graph): """ Write a SHACLObjectSet to an RDF graph """ e = RDFEncoder(g) - objset.encode(e) + state = EncodeState(objset) + objset.encode(e, state) except ImportError: pass diff --git a/tests/test_python.py b/tests/test_python.py index 61683c43..88c309ff 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -249,6 +249,9 @@ def test_from_rdf_roundtrip(model, tmp_path, roundtrip): objset = model.SHACLObjectSet() model.RDFDeserializer().read(g, objset) + # Copy context from expected roundtrip file (RDF doesn't preserve context) + model.decode_context(model.JSONLDDecoder(expect_data["@context"]), objset) + # Write out outfile = tmp_path / "out.json" with outfile.open("wb") as f: @@ -274,6 +277,9 @@ def test_to_rdf_roundtrip(model, tmp_path, roundtrip): objset = model.SHACLObjectSet() model.RDFDeserializer().read(g, objset) + # Copy context from expected roundtrip file (RDF doesn't preserve context) + model.decode_context(model.JSONLDDecoder(expect_data["@context"]), objset) + # Write out outfile = tmp_path / "out.json" with outfile.open("wb") as f: @@ -1666,3 +1672,12 @@ def test_deprecated_property(model): with pytest.deprecated_call(): c.test_deprecated_class_deprecated_string_prop = "foo" + + +@jsonvalidation.context_tests() +def test_objset_context(model, context, expanded, compacted): + objset = model.SHACLObjectSet() + objset.context = context + + assert objset.compact_iri(expanded) == compacted + assert expanded == objset.expand_iri(compacted) From 9213fc9ae8265b4054f347793845f8a472e28392 Mon Sep 17 00:00:00 2001 From: Joshua Watt Date: Sun, 8 Feb 2026 12:26:43 -0700 Subject: [PATCH 05/11] cpp: Add custom context support Adds support for documents to support additional local context with a dictionary of key/value pairs in the "@context". This allows users to shorten the IRIs in their documents. --- .../lang/templates/cpp/decode.cpp.j2 | 15 ++ .../lang/templates/cpp/decode.hpp.j2 | 26 ++- .../lang/templates/cpp/encode.cpp.j2 | 17 +- .../lang/templates/cpp/encode.hpp.j2 | 53 ++++++- .../lang/templates/cpp/extensible.cpp.j2 | 6 +- .../lang/templates/cpp/jsonld-header.j2 | 16 +- .../lang/templates/cpp/jsonld-source.j2 | 149 +++++++++++------- .../lang/templates/cpp/object.cpp.j2 | 14 +- .../lang/templates/cpp/objectset.cpp.j2 | 35 ++++ .../lang/templates/cpp/objectset.hpp.j2 | 43 +++++ .../lang/templates/cpp/property.cpp.j2 | 8 +- .../lang/templates/cpp/property.hpp.j2 | 10 +- .../lang/templates/cpp/refproperty.hpp.j2 | 9 +- tests/test_cpp.py | 16 +- 14 files changed, 332 insertions(+), 85 deletions(-) diff --git a/src/shacl2code/lang/templates/cpp/decode.cpp.j2 b/src/shacl2code/lang/templates/cpp/decode.cpp.j2 index 47efbad9..4a84bfef 100644 --- a/src/shacl2code/lang/templates/cpp/decode.cpp.j2 +++ b/src/shacl2code/lang/templates/cpp/decode.cpp.j2 @@ -13,6 +13,7 @@ vim: ft=cpp #include #include "decode.hpp" +#include "objectset.hpp" /* {{ "*" + "/" }} {%- if namespace %} @@ -28,6 +29,20 @@ using std::string_literals::operator""s; DecoderState::DecoderState(SHACLObjectSet* objectSet) : mObjectSet(objectSet) {} DecoderState::~DecoderState() {} +std::string DecoderState::expandIRI(std::string const& iri) const { + if (!mObjectSet) { + return iri; + } + return mObjectSet->expandIRI(iri); +} + +std::string DecoderState::compactIRI(std::string const& iri) const { + if (!mObjectSet) { + return iri; + } + return mObjectSet->compactIRI(iri); +} + // ElementDecoder ElementDecoder::ElementDecoder(DecoderState& state) : mState(state) {} ElementDecoder::~ElementDecoder() {} diff --git a/src/shacl2code/lang/templates/cpp/decode.hpp.j2 b/src/shacl2code/lang/templates/cpp/decode.hpp.j2 index 4216676f..0e64f3c1 100644 --- a/src/shacl2code/lang/templates/cpp/decode.hpp.j2 +++ b/src/shacl2code/lang/templates/cpp/decode.hpp.j2 @@ -47,10 +47,34 @@ class EXPORT DecoderState { /** * @brief Get object set * - * @returns The SHACLObjectSet being encoded + * @returns The SHACLObjectSet being decoded */ SHACLObjectSet* getObjectSet() const { return mObjectSet; } + /** + * @brief Expand IRI + * + * Expands an IRI if there is a SHACLObjectSet attached to the state, + * otherwise returns the original IRI. + * + * @param[in] iri The IRI to expand + * + * @returns the expanded IRI + */ + std::string expandIRI(std::string const& iri) const; + + /** + * @brief Compact IRI + * + * Compacts an IRI if there is a SHACLOBjectSet attached to the state, + * otherwise returns the original IRI. + * + * @param[in] iri The IRI to compact + * + * @returns the compacted IRI + */ + std::string compactIRI(std::string const& iri) const; + private: SHACLObjectSet* mObjectSet; }; diff --git a/src/shacl2code/lang/templates/cpp/encode.cpp.j2 b/src/shacl2code/lang/templates/cpp/encode.cpp.j2 index e9fe0fb1..d616366f 100644 --- a/src/shacl2code/lang/templates/cpp/encode.cpp.j2 +++ b/src/shacl2code/lang/templates/cpp/encode.cpp.j2 @@ -19,6 +19,7 @@ vim: ft=cpp #include "encode.hpp" #include "object.hpp" +#include "objectset.hpp" /* {{ "*" + "/" }} {%- if namespace %} @@ -31,7 +32,7 @@ namespace {{ n }} { using std::string_literals::operator""s; // EncoderState -EncoderState::EncoderState() {} +EncoderState::EncoderState(SHACLObjectSet* objectSet) : mObjectSet(objectSet) {} EncoderState::~EncoderState() {} std::string EncoderState::getObjectId(SHACLObject const& obj) { @@ -78,6 +79,20 @@ EncoderState::objectKey EncoderState::getObjectKey( return reinterpret_cast(&obj); } +std::string EncoderState::expandIRI(std::string const& iri) const { + if (!mObjectSet) { + return iri; + } + return mObjectSet->expandIRI(iri); +} + +std::string EncoderState::compactIRI(std::string const& iri) const { + if (!mObjectSet) { + return iri; + } + return mObjectSet->compactIRI(iri); +} + // ElementEncoder ElementEncoder::ElementEncoder(EncoderState& state) : mState(state) {} ElementEncoder::~ElementEncoder() {} diff --git a/src/shacl2code/lang/templates/cpp/encode.hpp.j2 b/src/shacl2code/lang/templates/cpp/encode.hpp.j2 index 979e7d8a..669ce18a 100644 --- a/src/shacl2code/lang/templates/cpp/encode.hpp.j2 +++ b/src/shacl2code/lang/templates/cpp/encode.hpp.j2 @@ -24,6 +24,7 @@ vim: ft=cpp /* {{ ns_begin }} */ class SHACLObject; +class SHACLObjectSet; class ObjectEncoder; class ListEncoder; class DateTime; @@ -43,7 +44,7 @@ class EXPORT EncoderState { * * Creates a new EncoderState */ - EncoderState(); + EncoderState(SHACLObjectSet* objectSet = nullptr); /** * @brief Destructor @@ -123,6 +124,37 @@ class EXPORT EncoderState { */ void removeWritten(SHACLObject const& obj); + /** + * @brief Get object set + * + * @returns The SHACLObjectSet being encoded + */ + SHACLObjectSet* getObjectSet() const { return mObjectSet; } + + /** + * @brief Expand IRI + * + * Expands an IRI if there is a SHACLObjectSet attached to the state, + * otherwise returns the original IRI. + * + * @param[in] iri The IRI to expand + * + * @returns the expanded IRI + */ + std::string expandIRI(std::string const& iri) const; + + /** + * @brief Compact IRI + * + * Compacts an IRI if there is a SHACLOBjectSet attached to the state, + * otherwise returns the original IRI. + * + * @param[in] iri The IRI to compact + * + * @returns the compacted IRI + */ + std::string compactIRI(std::string const& iri) const; + private: using objectKey = uintptr_t; @@ -140,6 +172,7 @@ class EXPORT EncoderState { std::set> mRefed; std::unordered_set mIsRefed; std::unordered_set mWritten; + SHACLObjectSet* mObjectSet; }; /** @@ -252,6 +285,19 @@ class EXPORT ValueEncoder : public ElementEncoder { */ virtual void writeFloat(double value) = 0; + /** + * @brief Write dictionary + * + * Writes a dictionary in the output. The function @p f will be invoked + * with a new ObjectEncoder to encoder the contest pf the dictionary + * + * @param[in] f Function called by the encoder to encoder the + * content of the dictionary + * + * @see ObjectEncoder + */ + virtual void writeDict(std::function const& f) = 0; + /** * @brief Write object * @@ -259,6 +305,7 @@ class EXPORT ValueEncoder : public ElementEncoder { * with a new ObjectEncoder to encode the contents of the object * * @param[in] o The object to encode + * @param[in] type The type IRI to use for the object * @param[in] id The ID to use when encoding the object. This will * always be a valid ID (either an IRI or a blank * node), and should be used by the encoder instead of querying the @@ -271,8 +318,8 @@ class EXPORT ValueEncoder : public ElementEncoder { * * @see ObjectEncoder */ - virtual void writeObject(SHACLObject const& o, std::string const& id, - bool needs_id, + virtual void writeObject(SHACLObject const& o, std::string const& type, + std::string const& id, bool needs_id, std::function const& f) = 0; /** diff --git a/src/shacl2code/lang/templates/cpp/extensible.cpp.j2 b/src/shacl2code/lang/templates/cpp/extensible.cpp.j2 index 98eceebb..34525245 100644 --- a/src/shacl2code/lang/templates/cpp/extensible.cpp.j2 +++ b/src/shacl2code/lang/templates/cpp/extensible.cpp.j2 @@ -43,6 +43,7 @@ std::vector& SHACLExtensibleObjectBase::getExtProperty( void SHACLExtensibleObjectBase::encodeExtensibleProperties( ObjectEncoder& encoder, ErrorHandler& errorHandler, ObjectPath& path) const { + auto& state = encoder.getState(); for (auto const& p : mExtendedProperties) { auto const& v = p.second; if (!v.size()) { @@ -80,8 +81,9 @@ void SHACLExtensibleObjectBase::encodeExtensibleProperties( errorHandler, path); } else { - item_encoder.writeIRI(value.iri(), - {}); + item_encoder.writeIRI( + state.compactIRI(value.iri()), + {}); } } else { diff --git a/src/shacl2code/lang/templates/cpp/jsonld-header.j2 b/src/shacl2code/lang/templates/cpp/jsonld-header.j2 index 31794fa2..0c88e254 100644 --- a/src/shacl2code/lang/templates/cpp/jsonld-header.j2 +++ b/src/shacl2code/lang/templates/cpp/jsonld-header.j2 @@ -36,6 +36,8 @@ namespace {{ n }} { #endif // clang-format on +class SHACLObjectSet; + // Encode /** * @brief JSONLD Encoder State @@ -45,7 +47,8 @@ namespace {{ n }} { class EXPORT JSONLDEncoderState : public EncoderState { public: /// Constructor - JSONLDEncoderState(std::ostream& output); + JSONLDEncoderState(std::ostream& output, + SHACLObjectSet* objectSet = nullptr); /// Destructor virtual ~JSONLDEncoderState(); @@ -112,8 +115,12 @@ class EXPORT JSONLDValueEncoder : public ValueEncoder { /// @copydoc {{ nsprefix }}::ValueEncoder::writeFloat() void writeFloat(double value) override; + /// @copydoc {{ nsprefix }}::ValueEncoder::writeDict() + void writeDict(std::function const& f) override; + /// @copydoc {{ nsprefix }}::ValueEncoder::writeObject() - void writeObject(SHACLObject const& o, std::string const& id, bool needs_id, + void writeObject(SHACLObject const& o, std::string const& type, + std::string const& id, bool needs_id, std::function const& f) override; /// @copydoc {{ nsprefix }}::ValueEncoder::writeList() @@ -256,8 +263,9 @@ class EXPORT JSONLDValueDecoder : public ValueDecoder { class EXPORT JSONLDObjectDecoder : public ObjectDecoder { public: /// Constructor - JSONLDObjectDecoder(DecoderState& state, JSONData const& data, - std::unordered_set const& ignoreProperties = {}); + JSONLDObjectDecoder( + DecoderState& state, JSONData const& data, + std::unordered_set const& ignoreProperties = {}); /// Destructor virtual ~JSONLDObjectDecoder(); diff --git a/src/shacl2code/lang/templates/cpp/jsonld-source.j2 b/src/shacl2code/lang/templates/cpp/jsonld-source.j2 index f852f07e..c6bdafb1 100644 --- a/src/shacl2code/lang/templates/cpp/jsonld-source.j2 +++ b/src/shacl2code/lang/templates/cpp/jsonld-source.j2 @@ -37,8 +37,9 @@ using json = nlohmann::json; using std::string_literals::operator""s; // EncoderState -JSONLDEncoderState::JSONLDEncoderState(std::ostream& output) - : EncoderState(), mOutput(output) {} +JSONLDEncoderState::JSONLDEncoderState(std::ostream& output, + SHACLObjectSet* objectSet) + : EncoderState(objectSet), mOutput(output) {} JSONLDEncoderState::~JSONLDEncoderState() {} void JSONLDEncoderState::writeComma() { @@ -86,23 +87,10 @@ void JSONLDValueEncoder::writeFloat(double value) { writeString(ss.str()); } -void JSONLDValueEncoder::writeObject( - SHACLObject const& o, std::string const& id, bool needs_id, +void JSONLDValueEncoder::writeDict( std::function const& f) { mState.writeComma(); mState.output() << "{"; - writeString("{{ context.compact_iri('@type') }}"); - mState.output() << ":"; - writeString(o.getCompactTypeIRI().value_or(o.getTypeIRI())); - mState.needsComma(); - - if (needs_id) { - mState.writeComma(); - writeString(o._id.getCompactIRI().value_or(o._id.getIRI())); - mState.output() << ":"; - writeString(id); - mState.needsComma(); - } { JSONLDObjectEncoder e(mState); @@ -112,6 +100,24 @@ void JSONLDValueEncoder::writeObject( mState.needsComma(); } +void JSONLDValueEncoder::writeObject( + SHACLObject const& o, std::string const& type, std::string const& id, + bool needs_id, std::function const& f) { + writeDict([&](ObjectEncoder& objEncoder) { + objEncoder.writeProperty( + "{{ context.compact_iri('@type') }}", {}, + [&](ValueEncoder& value) { value.writeString(type); }); + + if (needs_id) { + objEncoder.writeProperty( + o._id.getCompactIRI().value_or(o._id.getIRI()), {}, + [&](ValueEncoder& value) { value.writeString(id); }); + } + + f(objEncoder); + }); +} + void JSONLDValueEncoder::writeList(std::function const& f) { mState.writeComma(); mState.output() << "["; @@ -163,24 +169,44 @@ JSONLDInlineSerializer::~JSONLDInlineSerializer() {} void JSONLDInlineSerializer::write(std::ostream& output, SHACLObjectSet& objectSet, ErrorHandler& errorHandler) { - JSONLDEncoderState state(output); + JSONLDEncoderState state(output, &objectSet); output << "{"; JSONLDObjectEncoder top(state); - top.writeProperty("@context", {}, [&](ValueEncoder& value) { - if (objectSet.ContextURLs.size() > 1) { - value.writeList([&](ListEncoder& list) { - for (auto const& url : objectSet.ContextURLs) { - list.writeItem([&](ValueEncoder& list_value) { - list_value.writeString(url); - }); - } - }); - } else { - value.writeString(objectSet.ContextURLs[0]); - } - }); + int const numContext = + objectSet.ContextURLs.size() + (objectSet.context().empty() ? 0 : 1); + + auto writeContext = [&](ValueEncoder& v) { + v.writeDict([&](ObjectEncoder& d) { + for (auto const& it : objectSet.context()) { + d.writeProperty(it.first, {}, [&](ValueEncoder& val) { + val.writeString(it.second); + }); + } + }); + }; + + if (numContext) { + top.writeProperty("@context", {}, [&](ValueEncoder& value) { + if (numContext > 1) { + value.writeList([&](ListEncoder& list) { + for (auto const& url : objectSet.ContextURLs) { + list.writeItem([&](ValueEncoder& list_value) { + list_value.writeString(url); + }); + } + if (!objectSet.context().empty()) { + list.writeItem(writeContext); + } + }); + } else if (objectSet.ContextURLs.size()) { + value.writeString(objectSet.ContextURLs[0]); + } else { + writeContext(value); + } + }); + } top.writeProperty("@graph", {}, [&](ValueEncoder& value) { value.writeList( @@ -413,46 +439,51 @@ void JSONLDDeserializer::read(std::istream& input, SHACLObjectSet& objectSet, ObjectPath path; DecoderState state(&objectSet); - if (!objectSet.ContextURLs.empty()) { - if (!d.contains("@context")) { - errorHandler.handleDeserializeError( - "@context missing when it is required", path); - return; - } - std::vector contextURLs; - path.pushMember("@context", [&] { - if (d["@context"].is_array()) { - path.foreachItem(d["@context"], [&](auto const& u) { - if (!u.is_string()) { + std::vector contextURLs; + std::unordered_map contextMap; + + auto contextHandler = [&](auto const& u) { + if (u.is_string()) { + contextURLs.push_back(u); + } else if (u.is_object()) { + for (auto const& it : u.items()) { + path.pushMember(it.key(), [&] { + if (!it.value().is_string()) { errorHandler.handleDeserializeError( - "Context must be a string", path); + "Context value must be a string", path); return; } - contextURLs.push_back(u); + objectSet.addContext(it.key(), it.value()); }); - } else if (!d["@context"].is_string()) { - errorHandler.handleDeserializeError("Context must be a string", - path); - return; + } + } else { + errorHandler.handleDeserializeError("Context value must be a string", + path); + return; + } + }; + + if (d.contains("@context")) { + path.pushMember("@context", [&] { + if (d["@context"].is_array()) { + path.foreachItem(d["@context"], contextHandler); } else { - contextURLs.push_back(d["@context"]); + contextHandler(d["@context"]); } + }); + } - std::sort(contextURLs.begin(), contextURLs.end()); + std::sort(contextURLs.begin(), contextURLs.end()); - std::vector sortedObjectSetContextURLs( - objectSet.ContextURLs); + std::vector sortedObjectSetContextURLs(objectSet.ContextURLs); - std::sort(sortedObjectSetContextURLs.begin(), - sortedObjectSetContextURLs.end()); + std::sort(sortedObjectSetContextURLs.begin(), + sortedObjectSetContextURLs.end()); - if (contextURLs != sortedObjectSetContextURLs) { - errorHandler.handleDeserializeError("Invalid context URLs", - path); - return; - } - }); + if (contextURLs != sortedObjectSetContextURLs) { + errorHandler.handleDeserializeError("Invalid context URLs", path); + return; } if (!d.contains("@graph")) { diff --git a/src/shacl2code/lang/templates/cpp/object.cpp.j2 b/src/shacl2code/lang/templates/cpp/object.cpp.j2 index 8901227b..68156635 100644 --- a/src/shacl2code/lang/templates/cpp/object.cpp.j2 +++ b/src/shacl2code/lang/templates/cpp/object.cpp.j2 @@ -118,7 +118,7 @@ std::optional const& SHACLObject::getCompactTypeIRI() const { void SHACLObject::encode(ValueEncoder& encoder, ErrorHandler& errorHandler, ObjectPath& path) const { auto& state = encoder.getState(); - auto const objId = state.getObjectId(*this); + auto const objId = state.compactIRI(state.getObjectId(*this)); // TODO: This is recursive, which means it will over-check if (!validate(errorHandler, path)) { @@ -132,10 +132,12 @@ void SHACLObject::encode(ValueEncoder& encoder, ErrorHandler& errorHandler, state.addWritten(*this); - encoder.writeObject(*this, objId, _id.isSet() || state.objectIsRefed(*this), - [&](ObjectEncoder& objectEncoder) { - encodeProperties(objectEncoder, errorHandler, path); - }); + encoder.writeObject( + *this, getCompactTypeIRI().value_or(state.compactIRI(getTypeIRI())), + objId, _id.isSet() || state.objectIsRefed(*this), + [&](ObjectEncoder& objectEncoder) { + encodeProperties(objectEncoder, errorHandler, path); + }); } void SHACLObject::encodeProperties(ObjectEncoder& encoder, @@ -196,7 +198,7 @@ std::shared_ptr SHACLObject::decode(ObjectDecoder& decoder, return nullptr; } - auto const& obj_type_iri = t.value(); + auto const& obj_type_iri = decoder.getState().expandIRI(t.value()); std::shared_ptr obj = nullptr; diff --git a/src/shacl2code/lang/templates/cpp/objectset.cpp.j2 b/src/shacl2code/lang/templates/cpp/objectset.cpp.j2 index 0efcf2aa..423f896e 100644 --- a/src/shacl2code/lang/templates/cpp/objectset.cpp.j2 +++ b/src/shacl2code/lang/templates/cpp/objectset.cpp.j2 @@ -263,6 +263,41 @@ bool SHACLObjectSet::checkAll(ErrorHandler& errorHandler) const { return ret; } +void SHACLObjectSet::addContext(std::string const& prefix, + std::string const& expanded) { + mContext[prefix] = expanded; +} + +std::string SHACLObjectSet::expandIRI(std::string const& iri) const { + for (auto const& [prefix, expanded] : mContext) { + if (iri == prefix) { + return expandIRI(expanded); + } + + auto p = prefix + ":"; + if (iri.rfind(p, 0) == 0) { + return expandIRI(expanded + iri.substr(p.length())); + } + } + return iri; +} + +std::string SHACLObjectSet::compactIRI(std::string const& iri) const { + for (auto const& [prefix, expanded] : mContext) { + if (iri == expanded) { + return compactIRI(prefix); + } + if (iri.rfind(expanded, 0) == 0) { + return compactIRI(prefix + ":" + iri.substr(expanded.length())); + } + } + return iri; +} + +SHACLObjectSet::Context const& SHACLObjectSet::context() const { + return mContext; +} + void SHACLObjectSet::addIndex(SHACLObjectSet::ptr const& obj) { if (!obj->_id.isSet() || obj->_id.get().empty()) { return; diff --git a/src/shacl2code/lang/templates/cpp/objectset.hpp.j2 b/src/shacl2code/lang/templates/cpp/objectset.hpp.j2 index 421eba2c..3e814778 100644 --- a/src/shacl2code/lang/templates/cpp/objectset.hpp.j2 +++ b/src/shacl2code/lang/templates/cpp/objectset.hpp.j2 @@ -42,6 +42,9 @@ class EXPORT SHACLObjectSet { /// Alias for the pointer type using ptr = std::shared_ptr; + /// Alias for the context type + using Context = std::unordered_map; + /// Constructor SHACLObjectSet(); @@ -185,6 +188,45 @@ class EXPORT SHACLObjectSet { */ bool checkAll(ErrorHandler& errorHandler) const; + /** + * @brief Add context + * + * Adds a new context entry to the context for this ObjectSet + * + * @param prefix The context prefix to add + * @param expanded The expanded value that maps to the prefix + */ + void addContext(std::string const& prefix, std::string const& expanded); + + /** + * @brief Expand IRI + * + * Expands an IRI using the context of the ObjectSet + * + * @param iri The IRI to expand + * + * @returns the expanded form of the IRI + */ + std::string expandIRI(std::string const& iri) const; + + /** + * @brief Compact IRI + * + * Compacts an IRI using the context of the ObjectSet + * + * @param iri The IRI to compact + * + * @returns the compacted form of the IRI + */ + std::string compactIRI(std::string const& iri) const; + + /** + * @brief Get context + * + * @returns the context of the ObjectSet + */ + Context const& context() const; + /** * @brief Context URLs * @@ -208,6 +250,7 @@ class EXPORT SHACLObjectSet { std::unordered_map> mObjByType; std::unordered_map mObjById; std::set mMissingIds; + Context mContext; /** * @brief Internal Link diff --git a/src/shacl2code/lang/templates/cpp/property.cpp.j2 b/src/shacl2code/lang/templates/cpp/property.cpp.j2 index e5cfac5f..de48c4a5 100644 --- a/src/shacl2code/lang/templates/cpp/property.cpp.j2 +++ b/src/shacl2code/lang/templates/cpp/property.cpp.j2 @@ -40,7 +40,7 @@ PropertyContext::PropertyContext(PropertyContext::Context&& context) PropertyContext::~PropertyContext() {} std::optional PropertyContext::compactValue( - std::string const& value) const { + std::string const& value, std::optional const& dflt) const { for (auto const& v : mContext) { if (v.first == value) { return v.second; @@ -51,11 +51,11 @@ std::optional PropertyContext::compactValue( return named->getCompactIRI(); } - return {}; + return dflt; } std::optional PropertyContext::expandValue( - std::string const& value) const { + std::string const& value, std::optional const& dflt) const { for (auto const& v : mContext) { if (v.second == value) { return v.first; @@ -67,7 +67,7 @@ std::optional PropertyContext::expandValue( return named->getIRI(); } - return {}; + return dflt; } // RegexProperty diff --git a/src/shacl2code/lang/templates/cpp/property.hpp.j2 b/src/shacl2code/lang/templates/cpp/property.hpp.j2 index d2fe1e38..f5d83b3e 100644 --- a/src/shacl2code/lang/templates/cpp/property.hpp.j2 +++ b/src/shacl2code/lang/templates/cpp/property.hpp.j2 @@ -48,23 +48,29 @@ class EXPORT PropertyContext { * @brief Compact value * * @param[in] value The fully qualified IRI to compact + * @param[in] dflt The value to return if no compaction occurs * * @returns The compacted string for @p value based on the context. If * there is no compacted version of @p value, the optional will be empty */ - std::optional compactValue(std::string const& value) const; + std::optional compactValue( + std::string const& value, + std::optional const& dflt = {}) const; /** * @brief Expand value * * @param[in] value The compacted value to expand to a fully qualified * IRI + * @param[in] dflt The value to return if no expansion occurs * * @returns The fully qualified IRI value for the compact string @p * value based on the context. If there is no expanded version of @p * value, the optional will be empty */ - std::optional expandValue(std::string const& value) const; + std::optional expandValue( + std::string const& value, + std::optional const& dflt = {}) const; private: std::vector> mContext; diff --git a/src/shacl2code/lang/templates/cpp/refproperty.hpp.j2 b/src/shacl2code/lang/templates/cpp/refproperty.hpp.j2 index 705ffd80..da06a32b 100644 --- a/src/shacl2code/lang/templates/cpp/refproperty.hpp.j2 +++ b/src/shacl2code/lang/templates/cpp/refproperty.hpp.j2 @@ -117,7 +117,10 @@ class RefProperty : public Property>, private RefPropertyHelper { if (value.isObj()) { value.obj()->encode(encoder, errorHandler, path); } else { - encoder.writeIRI(value.iri(), this->compactValue(value.iri())); + encoder.writeIRI( + value.iri(), + this->compactValue(value.iri(), + encoder.getState().compactIRI(value.iri()))); } } @@ -127,7 +130,9 @@ class RefProperty : public Property>, private RefPropertyHelper { ObjectPath& path) const override { auto iri = decoder.readIRI(); if (iri) { - return Ref(this->expandValue(iri.value()).value_or(iri.value())); + return Ref( + this->expandValue(iri.value()) + .value_or(decoder.getState().expandIRI(iri.value()))); } std::shared_ptr obj = nullptr; diff --git a/tests/test_cpp.py b/tests/test_cpp.py index f9a8737c..818ca57b 100644 --- a/tests/test_cpp.py +++ b/tests/test_cpp.py @@ -1326,7 +1326,7 @@ def test_roundtrip(compile_test, tmp_path, roundtrip): with out_file.open("r") as f: actual = json.load(f) - assert expect == actual + assert actual == expect def test_static(compile_test, tmp_path, roundtrip): @@ -1446,3 +1446,17 @@ def test_json_types(passes, data, tmp_path, test_lib, test_context_url): assert p.returncode == 0, "Validation failed when a pass was expected" else: assert p.returncode != 0, "Validation passed when failure was expected" + + +@jsonvalidation.context_tests() +def test_objset_context(compile_test, context, expanded, compacted): + program = ["SHACLObjectSet objset;"] + for k, v in context.items(): + program.append(f'objset.addContext("{k}", "{v}");') + + program.append(f'std::cout << objset.compactIRI("{expanded}") << std::endl;') + program.append(f'std::cout << objset.expandIRI("{compacted}") << std::endl;') + + output = compile_test("\n".join(program)) + + assert output.splitlines() == [compacted, expanded] From f0dc6272d374cd02da16e6ee59378e3a6157d5b4 Mon Sep 17 00:00:00 2001 From: Joshua Watt Date: Sun, 8 Feb 2026 12:26:43 -0700 Subject: [PATCH 06/11] golang: Add custom context support Adds support for documents to support additional local context with a dictionary of key/value pairs in the "@context". This allows users to shorten the IRIs in their documents. --- .../lang/templates/golang/shaclobject.go.j2 | 12 +- .../templates/golang/shaclobjectset.go.j2 | 137 ++++++++++++++++-- .../lang/templates/golang/shacltype.go.j2 | 16 ++ tests/test_golang.py | 16 ++ 4 files changed, 159 insertions(+), 22 deletions(-) diff --git a/src/shacl2code/lang/templates/golang/shaclobject.go.j2 b/src/shacl2code/lang/templates/golang/shaclobject.go.j2 index b9df4eb1..bad607e4 100644 --- a/src/shacl2code/lang/templates/golang/shaclobject.go.j2 +++ b/src/shacl2code/lang/templates/golang/shaclobject.go.j2 @@ -61,19 +61,15 @@ func (self *SHACLObjectBase) Walk(path Path, visit Visit) { func (self *SHACLObjectBase) EncodeProperties(data map[string]interface{}, path Path, state *EncodeState) error { if self.typeIRI != "" { - data["{{ context.compact_iri('@type') }}"] = self.typeIRI + data["{{ context.compact_iri('@type') }}"] = state.CompactIRI(self.typeIRI) } else { - data["{{ context.compact_iri('@type') }}"] = self.typ.GetCompactTypeIRI().GetDefault(self.typ.GetTypeIRI()) + data["{{ context.compact_iri('@type') }}"] = self.typ.GetCompactTypeIRI().GetDefault(state.CompactIRI(self.typ.GetTypeIRI())) } id_prop := self.typ.GetIDAlias().GetDefault("@id") if self.id.IsSet() { - val, err := EncodeIRI(self.id.Get(), path.PushPath(id_prop), map[string]string{}, state) - if err != nil { - return err - } - data[id_prop] = val + data[id_prop] = state.CompactIRI(self.id.Get()) } return nil @@ -111,7 +107,7 @@ func EncodeSHACLObject(o SHACLObject, path Path, state *EncodeState) (any, error if state != nil { if state.Written[o] { if o.ID().IsSet() { - return o.ID().Get(), nil + return state.CompactIRI(o.ID().Get()), nil } return nil, &EncodeError{ diff --git a/src/shacl2code/lang/templates/golang/shaclobjectset.go.j2 b/src/shacl2code/lang/templates/golang/shaclobjectset.go.j2 index b10683fa..0a1c3ba6 100644 --- a/src/shacl2code/lang/templates/golang/shaclobjectset.go.j2 +++ b/src/shacl2code/lang/templates/golang/shaclobjectset.go.j2 @@ -11,7 +11,9 @@ package {{ package }} import ( "encoding/json" "fmt" + "reflect" "sort" + "strings" ) type SHACLObjectSet interface { @@ -23,11 +25,16 @@ type SHACLObjectSet interface { Validate(handler ErrorHandler) bool Link() (map[string]bool, error) GetMissingIDs() map[string]bool + CompactIRI(string) string + ExpandIRI(string) string + GetContext() map[string]string + AddContext(string, string) } type SHACLObjectSetObject struct { objects []SHACLObject missingIDs map[string]bool + context map[string]string } @@ -82,6 +89,18 @@ func (self *SHACLObjectSetObject) AddObject(r SHACLObject) { self.objects = append(self.objects, r) } +func (self *SHACLObjectSetObject) decodeContext(d map[string]interface{}, path Path) error { + for key, val := range d { + s, ok := val.(string) + if ! ok { + return &DecodeError{path, "Non-string value in context"} + } + self.AddContext(key, s) + } + + return nil +} + func (self *SHACLObjectSetObject) Decode(decoder *json.Decoder) error { path := Path{} @@ -91,19 +110,60 @@ func (self *SHACLObjectSetObject) Decode(decoder *json.Decoder) error { return err } + context_urls := []string{} + { + sub_path := path.PushPath("@context") v, ok := data["@context"] - if ! ok { - return &DecodeError{path, "@context missing"} + if ok { + value, ok := v.(string) + if ok { + context_urls = append(context_urls, value) + } else { + value, ok := v.([]any) + if ok { + for _, ctx := range value { + s, ok := ctx.(string) + if ok { + context_urls = append(context_urls, s) + continue + } + c, ok := ctx.(map[string]interface{}) + if ok { + err := self.decodeContext(c, sub_path) + if err != nil { + return err + } + continue + } + + return &DecodeError{sub_path, "Bad context"} + } + } else { + value, ok := v.(map[string]interface{}) + if ok { + err := self.decodeContext(value, sub_path) + if err != nil { + return err + } + } else { + return &DecodeError{sub_path, "Bad type for context"} + } + } + } } - sub_path := path.PushPath("@context") - value, ok := v.(string) - if ! ok { - return &DecodeError{sub_path, "@context must be a string, or list of string"} + // Check context strings. Using maps.Equal() would be much better, but requires go >= 1.21 + expected_context_urls := []string{ + {%- for url in context.urls -%} + "{{ url }}", + {%- endfor %} } - if value != "{{ context.urls[0] }}" { - return &DecodeError{sub_path, "Wrong context URL '" + value + "'"} + sort.Sort(sort.StringSlice(expected_context_urls)) + sort.Sort(sort.StringSlice(context_urls)) + + if ! reflect.DeepEqual(context_urls, expected_context_urls) { + return &DecodeError{sub_path, "Wrong context URL(s)"} } } @@ -155,18 +215,33 @@ func (self *SHACLObjectSetObject) Decode(decoder *json.Decoder) error { func (self *SHACLObjectSetObject) Encode(encoder *json.Encoder) error { data := make(map[string]interface{}) -{%- if context.urls | length > 1 %} - data["@context"] = []string{ - {%- for url in context.urls %} - "{{ url }}", - {%- endfor %} + num_context := {{ context.urls | length }} + if len(self.context) > 0 { + num_context += 1 } + + if num_context == 1 { +{%- if context.urls | length %} + data["@context"] = "{{ context.urls[0] }}" {%- else %} - data["@context"] = "{{ context.urls[0] }}" + data["@context"] = self.context {%- endif %} + } else if num_context > 1 { + ctx := []interface{} { + {%- for url in context.urls %} + "{{ url }}", + {%- endfor %} + } + if len(self.context) > 0 { + ctx = append(ctx, self.context) + } + data["@context"] = ctx + } + path := Path{} state := EncodeState{ Written: make(map[SHACLObject]bool), + objectSet: self, } ref_counts := make(map[SHACLObject]int) @@ -360,6 +435,40 @@ func (self *SHACLObjectSetObject) GetMissingIDs() map[string]bool { return self.missingIDs } +func (self *SHACLObjectSetObject) CompactIRI(iri string) string { + for prefix, expanded := range self.context { + if iri == expanded { + return self.CompactIRI(prefix) + } + if strings.HasPrefix(iri, expanded) { + return self.CompactIRI(prefix + ":" + iri[len(expanded):]) + } + } + return iri +} +func (self *SHACLObjectSetObject) ExpandIRI(iri string) string { + for prefix, expanded := range self.context { + if iri == prefix { + return self.ExpandIRI(expanded) + } + if strings.HasPrefix(iri, prefix + ":") { + return self.ExpandIRI(expanded + iri[len(prefix)+1:]) + } + } + return iri +} + +func (self *SHACLObjectSetObject) GetContext() map[string]string { + return self.context +} + +func (self *SHACLObjectSetObject) AddContext(prefix string, expanded string) { + if self.context == nil { + self.context = map[string]string{} + } + self.context[prefix] = expanded +} + func NewSHACLObjectSet() SHACLObjectSet { os := SHACLObjectSetObject{} return &os diff --git a/src/shacl2code/lang/templates/golang/shacltype.go.j2 b/src/shacl2code/lang/templates/golang/shacltype.go.j2 index afe5d43d..d2f9c7a1 100644 --- a/src/shacl2code/lang/templates/golang/shacltype.go.j2 +++ b/src/shacl2code/lang/templates/golang/shacltype.go.j2 @@ -107,6 +107,22 @@ func (self SHACLTypeBase) IsSubClassOf(other SHACLType) bool { type EncodeState struct { Written map[SHACLObject]bool + objectSet SHACLObjectSet +} + +func (self EncodeState) ExpandIRI(iri string) string { + if self.objectSet == nil { + return iri; + } + + return self.objectSet.ExpandIRI(iri); +} + +func (self EncodeState) CompactIRI(iri string) string { + if self.objectSet == nil { + return iri; + } + return self.objectSet.CompactIRI(iri); } func (self SHACLTypeBase) DecodeProperty(o SHACLObject, name string, value interface{}, path Path) (bool, error) { diff --git a/tests/test_golang.py b/tests/test_golang.py index 7cc36306..16c8ccfd 100644 --- a/tests/test_golang.py +++ b/tests/test_golang.py @@ -98,6 +98,7 @@ def f(code_fragment, *, progress=Progress.RUNS, static=False, imports=[]): """) + textwrap.dedent(code_fragment) + textwrap.dedent("""\ + return nil } @@ -1147,3 +1148,18 @@ def test_links(filename, name, expect_tag, tmp_path, test_context_url, link_test ) link_test(data_file, name, expect_tag, check=True) + + +@jsonvalidation.context_tests() +def test_objset_context(compile_test, context, expanded, compacted): + program = ["objset := model.NewSHACLObjectSet()"] + + for k, v in context.items(): + program.append(f'objset.AddContext("{k}", "{v}")') + + program.append(f'fmt.Println(objset.CompactIRI("{expanded}"))') + program.append(f'fmt.Println(objset.ExpandIRI("{compacted}"))') + + output = compile_test("\n".join(program)) + + assert output.splitlines() == [compacted, expanded] From f5885c5ef12a71958245389e7fd8660901767636 Mon Sep 17 00:00:00 2001 From: Joshua Watt Date: Fri, 6 Feb 2026 15:01:56 -0700 Subject: [PATCH 07/11] tests: Update roundtrip with custom context Updates the roundtrip file with a custom context to exercise that code path in the language bindings --- tests/data/roundtrip.json | 43 ++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/tests/data/roundtrip.json b/tests/data/roundtrip.json index 13929e65..953fd768 100644 --- a/tests/data/roundtrip.json +++ b/tests/data/roundtrip.json @@ -1,32 +1,37 @@ { - "@context": "@CONTEXT_URL@", + "@context": [ + "@CONTEXT_URL@", + { + "prefix": "http://serialize.example.com/" + } + ], "@graph": [ { "@type": "link-class", - "link-class-link-prop": "http://serialize.example.com/link-derived-target", - "link-class-link-prop-no-class": "http://serialize.example.com/link-derived-target", + "link-class-link-prop": "prefix:link-derived-target", + "link-class-link-prop-no-class": "prefix:link-derived-target", "link-class-link-list-prop": [ - "http://serialize.example.com/link-derived-target" + "prefix:link-derived-target" ] }, { "@type": "concrete-class", - "@id": "http://serialize.example.com/concrete-class" + "@id": "prefix:concrete-class" }, { - "@id": "http://serialize.example.com/link-derived-target", + "@id": "prefix:link-derived-target", "@type": "link-derived-class" }, { "@type": "test-class", - "@id": "http://serialize.example.com/nested-parent", + "@id": "prefix:nested-parent", "test-class/class-prop": { "@type": "test-class" } }, { "@type": "test-class-required", - "@id": "http://serialize.example.com/required", + "@id": "prefix:required", "test-class/required-string-scalar-prop": "hello", "test-class/required-string-list-prop": [ "hello", @@ -35,7 +40,7 @@ }, { "@type": "test-class", - "@id": "http://serialize.example.com/test", + "@id": "prefix:test", "test-class/string-list-prop": [ "hello", "world" @@ -55,11 +60,11 @@ "test-class/integer-prop": -1, "test-class/boolean-prop": true, "test-class/float-prop": "0.1", - "test-class/class-prop": "http://serialize.example.com/test-derived", - "test-class/class-prop-no-class": "http://serialize.example.com/test-derived", + "test-class/class-prop": "prefix:test-derived", + "test-class/class-prop-no-class": "prefix:test-derived", "test-class/class-list-prop": [ - "http://serialize.example.com/test", - "http://serialize.example.com/test-derived" + "prefix:test", + "prefix:test-derived" ], "test-class/enum-prop": "foo", "test-class/enum-prop-no-class": "bar", @@ -67,30 +72,30 @@ "test-class/regex-datetime": "2024-03-11T00:00:00+01:00", "test-class/regex-datetimestamp": "2024-03-11T00:00:00Z", "test-class/regex-list": ["foo2", "foo3"], - "test-class/non-shape": "http://serialize.example.com/non-shape", + "test-class/non-shape": "prefix:non-shape", "import": "foo", "encode": "foo", "test-class/split-string-prop": "this is split" }, { - "@id": "http://serialize.example.com/test-derived", + "@id": "prefix:test-derived", "@type": "test-derived-class" }, { "@type": "test-class", - "@id": "http://serialize.example.com/test-named-individual-reference", + "@id": "prefix:test-named-individual-reference", "test-class/class-prop": "named" }, { "@type": "test-class", - "@id": "http://serialize.example.com/test-special-chars", + "@id": "prefix:test-special-chars", "test-class/string-scalar-prop": "special chars \"\n\r:{}[]" }, { "@type": "uses-extensible-abstract-class", - "@id": "http://serialize.example.com/test-uses-extensible-abstract", + "@id": "prefix:test-uses-extensible-abstract", "uses-extensible-abstract-class/prop": { - "@type": "http://serialize.example.com/custom-extensible", + "@type": "prefix:custom-extensible", "http://custom-list-prop": [ "abc", "def" From 496d0c29ab474d9e45ea35c221b65a743ad5cbea Mon Sep 17 00:00:00 2001 From: Joshua Watt Date: Tue, 10 Feb 2026 08:51:28 -0700 Subject: [PATCH 08/11] testfixtures: Add custom context validation Adds validation of custom contexts --- testfixtures/testfixtures/jsonvalidation.py | 80 ++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/testfixtures/testfixtures/jsonvalidation.py b/testfixtures/testfixtures/jsonvalidation.py index 3bb74667..999fac82 100644 --- a/testfixtures/testfixtures/jsonvalidation.py +++ b/testfixtures/testfixtures/jsonvalidation.py @@ -16,11 +16,20 @@ def replace_context(d, url): - for k, v in d.items(): + def replace(d, k, v): if v is CONTEXT: d[k] = url elif isinstance(v, dict): replace_context(v, url) + elif isinstance(v, list): + replace_context(v, url) + + if isinstance(d, dict): + for k, v in d.items(): + replace(d, k, v) + elif isinstance(d, list): + for idx, v in enumerate(d): + replace(d, idx, v) BASE_OBJ = { @@ -121,6 +130,75 @@ def node_kind_tests(name, blank, iri): }, id="Bad context", ), + param( + True, + { + "@context": [ + CONTEXT, + { + "foo": "http://example.com/", + "bar": "http://bar.com/", + }, + ], + "@graph": [], + }, + id="Extended context", + ), + param( + True, + { + "@context": [ + { + "foo": "http://example.com/", + "bar": "http://bar.com/", + }, + CONTEXT, + ], + "@graph": [], + }, + id="Extended context (alternate order)", + ), + param( + True, + { + "@context": [ + CONTEXT, + { + "foo": "http://example.com/", + }, + { + "bar": "http://bar.com/", + }, + ], + "@graph": [], + }, + id="Multiple extended context", + ), + param( + False, + { + "@context": [ + CONTEXT, + "http://foo.com", + { + "foo": "http://example.com/", + }, + { + "bar": "http://bar.com/", + }, + ], + "@graph": [], + }, + id="Multiple context with bad URL", + ), + param( + True, + { + "@context": [CONTEXT], + "@graph": [], + }, + id="Single array context", + ), param( False, { From 29e9788cbca7a1821ce005f0b5d0f7ca1c45c6a2 Mon Sep 17 00:00:00 2001 From: Joshua Watt Date: Tue, 10 Feb 2026 10:32:07 -0700 Subject: [PATCH 09/11] gha: Bump black version Bumps the version of black to the latest release --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b15a68d1..88777308 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -57,5 +57,5 @@ jobs: uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 with: python-version: "3.11" - - uses: psf/black@8a737e727ac5ab2f1d4cf5876720ed276dc8dc4b # 25.1.0 + - uses: psf/black@6305bf1ae645ab7541be4f5028a86239316178eb # 26.1.0 - uses: py-actions/flake8@84ec6726560b6d5bd68f2a5bed83d62b52bb50ba # v2.3.0 From 388cb927da3d7b087da7b9660ea58ed87a2dc479 Mon Sep 17 00:00:00 2001 From: Joshua Watt Date: Wed, 11 Feb 2026 16:51:01 -0700 Subject: [PATCH 10/11] tests: python: Add model URL fixture Adds a fixture for the model URL and also cleans up the confusing name of some of the other fixtures --- tests/test_python.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/test_python.py b/tests/test_python.py index 88c309ff..b86a1f85 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -51,7 +51,12 @@ def shacl2code_generate(args, outfile): @pytest.fixture(scope="module") -def test_context(tmp_path_factory, model_server): +def model_context_url(model_server): + yield model_server + "/test-context.json" + + +@pytest.fixture(scope="module") +def python_model(tmp_path_factory, model_context_url): tmp_directory = tmp_path_factory.mktemp("pythontestcontext") outfile = tmp_directory / "model.py" shacl2code_generate( @@ -59,7 +64,7 @@ def test_context(tmp_path_factory, model_server): "--input", TEST_MODEL, "--context", - model_server + "/test-context.json", + model_context_url, ], outfile, ) @@ -68,14 +73,14 @@ def test_context(tmp_path_factory, model_server): @pytest.fixture(scope="module") -def test_script(test_context): - _, script = test_context +def model_script(python_model): + _, script = python_model yield script @pytest.fixture(scope="function") -def model(test_context): - tmp_directory, _ = test_context +def model(python_model): + tmp_directory, _ = python_model old_path = sys.path[:] sys.path.append(str(tmp_directory)) @@ -220,11 +225,11 @@ def test_roundtrip(model, tmp_path, roundtrip): check_file(outfile, expect_data, digest) -def test_script_roundtrip(test_script, tmp_path, roundtrip): +def test_script_roundtrip(model_script, tmp_path, roundtrip): outpath = tmp_path / "out.json" subprocess.run( - [test_script, roundtrip, "--outfile", outpath], + [model_script, roundtrip, "--outfile", outpath], check=True, ) From bae42c8fbd4222064c212c6977ae4571548c208f Mon Sep 17 00:00:00 2001 From: Joshua Watt Date: Wed, 11 Feb 2026 10:35:07 -0700 Subject: [PATCH 11/11] python: Fix context expansion for extensible object keys Fixes the case where derived extensible objects having custom properties would not have the context applied to their property keys --- src/shacl2code/lang/templates/python.j2 | 24 ++++++------ tests/test_python.py | 49 +++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/shacl2code/lang/templates/python.j2 b/src/shacl2code/lang/templates/python.j2 index 4e916288..1b19fbe0 100644 --- a/src/shacl2code/lang/templates/python.j2 +++ b/src/shacl2code/lang/templates/python.j2 @@ -881,7 +881,9 @@ class SHACLObject(object): if iri == self._OBJ_IRIS["_id"]: continue - with encoder.write_property(iri, compact) as prop_s: + with encoder.write_property( + state.objectset.compact_iri(iri), compact + ) as prop_s: prop.encode(prop_s, value, state) @classmethod @@ -926,15 +928,13 @@ class SHACLObject(object): if key in (self._OBJ_IRIS["_id"], self.ID_ALIAS): return True + expanded_key = state.objectset.expand_iri(key) + for iri, prop, _, _, _, compact in self.__iter_props(): - if compact == key: - read_key = compact - elif iri == key: - read_key = iri - else: + if key not in (compact, iri) and not expanded_key == iri: continue - with decoder.read_property(read_key) as prop_d: + with decoder.read_property(key) as prop_d: v = prop.decode(prop_d, state) prop.validate(v) self.__dict__["_obj_data"][iri] = v @@ -1030,13 +1030,15 @@ class SHACLExtensibleObject(SHACLObject): if self._decode_prop(decoder, key, state): continue - if not is_IRI(key): + expanded_key = state.objectset.expand_iri(key) + + if not is_IRI(expanded_key): raise KeyError( - f"Extensible object properties must be IRIs. Got '{key}'" + f"Extensible object properties must be IRIs. Got '{key}' (expanded to '{expanded_key}')" ) with decoder.read_property(key) as prop_d: - self.__dict__["_obj_data"][key] = decode_value(prop_d) + self.__dict__["_obj_data"][expanded_key] = decode_value(prop_d) def _encode_properties(self, encoder, state) -> None: super()._encode_properties(encoder, state) @@ -1047,7 +1049,7 @@ class SHACLExtensibleObject(SHACLObject): if iri in self._OBJ_PROPERTIES: continue - with encoder.write_property(iri) as prop_s: + with encoder.write_property(state.objectset.compact_iri(iri)) as prop_s: if isinstance(value, list): v = value else: diff --git a/tests/test_python.py b/tests/test_python.py index b86a1f85..c1872df9 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -1686,3 +1686,52 @@ def test_objset_context(model, context, expanded, compacted): assert objset.compact_iri(expanded) == compacted assert expanded == objset.expand_iri(compacted) + + +def test_extensible_properties(model, model_context_url): + + @model.register("http://example.org/extension") + class Extension(model.extensible_class): + @classmethod + def _register_props(cls): + super()._register_props() + cls._add_property( + "string_prop", + model.StringProp(), + "http://example.org/string-prop", + min_count=1, + ) + + DATA = { + "@context": [ + model_context_url, + { + "prefix": "http://example.org/", + }, + ], + "@graph": [ + { + "@type": "prefix:extension", + "@id": "prefix:e", + "prefix:string-prop": "foo", + "extensible-class/required": "required", + "prefix:extra-data": ["bar"], + } + ], + } + + objset = model.SHACLObjectSet() + d = model.JSONLDDeserializer() + d.deserialize_data(DATA, objset) + + e = objset.find_by_id("http://example.org/e") + assert e + assert isinstance(e, Extension) + assert e.string_prop == "foo" + assert e["http://example.org/string-prop"] == "foo" + assert e._id == "http://example.org/e" + assert e["http://example.org/extra-data"] == ["bar"] + + # Ensure the context is preserved + s = model.JSONLDSerializer() + assert s.serialize_data(objset, True) == DATA