Coverage for src/footman/_fetch.py: 97%

222 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-06 02:59 +0000

1"""Download files into footman's cache — `fetch()`. 

2 

3Build tasks fetch things: a toolchain tarball, a schema, a fixture. Doing 

4it well means caching by URL, revalidating instead of re-downloading, 

5verifying what arrived, and reporting progress — and doing it *here* 

6means it composes with everything already built: 

7 

8- the cached copy lives under `footman_cache_dir()`, so 

9 `FOOTMAN_CACHE_DIR` relocates it and the cache collector tends it; 

10- every fetch records a `Result`, so `--dry-run` prints without 

11 downloading, `recording()` asserts on it in tests, `--json` carries 

12 it, and the step lines show it in the same aligned grid as `run()`; 

13- byte counts feed `progress()`, so a download drives the live bar. 

14 

15**Backends.** The default is stdlib `urllib` — always present, zero 

16dependencies, deterministic, and the only backend that can report bytes 

17as they arrive. `curl` (shipped in Windows' System32 since build 17063, 

18and on every POSIX box) is the escape hatch for corporate proxies and 

19TLS stores that Python's defaults can't see; `httpx` and `requests` are 

20used only when explicitly named. Choose per call, or set 

21`[fetch] backend` in any config file — a machine behind a proxy sets it 

22once in `~/.config/footman/config.toml` and every project follows. 

23 

24Which one you name changes the socket, and nothing else. All four 

25revalidate with the same headers, refuse a body that arrived short, and 

26land the finished file with the same rename — a backend that behaved its 

27own way would make the choice a behaviour change rather than a plumbing 

28one, and the conformance table in `tests/test_fetch.py` runs one set of 

29scenarios against every backend installed to keep it that way. 

30 

31Deliberately *not* automatic: a fetch that silently picks a different 

32engine depending on what happens to be importable would change its TLS 

33trust store and proxy semantics when an unrelated dependency appears. 

34`backend = "auto"` exists for people who want that, spelled out as a 

35choice rather than a surprise. 

36 

37This is for build artifacts, not a general HTTP client. Anything exotic 

38belongs in a real curl call — `run(["curl", ...])`, or toolroom's typed 

39`curl(...)` handle. 

40""" 

41 

42from __future__ import annotations 

43 

44import contextlib 

45import hashlib 

46import http.client 

47import json 

48import os 

49import shutil 

50import tempfile 

51import time 

52import urllib.error 

53import urllib.request 

54from pathlib import Path 

55from typing import Any 

56 

57from footman import _paths, context 

58 

59BACKENDS = ("urllib", "curl", "httpx", "requests", "auto") 

60_AUTO_ORDER = ("httpx", "requests", "urllib", "curl") 

61CHUNK = 64 * 1024 

62 

63 

64class FetchError(Exception): 

65 """A download failed, or arrived wrong (checksum, missing backend).""" 

66 

67 

68def cache_dir() -> Path: 

69 """Where fetched files live: a `fetch/` room in footman's own cache.""" 

70 return _paths.footman_cache_dir() / "fetch" 

71 

72 

73def _key(url: str) -> str: 

74 return hashlib.sha256(url.encode("utf-8")).hexdigest()[:16] 

75 

76 

77def _manifest_path(url: str) -> Path: 

78 """The URL's manifest: a small JSON that names its own data file. 

79 

80 The layout's one moving part. The data file is content-addressed 

81 (`<key>-<digest16>.bin`) and immutable once published; a fresh download 

82 lands under a fresh name and only this manifest is replaced — one 

83 uncontended swap of a small file no reader holds open. A path handed to 

84 a caller therefore never changes underneath them, the validators in the 

85 manifest describe exactly the bytes they sit beside, and the Windows 

86 replace-under-reader failure has nothing left to happen to. 

87 """ 

88 return cache_dir() / f"{_key(url)}.json" 

89 

90 

91def _data_name(url: str, digest: str) -> str: 

92 return f"{_key(url)}-{digest[:16]}.bin" 

93 

94 

95def _cached_body(url: str, meta: dict[str, Any]) -> Path | None: 

96 """The data file *meta* names, when it is really there.""" 

97 name = meta.get("data") 

98 if not isinstance(name, str) or not name.startswith(_key(url)): 

99 return None 

100 body = cache_dir() / name 

101 return body if body.is_file() else None 

102 

103 

104def _load_meta(path: Path) -> dict[str, Any]: 

105 try: 

106 data = json.loads(path.read_text("utf-8")) 

107 except (OSError, ValueError): 

108 return {} 

109 return data if isinstance(data, dict) else {} 

110 

111 

112def _digest(path: Path) -> str: 

113 sha = hashlib.sha256() 

114 with open(path, "rb") as fh: 

115 for block in iter(lambda: fh.read(CHUNK), b""): 

116 sha.update(block) 

117 return sha.hexdigest() 

118 

119 

120def _resolve_backend(name: str) -> str: 

121 """The backend to use, refusing a named-but-missing one out loud.""" 

122 if name == "auto": 

123 for candidate in _AUTO_ORDER: 123 ↛ 126line 123 didn't jump to line 126 because the loop on line 123 didn't complete

124 if _available(candidate): 

125 return candidate 

126 raise FetchError("fetch: no usable backend (not even urllib?)") 

127 if name not in BACKENDS: 

128 options = ", ".join(BACKENDS) 

129 raise FetchError(f"fetch: unknown backend {name!r} — choose one of {options}") 

130 if not _available(name): 

131 if name == "curl": 

132 raise FetchError("fetch: backend 'curl' is not on PATH") 

133 raise FetchError( 

134 f"fetch: backend {name!r} is not installed — `pip install {name}`, " 

135 f"or leave [fetch] backend unset to use the stdlib" 

136 ) 

137 return name 

138 

139 

140def _available(name: str) -> bool: 

141 if name == "urllib": 

142 return True 

143 if name == "curl": 

144 return shutil.which("curl") is not None 

145 import importlib.util 

146 

147 return importlib.util.find_spec(name) is not None 

148 

149 

150def _scratch(near: Path, suffix: str = ".part") -> Path: 

151 """A unique scratch file beside *near*, for a download in flight. 

152 

153 Beside, so the finished body lands on its cache path with a rename on 

154 the same filesystem. Named `.part`, so the collector can sweep one 

155 whose process died before it got that far. 

156 """ 

157 fd, name = tempfile.mkstemp(dir=near.parent, prefix=f"{near.stem}.", suffix=suffix) 

158 os.close(fd) 

159 return Path(name) 

160 

161 

162def _download( 

163 backend: str, url: str, dest: Path, meta: dict[str, Any] 

164) -> tuple[bool, dict[str, Any]]: 

165 """Fetch *url* into *dest*; returns `(downloaded, validators)`. 

166 

167 Two separate facts that used to share one value: whether bytes moved 

168 (what the receipt reports), and the validators to persist for the next 

169 revalidation (what the manifest stores). A revalidation that comes back 

170 `304` moves no bytes and keeps the validators it already had, which 

171 under the shared value read as a fresh download of nothing. 

172 

173 *dest* is a scratch path, never the cache path — `fetch()` renames it 

174 into place, so every backend inherits the same atomic landing.""" 

175 if backend == "curl": 

176 return _download_curl(url, dest, meta) 

177 if backend in ("httpx", "requests"): 

178 return _download_lib(backend, url, dest, meta) 

179 return _download_urllib(url, dest, meta) 

180 

181 

182def _conditional_headers(meta: dict[str, Any]) -> dict[str, str]: 

183 headers = {} 

184 if etag := meta.get("etag"): 

185 headers["If-None-Match"] = str(etag) 

186 if modified := meta.get("last_modified"): 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true

187 headers["If-Modified-Since"] = str(modified) 

188 return headers 

189 

190 

191def _download_urllib( 

192 url: str, dest: Path, meta: dict[str, Any] 

193) -> tuple[bool, dict[str, Any]]: 

194 request = urllib.request.Request(url, headers=_conditional_headers(meta)) 

195 try: 

196 with urllib.request.urlopen(request) as response: 

197 total = int(response.headers.get("Content-Length") or 0) 

198 received = 0 

199 with open(dest, "wb") as fh: 

200 while chunk := response.read(CHUNK): 

201 fh.write(chunk) 

202 received += len(chunk) 

203 if total: 

204 context.progress(received, total) 

205 if total: 205 ↛ 207line 205 didn't jump to line 207 because the condition on line 205 was always true

206 context.progress(0, 0) # done reporting: back to the estimate 

207 if total and received != total: 

208 # A short body reads as a small one to everyone upstream, and 

209 # cached it is forever: the ETag off this same response goes 

210 # in the manifest, the healthy origin answers 304 from then 

211 # on, and the half file is served as a hit. CPython won't 

212 # raise here itself — HTTPResponse.read's own comment says it 

213 # "might break compatibility" — so the check is ours to make. 

214 raise FetchError( 

215 f"fetch: {url} — the body ended early: {received} of {total} bytes" 

216 ) 

217 return True, { 

218 "etag": response.headers.get("ETag"), 

219 "last_modified": response.headers.get("Last-Modified"), 

220 } 

221 except urllib.error.HTTPError as exc: 

222 if exc.code == 304: # not modified: the cached copy stands 

223 return False, {} 

224 raise FetchError(f"fetch: {url} — HTTP {exc.code} {exc.reason}") from exc 

225 except urllib.error.URLError as exc: 

226 raise FetchError( 

227 f"fetch: {url} — {exc.reason}. If this machine needs the system " 

228 f'curl (a corporate proxy or TLS store), set `backend = "curl"` ' 

229 f"under [fetch] — in this project, or once for every project in " 

230 f"{_paths.footman_config_file()}" 

231 ) from exc 

232 except (OSError, http.client.HTTPException) as exc: 

233 # A connection that dies mid-body, after the headers arrived. It has 

234 # to read as a FetchError or the cached copy never gets its say. 

235 raise FetchError(f"fetch: {url} — {exc}") from exc 

236 

237 

238def _download_curl( 

239 url: str, dest: Path, meta: dict[str, Any] 

240) -> tuple[bool, dict[str, Any]]: 

241 import subprocess 

242 

243 from footman import _globals 

244 

245 dump = _scratch(dest, suffix=".headers.part") 

246 argv = ["curl", "-fsSL", "--retry", "2", "-D", str(dump), "-o", str(dest), url] 

247 for header, value in _conditional_headers(meta).items(): 

248 argv += ["-H", f"{header}: {value}"] 

249 # This spawn is footman working on the body's behalf, so the Popen 

250 # injector must not attribute it to the task — the "prefer run()" note is 

251 # teach-once, and spending it here swallows the note a real raw spawn in 

252 # the same task would have earned. `internal()` stands the injector down; 

253 # cwd and env are passed explicitly so nothing changes with it gone: 

254 # every path on the command line is absolute (cwd is unobservable, pinned 

255 # to the cache directory anyway), and the environment is snapshotted 

256 # through the router *before* entering `internal()`, so a managed task's 

257 # curl sees the same proxy variables the urllib backend reads in-process. 

258 env = dict(os.environ) 

259 try: 

260 with _globals.internal(): 

261 done = subprocess.run( 

262 argv, capture_output=True, text=True, cwd=dest.parent, env=env 

263 ) 

264 if done.returncode != 0: 

265 # Exit 18 lives here too: curl counts the body against 

266 # Content-Length itself and calls a short one a failure. 

267 raise FetchError(f"fetch: {url} — curl: {done.stderr.strip()}") 

268 code, headers = _last_response(dump.read_text("utf-8", errors="replace")) 

269 finally: 

270 with contextlib.suppress(OSError): 

271 dump.unlink() 

272 if code == 304: # not modified: curl wrote nothing, the cached copy stands 

273 return False, {} 

274 return True, { 

275 "etag": headers.get("etag"), 

276 "last_modified": headers.get("last-modified"), 

277 } 

278 

279 

280def _last_response(dump: str) -> tuple[int, dict[str, str]]: 

281 """The status and headers of the final response in a curl `-D` dump. 

282 

283 Redirects and retries each append their own block; only the last one 

284 describes the bytes that landed in the file. 

285 """ 

286 code = 0 

287 headers: dict[str, str] = {} 

288 for line in dump.splitlines(): 

289 if line.upper().startswith("HTTP/"): 

290 parts = line.split() 

291 code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0 

292 headers = {} 

293 elif ":" in line: 

294 name, _, value = line.partition(":") 

295 headers[name.strip().lower()] = value.strip() 

296 return code, headers 

297 

298 

299def _download_lib( 

300 name: str, url: str, dest: Path, meta: dict[str, Any] 

301) -> tuple[bool, dict[str, Any]]: 

302 import importlib 

303 

304 client = importlib.import_module(name) 

305 # Each library's own error base, reached through the module object that is 

306 # already in hand. Naming them costs no import up here — which is the one 

307 # thing this file may not do — and `except Exception` was too much net: it 

308 # dressed footman's own bugs as network failures and then served a stale 

309 # cached copy on the strength of it. Both bases cover the transport 

310 # failures that matter (httpx's RemoteProtocolError, requests' 

311 # ChunkedEncodingError) and nothing else. 

312 transport_error: type[BaseException] = ( 

313 client.HTTPError if name == "httpx" else client.exceptions.RequestException 

314 ) 

315 try: 

316 response = ( 

317 client.get(url, headers=_conditional_headers(meta), follow_redirects=True) 

318 if name == "httpx" 

319 else client.get( 

320 url, headers=_conditional_headers(meta), allow_redirects=True 

321 ) 

322 ) 

323 if response.status_code == 304: 

324 return False, {} 

325 if response.status_code >= 400: 

326 raise FetchError(f"fetch: {url} — HTTP {response.status_code}") 

327 payload = response.content 

328 except FetchError: 

329 raise 

330 except transport_error as exc: 

331 # A dropped connection has to arrive as a FetchError, or the cached 

332 # copy never gets its say. 

333 raise FetchError(f"fetch: {url} — {type(exc).__name__}: {exc}") from exc 

334 dest.write_bytes(payload) 

335 return True, { 

336 "etag": response.headers.get("ETag"), 

337 "last_modified": response.headers.get("Last-Modified"), 

338 } 

339 

340 

341def fetch( 

342 url: str, 

343 *, 

344 into: Path | str | None = None, 

345 sha256: str = "", 

346 backend: str = "", 

347 refresh: bool = False, 

348) -> Path: 

349 """Download *url* (cached), returning the path to the local file. 

350 

351 A second call for the same URL revalidates with the server (ETag / 

352 Last-Modified) rather than re-downloading; a `304 Not Modified` 

353 costs one round trip and keeps "cached" honest. Pass *refresh* to 

354 skip revalidation and fetch unconditionally. 

355 

356 ```python 

357 @task 

358 def deps(): 

359 "Fetch the toolchain." 

360 archive = fetch(TOOLCHAIN_URL, sha256="9f86d0…") 

361 run(["tar", "-xzf", archive, "-C", "vendor"]) 

362 ``` 

363 

364 *into* copies the cached file to a path of your choosing (and 

365 returns that path). *sha256* verifies what arrived and refuses a 

366 mismatch — the way to make a build reproducible. *backend* overrides 

367 the configured one for this call. 

368 

369 Under `--dry-run` nothing is downloaded: the step is recorded and 

370 the would-be cache path returned, so a plan can be inspected safely. 

371 """ 

372 ctx = context.current() 

373 label = f"fetch {url}" 

374 manifest = _manifest_path(url) 

375 meta = _load_meta(manifest) 

376 body = _cached_body(url, meta) 

377 # `refresh` skips revalidation, not the cache: the download goes out 

378 # unconditional, but a cached copy keeps its say as the fallback when 

379 # that download dies — the documented offline behaviour. 

380 validators = {} if refresh else meta 

381 

382 if ctx.dry_run: 

383 ctx.steps.append(context.Result(0, command=label, raw=label)) 

384 if not ctx.quiet: 384 ↛ 388line 384 didn't jump to line 388 because the condition on line 384 was always true

385 print(f"$ {label}") 

386 # The cached path when there is one; otherwise the key's nominal 

387 # stem — a stable placeholder for the plan, never a real file. 

388 nominal = body or cache_dir() / f"{_key(url)}.bin" 

389 return Path(into) if into is not None else nominal 

390 

391 chosen = _resolve_backend(backend or _configured_backend(ctx)) 

392 started = time.perf_counter() 

393 cache_dir().mkdir(parents=True, exist_ok=True) 

394 incoming = _scratch(manifest) 

395 try: 

396 downloaded, fresh = _download( 

397 chosen, url, incoming, validators if body is not None else {} 

398 ) 

399 if downloaded: 

400 # The data file is content-addressed and immutable: a fresh 

401 # download lands under a fresh name, so no reader's path is 

402 # ever replaced underneath them — two cold fetches of one URL 

403 # publish the same name from the same bytes, and a changed 

404 # upstream publishes a different one. Only the manifest moves. 

405 digest = _digest(incoming) 

406 final = cache_dir() / _data_name(url, digest) 

407 if final.exists(): 

408 # Same content already published: the bytes are identical 

409 # by construction, so the part is a duplicate — and not 

410 # replacing means a Windows reader's open handle is safe. 

411 incoming.unlink() 

412 else: 

413 os.replace(incoming, final) 

414 body = final 

415 _publish( 

416 manifest, 

417 {"url": url, "data": final.name, "sha256": digest, **(fresh or {})}, 

418 ) 

419 except FetchError: 

420 if body is not None: # a cached copy beats a failed refresh 

421 _touch(manifest, body) 

422 _record(ctx, label, started, cached=True) 

423 return _deliver(body, _destination(into, body), sha256, url) 

424 raise 

425 finally: 

426 with contextlib.suppress(OSError): 

427 incoming.unlink(missing_ok=True) 

428 if body is None: 428 ↛ 429line 428 didn't jump to line 429 because the condition on line 428 was never true

429 raise FetchError(f"fetch: {url} — nothing downloaded and nothing cached") 

430 if not downloaded: 

431 if fresh: 431 ↛ 434line 431 didn't jump to line 434 because the condition on line 431 was never true

432 # A revalidation may hand back fresh validators for the same 

433 # bytes (a rotated ETag): same data file, updated manifest. 

434 _publish(manifest, {**meta, **fresh}) 

435 # A serve is a use: the collector's idle rule reads the manifest's 

436 # mtime, and a 304 writes nothing of its own — untouched, a 

437 # daily-fetched file would age out and force a pointless re-download. 

438 _touch(manifest, body) 

439 _record(ctx, label, started, cached=not downloaded) 

440 return _deliver(body, _destination(into, body), sha256, url) 

441 

442 

443def _destination(into: Path | str | None, body: Path) -> Path: 

444 return Path(into) if into is not None else body 

445 

446 

447def _publish(manifest: Path, data: dict[str, Any]) -> None: 

448 """The manifest, replaced whole — the layout's one swap. 

449 

450 Written beside and moved on, so a concurrent reader sees the old 

451 manifest or the new one, never a torn half; the file is small and no 

452 reader holds it open, so the swap is uncontended on every platform. 

453 """ 

454 scratch = _scratch(manifest, suffix=".json.part") 

455 scratch.write_text(json.dumps(data), encoding="utf-8") 

456 os.replace(scratch, manifest) 

457 

458 

459def _touch(*paths: Path) -> None: 

460 """Mark a cache serve as use, for the collector's idle rule.""" 

461 for path in paths: 

462 with contextlib.suppress(OSError): 

463 os.utime(path) 

464 

465 

466def _configured_backend(ctx: context.Context) -> str: 

467 """`[fetch] backend` from the config ladder, defaulting to urllib.""" 

468 return ctx.fetch_backend or "urllib" 

469 

470 

471def _deliver(body: Path, destination: Path, sha256: str, url: str) -> Path: 

472 if sha256: 

473 actual = _digest(body) 

474 if actual != sha256.lower(): 

475 raise FetchError( 

476 f"fetch: {url} — sha256 mismatch\n expected {sha256.lower()}\n" 

477 f" received {actual}" 

478 ) 

479 if destination != body: 

480 destination.parent.mkdir(parents=True, exist_ok=True) 

481 shutil.copyfile(body, destination) 

482 return destination 

483 

484 

485def _record(ctx: context.Context, label: str, started: float, *, cached: bool) -> None: 

486 """A fetch is a step: same grid, same --json entry, same recording().""" 

487 note = "cached" if cached else "" 

488 ctx.steps.append( 

489 context.Result( 

490 0, 

491 command=label, 

492 stdout=note, 

493 duration=time.perf_counter() - started, 

494 raw=label, 

495 ) 

496 )