• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

abravalheri / validate-pyproject / 6173991897923584

11 Nov 2024 04:41PM CUT coverage: 97.859%. Remained the same
6173991897923584

Pull #218

cirrus-ci

pre-commit-ci[bot]
[pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
Pull Request #218: [pre-commit.ci] pre-commit autoupdate

293 of 306 branches covered (95.75%)

Branch coverage included in aggregate %.

1 of 1 new or added line in 1 file covered. (100.0%)

941 of 955 relevant lines covered (98.53%)

6.89 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.94
/src/validate_pyproject/formats.py
1
"""
2
The functions in this module are used to validate schemas with the
3
`format JSON Schema keyword
4
<https://json-schema.org/understanding-json-schema/reference/string#format>`_.
5

6
The correspondence is given by replacing the ``_`` character in the name of the
7
function with a ``-`` to obtain the format name and vice versa.
8
"""
9

10
import builtins
7✔
11
import logging
7✔
12
import os
7✔
13
import re
7✔
14
import string
7✔
15
import typing
7✔
16
from itertools import chain as _chain
7✔
17

18
if typing.TYPE_CHECKING:
19
    from typing_extensions import Literal
20

21
_logger = logging.getLogger(__name__)
7✔
22

23
# -------------------------------------------------------------------------------------
24
# PEP 440
25

26
VERSION_PATTERN = r"""
7✔
27
    v?
28
    (?:
29
        (?:(?P<epoch>[0-9]+)!)?                           # epoch
30
        (?P<release>[0-9]+(?:\.[0-9]+)*)                  # release segment
31
        (?P<pre>                                          # pre-release
32
            [-_\.]?
33
            (?P<pre_l>alpha|a|beta|b|preview|pre|c|rc)
34
            [-_\.]?
35
            (?P<pre_n>[0-9]+)?
36
        )?
37
        (?P<post>                                         # post release
38
            (?:-(?P<post_n1>[0-9]+))
39
            |
40
            (?:
41
                [-_\.]?
42
                (?P<post_l>post|rev|r)
43
                [-_\.]?
44
                (?P<post_n2>[0-9]+)?
45
            )
46
        )?
47
        (?P<dev>                                          # dev release
48
            [-_\.]?
49
            (?P<dev_l>dev)
50
            [-_\.]?
51
            (?P<dev_n>[0-9]+)?
52
        )?
53
    )
54
    (?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
55
"""
56

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

59

60
def pep440(version: str) -> bool:
7✔
61
    """See :ref:`PyPA's version specification <pypa:version-specifiers>`
62
    (initially introduced in :pep:`440`).
63
    """
64
    return VERSION_REGEX.match(version) is not None
7✔
65

66

67
# -------------------------------------------------------------------------------------
68
# PEP 508
69

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

73

74
def pep508_identifier(name: str) -> bool:
7✔
75
    """See :ref:`PyPA's name specification <pypa:name-format>`
76
    (initially introduced in :pep:`508#names`).
77
    """
78
    return PEP508_IDENTIFIER_REGEX.match(name) is not None
7✔
79

80

81
try:
7✔
82
    try:
7✔
83
        from packaging import requirements as _req
7✔
84
    except ImportError:  # pragma: no cover
85
        # let's try setuptools vendored version
86
        from setuptools._vendor.packaging import (  # type: ignore[no-redef]
87
            requirements as _req,
88
        )
89

90
    def pep508(value: str) -> bool:
7✔
91
        """See :ref:`PyPA's dependency specifiers <pypa:dependency-specifiers>`
92
        (initially introduced in :pep:`508`).
93
        """
94
        try:
7✔
95
            _req.Requirement(value)
7✔
96
            return True
7✔
97
        except _req.InvalidRequirement:
7✔
98
            return False
7✔
99

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

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

110

111
def pep508_versionspec(value: str) -> bool:
7✔
112
    """Expression that can be used to specify/lock versions (including ranges)
113
    See ``versionspec`` in :ref:`PyPA's dependency specifiers
114
    <pypa:dependency-specifiers>` (initially introduced in :pep:`508`).
115
    """
116
    if any(c in value for c in (";", "]", "@")):
7✔
117
        # In PEP 508:
118
        # conditional markers, extras and URL specs are not included in the
119
        # versionspec
120
        return False
7✔
121
    # Let's pretend we have a dependency called `requirement` with the given
122
    # version spec, then we can reuse the pep508 function for validation:
123
    return pep508(f"requirement{value}")
7✔
124

125

126
# -------------------------------------------------------------------------------------
127
# PEP 517
128

129

130
def pep517_backend_reference(value: str) -> bool:
7✔
131
    """See PyPA's specification for defining build-backend references
132
    introduced in :pep:`517#source-trees`.
133

134
    This is similar to an entry-point reference (e.g., ``package.module:object``).
135
    """
136
    module, _, obj = value.partition(":")
7✔
137
    identifiers = (i.strip() for i in _chain(module.split("."), obj.split(".")))
7✔
138
    return all(python_identifier(i) for i in identifiers if i)
7✔
139

140

141
# -------------------------------------------------------------------------------------
142
# Classifiers - PEP 301
143

144

145
def _download_classifiers() -> str:
7✔
146
    import ssl
7✔
147
    from email.message import Message
7✔
148
    from urllib.request import urlopen
7✔
149

150
    url = "https://pypi.org/pypi?:action=list_classifiers"
7✔
151
    context = ssl.create_default_context()
7✔
152
    with urlopen(url, context=context) as response:  # noqa: S310 (audit URLs)
7✔
153
        headers = Message()
7✔
154
        headers["content_type"] = response.getheader("content-type", "text/plain")
7✔
155
        return response.read().decode(headers.get_param("charset", "utf-8"))  # type: ignore[no-any-return]
7✔
156

157

158
class _TroveClassifier:
7✔
159
    """The ``trove_classifiers`` package is the official way of validating classifiers,
160
    however this package might not be always available.
161
    As a workaround we can still download a list from PyPI.
162
    We also don't want to be over strict about it, so simply skipping silently is an
163
    option (classifiers will be validated anyway during the upload to PyPI).
164
    """
165

166
    downloaded: typing.Union[None, "Literal[False]", typing.Set[str]]
7✔
167
    """
6✔
168
    None => not cached yet
169
    False => unavailable
170
    set => cached values
171
    """
172

173
    def __init__(self) -> None:
7✔
174
        self.downloaded = None
7✔
175
        self._skip_download = False
7✔
176
        self.__name__ = "trove_classifier"  # Emulate a public function
7✔
177

178
    def _disable_download(self) -> None:
7✔
179
        # This is a private API. Only setuptools has the consent of using it.
180
        self._skip_download = True
7✔
181

182
    def __call__(self, value: str) -> bool:
7✔
183
        if self.downloaded is False or self._skip_download is True:
7✔
184
            return True
7✔
185

186
        if os.getenv("NO_NETWORK") or os.getenv("VALIDATE_PYPROJECT_NO_NETWORK"):
7✔
187
            self.downloaded = False
7✔
188
            msg = (
7✔
189
                "Install ``trove-classifiers`` to ensure proper validation. "
190
                "Skipping download of classifiers list from PyPI (NO_NETWORK)."
191
            )
192
            _logger.debug(msg)
7✔
193
            return True
7✔
194

195
        if self.downloaded is None:
7✔
196
            msg = (
7✔
197
                "Install ``trove-classifiers`` to ensure proper validation. "
198
                "Meanwhile a list of classifiers will be downloaded from PyPI."
199
            )
200
            _logger.debug(msg)
7✔
201
            try:
7✔
202
                self.downloaded = set(_download_classifiers().splitlines())
7✔
203
            except Exception:
7✔
204
                self.downloaded = False
7✔
205
                _logger.debug("Problem with download, skipping validation")
7✔
206
                return True
7✔
207

208
        return value in self.downloaded or value.lower().startswith("private ::")
7✔
209

210

211
try:
7✔
212
    from trove_classifiers import classifiers as _trove_classifiers
7✔
213

214
    def trove_classifier(value: str) -> bool:
7✔
215
        """See https://pypi.org/classifiers/"""
216
        return value in _trove_classifiers or value.lower().startswith("private ::")
7✔
217

218
except ImportError:  # pragma: no cover
219
    trove_classifier = _TroveClassifier()
220

221

222
# -------------------------------------------------------------------------------------
223
# Stub packages - PEP 561
224

225

226
def pep561_stub_name(value: str) -> bool:
7✔
227
    """Name of a directory containing type stubs.
228
    It must follow the name scheme ``<package>-stubs`` as defined in
229
    :pep:`561#stub-only-packages`.
230
    """
231
    top, *children = value.split(".")
7✔
232
    if not top.endswith("-stubs"):
7✔
233
        return False
7✔
234
    return python_module_name(".".join([top[: -len("-stubs")], *children]))
7✔
235

236

237
# -------------------------------------------------------------------------------------
238
# Non-PEP related
239

240

241
def url(value: str) -> bool:
7✔
242
    """Valid URL (validation uses :obj:`urllib.parse`).
243
    For maximum compatibility please make sure to include a ``scheme`` prefix
244
    in your URL (e.g. ``http://``).
245
    """
246
    from urllib.parse import urlparse
7✔
247

248
    try:
7✔
249
        parts = urlparse(value)
7✔
250
        if not parts.scheme:
7✔
251
            _logger.warning(
7✔
252
                "For maximum compatibility please make sure to include a "
253
                "`scheme` prefix in your URL (e.g. 'http://'). "
254
                f"Given value: {value}"
255
            )
256
            if not (value.startswith("/") or value.startswith("\\") or "@" in value):
7✔
257
                parts = urlparse(f"http://{value}")
7✔
258

259
        return bool(parts.scheme and parts.netloc)
7✔
260
    except Exception:
7✔
261
        return False
7✔
262

263

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

272

273
def python_identifier(value: str) -> bool:
7✔
274
    """Can be used as identifier in Python.
275
    (Validation uses :obj:`str.isidentifier`).
276
    """
277
    return value.isidentifier()
7✔
278

279

280
def python_qualified_identifier(value: str) -> bool:
7✔
281
    """
282
    Python "dotted identifier", i.e. a sequence of :obj:`python_identifier`
283
    concatenated with ``"."`` (e.g.: ``package.module.submodule``).
284
    """
285
    if value.startswith(".") or value.endswith("."):
7✔
286
        return False
7✔
287
    return all(python_identifier(m) for m in value.split("."))
7✔
288

289

290
def python_module_name(value: str) -> bool:
7✔
291
    """Module name that can be used in an ``import``-statement in Python.
292
    See :obj:`python_qualified_identifier`.
293
    """
294
    return python_qualified_identifier(value)
7✔
295

296

297
def python_module_name_relaxed(value: str) -> bool:
7✔
298
    """Similar to :obj:`python_module_name`, but relaxed to also accept
299
    dash characters (``-``) and cover special cases like ``pip-run``.
300

301
    It is recommended, however, that beginners avoid dash characters,
302
    as they require advanced knowledge about Python internals.
303

304
    The following are disallowed:
305

306
    * names starting/ending in dashes,
307
    * names ending in ``-stubs`` (potentially collide with :obj:`pep561_stub_name`).
308
    """
309
    if value.startswith("-") or value.endswith("-"):
7✔
310
        return False
7✔
311
    if value.endswith("-stubs"):
7✔
312
        return False  # Avoid collision with PEP 561
7✔
313
    return python_module_name(value.replace("-", "_"))
7✔
314

315

316
def python_entrypoint_group(value: str) -> bool:
7✔
317
    """See ``Data model > group`` in the :ref:`PyPA's entry-points specification
318
    <pypa:entry-points>`.
319
    """
320
    return ENTRYPOINT_GROUP_REGEX.match(value) is not None
7✔
321

322

323
def python_entrypoint_name(value: str) -> bool:
7✔
324
    """See ``Data model > name`` in the :ref:`PyPA's entry-points specification
325
    <pypa:entry-points>`.
326
    """
327
    if not ENTRYPOINT_REGEX.match(value):
7✔
328
        return False
7✔
329
    if not RECOMMEDED_ENTRYPOINT_REGEX.match(value):
7✔
330
        msg = f"Entry point `{value}` does not follow recommended pattern: "
7✔
331
        msg += RECOMMEDED_ENTRYPOINT_PATTERN
7✔
332
        _logger.warning(msg)
7✔
333
    return True
7✔
334

335

336
def python_entrypoint_reference(value: str) -> bool:
7✔
337
    """Reference to a Python object using in the format::
338

339
        importable.module:object.attr
340

341
    See ``Data model >object reference`` in the :ref:`PyPA's entry-points specification
342
    <pypa:entry-points>`.
343
    """
344
    module, _, rest = value.partition(":")
7✔
345
    if "[" in rest:
7✔
346
        obj, _, extras_ = rest.partition("[")
7✔
347
        if extras_.strip()[-1] != "]":
7✔
348
            return False
7✔
349
        extras = (x.strip() for x in extras_.strip(string.whitespace + "[]").split(","))
7✔
350
        if not all(pep508_identifier(e) for e in extras):
7✔
351
            return False
7✔
352
        _logger.warning(f"`{value}` - using extras for entry points is not recommended")
7✔
353
    else:
354
        obj = rest
7✔
355

356
    module_parts = module.split(".")
7✔
357
    identifiers = _chain(module_parts, obj.split(".")) if rest else iter(module_parts)
7✔
358
    return all(python_identifier(i.strip()) for i in identifiers)
7✔
359

360

361
def uint8(value: builtins.int) -> bool:
7✔
362
    r"""Unsigned 8-bit integer (:math:`0 \leq x < 2^8`)"""
363
    return 0 <= value < 2**8
×
364

365

366
def uint16(value: builtins.int) -> bool:
7✔
367
    r"""Unsigned 16-bit integer (:math:`0 \leq x < 2^{16}`)"""
368
    return 0 <= value < 2**16
×
369

370

371
def uint(value: builtins.int) -> bool:
7✔
372
    r"""Unsigned 64-bit integer (:math:`0 \leq x < 2^{64}`)"""
373
    return 0 <= value < 2**64
×
374

375

376
def int(value: builtins.int) -> bool:
7✔
377
    r"""Signed 64-bit integer (:math:`-2^{63} \leq x < 2^{63}`)"""
378
    return -(2**63) <= value < 2**63
×
379

380

381
try:
7✔
382
    from packaging import licenses as _licenses
7✔
383

384
    def SPDX(value: str) -> bool:
7✔
385
        """See :ref:`PyPA's License-Expression specification
386
        <pypa:core-metadata-license-expression>` (added in :pep:`639`).
387
        """
388
        try:
7✔
389
            _licenses.canonicalize_license_expression(value)
7✔
390
            return True
7✔
391
        except _licenses.InvalidLicenseExpression:
7✔
392
            return False
7✔
393

394
except ImportError:  # pragma: no cover
395
    _logger.warning(
396
        "Could not find an up-to-date installation of `packaging`. "
397
        "License expressions might not be validated. "
398
        "To enforce validation, please install `packaging>=24.2`."
399
    )
400

401
    def SPDX(value: str) -> bool:
402
        return True
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc