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,16 @@
|
|
1
|
+
"""
|
2
|
+
Check supported Python versions in a Python package.
|
3
|
+
|
4
|
+
Makes sure the set of supported Python versions is consistent between
|
5
|
+
|
6
|
+
- setup.py PyPI classifiers
|
7
|
+
- tox.ini default env list
|
8
|
+
- .travis-ci.yml
|
9
|
+
- appveyor.yml
|
10
|
+
- (optionally) .manylinux-install.sh as used by various ZopeFoundation projects
|
11
|
+
- .github/workflows/*.yml
|
12
|
+
|
13
|
+
"""
|
14
|
+
|
15
|
+
__author__ = 'Marius Gedminas <marius@gedmin.as>'
|
16
|
+
__version__ = '0.23.0'
|
@@ -0,0 +1,479 @@
|
|
1
|
+
"""
|
2
|
+
Command-line user interface.
|
3
|
+
|
4
|
+
This is the main module of check-python-versions, responsible for handling
|
5
|
+
command-line arguments, extracting information about supported Python versions
|
6
|
+
from various sources, presenting it to the user and possibly making
|
7
|
+
modifications.
|
8
|
+
"""
|
9
|
+
|
10
|
+
import argparse
|
11
|
+
import glob
|
12
|
+
import os
|
13
|
+
import sys
|
14
|
+
from io import StringIO
|
15
|
+
from typing import Callable, Collection, Dict, List, Optional, Tuple
|
16
|
+
|
17
|
+
from . import __version__
|
18
|
+
from .sources.all import ALL_SOURCES
|
19
|
+
from .sources.base import SourceFile
|
20
|
+
from .utils import (
|
21
|
+
FileLines,
|
22
|
+
FileOrFilename,
|
23
|
+
confirm_and_update_file,
|
24
|
+
show_diff,
|
25
|
+
)
|
26
|
+
from .versions import (
|
27
|
+
MAX_MINOR_FOR_MAJOR,
|
28
|
+
SortedVersionList,
|
29
|
+
Version,
|
30
|
+
VersionList,
|
31
|
+
important,
|
32
|
+
pypy_versions,
|
33
|
+
update_version_list,
|
34
|
+
)
|
35
|
+
|
36
|
+
|
37
|
+
def parse_version(v: str) -> Tuple[int, int]:
|
38
|
+
"""Parse a Python version number.
|
39
|
+
|
40
|
+
Expects 'MAJOR.MINOR', no more, no less.
|
41
|
+
|
42
|
+
Returns a tuple (major, minor).
|
43
|
+
|
44
|
+
This function is used for command-line argument parsing and may raise an
|
45
|
+
argparse.ArgumentTypeError.
|
46
|
+
"""
|
47
|
+
try:
|
48
|
+
major, minor = map(int, v.split('.', 1))
|
49
|
+
except ValueError:
|
50
|
+
raise argparse.ArgumentTypeError(f'bad version: {v}')
|
51
|
+
return (major, minor)
|
52
|
+
|
53
|
+
|
54
|
+
def parse_version_list(v: str) -> SortedVersionList:
|
55
|
+
"""Parse a list of Python version ranges.
|
56
|
+
|
57
|
+
Expects something like '2.7,3.6-3.8'. Allows open ranges.
|
58
|
+
|
59
|
+
Returns an ordered list of strings, each of which represents a single
|
60
|
+
version of the form 'MAJOR.MINOR'.
|
61
|
+
|
62
|
+
This function is used for command-line argument parsing and may raise an
|
63
|
+
argparse.ArgumentTypeError.
|
64
|
+
"""
|
65
|
+
versions = set()
|
66
|
+
|
67
|
+
for part in v.split(','):
|
68
|
+
if '-' in part:
|
69
|
+
lo, hi = part.split('-', 1)
|
70
|
+
else:
|
71
|
+
lo = hi = part
|
72
|
+
|
73
|
+
if lo and hi:
|
74
|
+
lo_major, lo_minor = parse_version(lo)
|
75
|
+
hi_major, hi_minor = parse_version(hi)
|
76
|
+
elif hi and not lo:
|
77
|
+
hi_major, hi_minor = parse_version(hi)
|
78
|
+
lo_major, lo_minor = hi_major, 0
|
79
|
+
elif lo and not hi:
|
80
|
+
lo_major, lo_minor = parse_version(lo)
|
81
|
+
try:
|
82
|
+
hi_major, hi_minor = lo_major, MAX_MINOR_FOR_MAJOR[lo_major]
|
83
|
+
except KeyError:
|
84
|
+
raise argparse.ArgumentTypeError(
|
85
|
+
f'bad range: {part}')
|
86
|
+
else:
|
87
|
+
raise argparse.ArgumentTypeError(
|
88
|
+
f'bad range: {part}')
|
89
|
+
|
90
|
+
if lo_major != hi_major:
|
91
|
+
raise argparse.ArgumentTypeError(
|
92
|
+
f'bad range: {part} ({lo_major} != {hi_major})')
|
93
|
+
|
94
|
+
for vmin in range(lo_minor, hi_minor + 1):
|
95
|
+
versions.add(Version(major=lo_major, minor=vmin))
|
96
|
+
|
97
|
+
return sorted(versions)
|
98
|
+
|
99
|
+
|
100
|
+
def is_package(where: str = '.') -> bool:
|
101
|
+
"""Check if there's a Python package in the given directory.
|
102
|
+
|
103
|
+
Currently only traditional packages having a setup.py are supported.
|
104
|
+
|
105
|
+
Does not emit any diagnostics.
|
106
|
+
"""
|
107
|
+
setup_py = os.path.join(where, 'setup.py')
|
108
|
+
pyproject_toml = os.path.join(where, 'pyproject.toml')
|
109
|
+
return os.path.exists(setup_py) or os.path.exists(pyproject_toml)
|
110
|
+
|
111
|
+
|
112
|
+
PrintFn = Callable[..., None]
|
113
|
+
|
114
|
+
|
115
|
+
def check_package(where: str = '.', *, print: PrintFn = print) -> bool:
|
116
|
+
"""Check if there's a Python package in the given directory.
|
117
|
+
|
118
|
+
Currently only traditional packages having a setup.py are supported.
|
119
|
+
|
120
|
+
Emits diagnostics to standard output if ``where`` is not a directory
|
121
|
+
or doesn't have a Python package in it.
|
122
|
+
"""
|
123
|
+
|
124
|
+
if not os.path.isdir(where):
|
125
|
+
print("not a directory")
|
126
|
+
return False
|
127
|
+
|
128
|
+
if not is_package(where):
|
129
|
+
print("no setup.py or pyproject.toml -- not a Python package?")
|
130
|
+
return False
|
131
|
+
|
132
|
+
return True
|
133
|
+
|
134
|
+
|
135
|
+
#
|
136
|
+
# The way check-python-version does version updates is that it calls
|
137
|
+
# various update functions and gives them a filename, then gets back
|
138
|
+
# the updated content as a list of lines. At the end we can show the diff
|
139
|
+
# to the user or write them back to the file.
|
140
|
+
#
|
141
|
+
# But. Sometimes we want to call two update functions for the same file
|
142
|
+
# (setup.py) to update different bits in it (classifiers and python_requires).
|
143
|
+
# We don't want to write out the result of the first updater to disk before
|
144
|
+
# we call the second one. So, here's what we do: we remember the updated
|
145
|
+
# contents of a file in a "replacement dict", then next time instead of passing
|
146
|
+
# a filename to an update function we pass it a StringIO() with the intermedate
|
147
|
+
# results, to get back the final results.
|
148
|
+
#
|
149
|
+
|
150
|
+
ReplacementDict = Dict[str, FileLines]
|
151
|
+
|
152
|
+
|
153
|
+
def filename_or_replacement(
|
154
|
+
pathname: str, replacements: Optional[ReplacementDict]
|
155
|
+
) -> FileOrFilename:
|
156
|
+
"""Look up a file in the replacement dict.
|
157
|
+
|
158
|
+
This is used to batch multiple updates to a single file.
|
159
|
+
|
160
|
+
Returns the filename if no replacement was found, or a StringIO
|
161
|
+
with replacement contents if a replacement was found.
|
162
|
+
"""
|
163
|
+
if replacements and pathname in replacements:
|
164
|
+
new_lines = replacements[pathname]
|
165
|
+
buf = StringIO("".join(new_lines))
|
166
|
+
buf.name = pathname
|
167
|
+
return buf
|
168
|
+
else:
|
169
|
+
return pathname
|
170
|
+
|
171
|
+
|
172
|
+
FilenameSet = Collection[str]
|
173
|
+
|
174
|
+
|
175
|
+
def find_sources(
|
176
|
+
where: str = '.',
|
177
|
+
*,
|
178
|
+
replacements: Optional[ReplacementDict] = None,
|
179
|
+
only: Optional[FilenameSet] = None,
|
180
|
+
supports_update: bool = False,
|
181
|
+
) -> List[SourceFile]:
|
182
|
+
"""Find all sources that exist in a given directory.
|
183
|
+
|
184
|
+
``replacements`` allows you to check the result of an update (see
|
185
|
+
`update_versions`) without actually performing an update.
|
186
|
+
|
187
|
+
``only`` allows you to check only a subset of the files.
|
188
|
+
|
189
|
+
``supports_update`` lets you skip sources that don't support updates.
|
190
|
+
"""
|
191
|
+
sources = []
|
192
|
+
for source in ALL_SOURCES:
|
193
|
+
if supports_update and source.update is None:
|
194
|
+
continue # pragma: nocover
|
195
|
+
pathnames = glob.glob(os.path.join(where, source.filename))
|
196
|
+
if not pathnames:
|
197
|
+
continue
|
198
|
+
for pathname in pathnames:
|
199
|
+
relpath = os.path.relpath(pathname, where)
|
200
|
+
if only and relpath not in only and source.filename not in only:
|
201
|
+
continue
|
202
|
+
versions = source.extract(
|
203
|
+
filename_or_replacement(pathname, replacements))
|
204
|
+
if versions is not None:
|
205
|
+
sources.append(source.for_file(pathname, versions, relpath))
|
206
|
+
return sources
|
207
|
+
|
208
|
+
|
209
|
+
def check_versions(
|
210
|
+
where: str = '.',
|
211
|
+
*,
|
212
|
+
print: PrintFn = print,
|
213
|
+
min_width: int = 0,
|
214
|
+
expect: Optional[VersionList] = None,
|
215
|
+
replacements: Optional[ReplacementDict] = None,
|
216
|
+
only: Optional[FilenameSet] = None,
|
217
|
+
) -> bool:
|
218
|
+
"""Check Python versions for a single package, located in ``where``.
|
219
|
+
|
220
|
+
``expect`` allows you to state what versions you expect to be supported.
|
221
|
+
|
222
|
+
``replacements`` allows you to check the result of an update (see
|
223
|
+
`update_versions`) without actually performing an update.
|
224
|
+
|
225
|
+
``only`` allows you to check only a subset of the files.
|
226
|
+
|
227
|
+
Emits diagnostics to standard output by calling ``print``.
|
228
|
+
"""
|
229
|
+
|
230
|
+
sources = find_sources(where, replacements=replacements, only=only)
|
231
|
+
|
232
|
+
if not sources:
|
233
|
+
print('no file with version information found')
|
234
|
+
return False
|
235
|
+
|
236
|
+
width = max(len(source.title) for source in sources) + len(" says:")
|
237
|
+
|
238
|
+
if expect:
|
239
|
+
width = max(width, len('expected:'))
|
240
|
+
|
241
|
+
width = max(width, min_width)
|
242
|
+
|
243
|
+
for source in sources:
|
244
|
+
print(f"{source.title} says:".ljust(width),
|
245
|
+
", ".join(str(v) for v in source.versions) or "(empty)")
|
246
|
+
|
247
|
+
if expect:
|
248
|
+
print("expected:".ljust(width), ', '.join(str(v) for v in expect))
|
249
|
+
|
250
|
+
return supported_versions_match(sources, expect)
|
251
|
+
|
252
|
+
|
253
|
+
def supported_versions_match(
|
254
|
+
sources: List[SourceFile],
|
255
|
+
expect: Optional[VersionList] = None,
|
256
|
+
) -> bool:
|
257
|
+
version_sets = []
|
258
|
+
pypy_version_sets = []
|
259
|
+
|
260
|
+
# This loop covers everything except for setup_requires
|
261
|
+
for source in sources:
|
262
|
+
if source.has_upper_bound:
|
263
|
+
version_sets.append(important(source.versions))
|
264
|
+
if source.check_pypy_consistency:
|
265
|
+
pypy_version_sets.append(pypy_versions(source.versions))
|
266
|
+
|
267
|
+
# setup_requires usually has no upper bound, which causes trouble when a
|
268
|
+
# new Python version gets released. Let's add an artificial upper bound
|
269
|
+
# that matches all the other sources.
|
270
|
+
for source in sources:
|
271
|
+
if not source.has_upper_bound:
|
272
|
+
max_supported_version = max(v for vs in version_sets for v in vs)
|
273
|
+
version_sets.append({v for v in important(source.versions)
|
274
|
+
if v <= max_supported_version})
|
275
|
+
|
276
|
+
if not expect:
|
277
|
+
expect = version_sets[0]
|
278
|
+
|
279
|
+
expect = important(expect)
|
280
|
+
if not all(expect == v for v in version_sets):
|
281
|
+
return False
|
282
|
+
|
283
|
+
if not pypy_version_sets:
|
284
|
+
# can't happen: at least one of our sources (setup.py) has pypy info
|
285
|
+
return True # pragma: nocover
|
286
|
+
|
287
|
+
expect_pypy = pypy_version_sets[0]
|
288
|
+
return all(expect_pypy == v for v in pypy_version_sets)
|
289
|
+
|
290
|
+
|
291
|
+
def update_versions(
|
292
|
+
where: str = '.',
|
293
|
+
*,
|
294
|
+
add: Optional[VersionList] = None,
|
295
|
+
drop: Optional[VersionList] = None,
|
296
|
+
update: Optional[VersionList] = None,
|
297
|
+
diff: bool = False,
|
298
|
+
dry_run: bool = False,
|
299
|
+
only: Optional[FilenameSet] = None,
|
300
|
+
) -> ReplacementDict:
|
301
|
+
"""Update Python versions for a single package, located in ``where``.
|
302
|
+
|
303
|
+
``add`` will add to supported versions.
|
304
|
+
``drop`` will remove from supported versions.
|
305
|
+
``update`` will specify supported versions.
|
306
|
+
|
307
|
+
You may combine ``add`` and ``drop``. It doesn't make sense to combine
|
308
|
+
``update`` with either ``add`` or ``drop``.
|
309
|
+
|
310
|
+
``only`` allows you to modify only a subset of the files.
|
311
|
+
|
312
|
+
This function performs user interaction: shows a diff, asks for
|
313
|
+
confirmation, updates files on disk.
|
314
|
+
|
315
|
+
``diff``, if true, prints a diff to standard output instead of writing any
|
316
|
+
files.
|
317
|
+
|
318
|
+
``dry_run``, if true, returns a dictionary mapping filenames to new file
|
319
|
+
contents instead of asking for confirmation and writing them to disk.
|
320
|
+
"""
|
321
|
+
|
322
|
+
replacements: ReplacementDict = {}
|
323
|
+
|
324
|
+
sources = find_sources(where, replacements=replacements, only=only,
|
325
|
+
supports_update=True)
|
326
|
+
for source in sources:
|
327
|
+
# this assert explains supports_update=True to mypy
|
328
|
+
assert source.update is not None
|
329
|
+
versions = sorted(important(source.versions))
|
330
|
+
new_versions = update_version_list(
|
331
|
+
versions, add=add, drop=drop, update=update)
|
332
|
+
if versions != new_versions:
|
333
|
+
fp = filename_or_replacement(source.pathname, replacements)
|
334
|
+
new_lines = source.update(fp, new_versions)
|
335
|
+
if new_lines is not None:
|
336
|
+
# TODO: refactor update_versions() into two functions, one that
|
337
|
+
# produces a replacement dict and does no user interaction, and
|
338
|
+
# another that does user interaction based on the contents of
|
339
|
+
# the replacement dict. This is because showing a diff for
|
340
|
+
# setup.py twice (once to update classifiers and once to update
|
341
|
+
# python_requires) is weird?
|
342
|
+
if diff:
|
343
|
+
fp = filename_or_replacement(source.pathname, replacements)
|
344
|
+
show_diff(fp, new_lines)
|
345
|
+
if dry_run:
|
346
|
+
# XXX: why do this on dry-run only, why not always return a
|
347
|
+
# replacement dict?
|
348
|
+
replacements[source.pathname] = new_lines
|
349
|
+
if not diff and not dry_run:
|
350
|
+
confirm_and_update_file(source.pathname, new_lines)
|
351
|
+
|
352
|
+
return replacements
|
353
|
+
|
354
|
+
|
355
|
+
def _main() -> None:
|
356
|
+
"""The guts of the main() function.
|
357
|
+
|
358
|
+
Parses command-line arguments, does work, reports results, exits with an
|
359
|
+
error code if necessary.
|
360
|
+
"""
|
361
|
+
parser = argparse.ArgumentParser(
|
362
|
+
description="verify that supported Python versions are the same"
|
363
|
+
" in setup.py, tox.ini, .travis.yml and appveyor.yml")
|
364
|
+
parser.add_argument('--version', action='version',
|
365
|
+
version="%(prog)s version " + __version__)
|
366
|
+
parser.add_argument('--expect', metavar='VERSIONS',
|
367
|
+
type=parse_version_list,
|
368
|
+
help='expect these versions to be supported, e.g.'
|
369
|
+
' --expect 2.7,3.5-3.7')
|
370
|
+
parser.add_argument('--skip-non-packages', action='store_true',
|
371
|
+
help='skip arguments that are not Python packages'
|
372
|
+
' without warning about them')
|
373
|
+
parser.add_argument('--allow-non-packages', action='store_true',
|
374
|
+
help='try to work on directories that are not Python'
|
375
|
+
' packages but have a tox.ini'
|
376
|
+
' or .github/workflows')
|
377
|
+
parser.add_argument('--only', metavar='FILES',
|
378
|
+
help='check only the specified files'
|
379
|
+
' (comma-separated list, e.g.'
|
380
|
+
' --only tox.ini,appveyor.yml)')
|
381
|
+
parser.add_argument('where', nargs='*',
|
382
|
+
help='directory where a Python package with a setup.py'
|
383
|
+
' and other files is located')
|
384
|
+
group = parser.add_argument_group(
|
385
|
+
"updating supported version lists (EXPERIMENTAL)")
|
386
|
+
group.add_argument('--add', metavar='VERSIONS', type=parse_version_list,
|
387
|
+
help='add these versions to supported ones, e.g'
|
388
|
+
' --add 3.8')
|
389
|
+
group.add_argument('--drop', metavar='VERSIONS', type=parse_version_list,
|
390
|
+
help='drop these versions from supported ones, e.g'
|
391
|
+
' --drop 2.6,3.4')
|
392
|
+
group.add_argument('--update', metavar='VERSIONS', type=parse_version_list,
|
393
|
+
help='update the set of supported versions, e.g.'
|
394
|
+
' --update 2.7,3.5-3.7')
|
395
|
+
group.add_argument('--diff', action='store_true',
|
396
|
+
help='show a diff of proposed changes')
|
397
|
+
group.add_argument('--dry-run', action='store_true',
|
398
|
+
help='verify proposed changes without'
|
399
|
+
' writing them to disk')
|
400
|
+
args = parser.parse_args()
|
401
|
+
|
402
|
+
if args.update and args.add:
|
403
|
+
parser.error("argument --add: not allowed with argument --update")
|
404
|
+
if args.update and args.drop:
|
405
|
+
parser.error("argument --drop: not allowed with argument --update")
|
406
|
+
if args.skip_non_packages and args.allow_non_packages:
|
407
|
+
parser.error("use either --skip-non-packages or --allow-non-packages,"
|
408
|
+
" not both")
|
409
|
+
if args.diff and not (args.update or args.add or args.drop):
|
410
|
+
parser.error(
|
411
|
+
"argument --diff: not allowed without --update/--add/--drop")
|
412
|
+
if args.dry_run and not (args.update or args.add or args.drop):
|
413
|
+
parser.error(
|
414
|
+
"argument --dry-run: not allowed without --update/--add/--drop")
|
415
|
+
if args.expect and args.diff and not args.dry_run:
|
416
|
+
# XXX: the logic of this escapes me, I think this is because
|
417
|
+
# update_versions() doesn't return a replacement dict if you don't use
|
418
|
+
# --dry-run? but why?
|
419
|
+
parser.error(
|
420
|
+
"argument --expect: not allowed with --diff,"
|
421
|
+
" unless you also add --dry-run")
|
422
|
+
|
423
|
+
where = args.where or ['.']
|
424
|
+
if args.skip_non_packages:
|
425
|
+
where = [path for path in where if is_package(path)]
|
426
|
+
|
427
|
+
only = [a.strip() for a in args.only.split(',')] if args.only else None
|
428
|
+
|
429
|
+
multiple = len(where) > 1
|
430
|
+
|
431
|
+
min_width = 0
|
432
|
+
if multiple:
|
433
|
+
min_width = max(len(s.title) for s in ALL_SOURCES) + len('says: ')
|
434
|
+
|
435
|
+
mismatches = []
|
436
|
+
for n, path in enumerate(where):
|
437
|
+
if multiple and (not args.diff or args.dry_run):
|
438
|
+
if n:
|
439
|
+
print("\n")
|
440
|
+
print(f"{path}:\n")
|
441
|
+
if not args.allow_non_packages:
|
442
|
+
if not check_package(path):
|
443
|
+
mismatches.append(path)
|
444
|
+
continue
|
445
|
+
replacements = {}
|
446
|
+
if args.add or args.drop or args.update:
|
447
|
+
replacements = update_versions(
|
448
|
+
path, add=args.add, drop=args.drop,
|
449
|
+
update=args.update, diff=args.diff,
|
450
|
+
dry_run=args.dry_run, only=only)
|
451
|
+
if not args.diff or args.dry_run:
|
452
|
+
if not check_versions(path, expect=args.expect,
|
453
|
+
replacements=replacements,
|
454
|
+
only=only,
|
455
|
+
min_width=min_width):
|
456
|
+
mismatches.append(path)
|
457
|
+
continue
|
458
|
+
|
459
|
+
if not args.diff or args.dry_run:
|
460
|
+
if mismatches:
|
461
|
+
if multiple:
|
462
|
+
sys.exit(f"\n\nmismatch in {' '.join(mismatches)}!")
|
463
|
+
else:
|
464
|
+
sys.exit("\nmismatch!")
|
465
|
+
elif multiple:
|
466
|
+
print("\n\nall ok!")
|
467
|
+
|
468
|
+
|
469
|
+
def main() -> None:
|
470
|
+
"""The main function.
|
471
|
+
|
472
|
+
It is here because I detest programs that print tracebacks when they're
|
473
|
+
terminated with a Ctrl+C. I could inline _main() here, but I didn't want
|
474
|
+
to indent all of that code. Maybe I should've added a decorator instead.
|
475
|
+
"""
|
476
|
+
try:
|
477
|
+
_main()
|
478
|
+
except KeyboardInterrupt:
|
479
|
+
sys.exit(2)
|
@@ -0,0 +1 @@
|
|
1
|
+
"""Low-level parsers for various file formats."""
|
@@ -0,0 +1,81 @@
|
|
1
|
+
"""
|
2
|
+
Tools for manipulating PyPI classifiers.
|
3
|
+
"""
|
4
|
+
from typing import List, Sequence
|
5
|
+
|
6
|
+
from ..versions import SortedVersionList, Version, expand_pypy
|
7
|
+
|
8
|
+
|
9
|
+
def is_version_classifier(s: str) -> bool:
|
10
|
+
"""Is this classifier a Python version classifer?"""
|
11
|
+
prefix = 'Programming Language :: Python :: '
|
12
|
+
return s.startswith(prefix) and s[len(prefix):len(prefix) + 1].isdigit()
|
13
|
+
|
14
|
+
|
15
|
+
def is_major_version_classifier(s: str) -> bool:
|
16
|
+
"""Is this classifier a major Python version classifer?
|
17
|
+
|
18
|
+
That is, is this a version classifier that omits the minor version?
|
19
|
+
"""
|
20
|
+
prefix = 'Programming Language :: Python :: '
|
21
|
+
return (
|
22
|
+
s.startswith(prefix)
|
23
|
+
and s[len(prefix):].replace(' :: Only', '').isdigit()
|
24
|
+
)
|
25
|
+
|
26
|
+
|
27
|
+
def get_versions_from_classifiers(
|
28
|
+
classifiers: Sequence[str],
|
29
|
+
) -> SortedVersionList:
|
30
|
+
"""Extract supported Python versions from classifiers."""
|
31
|
+
# Based on
|
32
|
+
# https://github.com/mgedmin/project-summary/blob/master/summary.py#L221-L234
|
33
|
+
prefix = 'Programming Language :: Python :: '
|
34
|
+
impl_prefix = 'Programming Language :: Python :: Implementation :: '
|
35
|
+
cpython = impl_prefix + 'CPython'
|
36
|
+
versions = {
|
37
|
+
s[len(prefix):].replace(' :: Only', '').rstrip()
|
38
|
+
for s in classifiers
|
39
|
+
if is_version_classifier(s)
|
40
|
+
} | {
|
41
|
+
s[len(impl_prefix):].rstrip()
|
42
|
+
for s in classifiers
|
43
|
+
if s.startswith(impl_prefix) and s != cpython
|
44
|
+
}
|
45
|
+
for major in '2', '3':
|
46
|
+
if major in versions and any(
|
47
|
+
v.startswith(f'{major}.') for v in versions):
|
48
|
+
versions.remove(major)
|
49
|
+
return expand_pypy(list(map(Version.from_string, versions)))
|
50
|
+
|
51
|
+
|
52
|
+
def update_classifiers(
|
53
|
+
classifiers: Sequence[str],
|
54
|
+
new_versions: SortedVersionList
|
55
|
+
) -> List[str]:
|
56
|
+
"""Update a list of classifiers with new Python versions."""
|
57
|
+
prefix = 'Programming Language :: Python :: '
|
58
|
+
|
59
|
+
for pos, s in enumerate(classifiers):
|
60
|
+
if is_version_classifier(s):
|
61
|
+
break
|
62
|
+
else:
|
63
|
+
pos = len(classifiers)
|
64
|
+
|
65
|
+
if any(map(is_major_version_classifier, classifiers)):
|
66
|
+
new_versions = sorted(
|
67
|
+
set(new_versions).union(
|
68
|
+
v._replace(prefix='', minor=-1, suffix='')
|
69
|
+
for v in new_versions
|
70
|
+
)
|
71
|
+
)
|
72
|
+
|
73
|
+
classifiers = [
|
74
|
+
s for s in classifiers if not is_version_classifier(s)
|
75
|
+
]
|
76
|
+
new_classifiers = [
|
77
|
+
f'{prefix}{version}'
|
78
|
+
for version in new_versions
|
79
|
+
]
|
80
|
+
classifiers[pos:pos] = new_classifiers
|
81
|
+
return classifiers
|
@@ -0,0 +1,81 @@
|
|
1
|
+
"""
|
2
|
+
Tools for manipulating INI files.
|
3
|
+
|
4
|
+
I want to preserve formatting and comments, therefore I cannot use a standard
|
5
|
+
INI parser and serializer.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import re
|
9
|
+
from typing import List
|
10
|
+
|
11
|
+
from ..utils import FileLines, get_indent, warn
|
12
|
+
|
13
|
+
|
14
|
+
def update_ini_setting(
|
15
|
+
orig_lines: FileLines,
|
16
|
+
section: str,
|
17
|
+
key: str,
|
18
|
+
new_value: str,
|
19
|
+
*,
|
20
|
+
filename: str,
|
21
|
+
) -> FileLines:
|
22
|
+
"""Update a setting in an .ini file.
|
23
|
+
|
24
|
+
Preserves formatting and comments.
|
25
|
+
|
26
|
+
``orig_lines`` contains the old contents of the INI file.
|
27
|
+
|
28
|
+
``section`` and ``key`` specify which value in which section need to be
|
29
|
+
updated. It is an error if the section or the key do not exist.
|
30
|
+
|
31
|
+
``filename`` is used for error reporting.
|
32
|
+
|
33
|
+
Returns the updated contents.
|
34
|
+
"""
|
35
|
+
lines = iter(enumerate(orig_lines))
|
36
|
+
for n, line in lines:
|
37
|
+
if line.startswith(f'[{section}]'):
|
38
|
+
break
|
39
|
+
else:
|
40
|
+
warn(f'Did not find [{section}] section in {filename}')
|
41
|
+
return orig_lines
|
42
|
+
|
43
|
+
space = prefix = ' '
|
44
|
+
for n, line in lines:
|
45
|
+
m = re.match(fr'{re.escape(key)}(\s*)=(\s*)', line.rstrip())
|
46
|
+
if m:
|
47
|
+
start = n
|
48
|
+
space = m.group(1)
|
49
|
+
if not line.rstrip().endswith('='):
|
50
|
+
prefix = m.group(2)
|
51
|
+
break
|
52
|
+
else:
|
53
|
+
warn(f'Did not find {key}= in [{section}] in {filename}')
|
54
|
+
return orig_lines
|
55
|
+
|
56
|
+
end = start + 1
|
57
|
+
comments = []
|
58
|
+
pending_comments: List[str] = []
|
59
|
+
indent = ' '
|
60
|
+
for n, line in lines:
|
61
|
+
if line.startswith(' '):
|
62
|
+
indent = get_indent(line)
|
63
|
+
comments += pending_comments
|
64
|
+
pending_comments = []
|
65
|
+
end = n + 1
|
66
|
+
elif line.lstrip().startswith('#'):
|
67
|
+
pending_comments.append(line)
|
68
|
+
else:
|
69
|
+
break
|
70
|
+
|
71
|
+
firstline = orig_lines[start].strip().expandtabs().replace(' ', '')
|
72
|
+
if firstline == f'{key}=':
|
73
|
+
if end > start + 1:
|
74
|
+
prefix = f'\n{"".join(comments)}{indent}'
|
75
|
+
|
76
|
+
new_value = new_value.replace('\n', '\n' + indent)
|
77
|
+
new_lines = orig_lines[:start] + (
|
78
|
+
f"{key}{space}={prefix}{new_value}\n"
|
79
|
+
).splitlines(True) + orig_lines[end:]
|
80
|
+
|
81
|
+
return new_lines
|