Skip to content

Commit

Permalink
Add validate-pyproject as a vendored dependency
Browse files Browse the repository at this point in the history
In order to minimise dependencies, `validate-pyproject` has the ability
to "dump" only the code necessary to run the validations to a given
directory. This special strategy is used instead of the default
`pip install -t`.

The idea of using JSONSchema for validation was suggested in #2671,
and the rationale for that approach is further discussed in
https://github.com/abravalheri/validate-pyproject/blob/main/docs/faq.rst

Using a library such as `validate-pyproject` has the advantage of
incentive sing reuse and collaboration with other projects.

Currently `validate-pyproject` ships a JSONSchema for the proposed
use of `pyproject.toml` as means of configuration for setuptools.
In the future, if there is interest, setuptools could also ship its own
schema and just use the shared infrastructure of `validate-pyproject`
(by advertising the schemas via entry-points).
  • Loading branch information
abravalheri committed Mar 5, 2022
1 parent 099ac60 commit 771488d
Show file tree
Hide file tree
Showing 9 changed files with 1,802 additions and 0 deletions.
439 changes: 439 additions & 0 deletions setuptools/_vendor/_validate_pyproject/NOTICE

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions setuptools/_vendor/_validate_pyproject/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from functools import reduce
from typing import Any, Callable, Dict

from . import formats
from .extra_validations import EXTRA_VALIDATIONS
from .fastjsonschema_exceptions import JsonSchemaException, JsonSchemaValueException
from .fastjsonschema_validations import validate as _validate

__all__ = [
"validate",
"FORMAT_FUNCTIONS",
"EXTRA_VALIDATIONS",
"JsonSchemaException",
"JsonSchemaValueException",
]


FORMAT_FUNCTIONS: Dict[str, Callable[[str], bool]] = {
fn.__name__.replace("_", "-"): fn
for fn in formats.__dict__.values()
if callable(fn) and not fn.__name__.startswith("_")
}


def validate(data: Any) -> bool:
"""Validate the given ``data`` object using JSON Schema
This function raises ``JsonSchemaValueException`` if ``data`` is invalid.
"""
_validate(data, custom_formats=FORMAT_FUNCTIONS)
reduce(lambda acc, fn: fn(acc), EXTRA_VALIDATIONS, data)
return True
36 changes: 36 additions & 0 deletions setuptools/_vendor/_validate_pyproject/extra_validations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""The purpose of this module is implement PEP 621 validations that are
difficult to express as a JSON Schema (or that are not supported by the current
JSON Schema library).
"""

from typing import Mapping, TypeVar

from .fastjsonschema_exceptions import JsonSchemaValueException

T = TypeVar("T", bound=Mapping)


class RedefiningStaticFieldAsDynamic(JsonSchemaValueException):
"""According to PEP 621:
Build back-ends MUST raise an error if the metadata specifies a field
statically as well as being listed in dynamic.
"""


def validate_project_dynamic(pyproject: T) -> T:
project_table = pyproject.get("project", {})
dynamic = project_table.get("dynamic", [])

for field in dynamic:
if field in project_table:
msg = f"You cannot provided a value for `project.{field}` and "
msg += "list it under `project.dynamic` at the same time"
name = f"data.project.{field}"
value = {field: project_table[field], "...": " # ...", "dynamic": dynamic}
raise RedefiningStaticFieldAsDynamic(msg, value, name, rule="PEP 621")

return pyproject


EXTRA_VALIDATIONS = (validate_project_dynamic,)
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import re


SPLIT_RE = re.compile(r'[\.\[\]]+')


class JsonSchemaException(ValueError):
"""
Base exception of ``fastjsonschema`` library.
"""


class JsonSchemaValueException(JsonSchemaException):
"""
Exception raised by validation function. Available properties:
* ``message`` containing human-readable information what is wrong (e.g. ``data.property[index] must be smaller than or equal to 42``),
* invalid ``value`` (e.g. ``60``),
* ``name`` of a path in the data structure (e.g. ``data.propery[index]``),
* ``path`` as an array in the data structure (e.g. ``['data', 'propery', 'index']``),
* the whole ``definition`` which the ``value`` has to fulfil (e.g. ``{'type': 'number', 'maximum': 42}``),
* ``rule`` which the ``value`` is breaking (e.g. ``maximum``)
* and ``rule_definition`` (e.g. ``42``).
.. versionchanged:: 2.14.0
Added all extra properties.
"""

def __init__(self, message, value=None, name=None, definition=None, rule=None):
super().__init__(message)
self.message = message
self.value = value
self.name = name
self.definition = definition
self.rule = rule

@property
def path(self):
return [item for item in SPLIT_RE.split(self.name) if item != '']

@property
def rule_definition(self):
if not self.rule or not self.definition:
return None
return self.definition.get(self.rule)


class JsonSchemaDefinitionException(JsonSchemaException):
"""
Exception raised by generator of validation function.
"""
1,002 changes: 1,002 additions & 0 deletions setuptools/_vendor/_validate_pyproject/fastjsonschema_validations.py

Large diffs are not rendered by default.

202 changes: 202 additions & 0 deletions setuptools/_vendor/_validate_pyproject/formats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import logging
import re
import string
from itertools import chain
from urllib.parse import urlparse

_logger = logging.getLogger(__name__)

# -------------------------------------------------------------------------------------
# PEP 440

VERSION_PATTERN = r"""
v?
(?:
(?:(?P<epoch>[0-9]+)!)? # epoch
(?P<release>[0-9]+(?:\.[0-9]+)*) # release segment
(?P<pre> # pre-release
[-_\.]?
(?P<pre_l>(a|b|c|rc|alpha|beta|pre|preview))
[-_\.]?
(?P<pre_n>[0-9]+)?
)?
(?P<post> # post release
(?:-(?P<post_n1>[0-9]+))
|
(?:
[-_\.]?
(?P<post_l>post|rev|r)
[-_\.]?
(?P<post_n2>[0-9]+)?
)
)?
(?P<dev> # dev release
[-_\.]?
(?P<dev_l>dev)
[-_\.]?
(?P<dev_n>[0-9]+)?
)?
)
(?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))? # local version
"""

VERSION_REGEX = re.compile(r"^\s*" + VERSION_PATTERN + r"\s*$", re.X | re.I)


def pep440(version: str) -> bool:
return VERSION_REGEX.match(version) is not None


# -------------------------------------------------------------------------------------
# PEP 508

PEP508_IDENTIFIER_PATTERN = r"([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])"
PEP508_IDENTIFIER_REGEX = re.compile(f"^{PEP508_IDENTIFIER_PATTERN}$", re.I)


def pep508_identifier(name: str) -> bool:
return PEP508_IDENTIFIER_REGEX.match(name) is not None


try:
try:
from packaging import requirements as _req
except ImportError: # pragma: no cover
# let's try setuptools vendored version
from setuptools._vendor.packaging import requirements as _req # type: ignore

def pep508(value: str) -> bool:
try:
_req.Requirement(value)
return True
except _req.InvalidRequirement:
return False


except ImportError: # pragma: no cover
_logger.warning(
"Could not find an installation of `packaging`. Requirements, dependencies and "
"versions might not be validated. "
"To enforce validation, please install `packaging`."
)

def pep508(value: str) -> bool:
return True


def pep508_versionspec(value: str) -> bool:
"""Expression that can be used to specify/lock versions (including ranges)"""
if any(c in value for c in (";", "]", "@")):
# In PEP 508:
# conditional markers, extras and URL specs are not included in the
# versionspec
return False
# Let's pretend we have a dependency called `requirement` with the given
# version spec, then we can re-use the pep508 function for validation:
return pep508(f"requirement{value}")


# -------------------------------------------------------------------------------------
# PEP 517


def pep517_backend_reference(value: str) -> bool:
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)


# -------------------------------------------------------------------------------------
# Classifiers - PEP 301


try:
from trove_classifiers import classifiers as _trove_classifiers

def trove_classifier(value: str) -> bool:
return value in _trove_classifiers


except ImportError: # pragma: no cover

class _TroveClassifier:
def __init__(self):
self._warned = False
self.__name__ = "trove-classifier"

def __call__(self, value: str) -> bool:
if self._warned is False:
self._warned = True
_logger.warning("Install ``trove-classifiers`` to ensure validation.")
return True

trove_classifier = _TroveClassifier()


# -------------------------------------------------------------------------------------
# Non-PEP related


def url(value: str) -> bool:
try:
parts = urlparse(value)
return bool(parts.scheme and parts.netloc)
# ^ TODO: should we enforce schema to be http(s)?
except Exception:
return False


# https://packaging.python.org/specifications/entry-points/
ENTRYPOINT_PATTERN = r"[^\[\s=]([^=]*[^\s=])?"
ENTRYPOINT_REGEX = re.compile(f"^{ENTRYPOINT_PATTERN}$", re.I)
RECOMMEDED_ENTRYPOINT_PATTERN = r"[\w.-]+"
RECOMMEDED_ENTRYPOINT_REGEX = re.compile(f"^{RECOMMEDED_ENTRYPOINT_PATTERN}$", re.I)
ENTRYPOINT_GROUP_PATTERN = r"\w+(\.\w+)*"
ENTRYPOINT_GROUP_REGEX = re.compile(f"^{ENTRYPOINT_GROUP_PATTERN}$", re.I)


def python_identifier(value: str) -> bool:
return value.isidentifier()


def python_qualified_identifier(value: str) -> bool:
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:
return python_qualified_identifier(value)


def python_entrypoint_group(value: str) -> bool:
return ENTRYPOINT_GROUP_REGEX.match(value) is not None


def python_entrypoint_name(value: str) -> bool:
if not ENTRYPOINT_REGEX.match(value):
return False
if not RECOMMEDED_ENTRYPOINT_REGEX.match(value):
msg = f"Entry point `{value}` does not follow recommended pattern: "
msg += RECOMMEDED_ENTRYPOINT_PATTERN
_logger.warning(msg)
return True


def python_entrypoint_reference(value: str) -> bool:
if ":" not in value:
return False
module, _, rest = value.partition(":")
if "[" in rest:
obj, _, extras_ = rest.partition("[")
if extras_.strip()[-1] != "]":
return False
extras = (x.strip() for x in extras_.strip(string.whitespace + "[]").split(","))
if not all(pep508_identifier(e) for e in extras):
return False
_logger.warning(f"`{value}` - using extras for entry points is not recommended")
else:
obj = rest

identifiers = chain(module.split("."), obj.split("."))
return all(python_identifier(i.strip()) for i in identifiers)
1 change: 1 addition & 0 deletions setuptools/_vendor/vendored.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ typing_extensions==4.0.1
# required for importlib_resources and _metadata on older Pythons
zipp==3.7.0
tomli==1.2.3
# validate-pyproject[all]==0.3.2 # Special handling, don't remove
1 change: 1 addition & 0 deletions setuptools/extern/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,6 @@ def install(self):
names = (
'packaging', 'pyparsing', 'ordered_set', 'more_itertools', 'importlib_metadata',
'zipp', 'importlib_resources', 'jaraco', 'typing_extensions', 'tomli',
'_validate_pyproject',
)
VendorImporter(__name__, names, 'setuptools._vendor').install()
39 changes: 39 additions & 0 deletions tools/vendored.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import os
import re
import sys
import shutil
import string
import subprocess
import venv
from tempfile import TemporaryDirectory

from path import Path

Expand Down Expand Up @@ -127,6 +132,7 @@ def update_pkg_resources():
def update_setuptools():
vendor = Path('setuptools/_vendor')
install(vendor)
install_validate_pyproject(vendor)
rewrite_packaging(vendor / 'packaging', 'setuptools.extern')
rewrite_jaraco_text(vendor / 'jaraco/text', 'setuptools.extern')
rewrite_jaraco(vendor / 'jaraco', 'setuptools.extern')
Expand All @@ -135,4 +141,37 @@ def update_setuptools():
rewrite_more_itertools(vendor / "more_itertools")


def install_validate_pyproject(vendor):
"""``validate-pyproject`` can be vendorized to remove all dependencies"""
req = next(
(x for x in (vendor / "vendored.txt").lines() if 'validate-pyproject' in x),
"validate-pyproject[all]"
)

pkg, _, _ = req.strip(string.whitespace + "#").partition("#")
pkg = pkg.strip()

opts = {}
if sys.version_info[:2] >= (3, 10):
opts["ignore_cleanup_errors"] = True

with TemporaryDirectory(**opts) as tmp:
venv.create(tmp, with_pip=True)
path = os.pathsep.join(Path(tmp).glob("*"))
venv_python = shutil.which("python", path=path)
subprocess.check_call([venv_python, "-m", "pip", "install", pkg])
cmd = [
venv_python,
"-m",
"validate_pyproject.vendoring",
"--output-dir",
str(vendor / "_validate_pyproject"),
"--enable-plugins",
"setuptools",
"distutils",
"--very-verbose"
]
subprocess.check_call(cmd)


__name__ == '__main__' and update_vendored()

0 comments on commit 771488d

Please sign in to comment.