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

abravalheri / validate-pyproject / 5226968556240896

pending completion
5226968556240896

push

cirrus-ci

GitHub
Typo: validate-project => validate-pyproject (#78)

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

100.0
/src/validate_pyproject/formats.py
1
import logging
8✔
2
import os
8✔
3
import re
8✔
4
import string
8✔
5
import typing
8✔
6
from itertools import chain as _chain
8✔
7

8
_logger = logging.getLogger(__name__)
8✔
9

10
# -------------------------------------------------------------------------------------
11
# PEP 440
12

13
VERSION_PATTERN = r"""
8✔
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)
8✔
45

46

47
def pep440(version: str) -> bool:
8✔
48
    return VERSION_REGEX.match(version) is not None
8✔
49

50

51
# -------------------------------------------------------------------------------------
52
# PEP 508
53

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

57

58
def pep508_identifier(name: str) -> bool:
8✔
59
    return PEP508_IDENTIFIER_REGEX.match(name) is not None
8✔
60

61

62
try:
8✔
63
    try:
8✔
64
        from packaging import requirements as _req
8✔
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:
8✔
70
        try:
8✔
71
            _req.Requirement(value)
8✔
72
            return True
8✔
73
        except _req.InvalidRequirement:
8✔
74
            return False
8✔
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:
8✔
88
    """Expression that can be used to specify/lock versions (including ranges)"""
89
    if any(c in value for c in (";", "]", "@")):
8✔
90
        # In PEP 508:
91
        # conditional markers, extras and URL specs are not included in the
92
        # versionspec
93
        return False
8✔
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}")
8✔
97

98

99
# -------------------------------------------------------------------------------------
100
# PEP 517
101

102

103
def pep517_backend_reference(value: str) -> bool:
8✔
104
    module, _, obj = value.partition(":")
8✔
105
    identifiers = (i.strip() for i in _chain(module.split("."), obj.split(".")))
8✔
106
    return all(python_identifier(i) for i in identifiers if i)
8✔
107

108

109
# -------------------------------------------------------------------------------------
110
# Classifiers - PEP 301
111

112

113
def _download_classifiers() -> str:
8✔
114
    import ssl
8✔
115
    from email.message import Message
8✔
116
    from urllib.request import urlopen
8✔
117

118
    url = "https://pypi.org/pypi?:action=list_classifiers"
8✔
119
    context = ssl.create_default_context()
8✔
120
    with urlopen(url, context=context) as response:
8✔
121
        headers = Message()
7 all except 5226968556240896.8 ✔
122
        headers["content_type"] = response.getheader("content-type", "text/plain")
7 all except 5226968556240896.8 ✔
123
        return response.read().decode(headers.get_param("charset", "utf-8"))
7 all except 5226968556240896.8 ✔
124

125

126
class _TroveClassifier:
8✔
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):
8✔
135
        self.downloaded: typing.Union[None, False, typing.Set[str]] = None
8✔
136
        self._skip_download = False
8✔
137
        # None => not cached yet
138
        # False => cache not available
139
        self.__name__ = "trove_classifier"  # Emulate a public function
8✔
140

141
    def _disable_download(self):
8✔
142
        # This is a private API. Only setuptools has the consent of using it.
143
        self._skip_download = True
8✔
144

145
    def __call__(self, value: str) -> bool:
8✔
146
        if self.downloaded is False or self._skip_download is True:
8✔
147
            return True
8✔
148

149
        if os.getenv("NO_NETWORK") or os.getenv("VALIDATE_PYPROJECT_NO_NETWORK"):
8✔
150
            self.downloaded = False
8✔
151
            msg = (
8✔
152
                "Install ``trove-classifiers`` to ensure proper validation. "
153
                "Skipping download of classifiers list from PyPI (NO_NETWORK)."
154
            )
155
            _logger.debug(msg)
8✔
156
            return True
8✔
157

158
        if self.downloaded is None:
8✔
159
            msg = (
8✔
160
                "Install ``trove-classifiers`` to ensure proper validation. "
161
                "Meanwhile a list of classifiers will be downloaded from PyPI."
162
            )
163
            _logger.debug(msg)
8✔
164
            try:
8✔
165
                self.downloaded = set(_download_classifiers().splitlines())
8✔
166
            except Exception:
8✔
167
                self.downloaded = False
8✔
168
                _logger.debug("Problem with download, skipping validation")
8✔
169
                return True
8✔
170

171
        return value in self.downloaded or value.lower().startswith("private ::")
8✔
172

173

174
try:
8✔
175
    from trove_classifiers import classifiers as _trove_classifiers
8✔
176

177
    def trove_classifier(value: str) -> bool:
8✔
178
        return value in _trove_classifiers or value.lower().startswith("private ::")
8✔
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:
8✔
189
    from urllib.parse import urlparse
8✔
190

191
    try:
8✔
192
        parts = urlparse(value)
8✔
193
        if not parts.scheme:
8✔
194
            _logger.warning(
8✔
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):
8✔
200
                parts = urlparse(f"http://{value}")
8✔
201

202
        return bool(parts.scheme and parts.netloc)
8✔
203
    except Exception:
8✔
204
        return False
8✔
205

206

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

215

216
def python_identifier(value: str) -> bool:
8✔
217
    return value.isidentifier()
8✔
218

219

220
def python_qualified_identifier(value: str) -> bool:
8✔
221
    if value.startswith(".") or value.endswith("."):
8✔
222
        return False
8✔
223
    return all(python_identifier(m) for m in value.split("."))
8✔
224

225

226
def python_module_name(value: str) -> bool:
8✔
227
    return python_qualified_identifier(value)
8✔
228

229

230
def python_entrypoint_group(value: str) -> bool:
8✔
231
    return ENTRYPOINT_GROUP_REGEX.match(value) is not None
8✔
232

233

234
def python_entrypoint_name(value: str) -> bool:
8✔
235
    if not ENTRYPOINT_REGEX.match(value):
8✔
236
        return False
8✔
237
    if not RECOMMEDED_ENTRYPOINT_REGEX.match(value):
8✔
238
        msg = f"Entry point `{value}` does not follow recommended pattern: "
8✔
239
        msg += RECOMMEDED_ENTRYPOINT_PATTERN
8✔
240
        _logger.warning(msg)
8✔
241
    return True
8✔
242

243

244
def python_entrypoint_reference(value: str) -> bool:
8✔
245
    module, _, rest = value.partition(":")
8✔
246
    if "[" in rest:
8✔
247
        obj, _, extras_ = rest.partition("[")
8✔
248
        if extras_.strip()[-1] != "]":
8✔
249
            return False
8✔
250
        extras = (x.strip() for x in extras_.strip(string.whitespace + "[]").split(","))
8✔
251
        if not all(pep508_identifier(e) for e in extras):
8✔
252
            return False
8✔
253
        _logger.warning(f"`{value}` - using extras for entry points is not recommended")
8✔
254
    else:
255
        obj = rest
8✔
256

257
    module_parts = module.split(".")
8✔
258
    identifiers = _chain(module_parts, obj.split(".")) if rest else module_parts
8✔
259
    return all(python_identifier(i.strip()) for i in identifiers)
8✔
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