diff --git a/.cirrus.yml b/.cirrus.yml index f9d2941..8dcda21 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -14,6 +14,8 @@ env: CI_BUILD_URL: https://cirrus-ci.com/build/${CIRRUS_BUILD_ID} COVERALLS_PARALLEL: "true" COVERALLS_FLAG_NAME: ${CIRRUS_TASK_NAME} + # Project-specific + VALIDATE_PYPROJECT_CACHE_REMOTE: tests/.cache # ---- Templates ---- @@ -40,12 +42,18 @@ env: depends_on: [build] <<: *task-template dist_cache: {folder: dist, fingerprint_script: echo $CIRRUS_BUILD_ID} # download + test_files_cache: + folder: ${VALIDATE_PYPROJECT_CACHE_REMOTE} + fingerprint_script: echo $CIRRUS_BUILD_ID + populate_script: python tools/cache_urls_for_tests.py + reupload_on_changes: true test_script: > tox --installpkg dist/*.whl -- -n 5 --randomly-seed=42 -rfEx --durations 10 --color yes submit_coverage_script: - pipx run coverage xml -o coverage.xml - - pipx run coveralls --submit coverage.xml + - pipx run 'coveralls<4' --submit coverage.xml + # ^-- https://github.com/TheKevJames/coveralls-python/issues/434 # Deep clone script for POSIX environments (required for setuptools-scm) .clone_script: &clone | @@ -77,7 +85,7 @@ check_task: name: check (Linux - 3.10) alias: check depends_on: [build] - container: {image: "python:3.10-bullseye"} # most recent => better types + container: {image: "python:3.10-bullseye"} # 3.11+ don't understand some code paths dist_cache: {folder: dist, fingerprint_script: echo $CIRRUS_BUILD_ID} # download <<: *task-template install_script: pip install tox @@ -85,11 +93,6 @@ check_task: linux_task: matrix: - - name: test (Linux - 3.6) - container: {image: "python:3.6-bullseye"} - allow_failures: true # EoL - - name: test (Linux - 3.7) - container: {image: "python:3.7-bullseye"} - name: test (Linux - 3.8) container: {image: "python:3.8-bullseye"} - name: test (Linux - 3.9) @@ -99,7 +102,7 @@ linux_task: - name: test (Linux - 3.11) container: {image: "python:3.11-bullseye"} - name: test (Linux - 3.12) - container: {image: "python:3.12-rc-bullseye"} + container: {image: "python:3.12-bullseye"} allow_failures: true # Experimental install_script: - python -m pip install --upgrade pip tox pipx @@ -133,7 +136,7 @@ freebsd_task: <<: *test-template windows_task: - name: test (Windows - 3.9.10) + name: test (Windows - 3.9.13) windows_container: image: "cirrusci/windowsservercore:2019" os_version: 2019 @@ -143,7 +146,7 @@ windows_task: install_script: # Activate long file paths to avoid some errors - ps: New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force - - choco install -y --no-progress python3 --version=3.9.10 --params "/NoLockdown" + - choco install -y --no-progress python3 --version=3.9.13 --params "/NoLockdown" - pip install --upgrade certifi - python -m pip install -U pip tox pipx <<: *test-template @@ -152,7 +155,8 @@ finalize_task: container: {image: "python:3.10-bullseye"} depends_on: [test] <<: *task-template - install_script: pip install coveralls + install_script: pip install 'coveralls<4' + # ^-- https://github.com/TheKevJames/coveralls-python/issues/434 finalize_coverage_script: coveralls --finish linkcheck_task: diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f3267e9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +# Keep GitHub Actions up to date with GitHub's Dependabot... +# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + groups: + actions: + patterns: + - "*" # Group all Actions updates into a single larger pull request + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1656e21..5c0ffb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,8 @@ on: # relevant branches and tags. Other branches can be checked via PRs. # branches: [main] tags: ['v[0-9]*', '[0-9]+.[0-9]+*'] # Match tags that resemble a version - # pull_request: # Run in every PR + pull_request: + paths: ['.github/workflows/ci.yml'] # On PRs only when this file itself is changed workflow_dispatch: # Allow manually triggering the workflow schedule: # Run roughly every 15 days at 00:00 UTC @@ -19,15 +20,19 @@ concurrency: ${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true +env: + VALIDATE_PYPROJECT_CACHE_REMOTE: tests/.cache + + jobs: prepare: runs-on: ubuntu-latest outputs: wheel-distribution: ${{ steps.wheel-distribution.outputs.path }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: {fetch-depth: 0} # deep clone for setuptools-scm - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: {python-version: "3.10"} - name: Run static analysis and format checkers run: pipx run --python python3.10 tox -e lint,typecheck @@ -39,37 +44,50 @@ jobs: - name: Store the distribution files for use in other stages # `tests` and `publish` will use the same pre-built distributions, # so we make sure to release the exact same package that was tested - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: python-distribution-files path: dist/ retention-days: 1 + - name: Download files used for testing + run: python3.10 tools/cache_urls_for_tests.py + - name: Store downloaded files + uses: actions/upload-artifact@v4 + with: + name: test-download-files + path: ${{ env.VALIDATE_PYPROJECT_CACHE_REMOTE }} + retention-days: 1 test: needs: prepare strategy: matrix: python: - - "3.7" # oldest Python supported by PSF - - "3.11" # newest Python that is stable + - "3.8" # oldest Python supported by PSF + - "3.12" # newest Python that is stable platform: - ubuntu-latest - - macos-latest + - macos-13 - windows-latest runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - name: Retrieve pre-built distribution files - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: {name: python-distribution-files, path: dist/} + - name: Retrieve test download files + uses: actions/download-artifact@v4 + with: + name: test-download-files + path: ${{ env.VALIDATE_PYPROJECT_CACHE_REMOTE }} - name: Run tests run: >- pipx run tox --installpkg '${{ needs.prepare.outputs.wheel-distribution }}' - -- -rFEx --durations 10 --color yes + -- -n 5 -rFEx --durations 10 --color yes - name: Generate coverage report run: pipx run coverage lcov -o coverage.lcov - name: Upload partial coverage report @@ -95,11 +113,11 @@ jobs: if: ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/') }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: {python-version: "3.10"} - name: Retrieve pre-built distribution files - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: {name: python-distribution-files, path: dist/} - name: Publish Package env: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bf490de..8809e91 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ exclude: '^src/validate_pyproject/_vendor' repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: check-added-large-files - id: check-ast @@ -26,7 +26,7 @@ repos: args: [-w] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.14 # Ruff version + rev: v0.4.4 # Ruff version hooks: - id: ruff args: [--fix, --show-fixes] @@ -56,7 +56,7 @@ repos: - validate-pyproject[all]>=0.13 - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.27.3 + rev: 0.28.3 hooks: - id: check-metaschema files: \.schema\.json$ diff --git a/.ruff.toml b/.ruff.toml index be8642c..a36942a 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -1,6 +1,6 @@ # --- General config --- src = ["src"] -target-version = "py37" +target-version = "py38" # --- Linting config --- [lint] @@ -30,7 +30,10 @@ ignore = [ ] [lint.per-file-ignores] -"tests/*" = ["S"] # Assert okay in tests +"tests/*" = [ + "S", # Assert okay in tests + "PLR2004", # Magic value comparison is actually desired in tests +] # --- Tool-related config --- diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8db34bf..f5ab4b2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,18 @@ Changelog Development Version ==================== +Version 0.16 +============ +- Allow overwriting schemas referring to the same ``tool``, #175. + +Version 0.16 +============ +- Update version regex according to latest packaging version, #153 +- Remove duplicate ``# ruff: noqa``, #158 +- Remove invalid top-of-the-file ``# type: ignore`` statement, #159 +- Align ``tool.setuptools.dynamic.optional-dependencies`` with ``project.optional-dependencies``, #170 +- Bump min Python version to 3.8, #167 + Version 0.16 ============ - Fix setuptools ``readme`` field , #116 diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 0ce75c2..8c85d74 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -77,7 +77,7 @@ Code Contributions ================== Understanding how the project works -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +----------------------------------- If you have a change in mind, please have a look in our :doc:`dev-guide`. It explains the main aspects of the project and provide a brief overview on how @@ -240,6 +240,7 @@ package: Maintainer tasks ================ + Releases -------- diff --git a/README.rst b/README.rst index 768d3ad..dfe6f38 100644 --- a/README.rst +++ b/README.rst @@ -136,6 +136,22 @@ extending the validation with your own plugins_. If you consider contributing to this project, have a look on our `contribution guides`_. +Plugins +======= + +The `validate-pyproject-schema-store`_ plugin has a vendored copy of +pyproject.toml related `SchemaStore`_ entries. You can even install this using +the ``[store]`` extra: + + $ pipx install 'validate-pyproject[all,store]' + +Some of the tools in SchemaStore also have integrated validate-pyproject +plugins, like ``cibuildwheel`` and ``scikit-build-core``. However, unless you +want to pin an exact version of those tools, the SchemaStore copy is lighter +weight than installing the entire package. + +If you want to write a custom plugin for your tool, please consider also contributing a copy to SchemaStore. + pre-commit ========== @@ -146,9 +162,11 @@ pre-commit --- repos: - repo: https://github.com/abravalheri/validate-pyproject - rev: main + rev: hooks: - id: validate-pyproject + # Optional extra validations from SchemaStore: + additional_dependencies: ["validate-pyproject-schema-store[all]"] By default, this ``pre-commit`` hook will only validate the ``pyproject.toml`` file at the root of the project repository. @@ -158,6 +176,8 @@ the ``files`` parameter. You can also use ``pre-commit autoupdate`` to update to the latest stable version of ``validate-pyproject`` (recommended). +You can also use `validate-pyproject-schema-store`_ as a pre-commit hook, which +allows pre-commit to pin and update that instead of ``validate-pyproject`` itself. Note ==== @@ -181,10 +201,12 @@ For details and usage information on PyScaffold see https://pyscaffold.org/. .. _PEP 517: https://peps.python.org/pep-0517/ .. _PEP 518: https://peps.python.org/pep-0518/ .. _PEP 621: https://peps.python.org/pep-0621/ -.. _pipx: https://pypa.github.io/pipx/ +.. _pipx: https://pipx.pypa.io/stable/ .. _project: https://packaging.python.org/tutorials/managing-dependencies/ .. _setuptools: https://setuptools.pypa.io/en/stable/ .. _used JSON schemas: https://validate-pyproject.readthedocs.io/en/latest/schemas.html .. _pre-compiled way: https://validate-pyproject.readthedocs.io/en/latest/embedding.html .. _plugins: https://validate-pyproject.readthedocs.io/en/latest/dev-guide.html .. _virtual environment: https://realpython.com/python-virtual-environments-a-primer/ +.. _validate-pyproject-schema-store: https://github.com/henryiii/validate-pyproject-schema-store +.. _SchemaStore: https://www.schemastore.org diff --git a/docs/_gendocs.py b/docs/_gendocs.py new file mode 100644 index 0000000..bea9834 --- /dev/null +++ b/docs/_gendocs.py @@ -0,0 +1,41 @@ +"""``sphinx-apidoc`` only allows users to specify "exclude patterns" but not +"include patterns". This module solves that gap. +""" + +import shutil +from pathlib import Path + +MODULE_TEMPLATE = """ +``{name}`` +~~{underline}~~ + +.. automodule:: {name} + :members:{_members} + :undoc-members: + :show-inheritance: + :special-members: __call__ +""" + +__location__ = Path(__file__).parent + + +def gen_stubs(module_dir: str, output_dir: str): + shutil.rmtree(output_dir, ignore_errors=True) # Always start fresh + out = Path(output_dir) + out.mkdir(parents=True, exist_ok=True) + manifest = shutil.copy(__location__ / "modules.rst.in", out / "modules.rst") + for module in iter_public(manifest): + text = module_template(module) + Path(output_dir, f"{module}.rst").write_text(text, encoding="utf-8") + + +def iter_public(manifest): + toc = Path(manifest).read_text(encoding="utf-8") + lines = (x.strip() for x in toc.splitlines()) + return (x for x in lines if x.startswith("validate_pyproject.")) + + +def module_template(name: str, *members: str) -> str: + underline = "~" * len(name) + _members = (" " + ", ".join(members)) if members else "" + return MODULE_TEMPLATE.format(name=name, underline=underline, _members=_members) diff --git a/docs/conf.py b/docs/conf.py index 2d3e846..2a9dcbd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -8,52 +8,20 @@ # serve to show the default. import os -import shutil import sys # -- Path setup -------------------------------------------------------------- __location__ = os.path.dirname(__file__) - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. +sys.path.insert(0, __location__) sys.path.insert(0, os.path.join(__location__, "../src")) -# -- Run sphinx-apidoc ------------------------------------------------------- -# This hack is necessary since RTD does not issue `sphinx-apidoc` before running -# `sphinx-build -b html . _build/html`. See Issue: -# https://github.com/readthedocs/readthedocs.org/issues/1139 -# DON'T FORGET: Check the box "Install your project inside a virtualenv using -# setup.py install" in the RTD Advanced Settings. -# Additionally it helps us to avoid running apidoc manually - -try: # for Sphinx >= 1.7 - from sphinx.ext import apidoc -except ImportError: - from sphinx import apidoc +# -- Dynamically generated docs ---------------------------------------------- +import _gendocs output_dir = os.path.join(__location__, "api") module_dir = os.path.join(__location__, "../src/validate_pyproject") -try: - shutil.rmtree(output_dir) -except FileNotFoundError: - pass - -try: - import sphinx - - cmd_line = f"sphinx-apidoc --implicit-namespaces -f -o {output_dir} {module_dir}" - - args = cmd_line.split(" ") - if tuple(sphinx.__version__.split(".")) >= ("1", "7"): - # This is a rudimentary parse_version to avoid external dependencies - args = args[1:] - - apidoc.main(args) -except Exception as e: - print("Running `sphinx-apidoc` failed!") - print(e) +_gendocs.gen_stubs(module_dir, output_dir) # -- General configuration --------------------------------------------------- @@ -323,6 +291,8 @@ "scipy": ("https://docs.scipy.org/doc/scipy/reference", None), "setuptools": ("https://setuptools.pypa.io/en/stable/", None), "pyscaffold": ("https://pyscaffold.org/en/stable", None), + "fastjsonschema": ("https://horejsek.github.io/python-fastjsonschema/", None), + "pypa": ("https://packaging.python.org/en/latest/", None), } extlinks = { "issue": (f"{repository}/issues/%s", "issue #%s"), diff --git a/docs/dev-guide.rst b/docs/dev-guide.rst index cf69db5..8591987 100644 --- a/docs/dev-guide.rst +++ b/docs/dev-guide.rst @@ -55,14 +55,14 @@ These functions receive as argument the name of the tool subtable and should return a JSON schema for the data structure **under** this table (it **should** not include the table name itself as a property). -To use a plugin you can pass a ``plugins`` argument to the +To use a plugin you can pass an ``extra_plugins`` argument to the :class:`~validate_pyproject.api.Validator` constructor, but you will need to wrap it with :class:`~validate_pyproject.plugins.PluginWrapper` to be able to specify which ``tool`` subtable it would be checking: .. code-block:: python - from validate_pyproject import api, plugins + from validate_pyproject import api def your_plugin(tool_name: str) -> dict: @@ -77,14 +77,18 @@ specify which ``tool`` subtable it would be checking: available_plugins = [ - *plugins.list_from_entry_points(), plugins.PluginWrapper("your-tool", your_plugin), ] - validator = api.Validator(available_plugins) + validator = api.Validator(extra_plugins=available_plugins) Please notice that you can also make your plugin "autoloadable" by creating and distributing your own Python package as described in the following section. +If you want to disable the automatic discovery of all "autoloadable" plugins you +can pass ``plugins=[]`` to the constructor; or, for example in the snippet +above, we could have used ``plugins=...`` instead of ``extra_plugins=...`` +to ensure only the explicitly given plugins are loaded. + Distributing Plugins -------------------- diff --git a/docs/embedding.rst b/docs/embedding.rst index d89301a..dca619d 100644 --- a/docs/embedding.rst +++ b/docs/embedding.rst @@ -27,25 +27,25 @@ via CLI as indicated in the command below: # in you terminal $ python -m validate_pyproject.pre_compile --help - $ python -m validate_pyproject.pre_compile -O dir/for/genereated_files + $ python -m validate_pyproject.pre_compile -O dir/for/generated_files This command will generate a few files under the directory given to the CLI. Please notice this directory should, ideally, be empty, and will correspond to a "sub-package" in your package (a ``__init__.py`` file will be generated, together with a few other ones). -Assuming you have created a ``genereated_files`` directory, and that the value +Assuming you have created a ``generated_files`` directory, and that the value for the ``--main-file`` option in the CLI was kept as the default ``__init__.py``, you should be able to invoke the validation function in your code by doing: .. code-block:: python - from .genereated_files import validate, JsonSchemaValueException + from .generated_files import validate, ValidationError try: validate(dict_representing_the_parsed_toml_file) - except JsonSchemaValueException: + except ValidationError: print("Invalid File") diff --git a/docs/faq.rst b/docs/faq.rst index 607257d..875729d 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -61,6 +61,12 @@ The text on the standard is: This information is confirmed in a `similar document submitted to the IETF`_. +Where do I find information about *format* X? +============================================= + +Please check :doc:`/api/validate_pyproject.formats`. + + .. _if-then-else: https://json-schema.org/understanding-json-schema/reference/conditionals.html .. _issue: https://github.com/pypa/setuptools/issues/2671 .. _JSON Schema: https://json-schema.org/ diff --git a/docs/modules.rst.in b/docs/modules.rst.in new file mode 100644 index 0000000..17d370f --- /dev/null +++ b/docs/modules.rst.in @@ -0,0 +1,17 @@ +Module Reference +================ + +The public API of ``validate-pyproject`` is exposed in the :mod:`validate_pyproject.api` module. +Users may also import :mod:`validate_pyproject.errors` and :mod:`validate_pyproject.types` +when handling exceptions or specifying type hints. + +In addition to that, special `formats `_ +that can be used in the JSON Schema definitions are implemented in :mod:`validate_pyproject.formats`. + +.. toctree:: + :maxdepth: 2 + + validate_pyproject.api + validate_pyproject.errors + validate_pyproject.types + validate_pyproject.formats diff --git a/setup.cfg b/setup.cfg index 29361dc..2863019 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,7 +32,11 @@ platforms = any # https://pypi.org/classifiers/ classifiers = Development Status :: 4 - Beta + Intended Audience :: Developers + Operating System :: OS Independent Programming Language :: Python + Topic :: Software Development :: Quality Assurance + Typing :: Typed [options] @@ -43,15 +47,13 @@ package_dir = =src # Require a min/specific Python version (comma-separated conditions) -python_requires = >=3.6 +python_requires = >=3.8 # Add here dependencies of your project (line-separated), e.g. requests>=2.2,<3.0. # Version specifiers like >=2.2,<3.0 avoid problems due to API changes in # new major versions. This works if the required packages follow Semantic Versioning. # For more information, check out https://semver.org/. install_requires = - importlib-metadata; python_version<"3.8" - importlib-resources; python_version<"3.7" fastjsonschema>=2.16.2,<=3 @@ -66,6 +68,9 @@ all = packaging>=20.4 trove-classifiers>=2021.10.20 +store = + validate-pyproject-schema-store + # Add here test requirements (semicolon/line-separated) testing = setuptools diff --git a/src/validate_pyproject/__init__.py b/src/validate_pyproject/__init__.py index fbaff2e..be9fa4e 100644 --- a/src/validate_pyproject/__init__.py +++ b/src/validate_pyproject/__init__.py @@ -1,10 +1,4 @@ -import sys - -if sys.version_info[:2] >= (3, 8): - # TODO: Import directly (no need for conditional) when `python_requires = >= 3.8` - from importlib.metadata import PackageNotFoundError, version # pragma: no cover -else: - from importlib_metadata import PackageNotFoundError, version # pragma: no cover +from importlib.metadata import PackageNotFoundError, version # pragma: no cover try: # Change here if project is renamed and does not equal the package name diff --git a/src/validate_pyproject/api.py b/src/validate_pyproject/api.py index 003bf78..4dd7e8e 100644 --- a/src/validate_pyproject/api.py +++ b/src/validate_pyproject/api.py @@ -1,9 +1,9 @@ """ Retrieve JSON schemas for validating dicts representing a ``pyproject.toml`` file. """ + import json import logging -import sys import typing from enum import Enum from functools import partial, reduce @@ -34,20 +34,18 @@ try: # pragma: no cover - if sys.version_info[:2] < (3, 7) or typing.TYPE_CHECKING: # See #22 - from importlib_resources import files - else: - from importlib.resources import files + from importlib.resources import files def read_text(package: Union[str, ModuleType], resource: str) -> str: - return files(package).joinpath(resource).read_text(encoding="utf-8") # type: ignore[no-any-return] + """:meta private:""" + return files(package).joinpath(resource).read_text(encoding="utf-8") except ImportError: # pragma: no cover from importlib.resources import read_text T = TypeVar("T", bound=Mapping) -AllPlugins = Enum("AllPlugins", "ALL_PLUGINS") +AllPlugins = Enum("AllPlugins", "ALL_PLUGINS") #: :meta private: ALL_PLUGINS = AllPlugins.ALL_PLUGINS TOP_LEVEL_SCHEMA = "pyproject_toml" @@ -68,11 +66,14 @@ def _get_public_functions(module: ModuleType) -> Mapping[str, FormatValidationFn def load(name: str, package: str = __package__, ext: str = ".schema.json") -> Schema: """Load the schema from a JSON Schema file. The returned dict-like object is immutable. + + :meta private: (low level detail) """ return Schema(json.loads(read_text(package, f"{name}{ext}"))) def load_builtin_plugin(name: str) -> Schema: + """:meta private: (low level detail)""" return load(name, f"{__package__}.plugins") @@ -85,6 +86,8 @@ class SchemaRegistry(Mapping[str, Schema]): Since this object work as a mapping between each schema ``$id`` and the schema itself, all schemas provided by plugins **MUST** have a top level ``$id``. + + :meta private: (low level detail) """ def __init__(self, plugins: Sequence["PluginProtocol"] = ()): @@ -106,11 +109,16 @@ def __init__(self, plugins: Sequence["PluginProtocol"] = ()): # Add tools using Plugins for plugin in plugins: + allow_overwrite: Optional[str] = None if plugin.tool in tool_properties: _logger.warning(f"{plugin.id} overwrites `tool.{plugin.tool}` schema") + allow_overwrite = plugin.schema.get("$id") else: _logger.info(f"{plugin.id} defines `tool.{plugin.tool}` schema") - sid = self._ensure_compatibility(plugin.tool, plugin.schema)["$id"] + compatible = self._ensure_compatibility( + plugin.tool, plugin.schema, allow_overwrite + ) + sid = compatible["$id"] sref = f"{sid}#{plugin.fragment}" if plugin.fragment else sid tool_properties[plugin.tool] = {"$ref": sref} self._schemas[sid] = (f"tool.{plugin.tool}", plugin.id, plugin.schema) @@ -130,11 +138,13 @@ def main(self) -> str: """Top level schema for validating a ``pyproject.toml`` file""" return self._main_id - def _ensure_compatibility(self, reference: str, schema: Schema) -> Schema: - if "$id" not in schema: + def _ensure_compatibility( + self, reference: str, schema: Schema, allow_overwrite: Optional[str] = None + ) -> Schema: + if "$id" not in schema or not schema["$id"]: raise errors.SchemaMissingId(reference) sid = schema["$id"] - if sid in self._schemas: + if sid in self._schemas and sid != allow_overwrite: raise errors.SchemaWithDuplicatedId(sid) version = schema.get("$schema") # Support schemas with missing trailing # (incorrect, but required before 0.15) @@ -158,6 +168,8 @@ class RefHandler(Mapping[str, Callable[[str], Schema]]): into a function that receives the schema URI and returns the schema (as parsed JSON) (otherwise :mod:`urllib` is used and the URI is assumed to be a valid URL). This class will ensure all the URIs are loaded from the local registry. + + :meta private: (low level detail) """ def __init__(self, registry: Mapping[str, Schema]): @@ -243,6 +255,9 @@ def __getitem__(self, schema_id: str) -> Schema: return self._schema_registry[schema_id] def __call__(self, pyproject: T) -> T: + """Checks a parsed ``pyproject.toml`` file (given as :obj:`typing.Mapping`) + and raises an exception when it is not a valid. + """ if self._cache is None: compiled = FJS.compile(self.schema, self.handlers, dict(self.formats)) fn = partial(compiled, custom_formats=self._format_validators) diff --git a/src/validate_pyproject/caching.py b/src/validate_pyproject/caching.py new file mode 100644 index 0000000..2c83e3f --- /dev/null +++ b/src/validate_pyproject/caching.py @@ -0,0 +1,48 @@ +# This module is intentionally kept minimal, +# so that it can be imported without triggering imports outside stdlib. +import hashlib +import io +import logging +import os +from pathlib import Path +from typing import Callable, Optional, Union + +PathLike = Union[str, os.PathLike] +_logger = logging.getLogger(__name__) + + +def as_file( + fn: Callable[[str], io.StringIO], + arg: str, + cache_dir: Optional[PathLike] = None, +) -> Union[io.StringIO, io.BufferedReader]: + """ + Cache the result of calling ``fn(arg)`` into a file inside ``cache_dir``. + The file name is derived from ``arg``. + If no ``cache_dir`` is provided, it is equivalent to calling ``fn(arg)``. + The return value can be used as a context. + """ + cache_path = path_for(arg, cache_dir) + if not cache_path: + return fn(arg) + + if cache_path.exists(): + _logger.debug(f"Using cached {arg} from {cache_path}") + else: + with fn(arg) as f: + cache_path.write_text(f.getvalue(), encoding="utf-8") + _logger.debug(f"Caching {arg} into {cache_path}") + + return open(cache_path, "rb") # noqa: SIM115 -- not relevant + + +def path_for(arbitrary_id: str, cache: Optional[PathLike] = None) -> Optional[Path]: + cache_dir = cache or os.getenv("VALIDATE_PYPROJECT_CACHE_REMOTE") + if not cache_dir: + return None + + escaped = "".join(c if c.isalnum() else "-" for c in arbitrary_id) + sha1 = hashlib.sha1(arbitrary_id.encode()) # noqa: S324 + # ^-- Non-crypto context and appending `escaped` should minimise collisions + return Path(os.path.expanduser(cache_dir), f"{sha1.hexdigest()}-{escaped}") + # ^-- Intentionally uses `os.path` instead of `pathlib` to avoid exception diff --git a/src/validate_pyproject/error_reporting.py b/src/validate_pyproject/error_reporting.py index b2f883d..b8dca35 100644 --- a/src/validate_pyproject/error_reporting.py +++ b/src/validate_pyproject/error_reporting.py @@ -45,6 +45,11 @@ "property names": "keys", } +_FORMATS_HELP = """ +For more details about `format` see +https://validate-pyproject.readthedocs.io/en/latest/api/validate_pyproject.formats.html +""" + class ValidationError(JsonSchemaValueException): """Report violations of a given JSON schema. @@ -160,7 +165,9 @@ def _expand_details(self) -> str: f"OFFENDING RULE: {self.ex.rule!r}", f"DEFINITION:\n{indent(schema, ' ')}", ] - return "\n\n".join(optional + defaults) + msg = "\n\n".join(optional + defaults) + epilog = f"\n{_FORMATS_HELP}" if "format" in msg.lower() else "" + return msg + epilog class _SummaryWriter: diff --git a/src/validate_pyproject/errors.py b/src/validate_pyproject/errors.py index e2d1ce2..4f29011 100644 --- a/src/validate_pyproject/errors.py +++ b/src/validate_pyproject/errors.py @@ -1,9 +1,19 @@ +""" +In general, users should expect :obj:`validate_pyproject.errors.ValidationError` +from :obj:`validate_pyproject.api.Validator.__call__`. + +Note that ``validate-pyproject`` derives most of its exceptions from +:mod:`fastjsonschema`, so it might make sense to also have a look on +:obj:`fastjsonschema.JsonSchemaException`, +:obj:`fastjsonschema.JsonSchemaValueException` and +:obj:`fastjsonschema.JsonSchemaDefinitionException`. +) +""" + from textwrap import dedent from fastjsonschema import ( - JsonSchemaDefinitionException, - JsonSchemaException, - JsonSchemaValueException, + JsonSchemaDefinitionException as _JsonSchemaDefinitionException, ) from .error_reporting import ValidationError @@ -24,7 +34,7 @@ def __init__(self, url: str): super().__init__(msg) -class InvalidSchemaVersion(JsonSchemaDefinitionException): +class InvalidSchemaVersion(_JsonSchemaDefinitionException): _DESC = """\ All schemas used in the validator should be specified using the same version \ as the toplevel schema ({version!r}). @@ -39,7 +49,7 @@ def __init__(self, name: str, given_version: str, required_version: str): super().__init__(msg) -class SchemaMissingId(JsonSchemaDefinitionException): +class SchemaMissingId(_JsonSchemaDefinitionException): _DESC = """\ All schemas used in the validator MUST define a unique toplevel `"$id"`. No `"$id"` was found for schema associated with {reference!r}. @@ -51,7 +61,7 @@ def __init__(self, reference: str): super().__init__(msg.format(reference=reference)) -class SchemaWithDuplicatedId(JsonSchemaDefinitionException): +class SchemaWithDuplicatedId(_JsonSchemaDefinitionException): _DESC = """\ All schemas used in the validator MUST define a unique toplevel `"$id"`. `$id = {schema_id!r}` was found at least twice. @@ -65,9 +75,6 @@ def __init__(self, schema_id: str): __all__ = [ "InvalidSchemaVersion", - "JsonSchemaDefinitionException", - "JsonSchemaException", - "JsonSchemaValueException", "SchemaMissingId", "SchemaWithDuplicatedId", "ValidationError", diff --git a/src/validate_pyproject/extra_validations.py b/src/validate_pyproject/extra_validations.py index 760acf9..c4ffe65 100644 --- a/src/validate_pyproject/extra_validations.py +++ b/src/validate_pyproject/extra_validations.py @@ -20,7 +20,7 @@ class RedefiningStaticFieldAsDynamic(ValidationError): __doc__ = _DESC _URL = ( "https://packaging.python.org/en/latest/specifications/" - "declaring-project-metadata/#dynamic" + "pyproject-toml/#dynamic" ) diff --git a/src/validate_pyproject/formats.py b/src/validate_pyproject/formats.py index 084cb83..5a0599c 100644 --- a/src/validate_pyproject/formats.py +++ b/src/validate_pyproject/formats.py @@ -1,3 +1,12 @@ +""" +The functions in this module are used to validate schemas with the +`format JSON Schema keyword +`_. + +The correspondence is given by replacing the ``_`` character in the name of the +function with a ``-`` to obtain the format name and vice versa. +""" + import builtins import logging import os @@ -21,7 +30,7 @@ (?P[0-9]+(?:\.[0-9]+)*) # release segment (?P
                                          # pre-release
             [-_\.]?
-            (?P(a|b|c|rc|alpha|beta|pre|preview))
+            (?Palpha|a|beta|b|preview|pre|c|rc)
             [-_\.]?
             (?P[0-9]+)?
         )?
@@ -49,6 +58,9 @@
 
 
 def pep440(version: str) -> bool:
+    """See :ref:`PyPA's version specification `
+    (initially introduced in :pep:`440`).
+    """
     return VERSION_REGEX.match(version) is not None
 
 
@@ -60,6 +72,9 @@ def pep440(version: str) -> bool:
 
 
 def pep508_identifier(name: str) -> bool:
+    """See :ref:`PyPA's name specification `
+    (initially introduced in :pep:`508#names`).
+    """
     return PEP508_IDENTIFIER_REGEX.match(name) is not None
 
 
@@ -71,6 +86,9 @@ def pep508_identifier(name: str) -> bool:
         from setuptools._vendor.packaging import requirements as _req  # type: ignore
 
     def pep508(value: str) -> bool:
+        """See :ref:`PyPA's dependency specifiers `
+        (initially introduced in :pep:`508`).
+        """
         try:
             _req.Requirement(value)
             return True
@@ -89,7 +107,10 @@ def pep508(value: str) -> bool:
 
 
 def pep508_versionspec(value: str) -> bool:
-    """Expression that can be used to specify/lock versions (including ranges)"""
+    """Expression that can be used to specify/lock versions (including ranges)
+    See ``versionspec`` in :ref:`PyPA's dependency specifiers
+    ` (initially introduced in :pep:`508`).
+    """
     if any(c in value for c in (";", "]", "@")):
         # In PEP 508:
         # conditional markers, extras and URL specs are not included in the
@@ -105,6 +126,11 @@ def pep508_versionspec(value: str) -> bool:
 
 
 def pep517_backend_reference(value: str) -> bool:
+    """See PyPA's specification for defining build-backend references
+    introduced in :pep:`517#source-trees`.
+
+    This is similar to an entry-point reference (e.g., ``package.module:object``).
+    """
     module, _, obj = value.partition(":")
     identifiers = (i.strip() for i in _chain(module.split("."), obj.split(".")))
     return all(python_identifier(i) for i in identifiers if i)
@@ -181,6 +207,7 @@ def __call__(self, value: str) -> bool:
     from trove_classifiers import classifiers as _trove_classifiers
 
     def trove_classifier(value: str) -> bool:
+        """See https://pypi.org/classifiers/"""
         return value in _trove_classifiers or value.lower().startswith("private ::")
 
 except ImportError:  # pragma: no cover
@@ -192,6 +219,10 @@ def trove_classifier(value: str) -> bool:
 
 
 def pep561_stub_name(value: str) -> bool:
+    """Name of a directory containing type stubs.
+    It must follow the name scheme ``-stubs`` as defined in
+    :pep:`561#stub-only-packages`.
+    """
     top, *children = value.split(".")
     if not top.endswith("-stubs"):
         return False
@@ -203,6 +234,10 @@ def pep561_stub_name(value: str) -> bool:
 
 
 def url(value: str) -> bool:
+    """Valid URL (validation uses :obj:`urllib.parse`).
+    For maximum compatibility please make sure to include a ``scheme`` prefix
+    in your URL (e.g. ``http://``).
+    """
     from urllib.parse import urlparse
 
     try:
@@ -231,24 +266,40 @@ def url(value: str) -> bool:
 
 
 def python_identifier(value: str) -> bool:
+    """Can be used as identifier in Python.
+    (Validation uses :obj:`str.isidentifier`).
+    """
     return value.isidentifier()
 
 
 def python_qualified_identifier(value: str) -> bool:
+    """
+    Python "dotted identifier", i.e. a sequence of :obj:`python_identifier`
+    concatenated with ``"."`` (e.g.: ``package.module.submodule``).
+    """
     if value.startswith(".") or value.endswith("."):
         return False
     return all(python_identifier(m) for m in value.split("."))
 
 
 def python_module_name(value: str) -> bool:
+    """Module name that can be used in an ``import``-statement in Python.
+    See :obj:`python_qualified_identifier`.
+    """
     return python_qualified_identifier(value)
 
 
 def python_entrypoint_group(value: str) -> bool:
+    """See ``Data model > group`` in the :ref:`PyPA's entry-points specification
+    `.
+    """
     return ENTRYPOINT_GROUP_REGEX.match(value) is not None
 
 
 def python_entrypoint_name(value: str) -> bool:
+    """See ``Data model > name`` in the :ref:`PyPA's entry-points specification
+    `.
+    """
     if not ENTRYPOINT_REGEX.match(value):
         return False
     if not RECOMMEDED_ENTRYPOINT_REGEX.match(value):
@@ -259,6 +310,13 @@ def python_entrypoint_name(value: str) -> bool:
 
 
 def python_entrypoint_reference(value: str) -> bool:
+    """Reference to a Python object using in the format::
+
+        importable.module:object.attr
+
+    See ``Data model >object reference`` in the :ref:`PyPA's entry-points specification
+    `.
+    """
     module, _, rest = value.partition(":")
     if "[" in rest:
         obj, _, extras_ = rest.partition("[")
@@ -277,16 +335,20 @@ def python_entrypoint_reference(value: str) -> bool:
 
 
 def uint8(value: builtins.int) -> bool:
+    r"""Unsigned 8-bit integer (:math:`0 \leq x < 2^8`)"""
     return 0 <= value < 2**8
 
 
 def uint16(value: builtins.int) -> bool:
+    r"""Unsigned 16-bit integer (:math:`0 \leq x < 2^{16}`)"""
     return 0 <= value < 2**16
 
 
 def uint(value: builtins.int) -> bool:
+    r"""Unsigned 64-bit integer (:math:`0 \leq x < 2^{64}`)"""
     return 0 <= value < 2**64
 
 
 def int(value: builtins.int) -> bool:
+    r"""Signed 64-bit integer (:math:`-2^{63} \leq x < 2^{63}`)"""
     return -(2**63) <= value < 2**63
diff --git a/src/validate_pyproject/http.py b/src/validate_pyproject/http.py
new file mode 100644
index 0000000..248e2e7
--- /dev/null
+++ b/src/validate_pyproject/http.py
@@ -0,0 +1,15 @@
+# This module is intentionally kept minimal,
+# so that it can be imported without triggering imports outside stdlib.
+import io
+import sys
+from urllib.request import urlopen
+
+if sys.platform == "emscripten" and "pyodide" in sys.modules:
+    from pyodide.http import open_url
+else:
+
+    def open_url(url: str) -> io.StringIO:
+        if not url.startswith(("http:", "https:")):
+            raise ValueError("URL must start with 'http:' or 'https:'")
+        with urlopen(url) as response:  # noqa: S310
+            return io.StringIO(response.read().decode("utf-8"))
diff --git a/src/validate_pyproject/plugins/__init__.py b/src/validate_pyproject/plugins/__init__.py
index 813ac2a..19ca2c1 100644
--- a/src/validate_pyproject/plugins/__init__.py
+++ b/src/validate_pyproject/plugins/__init__.py
@@ -5,57 +5,37 @@
 .. _entry point: https://setuptools.readthedocs.io/en/latest/userguide/entry_point.html
 """
 
-import sys
 import typing
+from importlib.metadata import EntryPoint, entry_points
 from string import Template
 from textwrap import dedent
-from typing import Any, Callable, Iterable, List, Optional
+from typing import Any, Callable, Iterable, List, Optional, Protocol
 
 from .. import __version__
-
-if sys.version_info[:2] >= (3, 8):  # pragma: no cover
-    # TODO: Import directly (no need for conditional) when `python_requires = >= 3.8`
-    from importlib.metadata import EntryPoint, entry_points
-else:  # pragma: no cover
-    from importlib_metadata import EntryPoint, entry_points
-
-if typing.TYPE_CHECKING:
-    from ..types import Plugin, Schema
-
-    if sys.version_info < (3, 8):
-        from typing_extensions import Protocol
-    else:
-        from typing import Protocol
-else:
-    Protocol = object
+from ..types import Plugin, Schema
 
 ENTRYPOINT_GROUP = "validate_pyproject.tool_schema"
 
 
 class PluginProtocol(Protocol):
     @property
-    def id(self) -> str:
-        ...
+    def id(self) -> str: ...
 
     @property
-    def tool(self) -> str:
-        ...
+    def tool(self) -> str: ...
 
     @property
-    def schema(self) -> "Schema":
-        ...
+    def schema(self) -> Schema: ...
 
     @property
-    def help_text(self) -> str:
-        ...
+    def help_text(self) -> str: ...
 
     @property
-    def fragment(self) -> str:
-        ...
+    def fragment(self) -> str: ...
 
 
 class PluginWrapper:
-    def __init__(self, tool: str, load_fn: "Plugin"):
+    def __init__(self, tool: str, load_fn: Plugin):
         self._tool = tool
         self._load_fn = load_fn
 
@@ -68,7 +48,7 @@ def tool(self) -> str:
         return self._tool
 
     @property
-    def schema(self) -> "Schema":
+    def schema(self) -> Schema:
         return self._load_fn(self.tool)
 
     @property
@@ -110,7 +90,9 @@ def iterate_entry_points(group: str = ENTRYPOINT_GROUP) -> Iterable[EntryPoint]:
         # TODO: Once Python 3.10 becomes the oldest version supported, this fallback and
         #       conditional statement can be removed.
         entries_ = (plugin for plugin in entries.get(group, []))
-    deduplicated = {e.name: e for e in sorted(entries_, key=lambda e: e.name)}
+    deduplicated = {
+        e.name: e for e in sorted(entries_, key=lambda e: (e.name, e.value))
+    }
     return list(deduplicated.values())
 
 
diff --git a/src/validate_pyproject/plugins/setuptools.schema.json b/src/validate_pyproject/plugins/setuptools.schema.json
index 2b029b6..adc203d 100644
--- a/src/validate_pyproject/plugins/setuptools.schema.json
+++ b/src/validate_pyproject/plugins/setuptools.schema.json
@@ -219,7 +219,7 @@
         "dependencies": {"$ref": "#/definitions/file-directive-for-dependencies"},
         "optional-dependencies": {
           "type": "object",
-          "propertyNames": {"type": "string", "format": "python-identifier"},
+          "propertyNames": {"type": "string", "format": "pep508-identifier"},
           "additionalProperties": false,
           "patternProperties": {
             ".+": {"$ref": "#/definitions/file-directive-for-dependencies"}
diff --git a/src/validate_pyproject/pre_compile/__init__.py b/src/validate_pyproject/pre_compile/__init__.py
index 0a597ca..efa6d00 100644
--- a/src/validate_pyproject/pre_compile/__init__.py
+++ b/src/validate_pyproject/pre_compile/__init__.py
@@ -1,6 +1,6 @@
 import logging
 import os
-import sys
+from importlib import metadata as _M
 from pathlib import Path
 from types import MappingProxyType
 from typing import TYPE_CHECKING, Dict, Mapping, Optional, Sequence, Union
@@ -9,11 +9,6 @@
 
 from .. import api, dist_name, types
 
-if sys.version_info[:2] >= (3, 8):  # pragma: no cover
-    from importlib import metadata as _M
-else:  # pragma: no cover
-    import importlib_metadata as _M
-
 if TYPE_CHECKING:  # pragma: no cover
     from ..plugins import PluginProtocol
 
@@ -112,12 +107,10 @@ def load_licenses() -> Dict[str, str]:
 
 NOCHECK_HEADERS = (
     "# noqa",
-    "# type: ignore",
     "# ruff: noqa",
     "# flake8: noqa",
     "# pylint: skip-file",
     "# mypy: ignore-errors",
-    "# ruff: noqa",
     "# yapf: disable",
     "# pylama:skip=1",
     "\n\n# *** PLEASE DO NOT MODIFY DIRECTLY: Automatically generated code *** \n\n\n",
diff --git a/src/validate_pyproject/pre_compile/cli.py b/src/validate_pyproject/pre_compile/cli.py
index 6694b7b..985ba74 100644
--- a/src/validate_pyproject/pre_compile/cli.py
+++ b/src/validate_pyproject/pre_compile/cli.py
@@ -17,13 +17,8 @@
 
 if sys.platform == "win32":  # pragma: no cover
     from subprocess import list2cmdline as arg_join
-elif sys.version_info[:2] >= (3, 8):  # pragma: no cover
-    from shlex import join as arg_join
 else:  # pragma: no cover
-    from shlex import quote
-
-    def arg_join(args: Sequence[str]) -> str:
-        return " ".join(quote(x) for x in args)
+    from shlex import join as arg_join
 
 
 _logger = logging.getLogger(__package__)
diff --git a/src/validate_pyproject/project_metadata.schema.json b/src/validate_pyproject/project_metadata.schema.json
index 8ad7199..43f2411 100644
--- a/src/validate_pyproject/project_metadata.schema.json
+++ b/src/validate_pyproject/project_metadata.schema.json
@@ -1,7 +1,7 @@
 {
   "$schema": "http://json-schema.org/draft-07/schema#",
 
-  "$id": "https://packaging.python.org/en/latest/specifications/declaring-project-metadata/",
+  "$id": "https://packaging.python.org/en/latest/specifications/pyproject-toml/",
   "title": "Package metadata stored in the ``project`` table",
   "$$description": [
     "Data structure for the **project** table inside ``pyproject.toml``",
diff --git a/src/validate_pyproject/pyproject_toml.schema.json b/src/validate_pyproject/pyproject_toml.schema.json
index 2e06117..f540981 100644
--- a/src/validate_pyproject/pyproject_toml.schema.json
+++ b/src/validate_pyproject/pyproject_toml.schema.json
@@ -55,7 +55,7 @@
     },
 
     "project": {
-      "$ref": "https://packaging.python.org/en/latest/specifications/declaring-project-metadata/"
+      "$ref": "https://packaging.python.org/en/latest/specifications/pyproject-toml/"
     },
 
     "tool": {
diff --git a/src/validate_pyproject/remote.py b/src/validate_pyproject/remote.py
index c024050..2194c17 100644
--- a/src/validate_pyproject/remote.py
+++ b/src/validate_pyproject/remote.py
@@ -1,31 +1,20 @@
-import io
 import json
 import logging
-import sys
 import typing
 import urllib.parse
-import urllib.request
-from typing import Generator, Tuple
+from typing import Generator, Optional, Tuple
 
-from . import errors
+from . import caching, errors, http
 from .types import Schema
 
 if typing.TYPE_CHECKING:
+    import sys
+
     if sys.version_info < (3, 11):
         from typing_extensions import Self
     else:
         from typing import Self
 
-if sys.platform == "emscripten" and "pyodide" in sys.modules:
-    from pyodide.http import open_url
-else:
-
-    def open_url(url: str) -> io.StringIO:
-        if not url.startswith(("http:", "https:")):
-            raise ValueError("URL must start with 'http:' or 'https:'")
-        with urllib.request.urlopen(url) as response:  # noqa: S310
-            return io.StringIO(response.read().decode("utf-8"))
-
 
 __all__ = ["RemotePlugin", "load_store"]
 
@@ -33,11 +22,14 @@ def open_url(url: str) -> io.StringIO:
 _logger = logging.getLogger(__name__)
 
 
-def load_from_uri(tool_uri: str) -> Tuple[str, Schema]:
+def load_from_uri(
+    tool_uri: str, cache_dir: Optional[caching.PathLike] = None
+) -> Tuple[str, Schema]:
     tool_info = urllib.parse.urlparse(tool_uri)
     if tool_info.netloc:
         url = f"{tool_info.scheme}://{tool_info.netloc}{tool_info.path}"
-        with open_url(url) as f:
+        download = caching.as_file(http.open_url, url, cache_dir)
+        with download as f:
             contents = json.load(f)
     else:
         with open(tool_info.path, "rb") as f:
diff --git a/tests/conftest.py b/tests/conftest.py
index ad76269..dbeb13b 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,9 +1,9 @@
 """
-    conftest.py for validate_pyproject.
+conftest.py for validate_pyproject.
 
-    Read more about conftest.py under:
-    - https://docs.pytest.org/en/stable/fixture.html
-    - https://docs.pytest.org/en/stable/writing_plugins.html
+Read more about conftest.py under:
+- https://docs.pytest.org/en/stable/fixture.html
+- https://docs.pytest.org/en/stable/writing_plugins.html
 """
 
 from pathlib import Path
@@ -14,6 +14,10 @@
 HERE = Path(__file__).parent.resolve()
 
 
+def pytest_configure(config):
+    config.addinivalue_line("markers", "uses_network: tests may try to download files")
+
+
 def collect(base: Path) -> List[str]:
     return [str(f.relative_to(base)) for f in base.glob("**/*.toml")]
 
diff --git a/tests/examples/setuptools/10-pyproject.toml b/tests/examples/setuptools/10-pyproject.toml
new file mode 100644
index 0000000..3c9c393
--- /dev/null
+++ b/tests/examples/setuptools/10-pyproject.toml
@@ -0,0 +1,7 @@
+[project]
+name = "myproj"
+version = "42"
+dynamic = ["optional-dependencies"]
+
+[tool.setuptools.dynamic.optional-dependencies]
+name-with-hyfens = {file = "extra.txt"}
diff --git a/tests/invalid-examples/setuptools/dependencies/invalid-extra-name.errors.txt b/tests/invalid-examples/setuptools/dependencies/invalid-extra-name.errors.txt
index 8f03afc..8abee19 100644
--- a/tests/invalid-examples/setuptools/dependencies/invalid-extra-name.errors.txt
+++ b/tests/invalid-examples/setuptools/dependencies/invalid-extra-name.errors.txt
@@ -1,3 +1,3 @@
 `tool.setuptools.dynamic.optional-dependencies` keys must be named by:
 
-    {type: string, format: 'python-identifier'}
+    {type: string, format: 'pep508-identifier'}
diff --git a/tests/test_api.py b/tests/test_api.py
index 48e7ce2..2a49ad4 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -17,7 +17,7 @@ def test_load():
     assert spec["$id"] == f"{PYPA_SPECS}/declaring-build-dependencies/"
 
     spec = api.load("project_metadata")
-    assert spec["$id"] == f"{PYPA_SPECS}/declaring-project-metadata/"
+    assert spec["$id"] == f"{PYPA_SPECS}/pyproject-toml/"
 
 
 def test_load_plugin():
@@ -36,7 +36,7 @@ def test_with_plugins(self):
         registry = api.SchemaRegistry(plg)
         main_schema = registry[registry.main]
         project = main_schema["properties"]["project"]
-        assert project["$ref"] == f"{PYPA_SPECS}/declaring-project-metadata/"
+        assert project["$ref"] == f"{PYPA_SPECS}/pyproject-toml/"
         tool = main_schema["properties"]["tool"]
         assert "setuptools" in tool["properties"]
         assert "$ref" in tool["properties"]["setuptools"]
@@ -63,11 +63,19 @@ def test_incompatible_versions(self):
         with pytest.raises(errors.InvalidSchemaVersion):
             api.SchemaRegistry([plg])
 
-    def test_duplicated_id(self):
-        plg = [plugins.PluginWrapper("plg", self.fake_plugin) for _ in range(2)]
+    def test_duplicated_id_different_tools(self):
+        schema = self.fake_plugin("plg")
+        fn = wraps(self.fake_plugin)(lambda _: schema)  # Same ID
+        plg = [plugins.PluginWrapper(f"plg{i}", fn) for i in range(2)]
         with pytest.raises(errors.SchemaWithDuplicatedId):
             api.SchemaRegistry(plg)
 
+    def test_allow_overwrite_same_tool(self):
+        plg = [plugins.PluginWrapper("plg", self.fake_plugin) for _ in range(2)]
+        registry = api.SchemaRegistry(plg)
+        sid = self.fake_plugin("plg")["$id"]
+        assert sid in registry
+
     def test_missing_id(self):
         def _fake_plugin(name):
             plg = dict(self.fake_plugin(name))
diff --git a/tests/test_caching.py b/tests/test_caching.py
new file mode 100644
index 0000000..65844a9
--- /dev/null
+++ b/tests/test_caching.py
@@ -0,0 +1,87 @@
+import io
+import os
+from unittest.mock import Mock
+
+import pytest
+
+from validate_pyproject import caching, http, remote
+
+
+@pytest.fixture(autouse=True)
+def no_cache_env_var(monkeypatch):
+    monkeypatch.delenv("VALIDATE_PYPROJECT_CACHE_REMOTE", raising=False)
+
+
+def fn1(arg: str) -> io.StringIO:
+    return io.StringIO("42")
+
+
+def fn2(arg: str) -> io.StringIO:
+    raise RuntimeError("should not be called")
+
+
+def test_as_file(tmp_path):
+    # The first call should create a file and return its contents
+    cache_path = caching.path_for("hello-world", tmp_path)
+    assert not cache_path.exists()
+
+    with caching.as_file(fn1, "hello-world", tmp_path) as f:
+        assert f.read() == b"42"
+
+    assert cache_path.exists()
+    assert cache_path.read_text("utf-8") == "42"
+
+    # Any further calls using the same ``arg`` should reuse the file
+    # and NOT call the function
+    with caching.as_file(fn2, "hello-world", tmp_path) as f:
+        assert f.read() == b"42"
+
+    # If the file is deleted, then the function should be called
+    cache_path.unlink()
+    with pytest.raises(RuntimeError, match="should not be called"):
+        caching.as_file(fn2, "hello-world", tmp_path)
+
+
+def test_as_file_no_cache():
+    # If no cache directory is passed, the orig function should
+    # be called straight away:
+    with pytest.raises(RuntimeError, match="should not be called"):
+        caching.as_file(fn2, "hello-world")
+
+
+def test_path_for_no_cache(monkeypatch):
+    cache_path = caching.path_for("hello-world", None)
+    assert cache_path is None
+
+
+@pytest.mark.uses_network
+@pytest.mark.skipif(
+    os.getenv("VALIDATE_PYPROJECT_NO_NETWORK") or os.getenv("NO_NETWORK"),
+    reason="Disable tests that depend on network",
+)
+class TestIntegration:
+    def test_cache_open_url(self, tmp_path, monkeypatch):
+        open_url = Mock(wraps=http.open_url)
+        monkeypatch.setattr(http, "open_url", open_url)
+
+        # The first time it is called, it will cache the results into a file
+        url = (
+            "https://raw.githubusercontent.com/abravalheri/validate-pyproject/main/"
+            "src/validate_pyproject/pyproject_toml.schema.json"
+        )
+        cache_path = caching.path_for(url, tmp_path)
+        assert not cache_path.exists()
+
+        with caching.as_file(http.open_url, url, tmp_path) as f:
+            assert b"build-system" in f.read()
+
+        open_url.assert_called_once()
+        assert cache_path.exists()
+        assert "build-system" in cache_path.read_text("utf-8")
+
+        # The second time, it will not reach the network, and use the file contents
+        open_url.reset_mock()
+        _, contents = remote.load_from_uri(url, cache_dir=tmp_path)
+
+        assert "build-system" in contents["properties"]
+        open_url.assert_not_called()
diff --git a/tests/test_error_reporting.py b/tests/test_error_reporting.py
index e0cc4a9..ccf6a3e 100644
--- a/tests/test_error_reporting.py
+++ b/tests/test_error_reporting.py
@@ -62,6 +62,8 @@
                         }
                     ]
                 }
+
+            For more details about `format` see
         """,
     },
     "description": {
diff --git a/tests/test_json_schema_summary.py b/tests/test_json_schema_summary.py
index a257847..2b39360 100644
--- a/tests/test_json_schema_summary.py
+++ b/tests/test_json_schema_summary.py
@@ -1,4 +1,5 @@
 """Test summary generation from schema examples"""
+
 import json
 from pathlib import Path
 
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index a5e8d01..1f07dec 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -1,8 +1,7 @@
 # The code in this module is mostly borrowed/adapted from PyScaffold and was originally
 # published under the MIT license
 # The original PyScaffold license can be found in 'NOTICE.txt'
-
-import sys
+from importlib.metadata import EntryPoint  # pragma: no cover
 
 import pytest
 
@@ -15,13 +14,6 @@
 )
 
 
-if sys.version_info[:2] >= (3, 8):
-    # TODO: Import directly (no need for conditional) when `python_requires = >= 3.8`
-    from importlib.metadata import EntryPoint  # pragma: no cover
-else:
-    from importlib_metadata import EntryPoint  # pragma: no cover
-
-
 def test_load_from_entry_point__error():
     # This module does not exist, so Python will have some trouble loading it
     # EntryPoint(name, value, group)
diff --git a/tools/cache_urls_for_tests.py b/tools/cache_urls_for_tests.py
new file mode 100644
index 0000000..14c5e78
--- /dev/null
+++ b/tools/cache_urls_for_tests.py
@@ -0,0 +1,50 @@
+import json
+import logging
+import os
+import sys
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+
+HERE = Path(__file__).parent.resolve()
+PROJECT = HERE.parent
+
+sys.path.insert(0, str(PROJECT / "src"))  # <-- Use development version of library
+logging.basicConfig(level=logging.DEBUG)
+
+from validate_pyproject import caching, http  # noqa: E402
+
+SCHEMA_STORE = "https://json.schemastore.org/pyproject.json"
+
+
+def iter_test_urls():
+    with caching.as_file(http.open_url, SCHEMA_STORE) as f:
+        store = json.load(f)
+        for _, tool in store["properties"]["tool"]["properties"].items():
+            if "$ref" in tool and tool["$ref"].startswith(("http://", "https://")):
+                yield tool["$ref"]
+
+    files = PROJECT.glob("**/test_config.json")
+    for file in files:
+        content = json.loads(file.read_text("utf-8"))
+        for _, url in content.get("tools", {}).items():
+            if url.startswith(("http://", "https://")):
+                yield url
+
+
+def download(url):
+    return caching.as_file(http.open_url, url).close()
+    # ^-- side-effect only: write cached file
+
+
+def download_all(cache: str):
+    with ThreadPoolExecutor(max_workers=5) as executor:
+        executor.map(download, set(iter_test_urls()))
+
+
+if __name__ == "__main__":
+    cache = os.getenv("VALIDATE_PYPROJECT_CACHE_REMOTE")
+    if not cache:
+        raise SystemExit("Please define VALIDATE_PYPROJECT_CACHE_REMOTE")
+
+    Path(cache).mkdir(parents=True, exist_ok=True)
+    download_all(cache)
diff --git a/tox.ini b/tox.ini
index 90dc453..d693c37 100644
--- a/tox.ini
+++ b/tox.ini
@@ -15,6 +15,7 @@ setenv =
 passenv =
     HOME
     SETUPTOOLS_*
+    VALIDATE_PYPROJECT_*
 extras =
     all
     testing