relenv 0.21.2__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 +292 -74
  15. relenv/build/windows.py +123 -169
  16. relenv/buildenv.py +48 -17
  17. relenv/check.py +10 -5
  18. relenv/common.py +489 -165
  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 -282
  26. relenv/toolchain.py +9 -3
  27. {relenv-0.21.2.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 +477 -34
  43. relenv/build/common.py +0 -1707
  44. relenv-0.21.2.dist-info/RECORD +0 -35
  45. {relenv-0.21.2.dist-info → relenv-0.22.0.dist-info}/WHEEL +0 -0
  46. {relenv-0.21.2.dist-info → relenv-0.22.0.dist-info}/entry_points.txt +0 -0
  47. {relenv-0.21.2.dist-info → relenv-0.22.0.dist-info}/licenses/LICENSE.md +0 -0
  48. {relenv-0.21.2.dist-info → relenv-0.22.0.dist-info}/licenses/NOTICE +0 -0
  49. {relenv-0.21.2.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.2"
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,26 +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"):
339
604
  log.debug("Found tgz archive")
340
605
  read_type = "r:gz"
341
- elif archive.endswith("tar.gz"):
606
+ elif archive_str.endswith(".tar.gz"):
342
607
  log.debug("Found tar.gz archive")
343
608
  read_type = "r:gz"
344
- elif archive.endswith("xz"):
609
+ elif archive_str.endswith(".xz"):
345
610
  log.debug("Found xz archive")
346
611
  read_type = "r:xz"
347
- elif archive.endswith("bz2"):
612
+ elif archive_str.endswith(".bz2"):
348
613
  log.debug("Found bz2 archive")
349
614
  read_type = "r:bz2"
350
615
  else:
351
- log.warning("Found unknown archive type: %s", archive)
352
- read_type = "r"
353
- with tarfile.open(archive, read_type) as t:
354
- 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))
355
619
 
356
620
 
357
- def get_download_location(url, dest):
621
+ def get_download_location(url: str, dest: Union[str, os.PathLike[str]]) -> str:
358
622
  """
359
623
  Get the full path to where the url will be downloaded to.
360
624
 
@@ -366,10 +630,10 @@ def get_download_location(url, dest):
366
630
  :return: The path to where the url will be downloaded to
367
631
  :rtype: str
368
632
  """
369
- return os.path.join(dest, os.path.basename(url))
633
+ return os.path.join(os.fspath(dest), os.path.basename(url))
370
634
 
371
635
 
372
- def check_url(url, timestamp=None, timeout=30):
636
+ def check_url(url: str, timestamp: Optional[float] = None, timeout: float = 30) -> bool:
373
637
  """
374
638
  Check that the url returns a 200.
375
639
  """
@@ -400,54 +664,79 @@ def check_url(url, timestamp=None, timeout=30):
400
664
  return True
401
665
 
402
666
 
403
- 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:
404
674
  """
405
675
  Fetch the contents of a url.
406
676
 
407
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]]
408
681
  """
409
682
  # Late import so we do not import hashlib before runtime.bootstrap is called.
410
683
  import urllib.error
411
684
  import urllib.request
412
685
 
413
686
  last = time.time()
414
- if backoff < 1:
415
- backoff = 1
416
- n = 0
417
- while n < backoff:
418
- n += 1
687
+ attempts = max(backoff, 1)
688
+ response: http.client.HTTPResponse | None = None
689
+ for attempt in range(1, attempts + 1):
419
690
  try:
420
- fin = urllib.request.urlopen(url, timeout=timeout)
691
+ response = urllib.request.urlopen(url, timeout=timeout)
421
692
  break
422
693
  except (
423
694
  urllib.error.HTTPError,
424
695
  urllib.error.URLError,
425
696
  http.client.RemoteDisconnected,
426
697
  ) as exc:
427
- if n >= backoff:
698
+ if attempt >= attempts:
428
699
  raise RelenvException(f"Error fetching url {url} {exc}")
429
700
  log.debug("Unable to connect %s", url)
430
- time.sleep(n * 10)
431
- else:
432
- raise RelenvException(f"Error fetching url: {url}")
701
+ time.sleep(attempt * 10)
702
+ if response is None:
703
+ raise RelenvException(f"Unable to open url {url}")
433
704
  log.info("url opened %s", url)
705
+
706
+ # Get content length from headers
707
+ content_length = 0
434
708
  try:
435
- 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
436
721
  size = 1024 * 300
437
- block = fin.read(size)
722
+ block = response.read(size)
438
723
  while block:
439
- total += size
724
+ block_size = len(block)
725
+ downloaded += block_size
440
726
  if time.time() - last > 10:
441
- log.info("%s > %d", url, total)
727
+ log.info("%s > %d", url, downloaded)
442
728
  last = time.time()
443
729
  fp.write(block)
444
- 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)
445
734
  finally:
446
- fin.close()
735
+ response.close()
447
736
  log.info("Download complete %s", url)
448
737
 
449
738
 
450
- def fetch_url_content(url, backoff=3, timeout=30):
739
+ def fetch_url_content(url: str, backoff: int = 3, timeout: float = 30) -> str:
451
740
  """
452
741
  Fetch the contents of a url.
453
742
 
@@ -455,55 +744,47 @@ def fetch_url_content(url, backoff=3, timeout=30):
455
744
  """
456
745
  # Late import so we do not import hashlib before runtime.bootstrap is called.
457
746
  import gzip
458
- import io
459
747
  import urllib.error
460
748
  import urllib.request
461
749
 
462
- fp = io.BytesIO()
463
-
464
- last = time.time()
465
- if backoff < 1:
466
- backoff = 1
467
- n = 0
468
- while n < backoff:
469
- n += 1
750
+ attempts = max(backoff, 1)
751
+ response: http.client.HTTPResponse | None = None
752
+ for attempt in range(1, attempts + 1):
470
753
  try:
471
- fin = urllib.request.urlopen(url, timeout=timeout)
754
+ response = urllib.request.urlopen(url, timeout=timeout)
755
+ break
472
756
  except (
473
757
  urllib.error.HTTPError,
474
758
  urllib.error.URLError,
475
759
  http.client.RemoteDisconnected,
476
760
  ) as exc:
477
- if n >= backoff:
761
+ if attempt >= attempts:
478
762
  raise RelenvException(f"Error fetching url {url} {exc}")
479
763
  log.debug("Unable to connect %s", url)
480
- time.sleep(n * 10)
764
+ time.sleep(attempt * 10)
765
+ if response is None:
766
+ raise RelenvException(f"Unable to open url {url}")
481
767
  log.info("url opened %s", url)
482
768
  try:
483
- total = 0
484
- size = 1024 * 300
485
- block = fin.read(size)
486
- while block:
487
- total += size
488
- if time.time() - last > 10:
489
- log.info("%s > %d", url, total)
490
- last = time.time()
491
- fp.write(block)
492
- block = fin.read(10240)
769
+ data = response.read()
770
+ encoding = response.headers.get("content-encoding", "").lower()
493
771
  finally:
494
- fin.close()
495
- # fp.close()
772
+ response.close()
773
+ if encoding == "gzip":
774
+ log.debug("Found gzipped content")
775
+ data = gzip.decompress(data)
496
776
  log.info("Download complete %s", url)
497
- fp.seek(0)
498
- info = fin.info()
499
- if "content-encoding" in info:
500
- if info["content-encoding"] == "gzip":
501
- log.debug("Found gzipped content")
502
- fp = gzip.GzipFile(fileobj=fp)
503
- return fp.read().decode()
777
+ return data.decode()
504
778
 
505
779
 
506
- 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:
507
788
  """
508
789
  Download the url to the provided destination.
509
790
 
@@ -515,6 +796,8 @@ def download_url(url, dest, verbose=True, backoff=3, timeout=60):
515
796
  :type dest: str
516
797
  :param verbose: Print download url and destination to stdout
517
798
  :type verbose: bool
799
+ :param progress_callback: Optional callback(downloaded_bytes, total_bytes)
800
+ :type progress_callback: Optional[Callable[[int, int], None]]
518
801
 
519
802
  :raises urllib.error.HTTPError: If the url was unable to be downloaded
520
803
 
@@ -524,9 +807,9 @@ def download_url(url, dest, verbose=True, backoff=3, timeout=60):
524
807
  local = get_download_location(url, dest)
525
808
  if verbose:
526
809
  log.debug(f"Downloading {url} -> {local}")
527
- fout = open(local, "wb")
528
810
  try:
529
- fetch_url(url, fout, backoff, timeout)
811
+ with open(local, "wb") as fout:
812
+ fetch_url(url, fout, backoff, timeout, progress_callback)
530
813
  except Exception as exc:
531
814
  if verbose:
532
815
  log.error("Unable to download: %s\n%s", url, exc)
@@ -536,12 +819,11 @@ def download_url(url, dest, verbose=True, backoff=3, timeout=60):
536
819
  pass
537
820
  raise
538
821
  finally:
539
- fout.close()
540
822
  log.debug(f"Finished downloading {url} -> {local}")
541
823
  return local
542
824
 
543
825
 
544
- def runcmd(*args, **kwargs):
826
+ def runcmd(*args: Any, **kwargs: Any) -> subprocess.Popen[str]:
545
827
  """
546
828
  Run a command.
547
829
 
@@ -553,7 +835,9 @@ def runcmd(*args, **kwargs):
553
835
 
554
836
  :raises RelenvException: If the command finishes with a non zero exit code
555
837
  """
556
- 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])))
557
841
  # if "stdout" not in kwargs:
558
842
  kwargs["stdout"] = subprocess.PIPE
559
843
  # if "stderr" not in kwargs:
@@ -563,44 +847,63 @@ def runcmd(*args, **kwargs):
563
847
  if sys.platform != "win32":
564
848
 
565
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")
566
855
  # Read both stdout and stderr simultaneously
567
856
  sel = selectors.DefaultSelector()
568
- sel.register(p.stdout, selectors.EVENT_READ)
569
- sel.register(p.stderr, selectors.EVENT_READ)
857
+ sel.register(stdout_stream, selectors.EVENT_READ)
858
+ sel.register(stderr_stream, selectors.EVENT_READ)
570
859
  ok = True
571
860
  while ok:
572
861
  for key, val1 in sel.select():
573
- line = key.fileobj.readline()
862
+ del val1 # unused
863
+ stream = cast(IO[str], key.fileobj)
864
+ line = stream.readline()
574
865
  if not line:
575
866
  ok = False
576
867
  break
577
868
  if line.endswith("\n"):
578
869
  line = line[:-1]
579
- if key.fileobj is p.stdout:
870
+ if stream is stdout_stream:
580
871
  log.info(line)
581
872
  else:
582
873
  log.error(line)
583
874
 
584
875
  else:
585
876
 
586
- def enqueue_stream(stream, queue, type):
587
- NOOP = object()
588
- for line in iter(stream.readline, NOOP):
589
- 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 == "":
590
885
  break
591
- if line:
592
- queue.put((type, line))
593
- 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)
594
889
  stream.close()
595
890
 
596
- 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:
597
895
  process.wait()
598
- queue.put(("x", "x"))
896
+ item_queue.put(("x", ""))
599
897
 
600
898
  p = subprocess.Popen(*args, **kwargs)
601
- q = queue.Queue()
602
- to = threading.Thread(target=enqueue_stream, args=(p.stdout, q, 1))
603
- 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))
604
907
  tp = threading.Thread(target=enqueue_process, args=(p, q))
605
908
  te.start()
606
909
  to.start()
@@ -622,11 +925,15 @@ def runcmd(*args, **kwargs):
622
925
 
623
926
  p.wait()
624
927
  if p.returncode != 0:
625
- raise RelenvException("Build cmd '{}' failed".format(" ".join(args[0])))
928
+ raise BuildCommandError("Build cmd '{}' failed".format(" ".join(args[0])))
626
929
  return p
627
930
 
628
931
 
629
- 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:
630
937
  """
631
938
  Return a relativized path to the given scripts_dir and interpreter.
632
939
  """
@@ -644,7 +951,7 @@ def relative_interpreter(root_dir, scripts_dir, interpreter):
644
951
  return relscripts / relinterp
645
952
 
646
953
 
647
- def makepath(*paths):
954
+ def makepath(*paths: Union[str, os.PathLike[str]]) -> tuple[str, str]:
648
955
  """
649
956
  Make a normalized path name from paths.
650
957
  """
@@ -656,31 +963,33 @@ def makepath(*paths):
656
963
  return dir, os.path.normcase(dir)
657
964
 
658
965
 
659
- def addpackage(sitedir, name):
966
+ def addpackage(sitedir: str, name: Union[str, os.PathLike[str]]) -> list[str] | None:
660
967
  """
661
968
  Add editable package to path.
662
969
  """
663
970
  import io
664
971
  import stat
665
972
 
666
- fullname = os.path.join(sitedir, name)
667
- paths = []
973
+ fullname = os.path.join(sitedir, os.fspath(name))
974
+ paths: list[str] = []
668
975
  try:
669
976
  st = os.lstat(fullname)
670
977
  except OSError:
671
- return
672
- if (getattr(st, "st_flags", 0) & stat.UF_HIDDEN) or (
673
- 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
674
983
  ):
675
984
  # print(f"Skipping hidden .pth file: {fullname!r}")
676
- return
985
+ return None
677
986
  # print(f"Processing .pth file: {fullname!r}")
678
987
  try:
679
988
  # locale encoding is not ideal especially on Windows. But we have used
680
989
  # it for a long time. setuptools uses the locale encoding too.
681
990
  f = io.TextIOWrapper(io.open_code(fullname), encoding="locale")
682
991
  except OSError:
683
- return
992
+ return None
684
993
  with f:
685
994
  for n, line in enumerate(f):
686
995
  if line.startswith("#"):
@@ -710,11 +1019,11 @@ def addpackage(sitedir, name):
710
1019
  return paths
711
1020
 
712
1021
 
713
- def sanitize_sys_path(sys_path_entries):
1022
+ def sanitize_sys_path(sys_path_entries: Iterable[str]) -> list[str]:
714
1023
  """
715
1024
  Sanitize `sys.path` to only include paths relative to the onedir environment.
716
1025
  """
717
- __sys_path = []
1026
+ __sys_path: list[str] = []
718
1027
  __valid_path_prefixes = tuple(
719
1028
  {
720
1029
  pathlib.Path(sys.prefix).resolve(),
@@ -735,6 +1044,8 @@ def sanitize_sys_path(sys_path_entries):
735
1044
  for known_path in __sys_path[:]:
736
1045
  for _ in pathlib.Path(known_path).glob("__editable__.*.pth"):
737
1046
  paths = addpackage(known_path, _)
1047
+ if not paths:
1048
+ continue
738
1049
  for p in paths:
739
1050
  if p not in __sys_path:
740
1051
  __sys_path.append(p)
@@ -746,23 +1057,26 @@ class Version:
746
1057
  Version comparisons.
747
1058
  """
748
1059
 
749
- def __init__(self, data):
750
- self.major, self.minor, self.micro = self.parse_string(data)
751
- 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
752
1066
 
753
- def __str__(self):
1067
+ def __str__(self: "Version") -> str:
754
1068
  """
755
1069
  Version as string.
756
1070
  """
757
- _ = f"{self.major}"
1071
+ result = f"{self.major}"
758
1072
  if self.minor is not None:
759
- _ += f".{self.minor}"
1073
+ result += f".{self.minor}"
760
1074
  if self.micro is not None:
761
- _ += f".{self.micro}"
1075
+ result += f".{self.micro}"
762
1076
  # XXX What if minor was None but micro was an int.
763
- return _
1077
+ return result
764
1078
 
765
- def __hash__(self):
1079
+ def __hash__(self: "Version") -> int:
766
1080
  """
767
1081
  Hash of the version.
768
1082
 
@@ -771,7 +1085,7 @@ class Version:
771
1085
  return hash((self.major, self.minor, self.micro))
772
1086
 
773
1087
  @staticmethod
774
- def parse_string(data):
1088
+ def parse_string(data: str) -> tuple[int, Optional[int], Optional[int]]:
775
1089
  """
776
1090
  Parse a version string into major, minor, and micro integers.
777
1091
  """
@@ -785,10 +1099,12 @@ class Version:
785
1099
  else:
786
1100
  raise RuntimeError("Too many parts to parse")
787
1101
 
788
- def __eq__(self, other):
1102
+ def __eq__(self: "Version", other: object) -> bool:
789
1103
  """
790
1104
  Equality comparisons.
791
1105
  """
1106
+ if not isinstance(other, Version):
1107
+ return NotImplemented
792
1108
  mymajor = 0 if self.major is None else self.major
793
1109
  myminor = 0 if self.minor is None else self.minor
794
1110
  mymicro = 0 if self.micro is None else self.micro
@@ -797,10 +1113,12 @@ class Version:
797
1113
  micro = 0 if other.micro is None else other.micro
798
1114
  return mymajor == major and myminor == minor and mymicro == micro
799
1115
 
800
- def __lt__(self, other):
1116
+ def __lt__(self: "Version", other: object) -> bool:
801
1117
  """
802
1118
  Less than comparrison.
803
1119
  """
1120
+ if not isinstance(other, Version):
1121
+ return NotImplemented
804
1122
  mymajor = 0 if self.major is None else self.major
805
1123
  myminor = 0 if self.minor is None else self.minor
806
1124
  mymicro = 0 if self.micro is None else self.micro
@@ -816,10 +1134,12 @@ class Version:
816
1134
  return True
817
1135
  return False
818
1136
 
819
- def __le__(self, other):
1137
+ def __le__(self: "Version", other: object) -> bool:
820
1138
  """
821
1139
  Less than or equal to comparrison.
822
1140
  """
1141
+ if not isinstance(other, Version):
1142
+ return NotImplemented
823
1143
  mymajor = 0 if self.major is None else self.major
824
1144
  myminor = 0 if self.minor is None else self.minor
825
1145
  mymicro = 0 if self.micro is None else self.micro
@@ -832,14 +1152,18 @@ class Version:
832
1152
  return True
833
1153
  return False
834
1154
 
835
- def __gt__(self, other):
1155
+ def __gt__(self: "Version", other: object) -> bool:
836
1156
  """
837
1157
  Greater than comparrison.
838
1158
  """
1159
+ if not isinstance(other, Version):
1160
+ return NotImplemented
839
1161
  return not self.__le__(other)
840
1162
 
841
- def __ge__(self, other):
1163
+ def __ge__(self: "Version", other: object) -> bool:
842
1164
  """
843
1165
  Greater than or equal to comparrison.
844
1166
  """
1167
+ if not isinstance(other, Version):
1168
+ return NotImplemented
845
1169
  return not self.__lt__(other)