Skip to content

Fix nested local reference resolution - #700

Open
Guflly wants to merge 2 commits into
python-jsonschema:mainfrom
Guflly:fix/nested-local-ref-resolution
Open

Fix nested local reference resolution#700
Guflly wants to merge 2 commits into
python-jsonschema:mainfrom
Guflly:fix/nested-local-ref-resolution

Conversation

@Guflly

@Guflly Guflly commented Aug 2, 2026

Copy link
Copy Markdown

Uses the root schema's retrieval URI as the referencing resolver base when $id is absent, so nested local refs resolve from the document that declares them without altering schema contents.

Adds acceptance coverage for both valid and invalid values through a three-file nested ref chain.

Local checks: 607 tests passed, 20 skipped; minimum jsonschema 4.18 coverage, mypy, and pre-commit pass.

Fixes #640

@Guflly
Guflly force-pushed the fix/nested-local-ref-resolution branch from 27d581e to 13251e5 Compare August 2, 2026 18:32
@sirosen

sirosen commented Aug 8, 2026

Copy link
Copy Markdown
Member

Hi, thanks for this! It looks more correct than the last fix someone sent, but I don't quite understand why it should work.

The current reference registry is built such that it's supposed to fallback to the retrieval URI if the $id is not present. I'm not clear on why it's not working in this case, but I'd rather that we fix it than start mutating the schema being evaluated.

Do you know why this is effective, when the lines here are meant to have a similar effect already?

@Guflly

Guflly commented Aug 9, 2026

Copy link
Copy Markdown
Author

You're right. The registry had the root resource under its retrieval URI, but jsonschema built a separate resolver from the schema contents. Without $id, that resolver started with an empty base. The first relative ref was loaded through the retrieval fallback, then stored under its relative lookup key, so the nested ref was joined against that relative key before the fallback ran.

I removed the schema rewrite and now start the resolver at the retrieval URI while retaining the standard specification registry. I also added a failing nested case to prove the third schema is applied.

if retrieval_uri is not None and not isinstance(schema.get("$id"), str):
reference_resolver = SPECIFICATIONS.combine(reference_registry).resolver(
retrieval_uri
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does the call to SPECIFICATIONS.combine() do here? I was just experimenting with a patch using reference_registry.resolver(), which also seems to work.
It may be correct, but I'd like to know why before proceeding.

Also, if we are adding direct use of jsonschema_specifications, it should be added as a direct dependency, even if the version is not bounded.

@sirosen

sirosen commented Aug 9, 2026

Copy link
Copy Markdown
Member

Assuming this higher level fix is put in place, updating the resolver base URI, the retrieval method should be simplified.

I just tested a version with a similar patch to the one in this PR, but with the retrieval callback updated to get rid of redundant path joining, and everything seemed to pass tests and work.

As a possible starting point, here's the change I tested for the callback
diff --git a/src/check_jsonschema/schema_loader/resolver.py b/src/check_jsonschema/schema_loader/resolver.py
index 15344d6..ddf2f80 100644
--- a/src/check_jsonschema/schema_loader/resolver.py
+++ b/src/check_jsonschema/schema_loader/resolver.py
@@ -26,9 +26,7 @@ def make_reference_registry(
     # mypy does not recognize that Registry is an `attrs` class and has `retrieve` as an
     # argument to its implicit initializer
     registry: referencing.Registry = referencing.Registry(  # type: ignore[call-arg]
-        retrieve=create_retrieve_callable(
-            parsers, retrieval_uri, id_attribute, disable_cache
-        )
+        retrieve=create_retrieve_callable(parsers, disable_cache)
     )
 
     if retrieval_uri is not None:
@@ -41,14 +39,8 @@ def make_reference_registry(
 
 def create_retrieve_callable(
     parser_set: ParserSet,
-    retrieval_uri: str | None,
-    id_attribute: str | None,
     disable_cache: bool,
 ) -> t.Callable[[str], referencing.Resource[Schema]]:
-    base_uri = id_attribute
-    if base_uri is None:
-        base_uri = retrieval_uri
-
     cache = ResourceCache()
     downloader = CacheDownloader("refs", disable_cache=disable_cache)
 
@@ -57,33 +49,26 @@ def create_retrieve_callable(
         return parser_set.parse_file(path, "json")
 
     def retrieve_reference(uri: str) -> referencing.Resource[Schema]:
-        scheme = urllib.parse.urlsplit(uri).scheme
-        if scheme == "" and base_uri is not None:
-            full_uri = urllib.parse.urljoin(base_uri, uri)
-        else:
-            full_uri = uri
+        if uri in cache:
+            return cache[uri]
 
-        if full_uri in cache:
-            return cache[full_uri]
-
-        full_uri_scheme = urllib.parse.urlsplit(full_uri).scheme
-        if full_uri_scheme in ("http", "https"):
+        uri_scheme = urllib.parse.urlsplit(uri).scheme
+        if uri_scheme in ("http", "https"):
 
             def validation_callback(content: bytes) -> None:
-                parser_set.parse_data_with_path(content, full_uri, "json")
+                parser_set.parse_data_with_path(content, uri, "json")
 
             bound_downloader = downloader.bind(
-                full_uri, validation_callback=validation_callback
+                uri, validation_callback=validation_callback
             )
             with bound_downloader.open() as fp:
                 data = fp.read()
 
-            parsed_object = parser_set.parse_data_with_path(data, full_uri, "json")
+            parsed_object = parser_set.parse_data_with_path(data, uri, "json")
         else:
-            parsed_object = get_local_file(full_uri)
+            parsed_object = get_local_file(uri)
 
         cache[full_uri] = parsed_object
         return cache[full_uri]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unable to resolve nested $refs when using local files

2 participants