pystand 2.12__py3-none-any.whl → 2.14__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,9 +1,9 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: pystand
3
- Version: 2.12
3
+ Version: 2.14
4
4
  Summary: Install Python versions from python-build-standalone project
5
5
  Author-email: Mark Blakeney <mark.blakeney@bullet-systems.net>
6
- License: GPLv3
6
+ License-Expression: GPL-3.0-or-later
7
7
  Project-URL: Homepage, https://github.com/bulletmark/pystand
8
8
  Keywords: python-build-standalone,pyenv,hatch,pdm
9
9
  Classifier: Programming Language :: Python :: 3
@@ -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
@@ -425,8 +426,6 @@ Note you can set a different default distribution by specifying
425
426
  The `show` command can be used to search for distributions as seen in the
426
427
  following examples.
427
428
 
428
- ```sh
429
-
430
429
  List all the versions installed on this system (at the default location):
431
430
 
432
431
  ```sh
@@ -0,0 +1,6 @@
1
+ pystand.py,sha256=gku268WNQw2VqYrjZa7R37k15a4Hkz5tBJ2_E75lBWw,36823
2
+ pystand-2.14.dist-info/METADATA,sha256=U5ybEsQsHJ5yn7M1mGtQg50RbOr6sjAhvQ3cMqTN0OU,24824
3
+ pystand-2.14.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
4
+ pystand-2.14.dist-info/entry_points.txt,sha256=DG4ps3I3nni1bubV1tXs6u8FARgkdbAYaEAzZD4RAo8,41
5
+ pystand-2.14.dist-info/top_level.txt,sha256=NoWUh19UQymAJLHTCdxMnVwV6Teftef5fzyF3OWLyNY,8
6
+ pystand-2.14.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.6.0)
2
+ Generator: setuptools (80.7.1)
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,22 +385,22 @@ 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
- from github.GithubException import UnknownObjectException
368
399
  gh = get_gh(args)
369
400
  try:
370
401
  release = gh.get_repo(GITHUB_REPO).get_release(tag)
371
- except UnknownObjectException:
402
+ except Exception as e:
403
+ print(f'Error: {str(e)}', file=sys.stderr)
372
404
  return {}
373
405
 
374
406
  # Iterate over the release assets and store pertinent files in a
@@ -384,8 +416,9 @@ def get_release_files(args, tag, implementation: str | None = None) -> dict:
384
416
 
385
417
  return files.get(implementation, {}) if implementation else files
386
418
 
419
+
387
420
  def update_version_symlinks(args: Namespace) -> None:
388
- 'Create/update symlinks pointing to latest version'
421
+ "Create/update symlinks pointing to latest version"
389
422
  base = args._versions
390
423
  if not base.exists():
391
424
  return
@@ -435,11 +468,13 @@ def update_version_symlinks(args: Namespace) -> None:
435
468
  if not old_tgt or old_tgt != tgt:
436
469
  (base / name).symlink_to(tgt, target_is_directory=True)
437
470
 
471
+
438
472
  def purge_unused_releases(args: Namespace) -> None:
439
- 'Purge old releases that are no longer needed and have expired'
473
+ "Purge old releases that are no longer needed and have expired"
440
474
  # 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'))}
475
+ keep = {
476
+ r for v in iter_versions(args) if (r := get_json(v / args._data).get('release'))
477
+ }
443
478
 
444
479
  # Add current release to keep list (even if not currently installed)
445
480
  if args._latest_release.exists():
@@ -460,8 +495,10 @@ def purge_unused_releases(args: Namespace) -> None:
460
495
  if path.name not in keep:
461
496
  rm_path(path)
462
497
 
498
+
463
499
  def show_list(args: Namespace) -> None:
464
- 'Show a list of available releases'
500
+ "Show a list of available releases"
501
+ latest = parse_version(get_release_tag(args))
465
502
  releases = {r: d for r, d in fetch_tags()}
466
503
  cached = set(p.name for p in args._releases.iterdir())
467
504
  for release in sorted(cached.union(releases)):
@@ -469,43 +506,40 @@ def show_list(args: Namespace) -> None:
469
506
  continue
470
507
 
471
508
  if dt_str := releases.get(release):
472
- dts = datetime.fromisoformat(dt_str).astimezone().isoformat(
473
- sep='_', timespec='minutes')
509
+ dts = (
510
+ datetime.fromisoformat(dt_str)
511
+ .astimezone()
512
+ .isoformat(sep='_', timespec='minutes')
513
+ )
474
514
  else:
475
515
  dts = '......................'
476
516
 
477
517
  if release in cached:
478
518
  ddir = args._downloads / release
479
519
  count = len(list(ddir.iterdir())) if ddir.exists() else 0
480
- app = f' cached + {count} downloaded files' \
481
- if count > 0 else ' cached'
520
+ app = f' cached + {count} downloaded files' if count > 0 else ' cached'
482
521
  else:
483
522
  app = ''
484
523
 
485
- print(f'{release} {dts}{app}')
524
+ pre = ' pre-release' if parse_version(release) > latest else ''
486
525
 
487
- class COMMAND:
488
- 'Base class for all commands'
489
- commands = []
526
+ print(f'{release} {dts}{app}{pre}')
490
527
 
491
- @classmethod
492
- def add(cls, parent) -> None:
493
- 'Append parent command to internal list'
494
- cls.commands.append(parent)
495
528
 
496
529
  def get_title(desc: str) -> str:
497
- 'Return single title line from description'
530
+ "Return single title line from description"
498
531
  res = []
499
532
  for line in desc.splitlines():
500
533
  line = line.strip()
501
534
  res.append(line)
502
535
  if line.endswith('.'):
503
- return ' '. join(res)
536
+ return ' '.join(res)
504
537
 
505
538
  sys.exit('Must end description with a full stop.')
506
539
 
540
+
507
541
  def remove(args: Namespace, version: str) -> None:
508
- 'Remove a version'
542
+ "Remove a version"
509
543
  vdir = args._versions / version
510
544
  if not vdir.exists():
511
545
  return
@@ -517,8 +551,9 @@ def remove(args: Namespace, version: str) -> None:
517
551
 
518
552
  shutil.rmtree(vdir)
519
553
 
554
+
520
555
  def strip_binaries(vdir: Path, distribution: str) -> bool:
521
- 'Strip binaries from files in a version directory'
556
+ "Strip binaries from files in a version directory"
522
557
  from subprocess import DEVNULL, run
523
558
 
524
559
  # Only run the strip command on Linux hosts and for Linux distributions
@@ -541,14 +576,17 @@ def strip_binaries(vdir: Path, distribution: str) -> bool:
541
576
 
542
577
  return was_stripped
543
578
 
544
- def install(args: Namespace, vdir: Path, release: str, distribution: str,
545
- files: dict) -> str | None:
546
- 'Install a version'
579
+
580
+ def install(
581
+ args: Namespace, vdir: Path, release: str, distribution: str, files: dict
582
+ ) -> str | None:
583
+ "Install a version"
547
584
  version = vdir.name
548
585
 
549
586
  if not (url := files[version].get(distribution)):
550
- return f'Arch "{distribution}" not found for release '\
551
- f'{release} version {version}.'
587
+ return (
588
+ f'Arch "{distribution}" not found for release {release} version {version}.'
589
+ )
552
590
 
553
591
  tmpdir = args._versions / f'.{version}-tmp'
554
592
  rm_path(tmpdir)
@@ -560,7 +598,7 @@ def install(args: Namespace, vdir: Path, release: str, distribution: str,
560
598
  if not args.no_strip and strip_binaries(tmpdir, distribution):
561
599
  data['stripped'] = 'true'
562
600
 
563
- if (error := set_json(tmpdir / args._data, data)):
601
+ if error := set_json(tmpdir / args._data, data):
564
602
  error = f'Failed to write {version} data file: {error}'
565
603
 
566
604
  if error:
@@ -571,8 +609,9 @@ def install(args: Namespace, vdir: Path, release: str, distribution: str,
571
609
 
572
610
  return error
573
611
 
612
+
574
613
  def main() -> str | None:
575
- 'Main code'
614
+ "Main code"
576
615
  distro_default = DISTRIBUTIONS.get((platform.system(), platform.machine()))
577
616
  distro_help = distro_default or '?unknown?'
578
617
 
@@ -581,41 +620,67 @@ def main() -> str | None:
581
620
  cache_dir = platformdirs.user_cache_path() / PROG
582
621
 
583
622
  # 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}.')
623
+ opt = ArgumentParser(
624
+ description=__doc__,
625
+ epilog='Some commands offer aliases as shown in parentheses above. '
626
+ 'Note you can set default starting global options in '
627
+ f'{CNFFILE}.',
628
+ )
588
629
 
589
630
  # 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')
631
+ opt.add_argument(
632
+ '-D',
633
+ '--distribution',
634
+ help=f'{REPO} distribution. Default is "{distro_help} for this host',
635
+ )
636
+ opt.add_argument(
637
+ '-P',
638
+ '--prefix-dir',
639
+ default=prefix_dir,
640
+ help='specify prefix dir for storing versions. Default is "%(default)s"',
641
+ )
642
+ opt.add_argument(
643
+ '-C',
644
+ '--cache-dir',
645
+ default=str(cache_dir),
646
+ help='specify cache dir for downloads. Default is "%(default)s"',
647
+ )
648
+ opt.add_argument(
649
+ '-M',
650
+ '--cache-minutes',
651
+ default=60,
652
+ type=float,
653
+ help='cache latest YYYYMMDD release tag fetch for this '
654
+ 'many minutes, before rechecking for latest. '
655
+ 'Default is %(default)d minutes',
656
+ )
657
+ opt.add_argument(
658
+ '--purge-days',
659
+ default=90,
660
+ type=int,
661
+ help='cache YYYYMMDD release file lists and downloads for '
662
+ 'this number of days after last version referencing that '
663
+ 'release is removed. Default is %(default)d days',
664
+ )
665
+ opt.add_argument(
666
+ '--github-access-token',
667
+ help='optional Github access token. Can specify to reduce rate limiting.',
668
+ )
669
+ opt.add_argument(
670
+ '--no-strip', action='store_true', help='do not strip downloaded binaries'
671
+ )
672
+ opt.add_argument(
673
+ '-V', '--version', action='store_true', help=f'just show {PROG} version'
674
+ )
614
675
  cmd = opt.add_subparsers(title='Commands', dest='cmdname')
615
676
 
616
677
  # Add each command ..
617
- for cls in COMMAND.commands:
618
- name = cls.__name__[1:]
678
+ for name in globals():
679
+ if not name[0].islower() or not name.endswith('_'):
680
+ continue
681
+
682
+ cls = globals()[name]
683
+ name = name[:-1]
619
684
 
620
685
  if hasattr(cls, 'doc'):
621
686
  desc = cls.doc.strip()
@@ -626,8 +691,7 @@ def main() -> str | None:
626
691
 
627
692
  aliases = cls.aliases if hasattr(cls, 'aliases') else []
628
693
  title = get_title(desc)
629
- cmdopt = cmd.add_parser(name, description=desc, help=title,
630
- aliases=aliases)
694
+ cmdopt = cmd.add_parser(name, description=desc, help=title, aliases=aliases)
631
695
 
632
696
  # Set up this commands own arguments, if it has any
633
697
  if hasattr(cls, 'init'):
@@ -661,8 +725,10 @@ def main() -> str | None:
661
725
 
662
726
  distribution = args.distribution or distro_default
663
727
  if not distribution:
664
- sys.exit('Unknown system + machine distribution. Please specify '
665
- 'using -D/--distribution option.')
728
+ sys.exit(
729
+ 'Unknown system + machine distribution. Please specify '
730
+ 'using -D/--distribution option.'
731
+ )
666
732
 
667
733
  # Keep some useful info in the namespace passed to the command
668
734
  prefix_dir = Path(args.prefix_dir).expanduser().resolve()
@@ -685,23 +751,35 @@ def main() -> str | None:
685
751
  update_version_symlinks(args)
686
752
  return result
687
753
 
688
- @COMMAND.add
689
- class _install(COMMAND):
754
+
755
+ # COMMAND
756
+ class install_:
690
757
  doc = f'Install one or more versions from a {REPO} release.'
691
758
 
692
759
  @staticmethod
693
760
  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')
761
+ parser.add_argument(
762
+ '-r',
763
+ '--release',
764
+ help=f'install from specified {REPO} '
765
+ f'YYYYMMDD release (e.g. {SAMPL_RELEASE}), '
766
+ 'default is latest release',
767
+ )
768
+ parser.add_argument(
769
+ '-f',
770
+ '--force',
771
+ action='store_true',
772
+ help='force install even if already installed',
773
+ )
774
+ parser.add_argument(
775
+ '-s',
776
+ '--include-source',
777
+ action='store_true',
778
+ help='also install source files if available in distribution download',
779
+ )
780
+ parser.add_argument(
781
+ 'version', nargs='+', help='version to install. E.g. 3.12 or 3.12.3'
782
+ )
705
783
 
706
784
  @staticmethod
707
785
  def run(args: Namespace) -> str | None:
@@ -727,27 +805,40 @@ class _install(COMMAND):
727
805
 
728
806
  print(f'Version {fmt(version, release)} installed.')
729
807
 
730
- @COMMAND.add
731
- class _update(COMMAND):
732
- 'Update one, more, or all versions to another release.'
808
+
809
+ # COMMAND
810
+ class update_:
811
+ "Update one, more, or all versions to another release."
812
+
733
813
  aliases = ['upgrade']
734
814
 
735
815
  @staticmethod
736
816
  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)')
817
+ parser.add_argument(
818
+ '-r',
819
+ '--release',
820
+ help='update to specified YYYMMDD release (e.g. '
821
+ f'{SAMPL_RELEASE}), default is latest release',
822
+ )
823
+ parser.add_argument(
824
+ '-a', '--all', action='store_true', help='update ALL versions'
825
+ )
826
+ parser.add_argument(
827
+ '--skip',
828
+ action='store_true',
829
+ help='skip the specified versions when '
830
+ 'updating all (only can be specified with --all)',
831
+ )
832
+ parser.add_argument(
833
+ '-k',
834
+ '--keep',
835
+ action='store_true',
836
+ help='keep old version after updating (but only '
837
+ 'if different version number)',
838
+ )
839
+ parser.add_argument(
840
+ 'version', nargs='*', help='version to update (or to skip for --all --skip)'
841
+ )
751
842
 
752
843
  @staticmethod
753
844
  def run(args: Namespace) -> str | None:
@@ -773,54 +864,66 @@ class _update(COMMAND):
773
864
  continue
774
865
 
775
866
  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)
867
+ print(
868
+ f'Error: {fmt(version, release)} would not be kept '
869
+ f'if update to {fmt(nextver, release_target)} '
870
+ f'distribution="{distribution}"',
871
+ file=sys.stderr,
872
+ )
779
873
  continue
780
874
 
781
875
  new_vdir = args._versions / nextver
782
876
  if nextver != version and new_vdir.exists():
783
877
  continue
784
878
 
785
- print(f'{fmt(version, release)} updating to '
786
- f'{fmt(nextver, release_target)} '
787
- f'distribution="{distribution}" ..')
879
+ print(
880
+ f'{fmt(version, release)} updating to '
881
+ f'{fmt(nextver, release_target)} '
882
+ f'distribution="{distribution}" ..'
883
+ )
788
884
 
789
885
  # If the source was originally included, then include it in
790
886
  # the update.
791
887
  args.include_source = (vdir / 'src').is_dir()
792
888
 
793
- if error := install(args, new_vdir, release_target, distribution,
794
- files):
889
+ if error := install(args, new_vdir, release_target, distribution, files):
795
890
  return error
796
891
 
797
892
  if nextver != version and not args.keep:
798
893
  remove(args, version)
799
894
 
800
- @COMMAND.add
801
- class _remove(COMMAND):
802
- 'Remove/uninstall one, more, or all versions.'
895
+
896
+ # COMMAND
897
+ class remove_:
898
+ "Remove/uninstall one, more, or all versions."
899
+
803
900
  aliases = ['uninstall']
804
901
 
805
902
  @staticmethod
806
903
  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)')
904
+ parser.add_argument(
905
+ '-a', '--all', action='store_true', help='remove ALL versions'
906
+ )
907
+ parser.add_argument(
908
+ '--skip',
909
+ action='store_true',
910
+ help='skip the specified versions when '
911
+ 'removing all (only can be specified with --all)',
912
+ )
913
+ parser.add_argument(
914
+ '-r',
915
+ '--release',
916
+ help='only remove versions if from specified '
917
+ f'YYYMMDD release (e.g. {SAMPL_RELEASE})',
918
+ )
919
+ parser.add_argument(
920
+ 'version', nargs='*', help='version to remove (or to skip for --all --skip)'
921
+ )
818
922
 
819
923
  @staticmethod
820
924
  def run(args: Namespace) -> str | None:
821
925
  release_del = args.release
822
- if release_del and \
823
- (err := check_release_tag(release_del)):
926
+ if release_del and (err := check_release_tag(release_del)):
824
927
  return err
825
928
 
826
929
  for version in get_version_names(args):
@@ -830,20 +933,29 @@ class _remove(COMMAND):
830
933
  remove(args, version)
831
934
  print(f'Version {fmt(version, release)} removed.')
832
935
 
833
- @COMMAND.add
834
- class _list(COMMAND):
835
- 'List installed versions and show which have an update available.'
936
+
937
+ # COMMAND
938
+ class list_:
939
+ "List installed versions and show which have an update available."
940
+
836
941
  @staticmethod
837
942
  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')
943
+ parser.add_argument(
944
+ '-v',
945
+ '--verbose',
946
+ action='store_true',
947
+ help='explicitly report why a version is not eligible for update',
948
+ )
949
+ parser.add_argument(
950
+ '-r',
951
+ '--release',
952
+ help='use specified YYYYMMDD release '
953
+ f'(e.g. {SAMPL_RELEASE}) for verbose compare, '
954
+ 'default is latest release',
955
+ )
956
+ parser.add_argument(
957
+ 'version', nargs='*', help='only list specified version, else all'
958
+ )
847
959
 
848
960
  @staticmethod
849
961
  def run(args: Namespace) -> str | None:
@@ -868,56 +980,72 @@ class _list(COMMAND):
868
980
  nextver = matcher.match(version, upgrade=True)
869
981
  if not nextver:
870
982
  if args.verbose:
871
- app = ' not eligible for update because '\
872
- f'release {release_target} does not provide '\
873
- 'this version.'
983
+ app = (
984
+ ' not eligible for update because '
985
+ f'release {release_target} does not provide '
986
+ 'this version.'
987
+ )
874
988
  else:
875
989
  new_vdir = args._versions / nextver
876
990
  if nextver != version and new_vdir.exists():
877
991
  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.'
992
+ nrelease = get_json(new_vdir / args._data).get(
993
+ 'release', '?'
994
+ )
995
+ app = (
996
+ f' not eligible for '
997
+ f'update because {fmt(nextver, nrelease)} '
998
+ 'is already installed.'
999
+ )
883
1000
  else:
884
1001
  # May not be updatable if newer release does not support
885
1002
  # this same distribution anymore
886
1003
  if nextver and distribution in files.get(nextver, {}):
887
- upd = ' updatable to '\
888
- f'{fmt(nextver, release_target)}'
1004
+ upd = f' updatable to {fmt(nextver, release_target)}'
889
1005
  elif args.verbose:
890
- app = ' not eligible for update because '\
891
- f'{fmt(nextver, release_target)} does '\
892
- 'not provide '\
893
- f'distribution="{distribution}".'
1006
+ app = (
1007
+ ' not eligible for update because '
1008
+ f'{fmt(nextver, release_target)} does '
1009
+ 'not provide '
1010
+ f'distribution="{distribution}".'
1011
+ )
1012
+
1013
+ print(f'{fmt(version, release)}{upd} distribution="{distribution}"{app}')
894
1014
 
895
- print(f'{fmt(version, release)}{upd} '
896
- f'distribution="{distribution}"{app}')
897
1015
 
898
- @COMMAND.add
899
- class _show(COMMAND):
900
- doc = f'''
1016
+ # COMMAND
1017
+ class show_:
1018
+ doc = f"""
901
1019
  Show versions available from a release.
902
1020
 
903
1021
  View available releases and their distributions at
904
1022
  {GITHUB_SITE}/releases.
905
- '''
1023
+ """
906
1024
 
907
1025
  @staticmethod
908
1026
  def init(parser: ArgumentParser) -> None:
909
1027
  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')
1028
+ group.add_argument(
1029
+ '-l', '--list', action='store_true', help='just list recent releases'
1030
+ )
1031
+ group.add_argument(
1032
+ '-r',
1033
+ '--release',
1034
+ help=f'{REPO} YYYYMMDD release to show (e.g. '
1035
+ f'{SAMPL_RELEASE}), default is latest release',
1036
+ )
1037
+ parser.add_argument(
1038
+ '-a',
1039
+ '--all',
1040
+ action='store_true',
1041
+ help='show all available distributions for each version from the release',
1042
+ )
1043
+ parser.add_argument(
1044
+ 're_match',
1045
+ nargs='?',
1046
+ help='show only versions+distributions '
1047
+ 'matching this regular expression pattern',
1048
+ )
921
1049
 
922
1050
  @staticmethod
923
1051
  def run(args: Namespace) -> str | None:
@@ -925,6 +1053,7 @@ class _show(COMMAND):
925
1053
  args.parser.error('Can not specify --all with --list.')
926
1054
 
927
1055
  if args.list:
1056
+ args.release = False
928
1057
  show_list(args)
929
1058
  return None
930
1059
 
@@ -936,44 +1065,56 @@ class _show(COMMAND):
936
1065
  installed = {}
937
1066
  for vdir in iter_versions(args):
938
1067
  data = get_json(vdir / args._data)
939
- if data.get('release') == release and \
940
- (distro := data.get('distribution')):
1068
+ if data.get('release') == release and (distro := data.get('distribution')):
941
1069
  installed[vdir.name] = distro
942
1070
 
943
1071
  installable = False
944
1072
  for version in sorted(files, key=parse_version):
945
1073
  installed_distribution = installed.get(version)
946
1074
  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:
1075
+ app = ' (installed)' if distribution == installed_distribution else ''
1076
+ if args.all or app or distribution == args._distribution:
951
1077
  if distribution == args._distribution:
952
1078
  installable = True
953
1079
 
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}')
1080
+ if not args.re_match or re.search(
1081
+ args.re_match, f'{version}+{distribution}'
1082
+ ):
1083
+ print(
1084
+ f'{fmt(version, release)} '
1085
+ f'distribution="{distribution}"{app}'
1086
+ )
959
1087
  if not installable:
960
- print(f'Warning: no distribution="{args._distribution}" '
961
- 'versions found in ' f'release "{release}".')
1088
+ print(
1089
+ f'Warning: no distribution="{args._distribution}" '
1090
+ 'versions found in '
1091
+ f'release "{release}".'
1092
+ )
1093
+
1094
+
1095
+ # COMMAND
1096
+ class path_:
1097
+ "Show path prefix to installed version base directory."
962
1098
 
963
- @COMMAND.add
964
- class _path(COMMAND):
965
- 'Show path prefix to installed version base directory.'
966
1099
  @staticmethod
967
1100
  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')
1101
+ parser.add_argument(
1102
+ '-p',
1103
+ '--python-path',
1104
+ action='store_true',
1105
+ help='add path to python executable',
1106
+ )
1107
+ parser.add_argument(
1108
+ '-r', '--resolve', action='store_true', help='fully resolve given version'
1109
+ )
972
1110
  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')
1111
+ group.add_argument(
1112
+ '-c',
1113
+ '--cache-path',
1114
+ action='store_true',
1115
+ help='just show path to cache dir',
1116
+ )
1117
+ group.add_argument('version', nargs='?', help='version number to show path for')
977
1118
 
978
1119
  @staticmethod
979
1120
  def run(args: Namespace) -> str | None:
@@ -997,10 +1138,10 @@ class _path(COMMAND):
997
1138
  if not path.exists():
998
1139
  path = basepath / 'python.exe'
999
1140
  if not path.exists():
1000
- return 'Error: Can not find python executable in '\
1001
- f'"{basepath}"'
1141
+ return f'Error: Can not find python executable in "{basepath}"'
1002
1142
 
1003
1143
  print(path)
1004
1144
 
1145
+
1005
1146
  if __name__ == '__main__':
1006
1147
  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,,