66from __future__ import annotations
77
88import http .client
9+ import json
910import logging
1011import os as _os
1112import pathlib
1213import platform
1314import queue
1415import selectors
16+ import shutil
1517import subprocess as _subprocess
1618import sys as _sys
1719import tarfile
2022import time
2123from 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.
2428os = _os
2529subprocess = _subprocess
2832# relenv package version
2933__version__ = "0.21.2"
3034
35+ log = logging .getLogger (__name__ )
36+
3137MODULE_DIR = pathlib .Path (__file__ ).resolve ().parent
3238
3339DEFAULT_PYTHON = "3.10.18"
3844
3945MACOS_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+
41124REQUEST_HEADERS = {"User-Agent" : f"relenv { __version__ } " }
42125
43126CHECK_HOSTS = (
69152SHEBANG_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
90174done
91175PHYS_DIR=$(pwd -P)
92176REALPATH=$PHYS_DIR/$TARGET_FILE
177+ # shellcheck disable=SC2093
93178"exec" "$(dirname "$REALPATH")"{} "$REALPATH" "$@"
94- '''"""
179+ ' '''
180+ """
95181)
96182
97183if sys .platform == "linux" :
100186 SHEBANG_TPL = SHEBANG_TPL_MACOS
101187
102188
103- log = logging .getLogger (__name__ )
104-
105-
106189class 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
392494def get_download_location (url : str , dest : Union [str , os .PathLike [str ]]) -> str :
0 commit comments