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,276 @@
|
|
1
|
+
"""
|
2
|
+
Tools for manipulating Poetry version constraints.
|
3
|
+
|
4
|
+
These are documented at
|
5
|
+
https://python-poetry.org/docs/dependency-specification/#version-constraints
|
6
|
+
"""
|
7
|
+
|
8
|
+
import re
|
9
|
+
from typing import Callable, Dict, List, Optional, Tuple, TypedDict, Union
|
10
|
+
|
11
|
+
from ..utils import warn
|
12
|
+
from ..versions import (
|
13
|
+
MAX_MINOR_FOR_MAJOR,
|
14
|
+
SortedVersionList,
|
15
|
+
Version,
|
16
|
+
VersionList,
|
17
|
+
)
|
18
|
+
|
19
|
+
|
20
|
+
def parse_poetry_version_constraint(
|
21
|
+
s: str,
|
22
|
+
name: str = "tool.poetry.dependencies.python",
|
23
|
+
*,
|
24
|
+
filename: str = 'pyproject.toml',
|
25
|
+
) -> Optional[SortedVersionList]:
|
26
|
+
"""Compute Python versions allowed by Poetry version constraints."""
|
27
|
+
|
28
|
+
rx = re.compile(r'^(|[~^]|==|!=|<=|>=|<|>)\s*(\d+(?:\.\d+)*(?:\.\*)?)$')
|
29
|
+
|
30
|
+
class BadConstraint(Exception):
|
31
|
+
"""The version clause is ill-formed."""
|
32
|
+
|
33
|
+
#
|
34
|
+
# This works as follows: we split the specifier on commas into a list
|
35
|
+
# of Constraints, each represented as a operator and a tuple of numbers
|
36
|
+
# with a possible trailing '*'. PEP 440 calls them "clauses".
|
37
|
+
#
|
38
|
+
|
39
|
+
Constraint = Tuple[Union[str, int], ...]
|
40
|
+
|
41
|
+
#
|
42
|
+
# The we look up a handler for each operartor. This handler takes a
|
43
|
+
# constraint and compiles it into a checker. A checker is a function
|
44
|
+
# that takes a Python version number as a 2-tuple and returns True if
|
45
|
+
# that version passes its constraint.
|
46
|
+
#
|
47
|
+
|
48
|
+
VersionTuple = Tuple[int, int]
|
49
|
+
CheckFn = Callable[[VersionTuple], bool]
|
50
|
+
HandlerFn = Callable[[Constraint], CheckFn]
|
51
|
+
|
52
|
+
#
|
53
|
+
# Here we're defining the handlers for all the operators
|
54
|
+
#
|
55
|
+
|
56
|
+
handlers: Dict[str, HandlerFn] = {}
|
57
|
+
|
58
|
+
def handler(operator: str) -> Callable[[HandlerFn], None]:
|
59
|
+
def decorator(fn: HandlerFn) -> None:
|
60
|
+
handlers[operator] = fn
|
61
|
+
return decorator
|
62
|
+
|
63
|
+
#
|
64
|
+
# We are not doing a strict version check here because if the spec says,
|
65
|
+
# e.g., Python 2.7.16, then we want to report that as Python 2.7. In each
|
66
|
+
# handler ``candidate`` is a two-tuple (X, Y) that represents any Python
|
67
|
+
# version between X.Y.0 and X.Y.<whatever>.
|
68
|
+
#
|
69
|
+
|
70
|
+
@handler('^')
|
71
|
+
def caret_version(constraint: Constraint) -> CheckFn:
|
72
|
+
"""^X.Y allows X.Y.* or X.(Y+n).*; ^X allows X.*."""
|
73
|
+
# Python version never have leading zeroes which allows us to simplify
|
74
|
+
# the spec interpretation -- we know that the first digit is always
|
75
|
+
# non-zero.
|
76
|
+
if constraint[-1] == '*':
|
77
|
+
raise BadConstraint('^ does not allow a .*')
|
78
|
+
return lambda candidate: (
|
79
|
+
candidate >= constraint[:2] and candidate[:1] <= constraint[:1])
|
80
|
+
|
81
|
+
@handler('~')
|
82
|
+
def tilde_version(constraint: Constraint) -> CheckFn:
|
83
|
+
"""~X.Y allows X.Y.*; ^X allows X.*."""
|
84
|
+
# Python version never have leading zeroes which allows us to simplify
|
85
|
+
# the spec interpretation -- we know that the first digit is always
|
86
|
+
# non-zero.
|
87
|
+
if constraint[-1] == '*':
|
88
|
+
raise BadConstraint('~ does not allow a .*')
|
89
|
+
elif len(constraint) == 1:
|
90
|
+
return lambda candidate: candidate[:1] == constraint[:2]
|
91
|
+
else:
|
92
|
+
return lambda candidate: candidate == constraint[:2]
|
93
|
+
|
94
|
+
@handler('')
|
95
|
+
def plain_version(constraint: Constraint) -> CheckFn:
|
96
|
+
"""Just X.Y means X.Y, no more, no less; X[.Y].* is allowed."""
|
97
|
+
# we know len(candidate) == 2
|
98
|
+
if len(constraint) == 2 and constraint[-1] == '*':
|
99
|
+
return lambda candidate: candidate[0] == constraint[0]
|
100
|
+
elif len(constraint) == 1:
|
101
|
+
# == X should imply Python X.0
|
102
|
+
return lambda candidate: candidate == constraint + (0,)
|
103
|
+
else:
|
104
|
+
# == X.Y.* and == X.Y.Z both imply Python X.Y
|
105
|
+
return lambda candidate: candidate == constraint[:2]
|
106
|
+
|
107
|
+
@handler('==')
|
108
|
+
def matching_version(constraint: Constraint) -> CheckFn:
|
109
|
+
"""== X.Y means X.Y, no more, no less; == X[.Y].* is allowed."""
|
110
|
+
# we know len(candidate) == 2
|
111
|
+
if len(constraint) == 2 and constraint[-1] == '*':
|
112
|
+
return lambda candidate: candidate[0] == constraint[0]
|
113
|
+
elif len(constraint) == 1:
|
114
|
+
# == X should imply Python X.0
|
115
|
+
return lambda candidate: candidate == constraint + (0,)
|
116
|
+
else:
|
117
|
+
# == X.Y.* and == X.Y.Z both imply Python X.Y
|
118
|
+
return lambda candidate: candidate == constraint[:2]
|
119
|
+
|
120
|
+
@handler('!=')
|
121
|
+
def excluded_version(constraint: Constraint) -> CheckFn:
|
122
|
+
"""!= X.Y is the opposite of == X.Y."""
|
123
|
+
# we know len(candidate) == 2
|
124
|
+
if constraint[-1] != '*':
|
125
|
+
# != X or != X.Y or != X.Y.Z all are meaningless for us, because
|
126
|
+
# there exists some W != Z where we allow X.Y.W and thus allow
|
127
|
+
# Python X.Y.
|
128
|
+
return lambda candidate: True
|
129
|
+
elif len(constraint) == 2:
|
130
|
+
# != X.* excludes the entirety of a major version
|
131
|
+
return lambda candidate: candidate[0] != constraint[0]
|
132
|
+
else:
|
133
|
+
# != X.Y.* excludes one particular minor version X.Y,
|
134
|
+
# != X.Y.Z.* does not exclude anything, but it's fine,
|
135
|
+
# len(candidate) != len(constraint[:-1] so it'll be equivalent to
|
136
|
+
# True anyway.
|
137
|
+
return lambda candidate: candidate != constraint[:-1]
|
138
|
+
|
139
|
+
@handler('>=')
|
140
|
+
def greater_or_equal_version(constraint: Constraint) -> CheckFn:
|
141
|
+
""">= X.Y allows X.Y.* or X.(Y+n).*, or (X+n).*."""
|
142
|
+
if constraint[-1] == '*':
|
143
|
+
raise BadConstraint('>= does not allow a .*')
|
144
|
+
# >= X, >= X.Y, >= X.Y.Z all work out nicely because in Python
|
145
|
+
# (3, 0) >= (3,)
|
146
|
+
return lambda candidate: candidate >= constraint[:2]
|
147
|
+
|
148
|
+
@handler('<=')
|
149
|
+
def lesser_or_equal_version(constraint: Constraint) -> CheckFn:
|
150
|
+
"""<= X.Y is the opposite of > X.Y."""
|
151
|
+
if constraint[-1] == '*':
|
152
|
+
raise BadConstraint('<= does not allow a .*')
|
153
|
+
if len(constraint) == 1:
|
154
|
+
# <= X allows up to X.0
|
155
|
+
return lambda candidate: candidate <= constraint + (0,)
|
156
|
+
else:
|
157
|
+
# <= X.Y[.Z] allows up to X.Y
|
158
|
+
return lambda candidate: candidate <= constraint
|
159
|
+
|
160
|
+
@handler('>')
|
161
|
+
def greater_version(constraint: Constraint) -> CheckFn:
|
162
|
+
"""> X.Y is equivalent to >= X.Y and != X.Y, I think."""
|
163
|
+
if constraint[-1] == '*':
|
164
|
+
raise BadConstraint('> does not allow a .*')
|
165
|
+
if len(constraint) == 1:
|
166
|
+
# > X allows X+1.0 etc
|
167
|
+
return lambda candidate: candidate[:1] > constraint
|
168
|
+
elif len(constraint) == 2:
|
169
|
+
# > X.Y allows X.Y+1 etc
|
170
|
+
return lambda candidate: candidate > constraint
|
171
|
+
else:
|
172
|
+
# > X.Y.Z allows X.Y
|
173
|
+
return lambda candidate: candidate >= constraint[:2]
|
174
|
+
|
175
|
+
@handler('<')
|
176
|
+
def lesser_version(constraint: Constraint) -> CheckFn:
|
177
|
+
"""< X.Y is equivalent to <= X.Y and != X.Y, I think."""
|
178
|
+
if constraint[-1] == '*':
|
179
|
+
raise BadConstraint('< does not allow a .*')
|
180
|
+
# < X, < X.Y, < X.Y.Z all work out nicely because in Python
|
181
|
+
# (3, 0) > (3,), (3, 0) == (3, 0) and (3, 0) < (3, 0, 1)
|
182
|
+
return lambda candidate: candidate < constraint
|
183
|
+
|
184
|
+
#
|
185
|
+
# And now we can do what we planned: split and compile the constraints
|
186
|
+
# into checkers (which I also call "constraints", for maximum confusion).
|
187
|
+
#
|
188
|
+
|
189
|
+
constraints: List[CheckFn] = []
|
190
|
+
for specifier in map(str.strip, s.split(',')):
|
191
|
+
m = rx.match(specifier)
|
192
|
+
if not m:
|
193
|
+
warn(f'Bad {name} specifier in {filename}: {specifier}')
|
194
|
+
continue
|
195
|
+
op, arg = m.groups()
|
196
|
+
ver: Constraint = tuple(
|
197
|
+
int(segment) if segment != '*' else segment
|
198
|
+
for segment in arg.split('.')
|
199
|
+
)
|
200
|
+
try:
|
201
|
+
constraints.append(handlers[op](ver))
|
202
|
+
except BadConstraint as error:
|
203
|
+
warn(f'Bad {name} specifier in {filename}: {specifier} ({error})')
|
204
|
+
|
205
|
+
if not constraints:
|
206
|
+
return None
|
207
|
+
|
208
|
+
#
|
209
|
+
# And now we can check all the existing Python versions we know about
|
210
|
+
# and list those that pass all the requirements.
|
211
|
+
#
|
212
|
+
|
213
|
+
versions = []
|
214
|
+
for major in sorted(MAX_MINOR_FOR_MAJOR):
|
215
|
+
for minor in range(0, MAX_MINOR_FOR_MAJOR[major] + 1):
|
216
|
+
if all(constraint((major, minor)) for constraint in constraints):
|
217
|
+
versions.append(Version.from_string(f'{major}.{minor}'))
|
218
|
+
return versions
|
219
|
+
|
220
|
+
|
221
|
+
class VersionSpecStyle(TypedDict):
|
222
|
+
prefer_caret_tilde: bool
|
223
|
+
comma: str
|
224
|
+
space: str
|
225
|
+
|
226
|
+
|
227
|
+
def detect_poetry_version_spec_style(spec: str) -> VersionSpecStyle:
|
228
|
+
"""Determine how a python_requires string was formatted.
|
229
|
+
|
230
|
+
The return value is a dict of kwargs that can be splatted
|
231
|
+
into compute_poetry_spec(..., **style).
|
232
|
+
"""
|
233
|
+
comma = ', '
|
234
|
+
if ',' in spec and ', ' not in spec:
|
235
|
+
comma = ','
|
236
|
+
space = ''
|
237
|
+
if '> ' in spec or '= ' in spec or '^ ' in spec or '~ ' in spec:
|
238
|
+
space = ' '
|
239
|
+
prefer_caret_tilde = '^' in spec or '~' in spec
|
240
|
+
return dict(
|
241
|
+
comma=comma,
|
242
|
+
space=space,
|
243
|
+
prefer_caret_tilde=prefer_caret_tilde,
|
244
|
+
)
|
245
|
+
|
246
|
+
|
247
|
+
def compute_poetry_spec(
|
248
|
+
new_versions: VersionList,
|
249
|
+
*,
|
250
|
+
comma: str = ", ",
|
251
|
+
space: str = "",
|
252
|
+
prefer_caret_tilde: bool = True,
|
253
|
+
) -> str:
|
254
|
+
"""Compute a constraint that matches a set of versions."""
|
255
|
+
new_versions = set(new_versions)
|
256
|
+
latest_python = Version(major=3, minor=MAX_MINOR_FOR_MAJOR[3])
|
257
|
+
if len(new_versions) == 1 and new_versions != {latest_python}:
|
258
|
+
if prefer_caret_tilde:
|
259
|
+
return f'~{space}{new_versions.pop()}'
|
260
|
+
else:
|
261
|
+
return f'{space}{new_versions.pop()}.*'
|
262
|
+
min_version = min(new_versions)
|
263
|
+
max_version = max(new_versions)
|
264
|
+
specifiers = [f'>={space}{min_version}']
|
265
|
+
for major in sorted(MAX_MINOR_FOR_MAJOR):
|
266
|
+
for minor in range(0, MAX_MINOR_FOR_MAJOR[major] + 1):
|
267
|
+
ver = Version.from_string(f'{major}.{minor}')
|
268
|
+
if ver > max_version:
|
269
|
+
if major == max_version.major:
|
270
|
+
specifiers.append(f'<{space}{ver}')
|
271
|
+
break
|
272
|
+
if ver >= min_version and ver not in new_versions:
|
273
|
+
specifiers.append(f'!={space}{ver}.*')
|
274
|
+
if len(specifiers) == 1 and prefer_caret_tilde:
|
275
|
+
specifiers = [f'^{space}{min_version}']
|
276
|
+
return comma.join(specifiers)
|
@@ -0,0 +1,262 @@
|
|
1
|
+
"""
|
2
|
+
Tools for manipulating Python files.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import ast
|
6
|
+
import re
|
7
|
+
import string
|
8
|
+
from typing import List, Optional, Tuple, Union
|
9
|
+
|
10
|
+
from ..utils import FileLines, OneOrMore, get_indent, warn
|
11
|
+
|
12
|
+
|
13
|
+
AstValue = Union[str, List[str], Tuple[str, ...]]
|
14
|
+
|
15
|
+
|
16
|
+
def to_literal(value: str, quote_style: str = '"') -> str:
|
17
|
+
"""Convert a string value to a Python string literal."""
|
18
|
+
# Because I don't want to deal with quoting, I'll require all values
|
19
|
+
# to contain only safe characters (i.e. no ' or " or \). Except some
|
20
|
+
# PyPI classifiers do include ' so I need to handle that at least.
|
21
|
+
# And python_requires uses all sorts of comparisons like ~= 3.7.*
|
22
|
+
safe_chars = string.ascii_letters + string.digits + " .:,-=><!~*()/+'#"
|
23
|
+
assert all(
|
24
|
+
c in safe_chars for c in value
|
25
|
+
), f'{value!r} has unexpected characters'
|
26
|
+
if quote_style == "'" and quote_style in value:
|
27
|
+
quote_style = '"'
|
28
|
+
assert quote_style not in value
|
29
|
+
return f'{quote_style}{value}{quote_style}'
|
30
|
+
|
31
|
+
|
32
|
+
def update_call_arg_in_source(
|
33
|
+
source_lines: FileLines,
|
34
|
+
function: OneOrMore[str],
|
35
|
+
keyword: str,
|
36
|
+
new_value: Union[str, List[str]],
|
37
|
+
*,
|
38
|
+
filename: str = 'setup.py',
|
39
|
+
) -> FileLines:
|
40
|
+
"""Update a function call statement in Python source file.
|
41
|
+
|
42
|
+
Finds the first function all, removes the old value passed to a named
|
43
|
+
keyword argument and replaces it with a new value.
|
44
|
+
|
45
|
+
Tries to preserve existing formatting.
|
46
|
+
|
47
|
+
Returns the updated source.
|
48
|
+
"""
|
49
|
+
if isinstance(function, str):
|
50
|
+
function = (function, )
|
51
|
+
lines = iter(enumerate(source_lines))
|
52
|
+
rx = re.compile(fr'^({"|".join(map(re.escape, function))})\s*\(')
|
53
|
+
for n, line in lines:
|
54
|
+
m = rx.match(line)
|
55
|
+
if m:
|
56
|
+
fname = m.group(1)
|
57
|
+
break
|
58
|
+
else:
|
59
|
+
warn(f'Did not find {function[0]}() call in {filename}')
|
60
|
+
return source_lines
|
61
|
+
eq = '='
|
62
|
+
rx = re.compile(
|
63
|
+
f'^(?P<indent>\\s*){re.escape(keyword)}(?P<eq>\\s*=\\s*)(?P<rest>.*)'
|
64
|
+
)
|
65
|
+
for n, line in lines:
|
66
|
+
m = rx.match(line)
|
67
|
+
if m:
|
68
|
+
first_match = m
|
69
|
+
eq = m.group('eq')
|
70
|
+
first_indent = m.group('indent')
|
71
|
+
break
|
72
|
+
else:
|
73
|
+
warn(f'Did not find {keyword}= argument in {fname}() call'
|
74
|
+
f' in {filename}')
|
75
|
+
return source_lines
|
76
|
+
|
77
|
+
quote_style = '"'
|
78
|
+
rest = first_match.group('rest')
|
79
|
+
joined = False
|
80
|
+
|
81
|
+
if isinstance(new_value, list):
|
82
|
+
start = n
|
83
|
+
indent = first_indent + ' ' * 4
|
84
|
+
if rest.startswith('[]'):
|
85
|
+
fix_closing_bracket = True
|
86
|
+
end = n + 1
|
87
|
+
else:
|
88
|
+
must_fix_indents = rest.rstrip() != '['
|
89
|
+
fix_closing_bracket = False
|
90
|
+
for n, line in lines:
|
91
|
+
stripped = line.lstrip()
|
92
|
+
if stripped.startswith(']'):
|
93
|
+
end = n
|
94
|
+
break
|
95
|
+
elif stripped:
|
96
|
+
if not must_fix_indents:
|
97
|
+
indent = get_indent(line)
|
98
|
+
if stripped[0] in ('"', "'"):
|
99
|
+
quote_style = stripped[0]
|
100
|
+
if line.rstrip().endswith('],'):
|
101
|
+
end = n + 1
|
102
|
+
fix_closing_bracket = True
|
103
|
+
break
|
104
|
+
else:
|
105
|
+
warn(
|
106
|
+
f'Did not understand {keyword}= formatting'
|
107
|
+
f' in {fname}() call in {filename}'
|
108
|
+
)
|
109
|
+
return source_lines
|
110
|
+
elif rest.endswith('.join(['):
|
111
|
+
joined = True
|
112
|
+
start = n
|
113
|
+
indent = first_indent + ' ' * 4
|
114
|
+
for n, line in lines:
|
115
|
+
stripped = line.lstrip()
|
116
|
+
if stripped.startswith(']'):
|
117
|
+
end = n + 1
|
118
|
+
fix_closing_bracket = True
|
119
|
+
break
|
120
|
+
else:
|
121
|
+
warn(
|
122
|
+
f'Did not understand {keyword}= formatting'
|
123
|
+
f' in {fname}() call in {filename}'
|
124
|
+
)
|
125
|
+
return source_lines
|
126
|
+
else:
|
127
|
+
start = n
|
128
|
+
end = n + 1
|
129
|
+
|
130
|
+
if isinstance(new_value, list):
|
131
|
+
return source_lines[:start] + [
|
132
|
+
f"{first_indent}{keyword}{eq}[\n"
|
133
|
+
] + [
|
134
|
+
f"{indent}{to_literal(value, quote_style)},\n"
|
135
|
+
for value in new_value
|
136
|
+
] + ([
|
137
|
+
f"{first_indent}],\n"
|
138
|
+
] if fix_closing_bracket else [
|
139
|
+
]) + source_lines[end:]
|
140
|
+
elif joined:
|
141
|
+
if rest.startswith("'"):
|
142
|
+
quote_style = "'"
|
143
|
+
comma = ', '
|
144
|
+
if comma not in new_value:
|
145
|
+
comma = ','
|
146
|
+
new_value = new_value.split(comma)
|
147
|
+
comma = to_literal(comma, quote_style)
|
148
|
+
return source_lines[:start] + [
|
149
|
+
f"{first_indent}{keyword}{eq}{comma}.join([\n"
|
150
|
+
] + [
|
151
|
+
f"{indent}{to_literal(value, quote_style)},\n"
|
152
|
+
for value in new_value
|
153
|
+
] + ([
|
154
|
+
f"{first_indent}]),\n"
|
155
|
+
] if fix_closing_bracket else [
|
156
|
+
]) + source_lines[end:]
|
157
|
+
else:
|
158
|
+
if rest.startswith("'"):
|
159
|
+
quote_style = "'"
|
160
|
+
new_value_quoted = to_literal(new_value, quote_style)
|
161
|
+
return source_lines[:start] + [
|
162
|
+
f"{first_indent}{keyword}{eq}{new_value_quoted},\n"
|
163
|
+
] + source_lines[end:]
|
164
|
+
|
165
|
+
|
166
|
+
def find_call_kwarg_in_ast(
|
167
|
+
tree: ast.AST,
|
168
|
+
funcname: OneOrMore[str],
|
169
|
+
keyword: str,
|
170
|
+
*,
|
171
|
+
filename: str,
|
172
|
+
) -> Optional[ast.AST]:
|
173
|
+
"""Find the value passed to a function call.
|
174
|
+
|
175
|
+
``filename`` is used for error reporting.
|
176
|
+
"""
|
177
|
+
if isinstance(funcname, str):
|
178
|
+
funcname = (funcname, )
|
179
|
+
for node in ast.walk(tree):
|
180
|
+
if (isinstance(node, ast.Call)
|
181
|
+
and any(name_matches(n, node.func) for n in funcname)):
|
182
|
+
for kwarg in node.keywords:
|
183
|
+
if kwarg.arg == keyword:
|
184
|
+
return kwarg.value
|
185
|
+
else:
|
186
|
+
return None
|
187
|
+
else:
|
188
|
+
warn(f'Could not find {funcname[0]}() call in {filename}')
|
189
|
+
return None
|
190
|
+
|
191
|
+
|
192
|
+
def name_matches(funcname: str, node: ast.AST) -> bool:
|
193
|
+
"""Check if the AST node refers to a funcion named `funcname`."""
|
194
|
+
while '.' in funcname:
|
195
|
+
funcname, dot, attr = funcname.rpartition('.')
|
196
|
+
if not isinstance(node, ast.Attribute) or node.attr != attr:
|
197
|
+
return False
|
198
|
+
node = node.value
|
199
|
+
return isinstance(node, ast.Name) and node.id == funcname
|
200
|
+
|
201
|
+
|
202
|
+
def eval_ast_node(
|
203
|
+
node: ast.AST,
|
204
|
+
keyword: str,
|
205
|
+
*,
|
206
|
+
filename: str = 'setup.py',
|
207
|
+
) -> Optional[AstValue]:
|
208
|
+
"""Partially evaluate an AST node.
|
209
|
+
|
210
|
+
``keyword`` is used for error reporting.
|
211
|
+
"""
|
212
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
213
|
+
return node.value
|
214
|
+
if isinstance(node, (ast.List, ast.Tuple)):
|
215
|
+
values: List[str] = []
|
216
|
+
warned = False
|
217
|
+
for element in node.elts:
|
218
|
+
try:
|
219
|
+
value = ast.literal_eval(element)
|
220
|
+
if not isinstance(value, str):
|
221
|
+
raise ValueError
|
222
|
+
except ValueError:
|
223
|
+
pass
|
224
|
+
else:
|
225
|
+
values.append(value)
|
226
|
+
continue
|
227
|
+
if not warned:
|
228
|
+
warn(f'Non-literal {keyword}= passed to setup() in {filename},'
|
229
|
+
' skipping some values')
|
230
|
+
warned = True
|
231
|
+
if warned and not values:
|
232
|
+
# no strings inside!
|
233
|
+
return None
|
234
|
+
if isinstance(node, ast.Tuple):
|
235
|
+
return tuple(values)
|
236
|
+
return values
|
237
|
+
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
238
|
+
and isinstance(node.func.value, ast.Constant)
|
239
|
+
and isinstance(node.func.value.value, str)
|
240
|
+
and node.func.attr == 'join'):
|
241
|
+
try:
|
242
|
+
return node.func.value.value.join(ast.literal_eval(node.args[0]))
|
243
|
+
except ValueError:
|
244
|
+
pass
|
245
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
246
|
+
left = eval_ast_node(node.left, keyword, filename=filename)
|
247
|
+
right = eval_ast_node(node.right, keyword, filename=filename)
|
248
|
+
if left is not None and right is not None:
|
249
|
+
if type(left) is not type(right):
|
250
|
+
warn(f'{keyword}= in {filename} is computed by adding'
|
251
|
+
' incompatible types:'
|
252
|
+
f' {type(left).__name__} and {type(right).__name__}')
|
253
|
+
return None
|
254
|
+
# Not sure how to make mypy accept this:
|
255
|
+
# https://github.com/python/mypy/issues/8831
|
256
|
+
return left + right # type: ignore
|
257
|
+
if left is None and right is not None:
|
258
|
+
return right
|
259
|
+
if left is not None and right is None:
|
260
|
+
return left
|
261
|
+
warn(f'Non-literal {keyword}= passed to setup() in {filename}')
|
262
|
+
return None
|