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,201 @@
|
|
1
|
+
"""
|
2
|
+
Support for setup.py.
|
3
|
+
|
4
|
+
There are two ways of declaring Python versions in a setup.py:
|
5
|
+
classifiers like
|
6
|
+
|
7
|
+
Programming Language :: Python :: 3.8
|
8
|
+
|
9
|
+
and python_requires.
|
10
|
+
|
11
|
+
check-python-versions supports both.
|
12
|
+
"""
|
13
|
+
|
14
|
+
import ast
|
15
|
+
import os
|
16
|
+
import shutil
|
17
|
+
import sys
|
18
|
+
from typing import List, Optional, TextIO, Union, cast
|
19
|
+
|
20
|
+
from .base import Source
|
21
|
+
from ..parsers.classifiers import (
|
22
|
+
get_versions_from_classifiers,
|
23
|
+
update_classifiers,
|
24
|
+
)
|
25
|
+
from ..parsers.python import (
|
26
|
+
AstValue,
|
27
|
+
eval_ast_node,
|
28
|
+
find_call_kwarg_in_ast,
|
29
|
+
update_call_arg_in_source,
|
30
|
+
)
|
31
|
+
from ..parsers.requires_python import (
|
32
|
+
compute_python_requires,
|
33
|
+
detect_style,
|
34
|
+
parse_python_requires,
|
35
|
+
)
|
36
|
+
from ..utils import (
|
37
|
+
FileLines,
|
38
|
+
FileOrFilename,
|
39
|
+
file_name,
|
40
|
+
is_file_object,
|
41
|
+
open_file,
|
42
|
+
pipe,
|
43
|
+
warn,
|
44
|
+
)
|
45
|
+
from ..versions import SortedVersionList
|
46
|
+
|
47
|
+
|
48
|
+
SETUP_PY = 'setup.py'
|
49
|
+
|
50
|
+
|
51
|
+
def get_supported_python_versions(
|
52
|
+
filename: FileOrFilename = SETUP_PY
|
53
|
+
) -> SortedVersionList:
|
54
|
+
"""Extract supported Python versions from classifiers in setup.py.
|
55
|
+
|
56
|
+
Note: if AST-based parsing fails, this falls back to executing
|
57
|
+
``python setup.py --classifiers``.
|
58
|
+
"""
|
59
|
+
classifiers = get_setup_py_keyword(filename, 'classifiers')
|
60
|
+
if classifiers is None and not is_file_object(filename):
|
61
|
+
# AST parsing is complicated
|
62
|
+
filename = cast(str, filename)
|
63
|
+
setup_py = os.path.basename(filename)
|
64
|
+
classifiers = pipe(find_python(), setup_py, "-q", "--classifiers",
|
65
|
+
cwd=os.path.dirname(filename)).splitlines()
|
66
|
+
if classifiers is None:
|
67
|
+
# Note: do not return None because setup.py is not an optional source!
|
68
|
+
# We want errors to show up if setup.py fails to declare Python
|
69
|
+
# versions in classifiers.
|
70
|
+
return []
|
71
|
+
if not isinstance(classifiers, (list, tuple)):
|
72
|
+
warn(f'The value passed to setup(classifiers=...) in {filename}'
|
73
|
+
' is not a list')
|
74
|
+
return []
|
75
|
+
return get_versions_from_classifiers(classifiers)
|
76
|
+
|
77
|
+
|
78
|
+
def get_python_requires(
|
79
|
+
setup_py: FileOrFilename = SETUP_PY,
|
80
|
+
) -> Optional[SortedVersionList]:
|
81
|
+
"""Extract supported Python versions from python_requires in setup.py."""
|
82
|
+
python_requires = get_setup_py_keyword(setup_py, 'python_requires')
|
83
|
+
if python_requires is None:
|
84
|
+
return None
|
85
|
+
if not isinstance(python_requires, str):
|
86
|
+
warn('The value passed to setup(python_requires=...)'
|
87
|
+
f' in {file_name(setup_py)} is not a string')
|
88
|
+
return None
|
89
|
+
return parse_python_requires(python_requires, filename=file_name(setup_py))
|
90
|
+
|
91
|
+
|
92
|
+
def update_supported_python_versions(
|
93
|
+
filename: FileOrFilename,
|
94
|
+
new_versions: SortedVersionList,
|
95
|
+
) -> Optional[FileLines]:
|
96
|
+
"""Update classifiers in a setup.py.
|
97
|
+
|
98
|
+
Does not touch the file but returns a list of lines with new file contents.
|
99
|
+
"""
|
100
|
+
classifiers = get_setup_py_keyword(filename, 'classifiers')
|
101
|
+
if classifiers is None:
|
102
|
+
return None
|
103
|
+
if not isinstance(classifiers, (list, tuple)):
|
104
|
+
warn('The value passed to setup(classifiers=...) in'
|
105
|
+
f' {file_name(filename)} is not a list')
|
106
|
+
return None
|
107
|
+
new_classifiers = update_classifiers(classifiers, new_versions)
|
108
|
+
return update_setup_py_keyword(filename, 'classifiers', new_classifiers)
|
109
|
+
|
110
|
+
|
111
|
+
def update_python_requires(
|
112
|
+
filename: FileOrFilename,
|
113
|
+
new_versions: SortedVersionList,
|
114
|
+
) -> Optional[FileLines]:
|
115
|
+
"""Update python_requires in a setup.py, if it's defined there.
|
116
|
+
|
117
|
+
Does not touch the file but returns a list of lines with new file contents.
|
118
|
+
"""
|
119
|
+
python_requires = get_setup_py_keyword(filename, 'python_requires')
|
120
|
+
if not isinstance(python_requires, str):
|
121
|
+
return None
|
122
|
+
style = detect_style(python_requires)
|
123
|
+
new_python_requires = compute_python_requires(new_versions, **style)
|
124
|
+
if is_file_object(filename):
|
125
|
+
# Make sure we can read it twice please.
|
126
|
+
# XXX: I don't like this.
|
127
|
+
cast(TextIO, filename).seek(0)
|
128
|
+
return update_setup_py_keyword(filename, 'python_requires',
|
129
|
+
new_python_requires)
|
130
|
+
|
131
|
+
|
132
|
+
def get_setup_py_keyword(
|
133
|
+
setup_py: FileOrFilename,
|
134
|
+
keyword: str,
|
135
|
+
) -> Optional[AstValue]:
|
136
|
+
"""Extract a value passed to setup() in a setup.py.
|
137
|
+
|
138
|
+
Parses the setup.py into an Abstact Syntax Tree and tries to figure out
|
139
|
+
what value was passed to the named keyword argument.
|
140
|
+
|
141
|
+
Returns None if the AST is too complicated to statically evaluate.
|
142
|
+
"""
|
143
|
+
with open_file(setup_py) as f:
|
144
|
+
try:
|
145
|
+
tree = ast.parse(f.read(), f.name)
|
146
|
+
except SyntaxError as error:
|
147
|
+
warn(f'Could not parse {f.name}: {error}')
|
148
|
+
return None
|
149
|
+
node = find_call_kwarg_in_ast(tree, ('setup', 'setuptools.setup'), keyword,
|
150
|
+
filename=f.name)
|
151
|
+
if node is None:
|
152
|
+
return None
|
153
|
+
return eval_ast_node(node, keyword, filename=f.name)
|
154
|
+
|
155
|
+
|
156
|
+
def update_setup_py_keyword(
|
157
|
+
setup_py: FileOrFilename,
|
158
|
+
keyword: str,
|
159
|
+
new_value: Union[str, List[str]],
|
160
|
+
) -> FileLines:
|
161
|
+
"""Update a value passed to setup() in a setup.py.
|
162
|
+
|
163
|
+
Does not touch the file but returns a list of lines with new file contents.
|
164
|
+
"""
|
165
|
+
with open_file(setup_py) as f:
|
166
|
+
lines = f.readlines()
|
167
|
+
new_lines = update_call_arg_in_source(lines, ('setup', 'setuptools.setup'),
|
168
|
+
keyword, new_value, filename=f.name)
|
169
|
+
return new_lines
|
170
|
+
|
171
|
+
|
172
|
+
def find_python() -> str:
|
173
|
+
"""Find a Python interpreter."""
|
174
|
+
# The reason I prefer python3 or python from $PATH over sys.executable is
|
175
|
+
# this gives the user some control. E.g. if the setup.py of the project
|
176
|
+
# requires some dependencies, the user could install them into a virtualenv
|
177
|
+
# and activate it.
|
178
|
+
if shutil.which('python3'):
|
179
|
+
return 'python3'
|
180
|
+
if shutil.which('python'):
|
181
|
+
return 'python'
|
182
|
+
return sys.executable
|
183
|
+
|
184
|
+
|
185
|
+
SetupClassifiers = Source(
|
186
|
+
title=SETUP_PY,
|
187
|
+
filename=SETUP_PY,
|
188
|
+
extract=get_supported_python_versions,
|
189
|
+
update=update_supported_python_versions,
|
190
|
+
check_pypy_consistency=True,
|
191
|
+
has_upper_bound=True,
|
192
|
+
)
|
193
|
+
|
194
|
+
SetupPythonRequires = Source(
|
195
|
+
title='- python_requires',
|
196
|
+
filename=SETUP_PY,
|
197
|
+
extract=get_python_requires,
|
198
|
+
update=update_python_requires,
|
199
|
+
check_pypy_consistency=False,
|
200
|
+
has_upper_bound=False, # TBH it might have one!
|
201
|
+
)
|
@@ -0,0 +1,265 @@
|
|
1
|
+
"""
|
2
|
+
Support for Tox.
|
3
|
+
|
4
|
+
Tox is an amazing tool for running tests (and other tasks) in virtualenvs.
|
5
|
+
You create a ``tox.ini``, tell it what Python versions you want to support
|
6
|
+
and how to run your test suite, and Tox does everything else: create the
|
7
|
+
right virtualenvs using the right Python interpreter versions, install your
|
8
|
+
packages, and run the test commands you specified.
|
9
|
+
|
10
|
+
The list of supported Python versions is extracted from ::
|
11
|
+
|
12
|
+
[tox]
|
13
|
+
envlist = py27,py36,py37,py38
|
14
|
+
|
15
|
+
"""
|
16
|
+
|
17
|
+
import configparser
|
18
|
+
import re
|
19
|
+
from typing import Iterable, List, Optional
|
20
|
+
|
21
|
+
from .base import Source
|
22
|
+
from ..parsers.ini import update_ini_setting
|
23
|
+
from ..utils import FileLines, FileOrFilename, open_file, warn
|
24
|
+
from ..versions import SortedVersionList, Version, VersionList
|
25
|
+
|
26
|
+
|
27
|
+
TOX_INI = 'tox.ini'
|
28
|
+
|
29
|
+
|
30
|
+
def get_tox_ini_python_versions(
|
31
|
+
filename: FileOrFilename = TOX_INI,
|
32
|
+
) -> SortedVersionList:
|
33
|
+
"""Extract supported Python versions from tox.ini."""
|
34
|
+
conf = configparser.ConfigParser()
|
35
|
+
try:
|
36
|
+
with open_file(filename) as fp:
|
37
|
+
conf.read_file(fp)
|
38
|
+
if conf.has_option('tox', 'env_list'):
|
39
|
+
envlist = conf.get('tox', 'env_list')
|
40
|
+
else:
|
41
|
+
envlist = conf.get('tox', 'envlist')
|
42
|
+
except configparser.Error:
|
43
|
+
return []
|
44
|
+
return sorted({
|
45
|
+
e for e in map(tox_env_to_py_version, parse_envlist(envlist)) if e
|
46
|
+
})
|
47
|
+
|
48
|
+
|
49
|
+
def split_envlist(envlist: str) -> Iterable[str]:
|
50
|
+
"""Split an environment list into items.
|
51
|
+
|
52
|
+
Tox allows commas or whitespace as separators.
|
53
|
+
|
54
|
+
The trick is that commas inside {...} brace groups do not count.
|
55
|
+
|
56
|
+
This function does not expand brace groups.
|
57
|
+
"""
|
58
|
+
for part in re.split(r'((?:[{][^}]*[}]|[^,{\s])+)|,|\s+', envlist):
|
59
|
+
# NB: part can be None
|
60
|
+
part = (part or '').strip()
|
61
|
+
if part:
|
62
|
+
yield part
|
63
|
+
|
64
|
+
|
65
|
+
def parse_envlist(envlist: str) -> List[str]:
|
66
|
+
"""Parse an environment list.
|
67
|
+
|
68
|
+
This function expands brace groups.
|
69
|
+
"""
|
70
|
+
envs = []
|
71
|
+
for part in split_envlist(envlist):
|
72
|
+
envs += brace_expand(part)
|
73
|
+
return envs
|
74
|
+
|
75
|
+
|
76
|
+
def brace_expand(s: str) -> List[str]:
|
77
|
+
"""Expand a braced group.
|
78
|
+
|
79
|
+
E.g. brace_expand('a{1,2}{b,c}x') == ['a1bx', 'a1cx', 'a2bx', 'a2cx'].
|
80
|
+
|
81
|
+
Note that this function doesn't support nested brace groups. I'm not sure
|
82
|
+
Tox supports them.
|
83
|
+
"""
|
84
|
+
m = re.match('^([^{]*)[{]([^}]*)[}](.*)$', s)
|
85
|
+
if not m:
|
86
|
+
return [s]
|
87
|
+
left = m.group(1)
|
88
|
+
right = m.group(3)
|
89
|
+
res = []
|
90
|
+
for alt in m.group(2).split(','):
|
91
|
+
res += brace_expand(left + alt.strip() + right)
|
92
|
+
return res
|
93
|
+
|
94
|
+
|
95
|
+
def tox_env_to_py_version(env: str) -> Optional[Version]:
|
96
|
+
"""Convert a Tox environment name to a Python version.
|
97
|
+
|
98
|
+
E.g. py34 becomes '3.4', pypy3 becomes 'PyPy3'.
|
99
|
+
|
100
|
+
Unrecognized environments are left alone.
|
101
|
+
|
102
|
+
If the environment name has dashes, only the first part is considered,
|
103
|
+
e.g. py34-django20 becomes '3.4', and jython-docs becomes 'jython'.
|
104
|
+
"""
|
105
|
+
if '-' in env:
|
106
|
+
# e.g. py34-coverage, pypy-subunit
|
107
|
+
env = env.partition('-')[0]
|
108
|
+
if env.startswith('pypy'):
|
109
|
+
return Version.from_string('PyPy' + env[4:])
|
110
|
+
elif env.startswith('py') and len(env) >= 4 and env[2:].isdigit():
|
111
|
+
return Version.from_string(f'{env[2]}.{env[3:]}')
|
112
|
+
else:
|
113
|
+
return None
|
114
|
+
|
115
|
+
|
116
|
+
def update_tox_ini_python_versions(
|
117
|
+
filename: FileOrFilename,
|
118
|
+
new_versions: SortedVersionList,
|
119
|
+
) -> FileLines:
|
120
|
+
"""Update supported Python versions in tox.ini.
|
121
|
+
|
122
|
+
Does not touch the file but returns a list of lines with new file contents.
|
123
|
+
"""
|
124
|
+
with open_file(filename) as fp:
|
125
|
+
orig_lines = fp.readlines()
|
126
|
+
fp.seek(0)
|
127
|
+
conf = configparser.ConfigParser()
|
128
|
+
try:
|
129
|
+
conf.read_file(fp)
|
130
|
+
if conf.has_option('tox', 'env_list'):
|
131
|
+
conf_name = 'env_list'
|
132
|
+
else:
|
133
|
+
conf_name = 'envlist'
|
134
|
+
envlist = conf.get('tox', conf_name)
|
135
|
+
except configparser.Error as error:
|
136
|
+
warn(f"Could not parse {fp.name}: {error}")
|
137
|
+
return orig_lines
|
138
|
+
|
139
|
+
new_envlist = update_tox_envlist(envlist, new_versions)
|
140
|
+
|
141
|
+
new_lines = update_ini_setting(
|
142
|
+
orig_lines, 'tox', conf_name, new_envlist, filename=fp.name,
|
143
|
+
)
|
144
|
+
return new_lines
|
145
|
+
|
146
|
+
|
147
|
+
def update_tox_envlist(envlist: str, new_versions: SortedVersionList) -> str:
|
148
|
+
"""Update an environment list.
|
149
|
+
|
150
|
+
Makes sure all Python versions from ``new_versions`` are in the list.
|
151
|
+
Removes all Python versions not in ``new_versions``. Leaves other
|
152
|
+
environments (e.g. flake8, docs) alone.
|
153
|
+
|
154
|
+
Tries to preserve formatting and braced groups.
|
155
|
+
"""
|
156
|
+
# Find a comma outside brace groups and see what whitespace follows it
|
157
|
+
# (also note that items can be separated with whitespace without a comma,
|
158
|
+
# but the only whitespace used this way I've seen in the wild was newlines)
|
159
|
+
m = re.search(r',\s*|\n', re.sub(r'[{][^}]*[}]', '', envlist.strip()))
|
160
|
+
if m:
|
161
|
+
sep = m.group()
|
162
|
+
else:
|
163
|
+
sep = ','
|
164
|
+
|
165
|
+
trailing_comma = envlist.rstrip().endswith(',')
|
166
|
+
|
167
|
+
new_envs = [
|
168
|
+
toxenv_for_version(ver)
|
169
|
+
for ver in new_versions
|
170
|
+
]
|
171
|
+
|
172
|
+
if 'py{' in envlist or '{py' in envlist:
|
173
|
+
# Try to preserve braced groups
|
174
|
+
parts = []
|
175
|
+
added_vers = False
|
176
|
+
for part in split_envlist(envlist):
|
177
|
+
m = re.match(
|
178
|
+
r'(py[{](?:\d+|py\d*)(?:,(?:\d+|py\d*))*[}])(?P<rest>.*)',
|
179
|
+
part
|
180
|
+
)
|
181
|
+
if m:
|
182
|
+
keep = [env for env in brace_expand(m.group(1))
|
183
|
+
if should_keep(env, new_versions)]
|
184
|
+
parts.append(
|
185
|
+
'py{' + ','.join(
|
186
|
+
env[len('py'):] for env in new_envs + keep
|
187
|
+
) + '}' + m.group('rest')
|
188
|
+
)
|
189
|
+
added_vers = True
|
190
|
+
continue
|
191
|
+
m = re.match(
|
192
|
+
r'([{]py(?:\d+|py\d*)(?:,py(?:\d+|py\d*))*[}])(?P<rest>.*)',
|
193
|
+
part
|
194
|
+
)
|
195
|
+
if m:
|
196
|
+
keep = [env for env in brace_expand(m.group(1))
|
197
|
+
if should_keep(env, new_versions)]
|
198
|
+
parts.append(
|
199
|
+
'{' + ','.join(new_envs + keep) + '}' + m.group('rest')
|
200
|
+
)
|
201
|
+
added_vers = True
|
202
|
+
continue
|
203
|
+
vers = brace_expand(part)
|
204
|
+
if all(not should_keep(ver, new_versions) for ver in vers):
|
205
|
+
continue
|
206
|
+
if not all(should_keep(ver, new_versions) for ver in vers):
|
207
|
+
parts.append(sep.join(
|
208
|
+
ver for ver in vers if should_keep(ver, new_versions)
|
209
|
+
))
|
210
|
+
continue
|
211
|
+
parts.append(part)
|
212
|
+
if not added_vers:
|
213
|
+
parts = new_envs + parts
|
214
|
+
return sep.join(parts)
|
215
|
+
|
216
|
+
# Universal expansion, might destroy braced groups
|
217
|
+
keep_before: List[str] = []
|
218
|
+
keep_after: List[str] = []
|
219
|
+
keep = keep_before
|
220
|
+
for env in parse_envlist(envlist):
|
221
|
+
if should_keep(env, new_versions):
|
222
|
+
keep.append(env)
|
223
|
+
else:
|
224
|
+
keep = keep_after
|
225
|
+
new_envlist = sep.join(keep_before + new_envs + keep_after)
|
226
|
+
if trailing_comma:
|
227
|
+
new_envlist += ','
|
228
|
+
return new_envlist
|
229
|
+
|
230
|
+
|
231
|
+
def toxenv_for_version(ver: Version) -> str:
|
232
|
+
"""Compute a tox environment name for a Python version."""
|
233
|
+
return f"py{ver.major}{ver.minor if ver.minor >= 0 else ''}"
|
234
|
+
|
235
|
+
|
236
|
+
def should_keep(env: str, new_versions: VersionList) -> bool:
|
237
|
+
"""Check if a tox environment needs to be kept.
|
238
|
+
|
239
|
+
Any environments that refer to a specific Python version not in
|
240
|
+
``new_versions`` will be removed. All other environments are kept.
|
241
|
+
|
242
|
+
``pypy`` and ``pypy3`` are kept only if there's at least one Python 2.x
|
243
|
+
or 3.x version respectively in ``new_versions``.
|
244
|
+
|
245
|
+
"""
|
246
|
+
if not re.match(r'py(py)?\d*($|-)', env):
|
247
|
+
return True
|
248
|
+
if env == 'pypy':
|
249
|
+
return any(ver.major == 2 for ver in new_versions)
|
250
|
+
if env == 'pypy3':
|
251
|
+
return any(ver.major == 3 for ver in new_versions)
|
252
|
+
if '-' in env:
|
253
|
+
baseversion = tox_env_to_py_version(env)
|
254
|
+
if baseversion in new_versions:
|
255
|
+
return True
|
256
|
+
return False
|
257
|
+
|
258
|
+
|
259
|
+
Tox = Source(
|
260
|
+
filename=TOX_INI,
|
261
|
+
extract=get_tox_ini_python_versions,
|
262
|
+
update=update_tox_ini_python_versions,
|
263
|
+
check_pypy_consistency=True,
|
264
|
+
has_upper_bound=True,
|
265
|
+
)
|
@@ -0,0 +1,213 @@
|
|
1
|
+
"""
|
2
|
+
Support for Travis CI.
|
3
|
+
|
4
|
+
Travis CI is a hosted Continuous Integration solution that can be configured
|
5
|
+
by dropping a file named ``.travis.yml`` into your source repository.
|
6
|
+
|
7
|
+
There are multiple ways of selecting Python versions, some more canonical
|
8
|
+
than others:
|
9
|
+
|
10
|
+
- via the top-level ``python`` list
|
11
|
+
- via ``python`` attributes in the jobs defined by ``jobs.include`` or its
|
12
|
+
deprecated alias ``matrix.include``
|
13
|
+
- via ``TOXENV`` environment variables in the top-level ``env`` list
|
14
|
+
(this is discouraged and check-python-versions might drop support for this in
|
15
|
+
the future)
|
16
|
+
"""
|
17
|
+
|
18
|
+
from typing import Dict, List, Union
|
19
|
+
|
20
|
+
import yaml
|
21
|
+
|
22
|
+
from .base import Source
|
23
|
+
from .tox import parse_envlist, tox_env_to_py_version
|
24
|
+
from ..parsers.yaml import drop_yaml_node, quote_string, update_yaml_list
|
25
|
+
from ..utils import FileLines, FileOrFilename, open_file
|
26
|
+
from ..versions import SortedVersionList, Version, is_important
|
27
|
+
|
28
|
+
|
29
|
+
TRAVIS_YML = '.travis.yml'
|
30
|
+
|
31
|
+
|
32
|
+
# Back in the day you could do
|
33
|
+
#
|
34
|
+
# dist: trusty
|
35
|
+
# python:
|
36
|
+
# - pypy
|
37
|
+
# - pypy3
|
38
|
+
#
|
39
|
+
# but then xenial came out and it did not recognize 'pypy' or 'pypy3', instead
|
40
|
+
# requiring you to explicitly spell out full version numbers like
|
41
|
+
#
|
42
|
+
# dist: trusty
|
43
|
+
# python:
|
44
|
+
# - pypy2.7-6.0.0
|
45
|
+
# - pypy3.5-6.0.0
|
46
|
+
#
|
47
|
+
# and check-python-versions could upgrade your .travis.yml from the old version
|
48
|
+
# to the new. Happily, this is no longer necessary, because Travis supports
|
49
|
+
# 'pypy' and 'pypy3' once again.
|
50
|
+
XENIAL_SUPPORTED_PYPY_VERSIONS: Dict[str, str] = {
|
51
|
+
# e.g. 'pypy': 'pypy2.7-7.1.1',
|
52
|
+
}
|
53
|
+
|
54
|
+
|
55
|
+
def get_travis_yml_python_versions(
|
56
|
+
filename: FileOrFilename = TRAVIS_YML,
|
57
|
+
) -> SortedVersionList:
|
58
|
+
"""Extract supported Python versions from .travis.yml."""
|
59
|
+
with open_file(filename) as fp:
|
60
|
+
conf = yaml.safe_load(fp)
|
61
|
+
versions: List[Version] = []
|
62
|
+
if conf.get('python'):
|
63
|
+
if isinstance(conf['python'], list):
|
64
|
+
versions += map(travis_normalize_py_version, conf['python'])
|
65
|
+
else:
|
66
|
+
versions.append(travis_normalize_py_version(conf['python']))
|
67
|
+
if 'matrix' in conf and 'include' in conf['matrix']:
|
68
|
+
for job in conf['matrix']['include']:
|
69
|
+
if 'python' in job:
|
70
|
+
versions.append(travis_normalize_py_version(job['python']))
|
71
|
+
if 'jobs' in conf and 'include' in conf['jobs']:
|
72
|
+
for job in conf['jobs']['include']:
|
73
|
+
if 'python' in job:
|
74
|
+
versions.append(travis_normalize_py_version(job['python']))
|
75
|
+
if 'env' in conf:
|
76
|
+
toxenvs = []
|
77
|
+
for env in conf['env']:
|
78
|
+
if env.startswith('TOXENV='):
|
79
|
+
toxenvs.extend(parse_envlist(env.partition('=')[-1]))
|
80
|
+
versions.extend(e for e in map(tox_env_to_py_version, toxenvs) if e)
|
81
|
+
return sorted(set(versions))
|
82
|
+
|
83
|
+
|
84
|
+
def travis_normalize_py_version(v: Union[str, float]) -> Version:
|
85
|
+
"""Determine Python version from Travis ``python`` value."""
|
86
|
+
v = str(v)
|
87
|
+
if v.startswith('pypy3'):
|
88
|
+
# could be pypy3, pypy3.5, pypy3.5-5.10.0
|
89
|
+
return Version.from_string('PyPy3')
|
90
|
+
elif v.startswith('pypy'):
|
91
|
+
# could be pypy, pypy2, pypy2.7, pypy2.7-5.10.0
|
92
|
+
return Version.from_string('PyPy')
|
93
|
+
else:
|
94
|
+
return Version.from_string(v)
|
95
|
+
|
96
|
+
|
97
|
+
def needs_xenial(v: Version) -> bool:
|
98
|
+
"""Check if a Python version needs dist: xenial.
|
99
|
+
|
100
|
+
This is obsolete now that dist: xenial is the default, but it may
|
101
|
+
be helpful to determine when we need to drop old dist: trusty.
|
102
|
+
"""
|
103
|
+
return v >= Version(major=3, minor=7)
|
104
|
+
|
105
|
+
|
106
|
+
def update_travis_yml_python_versions(
|
107
|
+
filename: FileOrFilename,
|
108
|
+
new_versions: SortedVersionList,
|
109
|
+
) -> FileLines:
|
110
|
+
"""Update supported Python versions in .travis.yml.
|
111
|
+
|
112
|
+
Does not touch the file but returns a list of lines with new file contents.
|
113
|
+
"""
|
114
|
+
with open_file(filename) as fp:
|
115
|
+
orig_lines = fp.readlines()
|
116
|
+
fp.seek(0)
|
117
|
+
conf = yaml.safe_load(fp)
|
118
|
+
new_lines = orig_lines
|
119
|
+
|
120
|
+
# Make sure we're using dist: xenial if we want to use Python 3.7 or newer.
|
121
|
+
replacements = {}
|
122
|
+
if any(map(needs_xenial, new_versions)):
|
123
|
+
replacements.update(XENIAL_SUPPORTED_PYPY_VERSIONS)
|
124
|
+
if conf.get('dist') == 'trusty':
|
125
|
+
new_lines = drop_yaml_node(new_lines, 'dist', filename=fp.name)
|
126
|
+
if conf.get('sudo') is False:
|
127
|
+
# sudo is ignored nowadays, but in earlier times
|
128
|
+
# you needed both dist: xenial and sudo: required
|
129
|
+
# to get Python 3.7
|
130
|
+
new_lines = drop_yaml_node(new_lines, "sudo", filename=fp.name)
|
131
|
+
|
132
|
+
def keep_old(value: str) -> bool:
|
133
|
+
"""Determine if a Python version line should be preserved."""
|
134
|
+
ver = travis_normalize_py_version(value)
|
135
|
+
if ver == Version.from_string('PyPy'):
|
136
|
+
return any(v.major == 2 for v in new_versions)
|
137
|
+
if ver == Version.from_string('PyPy3'):
|
138
|
+
return any(v.major == 3 for v in new_versions)
|
139
|
+
return not is_important(ver)
|
140
|
+
|
141
|
+
def keep_old_job(job: str) -> bool:
|
142
|
+
"""Determine if a job line should be preserved."""
|
143
|
+
if job.startswith('python:'):
|
144
|
+
ver = job[len('python:'):].strip()
|
145
|
+
return not is_important(travis_normalize_py_version(ver))
|
146
|
+
else:
|
147
|
+
return True
|
148
|
+
|
149
|
+
quote_style = ''
|
150
|
+
old_versions = conf.get('python', [])
|
151
|
+
if isinstance(old_versions, (str, int, float)):
|
152
|
+
old_versions = [old_versions]
|
153
|
+
for toplevel in 'matrix', 'jobs':
|
154
|
+
for job in conf.get(toplevel, {}).get('include', []):
|
155
|
+
if 'python' in job:
|
156
|
+
old_versions.append(job['python'])
|
157
|
+
if old_versions and all(isinstance(v, str) for v in old_versions):
|
158
|
+
quote_style = '"'
|
159
|
+
|
160
|
+
yaml_new_versions = [
|
161
|
+
quote_string(str(v), quote_style)
|
162
|
+
for v in new_versions
|
163
|
+
]
|
164
|
+
|
165
|
+
if conf.get('python'):
|
166
|
+
new_lines = update_yaml_list(
|
167
|
+
new_lines, "python", yaml_new_versions, filename=fp.name,
|
168
|
+
keep=keep_old,
|
169
|
+
replacements=replacements,
|
170
|
+
)
|
171
|
+
else:
|
172
|
+
replacements = {
|
173
|
+
f'python: {k}': f'python: {v}'
|
174
|
+
for k, v in replacements.items()
|
175
|
+
}
|
176
|
+
for toplevel in 'matrix', 'jobs':
|
177
|
+
if 'include' not in conf.get(toplevel, {}):
|
178
|
+
continue
|
179
|
+
new_jobs = [
|
180
|
+
f'python: {ver}'
|
181
|
+
for ver in yaml_new_versions
|
182
|
+
]
|
183
|
+
new_lines = update_yaml_list(
|
184
|
+
new_lines, (toplevel, "include"), new_jobs, filename=fp.name,
|
185
|
+
replacements=replacements, keep=keep_old_job,
|
186
|
+
)
|
187
|
+
|
188
|
+
# If python 3.7 was enabled via matrix.include, we've just added a
|
189
|
+
# second 3.7 entry directly to top-level python by the above code.
|
190
|
+
# So let's drop the matrix.
|
191
|
+
|
192
|
+
if (
|
193
|
+
conf.get('python')
|
194
|
+
and 'include' in conf.get('matrix', {})
|
195
|
+
and all(
|
196
|
+
job.get('dist') == 'xenial'
|
197
|
+
and set(job) <= {'python', 'dist', 'sudo'}
|
198
|
+
for job in conf['matrix']['include']
|
199
|
+
)
|
200
|
+
):
|
201
|
+
# XXX: this may drop too much or too little!
|
202
|
+
new_lines = drop_yaml_node(new_lines, "matrix", filename=fp.name)
|
203
|
+
|
204
|
+
return new_lines
|
205
|
+
|
206
|
+
|
207
|
+
Travis = Source(
|
208
|
+
filename=TRAVIS_YML,
|
209
|
+
extract=get_travis_yml_python_versions,
|
210
|
+
update=update_travis_yml_python_versions,
|
211
|
+
check_pypy_consistency=True,
|
212
|
+
has_upper_bound=True,
|
213
|
+
)
|