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