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,87 @@
1
+ from typing import Callable, Optional
2
+
3
+ from ..utils import FileLines, FileOrFilename
4
+ from ..versions import SortedVersionList
5
+
6
+
7
+ ExtractorFn = Callable[[FileOrFilename], Optional[SortedVersionList]]
8
+ UpdaterFn = Callable[[FileOrFilename, SortedVersionList], Optional[FileLines]]
9
+
10
+
11
+ class Source:
12
+ """Source for information about supported Pythons.
13
+
14
+ Examples of sources are: setup.py, CI configuration files.
15
+
16
+ A source has a ``title``, a typical ``filename`` (which can be a
17
+ glob pattern).
18
+
19
+ A source knows how to extract the list of supported Python versions
20
+ from a file, and it can know how to update the list of versions.
21
+
22
+ A source knows whether it's possible/typical to include PyPy support
23
+ for packages that want to support PyPy.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ *,
29
+ title: Optional[str] = None,
30
+ filename: str,
31
+ extract: ExtractorFn,
32
+ update: Optional[UpdaterFn] = None,
33
+ check_pypy_consistency: bool,
34
+ has_upper_bound: bool,
35
+ ) -> None:
36
+ self.title = title or filename
37
+ self.filename = filename
38
+ self.extract = extract
39
+ self.update = update
40
+ self.check_pypy_consistency = check_pypy_consistency
41
+ self.has_upper_bound = has_upper_bound
42
+
43
+ def for_file(
44
+ self,
45
+ pathname: str,
46
+ versions: SortedVersionList,
47
+ relpath: str,
48
+ ) -> 'SourceFile':
49
+ title = relpath if self.title == self.filename else self.title
50
+ source = SourceFile(
51
+ title=title,
52
+ filename=self.filename,
53
+ extract=self.extract,
54
+ update=self.update,
55
+ check_pypy_consistency=self.check_pypy_consistency,
56
+ has_upper_bound=self.has_upper_bound,
57
+ pathname=pathname,
58
+ versions=versions,
59
+ )
60
+ return source
61
+
62
+
63
+ class SourceFile(Source):
64
+ """A concrete source file with information about supported Pythons.
65
+
66
+ Some sources (GitHub Actions) can have multiple files matching a
67
+ glob pattern. Each of those gets its own ``SourceFile`` instance.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ *,
73
+ title: Optional[str] = None,
74
+ filename: str,
75
+ extract: ExtractorFn,
76
+ update: Optional[UpdaterFn] = None,
77
+ check_pypy_consistency: bool,
78
+ has_upper_bound: bool,
79
+ pathname: str,
80
+ versions: SortedVersionList,
81
+ ) -> None:
82
+ super().__init__(
83
+ title=title, filename=filename, extract=extract, update=update,
84
+ check_pypy_consistency=check_pypy_consistency,
85
+ has_upper_bound=has_upper_bound)
86
+ self.pathname = pathname
87
+ self.versions = versions
@@ -0,0 +1,162 @@
1
+ """
2
+ Support for GitHub Actions.
3
+
4
+ GitHub Actions are very flexible, so this code is going to make some
5
+ simplifying assumptions:
6
+
7
+ - you use a matrix strategy
8
+ - on 'python-version' that contains python versions, or
9
+ - on 'config' that contains lists of [python_version, tox_env]
10
+ """
11
+
12
+ from typing import Optional, Set, Union
13
+
14
+ import yaml
15
+
16
+ from .base import Source
17
+ from ..parsers.yaml import quote_string, update_yaml_list
18
+ from ..sources.tox import toxenv_for_version
19
+ from ..utils import FileLines, FileOrFilename, open_file
20
+ from ..versions import SortedVersionList, Version
21
+
22
+
23
+ GHA_WORKFLOW_FILE = '.github/workflows/tests.yml'
24
+ GHA_WORKFLOW_GLOB = '.github/workflows/*.yml'
25
+
26
+
27
+ def get_gha_python_versions(
28
+ filename: FileOrFilename = GHA_WORKFLOW_FILE,
29
+ ) -> Optional[SortedVersionList]:
30
+ """Extract supported Python versions from a GitHub workflow."""
31
+ with open_file(filename) as fp:
32
+ conf = yaml.safe_load(fp)
33
+
34
+ versions: Set[Version] = set()
35
+ had_matrix = False
36
+ for job_name, job in conf.get('jobs', {}).items():
37
+ matrix = job.get('strategy', {}).get('matrix', {})
38
+ if 'python-version' in matrix:
39
+ had_matrix = True
40
+ versions.update(
41
+ e for e in map(parse_gh_ver, matrix['python-version']) if e)
42
+ if 'config' in matrix:
43
+ had_matrix = True
44
+ versions.update(
45
+ parse_gh_ver(c[0])
46
+ for c in matrix['config']
47
+ if isinstance(c, list)
48
+ )
49
+ if 'include' in matrix:
50
+ for extra in matrix['include']:
51
+ if 'python-version' in extra:
52
+ had_matrix = True
53
+ versions.add(parse_gh_ver(extra['python-version']))
54
+
55
+ if not had_matrix:
56
+ return None
57
+ return sorted(set(versions))
58
+
59
+
60
+ def parse_gh_ver(v: Union[str, float]) -> Version:
61
+ """Parse Python versions used for actions/setup-python@v2.
62
+
63
+ This format is not fully well documented. There's support for
64
+ specifying things like
65
+
66
+ - "3.x" (latest minor in Python 3.x; currently 3.9)
67
+ - "3.7" (latest bugfix in Python 3.7)
68
+ - "3.7.2" (specific version to be downloaded and installed)
69
+ - "pypy2"/"pypy3"
70
+ - "pypy-2.7"/"pypy-3.6"
71
+ - "pypy-3.7-v7.3.3"
72
+
73
+ https://github.com/actions/python-versions/blob/main/versions-manifest.json
74
+ contains a list of supported CPython versions that can be downloaded
75
+ and installed; this includes prereleases, but doesn't include PyPy.
76
+ """
77
+ v = str(v)
78
+ if v.startswith(('pypy3', 'pypy-3')):
79
+ return Version.from_string('PyPy3')
80
+ elif v.startswith(('pypy2', 'pypy-2')):
81
+ return Version.from_string('PyPy')
82
+ else:
83
+ return Version.from_string(v)
84
+
85
+
86
+ def update_gha_python_versions(
87
+ filename: FileOrFilename,
88
+ new_versions: SortedVersionList,
89
+ ) -> FileLines:
90
+ """Update supported Python versions in a GitHub workflow file.
91
+
92
+ Does not touch the file but returns a list of lines with new file contents.
93
+ """
94
+ with open_file(filename) as fp:
95
+ orig_lines = fp.readlines()
96
+ fp.seek(0)
97
+ conf = yaml.safe_load(fp)
98
+ new_lines = orig_lines
99
+
100
+ def keep_old_version(value: str) -> bool:
101
+ """Determine if a Python version line should be preserved."""
102
+ parsed = yaml.safe_load(value)
103
+ ver = parse_gh_ver(parsed)
104
+ if ver == Version.from_string('PyPy'):
105
+ return any(v.major == 2 for v in new_versions)
106
+ if ver == Version.from_string('PyPy3'):
107
+ return any(v.major == 3 for v in new_versions)
108
+ return False
109
+
110
+ def keep_old_config(value: str) -> bool:
111
+ """Determine if a Python version line should be preserved."""
112
+ parsed = yaml.safe_load(value)
113
+ if isinstance(parsed, list) and len(parsed) == 2:
114
+ ver = parse_gh_ver(parsed[0])
115
+ toxenv = str(parsed[1])
116
+ else:
117
+ return True
118
+ if ver == Version.from_string('PyPy'):
119
+ return any(v.major == 2 for v in new_versions)
120
+ if ver == Version.from_string('PyPy3'):
121
+ return any(v.major == 3 for v in new_versions)
122
+ return toxenv != toxenv_for_version(ver)
123
+
124
+ for job_name, job in conf.get('jobs', {}).items():
125
+ matrix = job.get('strategy', {}).get('matrix', {})
126
+ if 'python-version' in matrix:
127
+ quote_style = ''
128
+ if all(isinstance(v, str) for v in matrix['python-version']):
129
+ quote_style = '"'
130
+ yaml_new_versions = [
131
+ quote_string(str(v), quote_style)
132
+ for v in new_versions
133
+ ]
134
+ new_lines = update_yaml_list(
135
+ new_lines,
136
+ ('jobs', job_name, 'strategy', 'matrix', 'python-version'),
137
+ yaml_new_versions, filename=fp.name,
138
+ keep=keep_old_version,
139
+ )
140
+ if 'config' in matrix:
141
+ yaml_configs = []
142
+ for v in new_versions:
143
+ quoted_ver = quote_string(str(v), '"')
144
+ toxenv = quote_string(toxenv_for_version(v), '"')
145
+ yaml_configs.append(f"[{quoted_ver + ',':<8} {toxenv}]")
146
+ new_lines = update_yaml_list(
147
+ new_lines,
148
+ ('jobs', job_name, 'strategy', 'matrix', 'config'),
149
+ yaml_configs, filename=fp.name,
150
+ keep=keep_old_config,
151
+ )
152
+
153
+ return new_lines
154
+
155
+
156
+ GitHubActions = Source(
157
+ filename=GHA_WORKFLOW_GLOB,
158
+ extract=get_gha_python_versions,
159
+ update=update_gha_python_versions,
160
+ check_pypy_consistency=True,
161
+ has_upper_bound=True,
162
+ )
@@ -0,0 +1,96 @@
1
+ """
2
+ Support for .manylinux-install.sh. This is a shell script used by multiple
3
+ ZopeFoundation packages that builds manylinux wheels inside
4
+ quay.io/pypa/manylinux* Docker images.
5
+
6
+ The script loops over all installed Pythons, checks if each is a supported
7
+ version using a series of `if` statements, then builds wheels for each
8
+ supported versions. This looks like ::
9
+
10
+ for PYBIN in /opt/python/*/bin; do
11
+ if [[ "${PYBIN}" == *"cp27"* ]] || \
12
+ [[ "${PYBIN}" == *"cp34"* ]] || \
13
+ [[ "${PYBIN}" == *"cp35"* ]] || \
14
+ [[ "${PYBIN}" == *"cp36"* ]] || \
15
+ [[ "${PYBIN}" == *"cp37"* ]]; then
16
+ "${PYBIN}/pip" install -e /io/
17
+ "${PYBIN}/pip" wheel /io/ -w wheelhouse/
18
+ rm -rf /io/build /io/*.egg-info
19
+ fi
20
+ done
21
+
22
+ """
23
+
24
+ import re
25
+
26
+ from .base import Source
27
+ from ..utils import FileLines, FileOrFilename, open_file, warn
28
+ from ..versions import SortedVersionList, Version, VersionList
29
+
30
+
31
+ MANYLINUX_INSTALL_SH = '.manylinux-install.sh'
32
+
33
+
34
+ def get_manylinux_python_versions(
35
+ filename: FileOrFilename = MANYLINUX_INSTALL_SH,
36
+ ) -> SortedVersionList:
37
+ """Extract supported Python versions from .manylinux-install.sh."""
38
+ magic = re.compile(r'.*\[\[ "\$\{PYBIN\}" == \*"cp(\d)(\d+)"\* \]\]')
39
+ versions = []
40
+ with open_file(filename) as fp:
41
+ for line in fp:
42
+ m = magic.match(line)
43
+ if m:
44
+ v = Version.from_string('{}.{}'.format(*m.groups()))
45
+ versions.append(v)
46
+ return sorted(set(versions))
47
+
48
+
49
+ def update_manylinux_python_versions(
50
+ filename: FileOrFilename,
51
+ new_versions: VersionList,
52
+ ) -> FileLines:
53
+ """Update supported Python versions in .manylinux_install_sh.
54
+
55
+ Does not touch the file but returns a list of lines with new file contents.
56
+ """
57
+ magic = re.compile(r'.*\[\[ "\$\{PYBIN\}" == \*"cp(\d)(\d)"\* \]\]')
58
+ with open_file(filename) as f:
59
+ orig_lines = f.readlines()
60
+ lines = iter(enumerate(orig_lines))
61
+ for n, line in lines:
62
+ m = magic.match(line)
63
+ if m:
64
+ start = n
65
+ break
66
+ else:
67
+ warn(f'Failed to understand {f.name}')
68
+ return orig_lines
69
+ for n, line in lines:
70
+ m = magic.match(line)
71
+ if not m:
72
+ end = n
73
+ break
74
+ else:
75
+ warn(f'Failed to understand {f.name}')
76
+ return orig_lines
77
+
78
+ indent = ' ' * 4
79
+ conditions = f' || \\\n{indent} '.join(
80
+ f'[[ "${{PYBIN}}" == *"cp{ver.major}{ver.minor}"* ]]'
81
+ for ver in new_versions
82
+ )
83
+ new_lines = orig_lines[:start] + (
84
+ f'{indent}if {conditions}; then\n'
85
+ ).splitlines(True) + orig_lines[end:]
86
+
87
+ return new_lines
88
+
89
+
90
+ Manylinux = Source(
91
+ filename=MANYLINUX_INSTALL_SH,
92
+ extract=get_manylinux_python_versions,
93
+ update=update_manylinux_python_versions,
94
+ check_pypy_consistency=False,
95
+ has_upper_bound=True,
96
+ )
@@ -0,0 +1,244 @@
1
+ """
2
+ Support for pyproject.toml.
3
+
4
+ There are several build tools that use pyproject.toml to specify metadata.
5
+ Some of them use the PEP 621::
6
+
7
+ [project]
8
+ classifiers = [
9
+ ...
10
+ "Programming Language :: Python :: 3.8",
11
+ ...
12
+ ]
13
+ requires-python = ">= 3.8"
14
+
15
+ check-python-versions also supports old-style Flit and Poetry metadata::
16
+
17
+ [tool.flit.metadata]
18
+ classifiers = [
19
+ ...
20
+ "Programming Language :: Python :: 3.8",
21
+ ...
22
+ ]
23
+ requires-python = ">= 3.8"
24
+
25
+ [tool.poetry]
26
+ classifiers = [
27
+ ...
28
+ "Programming Language :: Python :: 3.8",
29
+ ...
30
+ ]
31
+
32
+ [tool.poetry.dependencies]
33
+ python = "^3.8"
34
+
35
+ """
36
+ from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union
37
+
38
+ import tomlkit
39
+ from tomlkit import TOMLDocument, dumps, load
40
+
41
+ from check_python_versions.parsers.poetry_version_spec import (
42
+ compute_poetry_spec,
43
+ detect_poetry_version_spec_style,
44
+ )
45
+ from check_python_versions.utils import file_name
46
+
47
+ from .base import Source
48
+ from ..parsers.classifiers import (
49
+ get_versions_from_classifiers,
50
+ update_classifiers,
51
+ )
52
+ from ..parsers.poetry_version_spec import parse_poetry_version_constraint
53
+ from ..parsers.requires_python import (
54
+ compute_python_requires,
55
+ detect_style,
56
+ parse_python_requires,
57
+ )
58
+ from ..utils import FileLines, FileOrFilename, open_file, warn
59
+ from ..versions import SortedVersionList
60
+
61
+
62
+ PYPROJECT_TOML = 'pyproject.toml'
63
+
64
+
65
+ if TYPE_CHECKING:
66
+ from tomlkit.container import Container
67
+ from tomlkit.items import Item
68
+
69
+
70
+ def traverse(document: TOMLDocument, path: str, default: Any = None) -> Any:
71
+ obj: Union[Container, Item] = document
72
+ for step in path.split('.'):
73
+ if not isinstance(obj, dict):
74
+ # complain
75
+ return default
76
+ if step not in obj:
77
+ return default
78
+ obj = obj[step]
79
+ return obj
80
+
81
+
82
+ def _get_pyproject_toml_classifiers(
83
+ filename: FileOrFilename = PYPROJECT_TOML,
84
+ ) -> Tuple[TOMLDocument, str, Optional[List[str]]]:
85
+ """Extract the list of PyPI classifiers from a pyproject.toml"""
86
+
87
+ with open_file(filename) as fp:
88
+ document = load(fp)
89
+
90
+ for path in 'project', 'tool.flit.metadata', 'tool.poetry':
91
+ classifiers = traverse(document, f"{path}.classifiers")
92
+ if classifiers is not None:
93
+ break
94
+
95
+ if classifiers is None:
96
+ return document, path, None
97
+
98
+ if not isinstance(classifiers, list):
99
+ warn(f'The value specified for {path}.classifiers in {fp.name}'
100
+ ' is not an array')
101
+ # Returning None means that pyproject.toml doesn't have metadata.
102
+ # Returning [] is likely to cause a mismatch with other
103
+ # metadata sources, making the problem more noticeable.
104
+ return document, path, []
105
+
106
+ if not all(isinstance(s, str) for s in classifiers):
107
+ warn(f'The value specified for {path}.classifiers in {fp.name}'
108
+ ' is not an array of strings')
109
+ # Returning None means that pyproject.toml doesn't have metadata.
110
+ # Returning [] is likely to cause a mismatch with other
111
+ # metadata sources, making the problem more noticeable.
112
+ return document, path, []
113
+
114
+ return document, path, classifiers
115
+
116
+
117
+ def get_supported_python_versions(
118
+ filename: FileOrFilename = PYPROJECT_TOML,
119
+ ) -> Optional[SortedVersionList]:
120
+ """Extract supported Python versions from classifiers in pyproject.toml."""
121
+
122
+ _d, _p, classifiers = _get_pyproject_toml_classifiers(filename)
123
+
124
+ if classifiers is None:
125
+ return None
126
+
127
+ return get_versions_from_classifiers(classifiers)
128
+
129
+
130
+ def _get_pyproject_toml_requires_python(
131
+ filename: FileOrFilename = PYPROJECT_TOML,
132
+ ) -> Tuple[TOMLDocument, str, Optional[str]]:
133
+
134
+ with open_file(filename) as fp:
135
+ document = load(fp)
136
+
137
+ for path in (
138
+ "project.requires-python",
139
+ "tool.flit.metadata.requires-python",
140
+ "tool.poetry.dependencies.python",
141
+ ):
142
+ python_requires = traverse(document, path)
143
+ if python_requires is not None:
144
+ break
145
+
146
+ if python_requires is None:
147
+ return document, path, None
148
+
149
+ if not isinstance(python_requires, str):
150
+ warn(f'The value specified for {path} in {fp.name} is not a string')
151
+ return document, path, None
152
+
153
+ return document, path, python_requires
154
+
155
+
156
+ def get_python_requires(
157
+ filename: FileOrFilename = PYPROJECT_TOML,
158
+ ) -> Optional[SortedVersionList]:
159
+ """Extract Python versions from require-python in pyproject.toml."""
160
+
161
+ _d, path, python_requires = _get_pyproject_toml_requires_python(filename)
162
+
163
+ if python_requires is None:
164
+ return None
165
+
166
+ if path == 'tool.poetry.dependencies.python':
167
+ return parse_poetry_version_constraint(
168
+ python_requires, path, filename=file_name(filename))
169
+ else:
170
+ return parse_python_requires(
171
+ python_requires, path, filename=file_name(filename))
172
+
173
+
174
+ def update_supported_python_versions(
175
+ filename: FileOrFilename,
176
+ new_versions: SortedVersionList,
177
+ ) -> Optional[FileLines]:
178
+ """Update classifiers in a pyproject.toml.
179
+
180
+ Does not touch the file but returns a list of lines with new file contents.
181
+ """
182
+
183
+ document, path, classifiers = _get_pyproject_toml_classifiers(filename)
184
+
185
+ if classifiers is None:
186
+ return None
187
+
188
+ new_classifiers = update_classifiers(classifiers, new_versions)
189
+
190
+ table = traverse(document, path)
191
+ table['classifiers'] = a = tomlkit.array().multiline(True)
192
+ a.extend(new_classifiers)
193
+
194
+ return dumps(document).splitlines(True)
195
+
196
+
197
+ def update_python_requires(
198
+ filename: FileOrFilename,
199
+ new_versions: SortedVersionList,
200
+ ) -> Optional[FileLines]:
201
+ """Update python dependency in a pyproject.toml, if it's defined there.
202
+
203
+ Does not touch the file but returns a list of lines with new file contents.
204
+ """
205
+
206
+ document, path, python_requires = _get_pyproject_toml_requires_python(
207
+ filename)
208
+
209
+ if python_requires is None:
210
+ return None
211
+
212
+ if path == 'tool.poetry.dependencies.python':
213
+ new_python_spec = compute_poetry_spec(
214
+ new_versions, **detect_poetry_version_spec_style(python_requires))
215
+
216
+ table = traverse(document, path.rpartition('.')[0])
217
+ table['python'] = new_python_spec
218
+ else:
219
+ new_python_requires = compute_python_requires(
220
+ new_versions, **detect_style(python_requires))
221
+
222
+ table = traverse(document, path.rpartition('.')[0])
223
+ table['requires-python'] = new_python_requires
224
+
225
+ return dumps(document).splitlines(True)
226
+
227
+
228
+ PyProject = Source(
229
+ title=PYPROJECT_TOML,
230
+ filename=PYPROJECT_TOML,
231
+ extract=get_supported_python_versions,
232
+ update=update_supported_python_versions,
233
+ check_pypy_consistency=True,
234
+ has_upper_bound=True,
235
+ )
236
+
237
+ PyProjectPythonRequires = Source(
238
+ title='- python_requires',
239
+ filename=PYPROJECT_TOML,
240
+ extract=get_python_requires,
241
+ update=update_python_requires,
242
+ check_pypy_consistency=False,
243
+ has_upper_bound=False, # TBH it might have one!
244
+ )