pbs-installer 2024.2.26__tar.gz → 2024.3.23__tar.gz

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,14 +1,14 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pbs-installer
3
- Version: 2024.2.26
3
+ Version: 2024.3.23
4
4
  Summary: Installer for Python Build Standalone
5
5
  Author-Email: Frost Ming <me@frostming.com>
6
6
  License: MIT
7
- Requires-Python: >=3.7
7
+ Requires-Python: >=3.8
8
8
  Provides-Extra: download
9
9
  Provides-Extra: install
10
10
  Provides-Extra: all
11
- Requires-Dist: requests>=2.24.0; extra == "download"
11
+ Requires-Dist: httpx<1,>=0.27.0; extra == "download"
12
12
  Requires-Dist: zstandard>=0.21.0; extra == "install"
13
13
  Requires-Dist: pbs-installer[download,install]; extra == "all"
14
14
  Description-Content-Type: text/markdown
@@ -20,3 +20,5 @@ Description-Content-Type: text/markdown
20
20
  An installer for @indygreg's [python-build-standalone](https://github.com/indygreg/python-build-standalone)
21
21
 
22
22
  The list of python versions are kept sync with the upstream automatically, via a periodically GitHub Action.
23
+
24
+ [📖 Read the docs](http://pbs-installer.readthedocs.io/)
@@ -5,3 +5,5 @@
5
5
  An installer for @indygreg's [python-build-standalone](https://github.com/indygreg/python-build-standalone)
6
6
 
7
7
  The list of python versions are kept sync with the upstream automatically, via a periodically GitHub Action.
8
+
9
+ [📖 Read the docs](http://pbs-installer.readthedocs.io/)
@@ -4,18 +4,17 @@ description = "Installer for Python Build Standalone"
4
4
  authors = [
5
5
  { name = "Frost Ming", email = "me@frostming.com" },
6
6
  ]
7
- dependencies = []
8
- requires-python = ">=3.7"
7
+ requires-python = ">=3.8"
9
8
  readme = "README.md"
10
9
  dynamic = []
11
- version = "2024.2.26"
10
+ version = "2024.3.23"
12
11
 
13
12
  [project.license]
14
13
  text = "MIT"
15
14
 
16
15
  [project.optional-dependencies]
17
16
  download = [
18
- "requests>=2.24.0",
17
+ "httpx<1,>=0.27.0",
19
18
  ]
20
19
  install = [
21
20
  "zstandard>=0.21.0",
@@ -33,25 +32,24 @@ requires = [
33
32
  ]
34
33
  build-backend = "pdm.backend"
35
34
 
36
- [tool.pdm.dev-dependencies]
37
- dev = [
38
- "black>=23.3.0",
39
- ]
40
-
41
35
  [tool.pdm.version]
42
36
  source = "scm"
43
37
 
44
38
  [tool.pdm.scripts.update]
45
39
  shell = "./scripts/update.sh"
46
40
 
47
- [tool.black]
48
- line-length = 100
49
- include = "\\.pyi?$"
50
- exclude = "/(\n \\.eggs\n | \\.git\n | \\.hg\n | \\.mypy_cache\n | \\.tox\n | \\.venv\n | build\n | dist\n | src/pythonfinder/_vendor\n)\n"
41
+ [tool.pdm.dev-dependencies]
42
+ doc = [
43
+ "mkdocs>=1.5.3",
44
+ "mkdocs-material>=9.5.14",
45
+ "mkdocstrings[python]>=0.24",
46
+ ]
51
47
 
52
48
  [tool.ruff]
53
49
  line-length = 100
50
+ target-version = "py310"
51
+
52
+ [tool.ruff.lint]
54
53
  extend-select = [
55
54
  "I",
56
55
  ]
57
- target-version = "py310"
@@ -1,8 +1,5 @@
1
1
  """
2
- Installer for Python Build Standalone
3
-
4
- Author: frostming
5
- License: MIT
2
+ Core functions for the PBS Installer.
6
3
  """
7
4
 
8
5
  from ._install import download, get_download_link, install, install_file
@@ -50,10 +50,15 @@ def main():
50
50
  archs, platforms = get_available_arch_platforms()
51
51
  parser = ArgumentParser("pbs-install", description="Installer for Python Build Standalone")
52
52
  install_group = parser.add_argument_group("Install Arguments")
53
- install_group.add_argument("version", help="The version of Python to install, e.g. 3.8,3.10.4")
53
+ install_group.add_argument(
54
+ "version", help="The version of Python to install, e.g. 3.8, 3.10.4, pypy@3.10"
55
+ )
54
56
  install_group.add_argument(
55
57
  "--version-dir", help="Install to a subdirectory named by the version", action="store_true"
56
58
  )
59
+ install_group.add_argument(
60
+ "--build-dir", help="Include the build directory", action="store_true"
61
+ )
57
62
  install_group.add_argument(
58
63
  "-d", "--destination", help="The directory to install to", required=True
59
64
  )
@@ -66,12 +71,17 @@ def main():
66
71
 
67
72
  args = parser.parse_args()
68
73
  _setup_logger(args.verbose)
74
+ impl, has_amp, version = args.version.rpartition("@")
75
+ if not has_amp:
76
+ impl = "cpython"
69
77
  install(
70
- args.version,
78
+ version,
71
79
  args.destination,
72
80
  version_dir=args.version_dir,
73
81
  arch=args.arch,
74
82
  platform=args.platform,
83
+ implementation=impl,
84
+ build_dir=args.build_dir,
75
85
  )
76
86
  print("Done!")
77
87
 
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ import tempfile
7
+ from typing import TYPE_CHECKING, Tuple
8
+ from urllib.parse import unquote
9
+
10
+ from ._utils import PythonVersion, get_arch_platform
11
+
12
+ if TYPE_CHECKING:
13
+ import httpx
14
+ from _typeshed import StrPath
15
+
16
+ logger = logging.getLogger(__name__)
17
+ THIS_ARCH, THIS_PLATFORM = get_arch_platform()
18
+ PythonFile = Tuple[str, str | None]
19
+
20
+
21
+ def _get_headers() -> dict[str, str] | None:
22
+ TOKEN = os.getenv("GITHUB_TOKEN")
23
+ if TOKEN is None:
24
+ return None
25
+ return {
26
+ "X-GitHub-Api-Version": "2022-11-28",
27
+ "Authorization": f"Bearer {TOKEN}",
28
+ }
29
+
30
+
31
+ def get_download_link(
32
+ request: str,
33
+ arch: str = THIS_ARCH,
34
+ platform: str = THIS_PLATFORM,
35
+ implementation: str = "cpython",
36
+ ) -> tuple[PythonVersion, PythonFile]:
37
+ """Get the download URL matching the given requested version.
38
+
39
+ Parameters:
40
+ request: The version of Python to install, e.g. 3.8,3.10.4
41
+ arch: The architecture to install, e.g. x86_64, arm64
42
+ platform: The platform to install, e.g. linux, macos
43
+ implementation: The implementation of Python to install, allowed values are 'cpython' and 'pypy'
44
+
45
+ Returns:
46
+ A tuple of the PythonVersion and the download URL
47
+
48
+ Examples:
49
+ >>> get_download_link("3.10", "x86_64", "linux")
50
+ (PythonVersion(kind='cpython', major=3, minor=10, micro=13),
51
+ 'https://github.com/indygreg/python-build-standalone/releases/download/20240224/cpython-3.10.13%2B20240224-x86_64-unknown-linux-gnu-pgo%2Blto-full.tar.zst')
52
+ """
53
+ from ._versions import PYTHON_VERSIONS
54
+
55
+ for py_ver, urls in PYTHON_VERSIONS.items():
56
+ if not py_ver.matches(request, implementation):
57
+ continue
58
+
59
+ matched = urls.get((platform, arch))
60
+ if matched is not None:
61
+ return py_ver, matched
62
+ raise ValueError(
63
+ f"Could not find a version matching version={request!r}, implementation={implementation}"
64
+ )
65
+
66
+
67
+ def download(
68
+ python_file: PythonFile, destination: StrPath, client: httpx.Client | None = None
69
+ ) -> str:
70
+ """Download the given url to the destination.
71
+
72
+ Note: Extras required
73
+ `pbs-installer[download]` must be installed to use this function.
74
+
75
+ Parameters:
76
+ python_file: The (url, checksum) tuple to download
77
+ destination: The file path to download to
78
+ client: A http.Client to use for downloading, or None to create a new one
79
+
80
+ Returns:
81
+ The original filename of the downloaded file
82
+ """
83
+ url, checksum = python_file
84
+ logger.debug("Downloading url %s to %s", url, destination)
85
+ try:
86
+ import httpx
87
+ except ModuleNotFoundError:
88
+ raise RuntimeError("You must install httpx to use this function") from None
89
+
90
+ if client is None:
91
+ client = httpx.Client(trust_env=True, follow_redirects=True)
92
+
93
+ filename = unquote(url.rsplit("/")[-1])
94
+ hasher = hashlib.sha256()
95
+ if not checksum:
96
+ logger.warning("No checksum found for %s, this would be insecure", url)
97
+
98
+ with (
99
+ open(destination, "wb") as f,
100
+ client.stream("GET", url, headers=_get_headers()) as resp,
101
+ ):
102
+ resp.raise_for_status()
103
+ for chunk in resp.iter_bytes(chunk_size=8192):
104
+ if checksum:
105
+ hasher.update(chunk)
106
+ f.write(chunk)
107
+
108
+ if checksum and hasher.hexdigest() != checksum:
109
+ raise RuntimeError(f"Checksum mismatch. Expected {checksum}, got {hasher.hexdigest()}")
110
+ return filename
111
+
112
+
113
+ def install_file(
114
+ filename: StrPath,
115
+ destination: StrPath,
116
+ original_filename: str | None = None,
117
+ build_dir: bool = False,
118
+ ) -> None:
119
+ """Unpack the downloaded file to the destination.
120
+
121
+ Note: Extras required
122
+ `pbs-installer[install]` must be installed to use this function.
123
+
124
+ Parameters:
125
+ filename: The file to unpack
126
+ destination: The directory to unpack to
127
+ original_filename: The original filename of the file, if it was renamed
128
+ build_dir: Whether to include the `build/` directory from indygreg builds
129
+ """
130
+
131
+ from ._utils import unpack_tar, unpack_zip, unpack_zst
132
+
133
+ if original_filename is None:
134
+ original_filename = str(filename)
135
+ logger.debug(
136
+ "Extracting file %s to %s with original filename %s",
137
+ filename,
138
+ destination,
139
+ original_filename,
140
+ )
141
+ if original_filename.endswith(".zst"):
142
+ unpack_zst(filename, destination, build_dir)
143
+ elif original_filename.endswith(".zip"):
144
+ unpack_zip(filename, destination, build_dir)
145
+ else:
146
+ unpack_tar(filename, destination, build_dir)
147
+
148
+
149
+ def install(
150
+ request: str,
151
+ destination: StrPath,
152
+ version_dir: bool = False,
153
+ client: httpx.Client | None = None,
154
+ arch: str | None = None,
155
+ platform: str | None = None,
156
+ implementation: str = "cpython",
157
+ build_dir: bool = False,
158
+ ) -> None:
159
+ """Download and install the requested python version.
160
+
161
+ Note: Extras required
162
+ `pbs-installer[all]` must be installed to use this function.
163
+
164
+ Parameters:
165
+ request: The version of Python to install, e.g. 3.8,3.10.4
166
+ destination: The directory to install to
167
+ version_dir: Whether to install to a subdirectory named with the python version
168
+ client: A httpx.Client to use for downloading
169
+ arch: The architecture to install, e.g. x86_64, arm64
170
+ platform: The platform to install, e.g. linux, macos
171
+ implementation: The implementation of Python to install, allowed values are 'cpython' and 'pypy'
172
+ build_dir: Whether to include the `build/` directory from indygreg builds
173
+
174
+ Examples:
175
+ >>> install("3.10", "./python")
176
+ Installing cpython@3.10.4 to ./python
177
+ >>> install("3.10", "./python", version_dir=True)
178
+ Installing cpython@3.10.4 to ./python/cpython@3.10.4
179
+ """
180
+ if platform is None:
181
+ platform = THIS_PLATFORM
182
+ if arch is None:
183
+ arch = THIS_ARCH
184
+
185
+ ver, python_file = get_download_link(
186
+ request, arch=arch, platform=platform, implementation=implementation
187
+ )
188
+ if version_dir:
189
+ destination = os.path.join(destination, str(ver))
190
+ logger.debug("Installing %s to %s", ver, destination)
191
+ os.makedirs(destination, exist_ok=True)
192
+ with tempfile.NamedTemporaryFile() as tf:
193
+ tf.close()
194
+ original_filename = download(python_file, tf.name, client)
195
+ install_file(tf.name, destination, original_filename, build_dir)
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import tarfile
4
+ from typing import TYPE_CHECKING, NamedTuple
5
+
6
+ if TYPE_CHECKING:
7
+ from _typeshed import StrPath
8
+
9
+ ARCH_MAPPING = {
10
+ "arm64": "aarch64",
11
+ "amd64": "x86_64",
12
+ "i686": "x86",
13
+ }
14
+ PLATFORM_MAPPING = {"darwin": "macos"}
15
+
16
+
17
+ class PythonVersion(NamedTuple):
18
+ implementation: str
19
+ major: int
20
+ minor: int
21
+ micro: int
22
+
23
+ def __str__(self) -> str:
24
+ return f"{self.implementation}@{self.major}.{self.minor}.{self.micro}"
25
+
26
+ def matches(self, request: str, implementation: str) -> bool:
27
+ if implementation != self.implementation:
28
+ return False
29
+ try:
30
+ parts = tuple(int(v) for v in request.split("."))
31
+ except ValueError:
32
+ raise ValueError(
33
+ f"Invalid version: {request!r}, each part must be an integer"
34
+ ) from None
35
+
36
+ if len(parts) < 1:
37
+ raise ValueError("Version must have at least one part")
38
+
39
+ if parts[0] != self.major:
40
+ return False
41
+ if len(parts) > 1 and parts[1] != self.minor:
42
+ return False
43
+ if len(parts) > 2 and parts[2] != self.micro:
44
+ return False
45
+ return True
46
+
47
+
48
+ def get_arch_platform() -> tuple[str, str]:
49
+ import platform
50
+
51
+ plat = platform.system().lower()
52
+ arch = platform.machine().lower()
53
+ return ARCH_MAPPING.get(arch, arch), PLATFORM_MAPPING.get(plat, plat)
54
+
55
+
56
+ def _unpack_tar(tf: tarfile.TarFile, destination: StrPath, build_dir: bool = False) -> None:
57
+ """Unpack the tarfile to the destination, with the first skip_parts parts of the path removed"""
58
+ members: list[tarfile.TarInfo] = []
59
+ has_build = any(
60
+ (p := fn.lstrip("/").split("/")) and len(p) > 1 and p[1] == "build" for fn in tf.getnames()
61
+ )
62
+ for member in tf.getmembers():
63
+ parts = member.name.lstrip("/").split("/")
64
+ if build_dir or not has_build:
65
+ member.name = "/".join(parts[1:])
66
+ elif len(parts) > 1 and parts[1] == "install":
67
+ member.name = "/".join(parts[2:])
68
+ else:
69
+ continue
70
+ if member.name:
71
+ members.append(member)
72
+ tf.extractall(destination, members=members)
73
+
74
+
75
+ def unpack_tar(filename: str, destination: StrPath, build_dir: bool = False) -> None:
76
+ """Unpack the tarfile to the destination"""
77
+ with tarfile.open(filename) as z:
78
+ _unpack_tar(z, destination, build_dir=build_dir)
79
+
80
+
81
+ def unpack_zst(filename: str, destination: StrPath, build_dir: bool = False) -> None:
82
+ """Unpack the zstd compressed tarfile to the destination"""
83
+ import tempfile
84
+
85
+ import zstandard as zstd
86
+
87
+ dctx = zstd.ZstdDecompressor()
88
+ with tempfile.TemporaryFile(suffix=".tar") as ofh:
89
+ with open(filename, "rb") as ifh:
90
+ dctx.copy_stream(ifh, ofh)
91
+ ofh.seek(0)
92
+ with tarfile.open(fileobj=ofh) as z:
93
+ _unpack_tar(z, destination, build_dir=build_dir)
94
+
95
+
96
+ def unpack_zip(filename: str, destination: StrPath, build_dir: bool = False) -> None:
97
+ """Unpack the zip file to the destination"""
98
+ import zipfile
99
+
100
+ with zipfile.ZipFile(filename) as z:
101
+ members: list[zipfile.ZipInfo] = []
102
+ has_build = any(fn.lstrip("/").split("/")[1] == "build" for fn in z.namelist())
103
+ for member in z.infolist():
104
+ parts = member.filename.lstrip("/").split("/")
105
+ if (build_dir or not has_build) and len(parts) > 1:
106
+ member.filename = "/".join(parts[1:])
107
+ elif len(parts) > 1 and parts[1] == "install":
108
+ member.filename = "/".join(parts[2:])
109
+ else:
110
+ continue
111
+ if member.filename:
112
+ members.append(member)
113
+
114
+ z.extractall(destination, members=members)
115
+
116
+
117
+ def get_available_arch_platforms() -> tuple[list[str], list[str]]:
118
+ from ._versions import PYTHON_VERSIONS
119
+
120
+ archs: set[str] = set()
121
+ platforms: set[str] = set()
122
+ for items in PYTHON_VERSIONS.values():
123
+ for item in items:
124
+ platforms.add(item[0])
125
+ archs.add(item[1])
126
+ return sorted(archs), sorted(platforms)