pystand 1.2__py3-none-any.whl → 1.4__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.2
3
+ Version: 1.4
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
@@ -33,7 +33,7 @@ provided:
33
33
  |`path` |Show path prefix to installed version base directory |
34
34
 
35
35
  By default, Python versions are sourced from the latest
36
- `python-build-standalone` [release][pbs-rel] available but you can
36
+ `python-build-standalone` [release][pbs-rel] available (e.g. "`20240415`") but you can
37
37
  optionally specify any older release. The required
38
38
  [distribution](https://gregoryszorc.com/docs/python-build-standalone/main/running.html)
39
39
  for your machine architecture is normally auto-detected but can be
@@ -99,8 +99,8 @@ $ pipx install --python $(pystand path -p 3.12) cowsay
99
99
  See detailed usage information in the [Usage](#usage) section that
100
100
  follows.
101
101
 
102
- Note that unlike nearly all similar tools such as [`pyenv`][pyenv], [`pdm
103
- python`][pdmpy], and [`hatch python`][hatchpy], `pystand` directly
102
+ Note that unlike nearly all similar tools such as [`pyenv`][pyenv],
103
+ [`pdm python`][pdmpy], and [`hatch python`][hatchpy], `pystand` directly
104
104
  checks the [`python-build-standalone`][pbs] github site to fetch for new
105
105
  [releases][pbs-rel] but those other tools require a software update
106
106
  before they can see new releases. This means that Python updates are
@@ -117,7 +117,8 @@ Type `pystand` or `pystand -h` to view the usage summary:
117
117
 
118
118
  ```
119
119
  usage: pystand [-h] [-D DISTRIBUTION] [-B BASE_DIR] [-C CACHE_MINUTES]
120
- [--purge-days PURGE_DAYS] [-V]
120
+ [--purge-days PURGE_DAYS]
121
+ [--github-access-token GITHUB_ACCESS_TOKEN] [-V]
121
122
  {install,update,remove,list,show,path} ...
122
123
 
123
124
  Command line tool to download, install, and update pre-built Python versions
@@ -135,12 +136,16 @@ options:
135
136
  specify pystand base dir for storing versions and
136
137
  metadata. Default is "$HOME/.local/share/pystand"
137
138
  -C CACHE_MINUTES, --cache-minutes CACHE_MINUTES
138
- cache latest release tag fetch for this many minutes,
139
- before rechecking for latest. Default is 60 minutes
139
+ cache latest YYYYMMDD release tag fetch for this many
140
+ minutes, before rechecking for latest. Default is 60
141
+ minutes
140
142
  --purge-days PURGE_DAYS
141
- cache release file lists for this number of days after
142
- last version referencing it is removed. Default is 30
143
- days
143
+ cache YYYYMMDD release file lists for this number of
144
+ days after last version referencing it is removed.
145
+ Default is 90 days
146
+ --github-access-token GITHUB_ACCESS_TOKEN
147
+ Optional Github access token. Can specify to reduce
148
+ rate limiting.
144
149
  -V show pystand version
145
150
 
146
151
  Commands:
@@ -174,8 +179,9 @@ positional arguments:
174
179
  options:
175
180
  -h, --help show this help message and exit
176
181
  -r RELEASE, --release RELEASE
177
- install from specified python-build-standalone release
178
- (e.g. 20240415), default is latest release
182
+ install from specified python-build-standalone
183
+ YYYYMMDD release (e.g. 20240415), default is latest
184
+ release
179
185
  -f, --force force install even if already installed
180
186
  ```
181
187
 
@@ -192,8 +198,8 @@ positional arguments:
192
198
  options:
193
199
  -h, --help show this help message and exit
194
200
  -r RELEASE, --release RELEASE
195
- update to specified release (e.g. 20240415), default
196
- is latest release
201
+ update to specified YYYMMDD release (e.g. 20240415),
202
+ default is latest release
197
203
  -a, --all update ALL versions
198
204
  --skip skip the specified versions when updating all (only
199
205
  can be specified with --all)
@@ -217,8 +223,8 @@ options:
217
223
  --skip skip the specified versions when removing all (only
218
224
  can be specified with --all)
219
225
  -r RELEASE, --release RELEASE
220
- only remove versions if from specified release (e.g.
221
- 20240415)
226
+ only remove versions if from specified YYYMMDD release
227
+ (e.g. 20240415)
222
228
  ```
223
229
 
224
230
  ### Command `list`
@@ -236,8 +242,8 @@ options:
236
242
  -v, --verbose explicitly report why a version is not eligible for
237
243
  update
238
244
  -r RELEASE, --release RELEASE
239
- use specified release (e.g. 20240415) for verbose
240
- compare, default is latest release
245
+ use specified YYYYMMDD release (e.g. 20240415) for
246
+ verbose compare, default is latest release
241
247
  ```
242
248
 
243
249
  ### Command `show`
@@ -248,7 +254,7 @@ usage: pystand show [-h] [-d] [release]
248
254
  Show versions available from a release.
249
255
 
250
256
  positional arguments:
251
- release python-build-standalone release to show (e.g.
257
+ release python-build-standalone YYYYMMDD release to show (e.g.
252
258
  20240415), default is latest release
253
259
 
254
260
  options:
@@ -302,8 +308,8 @@ $ pipx uninstall pystand
302
308
  `pystand` extrapolates any version text you specify on the command line
303
309
  to the latest available corresponding installed or release version. For
304
310
  example, if you specify `pystand install 3.12` then `pystand` will look
305
- in the release files to find the latest (i.e. highest) available
306
- version of `3.12`, e.g. `3.12.3` (at the time of writing), and will install
311
+ in the release files to find the latest (i.e. highest) available version
312
+ of `3.12`, e.g. `3.12.3` (at the time of writing), and will install
307
313
  that. Of course you can specify the exact version if you wish, e.g.
308
314
  `3.12.3` but generally you don't need to bother. This is true for any
309
315
  command that takes a version argument so be aware that this may be
@@ -333,8 +339,8 @@ anything after on a line) are ignored. Type `pystand` to see all
333
339
  supported options.
334
340
 
335
341
  The global options: `--distribution`, `--base-dir`, `--cache-minutes`,
336
- `--purge-days` are the only sensible candidates to consider setting as
337
- defaults.
342
+ `--purge-days`, `--github-access-token` are the only sensible candidates
343
+ to consider setting as defaults.
338
344
 
339
345
  ## Command Line Tab Completion
340
346
 
@@ -0,0 +1,6 @@
1
+ pystand.py,sha256=ZwryYWIROt0rscb3vQTx9uE8eZus4x7iUOhHeJBC3SA,28134
2
+ pystand-1.4.dist-info/METADATA,sha256=D-nGRE3vUfZFEJb-NsD74DgROuSzM2E_kcbmBQVvNBc,14679
3
+ pystand-1.4.dist-info/WHEEL,sha256=cpQTJ5IWu9CdaPViMhC9YzF8gZuS5-vlfoFihTBC86A,91
4
+ pystand-1.4.dist-info/entry_points.txt,sha256=DG4ps3I3nni1bubV1tXs6u8FARgkdbAYaEAzZD4RAo8,41
5
+ pystand-1.4.dist-info/top_level.txt,sha256=NoWUh19UQymAJLHTCdxMnVwV6Teftef5fzyF3OWLyNY,8
6
+ pystand-1.4.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.43.0)
2
+ Generator: setuptools (70.1.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
pystand.py CHANGED
@@ -18,6 +18,7 @@ import time
18
18
  import urllib.request
19
19
  from argparse import ArgumentParser, Namespace
20
20
  from collections import defaultdict
21
+ from datetime import date
21
22
  from pathlib import Path
22
23
  from typing import Any, Iterable, Iterator, Optional
23
24
 
@@ -31,6 +32,16 @@ GITHUB_REPO = f'{REPO_OWNER}/{REPO}'
31
32
  LATEST_RELEASE_URL = f'https://raw.githubusercontent.com/{GITHUB_REPO}'\
32
33
  '/latest-release/latest-release.json'
33
34
 
35
+ # The following release is the first one that supports "install_only"
36
+ # builds so this tool does not support any releases before this.
37
+ FIRST_RELEASE = '20210724'
38
+
39
+ # Sample release tag for documentation/usage examples
40
+ SAMPL_RELEASE = '20240415'
41
+
42
+ # The following regexp pattern is used to match the end of a release file name
43
+ ENDPAT = r'-install_only[-\dT]*.tar.gz$'
44
+
34
45
  PROG = Path(__file__).stem
35
46
  CNFFILE = platformdirs.user_config_path(f'{PROG}-flags.conf')
36
47
 
@@ -99,7 +110,14 @@ def get_gh(args: Namespace) -> Any:
99
110
  return get_gh_handle
100
111
 
101
112
  from github import Github
102
- get_gh_handle = Github() # type: ignore
113
+ if args.github_access_token:
114
+ from github import Auth
115
+ auth = Auth.Token(args.github_access_token)
116
+ else:
117
+ auth = None
118
+
119
+ # Save this handle globally for future use
120
+ get_gh_handle = Github(auth=auth) # type: ignore
103
121
  return get_gh_handle
104
122
 
105
123
  def rm_path(path: Path) -> None:
@@ -169,8 +187,30 @@ def get_version_names(args: Namespace) -> list[str]:
169
187
  return sorted(all_names - given, key=parse_version) \
170
188
  if args.all else versions
171
189
 
172
- def get_latest_release_tag(args: Namespace) -> str:
173
- 'Return the latest release tag'
190
+ def check_release_tag(release: str, *,
191
+ check_first: bool = True) -> Optional[str]:
192
+ 'Check the specified release tag is valid'
193
+ if not release.isdigit() or len(release) != len(FIRST_RELEASE):
194
+ return 'Release must be a YYYYMMDD string.'
195
+
196
+ try:
197
+ _ = date.fromisoformat(f'{release[:4]}-{release[4:6]}-{release[6:]}')
198
+ except Exception:
199
+ return 'Release must be a YYYYMMDD date string.'
200
+
201
+ if check_first and release < FIRST_RELEASE:
202
+ return f'Releases before "{FIRST_RELEASE}" do not include '\
203
+ '"*-install_only" builds so are not supported.'
204
+ return None
205
+
206
+ def get_release_tag(args: Namespace) -> str:
207
+ 'Return the release tag, or latest if not specified'
208
+ if release := args.release:
209
+ if err := check_release_tag(release):
210
+ sys.exit(err)
211
+
212
+ return release
213
+
174
214
  if args._latest_release.exists():
175
215
  stat = args._latest_release.stat()
176
216
  if time.time() < (stat.st_mtime + int(args.cache_minutes * 60)):
@@ -183,16 +223,25 @@ def get_latest_release_tag(args: Namespace) -> str:
183
223
  with urllib.request.urlopen(LATEST_RELEASE_URL) as url:
184
224
  data = json.load(url)
185
225
  except Exception:
186
- sys.exit('Failed to fetch latest release tag.')
226
+ sys.exit('Failed to fetch latest YYYYMMDD release tag.')
187
227
 
188
228
  tag = data.get('tag')
189
229
 
190
230
  if not tag:
191
- sys.exit('Latest release tag timestamp file is corrupted.')
231
+ sys.exit('Latest YYYYMMDD release tag timestamp file is corrupted.')
192
232
 
193
233
  args._latest_release.write_text(tag + '\n')
194
234
  return tag
195
235
 
236
+ def get_release_name(filename: str) -> Optional[str]:
237
+ 'Search for and strip the release name from the file name'
238
+ if not (m := re.search(ENDPAT, filename)):
239
+ return None
240
+
241
+ # Strip of end of file name and also strip any +8 digit embedded
242
+ # release date
243
+ return re.sub(r'\+\d{8}', '', filename[:m.start()])
244
+
196
245
  def get_release_files(args, tag, implementation: Optional[str] = None) -> dict:
197
246
  'Return the release files for the given tag'
198
247
  # Look for tag data in our release cache
@@ -212,17 +261,11 @@ def get_release_files(args, tag, implementation: Optional[str] = None) -> dict:
212
261
 
213
262
  # Iterate over the release assets and store the files in a dict to
214
263
  # return
215
- end = '-install_only.tar.gz'
216
264
  for file in release.get_assets():
217
- name = file.name
218
- if not name.endswith(end):
265
+ if not (name := get_release_name(file.name)):
219
266
  continue
220
267
 
221
- name = name[:-len(end)]
222
- impl_ver, rest = name.split('+', maxsplit=1)
223
- impl, ver = impl_ver.split('-', maxsplit=1)
224
- rest = rest.split('-', maxsplit=1)[1]
225
-
268
+ impl, ver, rest = name.split('-', maxsplit=2)
226
269
  if impl not in files:
227
270
  files[impl] = defaultdict(dict)
228
271
 
@@ -265,15 +308,13 @@ def update_version_symlinks(args: Namespace) -> None:
265
308
  for name, tgt in oldlinks.items():
266
309
  new_tgt = newlinks.get(name)
267
310
  if not new_tgt or new_tgt != tgt:
268
- path = Path(base / name)
269
- path.unlink()
311
+ Path(base / name).unlink()
270
312
 
271
313
  # Create all needed new links
272
314
  for name, tgt in newlinks.items():
273
315
  old_tgt = oldlinks.get(name)
274
316
  if not old_tgt or old_tgt != tgt:
275
- path = Path(base / name)
276
- path.symlink_to(tgt, target_is_directory=True)
317
+ Path(base / name).symlink_to(tgt, target_is_directory=True)
277
318
 
278
319
  def purge_unused_releases(args: Namespace) -> None:
279
320
  'Purge old releases that are no longer needed and have expired'
@@ -286,10 +327,11 @@ def purge_unused_releases(args: Namespace) -> None:
286
327
  if (release := get_json(version / args._data).get('release')):
287
328
  keep.add(release)
288
329
 
289
- for release in releases - keep:
330
+ now_secs = time.time()
331
+ end_secs = args.purge_days * 86400
332
+ for release in (releases - keep):
290
333
  rdir = args._releases / release
291
- stat = rdir.stat()
292
- if time.time() > (stat.st_mtime + args.purge_days * 86400):
334
+ if (rdir.stat().st_mtime + end_secs) < now_secs:
293
335
  rdir.unlink()
294
336
 
295
337
  class COMMAND:
@@ -381,13 +423,16 @@ def main() -> Optional[str]:
381
423
  help=f'specify {PROG} base dir for storing '
382
424
  'versions and metadata. Default is "%(default)s"')
383
425
  opt.add_argument('-C', '--cache-minutes', default=60, type=float,
384
- help='cache latest release tag fetch for this many '
385
- 'minutes, before rechecking for latest. '
426
+ help='cache latest YYYYMMDD release tag fetch for this '
427
+ 'many minutes, before rechecking for latest. '
386
428
  'Default is %(default)d minutes')
387
- opt.add_argument('--purge-days', default=30, type=int,
388
- help='cache release file lists for this number '
429
+ opt.add_argument('--purge-days', default=90, type=int,
430
+ help='cache YYYYMMDD release file lists for this number '
389
431
  'of days after last version referencing it is removed. '
390
432
  'Default is %(default)d days')
433
+ opt.add_argument('--github-access-token',
434
+ help='Optional Github access token. Can specify to reduce '
435
+ 'rate limiting.')
391
436
  opt.add_argument('-V', action='store_true',
392
437
  help=f'show {PROG} version')
393
438
  cmd = opt.add_subparsers(title='Commands', dest='cmdname')
@@ -466,7 +511,7 @@ class _install(COMMAND):
466
511
  def init(parser: ArgumentParser) -> None:
467
512
  parser.add_argument('-r', '--release',
468
513
  help=f'install from specified {REPO} '
469
- 'release (e.g. 20240415), '
514
+ f'YYYYMMDD release (e.g. {SAMPL_RELEASE}), '
470
515
  'default is latest release')
471
516
  parser.add_argument('-f', '--force', action='store_true',
472
517
  help='force install even if already installed')
@@ -475,7 +520,7 @@ class _install(COMMAND):
475
520
 
476
521
  @staticmethod
477
522
  def run(args: Namespace) -> Optional[str]:
478
- release = args.release or get_latest_release_tag(args)
523
+ release = get_release_tag(args)
479
524
  files = get_release_files(args, release, 'cpython')
480
525
  if not files:
481
526
  return f'Release "{release}" not found, or has no compatible files.'
@@ -503,8 +548,8 @@ class _update(COMMAND):
503
548
  @staticmethod
504
549
  def init(parser: ArgumentParser) -> None:
505
550
  parser.add_argument('-r', '--release',
506
- help='update to specified release (e.g. 20240415), '
507
- 'default is latest release')
551
+ help='update to specified YYYMMDD release (e.g. '
552
+ f'{SAMPL_RELEASE}), default is latest release')
508
553
  parser.add_argument('-a', '--all', action='store_true',
509
554
  help='update ALL versions')
510
555
  parser.add_argument('--skip', action='store_true',
@@ -519,7 +564,7 @@ class _update(COMMAND):
519
564
 
520
565
  @staticmethod
521
566
  def run(args: Namespace) -> Optional[str]:
522
- release_target = args.release or get_latest_release_tag(args)
567
+ release_target = get_release_tag(args)
523
568
  files = get_release_files(args, release_target, 'cpython')
524
569
  if not files:
525
570
  return f'Release "{release_target}" not found.'
@@ -571,18 +616,23 @@ class _remove(COMMAND):
571
616
  help='skip the specified versions when '
572
617
  'removing all (only can be specified with --all)')
573
618
  parser.add_argument('-r', '--release',
574
- help='only remove versions if from '
575
- 'specified release (e.g. 20240415)')
619
+ help='only remove versions if from specified '
620
+ f'YYYMMDD release (e.g. {SAMPL_RELEASE})')
576
621
  parser.add_argument('version', nargs='*',
577
622
  help='version to remove (or to skip for '
578
623
  '--all --skip)')
579
624
 
580
625
  @staticmethod
581
626
  def run(args: Namespace) -> Optional[str]:
627
+ release_del = args.release
628
+ if release_del and \
629
+ (err := check_release_tag(release_del, check_first=False)):
630
+ return err
631
+
582
632
  for version in get_version_names(args):
583
633
  dfile = args._versions / version / args._data
584
634
  release = get_json(dfile).get('release') or '?'
585
- if not args.release or release == args.release:
635
+ if not release_del or release == release_del:
586
636
  remove(args, version)
587
637
  print(f'Version {fmt(version, release)} removed.')
588
638
 
@@ -595,14 +645,15 @@ class _list(COMMAND):
595
645
  help='explicitly report why a version is '
596
646
  'not eligible for update')
597
647
  parser.add_argument('-r', '--release',
598
- help='use specified release (e.g. 20240415) for '
599
- 'verbose compare, default is latest release')
648
+ help='use specified YYYYMMDD release '
649
+ f'(e.g. {SAMPL_RELEASE}) for verbose compare, '
650
+ 'default is latest release')
600
651
  parser.add_argument('version', nargs='*',
601
652
  help='only list specified version, else all')
602
653
 
603
654
  @staticmethod
604
655
  def run(args: Namespace) -> Optional[str]:
605
- release_target = args.release or get_latest_release_tag(args)
656
+ release_target = get_release_tag(args)
606
657
  files = get_release_files(args, release_target, 'cpython')
607
658
  if not files:
608
659
  return f'Release "{release_target}" not found.'
@@ -651,12 +702,12 @@ class _show(COMMAND):
651
702
  help='also show all available distributions for '
652
703
  'each version from the release')
653
704
  parser.add_argument('release', nargs='?',
654
- help=f'{REPO} release to show (e.g. 20240415), '
655
- 'default is latest release')
705
+ help=f'{REPO} YYYYMMDD release to show (e.g. '
706
+ f'{SAMPL_RELEASE}), default is latest release')
656
707
 
657
708
  @staticmethod
658
709
  def run(args: Namespace) -> None:
659
- release = args.release or get_latest_release_tag(args)
710
+ release = get_release_tag(args)
660
711
  files = get_release_files(args, release, 'cpython')
661
712
  if not files:
662
713
  sys.exit(f'Error: release "{release}" not found.')
@@ -1,6 +0,0 @@
1
- pystand.py,sha256=5X--S1zNpaEauP4FkubSe9J5FM3qQ1OO92ZcPlk__4I,26248
2
- pystand-1.2.dist-info/METADATA,sha256=V72dn_zsBb0W0ym4lYh3EljiaOqY0s2l9fuszbK_EFg,14304
3
- pystand-1.2.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
4
- pystand-1.2.dist-info/entry_points.txt,sha256=DG4ps3I3nni1bubV1tXs6u8FARgkdbAYaEAzZD4RAo8,41
5
- pystand-1.2.dist-info/top_level.txt,sha256=NoWUh19UQymAJLHTCdxMnVwV6Teftef5fzyF3OWLyNY,8
6
- pystand-1.2.dist-info/RECORD,,