Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gts-spec
Submodule .gts-spec updated 33 files
+2 −2 .github/workflows/release-tests-image.yml
+21 −6 README.md
+72 −0 adr/0005-json-schema-format-assertions.md
+17 −0 examples/events/README.md
+1 −1 examples/events/instances/gts.x.core.events.topic.v1~x.commerce.orders.orders.v1.0.json
+19 −0 examples/events/instances/gts.x.core.events.topic.v1~x.core.audit.events.v1.json
+46 −0 ...ents/instances/gts.x.core.events.type.v1~x.core.audit.type.v1~x.core.iam.settings_changed.v1~.examples.json
+16 −0 .../instances/gts.x.core.iam.settings.v1~x.core.iam.security_policy.v1~x.core.iam.mfa_policy.v1~.examples.json
+18 −0 ...ances/gts.x.core.iam.settings.v1~x.core.iam.security_policy.v1~x.core.iam.password_policy.v1~.examples.json
+5 −0 examples/events/instances/gts.x.core.iam.settings_category.v1~x.core.iam.lockout.v1.json
+5 −0 examples/events/instances/gts.x.core.iam.settings_category.v1~x.core.iam.mfa.v1.json
+5 −0 examples/events/instances/gts.x.core.iam.settings_category.v1~x.core.iam.password_policy.v1.json
+5 −0 examples/events/instances/gts.x.core.iam.settings_category.v1~x.core.iam.session.v1.json
+1 −1 examples/events/types/gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~.schema.json
+1 −1 examples/events/types/gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.1~.schema.json
+47 −0 examples/events/types/gts.x.core.events.type.v1~x.core.audit.type.v1~.schema.json
+47 −0 ...les/events/types/gts.x.core.events.type.v1~x.core.audit.type.v1~x.core.iam.settings_changed.v1~.schema.json
+42 −0 examples/events/types/gts.x.core.iam.settings.v1~.schema.json
+27 −0 examples/events/types/gts.x.core.iam.settings.v1~x.core.iam.security_policy.v1~.schema.json
+34 −0 ...events/types/gts.x.core.iam.settings.v1~x.core.iam.security_policy.v1~x.core.iam.mfa_policy.v1~.schema.json
+41 −0 ...s/types/gts.x.core.iam.settings.v1~x.core.iam.security_policy.v1~x.core.iam.password_policy.v1~.schema.json
+23 −0 examples/events/types/gts.x.core.iam.settings_category.v1~.schema.json
+186 −2 tests/openapi.json
+1 −0 tests/requirements.txt
+40 −0 tests/test_op10_query_execution.py
+1 −1 tests/test_op12_type_derivation_validation.py
+571 −0 tests/test_op13_schema_traits_validation.py
+1,595 −21 tests/test_op6_schema_validation.py
+382 −0 tests/test_op8_compatibility_checking.py
+233 −0 tests/test_op9_version_casting.py
+46 −0 tests/test_refimpl_x_gts_final_abstract.py
+153 −0 tests/test_refimpl_x_gts_ref.py
+128 −0 tests/test_xgts_keyword_placement.py
6 changes: 4 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ py-env: $(PY_ENV_STAMP)
$(PY_ENV_PYTHON):
$(PYTHON_BOOTSTRAP) -m venv --clear $(PY_ENV_DIR)

$(PY_ENV_STAMP): $(PY_ENV_PYTHON) gts/pyproject.toml .gts-spec/tests/requirements.txt Makefile
$(PY_ENV_STAMP): $(PY_ENV_PYTHON) gts/pyproject.toml .gts-spec/tests/requirements.txt requirements.txt Makefile
@echo "Creating/updating Python virtual environment in $(PY_ENV_DIR)..."
$(PYTHON_BOOTSTRAP) -m venv $(PY_ENV_DIR)
$(PYTHON) -m pip install --upgrade pip
# Spec test-client deps, then httprunner (--no-deps: its own pins are
# incompatible with this venv), then local dev tooling + version overrides.
$(PYTHON) -m pip install -r .gts-spec/tests/requirements.txt
$(PYTHON) -m pip install --no-deps 'httprunner>=4,<5'
$(PYTHON) -m pip install ruff mypy
$(PYTHON) -m pip install -r requirements.txt
@touch $@

# Install gts package into the venv (editable, for development)
Expand Down
6 changes: 5 additions & 1 deletion gts/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "gts"
version = "0.13.1"
version = "0.13.2"
description = "Global Type System (GTS) helpers: identifiers, parsing, validation, and operations"
readme = "README.md"
authors = [{ name = "GTS Community" }]
Expand All @@ -15,8 +15,12 @@ dependencies = [
"referencing>=0.30,<0.37",
"jsonsubschema>=0.0.8,<0.1",
"fastapi>=0.110,<1",
# Server models use pydantic v2 APIs (model_validator); declare it directly
# rather than relying on FastAPI to pull a compatible version transitively.
"pydantic>=2,<3",
"uvicorn>=0.23,<1",
"pyyaml>=6.0,<7",
"regex==2025.11.3",
"eval_type_backport>=0.1,<0.3; python_version < '3.10'"
]

Expand Down
71 changes: 71 additions & 0 deletions gts/src/gts/_json_pointer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""RFC 6901 JSON Pointer resolution.

A JSON Pointer (RFC 6901) addresses a single value inside a JSON document, e.g.
``/properties/type``. Because ``/`` separates reference tokens and ``~`` begins
an escape sequence, those two characters are escaped *inside* a token:

- ``~1`` denotes a literal ``/``
- ``~0`` denotes a literal ``~``

Unescaping MUST replace ``~1`` before ``~0``; otherwise an encoded ``~01`` would
be corrupted. This module is the single home for that logic, which was
previously duplicated (with the same ``~1``/``~0`` magic) across ``traits.py``
and ``x_gts_ref.py``.
"""

from __future__ import annotations

from typing import Any
from urllib.parse import unquote

# Sentinel distinguishing "pointer resolved to a real ``None``" from
# "pointer could not be resolved". Callers that care should pass this (or their
# own default) and compare identity against the returned value.
MISSING: Any = object()


def unescape_token(token: str) -> str:
"""Decode a single RFC 6901 reference token (``~1`` -> ``/``, ``~0`` -> ``~``)."""
return token.replace("~1", "/").replace("~0", "~")


def resolve(document: Any, pointer: str, default: Any = None) -> Any:
"""Resolve an RFC 6901 JSON Pointer against ``document``.

``pointer`` accepts three equivalent spellings:

- the empty string ``""`` - the whole document;
- a pointer beginning with ``/`` - ``/a/b``;
- a same-document URI fragment - ``#`` or ``#/a/b``.

Returns ``default`` if any reference token cannot be resolved (missing key,
non-integer/out-of-range array index, or descending into a scalar).
"""
if pointer.startswith("#"):
pointer = unquote(pointer[1:])
if pointer == "":
return document
if not pointer.startswith("/"):
return default

current = document
for raw_token in pointer.split("/")[1:]:
token = unescape_token(raw_token)
if isinstance(current, dict):
if token not in current:
return default
current = current[token]
elif isinstance(current, list):
if not (
token.isascii()
and token.isdecimal()
and (token == "0" or not token.startswith("0"))
):
return default
try:
current = current[int(token)]
except IndexError:
return default
else:
return default
return current
6 changes: 3 additions & 3 deletions gts/src/gts/_json_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
from pathlib import Path
from typing import Any

from ._naming import GTS_PREFIX, GTS_URI_PREFIX, looks_like_gts
from .entities import GtsEntity, GtsFile
from .files_reader import DEFAULT_EXCLUDE_LIST
from .gts import GTS_PREFIX, GTS_URI_PREFIX, GtsID
from .gts import GtsID
from .store import GtsStore

_X_GTS_REF_KEYWORD = "x-gts-ref"
Expand Down Expand Up @@ -232,8 +233,7 @@ def _is_gts_related(self, value: Any) -> bool:

@staticmethod
def _looks_gts(v: str) -> bool:
normalized = v.removeprefix(GTS_URI_PREFIX)
return normalized.startswith(GTS_PREFIX) or v.startswith(GTS_URI_PREFIX)
return looks_like_gts(v)

@staticmethod
def _registry_key(entity: GtsEntity) -> str | None:
Expand Down
62 changes: 62 additions & 0 deletions gts/src/gts/_naming.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Internal naming primitives for GTS identifiers.

This module is the single, **internal** home for the low-level string handling
of GTS identifiers:

- the ``gts://`` URI scheme,
- the bare ``gts.`` prefix, and
- the ``~`` type marker.

It is deliberately private (underscore-prefixed and absent from the public
package exports). Consumers of the SDK should never reach for these primitives:
they work with the :class:`~gts.gts.GtsID` value object and high-level
operations (validate, cast, resolve, store lookups, ...), all of which normalize
identifiers internally. Keeping this logic in one private place stops it from
leaking across the library and onto the public API surface.
"""

from __future__ import annotations

# Distinguishes GTS identifiers from other strings; also used for URI encoding.
GTS_PREFIX = "gts."
GTS_URI_PREFIX = "gts://"
# Separates the segments of a chained identifier and, at the very end, marks a
# type identifier (e.g. ``gts.acme.pkg._.user.v1~``).
GTS_TYPE_MARKER = "~"


def strip_scheme(value: str) -> str:
"""Return the canonical bare form, dropping any ``gts://`` scheme.

Non-GTS strings are returned unchanged, so this is safe to call on arbitrary
registry keys or ``$ref`` targets at a boundary.
"""
return value.removeprefix(GTS_URI_PREFIX)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def has_scheme(value: str) -> bool:
"""True if ``value`` carries the ``gts://`` URI scheme."""
return value.startswith(GTS_URI_PREFIX)


def with_scheme(value: str) -> str:
"""Return the ``gts://`` URI encoding of ``value`` (idempotent)."""
return value if has_scheme(value) else GTS_URI_PREFIX + value


def looks_like_gts(value: str) -> bool:
"""True if ``value`` looks like a GTS identifier in either encoding.

A cheap prefix check (bare ``gts.`` or ``gts://``); it does not fully
validate the identifier.
"""
return value.startswith((GTS_URI_PREFIX, GTS_PREFIX))


def is_type_ref(value: str) -> bool:
"""True if the (scheme-stripped) identifier denotes a type.

Type identifiers end with the type marker ``~``; instance identifiers do
not.
"""
return strip_scheme(value).endswith(GTS_TYPE_MARKER)
47 changes: 41 additions & 6 deletions gts/src/gts/compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,37 @@ def sanitize(schema: Any) -> Any:
return schema


def _lower_root_unevaluated_properties(schema: Any) -> Any | None:
if not isinstance(schema, dict) or "unevaluatedProperties" not in schema:
return schema
if any(
key in schema
for key in (
"$ref",
"$dynamicRef",
"allOf",
"anyOf",
"oneOf",
"if",
"then",
"else",
"not",
"dependentSchemas",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
):
return None
unevaluated = schema["unevaluatedProperties"]
if (
"additionalProperties" in schema
and schema["additionalProperties"] != unevaluated
):
return None
result = dict(schema)
result.pop("unevaluatedProperties")
result.setdefault("additionalProperties", unevaluated)
return result


def _coerce_bool_schema(schema: Any) -> Any:
"""Turn a top-level boolean schema into its object-equivalent.

Expand All @@ -147,14 +178,18 @@ def _coerce_bool_schema(schema: Any) -> Any:

def _is_subschema(subset: Any, superset: Any) -> bool | None:
"""``Valid(subset) subset-of Valid(superset)`` or ``None`` when unprovable."""
finite_result = _finite_subset(subset, superset)
lowered_subset = _lower_root_unevaluated_properties(subset)
lowered_superset = _lower_root_unevaluated_properties(superset)
if lowered_subset is None or lowered_superset is None:
return None
finite_result = _finite_subset(lowered_subset, lowered_superset)
if finite_result is not None:
return finite_result
try:
return bool(
isSubschema(
_coerce_bool_schema(sanitize(subset)),
_coerce_bool_schema(sanitize(superset)),
_coerce_bool_schema(sanitize(lowered_subset)),
_coerce_bool_schema(sanitize(lowered_superset)),
)
)
except Exception: # noqa: BLE001 - intentional broad fallback
Expand All @@ -172,7 +207,7 @@ def _canonical_dialect(declared: str) -> str:
return body.removeprefix("https://").removeprefix("http://")


def _dialect_changed(old_schema: Any, new_schema: Any) -> bool:
def dialects_differ(old_schema: Any, new_schema: Any) -> bool:
if not isinstance(old_schema, dict) or not isinstance(new_schema, dict):
return False
old_dialect = old_schema.get("$schema")
Expand All @@ -186,14 +221,14 @@ def _dialect_changed(old_schema: Any, new_schema: Any) -> bool:

def check_backward_compatibility(old_schema: Any, new_schema: Any) -> str:
"""new consumers read old data: ``Valid(old) subset-of Valid(new)``."""
if _dialect_changed(old_schema, new_schema):
if dialects_differ(old_schema, new_schema):
return UNKNOWN
return _verdict(_is_subschema(old_schema, new_schema))


def check_forward_compatibility(old_schema: Any, new_schema: Any) -> str:
"""old consumers read new data: ``Valid(new) subset-of Valid(old)``."""
if _dialect_changed(old_schema, new_schema):
if dialects_differ(old_schema, new_schema):
return UNKNOWN
return _verdict(_is_subschema(new_schema, old_schema))

Expand Down
40 changes: 14 additions & 26 deletions gts/src/gts/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

from .gts import GTS_PREFIX, GTS_URI_PREFIX, GtsID
from ._naming import GTS_PREFIX, has_scheme, strip_scheme
from .gts import GtsID
from .schema_cast import GtsEntityCastResult, SchemaCastError

if TYPE_CHECKING:
Expand Down Expand Up @@ -258,8 +259,7 @@ def _extract_gts_ids_with_paths(self) -> list[dict[str, str]]:
def gts_id_matcher(node: Any, path: str) -> dict[str, str] | None:
"""Match GTS ID strings."""
if isinstance(node, str):
val = node
val = val.removeprefix("gts://")
val = strip_scheme(node)
if GtsID.is_valid(val):
return {"id": val, "sourcePath": path or "root"}
return None
Expand All @@ -274,9 +274,7 @@ def _extract_ref_strings_with_paths(self) -> list[dict[str, str]]:
def ref_matcher(node: Any, path: str) -> dict[str, str] | None:
"""Match $ref properties in dict nodes."""
if isinstance(node, dict) and isinstance(node.get("$ref"), str):
val = node["$ref"]
# Issue #32: handle gts:// prefix
val = val.removeprefix("gts://")
val = strip_scheme(node["$ref"])
ref_path = f"{path}.$ref" if path else "$ref"
return {"id": val, "sourcePath": ref_path}
return None
Expand All @@ -290,9 +288,8 @@ def _get_field_value(self, field: str) -> str | None:
return None
v = self.content.get(field)
if isinstance(v, str) and v.strip():
# Issue #31, #32: Handle gts:// prefix in fields (e.g. $id)
v = v.removeprefix("gts://")
return v
# Normalize the ``gts://`` scheme at this document boundary.
return strip_scheme(v)
return None

def _schema_id_uses_plain_prefix(self) -> bool:
Expand All @@ -308,7 +305,7 @@ def _schema_id_uses_plain_prefix(self) -> bool:
if not isinstance(raw, str):
return False
raw = raw.strip()
return raw.startswith(GTS_PREFIX) and not raw.startswith(GTS_URI_PREFIX)
return raw.startswith(GTS_PREFIX) and not has_scheme(raw)

def _first_non_empty_field(self, fields: list[str]) -> tuple[str, str] | None:
"""Find first non-empty field value in order.
Expand Down Expand Up @@ -348,12 +345,10 @@ def _calc_json_schema_id(self, cfg: GtsConfig) -> str | None:
# type_id is the parent (everything up to the second-to-last '~').
# idv ends with '~' for schemas.
# Strip trailing '~' to find internal chain boundaries.
inner = idv.removesuffix("~")
last_tilde = inner.rfind("~")
if last_tilde > 0:
# Has at least 2 segments - return parent chain
parent_type_id = GtsID(idv).parent_type_id
if parent_type_id:
self.selected_type_id_field = "$id"
return inner[: last_tilde + 1]
return parent_type_id
# Base schema (single segment) - no GTS parent type.
# The $schema URL is NOT a GTS Type Identifier.
return None
Expand All @@ -368,17 +363,13 @@ def _calc_json_schema_id(self, cfg: GtsConfig) -> str | None:
if entity_id_cand[0] == "$id" and not self.is_schema:
pass # Skip to PRIORITY 2
else:
idv = entity_id_cand[1]
# If already a type id (ends with '~'), use it as-is
if idv.endswith("~"):
self.selected_type_id_field = entity_id_cand[0]
return idv
# For chained IDs (well-known instances), extract schema:
# everything up to and including last '~'
last_tilde = idv.rfind("~")
if last_tilde > 0:
type_id = GtsID(entity_id_cand[1]).type_id
if type_id:
self.selected_type_id_field = entity_id_cand[0]
return idv[: last_tilde + 1]
return type_id

# PRIORITY 2: Fall back to explicit schema_id_fields (type, gtsTid, etc.)
# Only check these if no chained GTS ID was found in entity_id_fields
Expand All @@ -389,10 +380,7 @@ def _calc_json_schema_id(self, cfg: GtsConfig) -> str | None:
type_id_val = cand[1]
# If type_id is a chained GTS ID, extract parent (base type)
if GtsID.is_valid(type_id_val):
last_tilde = type_id_val.rfind("~")
if last_tilde > 0 and not type_id_val.endswith("~"):
# It's an instance ID in type field - extract schema part
return type_id_val[: last_tilde + 1]
return GtsID(type_id_val).type_id
return type_id_val

# No schema reference found for instance
Expand Down
Loading
Loading