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.
@@ -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
+ )