Skip to content

Commit 1aa4812

Browse files
committed
Fix more mypy errors and flake8 warnings
1 parent 98701e6 commit 1aa4812

File tree

8 files changed

+445
-200
lines changed

8 files changed

+445
-200
lines changed

relenv/build/common.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
"""
66
from __future__ import annotations
77

8-
import glob
98
import fnmatch
109
import hashlib
1110
import io
@@ -26,23 +25,26 @@
2625
from html.parser import HTMLParser
2726
from types import ModuleType
2827
from typing import (
29-
TYPE_CHECKING,
3028
Any,
3129
Callable,
32-
cast,
3330
Dict,
3431
IO,
35-
Iterable,
3632
List,
3733
MutableMapping,
3834
Optional,
3935
Sequence,
4036
Tuple,
41-
TypedDict,
4237
Union,
43-
Protocol,
38+
cast,
4439
)
4540

41+
try:
42+
from typing import TYPE_CHECKING
43+
except ImportError: # pragma: no cover
44+
TYPE_CHECKING = False
45+
46+
from typing_extensions import Protocol, TypedDict
47+
4648
if TYPE_CHECKING:
4749
from multiprocessing.synchronize import Event as SyncEvent
4850
else:
@@ -564,11 +566,13 @@ def handle_starttag(
564566

565567

566568
class Comparable(Protocol):
569+
"""Protocol capturing the comparison operations we rely on."""
570+
567571
def __lt__(self, other: Any) -> bool:
568-
...
572+
"""Return True when self is ordered before *other*."""
569573

570574
def __gt__(self, other: Any) -> bool:
571-
...
575+
"""Return True when self is ordered after *other*."""
572576

573577

574578
def check_files(
@@ -652,7 +656,9 @@ def __init__(
652656
self.url_tpl = url
653657
self.fallback_url_tpl = fallback_url
654658
self.signature_tpl = signature
655-
self.destination = destination
659+
self._destination: pathlib.Path = pathlib.Path()
660+
if destination:
661+
self._destination = pathlib.Path(destination)
656662
self.version = version
657663
self.checksum = checksum
658664
self.checkfunc = checkfunc
@@ -849,6 +855,8 @@ def check_version(self) -> bool:
849855

850856

851857
class Recipe(TypedDict):
858+
"""Typed description of a build recipe entry."""
859+
852860
build_func: Callable[[MutableMapping[str, str], "Dirs", IO[str]], None]
853861
wait_on: List[str]
854862
download: Optional[Download]

relenv/common.py

Lines changed: 114 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
from __future__ import annotations
77

88
import http.client
9+
import json
910
import logging
1011
import os as _os
1112
import pathlib
1213
import platform
1314
import queue
1415
import selectors
16+
import shutil
1517
import subprocess as _subprocess
1618
import sys as _sys
1719
import tarfile
@@ -20,6 +22,8 @@
2022
import time
2123
from typing import IO, Any, BinaryIO, Iterable, Mapping, Optional, Union, cast
2224

25+
from typing_extensions import Literal
26+
2327
# Re-export frequently monkeypatched modules for type checking.
2428
os = _os
2529
subprocess = _subprocess
@@ -28,6 +32,8 @@
2832
# relenv package version
2933
__version__ = "0.21.2"
3034

35+
log = logging.getLogger(__name__)
36+
3137
MODULE_DIR = pathlib.Path(__file__).resolve().parent
3238

3339
DEFAULT_PYTHON = "3.10.18"
@@ -38,6 +44,83 @@
3844

3945
MACOS_DEVELOPMENT_TARGET = "10.15"
4046

47+
TOOLCHAIN_CACHE_ENV = "RELENV_TOOLCHAIN_CACHE"
48+
_TOOLCHAIN_MANIFEST = ".toolchain-manifest.json"
49+
50+
51+
# 8 GiB archives are not unusual; stick to metadata to fingerprint them.
52+
def _archive_metadata(path: pathlib.Path) -> dict[str, Union[str, int]]:
53+
stat = path.stat()
54+
return {
55+
"archive": str(path.resolve()),
56+
"size": stat.st_size,
57+
"mtime": stat.st_mtime_ns,
58+
}
59+
60+
61+
def _toolchain_cache_root() -> Optional[pathlib.Path]:
62+
override = os.environ.get(TOOLCHAIN_CACHE_ENV)
63+
if override:
64+
if override.strip().lower() == "none":
65+
return None
66+
return pathlib.Path(override).expanduser()
67+
cache_home = os.environ.get("XDG_CACHE_HOME")
68+
if cache_home:
69+
base = pathlib.Path(cache_home)
70+
else:
71+
base = pathlib.Path.home() / ".cache"
72+
return base / "relenv" / "toolchains"
73+
74+
75+
def _toolchain_manifest_path(toolchain_path: pathlib.Path) -> pathlib.Path:
76+
return toolchain_path / _TOOLCHAIN_MANIFEST
77+
78+
79+
def _load_toolchain_manifest(path: pathlib.Path) -> Optional[Mapping[str, Any]]:
80+
if not path.exists():
81+
return None
82+
try:
83+
with path.open(encoding="utf-8") as handle:
84+
data = json.load(handle)
85+
except (OSError, json.JSONDecodeError):
86+
return None
87+
if not isinstance(data, dict):
88+
return None
89+
return data
90+
91+
92+
def _manifest_matches(manifest: Mapping[str, Any], metadata: Mapping[str, Any]) -> bool:
93+
return (
94+
manifest.get("archive") == metadata.get("archive")
95+
and manifest.get("size") == metadata.get("size")
96+
and manifest.get("mtime") == metadata.get("mtime")
97+
)
98+
99+
100+
def _write_toolchain_manifest(
101+
toolchain_path: pathlib.Path, metadata: Mapping[str, Any]
102+
) -> None:
103+
manifest_path = _toolchain_manifest_path(toolchain_path)
104+
try:
105+
with manifest_path.open("w", encoding="utf-8") as handle:
106+
json.dump(metadata, handle, indent=2, sort_keys=True)
107+
handle.write("\n")
108+
except OSError as exc: # pragma: no cover - permissions edge cases
109+
log.warning(
110+
"Unable to persist toolchain manifest at %s: %s", manifest_path, exc
111+
)
112+
113+
114+
def toolchain_root_dir() -> pathlib.Path:
115+
"""Return the root directory used for cached toolchains."""
116+
if sys.platform != "linux":
117+
return DATA_DIR
118+
root = _toolchain_cache_root()
119+
if root is None:
120+
return DATA_DIR / "toolchain"
121+
return root
122+
123+
41124
REQUEST_HEADERS = {"User-Agent": f"relenv {__version__}"}
42125

43126
CHECK_HOSTS = (
@@ -69,8 +152,9 @@
69152
SHEBANG_TPL_LINUX = textwrap.dedent(
70153
"""#!/bin/sh
71154
"true" ''''
155+
# shellcheck disable=SC2093
72156
"exec" "$(dirname "$(readlink -f "$0")"){}" "$0" "$@"
73-
'''
157+
' '''
74158
"""
75159
)
76160

@@ -90,8 +174,10 @@
90174
done
91175
PHYS_DIR=$(pwd -P)
92176
REALPATH=$PHYS_DIR/$TARGET_FILE
177+
# shellcheck disable=SC2093
93178
"exec" "$(dirname "$REALPATH")"{} "$REALPATH" "$@"
94-
'''"""
179+
' '''
180+
"""
95181
)
96182

97183
if sys.platform == "linux":
@@ -100,9 +186,6 @@
100186
SHEBANG_TPL = SHEBANG_TPL_MACOS
101187

102188

103-
log = logging.getLogger(__name__)
104-
105-
106189
class RelenvException(Exception):
107190
"""
108191
Base class for exeptions generated from relenv.
@@ -180,7 +263,7 @@ def __init__(self: "WorkDirs", root: Union[str, os.PathLike[str]]) -> None:
180263
self.root: pathlib.Path = pathlib.Path(root)
181264
self.data: pathlib.Path = DATA_DIR
182265
self.toolchain_config: pathlib.Path = work_dir("toolchain", self.root)
183-
self.toolchain: pathlib.Path = work_dir("toolchain", DATA_DIR)
266+
self.toolchain: pathlib.Path = toolchain_root_dir()
184267
self.build: pathlib.Path = work_dir("build", DATA_DIR)
185268
self.src: pathlib.Path = work_dir("src", DATA_DIR)
186269
self.logs: pathlib.Path = work_dir("logs", DATA_DIR)
@@ -251,16 +334,17 @@ def get_toolchain(
251334
del root # Kept for backward compatibility; location driven by DATA_DIR
252335
os.makedirs(DATA_DIR, exist_ok=True)
253336
if sys.platform != "linux":
254-
return DATA_DIR
337+
return toolchain_root_dir()
255338

256-
toolchain_root = DATA_DIR / "toolchain"
339+
toolchain_root = toolchain_root_dir()
257340
try:
258341
triplet = get_triplet(machine=arch)
259342
except TypeError:
260343
triplet = get_triplet()
261344
toolchain_path = toolchain_root / triplet
345+
metadata: Optional[Mapping[str, Any]] = None
262346
if toolchain_path.exists():
263-
return toolchain_path
347+
metadata = _load_toolchain_manifest(_toolchain_manifest_path(toolchain_path))
264348

265349
try:
266350
from importlib import import_module
@@ -275,7 +359,24 @@ def get_toolchain(
275359

276360
toolchain_root.mkdir(parents=True, exist_ok=True)
277361
archive_path = pathlib.Path(archive_attr)
362+
archive_meta = _archive_metadata(archive_path)
363+
364+
if (
365+
toolchain_path.exists()
366+
and metadata
367+
and _manifest_matches(metadata, archive_meta)
368+
):
369+
return toolchain_path
370+
371+
if toolchain_path.exists():
372+
shutil.rmtree(toolchain_path)
373+
278374
extract(str(toolchain_root), str(archive_path))
375+
if not toolchain_path.exists():
376+
raise RelenvException(
377+
f"Toolchain archive {archive_path} did not produce {toolchain_path}"
378+
)
379+
_write_toolchain_manifest(toolchain_path, archive_meta)
279380
return toolchain_path
280381

281382

@@ -370,6 +471,8 @@ def extract_archive(
370471
archive_path = pathlib.Path(archive)
371472
archive_str = str(archive_path)
372473
to_path = pathlib.Path(to_dir)
474+
TarReadMode = Literal["r:gz", "r:xz", "r:bz2", "r"]
475+
read_type: TarReadMode = "r"
373476
if archive_str.endswith(".tgz"):
374477
log.debug("Found tgz archive")
375478
read_type = "r:gz"
@@ -384,9 +487,8 @@ def extract_archive(
384487
read_type = "r:bz2"
385488
else:
386489
log.warning("Found unknown archive type: %s", archive_path)
387-
read_type = "r"
388-
with tarfile.open(archive_path, read_type) as tar:
389-
tar.extractall(to_path)
490+
with tarfile.open(str(archive_path), mode=read_type) as tar:
491+
tar.extractall(str(to_path))
390492

391493

392494
def get_download_location(url: str, dest: Union[str, os.PathLike[str]]) -> str:

relenv/create.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@
1515
import tarfile
1616
from collections.abc import Iterator
1717

18-
from .common import RelenvException, arches, archived_build, build_arch
18+
from .common import (
19+
RelenvException,
20+
arches,
21+
archived_build,
22+
build_arch,
23+
format_shebang,
24+
relative_interpreter,
25+
)
1926

2027

2128
@contextlib.contextmanager
@@ -135,6 +142,7 @@ def create(
135142
for f in fp:
136143
fp.extract(f, writeto)
137144
_sync_relenv_package(writeto, version)
145+
_repair_script_shebangs(writeto, version)
138146

139147

140148
def _site_packages_dir(root: pathlib.Path, version: str) -> pathlib.Path:
@@ -166,6 +174,71 @@ def _sync_relenv_package(root: pathlib.Path, version: str) -> None:
166174
)
167175

168176

177+
def _repair_script_shebangs(root: pathlib.Path, version: str) -> None:
178+
"""
179+
Update legacy shell-wrapped entry points to the current shebang format.
180+
181+
Older archives shipped scripts that started with the ``"true" ''''`` preamble.
182+
Those files break when executed directly under Python (the parser sees the
183+
unmatched triple-quoted literal). Patch any remaining copies to the new
184+
`format_shebang` layout so fresh installs do not inherit stale loaders.
185+
"""
186+
if sys.platform == "win32":
187+
return
188+
189+
scripts_dir = root / "bin"
190+
if not scripts_dir.is_dir():
191+
return
192+
193+
major_minor = ".".join(version.split(".")[:2])
194+
interpreter_candidates = [
195+
scripts_dir / f"python{major_minor}",
196+
scripts_dir / f"python{major_minor.split('.')[0]}",
197+
scripts_dir / "python3",
198+
scripts_dir / "python",
199+
]
200+
interpreter_path: pathlib.Path | None = None
201+
for candidate in interpreter_candidates:
202+
if candidate.exists():
203+
interpreter_path = candidate
204+
break
205+
if interpreter_path is None:
206+
return
207+
208+
try:
209+
rel_interpreter = relative_interpreter(root, scripts_dir, interpreter_path)
210+
except ValueError:
211+
# Paths are not relative to the install root; abandon the rewrite.
212+
return
213+
214+
try:
215+
shebang = format_shebang(str(pathlib.PurePosixPath("/") / rel_interpreter))
216+
except Exception:
217+
return
218+
219+
legacy_prefix = "#!/bin/sh\n\"true\" ''''\n"
220+
marker = "\n'''"
221+
for script in scripts_dir.iterdir():
222+
if not script.is_file():
223+
continue
224+
try:
225+
text = script.read_text(encoding="utf-8")
226+
except (OSError, UnicodeDecodeError):
227+
continue
228+
if not text.startswith(legacy_prefix):
229+
continue
230+
idx = text.find(marker)
231+
if idx == -1:
232+
continue
233+
idy = idx + len(marker)
234+
rest = text[idy:]
235+
updated = shebang + rest.lstrip("\n")
236+
try:
237+
script.write_text(updated, encoding="utf-8")
238+
except OSError:
239+
continue
240+
241+
169242
def main(args: argparse.Namespace) -> None:
170243
"""
171244
The entrypoint into the ``relenv create`` command.

0 commit comments

Comments
 (0)