pystand 2.16__py3-none-any.whl → 2.17__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.4
2
2
  Name: pystand
3
- Version: 2.16
3
+ Version: 2.17
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-Expression: GPL-3.0-or-later
@@ -13,7 +13,8 @@ Requires-Dist: argcomplete
13
13
  Requires-Dist: packaging
14
14
  Requires-Dist: platformdirs
15
15
  Requires-Dist: pygithub
16
- Requires-Dist: zstandard
16
+ Requires-Dist: certifi
17
+ Requires-Dist: zstandard; python_version < "3.14"
17
18
 
18
19
  ## PYSTAND - Install Python Versions From The Python-Build-Standalone Project
19
20
  [![PyPi](https://img.shields.io/pypi/v/pystand)](https://pypi.org/project/pystand/)
@@ -130,7 +131,7 @@ Type `pystand` or `pystand -h` to view the usage summary:
130
131
  usage: pystand [-h] [-D DISTRIBUTION] [-P PREFIX_DIR] [-C CACHE_DIR]
131
132
  [-M CACHE_MINUTES] [--purge-days PURGE_DAYS]
132
133
  [--github-access-token GITHUB_ACCESS_TOKEN] [--no-strip]
133
- [-V]
134
+ [--cert {system,certifi,none}] [-V]
134
135
  {install,update,upgrade,remove,uninstall,list,show,path,cache} ...
135
136
 
136
137
  Command line tool to download, install, and update pre-built Python versions
@@ -162,6 +163,9 @@ options:
162
163
  optional Github access token. Can specify to reduce
163
164
  rate limiting.
164
165
  --no-strip do not strip downloaded binaries
166
+ --cert {system,certifi,none}
167
+ specify which SSL certificates to use for HTTPS
168
+ requests. Default="system"
165
169
  -V, --version just show pystand version
166
170
 
167
171
  Commands:
@@ -598,7 +602,7 @@ either version 3 of the License, or any later version. This program is
598
602
  distributed in the hope that it will be useful, but WITHOUT ANY
599
603
  WARRANTY; without even the implied warranty of MERCHANTABILITY or
600
604
  FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License at
601
- <http://www.gnu.org/licenses/> for more details.
605
+ <https://en.wikipedia.org/wiki/GNU_General_Public_License> for more details.
602
606
 
603
607
  [pystand]: https://github.com/bulletmark/pystand
604
608
  [pbs]: https://github.com/astral-sh/python-build-standalone
@@ -0,0 +1,6 @@
1
+ pystand.py,sha256=P1m9MPvtswYsQ1EgwGi324v61FY7g9O28suXJln87Ko,39830
2
+ pystand-2.17.dist-info/METADATA,sha256=ecEhgvLgCKC4sVhhEMKcrQnY-yNHVMSYMYGr9dXWG14,25632
3
+ pystand-2.17.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
+ pystand-2.17.dist-info/entry_points.txt,sha256=DG4ps3I3nni1bubV1tXs6u8FARgkdbAYaEAzZD4RAo8,41
5
+ pystand-2.17.dist-info/top_level.txt,sha256=NoWUh19UQymAJLHTCdxMnVwV6Teftef5fzyF3OWLyNY,8
6
+ pystand-2.17.dist-info/RECORD,,
pystand.py CHANGED
@@ -13,6 +13,7 @@ import platform
13
13
  import re
14
14
  import shlex
15
15
  import shutil
16
+ import ssl
16
17
  import sys
17
18
  import time
18
19
  from argparse import ArgumentParser, Namespace
@@ -20,6 +21,7 @@ from collections import defaultdict
20
21
  from datetime import date, datetime
21
22
  from pathlib import Path
22
23
  from typing import Any, Iterable, Iterator
24
+ from urllib.request import urlopen
23
25
 
24
26
  import argcomplete
25
27
  import platformdirs
@@ -45,10 +47,13 @@ DISTRIBUTIONS = {
45
47
  ('Linux', 'armv8l'): 'armv7-unknown-linux-gnueabihf-install_only_stripped',
46
48
  ('Darwin', 'x86_64'): 'x86_64-apple-darwin-install_only_stripped',
47
49
  ('Darwin', 'aarch64'): 'aarch64-apple-darwin-install_only_stripped',
50
+ ('Darwin', 'arm64'): 'aarch64-apple-darwin-install_only_stripped',
48
51
  ('Windows', 'x86_64'): 'x86_64-pc-windows-msvc-shared-install_only_stripped',
49
52
  ('Windows', 'i686'): 'i686-pc-windows-msvc-shared-install_only_stripped',
50
53
  }
51
54
 
55
+ CERTS = ('system', 'certifi', 'none')
56
+
52
57
 
53
58
  def is_admin() -> bool:
54
59
  "Check if we are running as root"
@@ -77,7 +82,7 @@ def fmt(version, release) -> str:
77
82
  return f'{version} @ {release}'
78
83
 
79
84
 
80
- def get_json(file: Path) -> dict:
85
+ def get_json(file: Path) -> dict[str, Any]:
81
86
  from json import load
82
87
 
83
88
  'Get JSON data from given file'
@@ -90,7 +95,7 @@ def get_json(file: Path) -> dict:
90
95
  return {}
91
96
 
92
97
 
93
- def set_json(file: Path, data: dict) -> str | None:
98
+ def set_json(file: Path, data: dict[str, Any]) -> str | None:
94
99
  "Set JSON data to given file"
95
100
  from json import dump
96
101
 
@@ -138,23 +143,30 @@ def rm_path(path: Path) -> None:
138
143
  path.unlink()
139
144
 
140
145
 
141
- def unpack_zst(filename: str, extract_dir: str) -> None:
142
- "Unpack a zstandard compressed tar"
143
- import tarfile
146
+ def register_zst() -> None:
147
+ "Register custom zstandard unpacker"
148
+ # Python 3.14+ has built-in support for zstandard so only register custom
149
+ # handler for earlier versions
150
+ if sys.version_info < (3, 14):
151
+
152
+ def unpack_zst(filename: str, extract_dir: str) -> None:
153
+ "Unpack a zstandard compressed tar"
154
+ import tarfile
144
155
 
145
- import zstandard
156
+ import zstandard
146
157
 
147
- with open(filename, 'rb') as compressed:
148
- dctx = zstandard.ZstdDecompressor()
149
- with dctx.stream_reader(compressed) as reader:
150
- with tarfile.open(fileobj=reader, mode='r|') as tar:
151
- tar.extractall(path=extract_dir)
158
+ with open(filename, 'rb') as compressed:
159
+ dctx = zstandard.ZstdDecompressor()
160
+ with dctx.stream_reader(compressed) as reader:
161
+ with tarfile.open(fileobj=reader, mode='r|') as tar:
162
+ tar.extractall(path=extract_dir)
163
+
164
+ shutil.register_unpack_format('zst', ['.zst'], unpack_zst)
152
165
 
153
166
 
154
167
  def fetch(args: Namespace, release: str, url: str, tdir: Path) -> str | None:
155
168
  "Fetch and unpack a release file"
156
169
  from urllib.parse import unquote, urlparse
157
- from urllib.request import urlretrieve
158
170
 
159
171
  error = None
160
172
  tmpdir = tdir.with_name(f'{tdir.name}-tmp')
@@ -168,7 +180,8 @@ def fetch(args: Namespace, release: str, url: str, tdir: Path) -> str | None:
168
180
 
169
181
  if not cache_file.exists():
170
182
  try:
171
- urlretrieve(url, cache_file)
183
+ with urlopen(url, context=args._cert) as urlp, cache_file.open('wb') as fp:
184
+ shutil.copyfileobj(urlp, fp)
172
185
  except Exception as e:
173
186
  error = f'Failed to fetch "{url}": {e}'
174
187
 
@@ -176,7 +189,7 @@ def fetch(args: Namespace, release: str, url: str, tdir: Path) -> str | None:
176
189
  rm_path(cache_file)
177
190
  else:
178
191
  if filename.endswith('.zst'):
179
- shutil.register_unpack_format('zst', ['.zst'], unpack_zst)
192
+ register_zst()
180
193
 
181
194
  try:
182
195
  shutil.unpack_archive(cache_file, tmpdir)
@@ -306,13 +319,12 @@ def check_release_tag(release: str) -> str | None:
306
319
  # Note we use a simple direct URL fetch to get the latest tag info
307
320
  # because it is much faster than using the GitHub API, and has no
308
321
  # rate-limits.
309
- def fetch_tags() -> Iterator[tuple[str, str]]:
322
+ def fetch_tags(args: Namespace) -> Iterator[tuple[str, str]]:
310
323
  "Fetch the latest release tags from the GitHub release atom feed"
311
324
  import xml.etree.ElementTree as et
312
- from urllib.request import urlopen
313
325
 
314
326
  try:
315
- with urlopen(LATEST_RELEASES) as url:
327
+ with urlopen(LATEST_RELEASES, context=args._cert) as url:
316
328
  data = et.parse(url).getroot()
317
329
  except Exception:
318
330
  sys.exit('Failed to fetch latest YYYYMMDD release atom file.')
@@ -325,12 +337,10 @@ def fetch_tags() -> Iterator[tuple[str, str]]:
325
337
  yield tl, dt
326
338
 
327
339
 
328
- def fetch_tag_latest() -> str:
340
+ def fetch_tag_latest(args: Namespace) -> str:
329
341
  "Fetch the latest release tag from the GitHub"
330
- from urllib.request import urlopen
331
-
332
342
  try:
333
- with urlopen(LATEST_RELEASE_TAG) as url:
343
+ with urlopen(LATEST_RELEASE_TAG, context=args._cert) as url:
334
344
  data = url.geturl()
335
345
  except Exception:
336
346
  sys.exit('Failed to fetch latest YYYYMMDD release tag.')
@@ -351,14 +361,14 @@ def get_release_tag(args: Namespace) -> str:
351
361
  if time.time() < (stat.st_mtime + int(args.cache_minutes * 60)):
352
362
  return args._latest_release.read_text().strip()
353
363
 
354
- if not (tag := fetch_tag_latest()):
364
+ if not (tag := fetch_tag_latest(args)):
355
365
  sys.exit('Latest YYYYMMDD release tag timestamp file is unavailable.')
356
366
 
357
367
  args._latest_release.write_text(tag + '\n')
358
368
  return tag
359
369
 
360
370
 
361
- def add_file(files: dict, tag: str, name: str, url: str) -> None:
371
+ def add_file(files: dict[str, Any], tag: str, name: str, url: str) -> None:
362
372
  "Extract the implementation, version, and architecture from a filename"
363
373
  if name.endswith('.tar.zst'):
364
374
  name = name[:-8]
@@ -386,7 +396,7 @@ def add_file(files: dict, tag: str, name: str, url: str) -> None:
386
396
  vers[ver][arch] = url
387
397
 
388
398
 
389
- def get_release_files(args, tag, implementation: str | None = None) -> dict:
399
+ def get_release_files(args, tag) -> dict[str, Any]:
390
400
  "Return the release files for the given tag"
391
401
  # Look for tag data in our release cache
392
402
  jfile = args._releases / tag
@@ -414,7 +424,7 @@ def get_release_files(args, tag, implementation: str | None = None) -> dict:
414
424
  if error := set_json(jfile, files):
415
425
  sys.exit(f'Failed to write release {tag} file {jfile}: {error}')
416
426
 
417
- return files.get(implementation, {}) if implementation else files
427
+ return files.get(args._implementation, {})
418
428
 
419
429
 
420
430
  def update_version_symlinks(args: Namespace) -> None:
@@ -499,7 +509,7 @@ def purge_unused_releases(args: Namespace) -> None:
499
509
  def show_list(args: Namespace) -> None:
500
510
  "Show a list of available releases"
501
511
  latest = parse_version(get_release_tag(args))
502
- releases = {r: d for r, d in fetch_tags()}
512
+ releases = {r: d for r, d in fetch_tags(args)}
503
513
  cached = set(p.name for p in args._releases.iterdir())
504
514
  for release in sorted(cached.union(releases)):
505
515
  if args.re_match and not re.search(args.re_match, release):
@@ -578,7 +588,7 @@ def strip_binaries(vdir: Path, distribution: str) -> bool:
578
588
 
579
589
 
580
590
  def install(
581
- args: Namespace, vdir: Path, release: str, distribution: str, files: dict
591
+ args: Namespace, vdir: Path, release: str, distribution: str, files: dict[str, Any]
582
592
  ) -> str | None:
583
593
  "Install a version"
584
594
  version = vdir.name
@@ -640,6 +650,21 @@ def show_cache_size(path: Path, args: Namespace) -> None:
640
650
  print(f'{size_str}\tTOTAL')
641
651
 
642
652
 
653
+ def create_cert(option: str) -> ssl.SSLContext | None:
654
+ "Create an SSL context for HTTPS connections based on the specified certificate store"
655
+
656
+ if not option or option == 'system':
657
+ return None
658
+
659
+ if option == 'certifi':
660
+ import certifi
661
+
662
+ return ssl.create_default_context(cafile=certifi.where())
663
+
664
+ assert option == 'none'
665
+ return ssl._create_unverified_context()
666
+
667
+
643
668
  def main() -> str | None:
644
669
  "Main code"
645
670
  distro_default = DISTRIBUTIONS.get((platform.system(), platform.machine()))
@@ -700,6 +725,11 @@ def main() -> str | None:
700
725
  opt.add_argument(
701
726
  '--no-strip', action='store_true', help='do not strip downloaded binaries'
702
727
  )
728
+ opt.add_argument(
729
+ '--cert',
730
+ choices=CERTS,
731
+ help=f'specify which SSL certificates to use for HTTPS requests. Default="{CERTS[0]}"',
732
+ )
703
733
  opt.add_argument(
704
734
  '-V', '--version', action='store_true', help=f'just show {PROG} version'
705
735
  )
@@ -765,6 +795,7 @@ def main() -> str | None:
765
795
  prefix_dir = Path(args.prefix_dir).expanduser().resolve()
766
796
  cache_dir = Path(args.cache_dir).expanduser().resolve()
767
797
 
798
+ args._implementation = 'cpython' # at the moment, only support CPython
768
799
  args._distribution = distribution
769
800
  args._data = f'{PROG}.json'
770
801
 
@@ -776,6 +807,7 @@ def main() -> str | None:
776
807
  args._releases = cache_dir / 'releases'
777
808
  args._releases.mkdir(parents=True, exist_ok=True)
778
809
  args._latest_release = cache_dir / 'latest_release'
810
+ args._cert = create_cert(args.cert)
779
811
 
780
812
  result = args.func(args)
781
813
  purge_unused_releases(args)
@@ -815,7 +847,7 @@ class install_:
815
847
  @staticmethod
816
848
  def run(args: Namespace) -> str | None:
817
849
  release = get_release_tag(args)
818
- files = get_release_files(args, release, 'cpython')
850
+ files = get_release_files(args, release)
819
851
  if not files:
820
852
  return f'Release "{release}" not found, or has no compatible files.'
821
853
 
@@ -874,7 +906,7 @@ class update_:
874
906
  @staticmethod
875
907
  def run(args: Namespace) -> str | None:
876
908
  release_target = get_release_tag(args)
877
- files = get_release_files(args, release_target, 'cpython')
909
+ files = get_release_files(args, release_target)
878
910
  if not files:
879
911
  return f'Release "{release_target}" not found.'
880
912
 
@@ -991,7 +1023,7 @@ class list_:
991
1023
  @staticmethod
992
1024
  def run(args: Namespace) -> str | None:
993
1025
  release_target = get_release_tag(args)
994
- files = get_release_files(args, release_target, 'cpython')
1026
+ files = get_release_files(args, release_target)
995
1027
  if not files:
996
1028
  return f'Release "{release_target}" not found.'
997
1029
 
@@ -1089,7 +1121,7 @@ class show_:
1089
1121
  return None
1090
1122
 
1091
1123
  release = get_release_tag(args)
1092
- files = get_release_files(args, release, 'cpython')
1124
+ files = get_release_files(args, release)
1093
1125
  if not files:
1094
1126
  return f'Error: release "{release}" not found.'
1095
1127
 
@@ -1,6 +0,0 @@
1
- pystand.py,sha256=sTLUk0IwUHh0e4UC0l8V1SyoDnC8WR-n-Ny_jjNjxSU,38681
2
- pystand-2.16.dist-info/METADATA,sha256=oXreTd3Md066sjMLwKdib9L-xyKw0lITfi7hWflTwvE,25371
3
- pystand-2.16.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
- pystand-2.16.dist-info/entry_points.txt,sha256=DG4ps3I3nni1bubV1tXs6u8FARgkdbAYaEAzZD4RAo8,41
5
- pystand-2.16.dist-info/top_level.txt,sha256=NoWUh19UQymAJLHTCdxMnVwV6Teftef5fzyF3OWLyNY,8
6
- pystand-2.16.dist-info/RECORD,,
File without changes