relenv 0.21.1__py3-none-any.whl → 0.22.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.
Files changed (49) hide show
  1. relenv/__init__.py +14 -2
  2. relenv/__main__.py +12 -6
  3. relenv/_resources/xz/config.h +148 -0
  4. relenv/_resources/xz/readme.md +4 -0
  5. relenv/build/__init__.py +28 -30
  6. relenv/build/common/__init__.py +50 -0
  7. relenv/build/common/_sysconfigdata_template.py +72 -0
  8. relenv/build/common/builder.py +907 -0
  9. relenv/build/common/builders.py +163 -0
  10. relenv/build/common/download.py +324 -0
  11. relenv/build/common/install.py +609 -0
  12. relenv/build/common/ui.py +432 -0
  13. relenv/build/darwin.py +128 -14
  14. relenv/build/linux.py +296 -78
  15. relenv/build/windows.py +259 -44
  16. relenv/buildenv.py +48 -17
  17. relenv/check.py +10 -5
  18. relenv/common.py +499 -163
  19. relenv/create.py +147 -7
  20. relenv/fetch.py +16 -4
  21. relenv/manifest.py +15 -7
  22. relenv/python-versions.json +329 -0
  23. relenv/pyversions.py +817 -30
  24. relenv/relocate.py +101 -55
  25. relenv/runtime.py +452 -253
  26. relenv/toolchain.py +9 -3
  27. {relenv-0.21.1.dist-info → relenv-0.22.0.dist-info}/METADATA +1 -1
  28. relenv-0.22.0.dist-info/RECORD +48 -0
  29. tests/__init__.py +2 -0
  30. tests/_pytest_typing.py +45 -0
  31. tests/conftest.py +42 -36
  32. tests/test_build.py +426 -9
  33. tests/test_common.py +311 -48
  34. tests/test_create.py +149 -6
  35. tests/test_downloads.py +19 -15
  36. tests/test_fips_photon.py +6 -3
  37. tests/test_module_imports.py +44 -0
  38. tests/test_pyversions_runtime.py +177 -0
  39. tests/test_relocate.py +45 -39
  40. tests/test_relocate_module.py +257 -0
  41. tests/test_runtime.py +1802 -6
  42. tests/test_verify_build.py +500 -34
  43. relenv/build/common.py +0 -1609
  44. relenv-0.21.1.dist-info/RECORD +0 -35
  45. {relenv-0.21.1.dist-info → relenv-0.22.0.dist-info}/WHEEL +0 -0
  46. {relenv-0.21.1.dist-info → relenv-0.22.0.dist-info}/entry_points.txt +0 -0
  47. {relenv-0.21.1.dist-info → relenv-0.22.0.dist-info}/licenses/LICENSE.md +0 -0
  48. {relenv-0.21.1.dist-info → relenv-0.22.0.dist-info}/licenses/NOTICE +0 -0
  49. {relenv-0.21.1.dist-info → relenv-0.22.0.dist-info}/top_level.txt +0 -0
relenv/common.py CHANGED
@@ -1,24 +1,42 @@
1
- # Copyright 2023-2025 Broadcom.
2
- # SPDX-License-Identifier: Apache-2
1
+ # Copyright 2022-2025 Broadcom.
2
+ # SPDX-License-Identifier: Apache-2.0
3
3
  """
4
4
  Common classes and values used around relenv.
5
5
  """
6
+ from __future__ import annotations
7
+
6
8
  import http.client
9
+ import json
7
10
  import logging
8
11
  import os
9
12
  import pathlib
10
13
  import platform
11
14
  import queue
12
15
  import selectors
16
+ import shutil
13
17
  import subprocess
14
18
  import sys
15
19
  import tarfile
16
20
  import textwrap
17
21
  import threading
18
22
  import time
23
+ from typing import (
24
+ IO,
25
+ Any,
26
+ BinaryIO,
27
+ Callable,
28
+ Iterable,
29
+ Literal,
30
+ Mapping,
31
+ Optional,
32
+ Union,
33
+ cast,
34
+ )
19
35
 
20
36
  # relenv package version
21
- __version__ = "0.21.1"
37
+ __version__ = "0.22.0"
38
+
39
+ log = logging.getLogger(__name__)
22
40
 
23
41
  MODULE_DIR = pathlib.Path(__file__).resolve().parent
24
42
 
@@ -30,6 +48,83 @@ DARWIN = "darwin"
30
48
 
31
49
  MACOS_DEVELOPMENT_TARGET = "10.15"
32
50
 
51
+ TOOLCHAIN_CACHE_ENV = "RELENV_TOOLCHAIN_CACHE"
52
+ _TOOLCHAIN_MANIFEST = ".toolchain-manifest.json"
53
+
54
+
55
+ # 8 GiB archives are not unusual; stick to metadata to fingerprint them.
56
+ def _archive_metadata(path: pathlib.Path) -> dict[str, Union[str, int]]:
57
+ stat = path.stat()
58
+ return {
59
+ "archive": str(path.resolve()),
60
+ "size": stat.st_size,
61
+ "mtime": stat.st_mtime_ns,
62
+ }
63
+
64
+
65
+ def _toolchain_cache_root() -> Optional[pathlib.Path]:
66
+ override = os.environ.get(TOOLCHAIN_CACHE_ENV)
67
+ if override:
68
+ if override.strip().lower() == "none":
69
+ return None
70
+ return pathlib.Path(override).expanduser()
71
+ cache_home = os.environ.get("XDG_CACHE_HOME")
72
+ if cache_home:
73
+ base = pathlib.Path(cache_home)
74
+ else:
75
+ base = pathlib.Path.home() / ".cache"
76
+ return base / "relenv" / "toolchains"
77
+
78
+
79
+ def _toolchain_manifest_path(toolchain_path: pathlib.Path) -> pathlib.Path:
80
+ return toolchain_path / _TOOLCHAIN_MANIFEST
81
+
82
+
83
+ def _load_toolchain_manifest(path: pathlib.Path) -> Optional[Mapping[str, Any]]:
84
+ if not path.exists():
85
+ return None
86
+ try:
87
+ with path.open(encoding="utf-8") as handle:
88
+ data = json.load(handle)
89
+ except (OSError, json.JSONDecodeError):
90
+ return None
91
+ if not isinstance(data, dict):
92
+ return None
93
+ return data
94
+
95
+
96
+ def _manifest_matches(manifest: Mapping[str, Any], metadata: Mapping[str, Any]) -> bool:
97
+ return (
98
+ manifest.get("archive") == metadata.get("archive")
99
+ and manifest.get("size") == metadata.get("size")
100
+ and manifest.get("mtime") == metadata.get("mtime")
101
+ )
102
+
103
+
104
+ def _write_toolchain_manifest(
105
+ toolchain_path: pathlib.Path, metadata: Mapping[str, Any]
106
+ ) -> None:
107
+ manifest_path = _toolchain_manifest_path(toolchain_path)
108
+ try:
109
+ with manifest_path.open("w", encoding="utf-8") as handle:
110
+ json.dump(metadata, handle, indent=2, sort_keys=True)
111
+ handle.write("\n")
112
+ except OSError as exc: # pragma: no cover - permissions edge cases
113
+ log.warning(
114
+ "Unable to persist toolchain manifest at %s: %s", manifest_path, exc
115
+ )
116
+
117
+
118
+ def toolchain_root_dir() -> pathlib.Path:
119
+ """Return the root directory used for cached toolchains."""
120
+ if sys.platform != "linux":
121
+ return DATA_DIR
122
+ root = _toolchain_cache_root()
123
+ if root is None:
124
+ return DATA_DIR / "toolchain"
125
+ return root
126
+
127
+
33
128
  REQUEST_HEADERS = {"User-Agent": f"relenv {__version__}"}
34
129
 
35
130
  CHECK_HOSTS = (
@@ -61,8 +156,9 @@ DATA_DIR = pathlib.Path(os.environ.get("RELENV_DATA", DEFAULT_DATA_DIR)).resolve
61
156
  SHEBANG_TPL_LINUX = textwrap.dedent(
62
157
  """#!/bin/sh
63
158
  "true" ''''
159
+ # shellcheck disable=SC2093
64
160
  "exec" "$(dirname "$(readlink -f "$0")"){}" "$0" "$@"
65
- '''
161
+ ' '''
66
162
  """
67
163
  )
68
164
 
@@ -82,8 +178,10 @@ do
82
178
  done
83
179
  PHYS_DIR=$(pwd -P)
84
180
  REALPATH=$PHYS_DIR/$TARGET_FILE
181
+ # shellcheck disable=SC2093
85
182
  "exec" "$(dirname "$REALPATH")"{} "$REALPATH" "$@"
86
- '''"""
183
+ ' '''
184
+ """
87
185
  )
88
186
 
89
187
  if sys.platform == "linux":
@@ -92,23 +190,141 @@ else:
92
190
  SHEBANG_TPL = SHEBANG_TPL_MACOS
93
191
 
94
192
 
95
- log = logging.getLogger(__name__)
96
-
97
-
98
193
  class RelenvException(Exception):
99
194
  """
100
195
  Base class for exeptions generated from relenv.
101
196
  """
102
197
 
103
198
 
104
- def format_shebang(python, tpl=SHEBANG_TPL):
199
+ # Validation Errors
200
+ class ValidationError(RelenvException):
201
+ """Base class for validation-related errors.
202
+
203
+ Raised when data validation fails (checksums, signatures, etc.).
204
+ This follows CPython's convention of having intermediate base classes
205
+ for related exception types.
206
+ """
207
+
208
+
209
+ class ChecksumValidationError(ValidationError):
210
+ """Raised when file checksum verification fails.
211
+
212
+ This typically indicates file corruption or tampering. The error message
213
+ should include the expected and actual checksums when available.
214
+
215
+ Example:
216
+ raise ChecksumValidationError(
217
+ f"Checksum mismatch for {filename}: "
218
+ f"expected {expected}, got {actual}"
219
+ )
220
+ """
221
+
222
+
223
+ class SignatureValidationError(ValidationError):
224
+ """Raised when GPG signature verification fails.
225
+
226
+ This indicates that a downloaded file's cryptographic signature
227
+ does not match the expected signature, suggesting tampering or
228
+ an incomplete download.
229
+
230
+ Example:
231
+ raise SignatureValidationError(
232
+ f"GPG signature verification failed for {filename}"
233
+ )
234
+ """
235
+
236
+
237
+ # Download Errors
238
+ class DownloadError(RelenvException):
239
+ """Raised when downloading a file from a URL fails.
240
+
241
+ This encompasses network errors, HTTP errors, and other issues
242
+ that prevent successfully retrieving a remote resource.
243
+
244
+ Example:
245
+ raise DownloadError(f"Failed to download {url}: {reason}")
246
+ """
247
+
248
+
249
+ # Configuration Errors
250
+ class ConfigurationError(RelenvException):
251
+ """Raised when required configuration is missing or invalid.
252
+
253
+ This typically occurs during build setup when recipes are incomplete
254
+ or environment variables are not properly set.
255
+
256
+ Example:
257
+ raise ConfigurationError(
258
+ "Python recipe is missing download configuration"
259
+ )
260
+ """
261
+
262
+
263
+ # Platform Errors
264
+ class PlatformError(RelenvException):
265
+ """Raised when operating on an unsupported platform.
266
+
267
+ Relenv supports Linux, macOS, and Windows. This exception is raised
268
+ when attempting operations that are platform-specific or when running
269
+ on an unsupported platform.
270
+
271
+ Example:
272
+ raise PlatformError(f"Unsupported platform: {sys.platform}")
273
+ """
274
+
275
+
276
+ # Build Errors
277
+ class BuildCommandError(RelenvException):
278
+ """Raised when a build command execution fails.
279
+
280
+ This indicates that a subprocess (compiler, linker, etc.) returned
281
+ a non-zero exit code during the build process.
282
+
283
+ Example:
284
+ raise BuildCommandError(
285
+ f"Build command failed: {' '.join(cmd)}"
286
+ )
287
+ """
288
+
289
+
290
+ class MissingDependencyError(RelenvException):
291
+ """Raised when a required build dependency is not found.
292
+
293
+ This typically occurs when expected files, directories, or system
294
+ packages are missing from the build environment.
295
+
296
+ Example:
297
+ raise MissingDependencyError(
298
+ f"Unable to locate {dependency_name}"
299
+ )
300
+ """
301
+
302
+
303
+ # Environment Errors
304
+ class RelenvEnvironmentError(RelenvException):
305
+ """Raised when there are issues with the relenv environment.
306
+
307
+ This occurs when operations require being inside a relenv environment
308
+ but the current environment is not properly configured.
309
+
310
+ Example:
311
+ raise RelenvEnvironmentError(
312
+ "Not in a relenv environment"
313
+ )
314
+ """
315
+
316
+
317
+ def format_shebang(python: str, tpl: str = SHEBANG_TPL) -> str:
105
318
  """
106
319
  Return a formatted shebang.
107
320
  """
108
- return tpl.format(python).strip() + "\n"
321
+ shebang = tpl.format(python).strip()
322
+ if shebang.endswith("'''"):
323
+ return shebang + "\n\n"
324
+ return shebang + "\n"
109
325
 
110
326
 
111
- def build_arch():
327
+ def build_arch() -> str:
112
328
  """
113
329
  Return the current machine.
114
330
  """
@@ -116,7 +332,9 @@ def build_arch():
116
332
  return machine.lower()
117
333
 
118
334
 
119
- def work_root(root=None):
335
+ def work_root(
336
+ root: Optional[Union[str, os.PathLike[str]]] = None,
337
+ ) -> pathlib.Path:
120
338
  """
121
339
  Get the root directory that all other relenv working directories should be based on.
122
340
 
@@ -133,7 +351,9 @@ def work_root(root=None):
133
351
  return base
134
352
 
135
353
 
136
- def work_dir(name, root=None):
354
+ def work_dir(
355
+ name: str, root: Optional[Union[str, os.PathLike[str]]] = None
356
+ ) -> pathlib.Path:
137
357
  """
138
358
  Get the absolute path to the relenv working directory of the given name.
139
359
 
@@ -161,17 +381,17 @@ class WorkDirs:
161
381
  :type root: str
162
382
  """
163
383
 
164
- def __init__(self, root):
165
- self.root = root
166
- self.data = DATA_DIR
167
- self.toolchain_config = work_dir("toolchain", self.root)
168
- self.toolchain = work_dir("toolchain", DATA_DIR)
169
- self.build = work_dir("build", DATA_DIR)
170
- self.src = work_dir("src", DATA_DIR)
171
- self.logs = work_dir("logs", DATA_DIR)
172
- self.download = work_dir("download", DATA_DIR)
173
-
174
- def __getstate__(self):
384
+ def __init__(self: "WorkDirs", root: Union[str, os.PathLike[str]]) -> None:
385
+ self.root: pathlib.Path = pathlib.Path(root)
386
+ self.data: pathlib.Path = DATA_DIR
387
+ self.toolchain_config: pathlib.Path = work_dir("toolchain", self.root)
388
+ self.toolchain: pathlib.Path = toolchain_root_dir()
389
+ self.build: pathlib.Path = work_dir("build", DATA_DIR)
390
+ self.src: pathlib.Path = work_dir("src", DATA_DIR)
391
+ self.logs: pathlib.Path = work_dir("logs", DATA_DIR)
392
+ self.download: pathlib.Path = work_dir("download", DATA_DIR)
393
+
394
+ def __getstate__(self: "WorkDirs") -> dict[str, pathlib.Path]:
175
395
  """
176
396
  Return an object used for pickling.
177
397
 
@@ -187,7 +407,7 @@ class WorkDirs:
187
407
  "download": self.download,
188
408
  }
189
409
 
190
- def __setstate__(self, state):
410
+ def __setstate__(self: "WorkDirs", state: Mapping[str, pathlib.Path]) -> None:
191
411
  """
192
412
  Unwrap the object returned from unpickling.
193
413
 
@@ -203,7 +423,9 @@ class WorkDirs:
203
423
  self.download = state["download"]
204
424
 
205
425
 
206
- def work_dirs(root=None):
426
+ def work_dirs(
427
+ root: Optional[Union[str, os.PathLike[str]]] = None,
428
+ ) -> WorkDirs:
207
429
  """
208
430
  Returns a WorkDirs instance based on the given root.
209
431
 
@@ -216,41 +438,76 @@ def work_dirs(root=None):
216
438
  return WorkDirs(work_root(root))
217
439
 
218
440
 
219
- def get_toolchain(arch=None, root=None):
441
+ def get_toolchain(
442
+ arch: Optional[str] = None,
443
+ root: Optional[Union[str, os.PathLike[str]]] = None,
444
+ ) -> Optional[pathlib.Path]:
220
445
  """
221
446
  Get a the toolchain directory, specific to the arch if supplied.
222
447
 
448
+ On Linux, this function will extract the toolchain from ppbt if needed.
449
+ If the toolchain already exists, it will be returned even if ppbt is
450
+ not available (e.g., when running tests on non-Linux platforms that
451
+ patch sys.platform to "linux"). This allows using existing toolchains
452
+ without requiring ppbt to be installed.
453
+
223
454
  :param arch: The architecture to get the toolchain for
224
455
  :type arch: str
225
456
  :param root: The root of the relenv working directories to search in
226
457
  :type root: str
227
458
 
228
- :return: The directory holding the toolchain
459
+ :return: The directory holding the toolchain, or None if on Linux and
460
+ the toolchain doesn't exist and ppbt is unavailable
229
461
  :rtype: ``pathlib.Path``
230
462
  """
463
+ del root # Kept for backward compatibility; location driven by DATA_DIR
231
464
  os.makedirs(DATA_DIR, exist_ok=True)
232
465
  if sys.platform != "linux":
233
- return DATA_DIR
234
-
235
- TOOLCHAIN_ROOT = DATA_DIR / "toolchain"
236
- TOOLCHAIN_PATH = TOOLCHAIN_ROOT / get_triplet()
237
- if TOOLCHAIN_PATH.exists():
238
- return TOOLCHAIN_PATH
466
+ return toolchain_root_dir()
239
467
 
240
- ppbt = None
468
+ toolchain_root = toolchain_root_dir()
469
+ triplet = get_triplet(machine=arch)
470
+ toolchain_path = toolchain_root / triplet
471
+ metadata: Optional[Mapping[str, Any]] = None
472
+ if toolchain_path.exists():
473
+ metadata = _load_toolchain_manifest(_toolchain_manifest_path(toolchain_path))
241
474
 
242
475
  try:
243
- import ppbt.common
476
+ from importlib import import_module
477
+
478
+ ppbt_common = import_module("ppbt.common")
244
479
  except ImportError:
245
- pass
480
+ # If toolchain already exists, use it even without ppbt
481
+ return toolchain_path if toolchain_path.exists() else None
482
+ archive_attr = getattr(ppbt_common, "ARCHIVE", None)
483
+ extract = getattr(ppbt_common, "extract_archive", None)
484
+ if archive_attr is None or not callable(extract):
485
+ raise RelenvException("ppbt.common missing ARCHIVE or extract_archive")
486
+
487
+ toolchain_root.mkdir(parents=True, exist_ok=True)
488
+ archive_path = pathlib.Path(archive_attr)
489
+ archive_meta = _archive_metadata(archive_path)
490
+
491
+ if (
492
+ toolchain_path.exists()
493
+ and metadata
494
+ and _manifest_matches(metadata, archive_meta)
495
+ ):
496
+ return toolchain_path
246
497
 
247
- if ppbt:
248
- TOOLCHAIN_ROOT.mkdir(exist_ok=True)
249
- ppbt.common.extract_archive(str(TOOLCHAIN_ROOT), str(ppbt.common.ARCHIVE))
250
- return TOOLCHAIN_PATH
498
+ if toolchain_path.exists():
499
+ shutil.rmtree(toolchain_path)
500
+
501
+ extract(str(toolchain_root), str(archive_path))
502
+ if not toolchain_path.exists():
503
+ raise RelenvException(
504
+ f"Toolchain archive {archive_path} did not produce {toolchain_path}"
505
+ )
506
+ _write_toolchain_manifest(toolchain_path, archive_meta)
507
+ return toolchain_path
251
508
 
252
509
 
253
- def get_triplet(machine=None, plat=None):
510
+ def get_triplet(machine: Optional[str] = None, plat: Optional[str] = None) -> str:
254
511
  """
255
512
  Get the target triplet for the specified machine and platform.
256
513
 
@@ -277,10 +534,10 @@ def get_triplet(machine=None, plat=None):
277
534
  elif plat == "linux":
278
535
  return f"{machine}-linux-gnu"
279
536
  else:
280
- raise RelenvException(f"Unknown platform {plat}")
537
+ raise PlatformError(f"Unknown platform {plat}")
281
538
 
282
539
 
283
- def plat_from_triplet(plat):
540
+ def plat_from_triplet(plat: str) -> str:
284
541
  """
285
542
  Convert platform from build to the value of sys.platform.
286
543
  """
@@ -290,26 +547,27 @@ def plat_from_triplet(plat):
290
547
  return "darwin"
291
548
  elif plat == "win":
292
549
  return "win32"
293
- raise RelenvException(f"Unkown platform {plat}")
550
+ raise PlatformError(f"Unkown platform {plat}")
294
551
 
295
552
 
296
- def list_archived_builds():
553
+ def list_archived_builds() -> list[tuple[str, str, str]]:
297
554
  """
298
555
  Return a list of version, architecture and platforms for builds.
299
556
  """
300
- builds = []
301
- dirs = work_dirs(DATA_DIR)
302
- for root, dirs, files in os.walk(dirs.build):
303
- for file in files:
304
- if file.endswith(".tar.xz"):
305
- file = file[:-7]
306
- version, triplet = file.split("-", 1)
557
+ builds: list[tuple[str, str, str]] = []
558
+ working_dirs = work_dirs(DATA_DIR)
559
+ for root_dir, dirnames, filenames in os.walk(working_dirs.build):
560
+ del dirnames # unused
561
+ for filename in filenames:
562
+ if filename.endswith(".tar.xz"):
563
+ base_name = filename[:-7]
564
+ version, triplet = base_name.split("-", 1)
307
565
  arch, plat = triplet.split("-", 1)
308
566
  builds.append((version, arch, plat))
309
567
  return builds
310
568
 
311
569
 
312
- def archived_build(triplet=None):
570
+ def archived_build(triplet: Optional[str] = None) -> pathlib.Path:
313
571
  """
314
572
  Finds a the location of an archived build.
315
573
 
@@ -326,7 +584,9 @@ def archived_build(triplet=None):
326
584
  return dirs.build / archive
327
585
 
328
586
 
329
- def extract_archive(to_dir, archive):
587
+ def extract_archive(
588
+ to_dir: Union[str, os.PathLike[str]], archive: Union[str, os.PathLike[str]]
589
+ ) -> None:
330
590
  """
331
591
  Extract an archive to a specific location.
332
592
 
@@ -335,19 +595,30 @@ def extract_archive(to_dir, archive):
335
595
  :param archive: The archive to extract
336
596
  :type archive: str
337
597
  """
338
- if archive.endswith("tgz"):
598
+ archive_path = pathlib.Path(archive)
599
+ archive_str = str(archive_path)
600
+ to_path = pathlib.Path(to_dir)
601
+ TarReadMode = Literal["r:gz", "r:xz", "r:bz2", "r"]
602
+ read_type: TarReadMode = "r"
603
+ if archive_str.endswith(".tgz"):
604
+ log.debug("Found tgz archive")
339
605
  read_type = "r:gz"
340
- elif archive.endswith("xz"):
606
+ elif archive_str.endswith(".tar.gz"):
607
+ log.debug("Found tar.gz archive")
608
+ read_type = "r:gz"
609
+ elif archive_str.endswith(".xz"):
610
+ log.debug("Found xz archive")
341
611
  read_type = "r:xz"
342
- elif archive.endswith("bz2"):
612
+ elif archive_str.endswith(".bz2"):
613
+ log.debug("Found bz2 archive")
343
614
  read_type = "r:bz2"
344
615
  else:
345
- read_type = "r"
346
- with tarfile.open(archive, read_type) as t:
347
- t.extractall(to_dir)
616
+ log.warning("Found unknown archive type: %s", archive_path)
617
+ with tarfile.open(str(archive_path), mode=read_type) as tar:
618
+ tar.extractall(str(to_path))
348
619
 
349
620
 
350
- def get_download_location(url, dest):
621
+ def get_download_location(url: str, dest: Union[str, os.PathLike[str]]) -> str:
351
622
  """
352
623
  Get the full path to where the url will be downloaded to.
353
624
 
@@ -359,10 +630,10 @@ def get_download_location(url, dest):
359
630
  :return: The path to where the url will be downloaded to
360
631
  :rtype: str
361
632
  """
362
- return os.path.join(dest, os.path.basename(url))
633
+ return os.path.join(os.fspath(dest), os.path.basename(url))
363
634
 
364
635
 
365
- def check_url(url, timestamp=None, timeout=30):
636
+ def check_url(url: str, timestamp: Optional[float] = None, timeout: float = 30) -> bool:
366
637
  """
367
638
  Check that the url returns a 200.
368
639
  """
@@ -393,52 +664,79 @@ def check_url(url, timestamp=None, timeout=30):
393
664
  return True
394
665
 
395
666
 
396
- def fetch_url(url, fp, backoff=3, timeout=30):
667
+ def fetch_url(
668
+ url: str,
669
+ fp: BinaryIO,
670
+ backoff: int = 3,
671
+ timeout: float = 30,
672
+ progress_callback: Optional[Callable[[int, int], None]] = None,
673
+ ) -> None:
397
674
  """
398
675
  Fetch the contents of a url.
399
676
 
400
677
  This method will store the contents in the given file like object.
678
+
679
+ :param progress_callback: Optional callback(downloaded_bytes, total_bytes)
680
+ :type progress_callback: Optional[Callable[[int, int], None]]
401
681
  """
402
682
  # Late import so we do not import hashlib before runtime.bootstrap is called.
403
683
  import urllib.error
404
684
  import urllib.request
405
685
 
406
686
  last = time.time()
407
- if backoff < 1:
408
- backoff = 1
409
- n = 0
410
- while n < backoff:
411
- n += 1
687
+ attempts = max(backoff, 1)
688
+ response: http.client.HTTPResponse | None = None
689
+ for attempt in range(1, attempts + 1):
412
690
  try:
413
- fin = urllib.request.urlopen(url, timeout=timeout)
691
+ response = urllib.request.urlopen(url, timeout=timeout)
692
+ break
414
693
  except (
415
694
  urllib.error.HTTPError,
416
695
  urllib.error.URLError,
417
696
  http.client.RemoteDisconnected,
418
697
  ) as exc:
419
- if n >= backoff:
698
+ if attempt >= attempts:
420
699
  raise RelenvException(f"Error fetching url {url} {exc}")
421
700
  log.debug("Unable to connect %s", url)
422
- time.sleep(n * 10)
701
+ time.sleep(attempt * 10)
702
+ if response is None:
703
+ raise RelenvException(f"Unable to open url {url}")
423
704
  log.info("url opened %s", url)
705
+
706
+ # Get content length from headers
707
+ content_length = 0
424
708
  try:
425
- total = 0
709
+ content_length_str = response.headers.get("Content-Length")
710
+ if content_length_str:
711
+ content_length = int(content_length_str)
712
+ log.info("Content-Length: %d bytes", content_length)
713
+ # Report initial state to callback
714
+ if progress_callback:
715
+ progress_callback(0, content_length)
716
+ except (ValueError, TypeError):
717
+ log.debug("Could not parse Content-Length header")
718
+
719
+ try:
720
+ downloaded = 0
426
721
  size = 1024 * 300
427
- block = fin.read(size)
722
+ block = response.read(size)
428
723
  while block:
429
- total += size
724
+ block_size = len(block)
725
+ downloaded += block_size
430
726
  if time.time() - last > 10:
431
- log.info("%s > %d", url, total)
727
+ log.info("%s > %d", url, downloaded)
432
728
  last = time.time()
433
729
  fp.write(block)
434
- block = fin.read(10240)
730
+ # Report progress
731
+ if progress_callback and content_length > 0:
732
+ progress_callback(downloaded, content_length)
733
+ block = response.read(10240)
435
734
  finally:
436
- fin.close()
437
- # fp.close()
735
+ response.close()
438
736
  log.info("Download complete %s", url)
439
737
 
440
738
 
441
- def fetch_url_content(url, backoff=3, timeout=30):
739
+ def fetch_url_content(url: str, backoff: int = 3, timeout: float = 30) -> str:
442
740
  """
443
741
  Fetch the contents of a url.
444
742
 
@@ -446,55 +744,47 @@ def fetch_url_content(url, backoff=3, timeout=30):
446
744
  """
447
745
  # Late import so we do not import hashlib before runtime.bootstrap is called.
448
746
  import gzip
449
- import io
450
747
  import urllib.error
451
748
  import urllib.request
452
749
 
453
- fp = io.BytesIO()
454
-
455
- last = time.time()
456
- if backoff < 1:
457
- backoff = 1
458
- n = 0
459
- while n < backoff:
460
- n += 1
750
+ attempts = max(backoff, 1)
751
+ response: http.client.HTTPResponse | None = None
752
+ for attempt in range(1, attempts + 1):
461
753
  try:
462
- fin = urllib.request.urlopen(url, timeout=timeout)
754
+ response = urllib.request.urlopen(url, timeout=timeout)
755
+ break
463
756
  except (
464
757
  urllib.error.HTTPError,
465
758
  urllib.error.URLError,
466
759
  http.client.RemoteDisconnected,
467
760
  ) as exc:
468
- if n >= backoff:
761
+ if attempt >= attempts:
469
762
  raise RelenvException(f"Error fetching url {url} {exc}")
470
763
  log.debug("Unable to connect %s", url)
471
- time.sleep(n * 10)
764
+ time.sleep(attempt * 10)
765
+ if response is None:
766
+ raise RelenvException(f"Unable to open url {url}")
472
767
  log.info("url opened %s", url)
473
768
  try:
474
- total = 0
475
- size = 1024 * 300
476
- block = fin.read(size)
477
- while block:
478
- total += size
479
- if time.time() - last > 10:
480
- log.info("%s > %d", url, total)
481
- last = time.time()
482
- fp.write(block)
483
- block = fin.read(10240)
769
+ data = response.read()
770
+ encoding = response.headers.get("content-encoding", "").lower()
484
771
  finally:
485
- fin.close()
486
- # fp.close()
772
+ response.close()
773
+ if encoding == "gzip":
774
+ log.debug("Found gzipped content")
775
+ data = gzip.decompress(data)
487
776
  log.info("Download complete %s", url)
488
- fp.seek(0)
489
- info = fin.info()
490
- if "content-encoding" in info:
491
- if info["content-encoding"] == "gzip":
492
- log.debug("Found gzipped content")
493
- fp = gzip.GzipFile(fileobj=fp)
494
- return fp.read().decode()
777
+ return data.decode()
495
778
 
496
779
 
497
- def download_url(url, dest, verbose=True, backoff=3, timeout=60):
780
+ def download_url(
781
+ url: str,
782
+ dest: Union[str, os.PathLike[str]],
783
+ verbose: bool = True,
784
+ backoff: int = 3,
785
+ timeout: float = 60,
786
+ progress_callback: Optional[Callable[[int, int], None]] = None,
787
+ ) -> str:
498
788
  """
499
789
  Download the url to the provided destination.
500
790
 
@@ -506,6 +796,8 @@ def download_url(url, dest, verbose=True, backoff=3, timeout=60):
506
796
  :type dest: str
507
797
  :param verbose: Print download url and destination to stdout
508
798
  :type verbose: bool
799
+ :param progress_callback: Optional callback(downloaded_bytes, total_bytes)
800
+ :type progress_callback: Optional[Callable[[int, int], None]]
509
801
 
510
802
  :raises urllib.error.HTTPError: If the url was unable to be downloaded
511
803
 
@@ -514,22 +806,24 @@ def download_url(url, dest, verbose=True, backoff=3, timeout=60):
514
806
  """
515
807
  local = get_download_location(url, dest)
516
808
  if verbose:
517
- print(f"Downloading {url} -> {local}")
518
- fout = open(local, "wb")
809
+ log.debug(f"Downloading {url} -> {local}")
519
810
  try:
520
- fetch_url(url, fout, backoff, timeout)
811
+ with open(local, "wb") as fout:
812
+ fetch_url(url, fout, backoff, timeout, progress_callback)
521
813
  except Exception as exc:
522
814
  if verbose:
523
- print(f"Unable to download: {url} {exc}", file=sys.stderr, flush=True)
815
+ log.error("Unable to download: %s\n%s", url, exc)
524
816
  try:
525
817
  os.unlink(local)
526
818
  except OSError:
527
819
  pass
528
820
  raise
821
+ finally:
822
+ log.debug(f"Finished downloading {url} -> {local}")
529
823
  return local
530
824
 
531
825
 
532
- def runcmd(*args, **kwargs):
826
+ def runcmd(*args: Any, **kwargs: Any) -> subprocess.Popen[str]:
533
827
  """
534
828
  Run a command.
535
829
 
@@ -541,7 +835,9 @@ def runcmd(*args, **kwargs):
541
835
 
542
836
  :raises RelenvException: If the command finishes with a non zero exit code
543
837
  """
544
- log.debug("Running command: %s", " ".join(args[0]))
838
+ if not args:
839
+ raise RelenvException("No command provided to runcmd")
840
+ log.debug("Running command: %s", " ".join(map(str, args[0])))
545
841
  # if "stdout" not in kwargs:
546
842
  kwargs["stdout"] = subprocess.PIPE
547
843
  # if "stderr" not in kwargs:
@@ -551,44 +847,63 @@ def runcmd(*args, **kwargs):
551
847
  if sys.platform != "win32":
552
848
 
553
849
  p = subprocess.Popen(*args, **kwargs)
850
+ stdout_stream = p.stdout
851
+ stderr_stream = p.stderr
852
+ if stdout_stream is None or stderr_stream is None:
853
+ p.wait()
854
+ raise RelenvException("Process pipes are unavailable")
554
855
  # Read both stdout and stderr simultaneously
555
856
  sel = selectors.DefaultSelector()
556
- sel.register(p.stdout, selectors.EVENT_READ)
557
- sel.register(p.stderr, selectors.EVENT_READ)
857
+ sel.register(stdout_stream, selectors.EVENT_READ)
858
+ sel.register(stderr_stream, selectors.EVENT_READ)
558
859
  ok = True
559
860
  while ok:
560
861
  for key, val1 in sel.select():
561
- line = key.fileobj.readline()
862
+ del val1 # unused
863
+ stream = cast(IO[str], key.fileobj)
864
+ line = stream.readline()
562
865
  if not line:
563
866
  ok = False
564
867
  break
565
868
  if line.endswith("\n"):
566
869
  line = line[:-1]
567
- if key.fileobj is p.stdout:
870
+ if stream is stdout_stream:
568
871
  log.info(line)
569
872
  else:
570
873
  log.error(line)
571
874
 
572
875
  else:
573
876
 
574
- def enqueue_stream(stream, queue, type):
575
- NOOP = object()
576
- for line in iter(stream.readline, NOOP):
577
- if line is NOOP or line == "":
877
+ def enqueue_stream(
878
+ stream: IO[str],
879
+ item_queue: "queue.Queue[tuple[int | str, str]]",
880
+ kind: int,
881
+ ) -> None:
882
+ last_line = ""
883
+ for line in iter(stream.readline, ""):
884
+ if line == "":
578
885
  break
579
- if line:
580
- queue.put((type, line))
581
- log.debug("stream close %r %r", type, line)
886
+ item_queue.put((kind, line))
887
+ last_line = line
888
+ log.debug("stream close %r %r", kind, last_line)
582
889
  stream.close()
583
890
 
584
- def enqueue_process(process, queue):
891
+ def enqueue_process(
892
+ process: subprocess.Popen[str],
893
+ item_queue: "queue.Queue[tuple[int | str, str]]",
894
+ ) -> None:
585
895
  process.wait()
586
- queue.put(("x", "x"))
896
+ item_queue.put(("x", ""))
587
897
 
588
898
  p = subprocess.Popen(*args, **kwargs)
589
- q = queue.Queue()
590
- to = threading.Thread(target=enqueue_stream, args=(p.stdout, q, 1))
591
- te = threading.Thread(target=enqueue_stream, args=(p.stderr, q, 2))
899
+ stdout_stream = p.stdout
900
+ stderr_stream = p.stderr
901
+ if stdout_stream is None or stderr_stream is None:
902
+ p.wait()
903
+ raise RelenvException("Process pipes are unavailable")
904
+ q: "queue.Queue[tuple[int | str, str]]" = queue.Queue()
905
+ to = threading.Thread(target=enqueue_stream, args=(stdout_stream, q, 1))
906
+ te = threading.Thread(target=enqueue_stream, args=(stderr_stream, q, 2))
592
907
  tp = threading.Thread(target=enqueue_process, args=(p, q))
593
908
  te.start()
594
909
  to.start()
@@ -610,11 +925,15 @@ def runcmd(*args, **kwargs):
610
925
 
611
926
  p.wait()
612
927
  if p.returncode != 0:
613
- raise RelenvException("Build cmd '{}' failed".format(" ".join(args[0])))
928
+ raise BuildCommandError("Build cmd '{}' failed".format(" ".join(args[0])))
614
929
  return p
615
930
 
616
931
 
617
- def relative_interpreter(root_dir, scripts_dir, interpreter):
932
+ def relative_interpreter(
933
+ root_dir: Union[str, os.PathLike[str]],
934
+ scripts_dir: Union[str, os.PathLike[str]],
935
+ interpreter: Union[str, os.PathLike[str]],
936
+ ) -> pathlib.Path:
618
937
  """
619
938
  Return a relativized path to the given scripts_dir and interpreter.
620
939
  """
@@ -632,7 +951,7 @@ def relative_interpreter(root_dir, scripts_dir, interpreter):
632
951
  return relscripts / relinterp
633
952
 
634
953
 
635
- def makepath(*paths):
954
+ def makepath(*paths: Union[str, os.PathLike[str]]) -> tuple[str, str]:
636
955
  """
637
956
  Make a normalized path name from paths.
638
957
  """
@@ -644,31 +963,33 @@ def makepath(*paths):
644
963
  return dir, os.path.normcase(dir)
645
964
 
646
965
 
647
- def addpackage(sitedir, name):
966
+ def addpackage(sitedir: str, name: Union[str, os.PathLike[str]]) -> list[str] | None:
648
967
  """
649
968
  Add editable package to path.
650
969
  """
651
970
  import io
652
971
  import stat
653
972
 
654
- fullname = os.path.join(sitedir, name)
655
- paths = []
973
+ fullname = os.path.join(sitedir, os.fspath(name))
974
+ paths: list[str] = []
656
975
  try:
657
976
  st = os.lstat(fullname)
658
977
  except OSError:
659
- return
660
- if (getattr(st, "st_flags", 0) & stat.UF_HIDDEN) or (
661
- getattr(st, "st_file_attributes", 0) & stat.FILE_ATTRIBUTE_HIDDEN
978
+ return None
979
+ file_attr_hidden = getattr(stat, "FILE_ATTRIBUTE_HIDDEN", 0)
980
+ uf_hidden = getattr(stat, "UF_HIDDEN", 0)
981
+ if (getattr(st, "st_flags", 0) & uf_hidden) or (
982
+ getattr(st, "st_file_attributes", 0) & file_attr_hidden
662
983
  ):
663
984
  # print(f"Skipping hidden .pth file: {fullname!r}")
664
- return
985
+ return None
665
986
  # print(f"Processing .pth file: {fullname!r}")
666
987
  try:
667
988
  # locale encoding is not ideal especially on Windows. But we have used
668
989
  # it for a long time. setuptools uses the locale encoding too.
669
990
  f = io.TextIOWrapper(io.open_code(fullname), encoding="locale")
670
991
  except OSError:
671
- return
992
+ return None
672
993
  with f:
673
994
  for n, line in enumerate(f):
674
995
  if line.startswith("#"):
@@ -698,11 +1019,11 @@ def addpackage(sitedir, name):
698
1019
  return paths
699
1020
 
700
1021
 
701
- def sanitize_sys_path(sys_path_entries):
1022
+ def sanitize_sys_path(sys_path_entries: Iterable[str]) -> list[str]:
702
1023
  """
703
1024
  Sanitize `sys.path` to only include paths relative to the onedir environment.
704
1025
  """
705
- __sys_path = []
1026
+ __sys_path: list[str] = []
706
1027
  __valid_path_prefixes = tuple(
707
1028
  {
708
1029
  pathlib.Path(sys.prefix).resolve(),
@@ -723,6 +1044,8 @@ def sanitize_sys_path(sys_path_entries):
723
1044
  for known_path in __sys_path[:]:
724
1045
  for _ in pathlib.Path(known_path).glob("__editable__.*.pth"):
725
1046
  paths = addpackage(known_path, _)
1047
+ if not paths:
1048
+ continue
726
1049
  for p in paths:
727
1050
  if p not in __sys_path:
728
1051
  __sys_path.append(p)
@@ -734,23 +1057,26 @@ class Version:
734
1057
  Version comparisons.
735
1058
  """
736
1059
 
737
- def __init__(self, data):
738
- self.major, self.minor, self.micro = self.parse_string(data)
739
- self._data = data
1060
+ def __init__(self, data: str) -> None:
1061
+ major, minor, micro = self.parse_string(data)
1062
+ self.major: int = major
1063
+ self.minor: Optional[int] = minor
1064
+ self.micro: Optional[int] = micro
1065
+ self._data: str = data
740
1066
 
741
- def __str__(self):
1067
+ def __str__(self: "Version") -> str:
742
1068
  """
743
1069
  Version as string.
744
1070
  """
745
- _ = f"{self.major}"
1071
+ result = f"{self.major}"
746
1072
  if self.minor is not None:
747
- _ += f".{self.minor}"
1073
+ result += f".{self.minor}"
748
1074
  if self.micro is not None:
749
- _ += f".{self.micro}"
1075
+ result += f".{self.micro}"
750
1076
  # XXX What if minor was None but micro was an int.
751
- return _
1077
+ return result
752
1078
 
753
- def __hash__(self):
1079
+ def __hash__(self: "Version") -> int:
754
1080
  """
755
1081
  Hash of the version.
756
1082
 
@@ -759,7 +1085,7 @@ class Version:
759
1085
  return hash((self.major, self.minor, self.micro))
760
1086
 
761
1087
  @staticmethod
762
- def parse_string(data):
1088
+ def parse_string(data: str) -> tuple[int, Optional[int], Optional[int]]:
763
1089
  """
764
1090
  Parse a version string into major, minor, and micro integers.
765
1091
  """
@@ -773,10 +1099,12 @@ class Version:
773
1099
  else:
774
1100
  raise RuntimeError("Too many parts to parse")
775
1101
 
776
- def __eq__(self, other):
1102
+ def __eq__(self: "Version", other: object) -> bool:
777
1103
  """
778
1104
  Equality comparisons.
779
1105
  """
1106
+ if not isinstance(other, Version):
1107
+ return NotImplemented
780
1108
  mymajor = 0 if self.major is None else self.major
781
1109
  myminor = 0 if self.minor is None else self.minor
782
1110
  mymicro = 0 if self.micro is None else self.micro
@@ -785,10 +1113,12 @@ class Version:
785
1113
  micro = 0 if other.micro is None else other.micro
786
1114
  return mymajor == major and myminor == minor and mymicro == micro
787
1115
 
788
- def __lt__(self, other):
1116
+ def __lt__(self: "Version", other: object) -> bool:
789
1117
  """
790
1118
  Less than comparrison.
791
1119
  """
1120
+ if not isinstance(other, Version):
1121
+ return NotImplemented
792
1122
  mymajor = 0 if self.major is None else self.major
793
1123
  myminor = 0 if self.minor is None else self.minor
794
1124
  mymicro = 0 if self.micro is None else self.micro
@@ -804,10 +1134,12 @@ class Version:
804
1134
  return True
805
1135
  return False
806
1136
 
807
- def __le__(self, other):
1137
+ def __le__(self: "Version", other: object) -> bool:
808
1138
  """
809
1139
  Less than or equal to comparrison.
810
1140
  """
1141
+ if not isinstance(other, Version):
1142
+ return NotImplemented
811
1143
  mymajor = 0 if self.major is None else self.major
812
1144
  myminor = 0 if self.minor is None else self.minor
813
1145
  mymicro = 0 if self.micro is None else self.micro
@@ -820,14 +1152,18 @@ class Version:
820
1152
  return True
821
1153
  return False
822
1154
 
823
- def __gt__(self, other):
1155
+ def __gt__(self: "Version", other: object) -> bool:
824
1156
  """
825
1157
  Greater than comparrison.
826
1158
  """
1159
+ if not isinstance(other, Version):
1160
+ return NotImplemented
827
1161
  return not self.__le__(other)
828
1162
 
829
- def __ge__(self, other):
1163
+ def __ge__(self: "Version", other: object) -> bool:
830
1164
  """
831
1165
  Greater than or equal to comparrison.
832
1166
  """
1167
+ if not isinstance(other, Version):
1168
+ return NotImplemented
833
1169
  return not self.__lt__(other)