pystand 2.11__py3-none-any.whl → 2.13.1__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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: pystand
3
- Version: 2.11
3
+ Version: 2.13.1
4
4
  Summary: Install Python versions from python-build-standalone project
5
5
  Author-email: Mark Blakeney <mark.blakeney@bullet-systems.net>
6
6
  License: GPLv3
@@ -27,8 +27,8 @@ provided:
27
27
  |Command |Description |
28
28
  |---------|----------------------------------------------------------------------|
29
29
  |`install`|Install one or more versions from a python-build-standalone release |
30
- |`update` |Update one, more, or all versions to another release |
31
- |`remove` |Remove/uninstall one, more, or all versions |
30
+ |`update` (or `upgrade`) |Update one, more, or all versions to another release |
31
+ |`remove` (or `uninstall`) |Remove/uninstall one, more, or all versions |
32
32
  |`list` |List installed versions and show which have an update available |
33
33
  |`show` |Show versions available from a release |
34
34
  |`path` |Show path prefix to installed version base directory |
@@ -99,9 +99,9 @@ $ uv venv -p $(pystand path 3.12) myenv
99
99
  # installed python 3.12:
100
100
  $ $(pystand path -p 3.12) -m venv myenv
101
101
 
102
- # Use pipx to install a package to be run with pystand installed python
102
+ # Use uv tool to install a package to be run with pystand installed python
103
103
  # specific version 3.11.1:
104
- $ pipx install --python $(pystand path -p 3.11.1) cowsay
104
+ $ uv tool install -p $(pystand path 3.11.1) cowsay
105
105
  ```
106
106
 
107
107
  See detailed usage information in the [Usage](#usage) section that
@@ -139,17 +139,17 @@ sh/python-build-standalone.
139
139
 
140
140
  options:
141
141
  -h, --help show this help message and exit
142
- -D DISTRIBUTION, --distribution DISTRIBUTION
142
+ -D, --distribution DISTRIBUTION
143
143
  python-build-standalone distribution. Default is
144
144
  "x86_64_v3-unknown-linux-gnu-install_only_stripped for
145
145
  this host
146
- -P PREFIX_DIR, --prefix-dir PREFIX_DIR
146
+ -P, --prefix-dir PREFIX_DIR
147
147
  specify prefix dir for storing versions. Default is
148
148
  "$HOME/.local/share/pystand"
149
- -C CACHE_DIR, --cache-dir CACHE_DIR
149
+ -C, --cache-dir CACHE_DIR
150
150
  specify cache dir for downloads. Default is
151
151
  "$HOME/.cache/pystand"
152
- -M CACHE_MINUTES, --cache-minutes CACHE_MINUTES
152
+ -M, --cache-minutes CACHE_MINUTES
153
153
  cache latest YYYYMMDD release tag fetch for this many
154
154
  minutes, before rechecking for latest. Default is 60
155
155
  minutes
@@ -174,7 +174,7 @@ Commands:
174
174
  show Show versions available from a release.
175
175
  path Show path prefix to installed version base directory.
176
176
 
177
- Some commands offer aliases as shown in brackets above. Note you can set
177
+ Some commands offer aliases as shown in parentheses above. Note you can set
178
178
  default starting global options in $HOME/.config/pystand-flags.conf.
179
179
  ```
180
180
 
@@ -193,7 +193,7 @@ positional arguments:
193
193
 
194
194
  options:
195
195
  -h, --help show this help message and exit
196
- -r RELEASE, --release RELEASE
196
+ -r, --release RELEASE
197
197
  install from specified python-build-standalone
198
198
  YYYYMMDD release (e.g. 20240415), default is latest
199
199
  release
@@ -214,7 +214,7 @@ positional arguments:
214
214
 
215
215
  options:
216
216
  -h, --help show this help message and exit
217
- -r RELEASE, --release RELEASE
217
+ -r, --release RELEASE
218
218
  update to specified YYYMMDD release (e.g. 20240415),
219
219
  default is latest release
220
220
  -a, --all update ALL versions
@@ -241,7 +241,7 @@ options:
241
241
  -a, --all remove ALL versions
242
242
  --skip skip the specified versions when removing all (only
243
243
  can be specified with --all)
244
- -r RELEASE, --release RELEASE
244
+ -r, --release RELEASE
245
245
  only remove versions if from specified YYYMMDD release
246
246
  (e.g. 20240415)
247
247
 
@@ -262,7 +262,7 @@ options:
262
262
  -h, --help show this help message and exit
263
263
  -v, --verbose explicitly report why a version is not eligible for
264
264
  update
265
- -r RELEASE, --release RELEASE
265
+ -r, --release RELEASE
266
266
  use specified YYYYMMDD release (e.g. 20240415) for
267
267
  verbose compare, default is latest release
268
268
  ```
@@ -283,7 +283,7 @@ positional arguments:
283
283
  options:
284
284
  -h, --help show this help message and exit
285
285
  -l, --list just list recent releases
286
- -r RELEASE, --release RELEASE
286
+ -r, --release RELEASE
287
287
  python-build-standalone YYYYMMDD release to show (e.g.
288
288
  20240415), default is latest release
289
289
  -a, --all show all available distributions for each version from
@@ -313,23 +313,24 @@ Python 3.8 or later is required. Arch Linux users can install [`pystand`
313
313
  from the AUR](https://aur.archlinux.org/packages/pystand) and skip this
314
314
  section.
315
315
 
316
- The easiest way to install [`pystand`][pystand] is to use [`pipx`][pipx]
317
- (or [`pipxu`][pipxu], or [`uv tool`][uvtool]).
316
+ Note [pystand is on PyPI](https://pypi.org/project/pystand/) so the
317
+ easiest way to install it is to use [`uv tool`][uvtool] (or
318
+ [`pipx`][pipx] or [`pipxu`][pipxu]).
318
319
 
319
320
  ```sh
320
- $ pipx install pystand
321
+ $ uv tool install pystand
321
322
  ```
322
323
 
323
324
  To upgrade:
324
325
 
325
326
  ```sh
326
- $ pipx upgrade pystand
327
+ $ uv tool upgrade pystand
327
328
  ```
328
329
 
329
330
  To uninstall:
330
331
 
331
332
  ```sh
332
- $ pipx uninstall pystand
333
+ $ uv tool uninstall pystand
333
334
  ```
334
335
 
335
336
  ## Extrapolation of Python Versions
@@ -0,0 +1,6 @@
1
+ pystand.py,sha256=I7VERu60r7H6iCUy5SJvFv5_fT36MeNrIEZUzsoq-RM,36843
2
+ pystand-2.13.1.dist-info/METADATA,sha256=LsY47B2HgxjzssPumImNJmjcex6-uq4q5Z_6y022KNo,24811
3
+ pystand-2.13.1.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
4
+ pystand-2.13.1.dist-info/entry_points.txt,sha256=DG4ps3I3nni1bubV1tXs6u8FARgkdbAYaEAzZD4RAo8,41
5
+ pystand-2.13.1.dist-info/top_level.txt,sha256=NoWUh19UQymAJLHTCdxMnVwV6Teftef5fzyF3OWLyNY,8
6
+ pystand-2.13.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.6.0)
2
+ Generator: setuptools (75.8.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
pystand.py CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/python3
2
2
  # PYTHON_ARGCOMPLETE_OK
3
- '''
3
+ """
4
4
  Command line tool to download, install, and update pre-built Python
5
5
  versions from the python-build-standalone project at
6
6
  https://github.com/astral-sh/python-build-standalone.
7
- '''
7
+ """
8
+
8
9
  from __future__ import annotations
9
10
 
10
11
  import os
@@ -44,22 +45,25 @@ DISTRIBUTIONS = {
44
45
  ('Linux', 'armv8l'): 'armv7-unknown-linux-gnueabihf-install_only_stripped',
45
46
  ('Darwin', 'x86_64'): 'x86_64-apple-darwin-install_only_stripped',
46
47
  ('Darwin', 'aarch64'): 'aarch64-apple-darwin-install_only_stripped',
47
- ('Windows', 'x86_64'):
48
- 'x86_64-pc-windows-msvc-shared-install_only_stripped',
48
+ ('Windows', 'x86_64'): 'x86_64-pc-windows-msvc-shared-install_only_stripped',
49
49
  ('Windows', 'i686'): 'i686-pc-windows-msvc-shared-install_only_stripped',
50
50
  }
51
51
 
52
+
52
53
  def is_admin() -> bool:
53
- 'Check if we are running as root'
54
+ "Check if we are running as root"
54
55
  if platform.system() == 'Windows':
55
56
  import ctypes
57
+
56
58
  return ctypes.windll.shell32.IsUserAnAdmin() != 0 # type: ignore
57
59
 
58
60
  return os.geteuid() == 0
59
61
 
62
+
60
63
  def get_version() -> str:
61
- 'Return the version of this package'
64
+ "Return the version of this package"
62
65
  from importlib.metadata import version
66
+
63
67
  try:
64
68
  ver = version(PROG)
65
69
  except Exception:
@@ -67,12 +71,15 @@ def get_version() -> str:
67
71
 
68
72
  return ver
69
73
 
74
+
70
75
  def fmt(version, release) -> str:
71
- 'Return a formatted release version string'
76
+ "Return a formatted release version string"
72
77
  return f'{version} @ {release}'
73
78
 
79
+
74
80
  def get_json(file: Path) -> dict:
75
81
  from json import load
82
+
76
83
  'Get JSON data from given file'
77
84
  try:
78
85
  with file.open() as fp:
@@ -82,9 +89,11 @@ def get_json(file: Path) -> dict:
82
89
 
83
90
  return {}
84
91
 
92
+
85
93
  def set_json(file: Path, data: dict) -> str | None:
86
- 'Set JSON data to given file'
94
+ "Set JSON data to given file"
87
95
  from json import dump
96
+
88
97
  try:
89
98
  with file.open('w') as fp:
90
99
  dump(data, fp, indent=2)
@@ -93,11 +102,13 @@ def set_json(file: Path, data: dict) -> str | None:
93
102
 
94
103
  return None
95
104
 
105
+
96
106
  # The gh handle is an opaque github instance handle
97
107
  get_gh_handle = None
98
108
 
109
+
99
110
  def get_gh(args: Namespace) -> Any:
100
- 'Return a GitHub handle'
111
+ "Return a GitHub handle"
101
112
  # The gh handle is a global to lazily create it only if/when needed
102
113
  global get_gh_handle
103
114
  if get_gh_handle:
@@ -105,17 +116,20 @@ def get_gh(args: Namespace) -> Any:
105
116
 
106
117
  if args.github_access_token:
107
118
  from github.Auth import Token
119
+
108
120
  auth = Token(args.github_access_token)
109
121
  else:
110
122
  auth = None
111
123
 
112
124
  # Save this handle globally for future use
113
125
  from github import Github
126
+
114
127
  get_gh_handle = Github(auth=auth) # type: ignore
115
128
  return get_gh_handle
116
129
 
130
+
117
131
  def rm_path(path: Path) -> None:
118
- 'Remove the given path'
132
+ "Remove the given path"
119
133
  if path.is_symlink():
120
134
  path.unlink()
121
135
  elif path.is_dir():
@@ -123,21 +137,25 @@ def rm_path(path: Path) -> None:
123
137
  elif path.exists():
124
138
  path.unlink()
125
139
 
140
+
126
141
  def unpack_zst(filename: str, extract_dir: str) -> None:
127
- 'Unpack a zstandard compressed tar'
142
+ "Unpack a zstandard compressed tar"
128
143
  import tarfile
129
144
 
130
145
  import zstandard
146
+
131
147
  with open(filename, 'rb') as compressed:
132
148
  dctx = zstandard.ZstdDecompressor()
133
149
  with dctx.stream_reader(compressed) as reader:
134
150
  with tarfile.open(fileobj=reader, mode='r|') as tar:
135
151
  tar.extractall(path=extract_dir)
136
152
 
153
+
137
154
  def fetch(args: Namespace, release: str, url: str, tdir: Path) -> str | None:
138
- 'Fetch and unpack a release file'
155
+ "Fetch and unpack a release file"
139
156
  from urllib.parse import unquote, urlparse
140
157
  from urllib.request import urlretrieve
158
+
141
159
  error = None
142
160
  tmpdir = tdir.with_name(f'{tdir.name}-tmp')
143
161
  rm_path(tmpdir)
@@ -184,18 +202,20 @@ def fetch(args: Namespace, release: str, url: str, tdir: Path) -> str | None:
184
202
  rm_path(tmpdir)
185
203
  return error
186
204
 
205
+
187
206
  def is_release_version(version: str) -> bool:
188
- 'Check if a string is a formal Python release tag'
207
+ "Check if a string is a formal Python release tag"
189
208
  return version.replace('.', '').isdigit()
190
209
 
210
+
191
211
  class VersionMatcher:
192
- 'Match a version string to a list of versions'
212
+ "Match a version string to a list of versions"
213
+
193
214
  def __init__(self, seq: Iterable[str]) -> None:
194
215
  self.seq = sorted(seq, key=parse_version, reverse=True)
195
216
 
196
- def match(self, version: str | None, *,
197
- upgrade: bool = False) -> str | None:
198
- 'Return full version string given a [possibly] part version prefix'
217
+ def match(self, version: str | None, *, upgrade: bool = False) -> str | None:
218
+ "Return full version string given a [possibly] part version prefix"
199
219
 
200
220
  # If no version specified, return the latest release version
201
221
  if not version:
@@ -222,25 +242,31 @@ class VersionMatcher:
222
242
  # release.
223
243
  for full_version in self.seq:
224
244
  if full_version.startswith(version):
225
- if not upgrade or not is_release \
226
- or is_release_version(full_version):
245
+ if not upgrade or not is_release or is_release_version(full_version):
227
246
  return full_version
228
247
 
229
248
  return None
230
249
 
250
+
231
251
  def iter_versions(args: Namespace) -> Iterator[Path]:
232
- 'Iterate over all version dirs'
252
+ "Iterate over all version dirs"
233
253
  for f in args._versions.iterdir():
234
- if f.is_dir() and not f.is_symlink() \
235
- and f.name[0] != '.' and f.name[0].isdigit():
254
+ if (
255
+ f.is_dir()
256
+ and not f.is_symlink()
257
+ and f.name[0] != '.'
258
+ and f.name[0].isdigit()
259
+ ):
236
260
  yield f
237
261
 
262
+
238
263
  def get_version_names(args: Namespace) -> list[str]:
239
- 'Return a list of validated version names based on command line args'
264
+ "Return a list of validated version names based on command line args"
240
265
  if args.all:
241
266
  if not args.skip and args.version:
242
- args.parser.error('Can not specify versions with '
243
- '--all unless also specifying --skip.')
267
+ args.parser.error(
268
+ 'Can not specify versions with --all unless also specifying --skip.'
269
+ )
244
270
  else:
245
271
  if args.skip:
246
272
  args.parser.error('--skip can only be specified with --all.')
@@ -256,16 +282,16 @@ def get_version_names(args: Namespace) -> list[str]:
256
282
 
257
283
  given = set(versions)
258
284
 
259
- if (unknown := given - all_names):
285
+ if unknown := given - all_names:
260
286
  s = 's' if len(unknown) > 1 else ''
261
287
  unknowns = [f'"{u}"' for u in unknown]
262
288
  sys.exit(f'Error: version{s} {", ".join(unknowns)} not found.')
263
289
 
264
- return sorted(all_names - given, key=parse_version) \
265
- if args.all else versions
290
+ return sorted(all_names - given, key=parse_version) if args.all else versions
291
+
266
292
 
267
293
  def check_release_tag(release: str) -> str | None:
268
- 'Check the specified release tag is valid'
294
+ "Check the specified release tag is valid"
269
295
  if not release.isdigit() or len(release) != len(SAMPL_RELEASE):
270
296
  return 'Release must be a YYYYMMDD string.'
271
297
 
@@ -276,13 +302,15 @@ def check_release_tag(release: str) -> str | None:
276
302
 
277
303
  return None
278
304
 
305
+
279
306
  # Note we use a simple direct URL fetch to get the latest tag info
280
307
  # because it is much faster than using the GitHub API, and has no
281
308
  # rate-limits.
282
309
  def fetch_tags() -> Iterator[tuple[str, str]]:
283
- 'Fetch the latest release tags from the GitHub release atom feed'
310
+ "Fetch the latest release tags from the GitHub release atom feed"
284
311
  import xml.etree.ElementTree as et
285
312
  from urllib.request import urlopen
313
+
286
314
  try:
287
315
  with urlopen(LATEST_RELEASES) as url:
288
316
  data = et.parse(url).getroot()
@@ -296,9 +324,11 @@ def fetch_tags() -> Iterator[tuple[str, str]]:
296
324
  if tl and dt:
297
325
  yield tl, dt
298
326
 
327
+
299
328
  def fetch_tag_latest() -> str:
300
- 'Fetch the latest release tag from the GitHub'
329
+ "Fetch the latest release tag from the GitHub"
301
330
  from urllib.request import urlopen
331
+
302
332
  try:
303
333
  with urlopen(LATEST_RELEASE_TAG) as url:
304
334
  data = url.geturl()
@@ -307,8 +337,9 @@ def fetch_tag_latest() -> str:
307
337
 
308
338
  return data.split('/')[-1]
309
339
 
340
+
310
341
  def get_release_tag(args: Namespace) -> str:
311
- 'Return the release tag, or latest if not specified'
342
+ "Return the release tag, or latest if not specified"
312
343
  if release := args.release:
313
344
  if err := check_release_tag(release):
314
345
  sys.exit(err)
@@ -326,8 +357,9 @@ def get_release_tag(args: Namespace) -> str:
326
357
  args._latest_release.write_text(tag + '\n')
327
358
  return tag
328
359
 
360
+
329
361
  def add_file(files: dict, tag: str, name: str, url: str) -> None:
330
- 'Extract the implementation, version, and architecture from a filename'
362
+ "Extract the implementation, version, and architecture from a filename"
331
363
  if name.endswith('.tar.zst'):
332
364
  name = name[:-8]
333
365
  elif name.endswith('.tar.gz'):
@@ -353,18 +385,19 @@ def add_file(files: dict, tag: str, name: str, url: str) -> None:
353
385
 
354
386
  vers[ver][arch] = url
355
387
 
388
+
356
389
  def get_release_files(args, tag, implementation: str | None = None) -> dict:
357
- 'Return the release files for the given tag'
390
+ "Return the release files for the given tag"
358
391
  # Look for tag data in our release cache
359
392
  jfile = args._releases / tag
360
393
  if not (files := get_json(jfile)):
361
-
362
394
  # May have read this release before but it has no assets
363
395
  if jfile.exists():
364
396
  return {}
365
397
 
366
398
  # Not in cache so fetch it (and also store in cache)
367
399
  from github.GithubException import UnknownObjectException
400
+
368
401
  gh = get_gh(args)
369
402
  try:
370
403
  release = gh.get_repo(GITHUB_REPO).get_release(tag)
@@ -384,8 +417,9 @@ def get_release_files(args, tag, implementation: str | None = None) -> dict:
384
417
 
385
418
  return files.get(implementation, {}) if implementation else files
386
419
 
420
+
387
421
  def update_version_symlinks(args: Namespace) -> None:
388
- 'Create/update symlinks pointing to latest version'
422
+ "Create/update symlinks pointing to latest version"
389
423
  base = args._versions
390
424
  if not base.exists():
391
425
  return
@@ -435,11 +469,13 @@ def update_version_symlinks(args: Namespace) -> None:
435
469
  if not old_tgt or old_tgt != tgt:
436
470
  (base / name).symlink_to(tgt, target_is_directory=True)
437
471
 
472
+
438
473
  def purge_unused_releases(args: Namespace) -> None:
439
- 'Purge old releases that are no longer needed and have expired'
474
+ "Purge old releases that are no longer needed and have expired"
440
475
  # Want to keep releases for versions that we currently have installed
441
- keep = {r for v in iter_versions(args)
442
- if (r := get_json(v / args._data).get('release'))}
476
+ keep = {
477
+ r for v in iter_versions(args) if (r := get_json(v / args._data).get('release'))
478
+ }
443
479
 
444
480
  # Add current release to keep list (even if not currently installed)
445
481
  if args._latest_release.exists():
@@ -460,28 +496,51 @@ def purge_unused_releases(args: Namespace) -> None:
460
496
  if path.name not in keep:
461
497
  rm_path(path)
462
498
 
463
- class COMMAND:
464
- 'Base class for all commands'
465
- commands = []
466
499
 
467
- @classmethod
468
- def add(cls, parent) -> None:
469
- 'Append parent command to internal list'
470
- cls.commands.append(parent)
500
+ def show_list(args: Namespace) -> None:
501
+ "Show a list of available releases"
502
+ latest = parse_version(get_release_tag(args))
503
+ releases = {r: d for r, d in fetch_tags()}
504
+ cached = set(p.name for p in args._releases.iterdir())
505
+ for release in sorted(cached.union(releases)):
506
+ if args.re_match and not re.search(args.re_match, release):
507
+ continue
508
+
509
+ if dt_str := releases.get(release):
510
+ dts = (
511
+ datetime.fromisoformat(dt_str)
512
+ .astimezone()
513
+ .isoformat(sep='_', timespec='minutes')
514
+ )
515
+ else:
516
+ dts = '......................'
517
+
518
+ if release in cached:
519
+ ddir = args._downloads / release
520
+ count = len(list(ddir.iterdir())) if ddir.exists() else 0
521
+ app = f' cached + {count} downloaded files' if count > 0 else ' cached'
522
+ else:
523
+ app = ''
524
+
525
+ pre = ' pre-release' if parse_version(release) > latest else ''
526
+
527
+ print(f'{release} {dts}{app}{pre}')
528
+
471
529
 
472
530
  def get_title(desc: str) -> str:
473
- 'Return single title line from description'
531
+ "Return single title line from description"
474
532
  res = []
475
533
  for line in desc.splitlines():
476
534
  line = line.strip()
477
535
  res.append(line)
478
536
  if line.endswith('.'):
479
- return ' '. join(res)
537
+ return ' '.join(res)
480
538
 
481
539
  sys.exit('Must end description with a full stop.')
482
540
 
541
+
483
542
  def remove(args: Namespace, version: str) -> None:
484
- 'Remove a version'
543
+ "Remove a version"
485
544
  vdir = args._versions / version
486
545
  if not vdir.exists():
487
546
  return
@@ -493,8 +552,9 @@ def remove(args: Namespace, version: str) -> None:
493
552
 
494
553
  shutil.rmtree(vdir)
495
554
 
555
+
496
556
  def strip_binaries(vdir: Path, distribution: str) -> bool:
497
- 'Strip binaries from files in a version directory'
557
+ "Strip binaries from files in a version directory"
498
558
  from subprocess import DEVNULL, run
499
559
 
500
560
  # Only run the strip command on Linux hosts and for Linux distributions
@@ -517,14 +577,17 @@ def strip_binaries(vdir: Path, distribution: str) -> bool:
517
577
 
518
578
  return was_stripped
519
579
 
520
- def install(args: Namespace, vdir: Path, release: str, distribution: str,
521
- files: dict) -> str | None:
522
- 'Install a version'
580
+
581
+ def install(
582
+ args: Namespace, vdir: Path, release: str, distribution: str, files: dict
583
+ ) -> str | None:
584
+ "Install a version"
523
585
  version = vdir.name
524
586
 
525
587
  if not (url := files[version].get(distribution)):
526
- return f'Arch "{distribution}" not found for release '\
527
- f'{release} version {version}.'
588
+ return (
589
+ f'Arch "{distribution}" not found for release {release} version {version}.'
590
+ )
528
591
 
529
592
  tmpdir = args._versions / f'.{version}-tmp'
530
593
  rm_path(tmpdir)
@@ -536,7 +599,7 @@ def install(args: Namespace, vdir: Path, release: str, distribution: str,
536
599
  if not args.no_strip and strip_binaries(tmpdir, distribution):
537
600
  data['stripped'] = 'true'
538
601
 
539
- if (error := set_json(tmpdir / args._data, data)):
602
+ if error := set_json(tmpdir / args._data, data):
540
603
  error = f'Failed to write {version} data file: {error}'
541
604
 
542
605
  if error:
@@ -547,8 +610,9 @@ def install(args: Namespace, vdir: Path, release: str, distribution: str,
547
610
 
548
611
  return error
549
612
 
613
+
550
614
  def main() -> str | None:
551
- 'Main code'
615
+ "Main code"
552
616
  distro_default = DISTRIBUTIONS.get((platform.system(), platform.machine()))
553
617
  distro_help = distro_default or '?unknown?'
554
618
 
@@ -557,41 +621,67 @@ def main() -> str | None:
557
621
  cache_dir = platformdirs.user_cache_path() / PROG
558
622
 
559
623
  # Parse arguments
560
- opt = ArgumentParser(description=__doc__,
561
- epilog='Some commands offer aliases as shown in brackets above. '
562
- 'Note you can set default starting global options in '
563
- f'{CNFFILE}.')
624
+ opt = ArgumentParser(
625
+ description=__doc__,
626
+ epilog='Some commands offer aliases as shown in parentheses above. '
627
+ 'Note you can set default starting global options in '
628
+ f'{CNFFILE}.',
629
+ )
564
630
 
565
631
  # Set up main/global arguments
566
- opt.add_argument('-D', '--distribution',
567
- help=f'{REPO} distribution. '
568
- f'Default is "{distro_help} for this host')
569
- opt.add_argument('-P', '--prefix-dir', default=prefix_dir,
570
- help='specify prefix dir for storing '
571
- 'versions. Default is "%(default)s"')
572
- opt.add_argument('-C', '--cache-dir', default=str(cache_dir),
573
- help='specify cache dir for downloads. '
574
- 'Default is "%(default)s"')
575
- opt.add_argument('-M', '--cache-minutes', default=60, type=float,
576
- help='cache latest YYYYMMDD release tag fetch for this '
577
- 'many minutes, before rechecking for latest. '
578
- 'Default is %(default)d minutes')
579
- opt.add_argument('--purge-days', default=90, type=int,
580
- help='cache YYYYMMDD release file lists and downloads for '
581
- 'this number of days after last version referencing that '
582
- 'release is removed. Default is %(default)d days')
583
- opt.add_argument('--github-access-token',
584
- help='optional Github access token. Can specify to reduce '
585
- 'rate limiting.')
586
- opt.add_argument('--no-strip', action='store_true',
587
- help='do not strip downloaded binaries')
588
- opt.add_argument('-V', '--version', action='store_true',
589
- help=f'just show {PROG} version')
632
+ opt.add_argument(
633
+ '-D',
634
+ '--distribution',
635
+ help=f'{REPO} distribution. Default is "{distro_help} for this host',
636
+ )
637
+ opt.add_argument(
638
+ '-P',
639
+ '--prefix-dir',
640
+ default=prefix_dir,
641
+ help='specify prefix dir for storing versions. Default is "%(default)s"',
642
+ )
643
+ opt.add_argument(
644
+ '-C',
645
+ '--cache-dir',
646
+ default=str(cache_dir),
647
+ help='specify cache dir for downloads. Default is "%(default)s"',
648
+ )
649
+ opt.add_argument(
650
+ '-M',
651
+ '--cache-minutes',
652
+ default=60,
653
+ type=float,
654
+ help='cache latest YYYYMMDD release tag fetch for this '
655
+ 'many minutes, before rechecking for latest. '
656
+ 'Default is %(default)d minutes',
657
+ )
658
+ opt.add_argument(
659
+ '--purge-days',
660
+ default=90,
661
+ type=int,
662
+ help='cache YYYYMMDD release file lists and downloads for '
663
+ 'this number of days after last version referencing that '
664
+ 'release is removed. Default is %(default)d days',
665
+ )
666
+ opt.add_argument(
667
+ '--github-access-token',
668
+ help='optional Github access token. Can specify to reduce rate limiting.',
669
+ )
670
+ opt.add_argument(
671
+ '--no-strip', action='store_true', help='do not strip downloaded binaries'
672
+ )
673
+ opt.add_argument(
674
+ '-V', '--version', action='store_true', help=f'just show {PROG} version'
675
+ )
590
676
  cmd = opt.add_subparsers(title='Commands', dest='cmdname')
591
677
 
592
678
  # Add each command ..
593
- for cls in COMMAND.commands:
594
- name = cls.__name__[1:]
679
+ for name in globals():
680
+ if not name[0].islower() or not name.endswith('_'):
681
+ continue
682
+
683
+ cls = globals()[name]
684
+ name = name[:-1]
595
685
 
596
686
  if hasattr(cls, 'doc'):
597
687
  desc = cls.doc.strip()
@@ -602,8 +692,7 @@ def main() -> str | None:
602
692
 
603
693
  aliases = cls.aliases if hasattr(cls, 'aliases') else []
604
694
  title = get_title(desc)
605
- cmdopt = cmd.add_parser(name, description=desc, help=title,
606
- aliases=aliases)
695
+ cmdopt = cmd.add_parser(name, description=desc, help=title, aliases=aliases)
607
696
 
608
697
  # Set up this commands own arguments, if it has any
609
698
  if hasattr(cls, 'init'):
@@ -637,8 +726,10 @@ def main() -> str | None:
637
726
 
638
727
  distribution = args.distribution or distro_default
639
728
  if not distribution:
640
- sys.exit('Unknown system + machine distribution. Please specify '
641
- 'using -D/--distribution option.')
729
+ sys.exit(
730
+ 'Unknown system + machine distribution. Please specify '
731
+ 'using -D/--distribution option.'
732
+ )
642
733
 
643
734
  # Keep some useful info in the namespace passed to the command
644
735
  prefix_dir = Path(args.prefix_dir).expanduser().resolve()
@@ -661,23 +752,35 @@ def main() -> str | None:
661
752
  update_version_symlinks(args)
662
753
  return result
663
754
 
664
- @COMMAND.add
665
- class _install(COMMAND):
755
+
756
+ # COMMAND
757
+ class install_:
666
758
  doc = f'Install one or more versions from a {REPO} release.'
667
759
 
668
760
  @staticmethod
669
761
  def init(parser: ArgumentParser) -> None:
670
- parser.add_argument('-r', '--release',
671
- help=f'install from specified {REPO} '
672
- f'YYYYMMDD release (e.g. {SAMPL_RELEASE}), '
673
- 'default is latest release')
674
- parser.add_argument('-f', '--force', action='store_true',
675
- help='force install even if already installed')
676
- parser.add_argument('-s', '--include-source', action='store_true',
677
- help='also install source files if available in '
678
- 'distribution download')
679
- parser.add_argument('version', nargs='+',
680
- help='version to install. E.g. 3.12 or 3.12.3')
762
+ parser.add_argument(
763
+ '-r',
764
+ '--release',
765
+ help=f'install from specified {REPO} '
766
+ f'YYYYMMDD release (e.g. {SAMPL_RELEASE}), '
767
+ 'default is latest release',
768
+ )
769
+ parser.add_argument(
770
+ '-f',
771
+ '--force',
772
+ action='store_true',
773
+ help='force install even if already installed',
774
+ )
775
+ parser.add_argument(
776
+ '-s',
777
+ '--include-source',
778
+ action='store_true',
779
+ help='also install source files if available in distribution download',
780
+ )
781
+ parser.add_argument(
782
+ 'version', nargs='+', help='version to install. E.g. 3.12 or 3.12.3'
783
+ )
681
784
 
682
785
  @staticmethod
683
786
  def run(args: Namespace) -> str | None:
@@ -703,27 +806,40 @@ class _install(COMMAND):
703
806
 
704
807
  print(f'Version {fmt(version, release)} installed.')
705
808
 
706
- @COMMAND.add
707
- class _update(COMMAND):
708
- 'Update one, more, or all versions to another release.'
809
+
810
+ # COMMAND
811
+ class update_:
812
+ "Update one, more, or all versions to another release."
813
+
709
814
  aliases = ['upgrade']
710
815
 
711
816
  @staticmethod
712
817
  def init(parser: ArgumentParser) -> None:
713
- parser.add_argument('-r', '--release',
714
- help='update to specified YYYMMDD release (e.g. '
715
- f'{SAMPL_RELEASE}), default is latest release')
716
- parser.add_argument('-a', '--all', action='store_true',
717
- help='update ALL versions')
718
- parser.add_argument('--skip', action='store_true',
719
- help='skip the specified versions when '
720
- 'updating all (only can be specified with --all)')
721
- parser.add_argument('-k', '--keep', action='store_true',
722
- help='keep old version after updating (but only '
723
- 'if different version number)')
724
- parser.add_argument('version', nargs='*',
725
- help='version to update (or to skip for '
726
- '--all --skip)')
818
+ parser.add_argument(
819
+ '-r',
820
+ '--release',
821
+ help='update to specified YYYMMDD release (e.g. '
822
+ f'{SAMPL_RELEASE}), default is latest release',
823
+ )
824
+ parser.add_argument(
825
+ '-a', '--all', action='store_true', help='update ALL versions'
826
+ )
827
+ parser.add_argument(
828
+ '--skip',
829
+ action='store_true',
830
+ help='skip the specified versions when '
831
+ 'updating all (only can be specified with --all)',
832
+ )
833
+ parser.add_argument(
834
+ '-k',
835
+ '--keep',
836
+ action='store_true',
837
+ help='keep old version after updating (but only '
838
+ 'if different version number)',
839
+ )
840
+ parser.add_argument(
841
+ 'version', nargs='*', help='version to update (or to skip for --all --skip)'
842
+ )
727
843
 
728
844
  @staticmethod
729
845
  def run(args: Namespace) -> str | None:
@@ -749,54 +865,66 @@ class _update(COMMAND):
749
865
  continue
750
866
 
751
867
  if nextver == version and args.keep:
752
- print(f'Error: {fmt(version, release)} would not be kept '
753
- f'if update to {fmt(nextver, release_target)} '
754
- f'distribution="{distribution}"', file=sys.stderr)
868
+ print(
869
+ f'Error: {fmt(version, release)} would not be kept '
870
+ f'if update to {fmt(nextver, release_target)} '
871
+ f'distribution="{distribution}"',
872
+ file=sys.stderr,
873
+ )
755
874
  continue
756
875
 
757
876
  new_vdir = args._versions / nextver
758
877
  if nextver != version and new_vdir.exists():
759
878
  continue
760
879
 
761
- print(f'{fmt(version, release)} updating to '
762
- f'{fmt(nextver, release_target)} '
763
- f'distribution="{distribution}" ..')
880
+ print(
881
+ f'{fmt(version, release)} updating to '
882
+ f'{fmt(nextver, release_target)} '
883
+ f'distribution="{distribution}" ..'
884
+ )
764
885
 
765
886
  # If the source was originally included, then include it in
766
887
  # the update.
767
888
  args.include_source = (vdir / 'src').is_dir()
768
889
 
769
- if error := install(args, new_vdir, release_target, distribution,
770
- files):
890
+ if error := install(args, new_vdir, release_target, distribution, files):
771
891
  return error
772
892
 
773
893
  if nextver != version and not args.keep:
774
894
  remove(args, version)
775
895
 
776
- @COMMAND.add
777
- class _remove(COMMAND):
778
- 'Remove/uninstall one, more, or all versions.'
896
+
897
+ # COMMAND
898
+ class remove_:
899
+ "Remove/uninstall one, more, or all versions."
900
+
779
901
  aliases = ['uninstall']
780
902
 
781
903
  @staticmethod
782
904
  def init(parser: ArgumentParser) -> None:
783
- parser.add_argument('-a', '--all', action='store_true',
784
- help='remove ALL versions')
785
- parser.add_argument('--skip', action='store_true',
786
- help='skip the specified versions when '
787
- 'removing all (only can be specified with --all)')
788
- parser.add_argument('-r', '--release',
789
- help='only remove versions if from specified '
790
- f'YYYMMDD release (e.g. {SAMPL_RELEASE})')
791
- parser.add_argument('version', nargs='*',
792
- help='version to remove (or to skip for '
793
- '--all --skip)')
905
+ parser.add_argument(
906
+ '-a', '--all', action='store_true', help='remove ALL versions'
907
+ )
908
+ parser.add_argument(
909
+ '--skip',
910
+ action='store_true',
911
+ help='skip the specified versions when '
912
+ 'removing all (only can be specified with --all)',
913
+ )
914
+ parser.add_argument(
915
+ '-r',
916
+ '--release',
917
+ help='only remove versions if from specified '
918
+ f'YYYMMDD release (e.g. {SAMPL_RELEASE})',
919
+ )
920
+ parser.add_argument(
921
+ 'version', nargs='*', help='version to remove (or to skip for --all --skip)'
922
+ )
794
923
 
795
924
  @staticmethod
796
925
  def run(args: Namespace) -> str | None:
797
926
  release_del = args.release
798
- if release_del and \
799
- (err := check_release_tag(release_del)):
927
+ if release_del and (err := check_release_tag(release_del)):
800
928
  return err
801
929
 
802
930
  for version in get_version_names(args):
@@ -806,20 +934,29 @@ class _remove(COMMAND):
806
934
  remove(args, version)
807
935
  print(f'Version {fmt(version, release)} removed.')
808
936
 
809
- @COMMAND.add
810
- class _list(COMMAND):
811
- 'List installed versions and show which have an update available.'
937
+
938
+ # COMMAND
939
+ class list_:
940
+ "List installed versions and show which have an update available."
941
+
812
942
  @staticmethod
813
943
  def init(parser: ArgumentParser) -> None:
814
- parser.add_argument('-v', '--verbose', action='store_true',
815
- help='explicitly report why a version is '
816
- 'not eligible for update')
817
- parser.add_argument('-r', '--release',
818
- help='use specified YYYYMMDD release '
819
- f'(e.g. {SAMPL_RELEASE}) for verbose compare, '
820
- 'default is latest release')
821
- parser.add_argument('version', nargs='*',
822
- help='only list specified version, else all')
944
+ parser.add_argument(
945
+ '-v',
946
+ '--verbose',
947
+ action='store_true',
948
+ help='explicitly report why a version is not eligible for update',
949
+ )
950
+ parser.add_argument(
951
+ '-r',
952
+ '--release',
953
+ help='use specified YYYYMMDD release '
954
+ f'(e.g. {SAMPL_RELEASE}) for verbose compare, '
955
+ 'default is latest release',
956
+ )
957
+ parser.add_argument(
958
+ 'version', nargs='*', help='only list specified version, else all'
959
+ )
823
960
 
824
961
  @staticmethod
825
962
  def run(args: Namespace) -> str | None:
@@ -844,119 +981,141 @@ class _list(COMMAND):
844
981
  nextver = matcher.match(version, upgrade=True)
845
982
  if not nextver:
846
983
  if args.verbose:
847
- app = ' not eligible for update because '\
848
- f'release {release_target} does not provide '\
849
- 'this version.'
984
+ app = (
985
+ ' not eligible for update because '
986
+ f'release {release_target} does not provide '
987
+ 'this version.'
988
+ )
850
989
  else:
851
990
  new_vdir = args._versions / nextver
852
991
  if nextver != version and new_vdir.exists():
853
992
  if args.verbose:
854
- nrelease = get_json(
855
- new_vdir / args._data).get('release', '?')
856
- app = f' not eligible for '\
857
- f'update because {fmt(nextver, nrelease)} '\
858
- 'is already installed.'
993
+ nrelease = get_json(new_vdir / args._data).get(
994
+ 'release', '?'
995
+ )
996
+ app = (
997
+ f' not eligible for '
998
+ f'update because {fmt(nextver, nrelease)} '
999
+ 'is already installed.'
1000
+ )
859
1001
  else:
860
1002
  # May not be updatable if newer release does not support
861
1003
  # this same distribution anymore
862
1004
  if nextver and distribution in files.get(nextver, {}):
863
- upd = ' updatable to '\
864
- f'{fmt(nextver, release_target)}'
1005
+ upd = f' updatable to {fmt(nextver, release_target)}'
865
1006
  elif args.verbose:
866
- app = ' not eligible for update because '\
867
- f'{fmt(nextver, release_target)} does '\
868
- 'not provide '\
869
- f'distribution="{distribution}".'
1007
+ app = (
1008
+ ' not eligible for update because '
1009
+ f'{fmt(nextver, release_target)} does '
1010
+ 'not provide '
1011
+ f'distribution="{distribution}".'
1012
+ )
1013
+
1014
+ print(f'{fmt(version, release)}{upd} distribution="{distribution}"{app}')
870
1015
 
871
- print(f'{fmt(version, release)}{upd} '
872
- f'distribution="{distribution}"{app}')
873
1016
 
874
- @COMMAND.add
875
- class _show(COMMAND):
876
- doc = f'''
1017
+ # COMMAND
1018
+ class show_:
1019
+ doc = f"""
877
1020
  Show versions available from a release.
878
1021
 
879
1022
  View available releases and their distributions at
880
1023
  {GITHUB_SITE}/releases.
881
- '''
1024
+ """
882
1025
 
883
1026
  @staticmethod
884
1027
  def init(parser: ArgumentParser) -> None:
885
1028
  group = parser.add_mutually_exclusive_group()
886
- group.add_argument('-l', '--list', action='store_true',
887
- help='just list recent releases')
888
- group.add_argument('-r', '--release',
889
- help=f'{REPO} YYYYMMDD release to show (e.g. '
890
- f'{SAMPL_RELEASE}), default is latest release')
891
- parser.add_argument('-a', '--all', action='store_true',
892
- help='show all available distributions for '
893
- 'each version from the release')
894
- parser.add_argument('re_match', nargs='?',
895
- help='show only versions+distributions '
896
- 'matching this regular expression pattern')
1029
+ group.add_argument(
1030
+ '-l', '--list', action='store_true', help='just list recent releases'
1031
+ )
1032
+ group.add_argument(
1033
+ '-r',
1034
+ '--release',
1035
+ help=f'{REPO} YYYYMMDD release to show (e.g. '
1036
+ f'{SAMPL_RELEASE}), default is latest release',
1037
+ )
1038
+ parser.add_argument(
1039
+ '-a',
1040
+ '--all',
1041
+ action='store_true',
1042
+ help='show all available distributions for each version from the release',
1043
+ )
1044
+ parser.add_argument(
1045
+ 're_match',
1046
+ nargs='?',
1047
+ help='show only versions+distributions '
1048
+ 'matching this regular expression pattern',
1049
+ )
897
1050
 
898
1051
  @staticmethod
899
- def run(args: Namespace) -> None:
1052
+ def run(args: Namespace) -> str | None:
900
1053
  if args.all and args.list:
901
1054
  args.parser.error('Can not specify --all with --list.')
902
1055
 
903
1056
  if args.list:
904
- for title, datestr in fetch_tags():
905
- if args.re_match and not re.search(args.re_match, title):
906
- continue
907
-
908
- dts = datetime.fromisoformat(datestr).astimezone().isoformat(
909
- sep='_', timespec='minutes')
910
- print(title, dts)
911
-
912
- return
1057
+ args.release = False
1058
+ show_list(args)
1059
+ return None
913
1060
 
914
1061
  release = get_release_tag(args)
915
1062
  files = get_release_files(args, release, 'cpython')
916
1063
  if not files:
917
- sys.exit(f'Error: release "{release}" not found.')
1064
+ return f'Error: release "{release}" not found.'
918
1065
 
919
1066
  installed = {}
920
1067
  for vdir in iter_versions(args):
921
1068
  data = get_json(vdir / args._data)
922
- if data.get('release') == release and \
923
- (distro := data.get('distribution')):
1069
+ if data.get('release') == release and (distro := data.get('distribution')):
924
1070
  installed[vdir.name] = distro
925
1071
 
926
1072
  installable = False
927
1073
  for version in sorted(files, key=parse_version):
928
1074
  installed_distribution = installed.get(version)
929
1075
  for distribution in files[version]:
930
- app = ' (installed)' \
931
- if distribution == installed_distribution else ''
932
- if args.all or app \
933
- or distribution == args._distribution:
1076
+ app = ' (installed)' if distribution == installed_distribution else ''
1077
+ if args.all or app or distribution == args._distribution:
934
1078
  if distribution == args._distribution:
935
1079
  installable = True
936
1080
 
937
- if not args.re_match or \
938
- re.search(args.re_match,
939
- f'{version}+{distribution}'):
940
- print(f'{fmt(version, release)} '
941
- f'distribution="{distribution}"{app}')
1081
+ if not args.re_match or re.search(
1082
+ args.re_match, f'{version}+{distribution}'
1083
+ ):
1084
+ print(
1085
+ f'{fmt(version, release)} '
1086
+ f'distribution="{distribution}"{app}'
1087
+ )
942
1088
  if not installable:
943
- print(f'Warning: no distribution="{args._distribution}" '
944
- 'versions found in ' f'release "{release}".')
1089
+ print(
1090
+ f'Warning: no distribution="{args._distribution}" '
1091
+ 'versions found in '
1092
+ f'release "{release}".'
1093
+ )
1094
+
1095
+
1096
+ # COMMAND
1097
+ class path_:
1098
+ "Show path prefix to installed version base directory."
945
1099
 
946
- @COMMAND.add
947
- class _path(COMMAND):
948
- 'Show path prefix to installed version base directory.'
949
1100
  @staticmethod
950
1101
  def init(parser: ArgumentParser) -> None:
951
- parser.add_argument('-p', '--python-path', action='store_true',
952
- help='add path to python executable')
953
- parser.add_argument('-r', '--resolve', action='store_true',
954
- help='fully resolve given version')
1102
+ parser.add_argument(
1103
+ '-p',
1104
+ '--python-path',
1105
+ action='store_true',
1106
+ help='add path to python executable',
1107
+ )
1108
+ parser.add_argument(
1109
+ '-r', '--resolve', action='store_true', help='fully resolve given version'
1110
+ )
955
1111
  group = parser.add_mutually_exclusive_group()
956
- group.add_argument('-c', '--cache-path', action='store_true',
957
- help='just show path to cache dir')
958
- group.add_argument('version', nargs='?',
959
- help='version number to show path for')
1112
+ group.add_argument(
1113
+ '-c',
1114
+ '--cache-path',
1115
+ action='store_true',
1116
+ help='just show path to cache dir',
1117
+ )
1118
+ group.add_argument('version', nargs='?', help='version number to show path for')
960
1119
 
961
1120
  @staticmethod
962
1121
  def run(args: Namespace) -> str | None:
@@ -980,10 +1139,10 @@ class _path(COMMAND):
980
1139
  if not path.exists():
981
1140
  path = basepath / 'python.exe'
982
1141
  if not path.exists():
983
- return 'Error: Can not find python executable in '\
984
- f'"{basepath}"'
1142
+ return f'Error: Can not find python executable in "{basepath}"'
985
1143
 
986
1144
  print(path)
987
1145
 
1146
+
988
1147
  if __name__ == '__main__':
989
1148
  sys.exit(main())
@@ -1,6 +0,0 @@
1
- pystand.py,sha256=6yFhPrfS19fvrqxZKujgfWvbE0BxlKyexaX2-uXVD8E,36230
2
- pystand-2.11.dist-info/METADATA,sha256=Be4WcqhlUAXtn_PbXtG1hAlklpEQ3bspNTvhuKuOTJg,24843
3
- pystand-2.11.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
4
- pystand-2.11.dist-info/entry_points.txt,sha256=DG4ps3I3nni1bubV1tXs6u8FARgkdbAYaEAzZD4RAo8,41
5
- pystand-2.11.dist-info/top_level.txt,sha256=NoWUh19UQymAJLHTCdxMnVwV6Teftef5fzyF3OWLyNY,8
6
- pystand-2.11.dist-info/RECORD,,