Skip to content
Open
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
6 changes: 6 additions & 0 deletions docs/documentation/schema-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,12 @@ comment. Unannotated blocks fail CI.
| `direction` | no | `response` | `request` or `response` |
| `extract` | no | `$` | JSONPath inside the displayed block; selected subtree becomes the example |
| `target` | no | `$` | JSONPath into the schema/scaffold; example replaces a sub-tree |

JSONPath here is a small subset: dot-separated bare names with an optional
array index (`$.a.b[0]`), plus bracket-quoted names for keys that are not
bare-legal (`$.a['dev.ucp.common.identity_linking'][0]`). Use the quoted form
for reverse-domain keys and scope tokens — their dots and colons are otherwise
read as path separators.
| `def` | no | — | Pull `$defs/<name>` out of the schema and validate against that |
| `skip reason` | yes (with skip) | — | Free-form prose explaining why this block can't be validated |

Expand Down
96 changes: 96 additions & 0 deletions scripts/test_validate_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,101 @@ def fake_run(*args: object, **kwargs: object) -> object:
v._schema_cache.clear()


# -----------------------------------------------------------
# JSONPath subset: bare, indexed and bracket-quoted segments
# -----------------------------------------------------------


def test_jsonpath_quoted_keys() -> None:
"""Keys that are not bare-legal are reachable in bracket-quoted form.

UCP uses reverse-domain identifiers and scope tokens as object keys
(`dev.ucp.common.identity_linking`, `dev.ucp.shopping.order:read`).
Their dots and colons cannot appear in a bare segment, so extract=
and target= must accept the quoted form to address them at all.
"""
doc = {
"capabilities": {
"dev.ucp.common.identity_linking": [{"config": {"scopes": {}}}]
},
"config": {
"scopes": {"dev.ucp.shopping.order:read": {"description": "d"}},
"providers": {"com.example.attestor": [{"type": "wallet_attestation"}]},
},
"messages": [{"code": "identity_optional"}],
}

# Bare and indexed paths are unchanged.
_check(
"jsonpath_bare_unchanged",
v.jsonpath_get(doc, "$.messages")[0]["code"] == "identity_optional",
)
_check(
"jsonpath_index_unchanged",
v.jsonpath_get(doc, "$.messages[0]")["code"] == "identity_optional",
)
_check("jsonpath_root_unchanged", v.jsonpath_get(doc, "$") is doc)

# Dotted key, with and without a trailing index.
_check(
"jsonpath_quoted_dotted_key",
v.jsonpath_get(doc, "$.config.providers['com.example.attestor'][0]")["type"]
== "wallet_attestation",
)
_check(
"jsonpath_quoted_capability_name",
v.jsonpath_get(doc, "$.capabilities['dev.ucp.common.identity_linking'][0]")[
"config"
]
== {"scopes": {}},
)

# Colon-bearing scope token, and the double-quoted spelling.
_check(
"jsonpath_quoted_scope_token",
v.jsonpath_get(doc, "$.config.scopes['dev.ucp.shopping.order:read']")[
"description"
]
== "d",
)
_check(
"jsonpath_quoted_double_quotes",
v.jsonpath_get(doc, '$.config.scopes["dev.ucp.shopping.order:read"]')[
"description"
]
== "d",
)

# target= writes through the same parser.
target = {
"config": {"providers": {"com.example.attestor": [{"type": "oauth2"}]}}
}
v.jsonpath_set(
target,
"$.config.providers['com.example.attestor'][0]",
{"type": "wallet_attestation"},
)
_check(
"jsonpath_set_quoted_key",
target["config"]["providers"]["com.example.attestor"][0]["type"]
== "wallet_attestation",
)

# Elision paths are reported as JSON Pointers against the same subset.
_check(
"jsonpath_pointer_quoted_key",
v.jsonpath_to_pointer("$.config.providers['com.example.attestor'][0]")
== "/config/providers/com.example.attestor/0",
)

# Unparsable segments still raise rather than silently mis-navigating.
try:
v.jsonpath_get(doc, "$.config.providers.com.example.attestor")
_check("jsonpath_unquoted_dotted_key_raises", False, "expected KeyError")
except KeyError:
_check("jsonpath_unquoted_dotted_key_raises", True)


# -----------------------------------------------------------
# Main
# -----------------------------------------------------------
Expand All @@ -642,6 +737,7 @@ def main() -> int:
test_annotation_parsing()
test_extract_blocks()
test_scaffold_resolution()
test_jsonpath_quoted_keys()
test_resolve_schema_cache_key()
test_process_block_integration()
return _report()
Expand Down
78 changes: 57 additions & 21 deletions scripts/validate_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@

- If extract= is present, the indicated subtree is selected from
the parsed displayed example before semantic validation.

JSONPath subset used by extract= and target=: dot-separated bare
names with an optional array index (`$.a.b[0]`), plus bracket-quoted
names for keys that are not bare-legal (`$.a['dev.ucp.common.x'][0]`).
The quoted form is required for UCP's reverse-domain keys and scope
tokens, whose dots and colons are otherwise read as separators.
- Ellipsis sentinels are recorded as elided JSON Pointer paths,
then removed from the tree.
- The example is deep-merged into a scaffold (a known-valid
Expand Down Expand Up @@ -424,19 +430,59 @@ def strip_ellipsis(obj, _path="", _paths=None):
# JSONPath navigation (minimal subset)
# -----------------------------------------------------------

_SEGMENT_RE = re.compile(r"^(\w+)(?:\[(\d+)\])?$")
_BARE_SEGMENT_RE = re.compile(r"\w+")
_QUOTED_SEGMENT_RE = re.compile(r"\[(['\"])(.*?)\1\]")
_INDEX_RE = re.compile(r"\[(\d+)\]")


def split_path(path: str) -> list[tuple[str, str | None]]:
"""Tokenize the supported JSONPath subset into (name, index) pairs.

Bare names are dot-separated (`$.a.b`) and may carry a single array
index (`$.a[0]`). Names that are not bare-legal are written in
bracket-quoted form (`$.a['x.y'][0]`) — required for the
reverse-domain identifiers and scope tokens UCP uses as object keys,
such as `dev.ucp.common.identity_linking` and
`dev.ucp.shopping.order:read`, whose dots and colons would otherwise
be read as path separators or fail to match.

Raises KeyError on an unparsable segment.
"""
segments: list[tuple[str, str | None]] = []
pos = 1 if path.startswith("$") else 0
while pos < len(path):
if path[pos] == ".":
pos += 1
continue
quoted = _QUOTED_SEGMENT_RE.match(path, pos)
if quoted:
name = quoted.group(2)
pos = quoted.end()
else:
bare = _BARE_SEGMENT_RE.match(path, pos)
if not bare:
raise KeyError(path[pos:])
name = bare.group(0)
pos = bare.end()
index = None
indexed = _INDEX_RE.match(path, pos)
if indexed:
index = indexed.group(1)
pos = indexed.end()
segments.append((name, index))
return segments


def jsonpath_to_pointer(path: str) -> str:
"""Convert the supported JSONPath subset to a JSON Pointer prefix."""
if path == "$":
return ""
try:
segments = split_path(path)
except KeyError:
return ""
pointer_parts: list[str] = []
for seg in path.lstrip("$").lstrip(".").split("."):
m = _SEGMENT_RE.match(seg)
if not m:
return ""
name, idx = m.group(1), m.group(2)
for name, idx in segments:
pointer_parts.append(name)
if idx is not None:
pointer_parts.append(idx)
Expand All @@ -448,11 +494,7 @@ def jsonpath_get(obj, path: str):
if path == "$":
return obj
current = obj
for seg in path.lstrip("$").lstrip(".").split("."):
m = _SEGMENT_RE.match(seg)
if not m:
raise KeyError(seg)
name, idx = m.group(1), m.group(2)
for name, idx in split_path(path):
current = current[name]
if idx is not None:
current = current[int(idx)]
Expand All @@ -461,16 +503,13 @@ def jsonpath_get(obj, path: str):

def jsonpath_set(obj: dict, path: str, value):
"""Set a value at a JSONPath. Mutates obj."""
segments = path.lstrip("$").lstrip(".").split(".")
segments = split_path(path)
current = obj
for seg in segments[:-1]:
m = _SEGMENT_RE.match(seg)
name, idx = m.group(1), m.group(2)
for name, idx in segments[:-1]:
current = current[name]
if idx is not None:
current = current[int(idx)]
last = _SEGMENT_RE.match(segments[-1])
name, idx = last.group(1), last.group(2)
name, idx = segments[-1]
if idx is not None:
current[name][int(idx)] = value
else:
Expand All @@ -479,11 +518,8 @@ def jsonpath_set(obj: dict, path: str, value):

def jsonpath_get_schema(schema: dict, path: str) -> dict:
"""Navigate a JSON Schema to the sub-schema at path."""
segments = path.lstrip("$").lstrip(".").split(".")
current = schema
for seg in segments:
m = _SEGMENT_RE.match(seg)
name, idx = m.group(1), m.group(2)
for name, idx in split_path(path):
# Resolve through allOf to find properties
current = _get_property_schema(current, name)
if current is None:
Expand Down
Loading