Repository: altair-viz/altair (python)

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`.


Pull request:
Add support for new referencing resolution in jsonschema>=4.18
Closes #3097.

@Julian:
* Could you elaborate on your comment in https://github.com/altair-viz/altair/issues/3097#issuecomment-1638584720? `alt.load_schema` should return the full Vega-Lite schema which is hosted at https://vega.github.io/schema/vega-lite/v5.8.0.json.:
```python
# 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.
```
* In https://github.com/altair-viz/altair/pull/3118/files#diff-25906c2570b127f0bb9cff4550bcc153006b2fdb36aea55ae804ca9a74eb4404R176 I now iterate through a schema to append the URN I use in the registry to all `$ref`s. This works great with our test suite but it would be somehow great if there is a more direct way of telling that "all refs should always refer to a specific rootschema" without having to do this manually for all. Maybe this is a weird thing we have in Altair how we use the schemas. If you have any idea how to simplify this, it would be greatly appreciated. Else, we can also go forward with the current implementation.
* Do you spot anything in the diff which might not be a future-proof usage of `referencing`? I'm aware that the library is still in it's early stages so things might change but maybe you're already aware of an upcoming change.

Thanks again for your help! 


For myself:

- [x] Add changelog entry
- [x] Update test pipelines that they use the latest jsonschema version instead of hardcoded 4.17

Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4fa75275..25ef7f53 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -8,7 +8,7 @@ jobs:
     strategy:
       matrix:
         python-version: ["3.8", "3.9", "3.10", "3.11"]
-        jsonschema-version: ["3.0", "4.17"]
+        jsonschema-version: ["3.0", "latest"]
     name: py ${{ matrix.python-version }} js ${{ matrix.jsonschema-version }}
     steps:
       - uses: actions/checkout@v3
@@ -24,10 +24,14 @@ jobs:
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip
-          pip install jsonschema==${{ matrix.jsonschema-version }}
           pip install .[dev]
           # pip install "selenium<4.3.0"
           # pip install altair_saver
+      - name: Install specific jsonschema
+        # Only have to execute this if we don't want the latest jsonschema version
+        if: ${{ matrix.jsonschema-version != 'latest' }}
+        run: |
+          pip install jsonschema==${{ matrix.jsonschema-version }}
       - name: Maybe uninstall optional dependencies
         # We uninstall pyarrow and vegafusion for one job to test that we have not
         # accidentally introduced a hard dependency on these libraries.
diff --git a/altair/utils/schemapi.py b/altair/utils/schemapi.py
index 5357e523..f3842468 100644
--- 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 @@ from typing import (
     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 @@ _TSchemaBase = TypeVar("_TSchemaBase", bound="SchemaBase")
 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 @@ class SchemaBase:
         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/doc/about/governance.rst b/doc/about/governance.rst
new file mode 100644
index 00000000..3b467a91
--- /dev/null
+++ b/doc/about/governance.rst
@@ -0,0 +1,16 @@
+Governance
+==========
+Vega-Altair's governance structure is based on GitHub's
+`Minimum Viable Governance <https://github.com/github/MVG>`_ (MVG) template.
+
+Organizational Governance
+-------------------------
+The Altair-Viz organization is governed by the documents that reside in the
+`governance/org-docs <https://github.com/altair-viz/altair/tree/main/governance/org-docs>`_ directory of the
+Vega-Altair GitHub repository.
+
+Project Governance
+------------------
+The Vega-Altair library is governed by the documents that reside in the
+`governance/project-docs <https://github.com/altair-viz/altair/tree/main/governance/project-docs>`_ directory of
+the Vega-Altair GitHub repository.
\ No newline at end of file
diff --git a/doc/about/roadmap.rst b/doc/about/roadmap.rst
index c9f27e7d..93488a2b 100644
--- a/doc/about/roadmap.rst
+++ b/doc/about/roadmap.rst
@@ -162,4 +162,5 @@ Areas of focus:
    :hidden:
 
    self
-   code_of_conduct
\ No newline at end of file
+   code_of_conduct
+   governance
diff --git a/doc/releases/changes.rst b/doc/releases/changes.rst
index dad0fb6a..9030af72 100644
--- a/doc/releases/changes.rst
+++ b/doc/releases/changes.rst
@@ -11,6 +11,7 @@ Enhancements
 - Add ``chart.transformed_data()`` method to extract transformed chart data using VegaFusion (#3081, #3084, #3102)
 - Add ``"vegafusion"`` data transformer with mime renderer, save, and ``to_dict``/``to_json`` integration (#3094, #3107)
 - Support field encoding inference for objects that support the DataFrame Interchange Protocol (#3114)
+- Add support for new referencing logic in version 4.18 of the jsonschema package
 
 Bug Fixes
 ~~~~~~~~~
@@ -25,7 +26,7 @@ Version 5.0.1 (released May 26, 2023)
 -------------------------------------
 
 - Remove unwanted files during build to avoid littering site-packages folder (#3057).
-- Deprecate the ``.ref()`` function for selections, instead of removing it (#3063). 
+- Deprecate the ``.ref()`` function for selections, instead of removing it (#3063).
 - Fix bug in reconstructing layered charts with ``Chart.from_json()``/``Chart.from_dict()`` (#3068).
 
 Version 5.0.0 (released May 9, 2023)
@@ -118,7 +119,7 @@ Enhancements
 
 - Pie charts are now supported through the use of ``mark_arc``. (Examples: eg.
   :ref:`gallery_pie_chart` and :ref:`gallery_radial_chart`.)
-- Support for the ``datum`` encoding specifications from Vega-Lite; see 
+- Support for the ``datum`` encoding specifications from Vega-Lite; see
   `Vega-Lite Datum Definition <https://vega.github.io/vega-lite/docs/encoding.html#datum-def>`_.
   (Examples: :ref:`gallery_line_chart_with_datum` and :ref:`gallery_line_chart_with_color_datum`.)
 - ``angle`` encoding can now be used to control point styles (Example: :ref:`gallery_wind_vector_map`)
diff --git a/doc/user_guide/display_frontends.rst b/doc/user_guide/display_frontends.rst
index 10685b1f..ad418bea 100644
--- a/doc/user_guide/display_frontends.rst
+++ b/doc/user_guide/display_frontends.rst
@@ -162,6 +162,10 @@ Examples are:
 
 Altair Viewer
 ~~~~~~~~~~~~~
+.. note::
+   
+   altair_viewer does not yet support Altair 5.
+
 For non-notebook IDEs, a useful companion is the `Altair Viewer`_ package,
 which provides an Altair renderer that works directly from any Python terminal.
 Start by installing the package::
diff --git a/pyproject.toml b/pyproject.toml
index 1bd28d5d..bd3548a0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,7 +14,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "altair"
-authors = [ {name = "Vega-Altair Contributors"} ]
+authors = [{ name = "Vega-Altair Contributors" }]
 dependencies = [
     "typing_extensions>=4.0.1; python_version<\"3.11\"",
     "jinja2",
@@ -39,7 +39,7 @@ keywords = [
 requires-python = ">=3.8"
 dynamic = ["version"]
 license-files = { paths = ["LICENSE"] }
-classifiers= [
+classifiers = [
     "Development Status :: 5 - Production/Stable",
     "Environment :: Console",
     "Intended Audience :: Science/Research",
@@ -65,8 +65,6 @@ dev = [
     "pytest-cov",
     "m2r",
     "vega_datasets",
-    # following is rejected by PyPI
-    "altair_viewer @ git+https://github.com/altair-viz/altair_viewer.git",
     "vl-convert-python>=0.11.2",
     "mypy",
     "pandas-stubs",
@@ -87,7 +85,7 @@ doc = [
     "geopandas",
     "myst-parser",
     "sphinx_copybutton",
-    "sphinx-design"
+    "sphinx-design",
 ]
 
 [tool.hatch.version]
@@ -108,7 +106,7 @@ test = [
     "black --diff --color --check .",
     "ruff check .",
     "mypy altair tests",
-    "python -m pytest --pyargs --doctest-modules tests"
+    "python -m pytest --pyargs --doctest-modules tests",
 ]
 test-coverage = "python -m pytest --pyargs --doctest-modules --cov=altair --cov-report term altair"
 test-coverage-html = "python -m pytest --pyargs --doctest-modules --cov=altair --cov-report html altair"
@@ -118,15 +116,8 @@ features = ["dev", "doc"]
 
 [tool.hatch.envs.doc.scripts]
 clean = "rm -rf doc/_build"
-clean-generated = [
-    "rm -rf doc/user_guide/generated",
-    "rm -rf doc/gallery"
-]
-clean-all = [
-    "clean",
-    "clean-generated",
-    "rm -rf doc/_images"
-]
+clean-generated = ["rm -rf doc/user_guide/generated", "rm -rf doc/gallery"]
+clean-all = ["clean", "clean-generated", "rm -rf doc/_images"]
 clean-win = "if exist doc\\_build rd /s /q doc\\_build"
 clean-generated-win = [
     "if exist doc\\user_guide\\generated rd /s /q doc\\user_guide\\generated",
@@ -139,11 +130,11 @@ clean-all-win = [
 ]
 build-html = [
     "mkdir -p doc/_images",
-    "sphinx-build -b html -d doc/_build/doctrees doc doc/_build/html"
+    "sphinx-build -b html -d doc/_build/doctrees doc doc/_build/html",
 ]
 build-html-win = [
     "if not exist doc\\_images md doc\\_images",
-    "sphinx-build -b html -d doc\\_build\\doctrees doc doc\\_build\\html"
+    "sphinx-build -b html -d doc\\_build\\doctrees doc doc\\_build\\html",
 ]
 doctest = "sphinx-build -b doctest -d doc/_build/doctrees doc doc/_build/doctest"
 coverage = "sphinx-build -b coverage -d doc/_build/doctrees doc doc/_build/coverage"
@@ -151,7 +142,7 @@ serve = "(cd doc/_build/html && python -m http.server)"
 publish-clean-build = [
     "clean-all",
     "build-html",
-    "(cd doc && bash sync_website.sh)"
+    "(cd doc && bash sync_website.sh)",
 ]
 
 [tool.black]
@@ -187,10 +178,10 @@ select = [
     # pyflakes
     "F",
     # flake8-tidy-imports
-    "TID"
+    "TID",
 ]
 ignore = [
-    # E203, E266, W503 not yet supported by ruff, 
+    # E203, E266, W503 not yet supported by ruff,
     # see https://github.com/charliermarsh/ruff/issues/2402
     # Whitespace before ':'
     # "E203",
@@ -199,12 +190,12 @@ ignore = [
     # Line break occurred before a binary operator
     # "W503",
     # Line too long
-    "E501", 
+    "E501",
     # Relative imports are banned
     "TID252",
     # zip() without an explicit strict= parameter set.
     # python>=3.10 only
-    "B905"
+    "B905",
 ]
 exclude = [
     ".git",
@@ -237,12 +228,10 @@ module = [
     "vl_convert.*",
     "pandas.lib.*",
     "nbformat.*",
-    "ipykernel.*"
+    "ipykernel.*",
 ]
 ignore_missing_imports = true
 
 [[tool.mypy.overrides]]
-module = [
-    "altair.vegalite.v5.schema.*"
-]
+module = ["altair.vegalite.v5.schema.*"]
 ignore_errors = true
diff --git a/tools/schemapi/schemapi.py b/tools/schemapi/schemapi.py
index 5e824543..af98fbe8 100644
--- 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 @@ from typing import (
     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 @@ _TSchemaBase = TypeVar("_TSchemaBase", bound="SchemaBase")
 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 @@ class SchemaBase:
         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
index 61529718..e05cac91 100644
--- a/tools/schemapi/utils.py
+++ b/tools/schemapi/utils.py
@@ -5,21 +5,12 @@ import re
 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
index b6fd7cdb..62203524 100644
--- a/tests/utils/test_core.py
+++ b/tests/utils/test_core.py
@@ -8,13 +8,16 @@ import altair as alt
 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 @@ class ValueChannel:
 
 
 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
index 3e7a6e1f..3fa04fe2 100644
--- a/tests/utils/test_schemapi.py
+++ b/tests/utils/test_schemapi.py
@@ -20,13 +20,24 @@ from altair.utils.schemapi import (
     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 @@ class _TestSchema(SchemaBase):
 
 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": {}},
     }
diff --git a/tests/vegalite/v5/test_api.py b/tests/vegalite/v5/test_api.py
index e3d27d14..fec51743 100644
--- a/tests/vegalite/v5/test_api.py
+++ b/tests/vegalite/v5/test_api.py
@@ -367,7 +367,9 @@ def test_save(format, engine, basic_chart):
             os.remove(fp)
 
 
-@pytest.mark.parametrize("inline", [False, True])
+# Only test inline=False as altair_viewer is required for inline=True
+# but that package has not yet been released with support for Altair 5
+@pytest.mark.parametrize("inline", [False])
 def test_save_html(basic_chart, inline):
     out = io.StringIO()
     basic_chart.save(out, format="html", inline=inline)