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,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,7 @@
1
+ """Support python -m check_python_versions."""
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == '__main__':
7
+ main()
@@ -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