check-python-versions 0.23.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- check_python_versions/__init__.py +16 -0
- check_python_versions/__main__.py +7 -0
- check_python_versions/cli.py +479 -0
- check_python_versions/parsers/__init__.py +1 -0
- check_python_versions/parsers/classifiers.py +81 -0
- check_python_versions/parsers/ini.py +81 -0
- check_python_versions/parsers/poetry_version_spec.py +276 -0
- check_python_versions/parsers/python.py +262 -0
- check_python_versions/parsers/requires_python.py +239 -0
- check_python_versions/parsers/yaml.py +221 -0
- check_python_versions/sources/__init__.py +1 -0
- check_python_versions/sources/all.py +23 -0
- check_python_versions/sources/appveyor.py +191 -0
- check_python_versions/sources/base.py +87 -0
- check_python_versions/sources/github.py +162 -0
- check_python_versions/sources/manylinux.py +96 -0
- check_python_versions/sources/pyproject.py +244 -0
- check_python_versions/sources/setup_py.py +201 -0
- check_python_versions/sources/tox.py +265 -0
- check_python_versions/sources/travis.py +213 -0
- check_python_versions/utils.py +142 -0
- check_python_versions/versions.py +158 -0
- check_python_versions-0.23.0.dist-info/METADATA +434 -0
- check_python_versions-0.23.0.dist-info/RECORD +28 -0
- check_python_versions-0.23.0.dist-info/WHEEL +5 -0
- check_python_versions-0.23.0.dist-info/entry_points.txt +2 -0
- check_python_versions-0.23.0.dist-info/licenses/LICENSE +674 -0
- check_python_versions-0.23.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,239 @@
|
|
1
|
+
"""
|
2
|
+
Tools for manipulating requires-python PyPI classifiers.
|
3
|
+
"""
|
4
|
+
import re
|
5
|
+
from typing import Callable, Dict, List, Optional, Tuple, TypedDict, Union
|
6
|
+
|
7
|
+
from ..utils import warn
|
8
|
+
from ..versions import (
|
9
|
+
MAX_MINOR_FOR_MAJOR,
|
10
|
+
SortedVersionList,
|
11
|
+
Version,
|
12
|
+
VersionList,
|
13
|
+
)
|
14
|
+
|
15
|
+
|
16
|
+
def parse_python_requires(
|
17
|
+
s: str,
|
18
|
+
name: str = "python_requires",
|
19
|
+
*,
|
20
|
+
filename: str = "setup.py",
|
21
|
+
) -> Optional[SortedVersionList]:
|
22
|
+
"""Compute Python versions allowed by a python_requires expression."""
|
23
|
+
|
24
|
+
# https://www.python.org/dev/peps/pep-0440/#version-specifiers
|
25
|
+
rx = re.compile(r'^(~=|==|!=|<=|>=|<|>|===)\s*(\d+(?:\.\d+)*(?:\.\*)?)$')
|
26
|
+
|
27
|
+
class BadConstraint(Exception):
|
28
|
+
"""The version clause is ill-formed according to PEP 440."""
|
29
|
+
|
30
|
+
#
|
31
|
+
# This works as follows: we split the specifier on commas into a list
|
32
|
+
# of Constraints, each represented as a operator and a tuple of numbers
|
33
|
+
# with a possible trailing '*'. PEP 440 calls them "clauses".
|
34
|
+
#
|
35
|
+
|
36
|
+
Constraint = Tuple[Union[str, int], ...]
|
37
|
+
|
38
|
+
#
|
39
|
+
# The we look up a handler for each operartor. This handler takes a
|
40
|
+
# constraint and compiles it into a checker. A checker is a function
|
41
|
+
# that takes a Python version number as a 2-tuple and returns True if
|
42
|
+
# that version passes its constraint.
|
43
|
+
#
|
44
|
+
|
45
|
+
VersionTuple = Tuple[int, int]
|
46
|
+
CheckFn = Callable[[VersionTuple], bool]
|
47
|
+
HandlerFn = Callable[[Constraint], CheckFn]
|
48
|
+
|
49
|
+
#
|
50
|
+
# Here we're defining the handlers for all the operators
|
51
|
+
#
|
52
|
+
|
53
|
+
handlers: Dict[str, HandlerFn] = {}
|
54
|
+
|
55
|
+
def handler(operator: str) -> Callable[[HandlerFn], None]:
|
56
|
+
def decorator(fn: HandlerFn) -> None:
|
57
|
+
handlers[operator] = fn
|
58
|
+
return decorator
|
59
|
+
|
60
|
+
#
|
61
|
+
# We are not doing a strict PEP-440 implementation here because if
|
62
|
+
# python_requires allows, say, Python 2.7.16, then we want to report that
|
63
|
+
# as Python 2.7. In each handler ``candidate`` is a two-tuple (X, Y)
|
64
|
+
# that represents any Python version between X.Y.0 and X.Y.<whatever>.
|
65
|
+
#
|
66
|
+
|
67
|
+
@handler('~=')
|
68
|
+
def compatible_version(constraint: Constraint) -> CheckFn:
|
69
|
+
"""~= X.Y more or less means >= X.Y and == X.Y.*"""
|
70
|
+
if len(constraint) < 2:
|
71
|
+
raise BadConstraint('~= requires a version with at least one dot')
|
72
|
+
if constraint[-1] == '*':
|
73
|
+
raise BadConstraint('~= does not allow a .*')
|
74
|
+
return lambda candidate: candidate == constraint[:2]
|
75
|
+
|
76
|
+
@handler('==')
|
77
|
+
def matching_version(constraint: Constraint) -> CheckFn:
|
78
|
+
"""== X.Y means X.Y, no more, no less; == X[.Y].* is allowed."""
|
79
|
+
# we know len(candidate) == 2
|
80
|
+
if len(constraint) == 2 and constraint[-1] == '*':
|
81
|
+
return lambda candidate: candidate[0] == constraint[0]
|
82
|
+
elif len(constraint) == 1:
|
83
|
+
# == X should imply Python X.0
|
84
|
+
return lambda candidate: candidate == constraint + (0,)
|
85
|
+
else:
|
86
|
+
# == X.Y.* and == X.Y.Z both imply Python X.Y
|
87
|
+
return lambda candidate: candidate == constraint[:2]
|
88
|
+
|
89
|
+
@handler('!=')
|
90
|
+
def excluded_version(constraint: Constraint) -> CheckFn:
|
91
|
+
"""!= X.Y is the opposite of == X.Y."""
|
92
|
+
# we know len(candidate) == 2
|
93
|
+
if constraint[-1] != '*':
|
94
|
+
# != X or != X.Y or != X.Y.Z all are meaningless for us, because
|
95
|
+
# there exists some W != Z where we allow X.Y.W and thus allow
|
96
|
+
# Python X.Y.
|
97
|
+
return lambda candidate: True
|
98
|
+
elif len(constraint) == 2:
|
99
|
+
# != X.* excludes the entirety of a major version
|
100
|
+
return lambda candidate: candidate[0] != constraint[0]
|
101
|
+
else:
|
102
|
+
# != X.Y.* excludes one particular minor version X.Y,
|
103
|
+
# != X.Y.Z.* does not exclude anything, but it's fine,
|
104
|
+
# len(candidate) != len(constraint[:-1] so it'll be equivalent to
|
105
|
+
# True anyway.
|
106
|
+
return lambda candidate: candidate != constraint[:-1]
|
107
|
+
|
108
|
+
@handler('>=')
|
109
|
+
def greater_or_equal_version(constraint: Constraint) -> CheckFn:
|
110
|
+
""">= X.Y allows X.Y.* or X.(Y+n).*, or (X+n).*."""
|
111
|
+
if constraint[-1] == '*':
|
112
|
+
raise BadConstraint('>= does not allow a .*')
|
113
|
+
# >= X, >= X.Y, >= X.Y.Z all work out nicely because in Python
|
114
|
+
# (3, 0) >= (3,)
|
115
|
+
return lambda candidate: candidate >= constraint[:2]
|
116
|
+
|
117
|
+
@handler('<=')
|
118
|
+
def lesser_or_equal_version(constraint: Constraint) -> CheckFn:
|
119
|
+
"""<= X.Y is the opposite of > X.Y."""
|
120
|
+
if constraint[-1] == '*':
|
121
|
+
raise BadConstraint('<= does not allow a .*')
|
122
|
+
if len(constraint) == 1:
|
123
|
+
# <= X allows up to X.0
|
124
|
+
return lambda candidate: candidate <= constraint + (0,)
|
125
|
+
else:
|
126
|
+
# <= X.Y[.Z] allows up to X.Y
|
127
|
+
return lambda candidate: candidate <= constraint
|
128
|
+
|
129
|
+
@handler('>')
|
130
|
+
def greater_version(constraint: Constraint) -> CheckFn:
|
131
|
+
"""> X.Y is equivalent to >= X.Y and != X.Y, I think."""
|
132
|
+
if constraint[-1] == '*':
|
133
|
+
raise BadConstraint('> does not allow a .*')
|
134
|
+
if len(constraint) == 1:
|
135
|
+
# > X allows X+1.0 etc
|
136
|
+
return lambda candidate: candidate[:1] > constraint
|
137
|
+
elif len(constraint) == 2:
|
138
|
+
# > X.Y allows X.Y+1 etc
|
139
|
+
return lambda candidate: candidate > constraint
|
140
|
+
else:
|
141
|
+
# > X.Y.Z allows X.Y
|
142
|
+
return lambda candidate: candidate >= constraint[:2]
|
143
|
+
|
144
|
+
@handler('<')
|
145
|
+
def lesser_version(constraint: Constraint) -> CheckFn:
|
146
|
+
"""< X.Y is equivalent to <= X.Y and != X.Y, I think."""
|
147
|
+
if constraint[-1] == '*':
|
148
|
+
raise BadConstraint('< does not allow a .*')
|
149
|
+
# < X, < X.Y, < X.Y.Z all work out nicely because in Python
|
150
|
+
# (3, 0) > (3,), (3, 0) == (3, 0) and (3, 0) < (3, 0, 1)
|
151
|
+
return lambda candidate: candidate < constraint
|
152
|
+
|
153
|
+
@handler('===')
|
154
|
+
def arbitrary_version(constraint: Constraint) -> CheckFn:
|
155
|
+
"""=== X.Y means X.Y, without any zero padding etc."""
|
156
|
+
if constraint[-1] == '*':
|
157
|
+
raise BadConstraint('=== does not allow a .*')
|
158
|
+
# === X does not allow anything
|
159
|
+
# === X.Y throws me into confusion; will pip compare Python's X.Y.Z ===
|
160
|
+
# X.Y and reject all possible values of Z?
|
161
|
+
# === X.Y.Z allows X.Y
|
162
|
+
return lambda candidate: candidate == constraint[:2]
|
163
|
+
|
164
|
+
#
|
165
|
+
# And now we can do what we planned: split and compile the constraints
|
166
|
+
# into checkers (which I also call "constraints", for maximum confusion).
|
167
|
+
#
|
168
|
+
|
169
|
+
constraints: List[CheckFn] = []
|
170
|
+
for specifier in map(str.strip, s.split(',')):
|
171
|
+
m = rx.match(specifier)
|
172
|
+
if not m:
|
173
|
+
warn(f'Bad {name} specifier in {filename}: {specifier}')
|
174
|
+
continue
|
175
|
+
op, arg = m.groups()
|
176
|
+
ver: Constraint = tuple(
|
177
|
+
int(segment) if segment != '*' else segment
|
178
|
+
for segment in arg.split('.')
|
179
|
+
)
|
180
|
+
try:
|
181
|
+
constraints.append(handlers[op](ver))
|
182
|
+
except BadConstraint as error:
|
183
|
+
warn(f'Bad {name} specifier in {filename}: {specifier} ({error})')
|
184
|
+
|
185
|
+
if not constraints:
|
186
|
+
return None
|
187
|
+
|
188
|
+
#
|
189
|
+
# And now we can check all the existing Python versions we know about
|
190
|
+
# and list those that pass all the requirements.
|
191
|
+
#
|
192
|
+
|
193
|
+
versions = []
|
194
|
+
for major in sorted(MAX_MINOR_FOR_MAJOR):
|
195
|
+
for minor in range(0, MAX_MINOR_FOR_MAJOR[major] + 1):
|
196
|
+
if all(constraint((major, minor)) for constraint in constraints):
|
197
|
+
versions.append(Version.from_string(f'{major}.{minor}'))
|
198
|
+
return versions
|
199
|
+
|
200
|
+
|
201
|
+
class PythonRequiresStyle(TypedDict):
|
202
|
+
comma: str
|
203
|
+
space: str
|
204
|
+
|
205
|
+
|
206
|
+
def detect_style(python_requires: str) -> PythonRequiresStyle:
|
207
|
+
"""Determine how a python_requires string was formatted.
|
208
|
+
|
209
|
+
The return value is a dict of kwargs that can be splatted
|
210
|
+
into compute_python_requires(..., **style).
|
211
|
+
"""
|
212
|
+
comma = ', '
|
213
|
+
if ',' in python_requires and ', ' not in python_requires:
|
214
|
+
comma = ','
|
215
|
+
space = ''
|
216
|
+
if '> ' in python_requires or '= ' in python_requires:
|
217
|
+
space = ' '
|
218
|
+
return dict(comma=comma, space=space)
|
219
|
+
|
220
|
+
|
221
|
+
def compute_python_requires(
|
222
|
+
new_versions: VersionList,
|
223
|
+
*,
|
224
|
+
comma: str = ', ',
|
225
|
+
space: str = '',
|
226
|
+
) -> str:
|
227
|
+
"""Compute a value for python_requires that matches a set of versions."""
|
228
|
+
new_versions = set(new_versions)
|
229
|
+
latest_python = Version(major=3, minor=MAX_MINOR_FOR_MAJOR[3])
|
230
|
+
if len(new_versions) == 1 and new_versions != {latest_python}:
|
231
|
+
return f'=={space}{new_versions.pop()}.*'
|
232
|
+
min_version = min(new_versions)
|
233
|
+
specifiers = [f'>={space}{min_version}']
|
234
|
+
for major in sorted(MAX_MINOR_FOR_MAJOR):
|
235
|
+
for minor in range(0, MAX_MINOR_FOR_MAJOR[major] + 1):
|
236
|
+
ver = Version.from_string(f'{major}.{minor}')
|
237
|
+
if ver >= min_version and ver not in new_versions:
|
238
|
+
specifiers.append(f'!={space}{ver}.*')
|
239
|
+
return comma.join(specifiers)
|
@@ -0,0 +1,221 @@
|
|
1
|
+
"""
|
2
|
+
Tools for manipulating YAML files.
|
3
|
+
|
4
|
+
I want to preserve formatting and comments, therefore I cannot use a standard
|
5
|
+
YAML parser and serializer.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import string
|
9
|
+
from typing import Any, Callable, Dict, List, Optional
|
10
|
+
|
11
|
+
from ..utils import FileLines, OneOrMore, OneOrTuple, warn
|
12
|
+
|
13
|
+
|
14
|
+
def quote_string(value: str, quote_style: str = '') -> str:
|
15
|
+
"""Convert a string value to a YAML string literal."""
|
16
|
+
# Because I don't want to deal with quoting, I'll require all values
|
17
|
+
# to contain only safe characters (i.e. no ' or " or \). This is fine
|
18
|
+
# because the only thing I want to quote is version numbers
|
19
|
+
safe_chars = string.ascii_letters + string.digits + ".-"
|
20
|
+
assert all(
|
21
|
+
c in safe_chars for c in value
|
22
|
+
), f'{value!r} has unexpected characters'
|
23
|
+
try:
|
24
|
+
# 3.10 in yaml evaluates to 3.1 (a float), not '3.10' (a string)
|
25
|
+
if str(float(value)) != value:
|
26
|
+
quote_style = '"'
|
27
|
+
except ValueError:
|
28
|
+
pass
|
29
|
+
if quote_style:
|
30
|
+
assert quote_style not in value
|
31
|
+
return f'{quote_style}{value}{quote_style}'
|
32
|
+
|
33
|
+
|
34
|
+
def update_yaml_list(
|
35
|
+
orig_lines: FileLines,
|
36
|
+
key: OneOrTuple[str],
|
37
|
+
new_value: List[Any],
|
38
|
+
*,
|
39
|
+
filename: str,
|
40
|
+
keep: Optional[Callable[[str], bool]] = None,
|
41
|
+
replacements: Optional[Dict[str, str]] = None,
|
42
|
+
) -> FileLines:
|
43
|
+
"""Update a list of values in a YAML document.
|
44
|
+
|
45
|
+
The document is represented as a list of lines (``orig_lines``), because
|
46
|
+
we want to preserve the exact formatting including comments.
|
47
|
+
|
48
|
+
``key`` is a tuple that represents the traversal path from the root of
|
49
|
+
the document. As a special case it can be a string instead of a 1-tuple
|
50
|
+
for top-level keys.
|
51
|
+
|
52
|
+
The new value of the list will consist of ``new_value``, plus whatever
|
53
|
+
old values need to be kept according to the ``keep`` callback. Any of the
|
54
|
+
kept old values will also be optionally replaced with a replacement
|
55
|
+
from the ``replacements`` dict.
|
56
|
+
|
57
|
+
No YAML decoding is done for old values passed to ``keep()`` or
|
58
|
+
``replacements.get()``.
|
59
|
+
|
60
|
+
No YAML escaping or formatting is done for new values or replacements.
|
61
|
+
|
62
|
+
``filename`` is used for error reporting.
|
63
|
+
|
64
|
+
Returns an updated list of lines.
|
65
|
+
"""
|
66
|
+
if not isinstance(key, tuple):
|
67
|
+
key = (key,)
|
68
|
+
|
69
|
+
lines = iter(enumerate(orig_lines))
|
70
|
+
current = 0
|
71
|
+
indents = [0]
|
72
|
+
for n, line in lines:
|
73
|
+
stripped = line.lstrip()
|
74
|
+
if not stripped or stripped.startswith('#'):
|
75
|
+
continue
|
76
|
+
indent = len(line) - len(stripped)
|
77
|
+
if current >= len(indents):
|
78
|
+
indents.append(indent)
|
79
|
+
elif indent > indents[current]:
|
80
|
+
continue
|
81
|
+
else:
|
82
|
+
while current > 0 and indent < indents[current]:
|
83
|
+
del indents[current]
|
84
|
+
current -= 1
|
85
|
+
if stripped.startswith(f'{key[current]}:'):
|
86
|
+
current += 1
|
87
|
+
if current == len(key):
|
88
|
+
break
|
89
|
+
else:
|
90
|
+
warn(f'Did not find {".".join(key)}: setting in {filename}')
|
91
|
+
return orig_lines
|
92
|
+
|
93
|
+
start = n
|
94
|
+
end = n + 1
|
95
|
+
indent = 2
|
96
|
+
list_indent = None
|
97
|
+
keep_before: List[str] = []
|
98
|
+
keep_after: List[str] = []
|
99
|
+
lines_to_keep = keep_before
|
100
|
+
kept_last: Optional[bool] = False
|
101
|
+
for n, line in lines:
|
102
|
+
stripped = line.lstrip()
|
103
|
+
line_indent = len(line) - len(stripped)
|
104
|
+
if list_indent is None and stripped.startswith('- '):
|
105
|
+
list_indent = line_indent
|
106
|
+
if stripped.startswith('- ') and line_indent == list_indent:
|
107
|
+
indent = line_indent
|
108
|
+
end = n + 1
|
109
|
+
value = stripped[2:].strip()
|
110
|
+
kept_last = keep and keep(value)
|
111
|
+
if kept_last:
|
112
|
+
if replacements and value in replacements:
|
113
|
+
lines_to_keep.append(
|
114
|
+
f"{' ' * indent}- {replacements[value]}\n"
|
115
|
+
)
|
116
|
+
else:
|
117
|
+
lines_to_keep.append(line)
|
118
|
+
lines_to_keep = keep_after
|
119
|
+
elif stripped.startswith('#'):
|
120
|
+
lines_to_keep.append(line)
|
121
|
+
end = n + 1
|
122
|
+
elif line_indent > indent:
|
123
|
+
if kept_last:
|
124
|
+
lines_to_keep.append(line)
|
125
|
+
end = n + 1
|
126
|
+
elif line != '\n':
|
127
|
+
break
|
128
|
+
|
129
|
+
new_lines = orig_lines[:start] + [
|
130
|
+
f"{' ' * indents[-1]}{key[-1]}:\n"
|
131
|
+
] + keep_before + [
|
132
|
+
f"{' ' * indent}- {value}\n"
|
133
|
+
for value in new_value
|
134
|
+
] + keep_after + orig_lines[end:]
|
135
|
+
return new_lines
|
136
|
+
|
137
|
+
|
138
|
+
def drop_yaml_node(
|
139
|
+
orig_lines: FileLines,
|
140
|
+
key: str,
|
141
|
+
*,
|
142
|
+
filename: str,
|
143
|
+
) -> FileLines:
|
144
|
+
"""Drop a value from a YAML document.
|
145
|
+
|
146
|
+
The document is represented as a list of lines (``orig_lines``), because
|
147
|
+
we want to preserve the exact formatting including comments.
|
148
|
+
|
149
|
+
``key`` is a string. Currently only top-level nodes can be dropped.
|
150
|
+
|
151
|
+
``filename`` is used for error reporting.
|
152
|
+
|
153
|
+
It is not an error if ``key`` is not present in the document. In this
|
154
|
+
case ``orig_lines`` is returned unmodified.
|
155
|
+
|
156
|
+
Returns an updated list of lines.
|
157
|
+
"""
|
158
|
+
lines = iter(enumerate(orig_lines))
|
159
|
+
where = None
|
160
|
+
for n, line in lines:
|
161
|
+
if line.startswith(f'{key}:'):
|
162
|
+
if where is not None:
|
163
|
+
warn(
|
164
|
+
f"Duplicate {key}: setting in {filename}"
|
165
|
+
f" (lines {where + 1} and {n + 1})"
|
166
|
+
)
|
167
|
+
where = n
|
168
|
+
if where is None:
|
169
|
+
return orig_lines
|
170
|
+
|
171
|
+
lines = iter(enumerate(orig_lines[where + 1:], where + 1))
|
172
|
+
|
173
|
+
start = where
|
174
|
+
end = start + 1
|
175
|
+
for n, line in lines:
|
176
|
+
if line and line[0] != ' ':
|
177
|
+
break
|
178
|
+
else:
|
179
|
+
end = n + 1
|
180
|
+
new_lines = orig_lines[:start] + orig_lines[end:]
|
181
|
+
|
182
|
+
return new_lines
|
183
|
+
|
184
|
+
|
185
|
+
def add_yaml_node(
|
186
|
+
orig_lines: FileLines,
|
187
|
+
key: str,
|
188
|
+
value: str,
|
189
|
+
*,
|
190
|
+
before: Optional[OneOrMore[str]] = None,
|
191
|
+
) -> FileLines:
|
192
|
+
"""Add a value to a YAML document.
|
193
|
+
|
194
|
+
The document is represented as a list of lines (``orig_lines``), because
|
195
|
+
we want to preserve the exact formatting including comments.
|
196
|
+
|
197
|
+
``key`` is a string. Currently only top-level nodes can be added.
|
198
|
+
|
199
|
+
``value`` is the new value, as a string. No YAML escaping or formatting
|
200
|
+
is done.
|
201
|
+
|
202
|
+
``before`` can specify a key or a set of keys. If specified, the new
|
203
|
+
key will be added before the first of existing keys from this set.
|
204
|
+
|
205
|
+
Returns an updated list of lines.
|
206
|
+
"""
|
207
|
+
lines = iter(enumerate(orig_lines))
|
208
|
+
where = len(orig_lines)
|
209
|
+
if before:
|
210
|
+
if isinstance(before, str):
|
211
|
+
before = (before, )
|
212
|
+
lines = iter(enumerate(orig_lines))
|
213
|
+
for n, line in lines:
|
214
|
+
if any(line == f'{key}:\n' for key in before):
|
215
|
+
where = n
|
216
|
+
break
|
217
|
+
|
218
|
+
new_lines = orig_lines[:where] + [
|
219
|
+
f'{key}: {value}\n'
|
220
|
+
] + orig_lines[where:]
|
221
|
+
return new_lines
|
@@ -0,0 +1 @@
|
|
1
|
+
"""Higher-level parsers for various file formats."""
|
@@ -0,0 +1,23 @@
|
|
1
|
+
from .appveyor import Appveyor
|
2
|
+
from .github import GitHubActions
|
3
|
+
from .manylinux import Manylinux
|
4
|
+
from .pyproject import PyProject, PyProjectPythonRequires
|
5
|
+
from .setup_py import SetupClassifiers, SetupPythonRequires
|
6
|
+
from .tox import Tox
|
7
|
+
from .travis import Travis
|
8
|
+
|
9
|
+
|
10
|
+
# The order here is only mildly important: it's used for presentation.
|
11
|
+
# Note that SetupPythonRequires.title assumes it's included right after
|
12
|
+
# SetupClassifiers!
|
13
|
+
ALL_SOURCES = [
|
14
|
+
SetupClassifiers,
|
15
|
+
SetupPythonRequires,
|
16
|
+
PyProject,
|
17
|
+
PyProjectPythonRequires,
|
18
|
+
Tox,
|
19
|
+
Travis,
|
20
|
+
GitHubActions,
|
21
|
+
Appveyor,
|
22
|
+
Manylinux,
|
23
|
+
]
|
@@ -0,0 +1,191 @@
|
|
1
|
+
"""
|
2
|
+
Support for Appveyor.
|
3
|
+
|
4
|
+
Appveyor is a hosted Continuous Integration solution that can be configured
|
5
|
+
by dropping a file named ``appveyor.yml`` into your source repository.
|
6
|
+
|
7
|
+
Appveyor can be configured through a web form as well, but
|
8
|
+
check-python-manifest does not support checking that.
|
9
|
+
|
10
|
+
The aforementioned web form can specify an alternative filename, but
|
11
|
+
check-python-manifest does not support checking that.
|
12
|
+
|
13
|
+
Appveyor does not directly support specifying Python interpreter versions,
|
14
|
+
so most projects that test multiple Python versions do so by specifing the
|
15
|
+
desired Python version in an environment variable.
|
16
|
+
|
17
|
+
check-python-versions assumes this variable is called PYTHON and has either
|
18
|
+
a Python version number, or the path to a Python installation
|
19
|
+
("C:\\PythonX.Y").
|
20
|
+
|
21
|
+
Alternatively, check-python-version looks for TOXENV, which lists names
|
22
|
+
of Tox environments (pyXY).
|
23
|
+
"""
|
24
|
+
|
25
|
+
import ast
|
26
|
+
from io import StringIO
|
27
|
+
from typing import Optional, Set, cast
|
28
|
+
|
29
|
+
import yaml
|
30
|
+
|
31
|
+
from .base import Source
|
32
|
+
from .tox import parse_envlist, tox_env_to_py_version
|
33
|
+
from ..parsers.yaml import update_yaml_list
|
34
|
+
from ..utils import FileLines, FileOrFilename, open_file, warn
|
35
|
+
from ..versions import SortedVersionList, Version, VersionList
|
36
|
+
|
37
|
+
|
38
|
+
APPVEYOR_YML = 'appveyor.yml'
|
39
|
+
|
40
|
+
|
41
|
+
def get_appveyor_yml_python_versions(
|
42
|
+
filename: FileOrFilename = APPVEYOR_YML,
|
43
|
+
) -> SortedVersionList:
|
44
|
+
"""Extract supported Python versions from appveyor.yml."""
|
45
|
+
|
46
|
+
with open_file(filename) as fp:
|
47
|
+
conf = yaml.safe_load(fp)
|
48
|
+
# There's more than one way of doing this, I'm setting %PYTHON% to
|
49
|
+
# the directory that has a Python interpreter (C:\PythonXY)
|
50
|
+
versions = []
|
51
|
+
for env in conf['environment']['matrix']:
|
52
|
+
for var, value in env.items():
|
53
|
+
if var.lower() == 'python':
|
54
|
+
versions.append(appveyor_normalize_py_version(value))
|
55
|
+
elif var == 'TOXENV':
|
56
|
+
toxenvs = parse_envlist(value)
|
57
|
+
versions.extend(
|
58
|
+
tox_env_to_py_version(e)
|
59
|
+
for e in toxenvs if e.startswith('py'))
|
60
|
+
# The cast() is a workaround for https://github.com/python/mypy/issues/8526
|
61
|
+
return sorted(cast(Set[Version], set(versions) - {None}))
|
62
|
+
|
63
|
+
|
64
|
+
def appveyor_normalize_py_version(ver: str) -> Optional[Version]:
|
65
|
+
"""Determine Python version from PYTHON environment variable."""
|
66
|
+
ver = str(ver).lower().replace('\\', '/')
|
67
|
+
if ver.startswith('c:/python'):
|
68
|
+
ver = ver[len('c:/python'):]
|
69
|
+
if ver.endswith('/python.exe'):
|
70
|
+
ver = ver[:-len('/python.exe')]
|
71
|
+
elif ver.endswith('/'):
|
72
|
+
ver = ver[:-1]
|
73
|
+
if ver.endswith('-x64'):
|
74
|
+
ver = ver[:-len('-x64')]
|
75
|
+
if len(ver) >= 2 and ver[:2].isdigit():
|
76
|
+
return Version.from_string(f'{ver[0]}.{ver[1:]}')
|
77
|
+
else:
|
78
|
+
return None
|
79
|
+
|
80
|
+
|
81
|
+
def appveyor_detect_py_version_pattern(ver: str) -> Optional[str]:
|
82
|
+
"""Determine the format of the PYTHON environment variable.
|
83
|
+
|
84
|
+
Returns a format string suitable for formatting with placeholders
|
85
|
+
for major and minor version numbers.
|
86
|
+
"""
|
87
|
+
ver = str(ver)
|
88
|
+
pattern = '{}'
|
89
|
+
for prefix in 'c:\\python', 'c:/python':
|
90
|
+
if ver.lower().startswith(prefix):
|
91
|
+
pos = len(prefix)
|
92
|
+
prefix, ver = ver[:pos], ver[pos:]
|
93
|
+
pattern = pattern.format(f'{prefix}{{}}')
|
94
|
+
break
|
95
|
+
if ver.endswith('\\'):
|
96
|
+
ver = ver[:-1]
|
97
|
+
pattern = pattern.format('{}\\')
|
98
|
+
if ver.lower().endswith('-x64'):
|
99
|
+
pos = -len('-x64')
|
100
|
+
ver, suffix = ver[:pos], ver[pos:]
|
101
|
+
pattern = pattern.format(f'{{}}{suffix}')
|
102
|
+
if len(ver) >= 2 and ver[:2].isdigit():
|
103
|
+
return pattern.format('{}{}')
|
104
|
+
else:
|
105
|
+
return None
|
106
|
+
|
107
|
+
|
108
|
+
def escape(s: str) -> str:
|
109
|
+
"""Escape a string for embedding inside a double-quoted YAML string."""
|
110
|
+
return s.replace("\\", "\\\\").replace('"', '\\"')
|
111
|
+
|
112
|
+
|
113
|
+
def update_appveyor_yml_python_versions(
|
114
|
+
filename: FileOrFilename,
|
115
|
+
new_versions: VersionList,
|
116
|
+
) -> Optional[FileLines]:
|
117
|
+
"""Update supported Python versions in appveyor.yml.
|
118
|
+
|
119
|
+
Does not touch the file but returns a list of lines with new file contents.
|
120
|
+
"""
|
121
|
+
with open_file(filename) as fp:
|
122
|
+
orig_lines = fp.readlines()
|
123
|
+
fp.seek(0)
|
124
|
+
conf = yaml.safe_load(fp)
|
125
|
+
|
126
|
+
varname = 'PYTHON'
|
127
|
+
patterns = set()
|
128
|
+
for env in conf['environment']['matrix']:
|
129
|
+
for var, value in env.items():
|
130
|
+
if var.lower() == 'python':
|
131
|
+
varname = var
|
132
|
+
pattern = appveyor_detect_py_version_pattern(value)
|
133
|
+
if pattern is not None:
|
134
|
+
patterns.add(pattern)
|
135
|
+
break
|
136
|
+
|
137
|
+
if not patterns:
|
138
|
+
warn(f"Did not recognize any PYTHON environments in {fp.name}")
|
139
|
+
return orig_lines
|
140
|
+
|
141
|
+
quote = any(f'{varname}: "' in line for line in orig_lines)
|
142
|
+
|
143
|
+
new_pythons = [
|
144
|
+
pattern.format(ver.major, ver.minor)
|
145
|
+
for ver in new_versions
|
146
|
+
for pattern in sorted(patterns)
|
147
|
+
]
|
148
|
+
|
149
|
+
if quote:
|
150
|
+
new_environments = [
|
151
|
+
f'{varname}: "{escape(python)}"'
|
152
|
+
for python in new_pythons
|
153
|
+
]
|
154
|
+
else:
|
155
|
+
new_environments = [
|
156
|
+
f'{varname}: {python}'
|
157
|
+
for python in new_pythons
|
158
|
+
]
|
159
|
+
|
160
|
+
def keep_complicated(value: str) -> bool:
|
161
|
+
"""Determine if an environment matrix line should be preserved."""
|
162
|
+
if value.lower().startswith('python:'):
|
163
|
+
ver = value.partition(':')[-1].strip()
|
164
|
+
if ver.startswith('"'):
|
165
|
+
ver = ast.literal_eval(ver)
|
166
|
+
nver = appveyor_normalize_py_version(ver)
|
167
|
+
if nver is not None:
|
168
|
+
return False
|
169
|
+
elif value.startswith('{') and value.endswith('}'):
|
170
|
+
env = yaml.safe_load(StringIO(value))
|
171
|
+
for var, value in env.items():
|
172
|
+
if var.lower() == 'python':
|
173
|
+
nver = appveyor_normalize_py_version(value)
|
174
|
+
if nver is not None and nver not in new_versions:
|
175
|
+
return False
|
176
|
+
return True
|
177
|
+
|
178
|
+
new_lines = update_yaml_list(
|
179
|
+
orig_lines, ('environment', 'matrix'), new_environments,
|
180
|
+
keep=keep_complicated, filename=fp.name,
|
181
|
+
)
|
182
|
+
return new_lines
|
183
|
+
|
184
|
+
|
185
|
+
Appveyor = Source(
|
186
|
+
filename=APPVEYOR_YML,
|
187
|
+
extract=get_appveyor_yml_python_versions,
|
188
|
+
update=update_appveyor_yml_python_versions,
|
189
|
+
check_pypy_consistency=False,
|
190
|
+
has_upper_bound=True,
|
191
|
+
)
|