6 from itertools import chain as _chain
8 _logger = logging.getLogger(__name__)
10 # -------------------------------------------------------------------------------------
13 VERSION_PATTERN = r"""
16 (?:(?P<epoch>[0-9]+)!)? # epoch
17 (?P<release>[0-9]+(?:\.[0-9]+)*) # release segment
18 (?P<pre> # pre-release
20 (?P<pre_l>(a|b|c|rc|alpha|beta|pre|preview))
24 (?P<post> # post release
25 (?:-(?P<post_n1>[0-9]+))
29 (?P<post_l>post|rev|r)
34 (?P<dev> # dev release
41 (?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))? # local version
44 VERSION_REGEX = re.compile(r"^\s*" + VERSION_PATTERN + r"\s*$", re.X | re.I)
47 def pep440(version: str) -> bool:
48 return VERSION_REGEX.match(version) is not None
51 # -------------------------------------------------------------------------------------
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)
58 def pep508_identifier(name: str) -> bool:
59 return PEP508_IDENTIFIER_REGEX.match(name) is not None
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
69 def pep508(value: str) -> bool:
71 _req.Requirement(value)
73 except _req.InvalidRequirement:
76 except ImportError: # pragma: no cover
78 "Could not find an installation of `packaging`. Requirements, dependencies and "
79 "versions might not be validated. "
80 "To enforce validation, please install `packaging`."
83 def pep508(value: str) -> bool:
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 (";", "]", "@")):
91 # conditional markers, extras and URL specs are not included in the
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}")
99 # -------------------------------------------------------------------------------------
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)
109 # -------------------------------------------------------------------------------------
110 # Classifiers - PEP 301
113 def _download_classifiers() -> str:
115 from email.message import Message
116 from urllib.request import urlopen
118 url = "https://pypi.org/pypi?:action=list_classifiers"
119 context = ssl.create_default_context()
120 with urlopen(url, context=context) as response:
122 headers["content_type"] = response.getheader("content-type", "text/plain")
123 return response.read().decode(headers.get_param("charset", "utf-8"))
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).
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
141 def _disable_download(self):
142 # This is a private API. Only setuptools has the consent of using it.
143 self._skip_download = True
145 def __call__(self, value: str) -> bool:
146 if self.downloaded is False or self._skip_download is True:
149 if os.getenv("NO_NETWORK") or os.getenv("VALIDATE_PYPROJECT_NO_NETWORK"):
150 self.downloaded = False
152 "Install ``trove-classifiers`` to ensure proper validation. "
153 "Skipping download of classifiers list from PyPI (NO_NETWORK)."
158 if self.downloaded is None:
160 "Install ``trove-classifiers`` to ensure proper validation. "
161 "Meanwhile a list of classifiers will be downloaded from PyPI."
165 self.downloaded = set(_download_classifiers().splitlines())
167 self.downloaded = False
168 _logger.debug("Problem with download, skipping validation")
171 return value in self.downloaded or value.lower().startswith("private ::")
175 from trove_classifiers import classifiers as _trove_classifiers
177 def trove_classifier(value: str) -> bool:
178 return value in _trove_classifiers or value.lower().startswith("private ::")
180 except ImportError: # pragma: no cover
181 trove_classifier = _TroveClassifier()
184 # -------------------------------------------------------------------------------------
188 def url(value: str) -> bool:
189 from urllib.parse import urlparse
192 parts = urlparse(value)
195 "For maximum compatibility please make sure to include a "
196 "`scheme` prefix in your URL (e.g. 'http://'). "
197 f"Given value: {value}"
199 if not (value.startswith("/") or value.startswith("\\") or "@" in value):
200 parts = urlparse(f"http://{value}")
202 return bool(parts.scheme and parts.netloc)
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)
216 def python_identifier(value: str) -> bool:
217 return value.isidentifier()
220 def python_qualified_identifier(value: str) -> bool:
221 if value.startswith(".") or value.endswith("."):
223 return all(python_identifier(m) for m in value.split("."))
226 def python_module_name(value: str) -> bool:
227 return python_qualified_identifier(value)
230 def python_entrypoint_group(value: str) -> bool:
231 return ENTRYPOINT_GROUP_REGEX.match(value) is not None
234 def python_entrypoint_name(value: str) -> bool:
235 if not ENTRYPOINT_REGEX.match(value):
237 if not RECOMMEDED_ENTRYPOINT_REGEX.match(value):
238 msg = f"Entry point `{value}` does not follow recommended pattern: "
239 msg += RECOMMEDED_ENTRYPOINT_PATTERN
244 def python_entrypoint_reference(value: str) -> bool:
245 module, _, rest = value.partition(":")
247 obj, _, extras_ = rest.partition("[")
248 if extras_.strip()[-1] != "]":
250 extras = (x.strip() for x in extras_.strip(string.whitespace + "[]").split(","))
251 if not all(pep508_identifier(e) for e in extras):
253 _logger.warning(f"`{value}` - using extras for entry points is not recommended")
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)