Skip to content

Commit 771488d

Browse files
committed
Add validate-pyproject as a vendored dependency
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).
1 parent 099ac60 commit 771488d

File tree

9 files changed

+1802
-0
lines changed

9 files changed

+1802
-0
lines changed

setuptools/_vendor/_validate_pyproject/NOTICE

Lines changed: 439 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from functools import reduce
2+
from typing import Any, Callable, Dict
3+
4+
from . import formats
5+
from .extra_validations import EXTRA_VALIDATIONS
6+
from .fastjsonschema_exceptions import JsonSchemaException, JsonSchemaValueException
7+
from .fastjsonschema_validations import validate as _validate
8+
9+
__all__ = [
10+
"validate",
11+
"FORMAT_FUNCTIONS",
12+
"EXTRA_VALIDATIONS",
13+
"JsonSchemaException",
14+
"JsonSchemaValueException",
15+
]
16+
17+
18+
FORMAT_FUNCTIONS: Dict[str, Callable[[str], bool]] = {
19+
fn.__name__.replace("_", "-"): fn
20+
for fn in formats.__dict__.values()
21+
if callable(fn) and not fn.__name__.startswith("_")
22+
}
23+
24+
25+
def validate(data: Any) -> bool:
26+
"""Validate the given ``data`` object using JSON Schema
27+
This function raises ``JsonSchemaValueException`` if ``data`` is invalid.
28+
"""
29+
_validate(data, custom_formats=FORMAT_FUNCTIONS)
30+
reduce(lambda acc, fn: fn(acc), EXTRA_VALIDATIONS, data)
31+
return True
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""The purpose of this module is implement PEP 621 validations that are
2+
difficult to express as a JSON Schema (or that are not supported by the current
3+
JSON Schema library).
4+
"""
5+
6+
from typing import Mapping, TypeVar
7+
8+
from .fastjsonschema_exceptions import JsonSchemaValueException
9+
10+
T = TypeVar("T", bound=Mapping)
11+
12+
13+
class RedefiningStaticFieldAsDynamic(JsonSchemaValueException):
14+
"""According to PEP 621:
15+
16+
Build back-ends MUST raise an error if the metadata specifies a field
17+
statically as well as being listed in dynamic.
18+
"""
19+
20+
21+
def validate_project_dynamic(pyproject: T) -> T:
22+
project_table = pyproject.get("project", {})
23+
dynamic = project_table.get("dynamic", [])
24+
25+
for field in dynamic:
26+
if field in project_table:
27+
msg = f"You cannot provided a value for `project.{field}` and "
28+
msg += "list it under `project.dynamic` at the same time"
29+
name = f"data.project.{field}"
30+
value = {field: project_table[field], "...": " # ...", "dynamic": dynamic}
31+
raise RedefiningStaticFieldAsDynamic(msg, value, name, rule="PEP 621")
32+
33+
return pyproject
34+
35+
36+
EXTRA_VALIDATIONS = (validate_project_dynamic,)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import re
2+
3+
4+
SPLIT_RE = re.compile(r'[\.\[\]]+')
5+
6+
7+
class JsonSchemaException(ValueError):
8+
"""
9+
Base exception of ``fastjsonschema`` library.
10+
"""
11+
12+
13+
class JsonSchemaValueException(JsonSchemaException):
14+
"""
15+
Exception raised by validation function. Available properties:
16+
17+
* ``message`` containing human-readable information what is wrong (e.g. ``data.property[index] must be smaller than or equal to 42``),
18+
* invalid ``value`` (e.g. ``60``),
19+
* ``name`` of a path in the data structure (e.g. ``data.propery[index]``),
20+
* ``path`` as an array in the data structure (e.g. ``['data', 'propery', 'index']``),
21+
* the whole ``definition`` which the ``value`` has to fulfil (e.g. ``{'type': 'number', 'maximum': 42}``),
22+
* ``rule`` which the ``value`` is breaking (e.g. ``maximum``)
23+
* and ``rule_definition`` (e.g. ``42``).
24+
25+
.. versionchanged:: 2.14.0
26+
Added all extra properties.
27+
"""
28+
29+
def __init__(self, message, value=None, name=None, definition=None, rule=None):
30+
super().__init__(message)
31+
self.message = message
32+
self.value = value
33+
self.name = name
34+
self.definition = definition
35+
self.rule = rule
36+
37+
@property
38+
def path(self):
39+
return [item for item in SPLIT_RE.split(self.name) if item != '']
40+
41+
@property
42+
def rule_definition(self):
43+
if not self.rule or not self.definition:
44+
return None
45+
return self.definition.get(self.rule)
46+
47+
48+
class JsonSchemaDefinitionException(JsonSchemaException):
49+
"""
50+
Exception raised by generator of validation function.
51+
"""

setuptools/_vendor/_validate_pyproject/fastjsonschema_validations.py

Lines changed: 1002 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import logging
2+
import re
3+
import string
4+
from itertools import chain
5+
from urllib.parse import urlparse
6+
7+
_logger = logging.getLogger(__name__)
8+
9+
# -------------------------------------------------------------------------------------
10+
# PEP 440
11+
12+
VERSION_PATTERN = r"""
13+
v?
14+
(?:
15+
(?:(?P<epoch>[0-9]+)!)? # epoch
16+
(?P<release>[0-9]+(?:\.[0-9]+)*) # release segment
17+
(?P<pre> # pre-release
18+
[-_\.]?
19+
(?P<pre_l>(a|b|c|rc|alpha|beta|pre|preview))
20+
[-_\.]?
21+
(?P<pre_n>[0-9]+)?
22+
)?
23+
(?P<post> # post release
24+
(?:-(?P<post_n1>[0-9]+))
25+
|
26+
(?:
27+
[-_\.]?
28+
(?P<post_l>post|rev|r)
29+
[-_\.]?
30+
(?P<post_n2>[0-9]+)?
31+
)
32+
)?
33+
(?P<dev> # dev release
34+
[-_\.]?
35+
(?P<dev_l>dev)
36+
[-_\.]?
37+
(?P<dev_n>[0-9]+)?
38+
)?
39+
)
40+
(?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))? # local version
41+
"""
42+
43+
VERSION_REGEX = re.compile(r"^\s*" + VERSION_PATTERN + r"\s*$", re.X | re.I)
44+
45+
46+
def pep440(version: str) -> bool:
47+
return VERSION_REGEX.match(version) is not None
48+
49+
50+
# -------------------------------------------------------------------------------------
51+
# PEP 508
52+
53+
PEP508_IDENTIFIER_PATTERN = r"([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])"
54+
PEP508_IDENTIFIER_REGEX = re.compile(f"^{PEP508_IDENTIFIER_PATTERN}$", re.I)
55+
56+
57+
def pep508_identifier(name: str) -> bool:
58+
return PEP508_IDENTIFIER_REGEX.match(name) is not None
59+
60+
61+
try:
62+
try:
63+
from packaging import requirements as _req
64+
except ImportError: # pragma: no cover
65+
# let's try setuptools vendored version
66+
from setuptools._vendor.packaging import requirements as _req # type: ignore
67+
68+
def pep508(value: str) -> bool:
69+
try:
70+
_req.Requirement(value)
71+
return True
72+
except _req.InvalidRequirement:
73+
return False
74+
75+
76+
except ImportError: # pragma: no cover
77+
_logger.warning(
78+
"Could not find an installation of `packaging`. Requirements, dependencies and "
79+
"versions might not be validated. "
80+
"To enforce validation, please install `packaging`."
81+
)
82+
83+
def pep508(value: str) -> bool:
84+
return True
85+
86+
87+
def pep508_versionspec(value: str) -> bool:
88+
"""Expression that can be used to specify/lock versions (including ranges)"""
89+
if any(c in value for c in (";", "]", "@")):
90+
# In PEP 508:
91+
# conditional markers, extras and URL specs are not included in the
92+
# versionspec
93+
return False
94+
# Let's pretend we have a dependency called `requirement` with the given
95+
# version spec, then we can re-use the pep508 function for validation:
96+
return pep508(f"requirement{value}")
97+
98+
99+
# -------------------------------------------------------------------------------------
100+
# PEP 517
101+
102+
103+
def pep517_backend_reference(value: str) -> bool:
104+
module, _, obj = value.partition(":")
105+
identifiers = (i.strip() for i in chain(module.split("."), obj.split(".")))
106+
return all(python_identifier(i) for i in identifiers if i)
107+
108+
109+
# -------------------------------------------------------------------------------------
110+
# Classifiers - PEP 301
111+
112+
113+
try:
114+
from trove_classifiers import classifiers as _trove_classifiers
115+
116+
def trove_classifier(value: str) -> bool:
117+
return value in _trove_classifiers
118+
119+
120+
except ImportError: # pragma: no cover
121+
122+
class _TroveClassifier:
123+
def __init__(self):
124+
self._warned = False
125+
self.__name__ = "trove-classifier"
126+
127+
def __call__(self, value: str) -> bool:
128+
if self._warned is False:
129+
self._warned = True
130+
_logger.warning("Install ``trove-classifiers`` to ensure validation.")
131+
return True
132+
133+
trove_classifier = _TroveClassifier()
134+
135+
136+
# -------------------------------------------------------------------------------------
137+
# Non-PEP related
138+
139+
140+
def url(value: str) -> bool:
141+
try:
142+
parts = urlparse(value)
143+
return bool(parts.scheme and parts.netloc)
144+
# ^ TODO: should we enforce schema to be http(s)?
145+
except Exception:
146+
return False
147+
148+
149+
# https://packaging.python.org/specifications/entry-points/
150+
ENTRYPOINT_PATTERN = r"[^\[\s=]([^=]*[^\s=])?"
151+
ENTRYPOINT_REGEX = re.compile(f"^{ENTRYPOINT_PATTERN}$", re.I)
152+
RECOMMEDED_ENTRYPOINT_PATTERN = r"[\w.-]+"
153+
RECOMMEDED_ENTRYPOINT_REGEX = re.compile(f"^{RECOMMEDED_ENTRYPOINT_PATTERN}$", re.I)
154+
ENTRYPOINT_GROUP_PATTERN = r"\w+(\.\w+)*"
155+
ENTRYPOINT_GROUP_REGEX = re.compile(f"^{ENTRYPOINT_GROUP_PATTERN}$", re.I)
156+
157+
158+
def python_identifier(value: str) -> bool:
159+
return value.isidentifier()
160+
161+
162+
def python_qualified_identifier(value: str) -> bool:
163+
if value.startswith(".") or value.endswith("."):
164+
return False
165+
return all(python_identifier(m) for m in value.split("."))
166+
167+
168+
def python_module_name(value: str) -> bool:
169+
return python_qualified_identifier(value)
170+
171+
172+
def python_entrypoint_group(value: str) -> bool:
173+
return ENTRYPOINT_GROUP_REGEX.match(value) is not None
174+
175+
176+
def python_entrypoint_name(value: str) -> bool:
177+
if not ENTRYPOINT_REGEX.match(value):
178+
return False
179+
if not RECOMMEDED_ENTRYPOINT_REGEX.match(value):
180+
msg = f"Entry point `{value}` does not follow recommended pattern: "
181+
msg += RECOMMEDED_ENTRYPOINT_PATTERN
182+
_logger.warning(msg)
183+
return True
184+
185+
186+
def python_entrypoint_reference(value: str) -> bool:
187+
if ":" not in value:
188+
return False
189+
module, _, rest = value.partition(":")
190+
if "[" in rest:
191+
obj, _, extras_ = rest.partition("[")
192+
if extras_.strip()[-1] != "]":
193+
return False
194+
extras = (x.strip() for x in extras_.strip(string.whitespace + "[]").split(","))
195+
if not all(pep508_identifier(e) for e in extras):
196+
return False
197+
_logger.warning(f"`{value}` - using extras for entry points is not recommended")
198+
else:
199+
obj = rest
200+
201+
identifiers = chain(module.split("."), obj.split("."))
202+
return all(python_identifier(i.strip()) for i in identifiers)

setuptools/_vendor/vendored.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ typing_extensions==4.0.1
1010
# required for importlib_resources and _metadata on older Pythons
1111
zipp==3.7.0
1212
tomli==1.2.3
13+
# validate-pyproject[all]==0.3.2 # Special handling, don't remove

setuptools/extern/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,6 @@ def install(self):
7272
names = (
7373
'packaging', 'pyparsing', 'ordered_set', 'more_itertools', 'importlib_metadata',
7474
'zipp', 'importlib_resources', 'jaraco', 'typing_extensions', 'tomli',
75+
'_validate_pyproject',
7576
)
7677
VendorImporter(__name__, names, 'setuptools._vendor').install()

tools/vendored.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import os
12
import re
23
import sys
4+
import shutil
5+
import string
36
import subprocess
7+
import venv
8+
from tempfile import TemporaryDirectory
49

510
from path import Path
611

@@ -127,6 +132,7 @@ def update_pkg_resources():
127132
def update_setuptools():
128133
vendor = Path('setuptools/_vendor')
129134
install(vendor)
135+
install_validate_pyproject(vendor)
130136
rewrite_packaging(vendor / 'packaging', 'setuptools.extern')
131137
rewrite_jaraco_text(vendor / 'jaraco/text', 'setuptools.extern')
132138
rewrite_jaraco(vendor / 'jaraco', 'setuptools.extern')
@@ -135,4 +141,37 @@ def update_setuptools():
135141
rewrite_more_itertools(vendor / "more_itertools")
136142

137143

144+
def install_validate_pyproject(vendor):
145+
"""``validate-pyproject`` can be vendorized to remove all dependencies"""
146+
req = next(
147+
(x for x in (vendor / "vendored.txt").lines() if 'validate-pyproject' in x),
148+
"validate-pyproject[all]"
149+
)
150+
151+
pkg, _, _ = req.strip(string.whitespace + "#").partition("#")
152+
pkg = pkg.strip()
153+
154+
opts = {}
155+
if sys.version_info[:2] >= (3, 10):
156+
opts["ignore_cleanup_errors"] = True
157+
158+
with TemporaryDirectory(**opts) as tmp:
159+
venv.create(tmp, with_pip=True)
160+
path = os.pathsep.join(Path(tmp).glob("*"))
161+
venv_python = shutil.which("python", path=path)
162+
subprocess.check_call([venv_python, "-m", "pip", "install", pkg])
163+
cmd = [
164+
venv_python,
165+
"-m",
166+
"validate_pyproject.vendoring",
167+
"--output-dir",
168+
str(vendor / "_validate_pyproject"),
169+
"--enable-plugins",
170+
"setuptools",
171+
"distutils",
172+
"--very-verbose"
173+
]
174+
subprocess.check_call(cmd)
175+
176+
138177
__name__ == '__main__' and update_vendored()

0 commit comments

Comments
 (0)