pystand 1.11__py3-none-any.whl → 2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pystand
3
- Version: 1.11
3
+ Version: 2.0
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
@@ -13,6 +13,7 @@ Requires-Dist: argcomplete
13
13
  Requires-Dist: packaging
14
14
  Requires-Dist: platformdirs
15
15
  Requires-Dist: pygithub
16
+ Requires-Dist: zstandard
16
17
 
17
18
  ## PYSTAND - Install Python Versions From The Python-Build-Standalone Project
18
19
  [![PyPi](https://img.shields.io/pypi/v/pystand)](https://pypi.org/project/pystand/)
@@ -33,11 +34,15 @@ provided:
33
34
  |`path` |Show path prefix to installed version base directory |
34
35
 
35
36
  By default, Python versions are sourced from the latest
36
- `python-build-standalone` [release][pbs-rel] available (e.g. "`20240415`") but you can
37
- optionally specify any older release. The required
37
+ `python-build-standalone` [release][pbs-rel] available (e.g.
38
+ "`20240415`") but you can optionally specify any older release. The
39
+ required
38
40
  [distribution](https://gregoryszorc.com/docs/python-build-standalone/main/running.html)
39
- for your machine architecture is normally auto-detected but can be
40
- overridden if required.
41
+ for your machine architecture is normally auto-detected. By default, the
42
+ _`install_only_stripped`_ build of the distribution is installed but you
43
+ can choose to [install any other
44
+ build/distribution](#installing-other-builds/distributions) instead, or
45
+ in parallel.
41
46
 
42
47
  Some simple usage examples are:
43
48
 
@@ -66,21 +71,21 @@ $ pystand install 3.10
66
71
  Version 3.10.14 @ 20240415 installed.
67
72
 
68
73
  $ pystand list
69
- 3.10.14 @ 20240415 distribution="x86_64-unknown-linux-gnu"
70
- 3.12.3 @ 20240415 distribution="x86_64-unknown-linux-gnu"
74
+ 3.10.14 @ 20240415 distribution="x86_64-unknown-linux-gnu-install_only_stripped"
75
+ 3.12.3 @ 20240415 distribution="x86_64-unknown-linux-gnu-install_only_stripped"
71
76
 
72
77
  $ pystand show
73
- 3.8.19 @ 20240415 distribution="x86_64-unknown-linux-gnu"
74
- 3.9.19 @ 20240415 distribution="x86_64-unknown-linux-gnu"
75
- 3.10.14 @ 20240415 distribution="x86_64-unknown-linux-gnu" (installed)
76
- 3.11.9 @ 20240415 distribution="x86_64-unknown-linux-gnu"
77
- 3.12.3 @ 20240415 distribution="x86_64-unknown-linux-gnu" (installed)
78
+ 3.8.19 @ 20240415 distribution="x86_64-unknown-linux-gnu-install_only_stripped"
79
+ 3.9.19 @ 20240415 distribution="x86_64-unknown-linux-gnu-install_only_stripped"
80
+ 3.10.14 @ 20240415 distribution="x86_64-unknown-linux-gnu-install_only_stripped" (installed)
81
+ 3.11.9 @ 20240415 distribution="x86_64-unknown-linux-gnu-install_only_stripped"
82
+ 3.12.3 @ 20240415 distribution="x86_64-unknown-linux-gnu-install_only_stripped" (installed)
78
83
 
79
84
  $ pystand remove 3.10
80
85
  Version 3.10.14 @ 20240415 removed.
81
86
 
82
87
  $ pystand list
83
- 3.12.3 @ 20240415 distribution="x86_64-unknown-linux-gnu"
88
+ 3.12.3 @ 20240415 distribution="x86_64-unknown-linux-gnu-install_only_stripped"
84
89
  ```
85
90
 
86
91
  Here are some examples showing how to use an installed version ..
@@ -122,10 +127,10 @@ https://github.com/bulletmark/pystand.
122
127
  Type `pystand` or `pystand -h` to view the usage summary:
123
128
 
124
129
  ```
125
- usage: pystand [-h] [-D DISTRIBUTION] [-B BASE_DIR] [-C CACHE_MINUTES]
126
- [--purge-days PURGE_DAYS]
130
+ usage: pystand [-h] [-D DISTRIBUTION] [-P PREFIX_DIR] [-C CACHE_DIR]
131
+ [-M CACHE_MINUTES] [--purge-days PURGE_DAYS]
127
132
  [--github-access-token GITHUB_ACCESS_TOKEN] [--no-strip]
128
- [--no-extra-strip] [-V]
133
+ [-V]
129
134
  {install,update,remove,list,show,path} ...
130
135
 
131
136
  Command line tool to download, install, and update pre-built Python versions
@@ -135,26 +140,27 @@ https://github.com/indygreg/python-build-standalone.
135
140
  options:
136
141
  -h, --help show this help message and exit
137
142
  -D DISTRIBUTION, --distribution DISTRIBUTION
138
- python-build-standalone "*-install_only" distribution,
139
- e.g. "x86_64-unknown-linux-gnu". Default is auto-
140
- detected (detected as "x86_64-unknown-linux-gnu" for
141
- this current host).
142
- -B BASE_DIR, --base-dir BASE_DIR
143
- specify pystand base dir for storing versions and
144
- metadata. Default is "$HOME/.local/share/pystand"
145
- -C CACHE_MINUTES, --cache-minutes CACHE_MINUTES
143
+ python-build-standalone distribution. Default is auto-
144
+ detected (detected as "x86_64-unknown-linux-gnu-
145
+ install_only_stripped" for this current host).
146
+ -P PREFIX_DIR, --prefix-dir PREFIX_DIR
147
+ specify prefix dir for storing versions. Default is
148
+ "$HOME/.local/share/pystand"
149
+ -C CACHE_DIR, --cache-dir CACHE_DIR
150
+ specify cache dir for downloads. Default is
151
+ "$HOME/.cache/pystand"
152
+ -M CACHE_MINUTES, --cache-minutes CACHE_MINUTES
146
153
  cache latest YYYYMMDD release tag fetch for this many
147
154
  minutes, before rechecking for latest. Default is 60
148
155
  minutes
149
156
  --purge-days PURGE_DAYS
150
- cache YYYYMMDD release file lists for this number of
151
- days after last version referencing it is removed.
152
- Default is 90 days
157
+ cache YYYYMMDD release file lists and downloads for
158
+ this number of days after last version referencing
159
+ that release is removed. Default is 90 days
153
160
  --github-access-token GITHUB_ACCESS_TOKEN
154
161
  optional Github access token. Can specify to reduce
155
162
  rate limiting.
156
- --no-strip do not use or create stripped binaries
157
- --no-extra-strip do not restrip already stripped source binaries
163
+ --no-strip do strip downloaded binaries
158
164
  -V, --version just show pystand version
159
165
 
160
166
  Commands:
@@ -258,18 +264,18 @@ options:
258
264
  ### Command `show`
259
265
 
260
266
  ```
261
- usage: pystand show [-h] [-D] [release]
267
+ usage: pystand show [-h] [-a] [release]
262
268
 
263
269
  Show versions available from a release.
264
270
 
265
271
  positional arguments:
266
- release python-build-standalone YYYYMMDD release to show (e.g.
267
- 20240415), default is latest release
272
+ release python-build-standalone YYYYMMDD release to show (e.g.
273
+ 20240415), default is latest release
268
274
 
269
275
  options:
270
- -h, --help show this help message and exit
271
- -D, --distribution also show all available distributions for each version
272
- from the release
276
+ -h, --help show this help message and exit
277
+ -a, --all also show all available distributions for each version from the
278
+ release
273
279
  ```
274
280
 
275
281
  ### Command `path`
@@ -313,6 +319,36 @@ To uninstall:
313
319
  $ pipx uninstall pystand
314
320
  ```
315
321
 
322
+ ## Installing Other Builds/Distributions
323
+
324
+ The _`install_only_stripped`_ build of each distribution is installed by
325
+ default. See description of distributions/builds
326
+ [here](https://gregoryszorc.com/docs/python-build-standalone/main/running.html#obtaining-distributions).
327
+ However, you can choose to install other distributions/builds. E.g. If
328
+ we use a standard modern Linux x86_64 machine as an example, the default
329
+ distribution is _`x86_64-unknown-linux-gnu-install_only_stripped`_ and
330
+ the versions for these are installed by default at
331
+ `~/.local/share/pystand/<version>`.
332
+
333
+ However, let's say you want to experiment with the new free-threaded
334
+ 3.13 build. You can install this to a different directory, e.g.
335
+
336
+ ```sh
337
+ $ mkdir ./3.13-freethreaded
338
+ $ cd ./3.13-freethreaded
339
+
340
+ $ pystand -P. -D x86_64-unknown-linux-gnu-freethreaded+lto-full install 3.13
341
+ $ ./3.13/bin/python -V
342
+ Python 3.13.0
343
+
344
+ $ pystand -P . list
345
+ 3.13.0 @ 20241016 distribution="x86_64_v4-unknown-linux-gnu-freethreaded+lto-full"
346
+ ```
347
+
348
+ Note you can set a different default distribution by
349
+ specifying `--distribution` as a [default
350
+ option](#command-default-options).
351
+
316
352
  ## Extrapolation of Python Versions
317
353
 
318
354
  `pystand` extrapolates any version text you specify on the command line
@@ -348,9 +384,9 @@ options will be concatenated and automatically prepended to your
348
384
  anything after on a line) are ignored. Type `pystand` to see all
349
385
  supported options.
350
386
 
351
- The global options: `--distribution`, `--base-dir`, `--cache-minutes`,
352
- `--purge-days`, `--github-access-token`, `--no-strip`,
353
- `--no-extra-strip` are the only sensible candidates to consider setting
387
+ The global options: `--distribution`, `--prefix-dir`, `--cache-dir`,
388
+ `--cache-minutes`, `--purge-days`, `--github-access-token`,
389
+ `--no-strip`, are the only sensible candidates to consider setting
354
390
  as defaults.
355
391
 
356
392
  ## Github API Rate Limiting
@@ -0,0 +1,6 @@
1
+ pystand.py,sha256=UqlzHehVQ6gsP0p4CmVqrkYoaV6w5t0mO7y1d6VYkR0,32507
2
+ pystand-2.0.dist-info/METADATA,sha256=P6edgnJxiBTLN_iRj9fphdhOLW3nmRMJsZo4SOZA-EI,17403
3
+ pystand-2.0.dist-info/WHEEL,sha256=R06PA3UVYHThwHvxuRWMqaGcr-PuniXahwjmQRFMEkY,91
4
+ pystand-2.0.dist-info/entry_points.txt,sha256=DG4ps3I3nni1bubV1tXs6u8FARgkdbAYaEAzZD4RAo8,41
5
+ pystand-2.0.dist-info/top_level.txt,sha256=NoWUh19UQymAJLHTCdxMnVwV6Teftef5fzyF3OWLyNY,8
6
+ pystand-2.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.1.0)
2
+ Generator: setuptools (75.5.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
pystand.py CHANGED
@@ -15,7 +15,9 @@ import shlex
15
15
  import shutil
16
16
  import subprocess
17
17
  import sys
18
+ import tarfile
18
19
  import time
20
+ import urllib.parse
19
21
  import urllib.request
20
22
  from argparse import ArgumentParser, Namespace
21
23
  from collections import defaultdict
@@ -25,6 +27,7 @@ from typing import Any, Iterable, Iterator
25
27
 
26
28
  import argcomplete
27
29
  import platformdirs
30
+ import zstandard
28
31
  from packaging.version import parse as parse_version
29
32
 
30
33
  REPO_OWNER = 'indygreg'
@@ -33,31 +36,22 @@ GITHUB_REPO = f'{REPO_OWNER}/{REPO}'
33
36
  LATEST_RELEASE_URL = f'https://raw.githubusercontent.com/{GITHUB_REPO}'\
34
37
  '/latest-release/latest-release.json'
35
38
 
36
- # The following release is the first one that supports "install_only"
37
- # builds so this tool does not support any releases before this.
38
- FIRST_RELEASE = '20210724'
39
-
40
39
  # Sample release tag for documentation/usage examples
41
40
  SAMPL_RELEASE = '20240415'
42
41
 
43
- STRIP_EXT = '_stripped'
44
-
45
- # The following regexp pattern is used to match the end of a release file name
46
- ENDPAT = r'-install_only[-\dT]*.tar.gz$'
47
-
48
42
  PROG = Path(__file__).stem
49
43
  CNFFILE = platformdirs.user_config_path(f'{PROG}-flags.conf')
50
44
 
51
45
  # Default distributions for various platforms
52
46
  DISTRIBUTIONS = {
53
- ('Linux', 'x86_64'): 'x86_64-unknown-linux-gnu',
54
- ('Linux', 'aarch64'): 'aarch64-unknown-linux-gnu',
55
- ('Linux', 'armv7l'): 'armv7-unknown-linux-gnueabihf',
56
- ('Linux', 'armv8l'): 'armv7-unknown-linux-gnueabihf',
57
- ('Darwin', 'x86_64'): 'x86_64-apple-darwin',
58
- ('Darwin', 'aarch64'): 'aarch64-apple-darwin',
59
- ('Windows', 'x86_64'): 'x86_64-pc-windows-msvc',
60
- ('Windows', 'i686'): 'i686-pc-windows-msvc',
47
+ ('Linux', 'x86_64'): 'x86_64-unknown-linux-gnu-install_only_stripped',
48
+ ('Linux', 'aarch64'): 'aarch64-unknown-linux-gnu-install_only_stripped',
49
+ ('Linux', 'armv7l'): 'armv7-unknown-linux-gnueabihf-install_only_stripped',
50
+ ('Linux', 'armv8l'): 'armv7-unknown-linux-gnueabihf-install_only_stripped',
51
+ ('Darwin', 'x86_64'): 'x86_64-apple-darwin-install_only_stripped',
52
+ ('Darwin', 'aarch64'): 'aarch64-apple-darwin-install_only_stripped',
53
+ ('Windows', 'x86_64'): 'x86_64-pc-windows-msvc-install_only_stripped',
54
+ ('Windows', 'i686'): 'i686-pc-windows-msvc-install_only_stripped',
61
55
  }
62
56
 
63
57
  def is_admin() -> bool:
@@ -132,6 +126,51 @@ def rm_path(path: Path) -> None:
132
126
  elif path.exists():
133
127
  path.unlink()
134
128
 
129
+ def unpack_zst(filename, extract_dir):
130
+ with open(filename, 'rb') as compressed:
131
+ dctx = zstandard.ZstdDecompressor()
132
+ with dctx.stream_reader(compressed) as reader:
133
+ with tarfile.open(fileobj=reader, mode='r|') as tar:
134
+ tar.extractall(path=extract_dir)
135
+
136
+ def fetch(args: Namespace, release: str, url: str, tdir: Path) -> str | None:
137
+ 'Fetch and unpack a release file'
138
+ error = None
139
+ tmpdir = tdir.with_name(f'{tdir.name}-tmp')
140
+ rm_path(tmpdir)
141
+ tmpdir.mkdir(parents=True)
142
+
143
+ filename_q = Path(urllib.parse.urlparse(url).path).name
144
+ filename = urllib.parse.unquote(filename_q)
145
+ cache_file = args._downloads / release / filename
146
+ cache_file.parent.mkdir(parents=True, exist_ok=True)
147
+
148
+ if not cache_file.exists():
149
+ try:
150
+ urllib.request.urlretrieve(url, cache_file)
151
+ except Exception as e:
152
+ error = f'Failed to fetch "{url}": {e}'
153
+
154
+ if error:
155
+ rm_path(cache_file)
156
+ else:
157
+ if filename.endswith('.zst'):
158
+ shutil.register_unpack_format('zst', ['.zst'], unpack_zst)
159
+
160
+ try:
161
+ shutil.unpack_archive(cache_file, tmpdir)
162
+ except Exception as e:
163
+ error = f'Failed to unpack "{url}": {e}'
164
+ else:
165
+ pdir = tmpdir / 'python' / 'install'
166
+ if not pdir.exists():
167
+ pdir = pdir.parent
168
+
169
+ pdir.replace(tdir)
170
+
171
+ rm_path(tmpdir)
172
+ return error
173
+
135
174
  def is_release_version(version: str) -> bool:
136
175
  'Check if a string is a formal Python release tag'
137
176
  return version.replace('.', '').isdigit()
@@ -167,11 +206,11 @@ class VersionMatcher:
167
206
  version += '.'
168
207
 
169
208
  # Only allow upgrade of formal release to another formal
170
- # release, or alpha/beta release to another alpha/beta release.
209
+ # release.
171
210
  for full_version in self.seq:
172
211
  if full_version.startswith(version):
173
- if not upgrade \
174
- or is_release_version(full_version) == is_release:
212
+ if not upgrade or not is_release \
213
+ or is_release_version(full_version):
175
214
  return full_version
176
215
 
177
216
  return None
@@ -211,10 +250,9 @@ def get_version_names(args: Namespace) -> list[str]:
211
250
  return sorted(all_names - given, key=parse_version) \
212
251
  if args.all else versions
213
252
 
214
- def check_release_tag(release: str, *,
215
- check_first: bool = True) -> str | None:
253
+ def check_release_tag(release: str) -> str | None:
216
254
  'Check the specified release tag is valid'
217
- if not release.isdigit() or len(release) != len(FIRST_RELEASE):
255
+ if not release.isdigit() or len(release) != len(SAMPL_RELEASE):
218
256
  return 'Release must be a YYYYMMDD string.'
219
257
 
220
258
  try:
@@ -222,9 +260,6 @@ def check_release_tag(release: str, *,
222
260
  except Exception:
223
261
  return 'Release must be a YYYYMMDD date string.'
224
262
 
225
- if check_first and release < FIRST_RELEASE:
226
- return f'Releases before "{FIRST_RELEASE}" do not include '\
227
- '"*-install_only" builds so are not supported.'
228
263
  return None
229
264
 
230
265
  def get_release_tag(args: Namespace) -> str:
@@ -257,22 +292,32 @@ def get_release_tag(args: Namespace) -> str:
257
292
  args._latest_release.write_text(tag + '\n')
258
293
  return tag
259
294
 
260
- def merge(files: dict, name: str, url: str, stripped: bool) -> None:
261
- 'Merge a file into the files dict'
262
- # We store: A string url when there is no stripped version; or a
263
- # tuple of string urls, the first unstripped and the second
264
- # stripped. It's a bit messy but we are keeping compatibility with
265
- # earlier releases (and their cached release files) which did not
266
- # have stripped versions.
267
- impl, ver, distrib = name.split('-', maxsplit=2)
295
+ def add_file(files: dict, tag: str, name: str, url: str) -> None:
296
+ 'Extract the implementation, version, and architecture from a filename'
297
+ if name.endswith('.tar.zst'):
298
+ name = name[:-8]
299
+ elif name.endswith('.tar.gz'):
300
+ name = name[:-7]
301
+ else:
302
+ return
303
+
304
+ impl, ver, arch = name.split('-', 2)
305
+
306
+ # Modern releases have a '+' in the name to separate the version
307
+ if '+' in ver:
308
+ ver, filetag = ver.split('+')
309
+ if filetag != tag:
310
+ return
311
+
268
312
  if impl not in files:
269
- files[impl] = defaultdict(dict)
313
+ files[impl] = {}
270
314
 
271
- if exist := files[impl].get(ver, {}).get(distrib, {}):
272
- files[impl][ver][distrib] = (exist, url) \
273
- if stripped else (url, exist[1])
274
- else:
275
- files[impl][ver][distrib] = ('', url) if stripped else url
315
+ vers = files[impl]
316
+
317
+ if ver not in vers:
318
+ vers[ver] = {}
319
+
320
+ vers[ver][arch] = url
276
321
 
277
322
  def get_release_files(args, tag, implementation: str | None = None) -> dict:
278
323
  'Return the release files for the given tag'
@@ -294,13 +339,7 @@ def get_release_files(args, tag, implementation: str | None = None) -> dict:
294
339
  # Iterate over the release assets and store pertinent files in a
295
340
  # dict to return.
296
341
  for asset in release.get_assets():
297
- name = asset.name
298
- if is_stripped := (STRIP_EXT in name):
299
- name = name.replace(STRIP_EXT, '')
300
-
301
- if m := re.search(ENDPAT, name):
302
- name = re.sub(r'\+\d{8}', '', name[:m.start()])
303
- merge(files, name, asset.browser_download_url, is_stripped)
342
+ add_file(files, tag, asset.name, asset.browser_download_url)
304
343
 
305
344
  if not files:
306
345
  sys.exit(f'Failed to fetch any files for release {tag}')
@@ -374,10 +413,17 @@ def purge_unused_releases(args: Namespace) -> None:
374
413
 
375
414
  now_secs = time.time()
376
415
  end_secs = args.purge_days * 86400
377
- for release in (releases - keep):
416
+ releases -= keep
417
+ for release in releases:
378
418
  rdir = args._releases / release
379
419
  if (rdir.stat().st_mtime + end_secs) < now_secs:
380
420
  rdir.unlink()
421
+ else:
422
+ keep.add(release)
423
+
424
+ downloads = set(f.name for f in args._downloads.iterdir())
425
+ for release in (downloads - keep):
426
+ rm_path(args._downloads / release)
381
427
 
382
428
  class COMMAND:
383
429
  'Base class for all commands'
@@ -439,57 +485,29 @@ def install(args: Namespace, vdir: Path, release: str, distribution: str,
439
485
  'Install a version'
440
486
  version = vdir.name
441
487
 
442
- if not (fileurl := files[version].get(distribution)):
488
+ if not (url := files[version].get(distribution)):
443
489
  return f'Arch "{distribution}" not found for release '\
444
490
  f'{release} version {version}.'
445
491
 
446
- if isinstance(fileurl, str):
447
- stripped = False
448
- else:
449
- stripped = not args.no_strip
450
- if not (fileurl := fileurl[stripped]):
451
- desc = 'stripped' if stripped else 'unstripped'
452
- return f'Arch {desc} "{distribution}" not found for release '\
453
- f'{release} version {version}.'
454
-
455
492
  tmpdir = args._versions / f'.{version}-tmp'
456
493
  rm_path(tmpdir)
457
- tmpdir.mkdir()
458
- tmpdir_py = tmpdir / 'python'
459
- error = None
460
-
461
- try:
462
- urllib.request.urlretrieve(fileurl, tmpdir / 'tmp.tar.gz')
463
- except Exception as e:
464
- error = f'Failed to fetch "{fileurl}": {e}'
465
-
466
- try:
467
- shutil.unpack_archive(tmpdir / 'tmp.tar.gz', tmpdir)
468
- except Exception as e:
469
- error = f'Failed to unpack "{fileurl}": {e}'
494
+ tmpdir.mkdir(parents=True)
470
495
 
471
- if not error:
496
+ if not (error := fetch(args, release, url, tmpdir)):
472
497
  data = {'release': release, 'distribution': distribution}
473
498
 
474
- if args.no_strip:
475
- data['stripped'] = 'forced off'
476
- elif stripped and args.no_extra_strip:
477
- data['stripped'] = 'at_source_only'
478
- else:
479
- if strip_binaries(tmpdir_py, distribution):
480
- data['stripped'] = 'at_source_and_manually' \
481
- if stripped else 'manually'
482
- else:
483
- data['stripped'] = 'at_source' if stripped else 'n/a'
499
+ if not args.no_strip and strip_binaries(tmpdir, distribution):
500
+ data['stripped'] = 'true'
484
501
 
485
- if (error := set_json(tmpdir_py / args._data, data)):
502
+ if (error := set_json(tmpdir / args._data, data)):
486
503
  error = f'Failed to write {version} data file: {error}'
487
504
 
488
- if not error:
505
+ if error:
506
+ shutil.rmtree(tmpdir)
507
+ else:
489
508
  remove(args, version)
490
- tmpdir_py.replace(vdir)
509
+ tmpdir.replace(vdir)
491
510
 
492
- shutil.rmtree(tmpdir)
493
511
  return error
494
512
 
495
513
  def main() -> str | None:
@@ -497,8 +515,9 @@ def main() -> str | None:
497
515
  distro_default = DISTRIBUTIONS.get((platform.system(), platform.machine()))
498
516
  distro_help = distro_default or '?unknown?'
499
517
 
500
- base_dir = Path('/opt' if is_admin() else
501
- platformdirs.user_data_dir()) / PROG
518
+ p = '/opt' if is_admin() else platformdirs.user_data_dir()
519
+ prefix_dir = str(Path(p, PROG))
520
+ cache_dir = platformdirs.user_cache_path() / PROG
502
521
 
503
522
  # Parse arguments
504
523
  opt = ArgumentParser(description=__doc__,
@@ -507,28 +526,28 @@ def main() -> str | None:
507
526
 
508
527
  # Set up main/global arguments
509
528
  opt.add_argument('-D', '--distribution',
510
- help=f'{REPO} "*-install_only" '
511
- 'distribution, e.g. "x86_64-unknown-linux-gnu". '
529
+ help=f'{REPO} distribution. '
512
530
  f'Default is auto-detected (detected as "{distro_help}" '
513
531
  'for this current host).')
514
- opt.add_argument('-B', '--base-dir', default=str(base_dir),
515
- help=f'specify {PROG} base dir for storing '
516
- 'versions and metadata. Default is "%(default)s"')
517
- opt.add_argument('-C', '--cache-minutes', default=60, type=float,
532
+ opt.add_argument('-P', '--prefix-dir', default=prefix_dir,
533
+ help='specify prefix dir for storing '
534
+ 'versions. Default is "%(default)s"')
535
+ opt.add_argument('-C', '--cache-dir', default=str(cache_dir),
536
+ help='specify cache dir for downloads. '
537
+ 'Default is "%(default)s"')
538
+ opt.add_argument('-M', '--cache-minutes', default=60, type=float,
518
539
  help='cache latest YYYYMMDD release tag fetch for this '
519
540
  'many minutes, before rechecking for latest. '
520
541
  'Default is %(default)d minutes')
521
542
  opt.add_argument('--purge-days', default=90, type=int,
522
- help='cache YYYYMMDD release file lists for this number '
523
- 'of days after last version referencing it is removed. '
524
- 'Default is %(default)d days')
543
+ help='cache YYYYMMDD release file lists and downloads for '
544
+ 'this number of days after last version referencing that '
545
+ 'release is removed. Default is %(default)d days')
525
546
  opt.add_argument('--github-access-token',
526
547
  help='optional Github access token. Can specify to reduce '
527
548
  'rate limiting.')
528
549
  opt.add_argument('--no-strip', action='store_true',
529
- help='do not use or create stripped binaries')
530
- opt.add_argument('--no-extra-strip', action='store_true',
531
- help='do not restrip already stripped source binaries')
550
+ help='do strip downloaded binaries')
532
551
  opt.add_argument('-V', '--version', action='store_true',
533
552
  help=f'just show {PROG} version')
534
553
  cmd = opt.add_subparsers(title='Commands', dest='cmdname')
@@ -583,20 +602,24 @@ def main() -> str | None:
583
602
  'using -D/--distribution option.')
584
603
 
585
604
  # Keep some useful info in the namespace passed to the command
586
- base_dir = Path(args.base_dir).expanduser()
605
+ prefix_dir = Path(args.prefix_dir).expanduser()
606
+ cache_dir = Path(args.cache_dir).expanduser()
587
607
 
588
608
  args._distribution = distribution
589
609
  args._data = f'{PROG}.json'
590
- args._latest_release = base_dir / 'latest_release'
591
- args._latest_release.parent.mkdir(parents=True, exist_ok=True)
592
- args._versions = base_dir / 'versions'
610
+
611
+ args._versions = prefix_dir
593
612
  args._versions.mkdir(parents=True, exist_ok=True)
594
- args._releases = base_dir / 'releases'
613
+
614
+ args._downloads = cache_dir / 'downloads'
615
+ args._downloads.mkdir(parents=True, exist_ok=True)
616
+ args._releases = cache_dir / 'releases'
595
617
  args._releases.mkdir(parents=True, exist_ok=True)
618
+ args._latest_release = cache_dir / 'latest_release'
596
619
 
597
620
  result = args.func(args)
598
- update_version_symlinks(args)
599
621
  purge_unused_releases(args)
622
+ update_version_symlinks(args)
600
623
  return result
601
624
 
602
625
  @COMMAND.add
@@ -670,11 +693,11 @@ class _update(COMMAND):
670
693
  if not (data := get_json(args._versions / version / args._data)):
671
694
  continue
672
695
 
673
- release = data.get('release')
674
- if release == release_target:
696
+ if (release := data.get('release')) == release_target:
675
697
  continue
676
698
 
677
- nextver = matcher.match(version, upgrade=True)
699
+ if not (nextver := matcher.match(version, upgrade=True)):
700
+ continue
678
701
 
679
702
  distribution = data.get('distribution')
680
703
  if not distribution or distribution not in files.get(nextver, {}):
@@ -722,7 +745,7 @@ class _remove(COMMAND):
722
745
  def run(args: Namespace) -> str | None:
723
746
  release_del = args.release
724
747
  if release_del and \
725
- (err := check_release_tag(release_del, check_first=False)):
748
+ (err := check_release_tag(release_del)):
726
749
  return err
727
750
 
728
751
  for version in get_version_names(args):
@@ -768,23 +791,31 @@ class _list(COMMAND):
768
791
  app = ''
769
792
  if release_target and release != release_target:
770
793
  nextver = matcher.match(version, upgrade=True)
771
- new_vdir = args._versions / nextver
772
- if nextver != version and new_vdir.exists():
794
+ if not nextver:
773
795
  if args.verbose:
774
- nrelease = get_json(
775
- new_vdir / args._data).get('release', '?')
776
- app = f' not eligible for '\
777
- f'update because {fmt(nextver, nrelease)} '\
778
- 'is already installed.'
796
+ app = ' not eligible for update because '\
797
+ f'release {release_target} does not provide '\
798
+ 'this version.'
779
799
  else:
780
- # May not be updatable if newer release does not support
781
- # this same distribution anymore
782
- if nextver and distribution in files.get(nextver, {}):
783
- upd = f' updatable to {fmt(nextver, release_target)}'
784
- elif args.verbose:
785
- app = f' not eligible for update because '\
786
- f'{fmt(nextver, release_target)} does '\
787
- f'not provide distribution="{distribution}".'
800
+ new_vdir = args._versions / nextver
801
+ if nextver != version and new_vdir.exists():
802
+ if args.verbose:
803
+ nrelease = get_json(
804
+ new_vdir / args._data).get('release', '?')
805
+ app = f' not eligible for '\
806
+ f'update because {fmt(nextver, nrelease)} '\
807
+ 'is already installed.'
808
+ else:
809
+ # May not be updatable if newer release does not support
810
+ # this same distribution anymore
811
+ if nextver and distribution in files.get(nextver, {}):
812
+ upd = ' updatable to '\
813
+ f'{fmt(nextver, release_target)}'
814
+ elif args.verbose:
815
+ app = ' not eligible for update because '\
816
+ f'{fmt(nextver, release_target)} does '\
817
+ 'not provide '\
818
+ f'distribution="{distribution}".'
788
819
 
789
820
  print(f'{fmt(version, release)}{upd} '
790
821
  f'distribution="{distribution}"{app}')
@@ -794,7 +825,7 @@ class _show(COMMAND):
794
825
  'Show versions available from a release.'
795
826
  @staticmethod
796
827
  def init(parser: ArgumentParser) -> None:
797
- parser.add_argument('-D', '--distribution', action='store_true',
828
+ parser.add_argument('-a', '--all', action='store_true',
798
829
  help='also show all available distributions for '
799
830
  'each version from the release')
800
831
  parser.add_argument('release', nargs='?',
@@ -821,7 +852,7 @@ class _show(COMMAND):
821
852
  for distribution in files[version]:
822
853
  app = ' (installed)' \
823
854
  if distribution == installed_distribution else ''
824
- if args.distribution or app \
855
+ if args.all or app \
825
856
  or distribution == args._distribution:
826
857
  if distribution == args._distribution:
827
858
  installable = True
@@ -848,7 +879,8 @@ class _path(COMMAND):
848
879
  matcher = VersionMatcher([f.name for f in iter_versions(args)])
849
880
  version = matcher.match(args.version) or args.version
850
881
  if not version:
851
- return f'No Python version installed.'
882
+ return 'No Python version installed.'
883
+
852
884
  path = args._versions / version
853
885
  if not path.is_dir():
854
886
  return f'Version "{version}" is not installed.'
@@ -1,6 +0,0 @@
1
- pystand.py,sha256=HuCNuIYwXUcVCpAzqtEEj9hdMn-S4j4F7t8yYfNuYeU,32048
2
- pystand-1.11.dist-info/METADATA,sha256=6NYVL_gVjjOT0kvk66KNUFfXlRU-KuGo5HBzuO16w0g,15923
3
- pystand-1.11.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
4
- pystand-1.11.dist-info/entry_points.txt,sha256=DG4ps3I3nni1bubV1tXs6u8FARgkdbAYaEAzZD4RAo8,41
5
- pystand-1.11.dist-info/top_level.txt,sha256=NoWUh19UQymAJLHTCdxMnVwV6Teftef5fzyF3OWLyNY,8
6
- pystand-1.11.dist-info/RECORD,,