From 45eb5033193fdc0e0e435bdbbc935f5da6493462 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Wed, 16 Sep 2026 15:35:02 +0300 Subject: [PATCH 01/16] feat: enforce x-gts-ref existence uniformly for gts-spec v0.14.0 gts-spec v0.14.0 requires that an x-gts-ref value always resolve to a registered entity, uniformly across all constraint forms (including the bare "gts.*" wildcard and unregistered constraint types). Trait validation previously only checked existence when the constraint type itself was registered, letting "gts.*" and missing constraint types pass. - Always enforce referenced-entity existence when a store is available. - Replace the misleadingly-named require_registered_target flag with enforce_existence (default True), which honestly gates the check. - Bump supported spec version to 0.14.0 across package metadata, READMEs, OpenAPI, and the server title. Signed-off-by: Artifizer --- README.md | 2 +- gts/README.md | 2 +- gts/openapi.json | 2 +- gts/pyproject.toml | 2 +- gts/src/gts/_server.py | 2 +- gts/src/gts/traits.py | 2 +- gts/src/gts/x_gts_ref.py | 22 +++++++++++++--------- 7 files changed, 19 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3af6258..a356620 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A minimal, idiomatic Python library for working with **GTS** ([Global Type System](https://github.com/gts-spec/gts-spec)) identifiers and JSON/JSON Schema artifacts. -Current supported GTS spec version: `0.13.4` +Current supported GTS spec version: `0.14.0` ## Roadmap diff --git a/gts/README.md b/gts/README.md index 739fb10..43f66ff 100644 --- a/gts/README.md +++ b/gts/README.md @@ -2,7 +2,7 @@ Python helpers and a reference HTTP service for the [Global Type System (GTS)](https://github.com/globaltypesystem/gts-spec). The package supports GTS identifier parsing, JSON Schema-backed validation, schema compatibility and derivation checks, traits, casting, queries, file loading, a CLI, and a FastAPI application. -The package targets GTS specification v0.13.4 and requires Python 3.9 or later. +The package targets GTS specification v0.14.0 and requires Python 3.9 or later. ## Installation diff --git a/gts/openapi.json b/gts/openapi.json index 3e9b962..e4a2b05 100644 --- a/gts/openapi.json +++ b/gts/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "GTS Server", - "version": "0.13.4" + "version": "0.14.0" }, "paths": { "/entities": { diff --git a/gts/pyproject.toml b/gts/pyproject.toml index 2405c35..2d5a04a 100644 --- a/gts/pyproject.toml +++ b/gts/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "gts" -version = "0.13.4" +version = "0.14.0" description = "Global Type System (GTS) helpers: identifiers, parsing, validation, and operations" readme = "README.md" authors = [{ name = "GTS Community" }] diff --git a/gts/src/gts/_server.py b/gts/src/gts/_server.py index f5135ae..b55d31f 100644 --- a/gts/src/gts/_server.py +++ b/gts/src/gts/_server.py @@ -188,7 +188,7 @@ def __init__( self.host = host self.port = port self.base_url = f"http://{self.host}:{self.port}" - self.app = FastAPI(title="GTS Server", version="0.13.4") + self.app = FastAPI(title="GTS Server", version="0.14.0") self.app.add_middleware( _RequestLoggingMiddleware, verbose=self.ops.verbose, diff --git a/gts/src/gts/traits.py b/gts/src/gts/traits.py index 7d7f954..9c4183f 100644 --- a/gts/src/gts/traits.py +++ b/gts/src/gts/traits.py @@ -386,7 +386,7 @@ def _validate_trait_values( errors = _validate_traits_against_schema( effective_traits_schema, effective_traits, check_unresolved ) - xref = XGtsRefValidator(store=reference_store, require_registered_target=True) + xref = XGtsRefValidator(store=reference_store) for err in xref.validate_instance(effective_traits, effective_traits_schema, ""): errors.append(f"trait x-gts-ref: {err.reason}") return errors diff --git a/gts/src/gts/x_gts_ref.py b/gts/src/gts/x_gts_ref.py index f410273..70dd04f 100644 --- a/gts/src/gts/x_gts_ref.py +++ b/gts/src/gts/x_gts_ref.py @@ -77,17 +77,20 @@ def __init__(self, field_path: str, value: Any, ref_pattern: str, reason: str): class XGtsRefValidator: """Validator for x-gts-ref constraints in GTS schemas.""" - def __init__( - self, store: Any | None = None, require_registered_target: bool = False - ): + def __init__(self, store: Any | None = None, enforce_existence: bool = True): """ Initialize validator. Args: - store: Optional GtsStore for resolving entity references + store: Optional GtsStore for resolving entity references. + enforce_existence: When True (default) and a ``store`` is provided, + an x-gts-ref value must resolve to a registered entity or + validation fails. Set to False to validate only that the value + is a well-formed GTS id matching the constraint pattern, without + requiring the referenced entity to exist in the registry. """ self.store = store - self.require_registered_target = require_registered_target + self.enforce_existence = enforce_existence def validate_instance( self, instance: dict[str, Any], schema: dict[str, Any], instance_path: str = "" @@ -433,10 +436,11 @@ def _validate_gts_pattern( f"Value '{value}' does not match pattern '{pattern}'", ) - # Optionally check if entity exists in store - if self.store and ( - not self.require_registered_target or self.store.get(pattern) - ): + # Referenced value must resolve to a registered entity when a store is + # available and existence enforcement is enabled. Existence is enforced + # uniformly for all constraint forms (including the bare "gts.*" + # wildcard). + if self.store and self.enforce_existence: entity = self.store.get(value) if not entity: return XGtsRefValidationError( From 751eefffdf050d9c634d9407eb73247631962103 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Wed, 16 Sep 2026 22:04:39 +0300 Subject: [PATCH 02/16] fix: validate trait reference constraint types Signed-off-by: Artifizer --- gts/src/gts/traits.py | 28 ++++++++++---------------- gts/src/gts/x_gts_ref.py | 43 ++++++++++++++++++++++++++++++++++++++++ tests/test_traits.py | 24 ++++++++++++++++++++++ tests/test_x_gts_ref.py | 32 ++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 18 deletions(-) diff --git a/gts/src/gts/traits.py b/gts/src/gts/traits.py index 9c4183f..77cad54 100644 --- a/gts/src/gts/traits.py +++ b/gts/src/gts/traits.py @@ -324,28 +324,14 @@ def _validate_trait_schema_compatibility( return errors -def _strip_required(schema: Any, depth: int = 0) -> Any: - if depth >= MAX_RECURSION_DEPTH or not isinstance(schema, dict): - return schema - out = dict(schema) - out.pop("required", None) - all_of = out.get("allOf") - if isinstance(all_of, list): - out["allOf"] = [_strip_required(i, depth + 1) for i in all_of] - return out - - def _validate_traits_against_schema( trait_schema: Any, effective_traits: Any, check_unresolved: bool ) -> list[str]: errors: list[str] = [] - validation_schema = ( - trait_schema if check_unresolved else _strip_required(trait_schema) - ) try: - cls = validator_for(validation_schema) - validator = cls(validation_schema, format_checker=_FORMAT_CHECKER) + cls = validator_for(trait_schema) + validator = cls(trait_schema, format_checker=_FORMAT_CHECKER) for error in validator.iter_errors(effective_traits): errors.append(f"trait validation: {error.message}") except Exception as e: # noqa: BLE001 - surfaced as validation error message @@ -383,10 +369,16 @@ def _validate_trait_values( check_unresolved: bool, reference_store: Any | None, ) -> list[str]: - errors = _validate_traits_against_schema( - effective_traits_schema, effective_traits, check_unresolved + errors = ( + _validate_traits_against_schema( + effective_traits_schema, effective_traits, check_unresolved + ) + if check_unresolved + else [] ) xref = XGtsRefValidator(store=reference_store) + for err in xref.validate_schema_ref_existence(effective_traits_schema): + errors.append(f"trait x-gts-ref: {err.reason}") for err in xref.validate_instance(effective_traits, effective_traits_schema, ""): errors.append(f"trait x-gts-ref: {err.reason}") return errors diff --git a/gts/src/gts/x_gts_ref.py b/gts/src/gts/x_gts_ref.py index 70dd04f..0548257 100644 --- a/gts/src/gts/x_gts_ref.py +++ b/gts/src/gts/x_gts_ref.py @@ -268,6 +268,49 @@ def visit_schema(sch, path): visit_schema(schema, schema_path) return errors + def validate_schema_ref_existence( + self, schema: Any, schema_path: str = "" + ) -> list[XGtsRefValidationError]: + if self.store is None or not self.enforce_existence: + return [] + + store = self.store + errors: list[XGtsRefValidationError] = [] + + def visit_schema(sch: Any, path: str) -> None: + if not isinstance(sch, dict): + return + + ref_pattern = sch.get("x-gts-ref") + if ( + isinstance(ref_pattern, str) + and ref_pattern.startswith(GTS_PREFIX) + and "*" not in ref_pattern + and store.get(ref_pattern) is None + ): + ref_path = f"{path}/x-gts-ref" if path else "x-gts-ref" + errors.append( + XGtsRefValidationError( + ref_path, + ref_pattern, + ref_pattern, + f"x-gts-ref constraint type '{ref_pattern}' is not registered", + ) + ) + + for key, value in sch.items(): + if key == "x-gts-ref": + continue + nested_path = f"{path}/{key}" if path else key + if isinstance(value, dict): + visit_schema(value, nested_path) + elif isinstance(value, list): + for index, item in enumerate(value): + visit_schema(item, f"{nested_path}[{index}]") + + visit_schema(schema, schema_path) + return errors + def _validate_ref_value( self, value: str, ref_pattern: str, field_path: str, schema: dict[str, Any] ) -> XGtsRefValidationError | None: diff --git a/tests/test_traits.py b/tests/test_traits.py index c75bffe..98b1ed9 100644 --- a/tests/test_traits.py +++ b/tests/test_traits.py @@ -221,6 +221,30 @@ def test_abstract_skips_unresolved_check(self): errors = effective.validate(check_unresolved=False) assert errors == [] + def test_abstract_skips_standard_trait_validation(self): + schema = { + "type": "object", + "properties": {"a": {"type": "string"}}, + } + effective = build_effective_traits([schema], {"a": 5}, None) + assert effective.validate(check_unresolved=False) == [] + + def test_abstract_checks_x_gts_ref_constraint_type_existence(self): + class FakeStore: + def get(self, value): + return None + + schema = { + "type": "object", + "properties": { + "ref": {"type": "string", "x-gts-ref": "gts.x.test._.foo.v1~"} + }, + } + errors = build_effective_traits([schema], {}, None).validate( + check_unresolved=False, reference_store=FakeStore() + ) + assert any("constraint type 'gts.x.test._.foo.v1~' is not registered" in e for e in errors) + def test_incompatible_trait_schema_chain_flagged(self): # Second schema narrows type incompatibly with the ancestor. effective = build_effective_traits( diff --git a/tests/test_x_gts_ref.py b/tests/test_x_gts_ref.py index ceb78e2..242313c 100644 --- a/tests/test_x_gts_ref.py +++ b/tests/test_x_gts_ref.py @@ -80,6 +80,38 @@ def test_recurses_into_list_of_dicts(self): assert "allOf[0]/x-gts-ref" in errors[0].field_path +class TestValidateSchemaRefExistence: + def test_missing_concrete_constraint_type_fails(self): + class FakeStore: + def get(self, value): + return None + + errors = XGtsRefValidator(store=FakeStore()).validate_schema_ref_existence( + {"properties": {"ref": {"x-gts-ref": "gts.x.test._.foo.v1~"}}} + ) + + assert len(errors) == 1 + assert errors[0].field_path == "properties/ref/x-gts-ref" + assert "constraint type 'gts.x.test._.foo.v1~' is not registered" in errors[0].reason + + def test_registered_and_wildcard_constraints_pass(self): + class FakeStore: + def get(self, value): + return object() if value == "gts.x.test._.foo.v1~" else None + + errors = XGtsRefValidator(store=FakeStore()).validate_schema_ref_existence( + { + "allOf": [ + {"x-gts-ref": "gts.x.test._.foo.v1~"}, + {"x-gts-ref": "gts.x.test.*"}, + {"x-gts-ref": "/properties/ref"}, + ] + } + ) + + assert errors == [] + + class TestValidateInstanceValue: def test_non_string_instance_value_error(self): error = XGtsRefValidator()._validate_ref_value(123, "gts.*", "ref", {}) From c8ac5969779b6eb6c08e227a6f44863342ec159e Mon Sep 17 00:00:00 2001 From: Artifizer Date: Wed, 16 Sep 2026 22:21:26 +0300 Subject: [PATCH 03/16] fix(validation): reject unresolved GTS references Check every external GTS $ref target during explicit schema validation, including complete multi-segment identifiers. This keeps /validate-type-schema and /validate-entity from accepting schemas whose referenced Type Schemas are absent. Signed-off-by: Artifizer --- gts/src/gts/store.py | 22 ++++++++++++++++++++++ tests/test_store_extra.py | 27 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/gts/src/gts/store.py b/gts/src/gts/store.py index 1a489b4..f57d501 100644 --- a/gts/src/gts/store.py +++ b/gts/src/gts/store.py @@ -295,6 +295,27 @@ def _validate_schema_refs(schema: dict[str, Any], path: str = "") -> None: nested_path = f"{path}[{idx}]" GtsStore._validate_schema_refs(item, nested_path) + def _validate_schema_ref_targets(self, schema: Any, path: str = "") -> None: + if isinstance(schema, dict): + ref_uri = schema.get("$ref") + if isinstance(ref_uri, str): + ref = GtsRef.parse(ref_uri) + if not ref.is_local and ref.is_gts and ref.has_scheme: + current_path = f"{path}.$ref" if path else "$ref" + try: + self.get_schema_content(ref.target_id) + except KeyError as error: + raise ValueError( + f"Unresolvable $ref at '{current_path}': '{ref_uri}'" + ) from error + for key, value in schema.items(): + if key != "$ref": + nested_path = f"{path}.{key}" if path else key + self._validate_schema_ref_targets(value, nested_path) + elif isinstance(schema, list): + for index, item in enumerate(schema): + self._validate_schema_ref_targets(item, f"{path}[{index}]") + def _validate_schema_x_gts_refs(self, gts_id: str) -> None: """ Validate a schema's x-gts-ref fields. @@ -671,6 +692,7 @@ def validate_schema_content( logger.info(f"Validating schema {schema_id.id}") self._validate_schema_refs(schema_content, "") + self._validate_schema_ref_targets(schema_content) self._validate_schema_x_gts_refs_content(schema_id.id, schema_content) self._validate_gts_keywords(schema_content) self._validate_schema_chain(schema_id.id, schema_content) diff --git a/tests/test_store_extra.py b/tests/test_store_extra.py index ac90fad..3d1dc7b 100644 --- a/tests/test_store_extra.py +++ b/tests/test_store_extra.py @@ -108,6 +108,33 @@ def test_recurses_into_list(self): {"allOf": [{"$ref": "http://example.com/schema"}]} ) + def test_registered_gts_ref_target_resolves(self): + target = _schema_entity("gts.x.test._.target.v1~") + store = GtsStore(MockGtsReader([target])) + store._validate_schema_ref_targets( + {"allOf": [{"$ref": "gts://gts.x.test._.target.v1~"}]} + ) + + def test_missing_gts_ref_target_raises(self): + store = GtsStore(reader=None) + with pytest.raises(ValueError, match="Unresolvable \\$ref"): + store._validate_schema_ref_targets( + {"allOf": [{"$ref": "gts://gts.x.test._.missing.v1~"}]} + ) + + def test_missing_derived_gts_ref_target_raises(self): + target = _schema_entity("gts.x.test._.target.v1~") + store = GtsStore(MockGtsReader([target])) + with pytest.raises(ValueError, match="Unresolvable \\$ref"): + store._validate_schema_ref_targets( + { + "$ref": ( + "gts://gts.x.test._.target.v1~" + "x.test._.missing.v1~" + ) + } + ) + class TestValidateGtsKeywords: def test_final_must_be_bool(self): From ca8269830ea6f99a16201efde3515876b6e9888c Mon Sep 17 00:00:00 2001 From: Artifizer Date: Thu, 17 Sep 2026 12:15:33 +0300 Subject: [PATCH 04/16] chore: added gts-server to Makefile Signed-off-by: Artifizer --- Makefile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index fcfce56..736c3fb 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ $(error PYTHON must be set for local package targets (examples: venv: PYTHON=.ve endif endif -.PHONY: help py-env install build install-local uninstall-local clean dev-fmt all check fmt lint clippy mypy test security update-spec e2e coverage +.PHONY: help py-env install build install-local uninstall-local clean dev-fmt all check fmt lint clippy mypy test security update-spec e2e coverage gts-server # Default target - show help .DEFAULT_GOAL := help @@ -115,6 +115,11 @@ coverage: install $(PYTHON) -m pip install 'pytest-cov>=5,<7' $(PYTHON) -m pytest tests/ --cov=gts --cov-report=xml --cov-report=term +PORT ?= 8000 + +gts-server: install + $(PYTHON) -m gts server --host 0.0.0.0 --port $(PORT) + # Run end-to-end tests against gts-spec e2e: install @echo "Starting server in background..." From d921690b82e2fb24a39a0cbc68730ab3595c9aeb Mon Sep 17 00:00:00 2001 From: Artifizer Date: Thu, 17 Sep 2026 16:23:39 +0300 Subject: [PATCH 05/16] fix(validation): validate transitive dependencies Signed-off-by: Artifizer --- gts/src/gts/ops.py | 21 ++--- gts/src/gts/store.py | 168 ++++++++++++++++++++++++++++++++++++--- gts/src/gts/traits.py | 30 +++++-- gts/src/gts/x_gts_ref.py | 2 + tests/test_traits.py | 5 +- 5 files changed, 198 insertions(+), 28 deletions(-) diff --git a/gts/src/gts/ops.py b/gts/src/gts/ops.py index 893f4cd..a8b90ca 100644 --- a/gts/src/gts/ops.py +++ b/gts/src/gts/ops.py @@ -667,18 +667,21 @@ def validate_schema(self, gts_id: str) -> GtsValidationResult: return GtsValidationResult(id=gts_id, ok=False, error=str(e)) def validate_entity(self, gts_id: str) -> GtsEntityValidationResult: - try: - parsed = GtsID(gts_id) - except Exception as e: # noqa: BLE001 - converted to a result object at API boundary - return GtsEntityValidationResult( - id=gts_id, ok=False, entity_type="", error=str(e) - ) + entity = self.store.get(gts_id) + if entity: + entity_type = "schema" if entity.is_schema else "instance" + else: + try: + parsed = GtsID(gts_id) + entity_type = "schema" if parsed.is_type else "instance" + except Exception as e: # noqa: BLE001 - converted at API boundary + return GtsEntityValidationResult( + id=gts_id, ok=False, entity_type="", error=str(e) + ) - if parsed.is_type: - entity_type = "schema" + if entity_type == "schema": result = self.validate_schema(gts_id) else: - entity_type = "instance" result = self.validate_instance(gts_id) return GtsEntityValidationResult( diff --git a/gts/src/gts/store.py b/gts/src/gts/store.py index f57d501..6a5ed44 100644 --- a/gts/src/gts/store.py +++ b/gts/src/gts/store.py @@ -295,26 +295,37 @@ def _validate_schema_refs(schema: dict[str, Any], path: str = "") -> None: nested_path = f"{path}[{idx}]" GtsStore._validate_schema_refs(item, nested_path) - def _validate_schema_ref_targets(self, schema: Any, path: str = "") -> None: + def _validate_schema_ref_targets( + self, schema: Any, path: str = "", visited: set[str] | None = None + ) -> None: + visited = visited if visited is not None else set() if isinstance(schema, dict): ref_uri = schema.get("$ref") if isinstance(ref_uri, str): ref = GtsRef.parse(ref_uri) if not ref.is_local and ref.is_gts and ref.has_scheme: current_path = f"{path}.$ref" if path else "$ref" - try: - self.get_schema_content(ref.target_id) - except KeyError as error: + target = self.get(ref.target_id) + if ( + target is None + or not target.is_schema + or not isinstance(target.content, dict) + ): raise ValueError( f"Unresolvable $ref at '{current_path}': '{ref_uri}'" - ) from error + ) + if ref.target_id not in visited: + visited.add(ref.target_id) + self._validate_schema_ref_targets( + target.content, current_path, visited + ) for key, value in schema.items(): if key != "$ref": nested_path = f"{path}.{key}" if path else key - self._validate_schema_ref_targets(value, nested_path) + self._validate_schema_ref_targets(value, nested_path, visited) elif isinstance(schema, list): for index, item in enumerate(schema): - self._validate_schema_ref_targets(item, f"{path}[{index}]") + self._validate_schema_ref_targets(item, f"{path}[{index}]", visited) def _validate_schema_x_gts_refs(self, gts_id: str) -> None: """ @@ -722,7 +733,15 @@ def validate_schema_content( def validate_schema(self, gts_id: str) -> None: """Validate a registered schema and all of its dependencies.""" + self._validate_schema_transitive(gts_id, set(), set()) + + def _validate_schema_transitive( + self, gts_id: str, visiting: set[str], validated: set[str] + ) -> None: schema_id = _require_schema_id(gts_id) + key = f"schema:{schema_id.id}" + if key in validated or key in visiting: + return schema_entity = self.get(schema_id.id) if not schema_entity: @@ -733,9 +752,95 @@ def validate_schema(self, gts_id: str) -> None: raise ValueError( # noqa: TRY004 - keep ValueError for API compatibility f"Schema '{schema_id.id}' content must be a dictionary" ) - self.validate_schema_content(schema_id.id, schema_entity.content) - def validate_instance_content(self, content: dict[str, Any], type_id: str) -> None: + visiting.add(key) + try: + self.validate_schema_content(schema_id.id, schema_entity.content) + + effective_traits = self._build_effective_traits(schema_id.id) + trait_ref_validator = XGtsRefValidator(store=self) + trait_ref_validator.validate_instance( + effective_traits.values, effective_traits.schema + ) + for dependency_id in trait_ref_validator.referenced_ids: + try: + self._validate_entity_transitive(dependency_id, visiting, validated) + except Exception as error: + raise ValueError( + f"Referenced trait entity '{dependency_id}' is invalid: {error}" + ) from error + + chain_ids: list[str] = [] + prefix = "gts." + for segment in schema_id.gts_id_segments: + chain_ids.append(prefix + segment.segment) + prefix += segment.segment + for ancestor_id in chain_ids[:-1]: + try: + self._validate_schema_transitive(ancestor_id, visiting, validated) + except Exception as error: + raise ValueError( + f"Ancestor type '{ancestor_id}' is invalid: {error}" + ) from error + + for dependency_id, dependency_is_type in self._schema_dependencies( + schema_entity.content + ): + try: + if dependency_is_type: + self._validate_schema_transitive( + dependency_id, visiting, validated + ) + else: + self._validate_entity_transitive( + dependency_id, visiting, validated + ) + except Exception as error: + raise ValueError( + f"Referenced entity '{dependency_id}' is invalid: {error}" + ) from error + finally: + visiting.remove(key) + validated.add(key) + + def _schema_dependencies(self, schema: Any) -> Iterator[tuple[str, bool]]: + if isinstance(schema, dict): + ref_uri = schema.get("$ref") + if isinstance(ref_uri, str): + ref = GtsRef.parse(ref_uri) + if not ref.is_local and ref.is_gts and ref.has_scheme: + yield ref.target_id, True + + x_gts_ref = schema.get("x-gts-ref") + if ( + isinstance(x_gts_ref, str) + and x_gts_ref.startswith("gts.") + and "*" not in x_gts_ref + ): + yield x_gts_ref, True + + for key, value in schema.items(): + if key in {"$ref", "x-gts-ref"}: + continue + yield from self._schema_dependencies(value) + elif isinstance(schema, list): + for value in schema: + yield from self._schema_dependencies(value) + + def _validate_entity_transitive( + self, gts_id: str, visiting: set[str], validated: set[str] + ) -> None: + entity = self.get(gts_id) + if not entity: + raise StoreGtsEntityNotFound(gts_id) + if entity.is_schema: + self._validate_schema_transitive(gts_id, visiting, validated) + else: + self._validate_instance_transitive(gts_id, visiting, validated) + + def validate_instance_content( + self, content: dict[str, Any], type_id: str + ) -> set[str]: """Validate unregistered instance content against a registered type schema.""" schema_type = _require_schema_id(type_id) try: @@ -768,11 +873,54 @@ def validate_instance_content(self, content: dict[str, Any], type_id: str) -> No raise ValueError( f"x-gts-ref validation failed: {'; '.join(error_messages)}" ) + return x_gts_ref_validator.referenced_ids def validate_instance( self, gts_id: str, ) -> None: + """Validate an object instance and its complete dependency closure.""" + self._validate_instance_transitive(gts_id, set(), set()) + + def _validate_instance_transitive( + self, gts_id: str, visiting: set[str], validated: set[str] + ) -> None: + key = f"instance:{gts_id}" + if key in validated or key in visiting: + return + visiting.add(key) + try: + referenced_ids = self._validate_instance_local(gts_id) + + obj = ( + self.get(GtsID(gts_id).id) + if GtsID.is_valid(gts_id) + else self.get(gts_id) + ) + if not obj or not obj.type_id: + return + try: + self._validate_schema_transitive(obj.type_id, visiting, validated) + except Exception as error: + raise ValueError( + f"Instance type '{obj.type_id}' is invalid: {error}" + ) from error + + for dependency_id in referenced_ids: + try: + self._validate_entity_transitive(dependency_id, visiting, validated) + except Exception as error: + raise ValueError( + f"Referenced entity '{dependency_id}' is invalid: {error}" + ) from error + finally: + visiting.remove(key) + validated.add(key) + + def _validate_instance_local( + self, + gts_id: str, + ) -> set[str]: """ Validate an object instance against its schema. @@ -803,7 +951,7 @@ def validate_instance( raise TypeError(f"Instance '{lookup_id}' content must be a dictionary") logger.info(f"Validating instance {gts_id} against schema {obj.type_id}") - self.validate_instance_content(obj.content, obj.type_id) + return self.validate_instance_content(obj.content, obj.type_id) def cast( self, diff --git a/gts/src/gts/traits.py b/gts/src/gts/traits.py index 77cad54..14bf99a 100644 --- a/gts/src/gts/traits.py +++ b/gts/src/gts/traits.py @@ -72,10 +72,13 @@ def validate( return [] if _effective_schema_is_false(self.schema): - if self._has_explicit_values(): + has_non_false_declaration = any( + declaration is not False for declaration in self.resolved_trait_schemas + ) + if self._has_explicit_values() or has_non_false_declaration: return [ f"{X_GTS_TRAITS_SCHEMA} resolves to `false` in the chain - " # noqa: ISC004 - f"{X_GTS_TRAITS} values are prohibited" + "trait declarations and values are prohibited" ] return [] @@ -274,6 +277,18 @@ def _materialize_traits(trait_schema: Any, traits: Any, depth: int = 0) -> Any: # --- validation ------------------------------------------------------------ +def _without_required(schema: Any) -> Any: + if isinstance(schema, dict): + return { + key: _without_required(value) + for key, value in schema.items() + if key != "required" + } + if isinstance(schema, list): + return [_without_required(value) for value in schema] + return copy.deepcopy(schema) + + def _validate_trait_schema_integrity(resolved_trait_schemas: list[Any]) -> list[str]: for i, ts in enumerate(resolved_trait_schemas): if isinstance(ts, bool): @@ -369,12 +384,13 @@ def _validate_trait_values( check_unresolved: bool, reference_store: Any | None, ) -> list[str]: - errors = ( - _validate_traits_against_schema( - effective_traits_schema, effective_traits, check_unresolved - ) + schema_for_values = ( + effective_traits_schema if check_unresolved - else [] + else _without_required(effective_traits_schema) + ) + errors = _validate_traits_against_schema( + schema_for_values, effective_traits, check_unresolved ) xref = XGtsRefValidator(store=reference_store) for err in xref.validate_schema_ref_existence(effective_traits_schema): diff --git a/gts/src/gts/x_gts_ref.py b/gts/src/gts/x_gts_ref.py index 0548257..d18ac84 100644 --- a/gts/src/gts/x_gts_ref.py +++ b/gts/src/gts/x_gts_ref.py @@ -91,6 +91,7 @@ def __init__(self, store: Any | None = None, enforce_existence: bool = True): """ self.store = store self.enforce_existence = enforce_existence + self.referenced_ids: set[str] = set() def validate_instance( self, instance: dict[str, Any], schema: dict[str, Any], instance_path: str = "" @@ -492,6 +493,7 @@ def _validate_gts_pattern( pattern, f"Referenced entity '{value}' not found in registry", ) + self.referenced_ids.add(value) return None diff --git a/tests/test_traits.py b/tests/test_traits.py index 98b1ed9..a420954 100644 --- a/tests/test_traits.py +++ b/tests/test_traits.py @@ -221,13 +221,14 @@ def test_abstract_skips_unresolved_check(self): errors = effective.validate(check_unresolved=False) assert errors == [] - def test_abstract_skips_standard_trait_validation(self): + def test_abstract_validates_provided_trait_values(self): schema = { "type": "object", "properties": {"a": {"type": "string"}}, } effective = build_effective_traits([schema], {"a": 5}, None) - assert effective.validate(check_unresolved=False) == [] + errors = effective.validate(check_unresolved=False) + assert any("is not of type 'string'" in error for error in errors) def test_abstract_checks_x_gts_ref_constraint_type_existence(self): class FakeStore: From fd7b2378dc29ad491f9a05aebdd9aebd4b9dd346 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Thu, 17 Sep 2026 16:25:52 +0300 Subject: [PATCH 06/16] test(validation): cover schema reference target failures Signed-off-by: Artifizer --- tests/test_store_extra.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_store_extra.py b/tests/test_store_extra.py index 3d1dc7b..04cfb60 100644 --- a/tests/test_store_extra.py +++ b/tests/test_store_extra.py @@ -115,6 +115,26 @@ def test_registered_gts_ref_target_resolves(self): {"allOf": [{"$ref": "gts://gts.x.test._.target.v1~"}]} ) + def test_non_schema_gts_ref_target_raises(self): + target_id = "gts.x.test._.target.v1~" + target = GtsEntity( + content={"$id": target_id}, gts_id=GtsID(target_id), is_schema=False + ) + store = GtsStore(MockGtsReader([target])) + with pytest.raises(ValueError, match="Unresolvable \\$ref"): + store._validate_schema_ref_targets({"$ref": f"gts://{target_id}"}) + + def test_transitive_missing_gts_ref_target_raises(self): + target = _schema_entity( + "gts.x.test._.target.v1~", + {"$ref": "gts://gts.x.test._.missing.v1~"}, + ) + store = GtsStore(MockGtsReader([target])) + with pytest.raises(ValueError, match="Unresolvable \\$ref"): + store._validate_schema_ref_targets( + {"$ref": "gts://gts.x.test._.target.v1~"} + ) + def test_missing_gts_ref_target_raises(self): store = GtsStore(reader=None) with pytest.raises(ValueError, match="Unresolvable \\$ref"): From e5d600c36ed2bd83889a90c2d1e387b34ed89930 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Thu, 17 Sep 2026 17:37:13 +0300 Subject: [PATCH 07/16] fix: preserve trait constraints for abstract schemas Signed-off-by: Artifizer --- Makefile | 2 +- gts/src/gts/traits.py | 23 +++++++---------------- tests/test_traits.py | 11 +++++++++++ 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index 736c3fb..b4b2700 100644 --- a/Makefile +++ b/Makefile @@ -118,7 +118,7 @@ coverage: install PORT ?= 8000 gts-server: install - $(PYTHON) -m gts server --host 0.0.0.0 --port $(PORT) + $(PYTHON) -m gts server --host 127.0.0.1 --port $(PORT) # Run end-to-end tests against gts-spec e2e: install diff --git a/gts/src/gts/traits.py b/gts/src/gts/traits.py index 14bf99a..f3739bf 100644 --- a/gts/src/gts/traits.py +++ b/gts/src/gts/traits.py @@ -19,6 +19,8 @@ import copy from typing import Any +from jsonschema import validators + from . import derivation from ._json_pointer import resolve as resolve_json_pointer from .schema_validation import FORMAT_CHECKER as _FORMAT_CHECKER @@ -277,16 +279,8 @@ def _materialize_traits(trait_schema: Any, traits: Any, depth: int = 0) -> Any: # --- validation ------------------------------------------------------------ -def _without_required(schema: Any) -> Any: - if isinstance(schema, dict): - return { - key: _without_required(value) - for key, value in schema.items() - if key != "required" - } - if isinstance(schema, list): - return [_without_required(value) for value in schema] - return copy.deepcopy(schema) +def _ignore_required(*_args: Any) -> tuple[()]: + return () def _validate_trait_schema_integrity(resolved_trait_schemas: list[Any]) -> list[str]: @@ -346,6 +340,8 @@ def _validate_traits_against_schema( try: cls = validator_for(trait_schema) + if not check_unresolved: + cls = validators.extend(cls, {"required": _ignore_required}) validator = cls(trait_schema, format_checker=_FORMAT_CHECKER) for error in validator.iter_errors(effective_traits): errors.append(f"trait validation: {error.message}") @@ -384,13 +380,8 @@ def _validate_trait_values( check_unresolved: bool, reference_store: Any | None, ) -> list[str]: - schema_for_values = ( - effective_traits_schema - if check_unresolved - else _without_required(effective_traits_schema) - ) errors = _validate_traits_against_schema( - schema_for_values, effective_traits, check_unresolved + effective_traits_schema, effective_traits, check_unresolved ) xref = XGtsRefValidator(store=reference_store) for err in xref.validate_schema_ref_existence(effective_traits_schema): diff --git a/tests/test_traits.py b/tests/test_traits.py index a420954..7578ac4 100644 --- a/tests/test_traits.py +++ b/tests/test_traits.py @@ -230,6 +230,17 @@ def test_abstract_validates_provided_trait_values(self): errors = effective.validate(check_unresolved=False) assert any("is not of type 'string'" in error for error in errors) + def test_abstract_preserves_required_in_const_value(self): + schema = { + "type": "object", + "properties": {"config": {"const": {"required": ["a"]}}}, + "required": ["missing"], + } + effective = build_effective_traits( + [schema], {"config": {"required": ["a"]}}, None + ) + assert effective.validate(check_unresolved=False) == [] + def test_abstract_checks_x_gts_ref_constraint_type_existence(self): class FakeStore: def get(self, value): From d0b00342ae7e2a26a598ed3a0c0bd0d1de16a339 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Fri, 18 Sep 2026 01:20:35 +0300 Subject: [PATCH 08/16] fix(validation): traverse only schema-valued keywords Treat annotation payloads and property-name maps as data when validating GTS extensions and collecting dependencies. Preserve nested schema traversal, including x-gts-traits-schema, without interpreting keyword-shaped data as constraints. Signed-off-by: Artifizer --- gts/src/gts/schema_validation.py | 86 +++++++++++++++++++++++++++- gts/src/gts/store.py | 51 ++++------------- gts/src/gts/x_gts_ref.py | 96 ++++++++++---------------------- tests/test_store_extra.py | 29 ++++++++++ tests/test_x_gts_ref.py | 31 +++++++++++ 5 files changed, 185 insertions(+), 108 deletions(-) diff --git a/gts/src/gts/schema_validation.py b/gts/src/gts/schema_validation.py index 95b61b7..9b818b3 100644 --- a/gts/src/gts/schema_validation.py +++ b/gts/src/gts/schema_validation.py @@ -1,6 +1,7 @@ from __future__ import annotations -from collections.abc import Iterator +import copy +from collections.abc import Callable, Iterator from typing import Any import regex @@ -9,6 +10,89 @@ PATTERN_TIMEOUT_SECONDS = 1.0 +_SCHEMA_MAP_KEYWORDS = { + "$defs", + "definitions", + "dependentSchemas", + "patternProperties", + "properties", +} +_SCHEMA_ARRAY_KEYWORDS = {"allOf", "anyOf", "oneOf", "prefixItems"} +_SCHEMA_SINGLE_KEYWORDS = { + "additionalItems", + "additionalProperties", + "contains", + "contentSchema", + "else", + "if", + "not", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", + "x-gts-traits-schema", +} + + +def iter_schema_nodes( + schema: Any, path: str = "" +) -> Iterator[tuple[dict[str, Any], str]]: + if not isinstance(schema, dict): + return + yield schema, path + for keyword, value in schema.items(): + keyword_path = f"{path}/{keyword}" if path else keyword + if keyword in _SCHEMA_MAP_KEYWORDS and isinstance(value, dict): + for name, child in value.items(): + yield from iter_schema_nodes(child, f"{keyword_path}/{name}") + elif keyword in _SCHEMA_ARRAY_KEYWORDS and isinstance(value, list): + for index, child in enumerate(value): + yield from iter_schema_nodes(child, f"{keyword_path}[{index}]") + elif keyword in _SCHEMA_SINGLE_KEYWORDS: + yield from iter_schema_nodes(value, keyword_path) + elif keyword == "items": + if isinstance(value, list): + for index, child in enumerate(value): + yield from iter_schema_nodes(child, f"{keyword_path}[{index}]") + else: + yield from iter_schema_nodes(value, keyword_path) + elif keyword == "dependencies" and isinstance(value, dict): + for name, child in value.items(): + if isinstance(child, (dict, bool)): + yield from iter_schema_nodes(child, f"{keyword_path}/{name}") + + +def map_schema_nodes(schema: Any, transform: Callable[[Any], Any]) -> Any: + if not isinstance(schema, dict): + return copy.deepcopy(schema) + mapped = copy.deepcopy(schema) + for keyword, value in schema.items(): + if keyword in _SCHEMA_MAP_KEYWORDS and isinstance(value, dict): + mapped[keyword] = { + name: map_schema_nodes(child, transform) + for name, child in value.items() + } + elif keyword in _SCHEMA_ARRAY_KEYWORDS and isinstance(value, list): + mapped[keyword] = [map_schema_nodes(child, transform) for child in value] + elif keyword in _SCHEMA_SINGLE_KEYWORDS: + mapped[keyword] = map_schema_nodes(value, transform) + elif keyword == "items": + if isinstance(value, list): + mapped[keyword] = [ + map_schema_nodes(child, transform) for child in value + ] + else: + mapped[keyword] = map_schema_nodes(value, transform) + elif keyword == "dependencies" and isinstance(value, dict): + mapped[keyword] = { + name: map_schema_nodes(child, transform) + if isinstance(child, (dict, bool)) + else copy.deepcopy(child) + for name, child in value.items() + } + return transform(mapped) + + # Shared format checker for instance/trait validation. # # A bare ``FormatChecker()`` draws from jsonschema's shared, class-level checker diff --git a/gts/src/gts/store.py b/gts/src/gts/store.py index 6a5ed44..6e9f45e 100644 --- a/gts/src/gts/store.py +++ b/gts/src/gts/store.py @@ -15,7 +15,7 @@ from .entities import GtsEntity from .gts import GtsID, GtsRef, GtsWildcard from .schema_cast import GtsEntityCastResult -from .schema_validation import FORMAT_CHECKER, validator_for +from .schema_validation import FORMAT_CHECKER, iter_schema_nodes, validator_for from .x_gts_ref import XGtsRefValidator, _without_x_gts_ref logger = logging.getLogger(__name__) @@ -372,15 +372,6 @@ def _validate_gts_keywords(content: dict[str, Any]) -> None: } supported_keywords = top_level_keywords | {"x-gts-ref"} - def _contains_key_recursive(value: Any, key: str) -> bool: - if isinstance(value, dict): - if key in value: - return True - return any(_contains_key_recursive(v, key) for v in value.values()) - elif isinstance(value, list): - return any(_contains_key_recursive(v, key) for v in value) - return False - # Validate x-gts-final final_val = content.get("x-gts-final") if final_val is not None and not isinstance(final_val, bool): @@ -401,26 +392,12 @@ def _contains_key_recursive(value: Any, key: str) -> bool: "schema cannot declare both x-gts-final and x-gts-abstract as true" ) - def _validate_extensions(value: Any) -> None: - if isinstance(value, dict): - for key, nested_value in value.items(): - if key.startswith("x-gts-") and key not in supported_keywords: - raise ValueError(f"Unsupported GTS extension keyword: {key}") - _validate_extensions(nested_value) - elif isinstance(value, list): - for item in value: - _validate_extensions(item) - - _validate_extensions(content) - - # Check that x-gts-final/x-gts-abstract/x-gts-traits/x-gts-traits-schema - # appear only at the top level - for key, value in content.items(): - if key in top_level_keywords: - continue - for kw in top_level_keywords: - if _contains_key_recursive(value, kw): - raise ValueError(f"{kw} must be at the schema top level") + for subschema, path in iter_schema_nodes(content): + for key in subschema: + if key.startswith("x-gts-") and key not in supported_keywords: + raise ValueError(f"Unsupported GTS extension keyword: {key}") + if path and key in top_level_keywords: + raise ValueError(f"{key} must be at the schema top level") @staticmethod def _content_is_abstract(content: dict[str, Any]) -> bool: @@ -804,14 +781,14 @@ def _validate_schema_transitive( validated.add(key) def _schema_dependencies(self, schema: Any) -> Iterator[tuple[str, bool]]: - if isinstance(schema, dict): - ref_uri = schema.get("$ref") + for subschema, _path in iter_schema_nodes(schema): + ref_uri = subschema.get("$ref") if isinstance(ref_uri, str): ref = GtsRef.parse(ref_uri) if not ref.is_local and ref.is_gts and ref.has_scheme: yield ref.target_id, True - x_gts_ref = schema.get("x-gts-ref") + x_gts_ref = subschema.get("x-gts-ref") if ( isinstance(x_gts_ref, str) and x_gts_ref.startswith("gts.") @@ -819,14 +796,6 @@ def _schema_dependencies(self, schema: Any) -> Iterator[tuple[str, bool]]: ): yield x_gts_ref, True - for key, value in schema.items(): - if key in {"$ref", "x-gts-ref"}: - continue - yield from self._schema_dependencies(value) - elif isinstance(schema, list): - for value in schema: - yield from self._schema_dependencies(value) - def _validate_entity_transitive( self, gts_id: str, visiting: set[str], validated: set[str] ) -> None: diff --git a/gts/src/gts/x_gts_ref.py b/gts/src/gts/x_gts_ref.py index d18ac84..6738268 100644 --- a/gts/src/gts/x_gts_ref.py +++ b/gts/src/gts/x_gts_ref.py @@ -20,15 +20,14 @@ from ._json_pointer import resolve as resolve_json_pointer from ._naming import GTS_PREFIX, strip_scheme from .gts import GtsID +from .schema_validation import iter_schema_nodes, map_schema_nodes def _without_x_gts_ref(schema: Any) -> Any: - if isinstance(schema, dict): - stripped = { - key: _without_x_gts_ref(value) - for key, value in schema.items() - if key != "x-gts-ref" - } + def strip(node: Any) -> Any: + if not isinstance(node, dict): + return node + stripped = {key: value for key, value in node.items() if key != "x-gts-ref"} for keyword in ("oneOf", "anyOf", "allOf"): branches = stripped.get(keyword) if ( @@ -38,9 +37,8 @@ def _without_x_gts_ref(schema: Any) -> Any: ): stripped.pop(keyword, None) return stripped - if isinstance(schema, list): - return [_without_x_gts_ref(value) for value in schema] - return schema + + return map_schema_nodes(schema, strip) def _is_x_gts_ref_only_combinator(branches: list[Any]) -> bool: @@ -240,33 +238,14 @@ def validate_schema( root_schema = schema errors = [] - - def visit_schema(sch, path): - """Recursively visit schema nodes.""" - if not isinstance(sch, dict): - return - - # Check for x-gts-ref field - if "x-gts-ref" in sch: - ref_value = sch["x-gts-ref"] - ref_path = f"{path}/x-gts-ref" if path else "x-gts-ref" - error = self._validate_ref_pattern(ref_value, ref_path, root_schema) - if error: - errors.append(error) - - # Recurse into nested structures - for key, value in sch.items(): - if key == "x-gts-ref": - continue - nested_path = f"{path}/{key}" if path else key - if isinstance(value, dict): - visit_schema(value, nested_path) - elif isinstance(value, list): - for idx, item in enumerate(value): - if isinstance(item, dict): - visit_schema(item, f"{nested_path}[{idx}]") - - visit_schema(schema, schema_path) + for subschema, path in iter_schema_nodes(schema, schema_path): + if "x-gts-ref" not in subschema: + continue + ref_value = subschema["x-gts-ref"] + ref_path = f"{path}/x-gts-ref" if path else "x-gts-ref" + error = self._validate_ref_pattern(ref_value, ref_path, root_schema) + if error: + errors.append(error) return errors def validate_schema_ref_existence( @@ -277,39 +256,24 @@ def validate_schema_ref_existence( store = self.store errors: list[XGtsRefValidationError] = [] - - def visit_schema(sch: Any, path: str) -> None: - if not isinstance(sch, dict): - return - - ref_pattern = sch.get("x-gts-ref") + for subschema, path in iter_schema_nodes(schema, schema_path): + ref_pattern = subschema.get("x-gts-ref") if ( - isinstance(ref_pattern, str) - and ref_pattern.startswith(GTS_PREFIX) - and "*" not in ref_pattern - and store.get(ref_pattern) is None + not isinstance(ref_pattern, str) + or not ref_pattern.startswith(GTS_PREFIX) + or "*" in ref_pattern + or store.get(ref_pattern) is not None ): - ref_path = f"{path}/x-gts-ref" if path else "x-gts-ref" - errors.append( - XGtsRefValidationError( - ref_path, - ref_pattern, - ref_pattern, - f"x-gts-ref constraint type '{ref_pattern}' is not registered", - ) + continue + ref_path = f"{path}/x-gts-ref" if path else "x-gts-ref" + errors.append( + XGtsRefValidationError( + ref_path, + ref_pattern, + ref_pattern, + f"x-gts-ref constraint type '{ref_pattern}' is not registered", ) - - for key, value in sch.items(): - if key == "x-gts-ref": - continue - nested_path = f"{path}/{key}" if path else key - if isinstance(value, dict): - visit_schema(value, nested_path) - elif isinstance(value, list): - for index, item in enumerate(value): - visit_schema(item, f"{nested_path}[{index}]") - - visit_schema(schema, schema_path) + ) return errors def _validate_ref_value( diff --git a/tests/test_store_extra.py b/tests/test_store_extra.py index 04cfb60..5246839 100644 --- a/tests/test_store_extra.py +++ b/tests/test_store_extra.py @@ -156,6 +156,30 @@ def test_missing_derived_gts_ref_target_raises(self): ) +class TestSchemaDependencies: + def test_ignores_x_gts_ref_in_annotation_data(self): + store = GtsStore(reader=None) + assert list( + store._schema_dependencies( + {"const": {"x-gts-ref": "gts.x.test._.missing.v1~"}} + ) + ) == [] + + def test_finds_constraint_under_property_named_x_gts_ref(self): + store = GtsStore(reader=None) + assert list( + store._schema_dependencies( + { + "properties": { + "x-gts-ref": { + "x-gts-ref": "gts.x.test._.missing.v1~" + } + } + } + ) + ) == [("gts.x.test._.missing.v1~", True)] + + class TestValidateGtsKeywords: def test_final_must_be_bool(self): with pytest.raises(ValueError, match="x-gts-final must be a boolean"): @@ -181,6 +205,11 @@ def test_valid_top_level_keywords_pass(self): GtsStore._validate_gts_keywords({"x-gts-final": True}) GtsStore._validate_gts_keywords({"x-gts-abstract": True}) + def test_extension_shaped_annotation_data_is_ignored(self): + GtsStore._validate_gts_keywords( + {"const": {"x-gts-final": True, "x-gts-unknown": True}} + ) + def test_content_is_abstract_and_final(self): assert GtsStore._content_is_abstract({"x-gts-abstract": True}) is True assert GtsStore._content_is_abstract({}) is False diff --git a/tests/test_x_gts_ref.py b/tests/test_x_gts_ref.py index 242313c..08fa123 100644 --- a/tests/test_x_gts_ref.py +++ b/tests/test_x_gts_ref.py @@ -79,6 +79,27 @@ def test_recurses_into_list_of_dicts(self): assert len(errors) == 1 assert "allOf[0]/x-gts-ref" in errors[0].field_path + def test_ignores_x_gts_ref_in_annotation_data(self): + schema = { + "properties": { + "payload": { + "const": {"x-gts-ref": "gts.x.test._.missing.v1~"}, + "default": {"x-gts-ref": "not-a-gts-id"}, + } + } + } + assert XGtsRefValidator().validate_schema(schema) == [] + + def test_property_named_x_gts_ref_is_not_a_keyword(self): + schema = { + "properties": { + "x-gts-ref": {"x-gts-ref": "notgts.*"}, + } + } + errors = XGtsRefValidator().validate_schema(schema) + assert len(errors) == 1 + assert errors[0].field_path == "properties/x-gts-ref/x-gts-ref" + class TestValidateSchemaRefExistence: def test_missing_concrete_constraint_type_fails(self): @@ -111,6 +132,16 @@ def get(self, value): assert errors == [] + def test_annotation_data_does_not_require_constraint_type(self): + class FakeStore: + def get(self, value): + return None + + errors = XGtsRefValidator(store=FakeStore()).validate_schema_ref_existence( + {"const": {"x-gts-ref": "gts.x.test._.missing.v1~"}} + ) + assert errors == [] + class TestValidateInstanceValue: def test_non_string_instance_value_error(self): From 265ff2c0a16e4bf742c169e436e5b1163eeaf176 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Fri, 18 Sep 2026 01:20:47 +0300 Subject: [PATCH 09/16] fix(traits): suppress required across nested dialects Remove required only from actual trait subschemas when validating abstract types. This keeps annotation data intact and prevents nested schemas with their own dialect declaration from restoring completeness checks. Signed-off-by: Artifizer --- gts/src/gts/traits.py | 26 +++++++++++++++----------- tests/test_traits.py | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/gts/src/gts/traits.py b/gts/src/gts/traits.py index f3739bf..d86f556 100644 --- a/gts/src/gts/traits.py +++ b/gts/src/gts/traits.py @@ -19,12 +19,10 @@ import copy from typing import Any -from jsonschema import validators - from . import derivation from ._json_pointer import resolve as resolve_json_pointer from .schema_validation import FORMAT_CHECKER as _FORMAT_CHECKER -from .schema_validation import validator_for +from .schema_validation import map_schema_nodes, validator_for from .x_gts_ref import XGtsRefValidator X_GTS_TRAITS_SCHEMA = "x-gts-traits-schema" @@ -279,10 +277,6 @@ def _materialize_traits(trait_schema: Any, traits: Any, depth: int = 0) -> Any: # --- validation ------------------------------------------------------------ -def _ignore_required(*_args: Any) -> tuple[()]: - return () - - def _validate_trait_schema_integrity(resolved_trait_schemas: list[Any]) -> list[str]: for i, ts in enumerate(resolved_trait_schemas): if isinstance(ts, bool): @@ -339,10 +333,20 @@ def _validate_traits_against_schema( errors: list[str] = [] try: - cls = validator_for(trait_schema) - if not check_unresolved: - cls = validators.extend(cls, {"required": _ignore_required}) - validator = cls(trait_schema, format_checker=_FORMAT_CHECKER) + validation_schema = ( + trait_schema + if check_unresolved + else map_schema_nodes( + trait_schema, + lambda node: ( + {k: v for k, v in node.items() if k != "required"} + if isinstance(node, dict) + else node + ), + ) + ) + cls = validator_for(validation_schema) + validator = cls(validation_schema, format_checker=_FORMAT_CHECKER) for error in validator.iter_errors(effective_traits): errors.append(f"trait validation: {error.message}") except Exception as e: # noqa: BLE001 - surfaced as validation error message diff --git a/tests/test_traits.py b/tests/test_traits.py index 7578ac4..7f37b5c 100644 --- a/tests/test_traits.py +++ b/tests/test_traits.py @@ -241,6 +241,20 @@ def test_abstract_preserves_required_in_const_value(self): ) assert effective.validate(check_unresolved=False) == [] + def test_abstract_skips_required_in_nested_dialect_schema(self): + schema = { + "type": "object", + "allOf": [ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["missing"], + } + ], + } + effective = build_effective_traits([schema], {}, None) + assert effective.validate(check_unresolved=False) == [] + def test_abstract_checks_x_gts_ref_constraint_type_existence(self): class FakeStore: def get(self, value): From 8a51ad8faa1f7d5f01ff1c7e24b1d8da4d5bd753 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Fri, 18 Sep 2026 01:37:35 +0300 Subject: [PATCH 10/16] fix(validation): resolve relative x-gts-ref dependencies Resolve slash-prefixed x-gts-ref constraints against their root schema before checking target existence or collecting transitive dependencies. This keeps relative references subject to the same registry and dependency validation as concrete constraints. Signed-off-by: Artifizer --- gts/src/gts/store.py | 5 ++++- gts/src/gts/x_gts_ref.py | 31 +++++++++++++++++++++-------- tests/test_store_extra.py | 10 ++++++++++ tests/test_x_gts_ref.py | 41 +++++++++++++++++++++++++++++++++++---- 4 files changed, 74 insertions(+), 13 deletions(-) diff --git a/gts/src/gts/store.py b/gts/src/gts/store.py index 6e9f45e..626095f 100644 --- a/gts/src/gts/store.py +++ b/gts/src/gts/store.py @@ -781,6 +781,7 @@ def _validate_schema_transitive( validated.add(key) def _schema_dependencies(self, schema: Any) -> Iterator[tuple[str, bool]]: + x_gts_ref_validator = XGtsRefValidator(enforce_existence=False) for subschema, _path in iter_schema_nodes(schema): ref_uri = subschema.get("$ref") if isinstance(ref_uri, str): @@ -788,7 +789,9 @@ def _schema_dependencies(self, schema: Any) -> Iterator[tuple[str, bool]]: if not ref.is_local and ref.is_gts and ref.has_scheme: yield ref.target_id, True - x_gts_ref = subschema.get("x-gts-ref") + x_gts_ref = x_gts_ref_validator.resolve_ref_pattern( + subschema.get("x-gts-ref"), schema + ) if ( isinstance(x_gts_ref, str) and x_gts_ref.startswith("gts.") diff --git a/gts/src/gts/x_gts_ref.py b/gts/src/gts/x_gts_ref.py index 6738268..2aadaac 100644 --- a/gts/src/gts/x_gts_ref.py +++ b/gts/src/gts/x_gts_ref.py @@ -249,20 +249,26 @@ def validate_schema( return errors def validate_schema_ref_existence( - self, schema: Any, schema_path: str = "" + self, + schema: Any, + schema_path: str = "", + root_schema: dict[str, Any] | None = None, ) -> list[XGtsRefValidationError]: if self.store is None or not self.enforce_existence: return [] + if root_schema is None: + root_schema = schema store = self.store errors: list[XGtsRefValidationError] = [] for subschema, path in iter_schema_nodes(schema, schema_path): ref_pattern = subschema.get("x-gts-ref") + resolved_pattern = self.resolve_ref_pattern(ref_pattern, root_schema) if ( - not isinstance(ref_pattern, str) - or not ref_pattern.startswith(GTS_PREFIX) - or "*" in ref_pattern - or store.get(ref_pattern) is not None + not isinstance(resolved_pattern, str) + or not resolved_pattern.startswith(GTS_PREFIX) + or "*" in resolved_pattern + or store.get(resolved_pattern) is not None ): continue ref_path = f"{path}/x-gts-ref" if path else "x-gts-ref" @@ -270,12 +276,21 @@ def validate_schema_ref_existence( XGtsRefValidationError( ref_path, ref_pattern, - ref_pattern, - f"x-gts-ref constraint type '{ref_pattern}' is not registered", + resolved_pattern, + f"x-gts-ref constraint type '{resolved_pattern}' is not registered", ) ) return errors + def resolve_ref_pattern( + self, ref_pattern: Any, root_schema: dict[str, Any] + ) -> str | None: + if not isinstance(ref_pattern, str): + return None + if ref_pattern.startswith("/"): + return self._resolve_pointer(root_schema, ref_pattern) + return strip_scheme(ref_pattern) + def _validate_ref_value( self, value: str, ref_pattern: str, field_path: str, schema: dict[str, Any] ) -> XGtsRefValidationError | None: @@ -301,7 +316,7 @@ def _validate_ref_value( # Resolve pattern if it's a relative reference if ref_pattern.startswith("/"): - resolved_pattern = self._resolve_pointer(schema, ref_pattern) + resolved_pattern = self.resolve_ref_pattern(ref_pattern, schema) if resolved_pattern is None: return XGtsRefValidationError( field_path, diff --git a/tests/test_store_extra.py b/tests/test_store_extra.py index 5246839..7382384 100644 --- a/tests/test_store_extra.py +++ b/tests/test_store_extra.py @@ -179,6 +179,16 @@ def test_finds_constraint_under_property_named_x_gts_ref(self): ) ) == [("gts.x.test._.missing.v1~", True)] + def test_resolves_relative_x_gts_ref_dependency(self): + store = GtsStore(reader=None) + schema = { + "target": "gts.x.test._.target.v1~", + "properties": {"ref": {"x-gts-ref": "/target"}}, + } + assert list(store._schema_dependencies(schema)) == [ + ("gts.x.test._.target.v1~", True) + ] + class TestValidateGtsKeywords: def test_final_must_be_bool(self): diff --git a/tests/test_x_gts_ref.py b/tests/test_x_gts_ref.py index 08fa123..610efa6 100644 --- a/tests/test_x_gts_ref.py +++ b/tests/test_x_gts_ref.py @@ -113,7 +113,10 @@ def get(self, value): assert len(errors) == 1 assert errors[0].field_path == "properties/ref/x-gts-ref" - assert "constraint type 'gts.x.test._.foo.v1~' is not registered" in errors[0].reason + assert ( + "constraint type 'gts.x.test._.foo.v1~' is not registered" + in errors[0].reason + ) def test_registered_and_wildcard_constraints_pass(self): class FakeStore: @@ -142,6 +145,38 @@ def get(self, value): ) assert errors == [] + def test_relative_constraint_type_must_exist(self): + class FakeStore: + def get(self, value): + return None + + schema = { + "target": "gts.x.test._.missing.v1~", + "properties": {"ref": {"x-gts-ref": "/target"}}, + } + errors = XGtsRefValidator(store=FakeStore()).validate_schema_ref_existence( + schema + ) + assert len(errors) == 1 + assert ( + "constraint type 'gts.x.test._.missing.v1~' is not registered" + in errors[0].reason + ) + + def test_relative_constraint_type_can_resolve(self): + class FakeStore: + def get(self, value): + return object() if value == "gts.x.test._.target.v1~" else None + + schema = { + "target": "gts.x.test._.target.v1~", + "properties": {"ref": {"x-gts-ref": "/target"}}, + } + errors = XGtsRefValidator(store=FakeStore()).validate_schema_ref_existence( + schema + ) + assert errors == [] + class TestValidateInstanceValue: def test_non_string_instance_value_error(self): @@ -218,9 +253,7 @@ def test_array_items_recursion(self): "type": "array", "items": {"x-gts-ref": "gts.x.test.*"}, } - errors = XGtsRefValidator().validate_instance( - ["gts.x.other.v1~"], schema - ) + errors = XGtsRefValidator().validate_instance(["gts.x.other.v1~"], schema) assert len(errors) == 1 def test_object_properties_recursion(self): From 0d51d0f093ee0c854d41f48037e19dff3a2835b9 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Fri, 18 Sep 2026 02:12:33 +0300 Subject: [PATCH 11/16] fix(validation): traverse Draft 3 schema forms Visit schema-valued extends, type, and disallow forms in the shared schema walkers while preserving scalar forms. Resolve relative trait references before extracting subschemas so validation retains the host document context. Signed-off-by: Artifizer --- gts/src/gts/schema_validation.py | 18 ++++++++++++++++++ gts/src/gts/store.py | 6 +++++- gts/src/gts/x_gts_ref.py | 16 ++++++++++++++++ tests/test_traits.py | 31 +++++++++++++++++++++++++++++-- tests/test_x_gts_ref.py | 28 ++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 3 deletions(-) diff --git a/gts/src/gts/schema_validation.py b/gts/src/gts/schema_validation.py index 9b818b3..9496e8a 100644 --- a/gts/src/gts/schema_validation.py +++ b/gts/src/gts/schema_validation.py @@ -18,6 +18,7 @@ "properties", } _SCHEMA_ARRAY_KEYWORDS = {"allOf", "anyOf", "oneOf", "prefixItems"} +_DRAFT3_SCHEMA_KEYWORDS = {"disallow", "extends", "type"} _SCHEMA_SINGLE_KEYWORDS = { "additionalItems", "additionalProperties", @@ -50,6 +51,13 @@ def iter_schema_nodes( yield from iter_schema_nodes(child, f"{keyword_path}[{index}]") elif keyword in _SCHEMA_SINGLE_KEYWORDS: yield from iter_schema_nodes(value, keyword_path) + elif keyword in _DRAFT3_SCHEMA_KEYWORDS: + if isinstance(value, dict): + yield from iter_schema_nodes(value, keyword_path) + elif isinstance(value, list): + for index, child in enumerate(value): + if isinstance(child, dict): + yield from iter_schema_nodes(child, f"{keyword_path}[{index}]") elif keyword == "items": if isinstance(value, list): for index, child in enumerate(value): @@ -76,6 +84,16 @@ def map_schema_nodes(schema: Any, transform: Callable[[Any], Any]) -> Any: mapped[keyword] = [map_schema_nodes(child, transform) for child in value] elif keyword in _SCHEMA_SINGLE_KEYWORDS: mapped[keyword] = map_schema_nodes(value, transform) + elif keyword in _DRAFT3_SCHEMA_KEYWORDS: + if isinstance(value, dict): + mapped[keyword] = map_schema_nodes(value, transform) + elif isinstance(value, list): + mapped[keyword] = [ + map_schema_nodes(child, transform) + if isinstance(child, dict) + else copy.deepcopy(child) + for child in value + ] elif keyword == "items": if isinstance(value, list): mapped[keyword] = [ diff --git a/gts/src/gts/store.py b/gts/src/gts/store.py index 626095f..cd035c3 100644 --- a/gts/src/gts/store.py +++ b/gts/src/gts/store.py @@ -563,6 +563,7 @@ def _build_effective_traits( trait_schemas: list[Any] = [] merged_traits: dict[str, Any] = {} + x_gts_ref_validator = XGtsRefValidator(enforce_existence=False) for schema_id in chain_ids: entity = self.get(schema_id) @@ -582,7 +583,10 @@ def _build_effective_traits( # Inline local JSON Pointer refs against the host document, then # resolve any gts:// refs so the composed schema is self-contained. inlined = traits.inline_local_pointers(ts, content) - trait_schemas.append(self._resolve_schema_refs(inlined)) + resolved_patterns = x_gts_ref_validator.resolve_schema_ref_patterns( + inlined, content + ) + trait_schemas.append(self._resolve_schema_refs(resolved_patterns)) level_traits: dict[str, Any] = {} traits.collect_traits_from_value(content, level_traits) diff --git a/gts/src/gts/x_gts_ref.py b/gts/src/gts/x_gts_ref.py index 2aadaac..b37171f 100644 --- a/gts/src/gts/x_gts_ref.py +++ b/gts/src/gts/x_gts_ref.py @@ -291,6 +291,22 @@ def resolve_ref_pattern( return self._resolve_pointer(root_schema, ref_pattern) return strip_scheme(ref_pattern) + def resolve_schema_ref_patterns( + self, schema: Any, root_schema: dict[str, Any] + ) -> Any: + def resolve(node: Any) -> Any: + if not isinstance(node, dict): + return node + ref_pattern = node.get("x-gts-ref") + if not isinstance(ref_pattern, str) or not ref_pattern.startswith("/"): + return node + resolved_pattern = self.resolve_ref_pattern(ref_pattern, root_schema) + if resolved_pattern is not None: + node["x-gts-ref"] = resolved_pattern + return node + + return map_schema_nodes(schema, resolve) + def _validate_ref_value( self, value: str, ref_pattern: str, field_path: str, schema: dict[str, Any] ) -> XGtsRefValidationError | None: diff --git a/tests/test_traits.py b/tests/test_traits.py index 7f37b5c..3b8a6da 100644 --- a/tests/test_traits.py +++ b/tests/test_traits.py @@ -1,6 +1,7 @@ """Tests for gts.traits (OP#13 schema traits validation).""" from gts._json_pointer import resolve +from gts.schema_validation import map_schema_nodes from gts.traits import ( build_effective_traits, build_effective_traits_schema, @@ -11,6 +12,26 @@ ) +class TestSchemaTraversal: + def test_maps_draft3_schema_forms_only(self): + schema = { + "extends": {"required": ["extended"]}, + "type": ["object", {"required": ["typed"]}], + "disallow": ["array", {"required": ["disallowed"]}], + "const": {"required": ["data"]}, + } + mapped = map_schema_nodes( + schema, + lambda node: {k: v for k, v in node.items() if k != "required"}, + ) + assert mapped == { + "extends": {}, + "type": ["object", {}], + "disallow": ["array", {}], + "const": {"required": ["data"]}, + } + + class TestCollection: def test_collect_trait_schema_from_value_direct(self): out = [] @@ -269,7 +290,10 @@ def get(self, value): errors = build_effective_traits([schema], {}, None).validate( check_unresolved=False, reference_store=FakeStore() ) - assert any("constraint type 'gts.x.test._.foo.v1~' is not registered" in e for e in errors) + assert any( + "constraint type 'gts.x.test._.foo.v1~' is not registered" in e + for e in errors + ) def test_incompatible_trait_schema_chain_flagged(self): # Second schema narrows type incompatibly with the ancestor. @@ -288,7 +312,10 @@ def test_dialect_applied_to_effective_schema(self): effective = build_effective_traits( [{"type": "object"}], {}, "https://json-schema.org/draft/2020-12/schema" ) - assert effective.schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" + assert ( + effective.schema["$schema"] + == "https://json-schema.org/draft/2020-12/schema" + ) def test_x_gts_ref_errors_prefixed(self): schema = { diff --git a/tests/test_x_gts_ref.py b/tests/test_x_gts_ref.py index 610efa6..dee7351 100644 --- a/tests/test_x_gts_ref.py +++ b/tests/test_x_gts_ref.py @@ -100,6 +100,34 @@ def test_property_named_x_gts_ref_is_not_a_keyword(self): assert len(errors) == 1 assert errors[0].field_path == "properties/x-gts-ref/x-gts-ref" + def test_recurses_into_draft3_schema_forms(self): + schema = { + "$schema": "http://json-schema.org/draft-03/schema#", + "extends": {"x-gts-ref": "invalid-extends"}, + "type": ["object", {"x-gts-ref": "invalid-type"}], + "disallow": ["array", {"x-gts-ref": "invalid-disallow"}], + } + errors = XGtsRefValidator().validate_schema(schema) + assert [error.field_path for error in errors] == [ + "extends/x-gts-ref", + "type[1]/x-gts-ref", + "disallow[1]/x-gts-ref", + ] + + def test_resolves_relative_patterns_before_extracting_subschema(self): + root = { + "x-gts-traits-schema": { + "constraintType": "gts.x.test._.target.v1~", + "properties": { + "ref": {"x-gts-ref": "/x-gts-traits-schema/constraintType"} + }, + } + } + resolved = XGtsRefValidator().resolve_schema_ref_patterns( + root["x-gts-traits-schema"], root + ) + assert resolved["properties"]["ref"]["x-gts-ref"] == ("gts.x.test._.target.v1~") + class TestValidateSchemaRefExistence: def test_missing_concrete_constraint_type_fails(self): From 4ce41ea12a77ef9c827bca3433fdda893d237705 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Fri, 18 Sep 2026 13:01:32 +0300 Subject: [PATCH 12/16] fix(validation): defer relative refs in bulk registration Allow bulk registration to store schemas whose relative x-gts-ref pointers require explicit validation, while keeping single registration strict. Recognize the validation query alias so callers can request eager semantic checks consistently. Signed-off-by: Artifizer --- gts/src/gts/_server.py | 5 ++++- gts/src/gts/ops.py | 11 +++++++--- gts/src/gts/store.py | 21 +++++++++++++------ gts/src/gts/x_gts_ref.py | 8 ++++++++ tests/test_ops.py | 10 +++++++++ tests/test_server.py | 9 ++++++++ tests/test_store_extra.py | 43 +++++++++++++++++++++++---------------- 7 files changed, 80 insertions(+), 27 deletions(-) diff --git a/gts/src/gts/_server.py b/gts/src/gts/_server.py index b55d31f..d111056 100644 --- a/gts/src/gts/_server.py +++ b/gts/src/gts/_server.py @@ -345,8 +345,11 @@ async def add_entity( self, body: dict[str, Any] = Body(...), # noqa: B008 - FastAPI dependency pattern validate: bool = Query(False), + validation: bool = Query(False), ) -> JSONResponse: - result = self.ops.add_entity(body, validate=validate) + result = self.ops.add_entity( + body, validate=validate is True or validation is True + ) status_code = 200 if result.ok else 409 if result.conflict else 422 return JSONResponse(result.to_dict(), status_code=status_code) diff --git a/gts/src/gts/ops.py b/gts/src/gts/ops.py index a8b90ca..eb9013b 100644 --- a/gts/src/gts/ops.py +++ b/gts/src/gts/ops.py @@ -384,7 +384,10 @@ def reload_from_path(self, path: str | builtins.list[str]) -> None: self.store = GtsStore(self._reader) def add_entity( - self, content: dict[str, Any], validate: bool = False + self, + content: dict[str, Any], + validate: bool = False, + resolve_relative: bool = True, ) -> GtsAddEntityResult: entity = GtsEntity(content=content, cfg=self.cfg) @@ -427,7 +430,9 @@ def add_entity( try: if entity.is_schema: - self.store.validate_schema_basic(entity.gts_id.id) + self.store.validate_schema_basic( + entity.gts_id.id, resolve_relative=resolve_relative + ) if validate: self.store.validate_schema(entity.gts_id.id) elif validate: @@ -456,7 +461,7 @@ def add_entities( ) -> GtsAddEntitiesResult: results: list[GtsAddEntityResult] = [] for it in items: - results.append(self.add_entity(it)) + results.append(self.add_entity(it, resolve_relative=False)) ok = all(r.ok for r in results) return GtsAddEntitiesResult(ok=ok, results=results) diff --git a/gts/src/gts/store.py b/gts/src/gts/store.py index cd035c3..dd39abb 100644 --- a/gts/src/gts/store.py +++ b/gts/src/gts/store.py @@ -327,7 +327,9 @@ def _validate_schema_ref_targets( for index, item in enumerate(schema): self._validate_schema_ref_targets(item, f"{path}[{index}]", visited) - def _validate_schema_x_gts_refs(self, gts_id: str) -> None: + def _validate_schema_x_gts_refs( + self, gts_id: str, resolve_relative: bool = True + ) -> None: """ Validate a schema's x-gts-ref fields. @@ -342,16 +344,23 @@ def _validate_schema_x_gts_refs(self, gts_id: str) -> None: if not schema_entity.is_schema: raise ValueError(f"Entity '{schema_id.id}' is not a schema") - self._validate_schema_x_gts_refs_content(schema_id.id, schema_entity.content) + self._validate_schema_x_gts_refs_content( + schema_id.id, schema_entity.content, resolve_relative=resolve_relative + ) def _validate_schema_x_gts_refs_content( - self, gts_id: str, schema_content: dict[str, Any] + self, + gts_id: str, + schema_content: dict[str, Any], + resolve_relative: bool = True, ) -> None: logger.info(f"Validating schema x-gts-ref fields for {gts_id}") # Validate x-gts-ref constraints in the schema x_gts_ref_validator = XGtsRefValidator(store=self) - x_gts_ref_errors = x_gts_ref_validator.validate_schema(schema_content) + x_gts_ref_errors = x_gts_ref_validator.validate_schema( + schema_content, resolve_relative=resolve_relative + ) if x_gts_ref_errors: error_messages = [ f"{err.field_path}: {err.reason}" for err in x_gts_ref_errors @@ -624,7 +633,7 @@ def _validate_traits( f"Schema '{gts_id}' trait validation failed: " + "; ".join(errors) ) - def validate_schema_basic(self, gts_id: str) -> None: + def validate_schema_basic(self, gts_id: str, resolve_relative: bool = True) -> None: """Basic schema validation during registration (no chain validation). Checks: @@ -661,7 +670,7 @@ def validate_schema_basic(self, gts_id: str) -> None: self._validate_schema_refs(schema_content, "") # 2. Validate x-gts-ref fields - self._validate_schema_x_gts_refs(gts_id) + self._validate_schema_x_gts_refs(gts_id, resolve_relative=resolve_relative) # 3. Validate GTS keywords (x-gts-final, x-gts-abstract, placement) self._validate_gts_keywords(schema_content) diff --git a/gts/src/gts/x_gts_ref.py b/gts/src/gts/x_gts_ref.py index b37171f..331a086 100644 --- a/gts/src/gts/x_gts_ref.py +++ b/gts/src/gts/x_gts_ref.py @@ -222,6 +222,7 @@ def validate_schema( schema: dict[str, Any], schema_path: str = "", root_schema: dict[str, Any] | None = None, + resolve_relative: bool = True, ) -> list[XGtsRefValidationError]: """ Validate x-gts-ref fields in a schema definition. @@ -230,6 +231,7 @@ def validate_schema( schema: The JSON schema to validate schema_path: Current path in schema (for error reporting) root_schema: The root schema (for resolving relative refs) + resolve_relative: Whether relative refs must resolve during this check Returns: List of validation errors (empty if valid) @@ -242,6 +244,12 @@ def validate_schema( if "x-gts-ref" not in subschema: continue ref_value = subschema["x-gts-ref"] + if ( + not resolve_relative + and isinstance(ref_value, str) + and ref_value.startswith("/") + ): + continue ref_path = f"{path}/x-gts-ref" if path else "x-gts-ref" error = self._validate_ref_pattern(ref_value, ref_path, root_schema) if error: diff --git a/tests/test_ops.py b/tests/test_ops.py index 0db6446..497acf4 100644 --- a/tests/test_ops.py +++ b/tests/test_ops.py @@ -144,6 +144,16 @@ def test_add_entities_batch(self, ops): assert result.ok is True assert len(result.results) == 2 + def test_add_entities_defers_relative_pointer_resolution(self, ops): + schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.test._.relative.v1~", + "properties": {"ref": {"x-gts-ref": "/missing"}}, + } + result = ops.add_entities([schema]) + assert result.ok is True + assert ops.validate_schema("gts.x.test._.relative.v1~").ok is False + class TestAddSchemaLegacy: def test_add_schema_legacy_success(self, ops): diff --git a/tests/test_server.py b/tests/test_server.py index 3d99c3f..d6a6856 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -75,6 +75,15 @@ def test_add_entity_failure(self, server): resp = run(server.add_entity(body={"no": "id"}, validate=False)) assert resp.status_code == 422 + def test_add_entity_validation_alias(self, server): + schema = { + **SCHEMA, + "$id": "gts://gts.x.test._.relative.v1~", + "properties": {"ref": {"x-gts-ref": "/missing"}}, + } + resp = run(server.add_entity(body=schema, validate=False, validation=True)) + assert resp.status_code == 422 + def test_add_changed_entity_conflict(self, server): assert run(server.add_entity(body=SCHEMA, validate=False)).status_code == 200 changed_schema = {**SCHEMA, "properties": {"name": {"type": "integer"}}} diff --git a/tests/test_store_extra.py b/tests/test_store_extra.py index 7382384..53e2f8a 100644 --- a/tests/test_store_extra.py +++ b/tests/test_store_extra.py @@ -147,35 +147,27 @@ def test_missing_derived_gts_ref_target_raises(self): store = GtsStore(MockGtsReader([target])) with pytest.raises(ValueError, match="Unresolvable \\$ref"): store._validate_schema_ref_targets( - { - "$ref": ( - "gts://gts.x.test._.target.v1~" - "x.test._.missing.v1~" - ) - } + {"$ref": ("gts://gts.x.test._.target.v1~x.test._.missing.v1~")} ) class TestSchemaDependencies: def test_ignores_x_gts_ref_in_annotation_data(self): store = GtsStore(reader=None) - assert list( - store._schema_dependencies( - {"const": {"x-gts-ref": "gts.x.test._.missing.v1~"}} + assert ( + list( + store._schema_dependencies( + {"const": {"x-gts-ref": "gts.x.test._.missing.v1~"}} + ) ) - ) == [] + == [] + ) def test_finds_constraint_under_property_named_x_gts_ref(self): store = GtsStore(reader=None) assert list( store._schema_dependencies( - { - "properties": { - "x-gts-ref": { - "x-gts-ref": "gts.x.test._.missing.v1~" - } - } - } + {"properties": {"x-gts-ref": {"x-gts-ref": "gts.x.test._.missing.v1~"}}} ) ) == [("gts.x.test._.missing.v1~", True)] @@ -256,6 +248,23 @@ def test_invalid_x_gts_ref_raises(self): with pytest.raises(Exception, match="x-gts-ref validation failed"): store._validate_schema_x_gts_refs("gts.x.test._.foo.v1~") + def test_basic_validation_can_defer_relative_pointer_resolution(self): + schema = _schema_entity( + "gts.x.test._.foo.v1~", + { + "x-gts-traits-schema": { + "properties": { + "ref": {"x-gts-ref": "/x-gts-traits-schema/missingTarget"} + } + } + }, + ) + store = GtsStore(reader=None) + store.register(schema) + store.validate_schema_basic("gts.x.test._.foo.v1~", resolve_relative=False) + with pytest.raises(Exception, match="x-gts-ref validation failed"): + store.validate_schema_basic("gts.x.test._.foo.v1~") + class TestValidateSchemaChain: def test_single_segment_no_parent_ok(self): From b97aa270fdc89c3aedaa6a19b750921bb590f750 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Fri, 18 Sep 2026 13:55:23 +0300 Subject: [PATCH 13/16] fix(validation): traverse array tuple schemas Apply x-gts-ref constraints at the correct indices for Draft-07 tuple items and Draft 2020-12 prefixItems schemas. Signed-off-by: Artifizer --- gts/src/gts/x_gts_ref.py | 28 ++++++++++++++++++++++++---- tests/test_x_gts_ref.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/gts/src/gts/x_gts_ref.py b/gts/src/gts/x_gts_ref.py index 331a086..c9dbf17 100644 --- a/gts/src/gts/x_gts_ref.py +++ b/gts/src/gts/x_gts_ref.py @@ -204,10 +204,30 @@ def visit_instance(inst, sch, path, errs, refs=None): prop_path = f"{path}.{prop_name}" if path else prop_name visit_instance(inst[prop_name], prop_schema, prop_path, errs) - if "items" in sch and isinstance(inst, list): - for idx, item in enumerate(inst): - item_path = f"{path}[{idx}]" - visit_instance(item, sch["items"], item_path, errs) + if isinstance(inst, list): + prefix_items = sch.get("prefixItems") + items = sch.get("items") + if isinstance(prefix_items, list): + for idx, item_schema in enumerate(prefix_items[: len(inst)]): + item_path = f"{path}[{idx}]" + visit_instance(inst[idx], item_schema, item_path, errs) + if isinstance(items, dict): + for idx in range(len(prefix_items), len(inst)): + item_path = f"{path}[{idx}]" + visit_instance(inst[idx], items, item_path, errs) + elif isinstance(items, list): + for idx, item_schema in enumerate(items[: len(inst)]): + item_path = f"{path}[{idx}]" + visit_instance(inst[idx], item_schema, item_path, errs) + additional_items = sch.get("additionalItems") + if isinstance(additional_items, dict): + for idx in range(len(items), len(inst)): + item_path = f"{path}[{idx}]" + visit_instance(inst[idx], additional_items, item_path, errs) + elif isinstance(items, dict): + for idx, item in enumerate(inst): + item_path = f"{path}[{idx}]" + visit_instance(item, items, item_path, errs) def _validate_branch(inst, branch, path): branch_errors: list[XGtsRefValidationError] = [] diff --git a/tests/test_x_gts_ref.py b/tests/test_x_gts_ref.py index dee7351..b94f2b9 100644 --- a/tests/test_x_gts_ref.py +++ b/tests/test_x_gts_ref.py @@ -284,6 +284,36 @@ def test_array_items_recursion(self): errors = XGtsRefValidator().validate_instance(["gts.x.other.v1~"], schema) assert len(errors) == 1 + def test_tuple_additional_items_recursion(self): + schema = { + "type": "array", + "items": [{"type": "string"}], + "additionalItems": { + "type": "string", + "x-gts-ref": "gts.x.test._.target.v1~", + }, + } + errors = XGtsRefValidator().validate_instance( + ["tuple-prefix", "gts.x.other._.target.v1~"], schema + ) + assert len(errors) == 1 + assert errors[0].field_path == "[1]" + + def test_prefix_items_recursion(self): + schema = { + "type": "array", + "prefixItems": [{"type": "string"}], + "items": { + "type": "string", + "x-gts-ref": "gts.x.test._.target.v1~", + }, + } + errors = XGtsRefValidator().validate_instance( + ["tuple-prefix", "gts.x.other._.target.v1~"], schema + ) + assert len(errors) == 1 + assert errors[0].field_path == "[1]" + def test_object_properties_recursion(self): schema = { "type": "object", From 10a2e7ac0dfb9c1c1233f119e93c16baf4c6fe36 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sat, 19 Sep 2026 00:52:34 +0300 Subject: [PATCH 14/16] feat(x-gts-ref): support configurable validation modes Expose typed syntax-only, presence, and full reference validation through native and HTTP APIs while retaining full validation by default. Signed-off-by: Artifizer --- gts/src/gts/__init__.py | 2 + gts/src/gts/_server.py | 35 +++-- gts/src/gts/gts_ref_validation.py | 7 + gts/src/gts/ops.py | 47 +++++-- gts/src/gts/store.py | 209 ++++++++++++++++++++++-------- gts/src/gts/traits.py | 49 ++++--- gts/src/gts/x_gts_ref.py | 118 +++++++++-------- tests/test_x_gts_ref.py | 3 + 8 files changed, 325 insertions(+), 145 deletions(-) create mode 100644 gts/src/gts/gts_ref_validation.py diff --git a/gts/src/gts/__init__.py b/gts/src/gts/__init__.py index 87f7530..3bc3e95 100644 --- a/gts/src/gts/__init__.py +++ b/gts/src/gts/__init__.py @@ -14,6 +14,7 @@ GtsIdSegment, GtsWildcard, ) +from .gts_ref_validation import GtsRefValidationMode from .path_resolver import GtsPathResolver from .store import ( GtsReader, @@ -30,6 +31,7 @@ "GtsIdSegment", "GtsPathResolver", "GtsReader", + "GtsRefValidationMode", "GtsStore", "GtsWildcard", "JsonEntity", diff --git a/gts/src/gts/_server.py b/gts/src/gts/_server.py index d111056..42275da 100644 --- a/gts/src/gts/_server.py +++ b/gts/src/gts/_server.py @@ -1,9 +1,11 @@ from __future__ import annotations +# ruff: noqa: B008 + import logging import sys import time -from typing import Any +from typing import Annotated, Any from fastapi import Body, FastAPI, Query from fastapi.responses import JSONResponse @@ -11,8 +13,12 @@ from starlette.middleware.base import BaseHTTPMiddleware from .ops import GtsOps +from .gts_ref_validation import GtsRefValidationMode logger = logging.getLogger(__name__) +GTS_REF_VALIDATION_QUERY = Query( + GtsGtsRefValidationMode.FULL, alias="gts-ref-validation" +) # ANSI color codes @@ -346,9 +352,12 @@ async def add_entity( body: dict[str, Any] = Body(...), # noqa: B008 - FastAPI dependency pattern validate: bool = Query(False), validation: bool = Query(False), + gts_ref_validation: GtsRefValidationMode = GTS_REF_VALIDATION_QUERY, ) -> JSONResponse: result = self.ops.add_entity( - body, validate=validate is True or validation is True + body, + validate=validate is True or validation is True, + gts_ref_validation=gts_ref_validation, ) status_code = 200 if result.ok else 409 if result.conflict else 422 return JSONResponse(result.to_dict(), status_code=status_code) @@ -387,8 +396,12 @@ async def match_id_pattern( async def id_to_uuid(self, id: str = Query(..., alias="gts_id")) -> dict[str, Any]: return self.ops.uuid(id).to_dict() - async def validate_instance(self, body: ValidateInstanceRequest) -> dict[str, Any]: - return self.ops.validate_instance(body.instance_id).to_dict() + async def validate_instance( + self, + body: ValidateInstanceRequest, + gts_ref_validation: GtsRefValidationMode = GTS_REF_VALIDATION_QUERY, + ) -> dict[str, Any]: + return self.ops.validate_instance(body.instance_id, gts_ref_validation).to_dict() async def validate_json( self, @@ -404,12 +417,18 @@ async def validate_json_as_type( return self.ops.validate_json(body, explicit_type_id=gts_type).to_dict() async def validate_type_schema( - self, body: ValidateTypeSchemaRequest + self, + body: ValidateTypeSchemaRequest, + gts_ref_validation: GtsRefValidationMode = GTS_REF_VALIDATION_QUERY, ) -> dict[str, Any]: - return self.ops.validate_schema(body.type_id).to_dict() + return self.ops.validate_schema(body.type_id, gts_ref_validation).to_dict() - async def validate_entity(self, body: ValidateEntityRequest) -> dict[str, Any]: - return self.ops.validate_entity(body.resolved_id).to_dict() + async def validate_entity( + self, + body: ValidateEntityRequest, + gts_ref_validation: GtsRefValidationMode = GTS_REF_VALIDATION_QUERY, + ) -> dict[str, Any]: + return self.ops.validate_entity(body.resolved_id, gts_ref_validation).to_dict() async def schema_graph( self, id: str = Query(..., alias="gts_id") diff --git a/gts/src/gts/gts_ref_validation.py b/gts/src/gts/gts_ref_validation.py new file mode 100644 index 0000000..67c0b25 --- /dev/null +++ b/gts/src/gts/gts_ref_validation.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class GtsRefValidationMode(str, Enum): + NONE = "none" + PRESENCE = "presence" + FULL = "full" diff --git a/gts/src/gts/ops.py b/gts/src/gts/ops.py index eb9013b..9d871f3 100644 --- a/gts/src/gts/ops.py +++ b/gts/src/gts/ops.py @@ -10,6 +10,7 @@ from .entities import DEFAULT_GTS_CONFIG, GtsConfig, GtsEntity from .files_reader import GtsFileReader from .gts import GtsID, GtsWildcard +from .gts_ref_validation import GtsRefValidationMode from .path_resolver import GtsPathResolver from .schema_cast import GtsEntityCastResult from .store import GtsStore, GtsStoreQueryResult @@ -17,6 +18,15 @@ # Interface helpers +def _normalize_gts_ref_validation(value: Any) -> GtsRefValidationMode: + if isinstance(value, GtsRefValidationMode): + return value + try: + return GtsRefValidationMode(value) + except (TypeError, ValueError): + return GtsRefValidationMode.FULL + + @dataclass class GtsIdValidationResult: """Result of validating a GTS ID format.""" @@ -387,8 +397,10 @@ def add_entity( self, content: dict[str, Any], validate: bool = False, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, resolve_relative: bool = True, ) -> GtsAddEntityResult: + gts_ref_validation = _normalize_gts_ref_validation(gts_ref_validation) entity = GtsEntity(content=content, cfg=self.cfg) # For instances (non-schemas), require an id field from entity_id_fields @@ -434,9 +446,11 @@ def add_entity( entity.gts_id.id, resolve_relative=resolve_relative ) if validate: - self.store.validate_schema(entity.gts_id.id) + self.store.validate_schema(entity.gts_id.id, gts_ref_validation) elif validate: - self.store.validate_instance(entity.raw_id or entity.gts_id.id) + self.store.validate_instance( + entity.raw_id or entity.gts_id.id, gts_ref_validation + ) except Exception as e: # noqa: BLE001 - converted to a result object at API boundary self.store.unregister(store_key) if previous: @@ -657,21 +671,36 @@ def validate_json( is_type_schema=entity.is_schema, ) - def validate_instance(self, gts_id: str) -> GtsValidationResult: + def validate_instance( + self, + gts_id: str, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, + ) -> GtsValidationResult: + gts_ref_validation = _normalize_gts_ref_validation(gts_ref_validation) try: - self.store.validate_instance(gts_id) + self.store.validate_instance(gts_id, gts_ref_validation) return GtsValidationResult(id=gts_id, ok=True) except Exception as e: # noqa: BLE001 - converted to a result object at API boundary return GtsValidationResult(id=gts_id, ok=False, error=str(e)) - def validate_schema(self, gts_id: str) -> GtsValidationResult: + def validate_schema( + self, + gts_id: str, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, + ) -> GtsValidationResult: + gts_ref_validation = _normalize_gts_ref_validation(gts_ref_validation) try: - self.store.validate_schema(gts_id) + self.store.validate_schema(gts_id, gts_ref_validation) return GtsValidationResult(id=gts_id, ok=True) except Exception as e: # noqa: BLE001 - converted to a result object at API boundary return GtsValidationResult(id=gts_id, ok=False, error=str(e)) - def validate_entity(self, gts_id: str) -> GtsEntityValidationResult: + def validate_entity( + self, + gts_id: str, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, + ) -> GtsEntityValidationResult: + gts_ref_validation = _normalize_gts_ref_validation(gts_ref_validation) entity = self.store.get(gts_id) if entity: entity_type = "schema" if entity.is_schema else "instance" @@ -685,9 +714,9 @@ def validate_entity(self, gts_id: str) -> GtsEntityValidationResult: ) if entity_type == "schema": - result = self.validate_schema(gts_id) + result = self.validate_schema(gts_id, gts_ref_validation) else: - result = self.validate_instance(gts_id) + result = self.validate_instance(gts_id, gts_ref_validation) return GtsEntityValidationResult( id=result.id, ok=result.ok, entity_type=entity_type, error=result.error diff --git a/gts/src/gts/store.py b/gts/src/gts/store.py index dd39abb..d09ca18 100644 --- a/gts/src/gts/store.py +++ b/gts/src/gts/store.py @@ -14,6 +14,7 @@ from ._naming import looks_like_gts, strip_scheme, with_scheme from .entities import GtsEntity from .gts import GtsID, GtsRef, GtsWildcard +from .gts_ref_validation import GtsRefValidationMode from .schema_cast import GtsEntityCastResult from .schema_validation import FORMAT_CHECKER, iter_schema_nodes, validator_for from .x_gts_ref import XGtsRefValidator, _without_x_gts_ref @@ -198,7 +199,7 @@ def get(self, entity_id: str) -> GtsEntity | None: def get_schema_content(self, type_id: str) -> dict[str, Any]: """Get schema content as dict (legacy method for backward compatibility).""" entity = self.get(type_id) - if entity and isinstance(entity.content, dict): + if entity and entity.is_schema and isinstance(entity.content, dict): return entity.content raise KeyError(f"Schema not found: {type_id}") @@ -305,20 +306,15 @@ def _validate_schema_ref_targets( ref = GtsRef.parse(ref_uri) if not ref.is_local and ref.is_gts and ref.has_scheme: current_path = f"{path}.$ref" if path else "$ref" - target = self.get(ref.target_id) - if ( - target is None - or not target.is_schema - or not isinstance(target.content, dict) - ): + try: + target = self.get_schema_content(ref.target_id) + except KeyError as error: raise ValueError( f"Unresolvable $ref at '{current_path}': '{ref_uri}'" - ) + ) from error if ref.target_id not in visited: visited.add(ref.target_id) - self._validate_schema_ref_targets( - target.content, current_path, visited - ) + self._validate_schema_ref_targets(target, current_path, visited) for key, value in schema.items(): if key != "$ref": nested_path = f"{path}.{key}" if path else key @@ -401,12 +397,19 @@ def _validate_gts_keywords(content: dict[str, Any]) -> None: "schema cannot declare both x-gts-final and x-gts-abstract as true" ) - for subschema, path in iter_schema_nodes(content): - for key in subschema: + for schema_node, _ in iter_schema_nodes(content): + for key in schema_node: if key.startswith("x-gts-") and key not in supported_keywords: raise ValueError(f"Unsupported GTS extension keyword: {key}") - if path and key in top_level_keywords: - raise ValueError(f"{key} must be at the schema top level") + + # Check that x-gts-final/x-gts-abstract/x-gts-traits/x-gts-traits-schema + # appear only at the top level. + for schema_node, path in iter_schema_nodes(content): + if not path: + continue + for keyword in top_level_keywords: + if keyword in schema_node: + raise ValueError(f"{keyword} must be at the schema top level") @staticmethod def _content_is_abstract(content: dict[str, Any]) -> bool: @@ -572,7 +575,6 @@ def _build_effective_traits( trait_schemas: list[Any] = [] merged_traits: dict[str, Any] = {} - x_gts_ref_validator = XGtsRefValidator(enforce_existence=False) for schema_id in chain_ids: entity = self.get(schema_id) @@ -592,10 +594,7 @@ def _build_effective_traits( # Inline local JSON Pointer refs against the host document, then # resolve any gts:// refs so the composed schema is self-contained. inlined = traits.inline_local_pointers(ts, content) - resolved_patterns = x_gts_ref_validator.resolve_schema_ref_patterns( - inlined, content - ) - trait_schemas.append(self._resolve_schema_refs(resolved_patterns)) + trait_schemas.append(self._resolve_schema_refs(inlined)) level_traits: dict[str, Any] = {} traits.collect_traits_from_value(content, level_traits) @@ -622,11 +621,14 @@ def _validate_traits( gts_id: str, is_abstract: bool, transient_schema: dict[str, Any] | None = None, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, ) -> None: """Validate OP#13: schema traits for a type.""" effective = self._build_effective_traits(gts_id, transient_schema) errors = effective.validate( - check_unresolved=not is_abstract, reference_store=self + check_unresolved=not is_abstract, + reference_store=self, + gts_ref_validation=gts_ref_validation, ) if errors: raise ValueError( @@ -676,7 +678,10 @@ def validate_schema_basic(self, gts_id: str, resolve_relative: bool = True) -> N self._validate_gts_keywords(schema_content) def validate_schema_content( - self, gts_id: str, schema_content: dict[str, Any] + self, + gts_id: str, + schema_content: dict[str, Any], + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, ) -> None: """Validate a schema using the registry only for its dependencies.""" schema_id = _require_schema_id(gts_id) @@ -719,14 +724,23 @@ def validate_schema_content( schema_id.id, self._content_is_abstract(schema_content), schema_content, + gts_ref_validation, ) - def validate_schema(self, gts_id: str) -> None: + def validate_schema( + self, + gts_id: str, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, + ) -> None: """Validate a registered schema and all of its dependencies.""" - self._validate_schema_transitive(gts_id, set(), set()) + self._validate_schema_transitive(gts_id, set(), set(), gts_ref_validation) def _validate_schema_transitive( - self, gts_id: str, visiting: set[str], validated: set[str] + self, + gts_id: str, + visiting: set[str], + validated: set[str], + gts_ref_validation: GtsRefValidationMode, ) -> None: schema_id = _require_schema_id(gts_id) key = f"schema:{schema_id.id}" @@ -745,20 +759,50 @@ def _validate_schema_transitive( visiting.add(key) try: - self.validate_schema_content(schema_id.id, schema_entity.content) + self.validate_schema_content( + schema_id.id, schema_entity.content, gts_ref_validation + ) + + schema_ref_validator = XGtsRefValidator(store=self, mode=gts_ref_validation) + schema_ref_errors = schema_ref_validator.validate_schema_ref_existence( + schema_entity.content + ) + if schema_ref_errors: + raise ValueError( + "x-gts-ref validation failed: " + + "; ".join(error.reason for error in schema_ref_errors) + ) effective_traits = self._build_effective_traits(schema_id.id) - trait_ref_validator = XGtsRefValidator(store=self) + trait_ref_validator = XGtsRefValidator(store=self, mode=gts_ref_validation) + trait_ref_validator.validate_schema_ref_existence(effective_traits.schema) trait_ref_validator.validate_instance( effective_traits.values, effective_traits.schema ) - for dependency_id in trait_ref_validator.referenced_ids: - try: - self._validate_entity_transitive(dependency_id, visiting, validated) - except Exception as error: - raise ValueError( - f"Referenced trait entity '{dependency_id}' is invalid: {error}" - ) from error + xref_ids = ( + schema_ref_validator.referenced_ids | trait_ref_validator.referenced_ids + ) + wildcard_patterns = ( + schema_ref_validator.referenced_wildcard_patterns + | trait_ref_validator.referenced_wildcard_patterns + ) + if gts_ref_validation is GtsRefValidationMode.FULL: + for dependency_id in xref_ids: + try: + self._validate_entity_transitive( + dependency_id, visiting, validated, gts_ref_validation + ) + except Exception as error: + raise ValueError( + f"Referenced x-gts-ref entity '{dependency_id}' is invalid: {error}" + ) from error + for pattern in wildcard_patterns: + if not self._has_valid_wildcard_match( + pattern, visiting, validated, gts_ref_validation + ): + raise ValueError( + f"x-gts-ref wildcard constraint '{pattern}' has no valid registered match" + ) chain_ids: list[str] = [] prefix = "gts." @@ -767,23 +811,25 @@ def _validate_schema_transitive( prefix += segment.segment for ancestor_id in chain_ids[:-1]: try: - self._validate_schema_transitive(ancestor_id, visiting, validated) + self._validate_schema_transitive( + ancestor_id, visiting, validated, gts_ref_validation + ) except Exception as error: raise ValueError( f"Ancestor type '{ancestor_id}' is invalid: {error}" ) from error for dependency_id, dependency_is_type in self._schema_dependencies( - schema_entity.content + schema_entity.content, include_gts_refs=False ): try: if dependency_is_type: self._validate_schema_transitive( - dependency_id, visiting, validated + dependency_id, visiting, validated, gts_ref_validation ) else: self._validate_entity_transitive( - dependency_id, visiting, validated + dependency_id, visiting, validated, gts_ref_validation ) except Exception as error: raise ValueError( @@ -793,8 +839,10 @@ def _validate_schema_transitive( visiting.remove(key) validated.add(key) - def _schema_dependencies(self, schema: Any) -> Iterator[tuple[str, bool]]: - x_gts_ref_validator = XGtsRefValidator(enforce_existence=False) + def _schema_dependencies( + self, schema: Any, include_gts_refs: bool = True + ) -> Iterator[tuple[str, bool]]: + x_gts_ref_validator = XGtsRefValidator(mode=GtsRefValidationMode.NONE) for subschema, _path in iter_schema_nodes(schema): ref_uri = subschema.get("$ref") if isinstance(ref_uri, str): @@ -802,6 +850,8 @@ def _schema_dependencies(self, schema: Any) -> Iterator[tuple[str, bool]]: if not ref.is_local and ref.is_gts and ref.has_scheme: yield ref.target_id, True + if not include_gts_refs: + continue x_gts_ref = x_gts_ref_validator.resolve_ref_pattern( subschema.get("x-gts-ref"), schema ) @@ -813,18 +863,50 @@ def _schema_dependencies(self, schema: Any) -> Iterator[tuple[str, bool]]: yield x_gts_ref, True def _validate_entity_transitive( - self, gts_id: str, visiting: set[str], validated: set[str] + self, + gts_id: str, + visiting: set[str], + validated: set[str], + gts_ref_validation: GtsRefValidationMode, ) -> None: entity = self.get(gts_id) if not entity: raise StoreGtsEntityNotFound(gts_id) if entity.is_schema: - self._validate_schema_transitive(gts_id, visiting, validated) + self._validate_schema_transitive( + gts_id, visiting, validated, gts_ref_validation + ) else: - self._validate_instance_transitive(gts_id, visiting, validated) + self._validate_instance_transitive( + gts_id, visiting, validated, gts_ref_validation + ) + + def _has_valid_wildcard_match( + self, + pattern: str, + visiting: set[str], + validated: set[str], + gts_ref_validation: GtsRefValidationMode, + ) -> bool: + wildcard = GtsWildcard(pattern) + for entity_id in self._by_id: + try: + if not GtsID(entity_id).wildcard_match(wildcard): + continue + self._validate_entity_transitive( + entity_id, visiting, validated, gts_ref_validation + ) + return True + except Exception as error: # noqa: BLE001 - try another wildcard match + logger.debug("Invalid wildcard candidate %s: %s", entity_id, error) + continue + return False def validate_instance_content( - self, content: dict[str, Any], type_id: str + self, + content: dict[str, Any], + type_id: str, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, ) -> set[str]: """Validate unregistered instance content against a registered type schema.""" schema_type = _require_schema_id(type_id) @@ -847,7 +929,7 @@ def validate_instance_content( ) validator.validate(content) - x_gts_ref_validator = XGtsRefValidator(store=self) + x_gts_ref_validator = XGtsRefValidator(store=self, mode=gts_ref_validation) x_gts_ref_errors = x_gts_ref_validator.validate_instance( content, self._resolve_schema_refs(schema) ) @@ -863,19 +945,24 @@ def validate_instance_content( def validate_instance( self, gts_id: str, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, ) -> None: """Validate an object instance and its complete dependency closure.""" - self._validate_instance_transitive(gts_id, set(), set()) + self._validate_instance_transitive(gts_id, set(), set(), gts_ref_validation) def _validate_instance_transitive( - self, gts_id: str, visiting: set[str], validated: set[str] + self, + gts_id: str, + visiting: set[str], + validated: set[str], + gts_ref_validation: GtsRefValidationMode, ) -> None: key = f"instance:{gts_id}" if key in validated or key in visiting: return visiting.add(key) try: - referenced_ids = self._validate_instance_local(gts_id) + referenced_ids = self._validate_instance_local(gts_id, gts_ref_validation) obj = ( self.get(GtsID(gts_id).id) @@ -885,19 +972,24 @@ def _validate_instance_transitive( if not obj or not obj.type_id: return try: - self._validate_schema_transitive(obj.type_id, visiting, validated) + self._validate_schema_transitive( + obj.type_id, visiting, validated, gts_ref_validation + ) except Exception as error: raise ValueError( f"Instance type '{obj.type_id}' is invalid: {error}" ) from error - for dependency_id in referenced_ids: - try: - self._validate_entity_transitive(dependency_id, visiting, validated) - except Exception as error: - raise ValueError( - f"Referenced entity '{dependency_id}' is invalid: {error}" - ) from error + if gts_ref_validation is GtsRefValidationMode.FULL: + for dependency_id in referenced_ids: + try: + self._validate_entity_transitive( + dependency_id, visiting, validated, gts_ref_validation + ) + except Exception as error: + raise ValueError( + f"Referenced entity '{dependency_id}' is invalid: {error}" + ) from error finally: visiting.remove(key) validated.add(key) @@ -905,6 +997,7 @@ def _validate_instance_transitive( def _validate_instance_local( self, gts_id: str, + gts_ref_validation: GtsRefValidationMode, ) -> set[str]: """ Validate an object instance against its schema. @@ -936,7 +1029,9 @@ def _validate_instance_local( raise TypeError(f"Instance '{lookup_id}' content must be a dictionary") logger.info(f"Validating instance {gts_id} against schema {obj.type_id}") - return self.validate_instance_content(obj.content, obj.type_id) + return self.validate_instance_content( + obj.content, obj.type_id, gts_ref_validation + ) def cast( self, diff --git a/gts/src/gts/traits.py b/gts/src/gts/traits.py index d86f556..5554439 100644 --- a/gts/src/gts/traits.py +++ b/gts/src/gts/traits.py @@ -21,6 +21,7 @@ from . import derivation from ._json_pointer import resolve as resolve_json_pointer +from .gts_ref_validation import GtsRefValidationMode from .schema_validation import FORMAT_CHECKER as _FORMAT_CHECKER from .schema_validation import map_schema_nodes, validator_for from .x_gts_ref import XGtsRefValidator @@ -53,7 +54,10 @@ def _has_explicit_values(self) -> bool: return isinstance(self.merged_traits, dict) and len(self.merged_traits) > 0 def validate( - self, check_unresolved: bool, reference_store: Any | None = None + self, + check_unresolved: bool, + reference_store: Any | None = None, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, ) -> list[str]: """Return a list of error strings (empty means valid).""" errors = _validate_trait_schema_integrity(self.resolved_trait_schemas) @@ -83,7 +87,11 @@ def validate( return [] return _validate_trait_values( - self.schema, self.values, check_unresolved, reference_store + self.schema, + self.values, + check_unresolved, + reference_store, + gts_ref_validation, ) @@ -277,6 +285,17 @@ def _materialize_traits(trait_schema: Any, traits: Any, depth: int = 0) -> Any: # --- validation ------------------------------------------------------------ +def _without_required(schema: Any) -> Any: + def strip(node: Any) -> Any: + if not isinstance(node, dict): + return node + result = dict(node) + result.pop("required", None) + return result + + return map_schema_nodes(copy.deepcopy(schema), strip) + + def _validate_trait_schema_integrity(resolved_trait_schemas: list[Any]) -> list[str]: for i, ts in enumerate(resolved_trait_schemas): if isinstance(ts, bool): @@ -333,20 +352,8 @@ def _validate_traits_against_schema( errors: list[str] = [] try: - validation_schema = ( - trait_schema - if check_unresolved - else map_schema_nodes( - trait_schema, - lambda node: ( - {k: v for k, v in node.items() if k != "required"} - if isinstance(node, dict) - else node - ), - ) - ) - cls = validator_for(validation_schema) - validator = cls(validation_schema, format_checker=_FORMAT_CHECKER) + cls = validator_for(trait_schema) + validator = cls(trait_schema, format_checker=_FORMAT_CHECKER) for error in validator.iter_errors(effective_traits): errors.append(f"trait validation: {error.message}") except Exception as e: # noqa: BLE001 - surfaced as validation error message @@ -383,11 +390,17 @@ def _validate_trait_values( effective_traits: Any, check_unresolved: bool, reference_store: Any | None, + gts_ref_validation: GtsRefValidationMode, ) -> list[str]: + schema_for_values = ( + effective_traits_schema + if check_unresolved + else _without_required(effective_traits_schema) + ) errors = _validate_traits_against_schema( - effective_traits_schema, effective_traits, check_unresolved + schema_for_values, effective_traits, check_unresolved ) - xref = XGtsRefValidator(store=reference_store) + xref = XGtsRefValidator(store=reference_store, mode=gts_ref_validation) for err in xref.validate_schema_ref_existence(effective_traits_schema): errors.append(f"trait x-gts-ref: {err.reason}") for err in xref.validate_instance(effective_traits, effective_traits_schema, ""): diff --git a/gts/src/gts/x_gts_ref.py b/gts/src/gts/x_gts_ref.py index c9dbf17..a35cfe4 100644 --- a/gts/src/gts/x_gts_ref.py +++ b/gts/src/gts/x_gts_ref.py @@ -19,7 +19,8 @@ from ._json_pointer import MISSING from ._json_pointer import resolve as resolve_json_pointer from ._naming import GTS_PREFIX, strip_scheme -from .gts import GtsID +from .gts import GtsID, GtsWildcard +from .gts_ref_validation import GtsRefValidationMode from .schema_validation import iter_schema_nodes, map_schema_nodes @@ -75,21 +76,25 @@ def __init__(self, field_path: str, value: Any, ref_pattern: str, reason: str): class XGtsRefValidator: """Validator for x-gts-ref constraints in GTS schemas.""" - def __init__(self, store: Any | None = None, enforce_existence: bool = True): - """ - Initialize validator. - - Args: - store: Optional GtsStore for resolving entity references. - enforce_existence: When True (default) and a ``store`` is provided, - an x-gts-ref value must resolve to a registered entity or - validation fails. Set to False to validate only that the value - is a well-formed GTS id matching the constraint pattern, without - requiring the referenced entity to exist in the registry. - """ + def __init__( + self, + store: Any | None = None, + mode: GtsRefValidationMode | bool | str = GtsRefValidationMode.FULL, + *, + enforce_existence: bool | None = None, + ): + if enforce_existence is not None: + mode = ( + GtsRefValidationMode.PRESENCE + if enforce_existence + else GtsRefValidationMode.NONE + ) + elif isinstance(mode, bool): + mode = GtsRefValidationMode.PRESENCE if mode else GtsRefValidationMode.NONE self.store = store - self.enforce_existence = enforce_existence + self.mode = GtsRefValidationMode(mode) self.referenced_ids: set[str] = set() + self.referenced_wildcard_patterns: set[str] = set() def validate_instance( self, instance: dict[str, Any], schema: dict[str, Any], instance_path: str = "" @@ -205,14 +210,14 @@ def visit_instance(inst, sch, path, errs, refs=None): visit_instance(inst[prop_name], prop_schema, prop_path, errs) if isinstance(inst, list): - prefix_items = sch.get("prefixItems") + tuple_items = sch.get("prefixItems") items = sch.get("items") - if isinstance(prefix_items, list): - for idx, item_schema in enumerate(prefix_items[: len(inst)]): + if isinstance(tuple_items, list): + for idx, item_schema in enumerate(tuple_items[: len(inst)]): item_path = f"{path}[{idx}]" visit_instance(inst[idx], item_schema, item_path, errs) if isinstance(items, dict): - for idx in range(len(prefix_items), len(inst)): + for idx in range(len(tuple_items), len(inst)): item_path = f"{path}[{idx}]" visit_instance(inst[idx], items, item_path, errs) elif isinstance(items, list): @@ -244,18 +249,7 @@ def validate_schema( root_schema: dict[str, Any] | None = None, resolve_relative: bool = True, ) -> list[XGtsRefValidationError]: - """ - Validate x-gts-ref fields in a schema definition. - - Args: - schema: The JSON schema to validate - schema_path: Current path in schema (for error reporting) - root_schema: The root schema (for resolving relative refs) - resolve_relative: Whether relative refs must resolve during this check - - Returns: - List of validation errors (empty if valid) - """ + """Validate x-gts-ref fields in a schema definition.""" if root_schema is None: root_schema = schema @@ -282,32 +276,53 @@ def validate_schema_ref_existence( schema_path: str = "", root_schema: dict[str, Any] | None = None, ) -> list[XGtsRefValidationError]: - if self.store is None or not self.enforce_existence: + if self.store is None or self.mode == GtsRefValidationMode.NONE: return [] - + store = self.store if root_schema is None: root_schema = schema - store = self.store + + def matches(pattern: str) -> list[str]: + wildcard = GtsWildcard(pattern) + result = [] + for entity_id, _ in store.items(): # noqa: PERF102 - generic store protocol + try: + if GtsID(entity_id).wildcard_match(wildcard): + result.append(entity_id) + except ValueError: + continue + return result + errors: list[XGtsRefValidationError] = [] for subschema, path in iter_schema_nodes(schema, schema_path): ref_pattern = subschema.get("x-gts-ref") - resolved_pattern = self.resolve_ref_pattern(ref_pattern, root_schema) - if ( - not isinstance(resolved_pattern, str) - or not resolved_pattern.startswith(GTS_PREFIX) - or "*" in resolved_pattern - or store.get(resolved_pattern) is not None - ): + resolved = self.resolve_ref_pattern(ref_pattern, root_schema) + if not isinstance(resolved, str) or not resolved.startswith(GTS_PREFIX): continue ref_path = f"{path}/x-gts-ref" if path else "x-gts-ref" - errors.append( - XGtsRefValidationError( - ref_path, - ref_pattern, - resolved_pattern, - f"x-gts-ref constraint type '{resolved_pattern}' is not registered", + if "*" in resolved: + if matches(resolved): + self.referenced_wildcard_patterns.add(resolved) + else: + errors.append( + XGtsRefValidationError( + ref_path, + ref_pattern, + resolved, + f"x-gts-ref wildcard constraint '{resolved}' has no registered match", + ) + ) + elif store.get(resolved) is None: + errors.append( + XGtsRefValidationError( + ref_path, + ref_pattern, + resolved, + f"x-gts-ref constraint type '{resolved}' is not registered", + ) ) - ) + else: + self.referenced_ids.add(resolved) return errors def resolve_ref_pattern( @@ -360,7 +375,7 @@ def _validate_ref_value( # Resolve pattern if it's a relative reference if ref_pattern.startswith("/"): - resolved_pattern = self.resolve_ref_pattern(ref_pattern, schema) + resolved_pattern = self._resolve_pointer(schema, ref_pattern) if resolved_pattern is None: return XGtsRefValidationError( field_path, @@ -503,11 +518,8 @@ def _validate_gts_pattern( f"Value '{value}' does not match pattern '{pattern}'", ) - # Referenced value must resolve to a registered entity when a store is - # available and existence enforcement is enabled. Existence is enforced - # uniformly for all constraint forms (including the bare "gts.*" - # wildcard). - if self.store and self.enforce_existence: + # Referenced values use exact registry lookup in presence/full modes. + if self.store and self.mode != GtsRefValidationMode.NONE: entity = self.store.get(value) if not entity: return XGtsRefValidationError( diff --git a/tests/test_x_gts_ref.py b/tests/test_x_gts_ref.py index b94f2b9..d52193f 100644 --- a/tests/test_x_gts_ref.py +++ b/tests/test_x_gts_ref.py @@ -151,6 +151,9 @@ class FakeStore: def get(self, value): return object() if value == "gts.x.test._.foo.v1~" else None + def items(self): + return [("gts.x.test._.foo.v1~", object())] + errors = XGtsRefValidator(store=FakeStore()).validate_schema_ref_existence( { "allOf": [ From ff052fcf86f762c947cc6a06c7d3e76c93e21239 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sat, 19 Sep 2026 09:15:22 +0300 Subject: [PATCH 15/16] fix(x-gts-ref): bind self references to selected type Restrict x-gts-ref operands to concrete GTS identifiers, wildcard patterns, and the reserved /$id form. Remove general pointer resolution and the bulk-registration bypass so unsupported pointer syntax is rejected consistently. Propagate the selected leaf type through instance and effective-trait validation so inherited /$id constraints rebind correctly. Update unit coverage and fix the server's validation-mode default typo. Signed-off-by: Artifizer --- gts/src/gts/_server.py | 2 +- gts/src/gts/ops.py | 7 +- gts/src/gts/store.py | 41 +++++----- gts/src/gts/traits.py | 13 +++- gts/src/gts/x_gts_ref.py | 153 +++++++++++--------------------------- tests/test_ops.py | 8 +- tests/test_store_extra.py | 24 +++--- tests/test_x_gts_ref.py | 100 ++++++++----------------- 8 files changed, 122 insertions(+), 226 deletions(-) diff --git a/gts/src/gts/_server.py b/gts/src/gts/_server.py index 42275da..2819f6b 100644 --- a/gts/src/gts/_server.py +++ b/gts/src/gts/_server.py @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) GTS_REF_VALIDATION_QUERY = Query( - GtsGtsRefValidationMode.FULL, alias="gts-ref-validation" + GtsRefValidationMode.FULL, alias="gts-ref-validation" ) diff --git a/gts/src/gts/ops.py b/gts/src/gts/ops.py index 9d871f3..5d6b452 100644 --- a/gts/src/gts/ops.py +++ b/gts/src/gts/ops.py @@ -398,7 +398,6 @@ def add_entity( content: dict[str, Any], validate: bool = False, gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, - resolve_relative: bool = True, ) -> GtsAddEntityResult: gts_ref_validation = _normalize_gts_ref_validation(gts_ref_validation) entity = GtsEntity(content=content, cfg=self.cfg) @@ -442,9 +441,7 @@ def add_entity( try: if entity.is_schema: - self.store.validate_schema_basic( - entity.gts_id.id, resolve_relative=resolve_relative - ) + self.store.validate_schema_basic(entity.gts_id.id) if validate: self.store.validate_schema(entity.gts_id.id, gts_ref_validation) elif validate: @@ -475,7 +472,7 @@ def add_entities( ) -> GtsAddEntitiesResult: results: list[GtsAddEntityResult] = [] for it in items: - results.append(self.add_entity(it, resolve_relative=False)) + results.append(self.add_entity(it)) ok = all(r.ok for r in results) return GtsAddEntitiesResult(ok=ok, results=results) diff --git a/gts/src/gts/store.py b/gts/src/gts/store.py index d09ca18..96243ae 100644 --- a/gts/src/gts/store.py +++ b/gts/src/gts/store.py @@ -323,9 +323,7 @@ def _validate_schema_ref_targets( for index, item in enumerate(schema): self._validate_schema_ref_targets(item, f"{path}[{index}]", visited) - def _validate_schema_x_gts_refs( - self, gts_id: str, resolve_relative: bool = True - ) -> None: + def _validate_schema_x_gts_refs(self, gts_id: str) -> None: """ Validate a schema's x-gts-ref fields. @@ -340,23 +338,16 @@ def _validate_schema_x_gts_refs( if not schema_entity.is_schema: raise ValueError(f"Entity '{schema_id.id}' is not a schema") - self._validate_schema_x_gts_refs_content( - schema_id.id, schema_entity.content, resolve_relative=resolve_relative - ) + self._validate_schema_x_gts_refs_content(schema_id.id, schema_entity.content) def _validate_schema_x_gts_refs_content( - self, - gts_id: str, - schema_content: dict[str, Any], - resolve_relative: bool = True, + self, gts_id: str, schema_content: dict[str, Any] ) -> None: logger.info(f"Validating schema x-gts-ref fields for {gts_id}") # Validate x-gts-ref constraints in the schema x_gts_ref_validator = XGtsRefValidator(store=self) - x_gts_ref_errors = x_gts_ref_validator.validate_schema( - schema_content, resolve_relative=resolve_relative - ) + x_gts_ref_errors = x_gts_ref_validator.validate_schema(schema_content) if x_gts_ref_errors: error_messages = [ f"{err.field_path}: {err.reason}" for err in x_gts_ref_errors @@ -629,13 +620,14 @@ def _validate_traits( check_unresolved=not is_abstract, reference_store=self, gts_ref_validation=gts_ref_validation, + selected_type_id=gts_id, ) if errors: raise ValueError( f"Schema '{gts_id}' trait validation failed: " + "; ".join(errors) ) - def validate_schema_basic(self, gts_id: str, resolve_relative: bool = True) -> None: + def validate_schema_basic(self, gts_id: str) -> None: """Basic schema validation during registration (no chain validation). Checks: @@ -672,7 +664,7 @@ def validate_schema_basic(self, gts_id: str, resolve_relative: bool = True) -> N self._validate_schema_refs(schema_content, "") # 2. Validate x-gts-ref fields - self._validate_schema_x_gts_refs(gts_id, resolve_relative=resolve_relative) + self._validate_schema_x_gts_refs(gts_id) # 3. Validate GTS keywords (x-gts-final, x-gts-abstract, placement) self._validate_gts_keywords(schema_content) @@ -765,7 +757,7 @@ def _validate_schema_transitive( schema_ref_validator = XGtsRefValidator(store=self, mode=gts_ref_validation) schema_ref_errors = schema_ref_validator.validate_schema_ref_existence( - schema_entity.content + schema_entity.content, selected_type_id=schema_id.id ) if schema_ref_errors: raise ValueError( @@ -775,9 +767,13 @@ def _validate_schema_transitive( effective_traits = self._build_effective_traits(schema_id.id) trait_ref_validator = XGtsRefValidator(store=self, mode=gts_ref_validation) - trait_ref_validator.validate_schema_ref_existence(effective_traits.schema) + trait_ref_validator.validate_schema_ref_existence( + effective_traits.schema, selected_type_id=schema_id.id + ) trait_ref_validator.validate_instance( - effective_traits.values, effective_traits.schema + effective_traits.values, + effective_traits.schema, + selected_type_id=schema_id.id, ) xref_ids = ( schema_ref_validator.referenced_ids | trait_ref_validator.referenced_ids @@ -843,6 +839,11 @@ def _schema_dependencies( self, schema: Any, include_gts_refs: bool = True ) -> Iterator[tuple[str, bool]]: x_gts_ref_validator = XGtsRefValidator(mode=GtsRefValidationMode.NONE) + selected_type_id = ( + x_gts_ref_validator.selected_type_id(schema, None) + if isinstance(schema, dict) + else None + ) for subschema, _path in iter_schema_nodes(schema): ref_uri = subschema.get("$ref") if isinstance(ref_uri, str): @@ -853,7 +854,7 @@ def _schema_dependencies( if not include_gts_refs: continue x_gts_ref = x_gts_ref_validator.resolve_ref_pattern( - subschema.get("x-gts-ref"), schema + subschema.get("x-gts-ref"), selected_type_id ) if ( isinstance(x_gts_ref, str) @@ -931,7 +932,7 @@ def validate_instance_content( x_gts_ref_validator = XGtsRefValidator(store=self, mode=gts_ref_validation) x_gts_ref_errors = x_gts_ref_validator.validate_instance( - content, self._resolve_schema_refs(schema) + content, self._resolve_schema_refs(schema), selected_type_id=schema_type.id ) if x_gts_ref_errors: error_messages = [ diff --git a/gts/src/gts/traits.py b/gts/src/gts/traits.py index 5554439..9122329 100644 --- a/gts/src/gts/traits.py +++ b/gts/src/gts/traits.py @@ -58,6 +58,7 @@ def validate( check_unresolved: bool, reference_store: Any | None = None, gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, + selected_type_id: str | None = None, ) -> list[str]: """Return a list of error strings (empty means valid).""" errors = _validate_trait_schema_integrity(self.resolved_trait_schemas) @@ -92,6 +93,7 @@ def validate( check_unresolved, reference_store, gts_ref_validation, + selected_type_id, ) @@ -391,6 +393,7 @@ def _validate_trait_values( check_unresolved: bool, reference_store: Any | None, gts_ref_validation: GtsRefValidationMode, + selected_type_id: str | None, ) -> list[str]: schema_for_values = ( effective_traits_schema @@ -401,8 +404,14 @@ def _validate_trait_values( schema_for_values, effective_traits, check_unresolved ) xref = XGtsRefValidator(store=reference_store, mode=gts_ref_validation) - for err in xref.validate_schema_ref_existence(effective_traits_schema): + for err in xref.validate_schema_ref_existence( + effective_traits_schema, selected_type_id=selected_type_id + ): errors.append(f"trait x-gts-ref: {err.reason}") - for err in xref.validate_instance(effective_traits, effective_traits_schema, ""): + for err in xref.validate_instance( + effective_traits, + effective_traits_schema, + selected_type_id=selected_type_id, + ): errors.append(f"trait x-gts-ref: {err.reason}") return errors diff --git a/gts/src/gts/x_gts_ref.py b/gts/src/gts/x_gts_ref.py index a35cfe4..32d72da 100644 --- a/gts/src/gts/x_gts_ref.py +++ b/gts/src/gts/x_gts_ref.py @@ -5,7 +5,7 @@ in the GTS specification section 9.5. Key optimizations: -1. Use jsonpointer library for JSON Pointer resolution +1. Resolve local JSON Schema $ref pointers during traversal 2. Consolidate duplicate validation logic 3. Simplify recursive traversal with a generic walker """ @@ -16,13 +16,14 @@ from jsonschema.validators import validator_for -from ._json_pointer import MISSING from ._json_pointer import resolve as resolve_json_pointer from ._naming import GTS_PREFIX, strip_scheme from .gts import GtsID, GtsWildcard from .gts_ref_validation import GtsRefValidationMode from .schema_validation import iter_schema_nodes, map_schema_nodes +X_GTS_REF_SELF = "/$id" + def _without_x_gts_ref(schema: Any) -> Any: def strip(node: Any) -> Any: @@ -96,8 +97,23 @@ def __init__( self.referenced_ids: set[str] = set() self.referenced_wildcard_patterns: set[str] = set() + @staticmethod + def is_self_reference(value: Any) -> bool: + return value == X_GTS_REF_SELF + + @staticmethod + def selected_type_id( + schema: dict[str, Any], selected_type_id: str | None + ) -> str | None: + candidate = selected_type_id or schema.get("$id") + return strip_scheme(candidate) if isinstance(candidate, str) else None + def validate_instance( - self, instance: dict[str, Any], schema: dict[str, Any], instance_path: str = "" + self, + instance: dict[str, Any], + schema: dict[str, Any], + instance_path: str = "", + selected_type_id: str | None = None, ) -> list[XGtsRefValidationError]: """ Validate an instance against x-gts-ref constraints in schema. @@ -111,6 +127,7 @@ def validate_instance( List of validation errors (empty if valid) """ errors: list[XGtsRefValidationError] = [] + selected_type_id = self.selected_type_id(schema, selected_type_id) def resolve_local_ref(ref: str) -> Any | None: if ref != "#" and not ref.startswith("#/"): @@ -130,7 +147,9 @@ def visit_instance(inst, sch, path, errs, refs=None): visit_instance(inst, target, path, errs, refs | {ref}) if "x-gts-ref" in sch and isinstance(inst, str): - error = self._validate_ref_value(inst, sch["x-gts-ref"], path, schema) + error = self._validate_ref_value( + inst, sch["x-gts-ref"], path, selected_type_id + ) if error: errs.append(error) @@ -243,29 +262,15 @@ def _validate_branch(inst, branch, path): return errors def validate_schema( - self, - schema: dict[str, Any], - schema_path: str = "", - root_schema: dict[str, Any] | None = None, - resolve_relative: bool = True, + self, schema: dict[str, Any], schema_path: str = "" ) -> list[XGtsRefValidationError]: """Validate x-gts-ref fields in a schema definition.""" - if root_schema is None: - root_schema = schema - errors = [] for subschema, path in iter_schema_nodes(schema, schema_path): if "x-gts-ref" not in subschema: continue - ref_value = subschema["x-gts-ref"] - if ( - not resolve_relative - and isinstance(ref_value, str) - and ref_value.startswith("/") - ): - continue ref_path = f"{path}/x-gts-ref" if path else "x-gts-ref" - error = self._validate_ref_pattern(ref_value, ref_path, root_schema) + error = self._validate_ref_pattern(subschema["x-gts-ref"], ref_path) if error: errors.append(error) return errors @@ -274,13 +279,12 @@ def validate_schema_ref_existence( self, schema: Any, schema_path: str = "", - root_schema: dict[str, Any] | None = None, + selected_type_id: str | None = None, ) -> list[XGtsRefValidationError]: if self.store is None or self.mode == GtsRefValidationMode.NONE: return [] store = self.store - if root_schema is None: - root_schema = schema + selected_type_id = self.selected_type_id(schema, selected_type_id) def matches(pattern: str) -> list[str]: wildcard = GtsWildcard(pattern) @@ -296,7 +300,7 @@ def matches(pattern: str) -> list[str]: errors: list[XGtsRefValidationError] = [] for subschema, path in iter_schema_nodes(schema, schema_path): ref_pattern = subschema.get("x-gts-ref") - resolved = self.resolve_ref_pattern(ref_pattern, root_schema) + resolved = self.resolve_ref_pattern(ref_pattern, selected_type_id) if not isinstance(resolved, str) or not resolved.startswith(GTS_PREFIX): continue ref_path = f"{path}/x-gts-ref" if path else "x-gts-ref" @@ -326,32 +330,20 @@ def matches(pattern: str) -> list[str]: return errors def resolve_ref_pattern( - self, ref_pattern: Any, root_schema: dict[str, Any] + self, ref_pattern: Any, selected_type_id: str | None ) -> str | None: if not isinstance(ref_pattern, str): return None - if ref_pattern.startswith("/"): - return self._resolve_pointer(root_schema, ref_pattern) + if self.is_self_reference(ref_pattern): + return selected_type_id return strip_scheme(ref_pattern) - def resolve_schema_ref_patterns( - self, schema: Any, root_schema: dict[str, Any] - ) -> Any: - def resolve(node: Any) -> Any: - if not isinstance(node, dict): - return node - ref_pattern = node.get("x-gts-ref") - if not isinstance(ref_pattern, str) or not ref_pattern.startswith("/"): - return node - resolved_pattern = self.resolve_ref_pattern(ref_pattern, root_schema) - if resolved_pattern is not None: - node["x-gts-ref"] = resolved_pattern - return node - - return map_schema_nodes(schema, resolve) - def _validate_ref_value( - self, value: str, ref_pattern: str, field_path: str, schema: dict[str, Any] + self, + value: str, + ref_pattern: str, + field_path: str, + selected_type_id: str | None, ) -> XGtsRefValidationError | None: """ Validate an instance value against its x-gts-ref constraint. @@ -360,7 +352,7 @@ def _validate_ref_value( value: The field value to validate ref_pattern: The x-gts-ref pattern field_path: Path to the field (for error reporting) - schema: The complete schema (for resolving relative refs) + selected_type_id: Canonical identifier of the selected leaf type Returns: XGtsRefValidationError if validation fails, None otherwise @@ -373,32 +365,21 @@ def _validate_ref_value( f"Value must be a string, got {type(value).__name__}", ) - # Resolve pattern if it's a relative reference - if ref_pattern.startswith("/"): - resolved_pattern = self._resolve_pointer(schema, ref_pattern) - if resolved_pattern is None: + if self.is_self_reference(ref_pattern): + if selected_type_id is None: return XGtsRefValidationError( field_path, value, ref_pattern, - f"Cannot resolve reference path '{ref_pattern}'", + "Cannot resolve /$id without a selected GTS Type Schema", ) - if not isinstance(resolved_pattern, str) or not resolved_pattern.startswith( - "gts." - ): - return XGtsRefValidationError( - field_path, - value, - ref_pattern, - f"Resolved reference '{ref_pattern}' -> '{resolved_pattern}' is not a GTS pattern", - ) - ref_pattern = resolved_pattern + ref_pattern = selected_type_id # Validate against GTS pattern return self._validate_gts_pattern(value, ref_pattern, field_path) def _validate_ref_pattern( - self, ref_pattern: str, field_path: str, root_schema: dict[str, Any] + self, ref_pattern: str, field_path: str ) -> XGtsRefValidationError | None: """ Validate an x-gts-ref pattern in a schema definition. @@ -406,7 +387,6 @@ def _validate_ref_pattern( Args: ref_pattern: The x-gts-ref value field_path: Path to the field (for error reporting) - root_schema: The root schema (for resolving relative refs) Returns: XGtsRefValidationError if validation fails, None otherwise @@ -423,30 +403,14 @@ def _validate_ref_pattern( if ref_pattern.startswith(GTS_PREFIX): return self._validate_gts_id_or_pattern(ref_pattern, field_path) - # Case 2: Relative reference - if ref_pattern.startswith("/"): - resolved = self._resolve_pointer(root_schema, ref_pattern) - if resolved is None: - return XGtsRefValidationError( - field_path, - ref_pattern, - ref_pattern, - f"Cannot resolve reference path '{ref_pattern}'", - ) - if not isinstance(resolved, str) or not GtsID.is_valid(resolved): - return XGtsRefValidationError( - field_path, - ref_pattern, - ref_pattern, - f"Resolved reference '{ref_pattern}' -> '{resolved}' is not a valid GTS identifier", - ) + if self.is_self_reference(ref_pattern): return None return XGtsRefValidationError( field_path, ref_pattern, ref_pattern, - f"Invalid x-gts-ref value: '{ref_pattern}' must start with 'gts.' or '/'", + f"Invalid x-gts-ref value: '{ref_pattern}' must be a GTS identifier, wildcard, or '{X_GTS_REF_SELF}'", ) def _validate_gts_id_or_pattern( @@ -531,32 +495,3 @@ def _validate_gts_pattern( self.referenced_ids.add(value) return None - - def _resolve_pointer(self, schema: dict[str, Any], pointer: str) -> str | None: - """ - Resolve a JSON Pointer in the schema to a GTS identifier. - - Args: - schema: The schema to search - pointer: JSON Pointer (e.g., "/$id", "/properties/type") - - Returns: - The resolved GTS identifier (bare form) or None if not found. - """ - current = resolve_json_pointer(schema, pointer, default=MISSING) - if current is MISSING or current is None: - return None - - # If current is a string, return it (normalized to the bare form). - if isinstance(current, str): - return strip_scheme(current) - - # If current is a dict with x-gts-ref, resolve it - if isinstance(current, dict) and "x-gts-ref" in current: - ref_value = current["x-gts-ref"] - if isinstance(ref_value, str): - if ref_value.startswith("/"): - return self._resolve_pointer(schema, ref_value) - return strip_scheme(ref_value) - - return None diff --git a/tests/test_ops.py b/tests/test_ops.py index 497acf4..77e42fb 100644 --- a/tests/test_ops.py +++ b/tests/test_ops.py @@ -1,10 +1,8 @@ """Tests for gts.ops.GtsOps (the high-level CLI/HTTP operations facade).""" import pytest - from gts.ops import GtsOps - SCHEMA = { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "gts://gts.x.test._.foo.v1~", @@ -144,15 +142,15 @@ def test_add_entities_batch(self, ops): assert result.ok is True assert len(result.results) == 2 - def test_add_entities_defers_relative_pointer_resolution(self, ops): + def test_add_entities_rejects_unsupported_x_gts_ref_pointer(self, ops): schema = { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "gts://gts.x.test._.relative.v1~", "properties": {"ref": {"x-gts-ref": "/missing"}}, } result = ops.add_entities([schema]) - assert result.ok is True - assert ops.validate_schema("gts.x.test._.relative.v1~").ok is False + assert result.ok is False + assert "must be a GTS identifier" in result.results[0].error class TestAddSchemaLegacy: diff --git a/tests/test_store_extra.py b/tests/test_store_extra.py index 53e2f8a..1850bcc 100644 --- a/tests/test_store_extra.py +++ b/tests/test_store_extra.py @@ -1,17 +1,18 @@ """Additional coverage-focused tests for gts.store.GtsStore.""" -import pytest -from typing import Iterator, Optional +from collections.abc import Iterator +from typing import Optional +import pytest +from gts.entities import DEFAULT_GTS_CONFIG, GtsEntity +from gts.gts import GtsID +from gts.schema_validation import PATTERN_TIMEOUT_SECONDS, validator_for from gts.store import ( - GtsStore, GtsReader, + GtsStore, StoreGtsEntityNotFound, StoreGtsObjectNotFound, ) -from gts.entities import GtsEntity, DEFAULT_GTS_CONFIG -from gts.gts import GtsID -from gts.schema_validation import PATTERN_TIMEOUT_SECONDS, validator_for class MockGtsReader(GtsReader): @@ -171,15 +172,13 @@ def test_finds_constraint_under_property_named_x_gts_ref(self): ) ) == [("gts.x.test._.missing.v1~", True)] - def test_resolves_relative_x_gts_ref_dependency(self): + def test_unsupported_x_gts_ref_pointer_is_not_a_dependency(self): store = GtsStore(reader=None) schema = { "target": "gts.x.test._.target.v1~", "properties": {"ref": {"x-gts-ref": "/target"}}, } - assert list(store._schema_dependencies(schema)) == [ - ("gts.x.test._.target.v1~", True) - ] + assert list(store._schema_dependencies(schema)) == [] class TestValidateGtsKeywords: @@ -248,7 +247,7 @@ def test_invalid_x_gts_ref_raises(self): with pytest.raises(Exception, match="x-gts-ref validation failed"): store._validate_schema_x_gts_refs("gts.x.test._.foo.v1~") - def test_basic_validation_can_defer_relative_pointer_resolution(self): + def test_basic_validation_rejects_unsupported_pointer(self): schema = _schema_entity( "gts.x.test._.foo.v1~", { @@ -261,8 +260,7 @@ def test_basic_validation_can_defer_relative_pointer_resolution(self): ) store = GtsStore(reader=None) store.register(schema) - store.validate_schema_basic("gts.x.test._.foo.v1~", resolve_relative=False) - with pytest.raises(Exception, match="x-gts-ref validation failed"): + with pytest.raises(Exception, match="must be a GTS identifier"): store.validate_schema_basic("gts.x.test._.foo.v1~") diff --git a/tests/test_x_gts_ref.py b/tests/test_x_gts_ref.py index d52193f..e2ed54d 100644 --- a/tests/test_x_gts_ref.py +++ b/tests/test_x_gts_ref.py @@ -1,6 +1,6 @@ """Tests for gts.x_gts_ref (x-gts-ref schema & instance validation, spec sec 9.5).""" -from gts.x_gts_ref import XGtsRefValidator +from gts.x_gts_ref import X_GTS_REF_SELF, XGtsRefValidator class TestValidateSchema: @@ -36,32 +36,19 @@ def test_non_string_ref_value(self): def test_invalid_prefix_value(self): errors = XGtsRefValidator().validate_schema({"x-gts-ref": "nope"}) assert len(errors) == 1 - assert "must start with" in errors[0].reason + assert "must be a GTS identifier" in errors[0].reason - def test_relative_pointer_resolves_to_valid_id(self): - schema = { - "$id": "gts.x.test._.foo.v1~", - "properties": { - "ref_field": {"x-gts-ref": "/$id"}, - }, - } - errors = XGtsRefValidator().validate_schema(schema) - assert errors == [] - - def test_relative_pointer_unresolvable(self): - schema = {"properties": {"ref_field": {"x-gts-ref": "/missing/path"}}} - errors = XGtsRefValidator().validate_schema(schema) - assert len(errors) == 1 - assert "Cannot resolve reference path" in errors[0].reason + def test_selected_type_self_reference_is_valid(self): + validator = XGtsRefValidator() + assert validator.is_self_reference(X_GTS_REF_SELF) + assert validator.validate_schema({"x-gts-ref": X_GTS_REF_SELF}) == [] - def test_relative_pointer_resolves_to_invalid_id(self): - schema = { - "not_gts": "definitely not a gts id !!", - "properties": {"ref_field": {"x-gts-ref": "/not_gts"}}, - } - errors = XGtsRefValidator().validate_schema(schema) - assert len(errors) == 1 - assert "is not a valid GTS identifier" in errors[0].reason + def test_other_pointers_are_invalid(self): + validator = XGtsRefValidator() + for pointer in ("/missing/path", "/properties/id"): + errors = validator.validate_schema({"x-gts-ref": pointer}) + assert len(errors) == 1 + assert "must be a GTS identifier" in errors[0].reason def test_recurses_into_nested_structures(self): schema = { @@ -114,20 +101,6 @@ def test_recurses_into_draft3_schema_forms(self): "disallow[1]/x-gts-ref", ] - def test_resolves_relative_patterns_before_extracting_subschema(self): - root = { - "x-gts-traits-schema": { - "constraintType": "gts.x.test._.target.v1~", - "properties": { - "ref": {"x-gts-ref": "/x-gts-traits-schema/constraintType"} - }, - } - } - resolved = XGtsRefValidator().resolve_schema_ref_patterns( - root["x-gts-traits-schema"], root - ) - assert resolved["properties"]["ref"]["x-gts-ref"] == ("gts.x.test._.target.v1~") - class TestValidateSchemaRefExistence: def test_missing_concrete_constraint_type_fails(self): @@ -159,7 +132,6 @@ def items(self): "allOf": [ {"x-gts-ref": "gts.x.test._.foo.v1~"}, {"x-gts-ref": "gts.x.test.*"}, - {"x-gts-ref": "/properties/ref"}, ] } ) @@ -176,42 +148,21 @@ def get(self, value): ) assert errors == [] - def test_relative_constraint_type_must_exist(self): + def test_selected_type_constraint_uses_explicit_leaf(self): class FakeStore: def get(self, value): - return None - - schema = { - "target": "gts.x.test._.missing.v1~", - "properties": {"ref": {"x-gts-ref": "/target"}}, - } - errors = XGtsRefValidator(store=FakeStore()).validate_schema_ref_existence( - schema - ) - assert len(errors) == 1 - assert ( - "constraint type 'gts.x.test._.missing.v1~' is not registered" - in errors[0].reason - ) + return object() if value == "gts.x.test._.leaf.v1~" else None - def test_relative_constraint_type_can_resolve(self): - class FakeStore: - def get(self, value): - return object() if value == "gts.x.test._.target.v1~" else None - - schema = { - "target": "gts.x.test._.target.v1~", - "properties": {"ref": {"x-gts-ref": "/target"}}, - } errors = XGtsRefValidator(store=FakeStore()).validate_schema_ref_existence( - schema + {"x-gts-ref": X_GTS_REF_SELF}, + selected_type_id="gts.x.test._.leaf.v1~", ) assert errors == [] class TestValidateInstanceValue: def test_non_string_instance_value_error(self): - error = XGtsRefValidator()._validate_ref_value(123, "gts.*", "ref", {}) + error = XGtsRefValidator()._validate_ref_value(123, "gts.*", "ref", None) assert error is not None assert "Value must be a string" in error.reason @@ -226,17 +177,24 @@ def test_relative_ref_pattern_resolution_on_instance(self): ) assert errors == [] - def test_relative_ref_pattern_resolution_fails_when_not_gts_prefix(self): + def test_self_reference_uses_explicit_selected_leaf(self): schema = { - "other": "not-gts-value", + "$id": "gts.x.test._.base.v1~", "type": "object", - "properties": {"ref": {"x-gts-ref": "/other"}}, + "properties": {"ref": {"x-gts-ref": X_GTS_REF_SELF}}, } + leaf = "gts.x.test._.base.v1~x.test._.leaf.v1~" + assert ( + XGtsRefValidator().validate_instance( + {"ref": leaf}, schema, selected_type_id=leaf + ) + == [] + ) errors = XGtsRefValidator().validate_instance( - {"ref": "gts.x.test._.foo.v1~"}, schema + {"ref": "gts.x.test._.base.v1~"}, schema, selected_type_id=leaf ) assert len(errors) == 1 - assert "is not a GTS pattern" in errors[0].reason + assert "does not match pattern" in errors[0].reason def test_wildcard_pattern_matches_prefix(self): errors = XGtsRefValidator().validate_instance( From e1d2d63b5db4dbb0f5a573dcf58794e8a45b0439 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 20 Sep 2026 20:51:52 +0300 Subject: [PATCH 16/16] Rename gts-ref-validation modes: presence -> any-present, full -> any-valid Renames GtsRefValidationMode.PRESENCE/FULL to ANY_PRESENT/ANY_VALID and updates their string values across ops.py, _server.py, store.py, traits.py, and x_gts_ref.py to match the gts-spec rename. Signed-off-by: Artifizer --- gts/src/gts/_server.py | 2 +- gts/src/gts/gts_ref_validation.py | 4 ++-- gts/src/gts/ops.py | 10 +++++----- gts/src/gts/store.py | 14 +++++++------- gts/src/gts/traits.py | 2 +- gts/src/gts/x_gts_ref.py | 6 +++--- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/gts/src/gts/_server.py b/gts/src/gts/_server.py index 2819f6b..c02cd8f 100644 --- a/gts/src/gts/_server.py +++ b/gts/src/gts/_server.py @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) GTS_REF_VALIDATION_QUERY = Query( - GtsRefValidationMode.FULL, alias="gts-ref-validation" + GtsRefValidationMode.ANY_VALID, alias="gts-ref-validation" ) diff --git a/gts/src/gts/gts_ref_validation.py b/gts/src/gts/gts_ref_validation.py index 67c0b25..d3582b0 100644 --- a/gts/src/gts/gts_ref_validation.py +++ b/gts/src/gts/gts_ref_validation.py @@ -3,5 +3,5 @@ class GtsRefValidationMode(str, Enum): NONE = "none" - PRESENCE = "presence" - FULL = "full" + ANY_PRESENT = "any-present" + ANY_VALID = "any-valid" diff --git a/gts/src/gts/ops.py b/gts/src/gts/ops.py index 5d6b452..f9709b9 100644 --- a/gts/src/gts/ops.py +++ b/gts/src/gts/ops.py @@ -24,7 +24,7 @@ def _normalize_gts_ref_validation(value: Any) -> GtsRefValidationMode: try: return GtsRefValidationMode(value) except (TypeError, ValueError): - return GtsRefValidationMode.FULL + return GtsRefValidationMode.ANY_VALID @dataclass @@ -397,7 +397,7 @@ def add_entity( self, content: dict[str, Any], validate: bool = False, - gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.ANY_VALID, ) -> GtsAddEntityResult: gts_ref_validation = _normalize_gts_ref_validation(gts_ref_validation) entity = GtsEntity(content=content, cfg=self.cfg) @@ -671,7 +671,7 @@ def validate_json( def validate_instance( self, gts_id: str, - gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.ANY_VALID, ) -> GtsValidationResult: gts_ref_validation = _normalize_gts_ref_validation(gts_ref_validation) try: @@ -683,7 +683,7 @@ def validate_instance( def validate_schema( self, gts_id: str, - gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.ANY_VALID, ) -> GtsValidationResult: gts_ref_validation = _normalize_gts_ref_validation(gts_ref_validation) try: @@ -695,7 +695,7 @@ def validate_schema( def validate_entity( self, gts_id: str, - gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.ANY_VALID, ) -> GtsEntityValidationResult: gts_ref_validation = _normalize_gts_ref_validation(gts_ref_validation) entity = self.store.get(gts_id) diff --git a/gts/src/gts/store.py b/gts/src/gts/store.py index 96243ae..f8c601e 100644 --- a/gts/src/gts/store.py +++ b/gts/src/gts/store.py @@ -612,7 +612,7 @@ def _validate_traits( gts_id: str, is_abstract: bool, transient_schema: dict[str, Any] | None = None, - gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.ANY_VALID, ) -> None: """Validate OP#13: schema traits for a type.""" effective = self._build_effective_traits(gts_id, transient_schema) @@ -673,7 +673,7 @@ def validate_schema_content( self, gts_id: str, schema_content: dict[str, Any], - gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.ANY_VALID, ) -> None: """Validate a schema using the registry only for its dependencies.""" schema_id = _require_schema_id(gts_id) @@ -722,7 +722,7 @@ def validate_schema_content( def validate_schema( self, gts_id: str, - gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.ANY_VALID, ) -> None: """Validate a registered schema and all of its dependencies.""" self._validate_schema_transitive(gts_id, set(), set(), gts_ref_validation) @@ -782,7 +782,7 @@ def _validate_schema_transitive( schema_ref_validator.referenced_wildcard_patterns | trait_ref_validator.referenced_wildcard_patterns ) - if gts_ref_validation is GtsRefValidationMode.FULL: + if gts_ref_validation is GtsRefValidationMode.ANY_VALID: for dependency_id in xref_ids: try: self._validate_entity_transitive( @@ -907,7 +907,7 @@ def validate_instance_content( self, content: dict[str, Any], type_id: str, - gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.ANY_VALID, ) -> set[str]: """Validate unregistered instance content against a registered type schema.""" schema_type = _require_schema_id(type_id) @@ -946,7 +946,7 @@ def validate_instance_content( def validate_instance( self, gts_id: str, - gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.ANY_VALID, ) -> None: """Validate an object instance and its complete dependency closure.""" self._validate_instance_transitive(gts_id, set(), set(), gts_ref_validation) @@ -981,7 +981,7 @@ def _validate_instance_transitive( f"Instance type '{obj.type_id}' is invalid: {error}" ) from error - if gts_ref_validation is GtsRefValidationMode.FULL: + if gts_ref_validation is GtsRefValidationMode.ANY_VALID: for dependency_id in referenced_ids: try: self._validate_entity_transitive( diff --git a/gts/src/gts/traits.py b/gts/src/gts/traits.py index 9122329..36c40c5 100644 --- a/gts/src/gts/traits.py +++ b/gts/src/gts/traits.py @@ -57,7 +57,7 @@ def validate( self, check_unresolved: bool, reference_store: Any | None = None, - gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.FULL, + gts_ref_validation: GtsRefValidationMode = GtsRefValidationMode.ANY_VALID, selected_type_id: str | None = None, ) -> list[str]: """Return a list of error strings (empty means valid).""" diff --git a/gts/src/gts/x_gts_ref.py b/gts/src/gts/x_gts_ref.py index 32d72da..f0b6ce7 100644 --- a/gts/src/gts/x_gts_ref.py +++ b/gts/src/gts/x_gts_ref.py @@ -80,18 +80,18 @@ class XGtsRefValidator: def __init__( self, store: Any | None = None, - mode: GtsRefValidationMode | bool | str = GtsRefValidationMode.FULL, + mode: GtsRefValidationMode | bool | str = GtsRefValidationMode.ANY_VALID, *, enforce_existence: bool | None = None, ): if enforce_existence is not None: mode = ( - GtsRefValidationMode.PRESENCE + GtsRefValidationMode.ANY_PRESENT if enforce_existence else GtsRefValidationMode.NONE ) elif isinstance(mode, bool): - mode = GtsRefValidationMode.PRESENCE if mode else GtsRefValidationMode.NONE + mode = GtsRefValidationMode.ANY_PRESENT if mode else GtsRefValidationMode.NONE self.store = store self.mode = GtsRefValidationMode(mode) self.referenced_ids: set[str] = set()