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,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
+ )