diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a7db2ff..1d3a313 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,7 +35,7 @@ repos: - id: isort - repo: https://github.com/psf/black - rev: 22.1.0 + rev: 22.3.0 hooks: - id: black language_version: python3 diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f126e1c..b7aab6e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,12 @@ Changelog ========= +Version 0.7 (development) +========================= + +- **Deprecated** use of ``validate_pyproject.vendoring``. + This module is replaced by ``validate_pyproject.pre_compile``. + Version 0.6.1 ============= diff --git a/README.rst b/README.rst index b1401b2..89b91e5 100644 --- a/README.rst +++ b/README.rst @@ -123,7 +123,7 @@ To do so, don't forget to add it to your `virtual environment`_ or specify it as More details about ``validate-pyproject`` and its Python API can be found in `our docs`_, which includes a description of the `used JSON schemas`_, -instructions for using it in a |vendored way|_ and information about +instructions for using it in a |pre-compiled way|_ and information about extending the validation with your own plugins_. .. _pyscaffold-notes: @@ -142,7 +142,7 @@ For details and usage information on PyScaffold see https://pyscaffold.org/. .. |pipx| replace:: ``pipx`` -.. |vendored way| replace:: *"vendored" way* +.. |pre-compiled way| replace:: *pre-compiled* way .. _contribution guides: https://validate-pyproject.readthedocs.io/en/latest/contributing.html @@ -157,6 +157,6 @@ For details and usage information on PyScaffold see https://pyscaffold.org/. .. _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 -.. _vendored way: https://validate-pyproject.readthedocs.io/en/latest/embedding.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/ diff --git a/docs/embedding.rst b/docs/embedding.rst index af6bf79..d89301a 100644 --- a/docs/embedding.rst +++ b/docs/embedding.rst @@ -16,19 +16,18 @@ This can be done automatically via tools such as :pypi:`vendoring` or :pypi:`vendorize` and many others others, however this technique will copy several files into your project. -If you want to keep the amount of files to a minimum, -``validate-pyproject`` offers a different solution that consists in generating -a validation file (thanks to :pypi:`fastjsonschema`'s ability to compile JSON Schemas -to code) and copying only the strictly necessary Python modules. +However, if you want to keep the amount of files to a minimum, +``validate-pyproject`` offers a different solution that consists in +pre-compiling the JSON Schemas (thanks to :pypi:`fastjsonschema`). After :ref:`installing ` ``validate-pyproject`` this can be done -via CLI as indicated in the command bellow: +via CLI as indicated in the command below: .. code-block:: bash # in you terminal - $ python -m validate_pyproject.vendoring --help - $ python -m validate_pyproject.vendoring -O dir/for/genereated_files + $ python -m validate_pyproject.pre_compile --help + $ python -m validate_pyproject.pre_compile -O dir/for/genereated_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 diff --git a/docs/faq.rst b/docs/faq.rst index 1f62393..0a2d474 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -36,7 +36,7 @@ community, none of the ones the original author of this package investigated directly without requiring installation. :pypi:`fastjsonschema` has no dependency and can generate validation code directly, -which bypass the need for copying most of the files when :doc:`"vendoring" +which bypass the need for copying most of the files when :doc:`"embedding" `. diff --git a/src/validate_pyproject/vendoring/NOTICE.template b/src/validate_pyproject/pre_compile/NOTICE.template similarity index 93% rename from src/validate_pyproject/vendoring/NOTICE.template rename to src/validate_pyproject/pre_compile/NOTICE.template index 3db79d4..567a95a 100644 --- a/src/validate_pyproject/vendoring/NOTICE.template +++ b/src/validate_pyproject/pre_compile/NOTICE.template @@ -25,7 +25,7 @@ by the same projects: - `{main_file}` - `fastjsonschema_validations.py` -The relevant copyright notes and licenses are included bellow. +The relevant copyright notes and licenses are included below. *** diff --git a/src/validate_pyproject/pre_compile/__init__.py b/src/validate_pyproject/pre_compile/__init__.py new file mode 100644 index 0000000..0722ca3 --- /dev/null +++ b/src/validate_pyproject/pre_compile/__init__.py @@ -0,0 +1,144 @@ +import logging +import os +import sys +from pathlib import Path +from types import MappingProxyType +from typing import TYPE_CHECKING, Dict, Mapping, Optional, Sequence, Union + +from .. import api, dist_name, types +from .._vendor import fastjsonschema as FJS + +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 PluginWrapper # noqa + + +_logger = logging.getLogger(__name__) + + +TEXT_REPLACEMENTS = MappingProxyType( + { + "from fastjsonschema import": "from .fastjsonschema_exceptions import", + "from ._vendor.fastjsonschema import": "from .fastjsonschema_exceptions import", + } +) + + +def pre_compile( + output_dir: Union[str, os.PathLike] = ".", + main_file: str = "__init__.py", + original_cmd: str = "", + plugins: Union[api.AllPlugins, Sequence["PluginWrapper"]] = api.ALL_PLUGINS, + text_replacements: Mapping[str, str] = TEXT_REPLACEMENTS, +) -> Path: + """Populate the given ``output_dir`` with all files necessary to perform + the validation. + The validation can be performed by calling the ``validate`` function inside the + the file named with the ``main_file`` value. + ``text_replacements`` can be used to + """ + out = Path(output_dir) + out.mkdir(parents=True, exist_ok=True) + replacements = {**TEXT_REPLACEMENTS, **text_replacements} + + validator = api.Validator(plugins) + header = "\n".join(NOCHECK_HEADERS) + code = replace_text(validator.generated_code, replacements) + (out / "fastjsonschema_validations.py").write_text(header + code, "UTF-8") + + copy_fastjsonschema_exceptions(out, replacements) + copy_module("extra_validations", out, replacements) + copy_module("formats", out, replacements) + copy_module("error_reporting", out, replacements) + write_main(out / main_file, validator.schema, replacements) + write_notice(out, main_file, original_cmd, replacements) + (out / "__init__.py").touch() + + return out + + +def replace_text(text: str, replacements: Dict[str, str]) -> str: + for orig, subst in replacements.items(): + text = text.replace(orig, subst) + return text + + +def copy_fastjsonschema_exceptions( + output_dir: Path, replacements: Dict[str, str] +) -> Path: + file = output_dir / "fastjsonschema_exceptions.py" + code = replace_text(api.read_text(FJS.__name__, "exceptions.py"), replacements) + file.write_text(code, "UTF-8") + return file + + +def copy_module(name: str, output_dir: Path, replacements: Dict[str, str]) -> Path: + file = output_dir / f"{name}.py" + code = api.read_text(api.__package__, f"{name}.py") + code = replace_text(code, replacements) + file.write_text(code, "UTF-8") + return file + + +def write_main( + file_path: Path, schema: types.Schema, replacements: Dict[str, str] +) -> Path: + code = api.read_text(__name__, "main_file.template") + code = replace_text(code, replacements) + file_path.write_text(code, "UTF-8") + return file_path + + +def write_notice( + out: Path, main_file: str, cmd: str, replacements: Dict[str, str] +) -> Path: + if cmd: + opening = api.read_text(__name__, "cli-notice.template") + opening = opening.format(command=cmd) + else: + opening = api.read_text(__name__, "api-notice.template") + notice = api.read_text(__name__, "NOTICE.template") + notice = notice.format(notice=opening, main_file=main_file, **load_licenses()) + notice = replace_text(notice, replacements) + + file = out / "NOTICE" + file.write_text(notice, "UTF-8") + return file + + +def load_licenses() -> Dict[str, str]: + return { + "fastjsonschema_license": api.read_text(FJS, "LICENSE"), + "validate_pyproject_license": _find_and_load_licence(_M.files(dist_name)), + } + + +NOCHECK_HEADERS = ( + "# noqa", + "# type: ignore", + "# flake8: noqa", + "# pylint: skip-file", + "# mypy: ignore-errors", + "# yapf: disable", + "# pylama:skip=1", + "\n\n# *** PLEASE DO NOT MODIFY DIRECTLY: Automatically generated code *** \n\n\n", +) + + +def _find_and_load_licence(files: Optional[Sequence[_M.PackagePath]]) -> str: + if files is None: # pragma: no cover + raise ImportError("Could not find LICENSE for package") + try: + return next(f for f in files if f.stem.upper() == "LICENSE").read_text("UTF-8") + except FileNotFoundError: # pragma: no cover + msg = ( + "Please make sure to install `validate-pyproject` and `fastjsonschema` " + "in a NON-EDITABLE way. This is necessary due to the issue #112 in " + "python/importlib_metadata." + ) + _logger.warning(msg) + raise diff --git a/src/validate_pyproject/pre_compile/__main__.py b/src/validate_pyproject/pre_compile/__main__.py new file mode 100644 index 0000000..a4912bc --- /dev/null +++ b/src/validate_pyproject/pre_compile/__main__.py @@ -0,0 +1,4 @@ +from . import cli + +if __name__ == "__main__": + cli.main() diff --git a/src/validate_pyproject/vendoring/api-notice.template b/src/validate_pyproject/pre_compile/api-notice.template similarity index 100% rename from src/validate_pyproject/vendoring/api-notice.template rename to src/validate_pyproject/pre_compile/api-notice.template diff --git a/src/validate_pyproject/vendoring/cli-notice.template b/src/validate_pyproject/pre_compile/cli-notice.template similarity index 100% rename from src/validate_pyproject/vendoring/cli-notice.template rename to src/validate_pyproject/pre_compile/cli-notice.template diff --git a/src/validate_pyproject/pre_compile/cli.py b/src/validate_pyproject/pre_compile/cli.py new file mode 100644 index 0000000..cb11769 --- /dev/null +++ b/src/validate_pyproject/pre_compile/cli.py @@ -0,0 +1,90 @@ +import json +import logging +import sys +from pathlib import Path +from types import MappingProxyType +from typing import Any, Dict, List, Mapping, NamedTuple, Sequence + +from .. import cli +from ..plugins import PluginWrapper +from ..plugins import list_from_entry_points as list_plugins_from_entry_points +from . import pre_compile + +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) + + +_logger = logging.getLogger(__package__) + +META: Dict[str, dict] = { + "output_dir": dict( + flags=("-O", "--output-dir"), + default=".", + type=Path, + help="Path to the directory where the files for embedding will be generated " + "(default: current working directory)", + ), + "main_file": dict( + flags=("-M", "--main-file"), + default="__init__.py", + help="Name of the file that will contain the main `validate` function" + "(default: `%(default)s`)", + ), + "replacements": dict( + flags=("-R", "--replacements"), + default="{}", + type=lambda x: ensure_dict("replacements", json.loads(x)), + help="JSON string (don't forget to quote) representing a map between strings " + "that should be replaced in the generated files and their replacement, " + "for example: \n" + '-R \'{"from packaging import": "from .._vendor.packaging import"}\'', + ), +} + + +def ensure_dict(name: str, value: Any) -> dict: + if not isinstance(value, dict): + msg = f"`{value.__class__.__name__}` given (value = {value!r})." + raise ValueError(f"`{name}` should be a dict. {msg}") + return value + + +class CliParams(NamedTuple): + plugins: List[PluginWrapper] + output_dir: Path = Path(".") + main_file: str = "__init__.py" + replacements: Mapping[str, str] = MappingProxyType({}) + loglevel: int = logging.WARNING + + +def parser_spec(plugins: Sequence[PluginWrapper]) -> Dict[str, dict]: + common = ("version", "enable", "disable", "verbose", "very_verbose") + cli_spec = cli.__meta__(plugins) + meta = {k: v.copy() for k, v in META.items()} + meta.update({k: cli_spec[k].copy() for k in common}) + return meta + + +def run(args: Sequence[str] = ()): + args = args if args else sys.argv[1:] + cmd = f"python -m {__package__} " + arg_join(args) + plugins = list_plugins_from_entry_points() + desc = 'Generate files for "pre-compiling" `validate-pyproject`' + prms = cli.parse_args(args, plugins, desc, parser_spec, CliParams) + cli.setup_logging(prms.loglevel) + pre_compile(prms.output_dir, prms.main_file, cmd, prms.plugins, prms.replacements) + return 0 + + +main = cli.exceptisons2exit()(run) + + +if __name__ == "__main__": + main() diff --git a/src/validate_pyproject/vendoring/main_file.template b/src/validate_pyproject/pre_compile/main_file.template similarity index 100% rename from src/validate_pyproject/vendoring/main_file.template rename to src/validate_pyproject/pre_compile/main_file.template diff --git a/src/validate_pyproject/vendoring/__init__.py b/src/validate_pyproject/vendoring/__init__.py index 556aa53..2059884 100644 --- a/src/validate_pyproject/vendoring/__init__.py +++ b/src/validate_pyproject/vendoring/__init__.py @@ -1,144 +1,25 @@ -import logging -import os -import sys -from pathlib import Path -from types import MappingProxyType -from typing import TYPE_CHECKING, Dict, Mapping, Optional, Sequence, Union +import warnings +from functools import wraps +from inspect import cleandoc -from .. import api, dist_name, types -from .._vendor import fastjsonschema as FJS +from ..pre_compile import pre_compile -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 PluginWrapper # noqa +def _deprecated(orig, repl): + msg = f""" + `{orig.__module__}:{orig.__name__}` is deprecated and will be removed in future + versions of `validate-pyproject`. - -_logger = logging.getLogger(__name__) - - -TEXT_REPLACEMENTS = MappingProxyType( - { - "from fastjsonschema import": "from .fastjsonschema_exceptions import", - "from ._vendor.fastjsonschema import": "from .fastjsonschema_exceptions import", - } -) - - -def vendorify( - output_dir: Union[str, os.PathLike] = ".", - main_file: str = "__init__.py", - original_cmd: str = "", - plugins: Union[api.AllPlugins, Sequence["PluginWrapper"]] = api.ALL_PLUGINS, - text_replacements: Mapping[str, str] = TEXT_REPLACEMENTS, -) -> Path: - """Populate the given ``output_dir`` with all files necessary to perform - the validation. - The validation can be performed by calling the ``validate`` function inside the - the file named with the ``main_file`` value. - ``text_replacements`` can be used to + Please use `{repl.__module__}:{repl.__name__}` instead. """ - out = Path(output_dir) - out.mkdir(parents=True, exist_ok=True) - replacements = {**TEXT_REPLACEMENTS, **text_replacements} - - validator = api.Validator(plugins) - header = "\n".join(NOCHECK_HEADERS) - code = replace_text(validator.generated_code, replacements) - (out / "fastjsonschema_validations.py").write_text(header + code, "UTF-8") - - copy_fastjsonschema_exceptions(out, replacements) - copy_module("extra_validations", out, replacements) - copy_module("formats", out, replacements) - copy_module("error_reporting", out, replacements) - write_main(out / main_file, validator.schema, replacements) - write_notice(out, main_file, original_cmd, replacements) - (out / "__init__.py").touch() - - return out - - -def replace_text(text: str, replacements: Dict[str, str]) -> str: - for orig, subst in replacements.items(): - text = text.replace(orig, subst) - return text - - -def copy_fastjsonschema_exceptions( - output_dir: Path, replacements: Dict[str, str] -) -> Path: - file = output_dir / "fastjsonschema_exceptions.py" - code = replace_text(api.read_text(FJS.__name__, "exceptions.py"), replacements) - file.write_text(code, "UTF-8") - return file - - -def copy_module(name: str, output_dir: Path, replacements: Dict[str, str]) -> Path: - file = output_dir / f"{name}.py" - code = api.read_text(api.__package__, f"{name}.py") - code = replace_text(code, replacements) - file.write_text(code, "UTF-8") - return file - - -def write_main( - file_path: Path, schema: types.Schema, replacements: Dict[str, str] -) -> Path: - code = api.read_text(__name__, "main_file.template") - code = replace_text(code, replacements) - file_path.write_text(code, "UTF-8") - return file_path - - -def write_notice( - out: Path, main_file: str, cmd: str, replacements: Dict[str, str] -) -> Path: - if cmd: - opening = api.read_text(__name__, "cli-notice.template") - opening = opening.format(command=cmd) - else: - opening = api.read_text(__name__, "api-notice.template") - notice = api.read_text(__name__, "NOTICE.template") - notice = notice.format(notice=opening, main_file=main_file, **load_licenses()) - notice = replace_text(notice, replacements) - - file = out / "NOTICE" - file.write_text(notice, "UTF-8") - return file - - -def load_licenses() -> Dict[str, str]: - return { - "fastjsonschema_license": api.read_text(FJS, "LICENSE"), - "validate_pyproject_license": _find_and_load_licence(_M.files(dist_name)), - } + @wraps(orig) + def _wrapper(*args, **kwargs): + warnings.warn(cleandoc(msg), category=DeprecationWarning) + return repl(*args, **kwargs) -NOCHECK_HEADERS = ( - "# noqa", - "# type: ignore", - "# flake8: noqa", - "# pylint: skip-file", - "# mypy: ignore-errors", - "# yapf: disable", - "# pylama:skip=1", - "\n\n# *** PLEASE DO NOT MODIFY DIRECTLY: Automatically generated code *** \n\n\n", -) + return _wrapper -def _find_and_load_licence(files: Optional[Sequence[_M.PackagePath]]) -> str: - if files is None: # pragma: no cover - raise ImportError("Could not find LICENSE for package") - try: - return next(f for f in files if f.stem.upper() == "LICENSE").read_text("UTF-8") - except FileNotFoundError: # pragma: no cover - msg = ( - "Please make sure to install `validate-pyproject` and `fastjsonschema` " - "in a NON-EDITABLE way. This is necessary due to the issue #112 in " - "python/importlib_metadata." - ) - _logger.warning(msg) - raise +def vendorify(*args, **kwargs): + return _deprecated(vendorify, pre_compile)(*args, **kwargs) diff --git a/src/validate_pyproject/vendoring/cli.py b/src/validate_pyproject/vendoring/cli.py index a10ec86..2c32734 100644 --- a/src/validate_pyproject/vendoring/cli.py +++ b/src/validate_pyproject/vendoring/cli.py @@ -1,90 +1,10 @@ -import json -import logging -import sys -from pathlib import Path -from types import MappingProxyType -from typing import Any, Dict, List, Mapping, NamedTuple, Sequence +from ..pre_compile import cli +from . import _deprecated -from .. import cli -from ..plugins import PluginWrapper -from ..plugins import list_from_entry_points as list_plugins_from_entry_points -from . import vendorify -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 run(*args, **kwargs): + return _deprecated(run, cli.run)(*args, **kwargs) - def arg_join(args: Sequence[str]) -> str: - return " ".join(quote(x) for x in args) - -_logger = logging.getLogger(__package__) - -META: Dict[str, dict] = { - "output_dir": dict( - flags=("-O", "--output-dir"), - default=".", - type=Path, - help="Path to the directory where the files for embedding will be generated " - "(default: current working directory)", - ), - "main_file": dict( - flags=("-M", "--main-file"), - default="__init__.py", - help="Name of the file that will contain the main `validate` function" - "(default: `%(default)s`)", - ), - "replacements": dict( - flags=("-R", "--replacements"), - default="{}", - type=lambda x: ensure_dict("replacements", json.loads(x)), - help="JSON string (don't forget to quote) representing a map between strings " - "that should be replaced in the generated files and their replacement, " - "for example: \n" - '-R \'{"from packaging import": "from .._vendor.packaging import"}\'', - ), -} - - -def ensure_dict(name: str, value: Any) -> dict: - if not isinstance(value, dict): - msg = f"`{value.__class__.__name__}` given (value = {value!r})." - raise ValueError(f"`{name}` should be a dict. {msg}") - return value - - -class CliParams(NamedTuple): - plugins: List[PluginWrapper] - output_dir: Path = Path(".") - main_file: str = "__init__.py" - replacements: Mapping[str, str] = MappingProxyType({}) - loglevel: int = logging.WARNING - - -def parser_spec(plugins: Sequence[PluginWrapper]) -> Dict[str, dict]: - common = ("version", "enable", "disable", "verbose", "very_verbose") - cli_spec = cli.__meta__(plugins) - meta = {k: v.copy() for k, v in META.items()} - meta.update({k: cli_spec[k].copy() for k in common}) - return meta - - -def run(args: Sequence[str] = ()): - args = args if args else sys.argv[1:] - cmd = f"python -m {__package__} " + arg_join(args) - plugins = list_plugins_from_entry_points() - desc = 'Generate files for "vendoring" `validate-pyproject`' - prms = cli.parse_args(args, plugins, desc, parser_spec, CliParams) - cli.setup_logging(prms.loglevel) - vendorify(prms.output_dir, prms.main_file, cmd, prms.plugins, prms.replacements) - return 0 - - -main = cli.exceptisons2exit()(run) - - -if __name__ == "__main__": - main() +def main(*args, **kwargs): + return _deprecated(run, cli.main)(*args, **kwargs) diff --git a/tests/vendoring/test_cli.py b/tests/pre_compile/test_cli.py similarity index 88% rename from tests/vendoring/test_cli.py rename to tests/pre_compile/test_cli.py index 45336b8..a5a1970 100644 --- a/tests/vendoring/test_cli.py +++ b/tests/pre_compile/test_cli.py @@ -2,7 +2,7 @@ import pytest -from validate_pyproject.vendoring import cli +from validate_pyproject.pre_compile import cli def test_invalid_replacements(tmp_path): diff --git a/tests/test_pre_compile.py b/tests/test_pre_compile.py new file mode 100644 index 0000000..8ae4ad9 --- /dev/null +++ b/tests/test_pre_compile.py @@ -0,0 +1,172 @@ +import builtins +import re +import shutil +import subprocess +import sys +from inspect import cleandoc +from pathlib import Path + +import pytest +import tomli +from validate_pyproject._vendor.fastjsonschema import JsonSchemaValueException + +from validate_pyproject.pre_compile import cli, pre_compile + +from .helpers import EXAMPLES, INVALID, error_file, examples, invalid_examples + +MAIN_FILE = "hello_world.py" # Let's use something different that `__init__.py` + + +def _pre_compile_checks(path: Path): + assert (path / "__init__.py").exists() + assert (path / "__init__.py").read_text() == "" + assert (path / MAIN_FILE).exists() + files = [ + (MAIN_FILE, "def validate("), + (MAIN_FILE, "from .error_reporting import detailed_errors, ValidationError"), + ("error_reporting.py", "def detailed_errors("), + ("fastjsonschema_exceptions.py", "class JsonSchemaValueException"), + ("fastjsonschema_validations.py", "def validate("), + ("extra_validations.py", "def validate"), + ("formats.py", "def pep508("), + ("NOTICE", "The relevant copyright notes and licenses are included below"), + ] + for file, content in files: + assert (path / file).exists() + assert content in (path / file).read_text() + + # Make sure standard replacements work + for file in ("fastjsonschema_validations.py", "error_reporting.py"): + file_contents = (path / file).read_text() + assert "from fastjsonschema" not in file_contents + assert "from ._vendor.fastjsonschema" not in file_contents + assert "from validate_pyproject._vendor.fastjsonschema" not in file_contents + assert "from .fastjsonschema_exceptions" in file_contents + + # Make sure the pre-compiled lib works + script = f""" + from {path.stem} import {Path(MAIN_FILE).stem} as mod + + assert issubclass(mod.ValidationError, mod.JsonSchemaValueException) + + example = {{ + "project": {{"name": "proj", "version": 42}} + }} + assert mod.validate(example) == example + """ + cmd = [sys.executable, "-c", cleandoc(script)] + error = r".project\.version. must be string" + with pytest.raises(subprocess.CalledProcessError) as exc_info: + subprocess.check_output(cmd, cwd=path.parent, stderr=subprocess.STDOUT) + + assert re.search(error, str(exc_info.value.output, "utf-8")) + + +def test_pre_compile_api(tmp_path): + path = Path(tmp_path) + pre_compile(path, MAIN_FILE) + _pre_compile_checks(path) + # Let's make sure it also works for __init__ + shutil.rmtree(str(path), ignore_errors=True) + replacements = {"from fastjsonschema import": "from _vend.fastjsonschema import"} + pre_compile(path, text_replacements=replacements) + assert "def validate(" in (path / "__init__.py").read_text() + assert not (path / MAIN_FILE).exists() + file_contents = (path / "fastjsonschema_validations.py").read_text() + assert "from _vend" in file_contents + assert "from fastjsonschema" not in file_contents + + +def test_vendoring_cli(tmp_path): + path = Path(tmp_path) + cli.run(["-O", str(path), "-M", MAIN_FILE]) + _pre_compile_checks(Path(path)) + # Let's also try to test JSON replacements + shutil.rmtree(str(path), ignore_errors=True) + replacements = '{"from fastjsonschema import": "from _vend.fastjsonschema import"}' + cli.run(["-O", str(path), "-R", replacements]) + file_contents = (path / "fastjsonschema_validations.py").read_text() + assert "from _vend" in file_contents + assert "from fastjsonschema" not in file_contents + + +# ---- Examples ---- + + +PRE_COMPILED_NAME = "_validation" + + +def api_pre_compile(tmp_path) -> Path: + return pre_compile(Path(tmp_path / PRE_COMPILED_NAME)) + + +def cli_pre_compile(tmp_path) -> Path: + path = Path(tmp_path / PRE_COMPILED_NAME) + cli.run(["-O", str(path)]) + return path + + +_PRE_COMPILED = (api_pre_compile, cli_pre_compile) + + +@pytest.fixture +def pre_compiled_validate(monkeypatch): + def _validate(vendored_path, toml_equivalent): + assert PRE_COMPILED_NAME not in sys.modules + with monkeypatch.context() as m: + # Make sure original imports are not used + _disable_import(m, "fastjsonschema") + _disable_import(m, "validate_pyproject") + # Make newly generated package available for importing + m.syspath_prepend(str(vendored_path.parent)) + mod = __import__(PRE_COMPILED_NAME) + print(list(vendored_path.glob("*"))) + print(mod, "\n\n", dir(mod)) + try: + return mod.validate(toml_equivalent) + except mod.JsonSchemaValueException as ex: + # Let's translate the exceptions so we have identical classes + new_ex = JsonSchemaValueException( + ex.message, ex.value, ex.name, ex.definition, ex.rule + ) + raise new_ex from ex + finally: + del sys.modules[PRE_COMPILED_NAME] + + return _validate + + +@pytest.mark.parametrize("example", examples()) +@pytest.mark.parametrize("pre_compiled", _PRE_COMPILED) +def test_examples_api(tmp_path, pre_compiled_validate, example, pre_compiled): + toml_equivalent = tomli.loads((EXAMPLES / example).read_text()) + pre_compiled_path = pre_compiled(Path(tmp_path)) + return pre_compiled_validate(pre_compiled_path, toml_equivalent) is not None + + +@pytest.mark.parametrize("example", invalid_examples()) +@pytest.mark.parametrize("pre_compiled", _PRE_COMPILED) +def test_invalid_examples_api(tmp_path, pre_compiled_validate, example, pre_compiled): + example_file = INVALID / example + expected_error = error_file(example_file).read_text("utf-8") + toml_equivalent = tomli.loads(example_file.read_text()) + pre_compiled_path = pre_compiled(Path(tmp_path)) + with pytest.raises(JsonSchemaValueException) as exc_info: + pre_compiled_validate(pre_compiled_path, toml_equivalent) + exception_message = str(exc_info.value) + print("rule", "=", exc_info.value.rule) + print("rule_definition", "=", exc_info.value.rule_definition) + print("definition", "=", exc_info.value.definition) + for error in expected_error.splitlines(): + assert error in exception_message + + +def _disable_import(monkeypatch, name): + orig = builtins.__import__ + + def _import(import_name, *args, **kwargs): + if import_name == name or import_name.startswith(f"{name}."): + raise ImportError(name) + return orig(import_name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _import) diff --git a/tests/test_vendoring.py b/tests/test_vendoring.py index b120d98..ddca5e6 100644 --- a/tests/test_vendoring.py +++ b/tests/test_vendoring.py @@ -1,155 +1,13 @@ -import re -import shutil -import subprocess -import sys -from inspect import cleandoc -from itertools import product -from pathlib import Path - import pytest -import tomli -from validate_pyproject._vendor.fastjsonschema import JsonSchemaValueException from validate_pyproject.vendoring import cli, vendorify -from .helpers import EXAMPLES, INVALID, error_file, examples, invalid_examples - -MAIN_FILE = "hello_world.py" # Let's use something different that `__init__.py` - - -def _vendoring_checks(path: Path): - assert (path / "__init__.py").exists() - assert (path / "__init__.py").read_text() == "" - assert (path / MAIN_FILE).exists() - files = [ - (MAIN_FILE, "def validate("), - (MAIN_FILE, "from .error_reporting import detailed_errors, ValidationError"), - ("error_reporting.py", "def detailed_errors("), - ("fastjsonschema_exceptions.py", "class JsonSchemaValueException"), - ("fastjsonschema_validations.py", "def validate("), - ("extra_validations.py", "def validate"), - ("formats.py", "def pep508("), - ("NOTICE", "The relevant copyright notes and licenses are included bellow"), - ] - for file, content in files: - assert (path / file).exists() - assert content in (path / file).read_text() - - # Make sure standard replacements work - for file in ("fastjsonschema_validations.py", "error_reporting.py"): - file_contents = (path / file).read_text() - assert "from fastjsonschema" not in file_contents - assert "from ._vendor.fastjsonschema" not in file_contents - assert "from validate_pyproject._vendor.fastjsonschema" not in file_contents - assert "from .fastjsonschema_exceptions" in file_contents - - # Make sure the vendored lib works - script = f""" - from {path.stem} import {Path(MAIN_FILE).stem} as mod - - assert issubclass(mod.ValidationError, mod.JsonSchemaValueException) - - example = {{ - "project": {{"name": "proj", "version": 42}} - }} - assert mod.validate(example) == example - """ - cmd = [sys.executable, "-c", cleandoc(script)] - error = r".project\.version. must be string" - with pytest.raises(subprocess.CalledProcessError) as exc_info: - subprocess.check_output(cmd, cwd=path.parent, stderr=subprocess.STDOUT) - - assert re.search(error, str(exc_info.value.output, "utf-8")) - - -def test_vendoring_api(tmp_path): - path = Path(tmp_path) - vendorify(path, MAIN_FILE) - _vendoring_checks(path) - # Let's make sure it also works for __init__ - shutil.rmtree(str(path), ignore_errors=True) - replacements = {"from fastjsonschema import": "from _vend.fastjsonschema import"} - vendorify(path, text_replacements=replacements) - assert "def validate(" in (path / "__init__.py").read_text() - assert not (path / MAIN_FILE).exists() - file_contents = (path / "fastjsonschema_validations.py").read_text() - assert "from _vend" in file_contents - assert "from fastjsonschema" not in file_contents - - -def test_vendoring_cli(tmp_path): - path = Path(tmp_path) - cli.run(["-O", str(path), "-M", MAIN_FILE]) - _vendoring_checks(Path(path)) - # Let's also try to test JSON replacements - shutil.rmtree(str(path), ignore_errors=True) - replacements = '{"from fastjsonschema import": "from _vend.fastjsonschema import"}' - cli.run(["-O", str(path), "-R", replacements]) - file_contents = (path / "fastjsonschema_validations.py").read_text() - assert "from _vend" in file_contents - assert "from fastjsonschema" not in file_contents - - -# ---- Examples ---- - - -VENDORED_NAME = "_vendored_validation" - - -def api_vendored(tmp_path) -> Path: - return vendorify(Path(tmp_path / VENDORED_NAME)) - - -def cli_vendored(tmp_path) -> Path: - path = Path(tmp_path / VENDORED_NAME) - cli.run(["-O", str(path)]) - return path - - -_VENDORING = (api_vendored, cli_vendored) - - -@pytest.fixture -def vendored_validate(monkeypatch): - def _validate(vendored_path, toml_equivalent): - assert VENDORED_NAME not in sys.modules - with monkeypatch.context() as m: - m.syspath_prepend(str(vendored_path.parent)) - mod = __import__(VENDORED_NAME) - print(list(vendored_path.glob("*"))) - print(mod, "\n\n", dir(mod)) - try: - return mod.validate(toml_equivalent) - except mod.JsonSchemaValueException as ex: - # Let's translate the exceptions so we have identical classes - new_ex = JsonSchemaValueException( - ex.message, ex.value, ex.name, ex.definition, ex.rule - ) - raise new_ex from ex - finally: - del sys.modules[VENDORED_NAME] - - return _validate - -@pytest.mark.parametrize("example, vendored", product(examples(), _VENDORING)) -def test_examples_api(tmp_path, vendored_validate, example, vendored): - toml_equivalent = tomli.loads((EXAMPLES / example).read_text()) - vendored_path = vendored(Path(tmp_path)) - return vendored_validate(vendored_path, toml_equivalent) is not None +def test_api(tmp_path): + with pytest.warns(DeprecationWarning, match="will be removed"): + vendorify(tmp_path) -@pytest.mark.parametrize("example, vendored", product(invalid_examples(), _VENDORING)) -def test_invalid_examples_api(tmp_path, vendored_validate, example, vendored): - example_file = INVALID / example - expected_error = error_file(example_file).read_text("utf-8") - toml_equivalent = tomli.loads(example_file.read_text()) - vendored_path = vendored(Path(tmp_path)) - with pytest.raises(JsonSchemaValueException) as exc_info: - vendored_validate(vendored_path, toml_equivalent) - exception_message = str(exc_info.value) - print("rule", "=", exc_info.value.rule) - print("rule_definition", "=", exc_info.value.rule_definition) - print("definition", "=", exc_info.value.definition) - for error in expected_error.splitlines(): - assert error in exception_message +def test_cli(tmp_path): + with pytest.warns(DeprecationWarning, match="will be removed"): + cli.run(["-O", str(tmp_path)])