Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace vendoring with pre_compile #33

Merged
merged 8 commits into from
Mar 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
=============

Expand Down
6 changes: 3 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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/
13 changes: 6 additions & 7 deletions docs/embedding.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <installation>` ``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
Expand Down
2 changes: 1 addition & 1 deletion docs/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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"
<embedding>`.


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.


***
Expand Down
144 changes: 144 additions & 0 deletions src/validate_pyproject/pre_compile/__init__.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions src/validate_pyproject/pre_compile/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from . import cli

if __name__ == "__main__":
cli.main()
90 changes: 90 additions & 0 deletions src/validate_pyproject/pre_compile/cli.py
Original file line number Diff line number Diff line change
@@ -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()
Loading