Repository: vega/altair

Issue:
Code path in utils emits `jsonschema` deprecation warning
[jsonschema](https://pypi.org/project/jsonschema/) recently released a new version and introduced a new deprecation warning around `jsonschema.RefResolver` - this is utilized in `altair/utils/schemapi.py`.

You can replicate with the following code:
```python
import altair as alt

alt.StandardType("...")
```

```
/opt/hostedtoolcache/Python/3.8.17/x64/lib/python3.8/site-packages/altair/vegalite/v4/schema/core.py:15771: in __init__
    super(StandardType, self).__init__(*args)
/opt/hostedtoolcache/Python/3.8.17/x64/lib/python3.8/site-packages/altair/utils/schemapi.py:197: in __init__
    self.to_dict(validate=True)
/opt/hostedtoolcache/Python/3.8.17/x64/lib/python3.8/site-packages/altair/utils/schemapi.py:358: in to_dict
    self.validate(result)
/opt/hostedtoolcache/Python/3.8.17/x64/lib/python3.8/site-packages/altair/utils/schemapi.py:462: in validate
    resolver = jsonschema.RefResolver.from_schema(cls._rootschema or cls._schema)
/opt/hostedtoolcache/Python/3.8.17/x64/lib/python3.8/site-packages/jsonschema/__init__.py:42: in __getattr__
    warnings.warn(
E   DeprecationWarning: jsonschema.RefResolver is deprecated as of v4.18.0, in favor of the https://github.com/python-jsonschema/referencing library, which provides more compliant referencing behavior as well as more flexible APIs for customization. A future release will remove RefResolver. Please file a feature request (on referencing) if you are missing an API for the kind of customization you need.
```

---

Please follow these steps to make it more efficient to solve your issue:

- [x] Since Altair is a Python wrapper around the Vega-Lite visualization grammar, [most bugs should be reported directly to Vega-Lite](https://github.com/vega/vega-lite/issues). You can click the Action Button of your Altair chart and "Open in Vega Editor" to create a reproducible Vega-Lite example and see if you get the same error in the Vega Editor.
- [x] Search for duplicate issues.
- [x] Use the latest version of Altair.
- [x] Describe how to reproduce the bug and include the full code and data to reproduce it, ideally using a sample data set from `vega_datasets`.



Discussion:
Thank you @cdkini for letting us know! I'll look into this.
I've been looking into how we can replace `RefResolver` with the new `referencing` library, spending quite some time reading the `referencing` and `jsonschema` docs (including [Migrating from RefResolver](https://python-jsonschema.readthedocs.io/en/stable/referencing/#migrating-from-refresolver)) but I'm somehow stuck. @Julian if you have a few minutes I'd highly appreciate your input. To make it easier, I slimmed it down to a minimal example.

# Setup
```python
import altair as alt
import jsonschema.validators
import referencing

# alt.load_schema returns the Vega-Lite schema as a dictionary.
# You can also view it at https://vega.github.io/schema/vega-lite/v5.json
rootschema = alt.load_schema()
schema = {'$ref': '#/definitions/PositionFieldDef'}
spec = {'field': 'x', 'type': 'temporal'}
```

# Our current approach (working)
```python
validator_cls = jsonschema.validators.validator_for(rootschema)
resolver = jsonschema.RefResolver.from_schema(rootschema)
validator = validator_cls(schema, resolver=resolver)

# This passes as expected
validator.validate(spec)

# This raises a ValidationError as expected: `Additional properties are not allowed ('test' was unexpected)`
validator.validate({**spec, "test": 3})
```

# Attempt to replace RefResolver with referencing library
```python
resource = referencing.Resource.from_contents(rootschema)
# The root schema does not have an ID and so I assumed that I can use "" but maybe this is
# where I'm wrong?
registry = referencing.Registry().with_resource(uri="", resource=resource)
validator_cls = jsonschema.validators.validator_for(rootschema)
validator = validator_cls(schema, registry=registry)

# This gives me the traceback below
validator.validate(spec)
```

```python
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
File [~/Library/CloudStorage/Dropbox/Programming/altair/.venv/lib/python3.10/site-packages/referencing/_core.py:186](https://file+.vscode-resource.vscode-cdn.net/Users/stefanbinder/Library/CloudStorage/Dropbox/Programming/altair/~/Library/CloudStorage/Dropbox/Programming/altair/.venv/lib/python3.10/site-packages/referencing/_core.py:186), in Resource.pointer(self, pointer, resolver)
    185 try:
--> 186     contents = contents[segment]  # type: ignore[reportUnknownArgumentType]  # noqa: E501
    187 except LookupError:

KeyError: 'definitions'

During handling of the above exception, another exception occurred:

PointerToNowhere                          Traceback (most recent call last)
File [~/Library/CloudStorage/Dropbox/Programming/altair/.venv/lib/python3.10/site-packages/jsonschema/validators.py:439](https://file+.vscode-resource.vscode-cdn.net/Users/stefanbinder/Library/CloudStorage/Dropbox/Programming/altair/~/Library/CloudStorage/Dropbox/Programming/altair/.venv/lib/python3.10/site-packages/jsonschema/validators.py:439), in create..Validator._validate_reference(self, ref, instance)
    438 try:
--> 439     resolved = self._resolver.lookup(ref)
    440 except referencing.exceptions.Unresolvable as err:

File [~/Library/CloudStorage/Dropbox/Programming/altair/.venv/lib/python3.10/site-packages/referencing/_core.py:594](https://file+.vscode-resource.vscode-cdn.net/Users/stefanbinder/Library/CloudStorage/Dropbox/Programming/altair/~/Library/CloudStorage/Dropbox/Programming/altair/.venv/lib/python3.10/site-packages/referencing/_core.py:594), in Resolver.lookup(self, ref)
    593     resolver = self._evolve(registry=retrieved.registry, base_uri=uri)
--> 594     return retrieved.value.pointer(pointer=fragment, resolver=resolver)
    596 if fragment:

File [~/Library/CloudStorage/Dropbox/Programming/altair/.venv/lib/python3.10/site-packages/referencing/_core.py:188](https://file+.vscode-resource.vscode-cdn.net/Users/stefanbinder/Library/CloudStorage/Dropbox/Programming/altair/~/Library/CloudStorage/Dropbox/Programming/altair/.venv/lib/python3.10/site-packages/referencing/_core.py:188), in Resource.pointer(self, pointer, resolver)
    187 except LookupError:
--> 188     raise exceptions.PointerToNowhere(ref=pointer, resource=self)
    190 segments.append(segment)

PointerToNowhere: '/definitions/PositionFieldDef' does not exist within {'$ref': '#/definitions/PositionFieldDef'}

During handling of the above exception, another exception occurred:

_WrappedReferencingError                  Traceback (most recent call last)
Cell In[10], line 2
      1 # Works as expected
----> 2 validator.validate(spec)

File [~/Library/CloudStorage/Dropbox/Programming/altair/.venv/lib/python3.10/site-packages/jsonschema/validators.py:427](https://file+.vscode-resource.vscode-cdn.net/Users/stefanbinder/Library/CloudStorage/Dropbox/Programming/altair/~/Library/CloudStorage/Dropbox/Programming/altair/.venv/lib/python3.10/site-packages/jsonschema/validators.py:427), in create..Validator.validate(self, *args, **kwargs)
    426 def validate(self, *args, **kwargs):
--> 427     for error in self.iter_errors(*args, **kwargs):
    428         raise error

File [~/Library/CloudStorage/Dropbox/Programming/altair/.venv/lib/python3.10/site-packages/jsonschema/validators.py:361](https://file+.vscode-resource.vscode-cdn.net/Users/stefanbinder/Library/CloudStorage/Dropbox/Programming/altair/~/Library/CloudStorage/Dropbox/Programming/altair/.venv/lib/python3.10/site-packages/jsonschema/validators.py:361), in create..Validator.iter_errors(self, instance, _schema)
    358     continue
    360 errors = validator(self, v, instance, _schema) or ()
--> 361 for error in errors:
    362     # set details if not already set by the called fn
    363     error._set(
    364         validator=k,
    365         validator_value=v,
   (...)
    368         type_checker=self.TYPE_CHECKER,
    369     )
    370     if k not in {"if", "$ref"}:

File [~/Library/CloudStorage/Dropbox/Programming/altair/.venv/lib/python3.10/site-packages/jsonschema/_validators.py:284](https://file+.vscode-resource.vscode-cdn.net/Users/stefanbinder/Library/CloudStorage/Dropbox/Programming/altair/~/Library/CloudStorage/Dropbox/Programming/altair/.venv/lib/python3.10/site-packages/jsonschema/_validators.py:284), in ref(validator, ref, instance, schema)
    283 def ref(validator, ref, instance, schema):
--> 284     yield from validator._validate_reference(ref=ref, instance=instance)

File [~/Library/CloudStorage/Dropbox/Programming/altair/.venv/lib/python3.10/site-packages/jsonschema/validators.py:441](https://file+.vscode-resource.vscode-cdn.net/Users/stefanbinder/Library/CloudStorage/Dropbox/Programming/altair/~/Library/CloudStorage/Dropbox/Programming/altair/.venv/lib/python3.10/site-packages/jsonschema/validators.py:441), in create..Validator._validate_reference(self, ref, instance)
    439         resolved = self._resolver.lookup(ref)
    440     except referencing.exceptions.Unresolvable as err:
--> 441         raise exceptions._WrappedReferencingError(err)
    443     return self.descend(
    444         instance,
    445         resolved.contents,
    446         resolver=resolved.resolver,
    447     )
    448 else:

_WrappedReferencingError: PointerToNowhere: '/definitions/PositionFieldDef' does not exist within {'$ref': '#/definitions/PositionFieldDef'}
```

# Where Altair currently uses RefResolver
Just for reference:
* https://github.com/altair-viz/altair/blob/b7ae4867a5c015a98a30cbe948d7ed35dad58ad1/altair/utils/schemapi.py#L118
* https://github.com/altair-viz/altair/blob/b7ae4867a5c015a98a30cbe948d7ed35dad58ad1/altair/utils/schemapi.py#L353
* https://github.com/altair-viz/altair/blob/b7ae4867a5c015a98a30cbe948d7ed35dad58ad1/tools/schemapi/utils.py#L16

Happy to have a look - will probably be Monday!
TL;DR: Yes you should assign some "real" URI to the Vega Lite schema I think (probably the one that already is the URL where it lives), and then use that URI to refer to your subschemas. Using the existing pointers in the Vega schema might be enough there, I'll try to show an example below.

Slightly less short:

When you say "`#/foo/bar`" you are saying "in the current schema resource is a key called foo with a key called bar inside it" -- so if you have a schema saying `{"$ref": "#/foo/bar"}` that's saying "in this schema is a `foo` key" as above -- but of course that isn't true -- there's just one key in that schema. The fact that what you had previously worked was sort of an "abuse" of the empty string doing some special stuff inside `RefResolver` which happened to use `""` as some internal fallback URI if it couldn't come up with one inside your root schema (which was outside the spec and wouldn't work if you tried it in some other JSON Schema implementation) -- so the error is trying to tell you exactly the above (there's no `definitions` key in the schema you're calling `""`.)

More specifically, some code that should do what your original code does:

```python
import warnings
with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    import altair as alt

import jsonschema.validators
import referencing

rooturi = "https://vega.github.io/schema/vega-lite/v5.json"
rootschema = alt.load_schema()
schema = {'$ref': f"{rooturi}#/definitions/PositionFieldDef"}
spec = {'field': 'x', 'type': 'temporal'}

# It looks like you were previously picking a subschema of the "real" schema
# that lives at this URL -- I'll point out you don't really need to do that at
# this point, you should be able to take the "real" root schema as-is and use
# fragments to reference inside of it. But I'm keeping things as-is in this
# snippet, other than adding a URI -- you can of course also pick another URI,
# like `urn:example:vega-lite-altair-schema` to refer to things!
registry = referencing.Registry().with_contents([(rooturi, rootschema)])
validator_cls = jsonschema.validators.validator_for(rootschema)
validator = validator_cls(schema, registry=registry)

validator.validate(spec)
validator.validate({**spec, "test": 3})  # gives your traceback as expected
``` 

Let me know if the above helps, and/or if you have suggestions. I can't commit fully to sending a PR making the changes to altair but I highly value the project so if there's help I can offer I can certainly say I'd like to help in any way I can, whether that's just a review of a PR, more comments, or sending a partial PR.
Thank you @Julian, this is already very helpful and makes a lot more sense now, especially with the code example. I'm at the EuroPython conference for the rest of the week and will pick this up again next week. Thank you for the offer, I appreciate it! I think I can get a PR started but I'd reach out in case I have any follow-up questions.

Resolving patch:
diff --git a/altair/utils/schemapi.py b/altair/utils/schemapi.py
--- a/altair/utils/schemapi.py
+++ b/altair/utils/schemapi.py
@@ -2,6 +2,7 @@
 # tools/generate_schema_wrapper.py. Do not modify directly.
 import collections
 import contextlib
+import copy
 import inspect
 import json
 import sys
@@ -23,13 +24,19 @@
     TypeVar,
 )
 from itertools import zip_longest
+from importlib.metadata import version as importlib_version
+from typing import Final
 
 import jsonschema
 import jsonschema.exceptions
 import jsonschema.validators
 import numpy as np
 import pandas as pd
+from packaging.version import Version
 
+# This leads to circular imports with the vegalite module. Currently, this works
+# but be aware that when you access it in this script, the vegalite module might
+# not yet be fully instantiated in case your code is being executed during import time
 from altair import vegalite
 
 if sys.version_info >= (3, 11):
@@ -42,6 +49,20 @@
 ValidationErrorList = List[jsonschema.exceptions.ValidationError]
 GroupedValidationErrors = Dict[str, ValidationErrorList]
 
+# This URI is arbitrary and could be anything else. It just cannot be an empty
+# string as we need to reference the schema registered in
+# the referencing.Registry.
+_VEGA_LITE_ROOT_URI: Final = "urn:vega-lite-schema"
+
+# Ideally, jsonschema specification would be parsed from the current Vega-Lite
+# schema instead of being hardcoded here as a default value.
+# However, due to circular imports between this module and the altair.vegalite
+# modules, this information is not yet available at this point as altair.vegalite
+# is only partially loaded. The draft version which is used is unlikely to
+# change often so it's ok to keep this. There is also a test which validates
+# that this value is always the same as in the Vega-Lite schema.
+_DEFAULT_JSON_SCHEMA_DRAFT_URL: Final = "http://json-schema.org/draft-07/schema#"
+
 
 # If DEBUG_MODE is True, then schema objects are converted to dict and
 # validated at creation time. This slows things down, particularly for
@@ -149,22 +170,102 @@ def _get_errors_from_spec(
     # e.g. '#/definitions/ValueDefWithCondition<MarkPropFieldOrDatumDef,
     # (Gradient|string|null)>' would be a valid $ref in a Vega-Lite schema but
     # it is not a valid URI reference due to the characters such as '<'.
-    if rootschema is not None:
-        validator_cls = jsonschema.validators.validator_for(rootschema)
-        resolver = jsonschema.RefResolver.from_schema(rootschema)
-    else:
-        validator_cls = jsonschema.validators.validator_for(schema)
-        # No resolver is necessary if the schema is already the full schema
-        resolver = None
 
-    validator_kwargs = {"resolver": resolver}
+    json_schema_draft_url = _get_json_schema_draft_url(rootschema or schema)
+    validator_cls = jsonschema.validators.validator_for(
+        {"$schema": json_schema_draft_url}
+    )
+    validator_kwargs: Dict[str, Any] = {}
     if hasattr(validator_cls, "FORMAT_CHECKER"):
         validator_kwargs["format_checker"] = validator_cls.FORMAT_CHECKER
+
+    if _use_referencing_library():
+        schema = _prepare_references_in_schema(schema)
+        validator_kwargs["registry"] = _get_referencing_registry(
+            rootschema or schema, json_schema_draft_url
+        )
+
+    else:
+        # No resolver is necessary if the schema is already the full schema
+        validator_kwargs["resolver"] = (
+            jsonschema.RefResolver.from_schema(rootschema)
+            if rootschema is not None
+            else None
+        )
+
     validator = validator_cls(schema, **validator_kwargs)
     errors = list(validator.iter_errors(spec))
     return errors
 
 
+def _get_json_schema_draft_url(schema: dict) -> str:
+    return schema.get("$schema", _DEFAULT_JSON_SCHEMA_DRAFT_URL)
+
+
+def _use_referencing_library() -> bool:
+    """In version 4.18.0, the jsonschema package deprecated RefResolver in
+    favor of the referencing library."""
+    jsonschema_version_str = importlib_version("jsonschema")
+    return Version(jsonschema_version_str) >= Version("4.18")
+
+
+def _prepare_references_in_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
+    # Create a copy so that $ref is not modified in the original schema in case
+    # that it would still reference a dictionary which might be attached to
+    # an Altair class _schema attribute
+    schema = copy.deepcopy(schema)
+
+    def _prepare_refs(d: Dict[str, Any]) -> Dict[str, Any]:
+        """Add _VEGA_LITE_ROOT_URI in front of all $ref values. This function
+        recursively iterates through the whole dictionary."""
+        for key, value in d.items():
+            if key == "$ref":
+                d[key] = _VEGA_LITE_ROOT_URI + d[key]
+            else:
+                # $ref values can only be nested in dictionaries or lists
+                # as the passed in `d` dictionary comes from the Vega-Lite json schema
+                # and in json we only have arrays (-> lists in Python) and objects
+                # (-> dictionaries in Python) which we need to iterate through.
+                if isinstance(value, dict):
+                    d[key] = _prepare_refs(value)
+                elif isinstance(value, list):
+                    prepared_values = []
+                    for v in value:
+                        if isinstance(v, dict):
+                            v = _prepare_refs(v)
+                        prepared_values.append(v)
+                    d[key] = prepared_values
+        return d
+
+    schema = _prepare_refs(schema)
+    return schema
+
+
+# We do not annotate the return value here as the referencing library is not always
+# available and this function is only executed in those cases.
+def _get_referencing_registry(
+    rootschema: Dict[str, Any], json_schema_draft_url: Optional[str] = None
+):
+    # Referencing is a dependency of newer jsonschema versions, starting with the
+    # version that is specified in _use_referencing_library and we therefore
+    # can expect that it is installed if the function returns True.
+    # We ignore 'import' mypy errors which happen when the referencing library
+    # is not installed. That's ok as in these cases this function is not called.
+    # We also have to ignore 'unused-ignore' errors as mypy raises those in case
+    # referencing is installed.
+    import referencing  # type: ignore[import,unused-ignore]
+    import referencing.jsonschema  # type: ignore[import,unused-ignore]
+
+    if json_schema_draft_url is None:
+        json_schema_draft_url = _get_json_schema_draft_url(rootschema)
+
+    specification = referencing.jsonschema.specification_with(json_schema_draft_url)
+    resource = specification.create_resource(rootschema)
+    return referencing.Registry().with_resource(
+        uri=_VEGA_LITE_ROOT_URI, resource=resource
+    )
+
+
 def _json_path(err: jsonschema.exceptions.ValidationError) -> str:
     """Drop in replacement for the .json_path property of the jsonschema
     ValidationError class, which is not available as property for
@@ -384,12 +485,27 @@ def _todict(obj: Any, context: Optional[Dict[str, Any]]) -> Any:
         return obj
 
 
-def _resolve_references(schema: dict, root: Optional[dict] = None) -> dict:
-    """Resolve schema references."""
-    resolver = jsonschema.RefResolver.from_schema(root or schema)
-    while "$ref" in schema:
-        with resolver.resolving(schema["$ref"]) as resolved:
-            schema = resolved
+def _resolve_references(
+    schema: Dict[str, Any], rootschema: Optional[Dict[str, Any]] = None
+) -> Dict[str, Any]:
+    """Resolve schema references until there is no $ref anymore
+    in the top-level of the dictionary.
+    """
+    if _use_referencing_library():
+        registry = _get_referencing_registry(rootschema or schema)
+        # Using a different variable name to show that this is not the
+        # jsonschema.RefResolver but instead a Resolver from the referencing
+        # library
+        referencing_resolver = registry.resolver()
+        while "$ref" in schema:
+            schema = referencing_resolver.lookup(
+                _VEGA_LITE_ROOT_URI + schema["$ref"]
+            ).contents
+    else:
+        resolver = jsonschema.RefResolver.from_schema(rootschema or schema)
+        while "$ref" in schema:
+            with resolver.resolving(schema["$ref"]) as resolved:
+                schema = resolved
     return schema
 
 
@@ -1010,7 +1126,7 @@ def resolve_references(cls, schema: Optional[dict] = None) -> dict:
         assert schema_to_pass is not None
         return _resolve_references(
             schema=schema_to_pass,
-            root=(cls._rootschema or cls._schema or schema),
+            rootschema=(cls._rootschema or cls._schema or schema),
         )
 
     @classmethod
diff --git a/tools/schemapi/schemapi.py b/tools/schemapi/schemapi.py
--- a/tools/schemapi/schemapi.py
+++ b/tools/schemapi/schemapi.py
@@ -1,5 +1,6 @@
 import collections
 import contextlib
+import copy
 import inspect
 import json
 import sys
@@ -21,13 +22,19 @@
     TypeVar,
 )
 from itertools import zip_longest
+from importlib.metadata import version as importlib_version
+from typing import Final
 
 import jsonschema
 import jsonschema.exceptions
 import jsonschema.validators
 import numpy as np
 import pandas as pd
+from packaging.version import Version
 
+# This leads to circular imports with the vegalite module. Currently, this works
+# but be aware that when you access it in this script, the vegalite module might
+# not yet be fully instantiated in case your code is being executed during import time
 from altair import vegalite
 
 if sys.version_info >= (3, 11):
@@ -40,6 +47,20 @@
 ValidationErrorList = List[jsonschema.exceptions.ValidationError]
 GroupedValidationErrors = Dict[str, ValidationErrorList]
 
+# This URI is arbitrary and could be anything else. It just cannot be an empty
+# string as we need to reference the schema registered in
+# the referencing.Registry.
+_VEGA_LITE_ROOT_URI: Final = "urn:vega-lite-schema"
+
+# Ideally, jsonschema specification would be parsed from the current Vega-Lite
+# schema instead of being hardcoded here as a default value.
+# However, due to circular imports between this module and the altair.vegalite
+# modules, this information is not yet available at this point as altair.vegalite
+# is only partially loaded. The draft version which is used is unlikely to
+# change often so it's ok to keep this. There is also a test which validates
+# that this value is always the same as in the Vega-Lite schema.
+_DEFAULT_JSON_SCHEMA_DRAFT_URL: Final = "http://json-schema.org/draft-07/schema#"
+
 
 # If DEBUG_MODE is True, then schema objects are converted to dict and
 # validated at creation time. This slows things down, particularly for
@@ -147,22 +168,102 @@ def _get_errors_from_spec(
     # e.g. '#/definitions/ValueDefWithCondition<MarkPropFieldOrDatumDef,
     # (Gradient|string|null)>' would be a valid $ref in a Vega-Lite schema but
     # it is not a valid URI reference due to the characters such as '<'.
-    if rootschema is not None:
-        validator_cls = jsonschema.validators.validator_for(rootschema)
-        resolver = jsonschema.RefResolver.from_schema(rootschema)
-    else:
-        validator_cls = jsonschema.validators.validator_for(schema)
-        # No resolver is necessary if the schema is already the full schema
-        resolver = None
 
-    validator_kwargs = {"resolver": resolver}
+    json_schema_draft_url = _get_json_schema_draft_url(rootschema or schema)
+    validator_cls = jsonschema.validators.validator_for(
+        {"$schema": json_schema_draft_url}
+    )
+    validator_kwargs: Dict[str, Any] = {}
     if hasattr(validator_cls, "FORMAT_CHECKER"):
         validator_kwargs["format_checker"] = validator_cls.FORMAT_CHECKER
+
+    if _use_referencing_library():
+        schema = _prepare_references_in_schema(schema)
+        validator_kwargs["registry"] = _get_referencing_registry(
+            rootschema or schema, json_schema_draft_url
+        )
+
+    else:
+        # No resolver is necessary if the schema is already the full schema
+        validator_kwargs["resolver"] = (
+            jsonschema.RefResolver.from_schema(rootschema)
+            if rootschema is not None
+            else None
+        )
+
     validator = validator_cls(schema, **validator_kwargs)
     errors = list(validator.iter_errors(spec))
     return errors
 
 
+def _get_json_schema_draft_url(schema: dict) -> str:
+    return schema.get("$schema", _DEFAULT_JSON_SCHEMA_DRAFT_URL)
+
+
+def _use_referencing_library() -> bool:
+    """In version 4.18.0, the jsonschema package deprecated RefResolver in
+    favor of the referencing library."""
+    jsonschema_version_str = importlib_version("jsonschema")
+    return Version(jsonschema_version_str) >= Version("4.18")
+
+
+def _prepare_references_in_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
+    # Create a copy so that $ref is not modified in the original schema in case
+    # that it would still reference a dictionary which might be attached to
+    # an Altair class _schema attribute
+    schema = copy.deepcopy(schema)
+
+    def _prepare_refs(d: Dict[str, Any]) -> Dict[str, Any]:
+        """Add _VEGA_LITE_ROOT_URI in front of all $ref values. This function
+        recursively iterates through the whole dictionary."""
+        for key, value in d.items():
+            if key == "$ref":
+                d[key] = _VEGA_LITE_ROOT_URI + d[key]
+            else:
+                # $ref values can only be nested in dictionaries or lists
+                # as the passed in `d` dictionary comes from the Vega-Lite json schema
+                # and in json we only have arrays (-> lists in Python) and objects
+                # (-> dictionaries in Python) which we need to iterate through.
+                if isinstance(value, dict):
+                    d[key] = _prepare_refs(value)
+                elif isinstance(value, list):
+                    prepared_values = []
+                    for v in value:
+                        if isinstance(v, dict):
+                            v = _prepare_refs(v)
+                        prepared_values.append(v)
+                    d[key] = prepared_values
+        return d
+
+    schema = _prepare_refs(schema)
+    return schema
+
+
+# We do not annotate the return value here as the referencing library is not always
+# available and this function is only executed in those cases.
+def _get_referencing_registry(
+    rootschema: Dict[str, Any], json_schema_draft_url: Optional[str] = None
+):
+    # Referencing is a dependency of newer jsonschema versions, starting with the
+    # version that is specified in _use_referencing_library and we therefore
+    # can expect that it is installed if the function returns True.
+    # We ignore 'import' mypy errors which happen when the referencing library
+    # is not installed. That's ok as in these cases this function is not called.
+    # We also have to ignore 'unused-ignore' errors as mypy raises those in case
+    # referencing is installed.
+    import referencing  # type: ignore[import,unused-ignore]
+    import referencing.jsonschema  # type: ignore[import,unused-ignore]
+
+    if json_schema_draft_url is None:
+        json_schema_draft_url = _get_json_schema_draft_url(rootschema)
+
+    specification = referencing.jsonschema.specification_with(json_schema_draft_url)
+    resource = specification.create_resource(rootschema)
+    return referencing.Registry().with_resource(
+        uri=_VEGA_LITE_ROOT_URI, resource=resource
+    )
+
+
 def _json_path(err: jsonschema.exceptions.ValidationError) -> str:
     """Drop in replacement for the .json_path property of the jsonschema
     ValidationError class, which is not available as property for
@@ -382,12 +483,27 @@ def _todict(obj: Any, context: Optional[Dict[str, Any]]) -> Any:
         return obj
 
 
-def _resolve_references(schema: dict, root: Optional[dict] = None) -> dict:
-    """Resolve schema references."""
-    resolver = jsonschema.RefResolver.from_schema(root or schema)
-    while "$ref" in schema:
-        with resolver.resolving(schema["$ref"]) as resolved:
-            schema = resolved
+def _resolve_references(
+    schema: Dict[str, Any], rootschema: Optional[Dict[str, Any]] = None
+) -> Dict[str, Any]:
+    """Resolve schema references until there is no $ref anymore
+    in the top-level of the dictionary.
+    """
+    if _use_referencing_library():
+        registry = _get_referencing_registry(rootschema or schema)
+        # Using a different variable name to show that this is not the
+        # jsonschema.RefResolver but instead a Resolver from the referencing
+        # library
+        referencing_resolver = registry.resolver()
+        while "$ref" in schema:
+            schema = referencing_resolver.lookup(
+                _VEGA_LITE_ROOT_URI + schema["$ref"]
+            ).contents
+    else:
+        resolver = jsonschema.RefResolver.from_schema(rootschema or schema)
+        while "$ref" in schema:
+            with resolver.resolving(schema["$ref"]) as resolved:
+                schema = resolved
     return schema
 
 
@@ -1008,7 +1124,7 @@ def resolve_references(cls, schema: Optional[dict] = None) -> dict:
         assert schema_to_pass is not None
         return _resolve_references(
             schema=schema_to_pass,
-            root=(cls._rootschema or cls._schema or schema),
+            rootschema=(cls._rootschema or cls._schema or schema),
         )
 
     @classmethod
diff --git a/tools/schemapi/utils.py b/tools/schemapi/utils.py
--- a/tools/schemapi/utils.py
+++ b/tools/schemapi/utils.py
@@ -5,21 +5,12 @@
 import textwrap
 import urllib
 
-import jsonschema
+from .schemapi import _resolve_references as resolve_references
 
 
 EXCLUDE_KEYS = ("definitions", "title", "description", "$schema", "id")
 
 
-def resolve_references(schema, root=None):
-    """Resolve References within a JSON schema"""
-    resolver = jsonschema.RefResolver.from_schema(root or schema)
-    while "$ref" in schema:
-        with resolver.resolving(schema["$ref"]) as resolved:
-            schema = resolved
-    return schema
-
-
 def get_valid_identifier(
     prop, replacement_character="", allow_unicode=False, url_decode=True
 ):


Test patch:
diff --git a/tests/utils/test_core.py b/tests/utils/test_core.py
--- a/tests/utils/test_core.py
+++ b/tests/utils/test_core.py
@@ -8,13 +8,16 @@
 from altair.utils.core import parse_shorthand, update_nested, infer_encoding_types
 from altair.utils.core import infer_dtype
 
+json_schema_specification = alt.load_schema()["$schema"]
+json_schema_dict_str = f'{{"$schema": "{json_schema_specification}"}}'
+
 try:
     import pyarrow as pa
 except ImportError:
     pa = None
 
 
-FAKE_CHANNELS_MODULE = '''
+FAKE_CHANNELS_MODULE = f'''
 """Fake channels module for utility tests."""
 
 from altair.utils import schemapi
@@ -33,32 +36,32 @@ def __init__(self, value, **kwargs):
 
 
 class X(FieldChannel, schemapi.SchemaBase):
-    _schema = {}
+    _schema = {json_schema_dict_str}
     _encoding_name = "x"
 
 
 class XValue(ValueChannel, schemapi.SchemaBase):
-    _schema = {}
+    _schema = {json_schema_dict_str}
     _encoding_name = "x"
 
 
 class Y(FieldChannel, schemapi.SchemaBase):
-    _schema = {}
+    _schema = {json_schema_dict_str}
     _encoding_name = "y"
 
 
 class YValue(ValueChannel, schemapi.SchemaBase):
-    _schema = {}
+    _schema = {json_schema_dict_str}
     _encoding_name = "y"
 
 
 class StrokeWidth(FieldChannel, schemapi.SchemaBase):
-    _schema = {}
+    _schema = {json_schema_dict_str}
     _encoding_name = "strokeWidth"
 
 
 class StrokeWidthValue(ValueChannel, schemapi.SchemaBase):
-    _schema = {}
+    _schema = {json_schema_dict_str}
     _encoding_name = "strokeWidth"
 '''
 
diff --git a/tests/utils/test_schemapi.py b/tests/utils/test_schemapi.py
--- a/tests/utils/test_schemapi.py
+++ b/tests/utils/test_schemapi.py
@@ -20,13 +20,24 @@
     Undefined,
     _FromDict,
     SchemaValidationError,
+    _DEFAULT_JSON_SCHEMA_DRAFT_URL,
 )
 
-_JSONSCHEMA_DRAFT = load_schema()["$schema"]
+_JSON_SCHEMA_DRAFT_URL = load_schema()["$schema"]
 # Make tests inherit from _TestSchema, so that when we test from_dict it won't
 # try to use SchemaBase objects defined elsewhere as wrappers.
 
 
+def test_actual_json_schema_draft_is_same_as_hardcoded_default():
+    # See comments next to definition of _DEFAULT_JSON_SCHEMA_DRAFT_URL
+    # for details why we need this test
+    assert _DEFAULT_JSON_SCHEMA_DRAFT_URL == _JSON_SCHEMA_DRAFT_URL, (
+        "The default json schema URL, which is hardcoded,"
+        + " is not the same as the one used in the Vega-Lite schema."
+        + " You need to update the default value."
+    )
+
+
 class _TestSchema(SchemaBase):
     @classmethod
     def _default_wrapper_classes(cls):
@@ -35,7 +46,7 @@ def _default_wrapper_classes(cls):
 
 class MySchema(_TestSchema):
     _schema = {
-        "$schema": _JSONSCHEMA_DRAFT,
+        "$schema": _JSON_SCHEMA_DRAFT_URL,
         "definitions": {
             "StringMapping": {
                 "type": "object",
@@ -72,7 +83,7 @@ class StringArray(_TestSchema):
 
 class Derived(_TestSchema):
     _schema = {
-        "$schema": _JSONSCHEMA_DRAFT,
+        "$schema": _JSON_SCHEMA_DRAFT_URL,
         "definitions": {
             "Foo": {"type": "object", "properties": {"d": {"type": "string"}}},
             "Bar": {"type": "string", "enum": ["A", "B"]},
@@ -99,7 +110,7 @@ class Bar(_TestSchema):
 
 class SimpleUnion(_TestSchema):
     _schema = {
-        "$schema": _JSONSCHEMA_DRAFT,
+        "$schema": _JSON_SCHEMA_DRAFT_URL,
         "anyOf": [{"type": "integer"}, {"type": "string"}],
     }
 
@@ -111,7 +122,7 @@ class DefinitionUnion(_TestSchema):
 
 class SimpleArray(_TestSchema):
     _schema = {
-        "$schema": _JSONSCHEMA_DRAFT,
+        "$schema": _JSON_SCHEMA_DRAFT_URL,
         "type": "array",
         "items": {"anyOf": [{"type": "integer"}, {"type": "string"}]},
     }
@@ -119,7 +130,7 @@ class SimpleArray(_TestSchema):
 
 class InvalidProperties(_TestSchema):
     _schema = {
-        "$schema": _JSONSCHEMA_DRAFT,
+        "$schema": _JSON_SCHEMA_DRAFT_URL,
         "type": "object",
         "properties": {"for": {}, "as": {}, "vega-lite": {}, "$schema": {}},
     }