638ac1195344227da3ebf20bb8a0faeb98cb6548
[SubU] /
1 import logging
2 import os
3 import re
4 import string
5 import typing
6 from itertools import chain as _chain
7
8 _logger = logging.getLogger(__name__)
9
10 # -------------------------------------------------------------------------------------
11 # PEP 440
12
13 VERSION_PATTERN = r"""
14     v?
15     (?:
16         (?:(?P<epoch>[0-9]+)!)?                           # epoch
17         (?P<release>[0-9]+(?:\.[0-9]+)*)                  # release segment
18         (?P<pre>                                          # pre-release
19             [-_\.]?
20             (?P<pre_l>(a|b|c|rc|alpha|beta|pre|preview))
21             [-_\.]?
22             (?P<pre_n>[0-9]+)?
23         )?
24         (?P<post>                                         # post release
25             (?:-(?P<post_n1>[0-9]+))
26             |
27             (?:
28                 [-_\.]?
29                 (?P<post_l>post|rev|r)
30                 [-_\.]?
31                 (?P<post_n2>[0-9]+)?
32             )
33         )?
34         (?P<dev>                                          # dev release
35             [-_\.]?
36             (?P<dev_l>dev)
37             [-_\.]?
38             (?P<dev_n>[0-9]+)?
39         )?
40     )
41     (?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
42 """
43
44 VERSION_REGEX = re.compile(r"^\s*" + VERSION_PATTERN + r"\s*$", re.X | re.I)
45
46
47 def pep440(version: str) -> bool:
48     return VERSION_REGEX.match(version) is not None
49
50
51 # -------------------------------------------------------------------------------------
52 # PEP 508
53
54 PEP508_IDENTIFIER_PATTERN = r"([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])"
55 PEP508_IDENTIFIER_REGEX = re.compile(f"^{PEP508_IDENTIFIER_PATTERN}$", re.I)
56
57
58 def pep508_identifier(name: str) -> bool:
59     return PEP508_IDENTIFIER_REGEX.match(name) is not None
60
61
62 try:
63     try:
64         from packaging import requirements as _req
65     except ImportError:  # pragma: no cover
66         # let's try setuptools vendored version
67         from setuptools._vendor.packaging import requirements as _req  # type: ignore
68
69     def pep508(value: str) -> bool:
70         try:
71             _req.Requirement(value)
72             return True
73         except _req.InvalidRequirement:
74             return False
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 def _download_classifiers() -> str:
114     import ssl
115     from email.message import Message
116     from urllib.request import urlopen
117
118     url = "https://pypi.org/pypi?:action=list_classifiers"
119     context = ssl.create_default_context()
120     with urlopen(url, context=context) as response:
121         headers = Message()
122         headers["content_type"] = response.getheader("content-type", "text/plain")
123         return response.read().decode(headers.get_param("charset", "utf-8"))
124
125
126 class _TroveClassifier:
127     """The ``trove_classifiers`` package is the official way of validating classifiers,
128     however this package might not be always available.
129     As a workaround we can still download a list from PyPI.
130     We also don't want to be over strict about it, so simply skipping silently is an
131     option (classifiers will be validated anyway during the upload to PyPI).
132     """
133
134     def __init__(self):
135         self.downloaded: typing.Union[None, False, typing.Set[str]] = None
136         self._skip_download = False
137         # None => not cached yet
138         # False => cache not available
139         self.__name__ = "trove_classifier"  # Emulate a public function
140
141     def _disable_download(self):
142         # This is a private API. Only setuptools has the consent of using it.
143         self._skip_download = True
144
145     def __call__(self, value: str) -> bool:
146         if self.downloaded is False or self._skip_download is True:
147             return True
148
149         if os.getenv("NO_NETWORK") or os.getenv("VALIDATE_PYPROJECT_NO_NETWORK"):
150             self.downloaded = False
151             msg = (
152                 "Install ``trove-classifiers`` to ensure proper validation. "
153                 "Skipping download of classifiers list from PyPI (NO_NETWORK)."
154             )
155             _logger.debug(msg)
156             return True
157
158         if self.downloaded is None:
159             msg = (
160                 "Install ``trove-classifiers`` to ensure proper validation. "
161                 "Meanwhile a list of classifiers will be downloaded from PyPI."
162             )
163             _logger.debug(msg)
164             try:
165                 self.downloaded = set(_download_classifiers().splitlines())
166             except Exception:
167                 self.downloaded = False
168                 _logger.debug("Problem with download, skipping validation")
169                 return True
170
171         return value in self.downloaded or value.lower().startswith("private ::")
172
173
174 try:
175     from trove_classifiers import classifiers as _trove_classifiers
176
177     def trove_classifier(value: str) -> bool:
178         return value in _trove_classifiers or value.lower().startswith("private ::")
179
180 except ImportError:  # pragma: no cover
181     trove_classifier = _TroveClassifier()
182
183
184 # -------------------------------------------------------------------------------------
185 # Non-PEP related
186
187
188 def url(value: str) -> bool:
189     from urllib.parse import urlparse
190
191     try:
192         parts = urlparse(value)
193         if not parts.scheme:
194             _logger.warning(
195                 "For maximum compatibility please make sure to include a "
196                 "`scheme` prefix in your URL (e.g. 'http://'). "
197                 f"Given value: {value}"
198             )
199             if not (value.startswith("/") or value.startswith("\\") or "@" in value):
200                 parts = urlparse(f"http://{value}")
201
202         return bool(parts.scheme and parts.netloc)
203     except Exception:
204         return False
205
206
207 # https://packaging.python.org/specifications/entry-points/
208 ENTRYPOINT_PATTERN = r"[^\[\s=]([^=]*[^\s=])?"
209 ENTRYPOINT_REGEX = re.compile(f"^{ENTRYPOINT_PATTERN}$", re.I)
210 RECOMMEDED_ENTRYPOINT_PATTERN = r"[\w.-]+"
211 RECOMMEDED_ENTRYPOINT_REGEX = re.compile(f"^{RECOMMEDED_ENTRYPOINT_PATTERN}$", re.I)
212 ENTRYPOINT_GROUP_PATTERN = r"\w+(\.\w+)*"
213 ENTRYPOINT_GROUP_REGEX = re.compile(f"^{ENTRYPOINT_GROUP_PATTERN}$", re.I)
214
215
216 def python_identifier(value: str) -> bool:
217     return value.isidentifier()
218
219
220 def python_qualified_identifier(value: str) -> bool:
221     if value.startswith(".") or value.endswith("."):
222         return False
223     return all(python_identifier(m) for m in value.split("."))
224
225
226 def python_module_name(value: str) -> bool:
227     return python_qualified_identifier(value)
228
229
230 def python_entrypoint_group(value: str) -> bool:
231     return ENTRYPOINT_GROUP_REGEX.match(value) is not None
232
233
234 def python_entrypoint_name(value: str) -> bool:
235     if not ENTRYPOINT_REGEX.match(value):
236         return False
237     if not RECOMMEDED_ENTRYPOINT_REGEX.match(value):
238         msg = f"Entry point `{value}` does not follow recommended pattern: "
239         msg += RECOMMEDED_ENTRYPOINT_PATTERN
240         _logger.warning(msg)
241     return True
242
243
244 def python_entrypoint_reference(value: str) -> bool:
245     module, _, rest = value.partition(":")
246     if "[" in rest:
247         obj, _, extras_ = rest.partition("[")
248         if extras_.strip()[-1] != "]":
249             return False
250         extras = (x.strip() for x in extras_.strip(string.whitespace + "[]").split(","))
251         if not all(pep508_identifier(e) for e in extras):
252             return False
253         _logger.warning(f"`{value}` - using extras for entry points is not recommended")
254     else:
255         obj = rest
256
257     module_parts = module.split(".")
258     identifiers = _chain(module_parts, obj.split(".")) if rest else module_parts
259     return all(python_identifier(i.strip()) for i in identifiers)