pystand 2.12__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.12
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
@@ -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,8 +496,10 @@ def purge_unused_releases(args: Namespace) -> None:
460
496
  if path.name not in keep:
461
497
  rm_path(path)
462
498
 
499
+
463
500
  def show_list(args: Namespace) -> None:
464
- 'Show a list of available releases'
501
+ "Show a list of available releases"
502
+ latest = parse_version(get_release_tag(args))
465
503
  releases = {r: d for r, d in fetch_tags()}
466
504
  cached = set(p.name for p in args._releases.iterdir())
467
505
  for release in sorted(cached.union(releases)):
@@ -469,43 +507,40 @@ def show_list(args: Namespace) -> None:
469
507
  continue
470
508
 
471
509
  if dt_str := releases.get(release):
472
- dts = datetime.fromisoformat(dt_str).astimezone().isoformat(
473
- sep='_', timespec='minutes')
510
+ dts = (
511
+ datetime.fromisoformat(dt_str)
512
+ .astimezone()
513
+ .isoformat(sep='_', timespec='minutes')
514
+ )
474
515
  else:
475
516
  dts = '......................'
476
517
 
477
518
  if release in cached:
478
519
  ddir = args._downloads / release
479
520
  count = len(list(ddir.iterdir())) if ddir.exists() else 0
480
- app = f' cached + {count} downloaded files' \
481
- if count > 0 else ' cached'
521
+ app = f' cached + {count} downloaded files' if count > 0 else ' cached'
482
522
  else:
483
523
  app = ''
484
524
 
485
- print(f'{release} {dts}{app}')
525
+ pre = ' pre-release' if parse_version(release) > latest else ''
486
526
 
487
- class COMMAND:
488
- 'Base class for all commands'
489
- commands = []
527
+ print(f'{release} {dts}{app}{pre}')
490
528
 
491
- @classmethod
492
- def add(cls, parent) -> None:
493
- 'Append parent command to internal list'
494
- cls.commands.append(parent)
495
529
 
496
530
  def get_title(desc: str) -> str:
497
- 'Return single title line from description'
531
+ "Return single title line from description"
498
532
  res = []
499
533
  for line in desc.splitlines():
500
534
  line = line.strip()
501
535
  res.append(line)
502
536
  if line.endswith('.'):
503
- return ' '. join(res)
537
+ return ' '.join(res)
504
538
 
505
539
  sys.exit('Must end description with a full stop.')
506
540
 
541
+
507
542
  def remove(args: Namespace, version: str) -> None:
508
- 'Remove a version'
543
+ "Remove a version"
509
544
  vdir = args._versions / version
510
545
  if not vdir.exists():
511
546
  return
@@ -517,8 +552,9 @@ def remove(args: Namespace, version: str) -> None:
517
552
 
518
553
  shutil.rmtree(vdir)
519
554
 
555
+
520
556
  def strip_binaries(vdir: Path, distribution: str) -> bool:
521
- 'Strip binaries from files in a version directory'
557
+ "Strip binaries from files in a version directory"
522
558
  from subprocess import DEVNULL, run
523
559
 
524
560
  # Only run the strip command on Linux hosts and for Linux distributions
@@ -541,14 +577,17 @@ def strip_binaries(vdir: Path, distribution: str) -> bool:
541
577
 
542
578
  return was_stripped
543
579
 
544
- def install(args: Namespace, vdir: Path, release: str, distribution: str,
545
- files: dict) -> str | None:
546
- '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"
547
585
  version = vdir.name
548
586
 
549
587
  if not (url := files[version].get(distribution)):
550
- return f'Arch "{distribution}" not found for release '\
551
- f'{release} version {version}.'
588
+ return (
589
+ f'Arch "{distribution}" not found for release {release} version {version}.'
590
+ )
552
591
 
553
592
  tmpdir = args._versions / f'.{version}-tmp'
554
593
  rm_path(tmpdir)
@@ -560,7 +599,7 @@ def install(args: Namespace, vdir: Path, release: str, distribution: str,
560
599
  if not args.no_strip and strip_binaries(tmpdir, distribution):
561
600
  data['stripped'] = 'true'
562
601
 
563
- if (error := set_json(tmpdir / args._data, data)):
602
+ if error := set_json(tmpdir / args._data, data):
564
603
  error = f'Failed to write {version} data file: {error}'
565
604
 
566
605
  if error:
@@ -571,8 +610,9 @@ def install(args: Namespace, vdir: Path, release: str, distribution: str,
571
610
 
572
611
  return error
573
612
 
613
+
574
614
  def main() -> str | None:
575
- 'Main code'
615
+ "Main code"
576
616
  distro_default = DISTRIBUTIONS.get((platform.system(), platform.machine()))
577
617
  distro_help = distro_default or '?unknown?'
578
618
 
@@ -581,41 +621,67 @@ def main() -> str | None:
581
621
  cache_dir = platformdirs.user_cache_path() / PROG
582
622
 
583
623
  # Parse arguments
584
- opt = ArgumentParser(description=__doc__,
585
- epilog='Some commands offer aliases as shown in brackets above. '
586
- 'Note you can set default starting global options in '
587
- 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
+ )
588
630
 
589
631
  # Set up main/global arguments
590
- opt.add_argument('-D', '--distribution',
591
- help=f'{REPO} distribution. '
592
- f'Default is "{distro_help} for this host')
593
- opt.add_argument('-P', '--prefix-dir', default=prefix_dir,
594
- help='specify prefix dir for storing '
595
- 'versions. Default is "%(default)s"')
596
- opt.add_argument('-C', '--cache-dir', default=str(cache_dir),
597
- help='specify cache dir for downloads. '
598
- 'Default is "%(default)s"')
599
- opt.add_argument('-M', '--cache-minutes', default=60, type=float,
600
- help='cache latest YYYYMMDD release tag fetch for this '
601
- 'many minutes, before rechecking for latest. '
602
- 'Default is %(default)d minutes')
603
- opt.add_argument('--purge-days', default=90, type=int,
604
- help='cache YYYYMMDD release file lists and downloads for '
605
- 'this number of days after last version referencing that '
606
- 'release is removed. Default is %(default)d days')
607
- opt.add_argument('--github-access-token',
608
- help='optional Github access token. Can specify to reduce '
609
- 'rate limiting.')
610
- opt.add_argument('--no-strip', action='store_true',
611
- help='do not strip downloaded binaries')
612
- opt.add_argument('-V', '--version', action='store_true',
613
- 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
+ )
614
676
  cmd = opt.add_subparsers(title='Commands', dest='cmdname')
615
677
 
616
678
  # Add each command ..
617
- for cls in COMMAND.commands:
618
- 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]
619
685
 
620
686
  if hasattr(cls, 'doc'):
621
687
  desc = cls.doc.strip()
@@ -626,8 +692,7 @@ def main() -> str | None:
626
692
 
627
693
  aliases = cls.aliases if hasattr(cls, 'aliases') else []
628
694
  title = get_title(desc)
629
- cmdopt = cmd.add_parser(name, description=desc, help=title,
630
- aliases=aliases)
695
+ cmdopt = cmd.add_parser(name, description=desc, help=title, aliases=aliases)
631
696
 
632
697
  # Set up this commands own arguments, if it has any
633
698
  if hasattr(cls, 'init'):
@@ -661,8 +726,10 @@ def main() -> str | None:
661
726
 
662
727
  distribution = args.distribution or distro_default
663
728
  if not distribution:
664
- sys.exit('Unknown system + machine distribution. Please specify '
665
- 'using -D/--distribution option.')
729
+ sys.exit(
730
+ 'Unknown system + machine distribution. Please specify '
731
+ 'using -D/--distribution option.'
732
+ )
666
733
 
667
734
  # Keep some useful info in the namespace passed to the command
668
735
  prefix_dir = Path(args.prefix_dir).expanduser().resolve()
@@ -685,23 +752,35 @@ def main() -> str | None:
685
752
  update_version_symlinks(args)
686
753
  return result
687
754
 
688
- @COMMAND.add
689
- class _install(COMMAND):
755
+
756
+ # COMMAND
757
+ class install_:
690
758
  doc = f'Install one or more versions from a {REPO} release.'
691
759
 
692
760
  @staticmethod
693
761
  def init(parser: ArgumentParser) -> None:
694
- parser.add_argument('-r', '--release',
695
- help=f'install from specified {REPO} '
696
- f'YYYYMMDD release (e.g. {SAMPL_RELEASE}), '
697
- 'default is latest release')
698
- parser.add_argument('-f', '--force', action='store_true',
699
- help='force install even if already installed')
700
- parser.add_argument('-s', '--include-source', action='store_true',
701
- help='also install source files if available in '
702
- 'distribution download')
703
- parser.add_argument('version', nargs='+',
704
- 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
+ )
705
784
 
706
785
  @staticmethod
707
786
  def run(args: Namespace) -> str | None:
@@ -727,27 +806,40 @@ class _install(COMMAND):
727
806
 
728
807
  print(f'Version {fmt(version, release)} installed.')
729
808
 
730
- @COMMAND.add
731
- class _update(COMMAND):
732
- '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
+
733
814
  aliases = ['upgrade']
734
815
 
735
816
  @staticmethod
736
817
  def init(parser: ArgumentParser) -> None:
737
- parser.add_argument('-r', '--release',
738
- help='update to specified YYYMMDD release (e.g. '
739
- f'{SAMPL_RELEASE}), default is latest release')
740
- parser.add_argument('-a', '--all', action='store_true',
741
- help='update ALL versions')
742
- parser.add_argument('--skip', action='store_true',
743
- help='skip the specified versions when '
744
- 'updating all (only can be specified with --all)')
745
- parser.add_argument('-k', '--keep', action='store_true',
746
- help='keep old version after updating (but only '
747
- 'if different version number)')
748
- parser.add_argument('version', nargs='*',
749
- help='version to update (or to skip for '
750
- '--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
+ )
751
843
 
752
844
  @staticmethod
753
845
  def run(args: Namespace) -> str | None:
@@ -773,54 +865,66 @@ class _update(COMMAND):
773
865
  continue
774
866
 
775
867
  if nextver == version and args.keep:
776
- print(f'Error: {fmt(version, release)} would not be kept '
777
- f'if update to {fmt(nextver, release_target)} '
778
- 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
+ )
779
874
  continue
780
875
 
781
876
  new_vdir = args._versions / nextver
782
877
  if nextver != version and new_vdir.exists():
783
878
  continue
784
879
 
785
- print(f'{fmt(version, release)} updating to '
786
- f'{fmt(nextver, release_target)} '
787
- f'distribution="{distribution}" ..')
880
+ print(
881
+ f'{fmt(version, release)} updating to '
882
+ f'{fmt(nextver, release_target)} '
883
+ f'distribution="{distribution}" ..'
884
+ )
788
885
 
789
886
  # If the source was originally included, then include it in
790
887
  # the update.
791
888
  args.include_source = (vdir / 'src').is_dir()
792
889
 
793
- if error := install(args, new_vdir, release_target, distribution,
794
- files):
890
+ if error := install(args, new_vdir, release_target, distribution, files):
795
891
  return error
796
892
 
797
893
  if nextver != version and not args.keep:
798
894
  remove(args, version)
799
895
 
800
- @COMMAND.add
801
- class _remove(COMMAND):
802
- 'Remove/uninstall one, more, or all versions.'
896
+
897
+ # COMMAND
898
+ class remove_:
899
+ "Remove/uninstall one, more, or all versions."
900
+
803
901
  aliases = ['uninstall']
804
902
 
805
903
  @staticmethod
806
904
  def init(parser: ArgumentParser) -> None:
807
- parser.add_argument('-a', '--all', action='store_true',
808
- help='remove ALL versions')
809
- parser.add_argument('--skip', action='store_true',
810
- help='skip the specified versions when '
811
- 'removing all (only can be specified with --all)')
812
- parser.add_argument('-r', '--release',
813
- help='only remove versions if from specified '
814
- f'YYYMMDD release (e.g. {SAMPL_RELEASE})')
815
- parser.add_argument('version', nargs='*',
816
- help='version to remove (or to skip for '
817
- '--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
+ )
818
923
 
819
924
  @staticmethod
820
925
  def run(args: Namespace) -> str | None:
821
926
  release_del = args.release
822
- if release_del and \
823
- (err := check_release_tag(release_del)):
927
+ if release_del and (err := check_release_tag(release_del)):
824
928
  return err
825
929
 
826
930
  for version in get_version_names(args):
@@ -830,20 +934,29 @@ class _remove(COMMAND):
830
934
  remove(args, version)
831
935
  print(f'Version {fmt(version, release)} removed.')
832
936
 
833
- @COMMAND.add
834
- class _list(COMMAND):
835
- '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
+
836
942
  @staticmethod
837
943
  def init(parser: ArgumentParser) -> None:
838
- parser.add_argument('-v', '--verbose', action='store_true',
839
- help='explicitly report why a version is '
840
- 'not eligible for update')
841
- parser.add_argument('-r', '--release',
842
- help='use specified YYYYMMDD release '
843
- f'(e.g. {SAMPL_RELEASE}) for verbose compare, '
844
- 'default is latest release')
845
- parser.add_argument('version', nargs='*',
846
- 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
+ )
847
960
 
848
961
  @staticmethod
849
962
  def run(args: Namespace) -> str | None:
@@ -868,56 +981,72 @@ class _list(COMMAND):
868
981
  nextver = matcher.match(version, upgrade=True)
869
982
  if not nextver:
870
983
  if args.verbose:
871
- app = ' not eligible for update because '\
872
- f'release {release_target} does not provide '\
873
- 'this version.'
984
+ app = (
985
+ ' not eligible for update because '
986
+ f'release {release_target} does not provide '
987
+ 'this version.'
988
+ )
874
989
  else:
875
990
  new_vdir = args._versions / nextver
876
991
  if nextver != version and new_vdir.exists():
877
992
  if args.verbose:
878
- nrelease = get_json(
879
- new_vdir / args._data).get('release', '?')
880
- app = f' not eligible for '\
881
- f'update because {fmt(nextver, nrelease)} '\
882
- '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
+ )
883
1001
  else:
884
1002
  # May not be updatable if newer release does not support
885
1003
  # this same distribution anymore
886
1004
  if nextver and distribution in files.get(nextver, {}):
887
- upd = ' updatable to '\
888
- f'{fmt(nextver, release_target)}'
1005
+ upd = f' updatable to {fmt(nextver, release_target)}'
889
1006
  elif args.verbose:
890
- app = ' not eligible for update because '\
891
- f'{fmt(nextver, release_target)} does '\
892
- 'not provide '\
893
- 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}')
894
1015
 
895
- print(f'{fmt(version, release)}{upd} '
896
- f'distribution="{distribution}"{app}')
897
1016
 
898
- @COMMAND.add
899
- class _show(COMMAND):
900
- doc = f'''
1017
+ # COMMAND
1018
+ class show_:
1019
+ doc = f"""
901
1020
  Show versions available from a release.
902
1021
 
903
1022
  View available releases and their distributions at
904
1023
  {GITHUB_SITE}/releases.
905
- '''
1024
+ """
906
1025
 
907
1026
  @staticmethod
908
1027
  def init(parser: ArgumentParser) -> None:
909
1028
  group = parser.add_mutually_exclusive_group()
910
- group.add_argument('-l', '--list', action='store_true',
911
- help='just list recent releases')
912
- group.add_argument('-r', '--release',
913
- help=f'{REPO} YYYYMMDD release to show (e.g. '
914
- f'{SAMPL_RELEASE}), default is latest release')
915
- parser.add_argument('-a', '--all', action='store_true',
916
- help='show all available distributions for '
917
- 'each version from the release')
918
- parser.add_argument('re_match', nargs='?',
919
- help='show only versions+distributions '
920
- '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
+ )
921
1050
 
922
1051
  @staticmethod
923
1052
  def run(args: Namespace) -> str | None:
@@ -925,6 +1054,7 @@ class _show(COMMAND):
925
1054
  args.parser.error('Can not specify --all with --list.')
926
1055
 
927
1056
  if args.list:
1057
+ args.release = False
928
1058
  show_list(args)
929
1059
  return None
930
1060
 
@@ -936,44 +1066,56 @@ class _show(COMMAND):
936
1066
  installed = {}
937
1067
  for vdir in iter_versions(args):
938
1068
  data = get_json(vdir / args._data)
939
- if data.get('release') == release and \
940
- (distro := data.get('distribution')):
1069
+ if data.get('release') == release and (distro := data.get('distribution')):
941
1070
  installed[vdir.name] = distro
942
1071
 
943
1072
  installable = False
944
1073
  for version in sorted(files, key=parse_version):
945
1074
  installed_distribution = installed.get(version)
946
1075
  for distribution in files[version]:
947
- app = ' (installed)' \
948
- if distribution == installed_distribution else ''
949
- if args.all or app \
950
- or distribution == args._distribution:
1076
+ app = ' (installed)' if distribution == installed_distribution else ''
1077
+ if args.all or app or distribution == args._distribution:
951
1078
  if distribution == args._distribution:
952
1079
  installable = True
953
1080
 
954
- if not args.re_match or \
955
- re.search(args.re_match,
956
- f'{version}+{distribution}'):
957
- print(f'{fmt(version, release)} '
958
- 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
+ )
959
1088
  if not installable:
960
- print(f'Warning: no distribution="{args._distribution}" '
961
- '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."
962
1099
 
963
- @COMMAND.add
964
- class _path(COMMAND):
965
- 'Show path prefix to installed version base directory.'
966
1100
  @staticmethod
967
1101
  def init(parser: ArgumentParser) -> None:
968
- parser.add_argument('-p', '--python-path', action='store_true',
969
- help='add path to python executable')
970
- parser.add_argument('-r', '--resolve', action='store_true',
971
- 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
+ )
972
1111
  group = parser.add_mutually_exclusive_group()
973
- group.add_argument('-c', '--cache-path', action='store_true',
974
- help='just show path to cache dir')
975
- group.add_argument('version', nargs='?',
976
- 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')
977
1119
 
978
1120
  @staticmethod
979
1121
  def run(args: Namespace) -> str | None:
@@ -997,10 +1139,10 @@ class _path(COMMAND):
997
1139
  if not path.exists():
998
1140
  path = basepath / 'python.exe'
999
1141
  if not path.exists():
1000
- return 'Error: Can not find python executable in '\
1001
- f'"{basepath}"'
1142
+ return f'Error: Can not find python executable in "{basepath}"'
1002
1143
 
1003
1144
  print(path)
1004
1145
 
1146
+
1005
1147
  if __name__ == '__main__':
1006
1148
  sys.exit(main())
@@ -1,6 +0,0 @@
1
- pystand.py,sha256=l-ecHjOFK35q5EiZY-FMufu_C_jz7wUom2EvVtSTAiw,36826
2
- pystand-2.12.dist-info/METADATA,sha256=F1TMDKy9Invr4mCU08z1-yWEpL0SFHJQFBRnAak-7yU,24843
3
- pystand-2.12.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
4
- pystand-2.12.dist-info/entry_points.txt,sha256=DG4ps3I3nni1bubV1tXs6u8FARgkdbAYaEAzZD4RAo8,41
5
- pystand-2.12.dist-info/top_level.txt,sha256=NoWUh19UQymAJLHTCdxMnVwV6Teftef5fzyF3OWLyNY,8
6
- pystand-2.12.dist-info/RECORD,,