Coverage for src/rhiza_hooks/_bundles_fetch.py: 100%
69 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 06:14 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 06:14 +0000
1#!/usr/bin/env python3
2"""Load and fetch template-bundles documents into a typed result.
4This module is responsible solely for *obtaining* a template-bundles document —
5from a local file, from already-fetched bytes, or from a remote GitHub
6repository — and returning it as a :class:`BundlesDoc`. Structural validation of
7the returned mapping lives in :mod:`rhiza_hooks._bundles_validate`.
8"""
10from __future__ import annotations
12import sys
13import time
14from contextlib import AbstractContextManager
15from dataclasses import dataclass
16from pathlib import Path
17from typing import Any, Protocol
18from urllib.error import HTTPError, URLError
19from urllib.request import urlopen
21import yaml
23from rhiza_hooks._yaml import YamlError, YamlFailure, load_yaml_mapping
25# Remote fetch is retried on transient network errors before giving up. Two
26# attempts = one initial try plus one retry, with a short linear backoff.
27# These are defaults; the CLI exposes `--retries` and `--timeout` to override.
28FETCH_ATTEMPTS = 2
29FETCH_BACKOFF_SECONDS = 1.0
30FETCH_TIMEOUT_SECONDS = 10.0
33class _Opener(Protocol):
34 """The ``urlopen``-shaped callable that performs a single HTTP GET.
36 Injected rather than reached for so tests can supply a fake without patching
37 this module's ``urlopen`` binding by name — a fetch is then exercised through
38 the same argument every caller uses.
39 """
41 def __call__(self, url: str, *, timeout: float) -> AbstractContextManager[Any]:
42 """Open ``url`` and return a response context manager exposing ``read()``."""
43 ...
46@dataclass(frozen=True)
47class BundlesDoc:
48 """Outcome of loading/parsing a template-bundles document.
50 ``data`` holds the parsed mapping on success and is ``None`` on failure;
51 ``errors`` carries the failure messages (empty on success). The two are
52 mutually exclusive, so callers branch on ``data is None`` — which also lets
53 the type checker narrow ``data`` to ``dict`` on the success path without a
54 cast.
55 """
57 data: dict[Any, Any] | None
58 errors: list[str]
61class Fetcher(Protocol):
62 """The :func:`fetch_remote_bundles`-shaped callable a validation run obtains its document from.
64 The same reasoning as :class:`_Opener`, one layer up. ``check_template_bundles``
65 injects this rather than reaching for the module global, so a test supplies a fake
66 document through the argument every caller uses instead of rebinding
67 ``check_template_bundles.fetch_remote_bundles`` by dotted name — a rebinding that
68 pins the wiring rather than the behaviour, and breaks on any rename.
70 Unprefixed, unlike :class:`_Opener`: this module's convention is that a leading
71 underscore marks a helper with no caller outside its own file, and this one is
72 named in another module's signatures.
73 """
75 def __call__(self, repo: str, branch: str, *, attempts: int, timeout: float) -> BundlesDoc:
76 """Fetch the template-bundles document for ``repo``/``branch``."""
77 ...
80def load_local_bundles(bundles_path: Path) -> BundlesDoc:
81 """Load and parse a local template-bundles file into a :class:`BundlesDoc`.
83 This is the local-file counterpart to :func:`fetch_remote_bundles` and part
84 of this module's cross-module surface — :mod:`rhiza_hooks._bundles_validate`
85 calls it to load a document before validating it.
86 """
87 result = load_yaml_mapping(bundles_path)
88 if not isinstance(result, YamlFailure):
89 return BundlesDoc(result, [])
91 messages = {
92 YamlError.NOT_FOUND: f"Template bundles file not found: {bundles_path}",
93 YamlError.INVALID: f"Invalid YAML: {result.detail}",
94 YamlError.EMPTY: "Template bundles file is empty",
95 YamlError.NOT_MAPPING: "Template bundles file must be a dictionary",
96 }
97 return BundlesDoc(None, [messages[result.kind]])
100def _parse_remote_bundles(content: bytes) -> BundlesDoc:
101 """Parse fetched template-bundles.yml content into a :class:`BundlesDoc`."""
102 try:
103 data = yaml.safe_load(content)
104 except yaml.YAMLError as e:
105 return BundlesDoc(None, [f"Invalid YAML in remote template bundles: {e}"])
107 if data is None:
108 return BundlesDoc(None, ["Remote template bundles file is empty"])
110 if not isinstance(data, dict):
111 return BundlesDoc(None, ["Remote template bundles must be a dictionary"])
113 return BundlesDoc(data, [])
116def _fetch_once(url: str, timeout: float, repo: str, branch: str, opener: _Opener) -> bytes | BundlesDoc | str:
117 """Perform a single fetch attempt with ``opener``.
119 Returns the raw response ``bytes`` on success; a :class:`BundlesDoc` carrying
120 a permanent HTTP error (e.g. 404) that must not be retried; or an error
121 ``str`` describing a transient network/timeout failure that may be retried.
122 """
123 try:
124 with opener(url, timeout=timeout) as response:
125 content: bytes = response.read()
126 except HTTPError as e:
127 if e.code == 404:
128 return BundlesDoc(None, [f"Template bundles file not found in repository {repo} (branch: {branch})"])
129 return BundlesDoc(None, [f"HTTP error fetching template bundles: {e.code} {e.reason}"])
130 except URLError as e:
131 return f"Error fetching template bundles from {url}: {e.reason}"
132 except TimeoutError:
133 return f"Timeout fetching template bundles from {url}"
134 return content
137def _log_failed_attempt(attempt: int, attempts: int, error: str, backoff: float) -> None:
138 """Log a failed fetch attempt, sleeping with linear backoff if retries remain.
140 Backoff grows linearly (backoff, 2*backoff, ...). The last attempt has
141 nowhere to retry, so it is logged without a backoff.
142 """
143 if attempt + 1 < attempts:
144 delay = backoff * (attempt + 1)
145 print(f" Attempt {attempt + 1}/{attempts} failed: {error}; retrying in {delay:.1f}s", file=sys.stderr)
146 time.sleep(delay)
147 else:
148 print(f" Attempt {attempt + 1}/{attempts} failed: {error}", file=sys.stderr)
151def fetch_remote_bundles(
152 repo: str,
153 branch: str,
154 attempts: int = FETCH_ATTEMPTS,
155 backoff: float = FETCH_BACKOFF_SECONDS,
156 timeout: float = FETCH_TIMEOUT_SECONDS,
157 opener: _Opener = urlopen,
158) -> BundlesDoc:
159 """Fetch template-bundles.yml from a remote GitHub repository.
161 Transient network failures (`URLError`/`TimeoutError`) are retried up to
162 ``attempts`` times with a linear backoff, and each failed attempt is logged
163 so CI failures are diagnosable. HTTP errors (e.g. 404) are permanent and
164 returned immediately without retrying.
166 Args:
167 repo: GitHub repository in 'owner/repo' format
168 branch: Branch name
169 attempts: Total number of fetch attempts (initial try + retries)
170 backoff: Base seconds to sleep between attempts (multiplied by attempt number)
171 timeout: Per-request socket timeout in seconds
172 opener: Performs one HTTP GET; defaults to :func:`urllib.request.urlopen`.
173 Only the https URL built below is ever passed to it — tests substitute a
174 fake instead of rebinding this module's ``urlopen``.
176 Returns:
177 A :class:`BundlesDoc` with the parsed mapping on success, or errors.
178 """
179 # The scheme and host are literal, and `repo`/`branch` interpolate only into the
180 # path that follows them, so this URL is https by construction. There used to be a
181 # `urlparse(url).scheme != "https"` guard here "for bandit B310" (#340): it could
182 # not fire for any argument, the only way to cover the line was a test that
183 # monkeypatched this module's `urlparse`, and a control that needs a patched parser
184 # to trigger asserts a safety property nobody is checking. If a caller-supplied URL
185 # is ever wanted, validate it *there*, where it can actually be untrusted.
186 #
187 # The `nosec B310` marker on the `opener` default above went with it, and for a
188 # related reason: B310 flags *calls* to urlopen, which this module never makes —
189 # every request goes through the injected `opener`, and `urlopen` appears only as
190 # that parameter's default value. So the marker was silencing a finding bandit does
191 # not raise. Confirmed by running the configured hook without it (`.bandit` skips
192 # B101 only, so B310 was live): bandit passes.
193 #
194 # Written without the leading `#` on purpose: bandit greps comments for that token
195 # and parses whatever follows as test ids, so spelling it in prose logs a dozen
196 # "Test in comment: ... is not a test name" warnings and quietly registers a
197 # suppression on this line.
198 url = f"https://raw.githubusercontent.com/{repo}/{branch}/.rhiza/template-bundles.yml"
200 # pragma below: equivalent mutant — the final `return BundlesDoc(None, errors)` is only
201 # reached after a transient-error iteration has reassigned `errors` (success and HTTP
202 # errors return early), so for attempts >= 1 this initial value is never the one returned.
203 errors: list[str] = [] # pragma: no mutate
204 for attempt in range(attempts):
205 outcome = _fetch_once(url, timeout, repo, branch, opener)
206 if isinstance(outcome, BundlesDoc):
207 return outcome # permanent HTTP error — do not retry
208 if isinstance(outcome, bytes):
209 return _parse_remote_bundles(outcome)
210 # Transient failure (network/timeout): record it, then back off and retry.
211 errors = [outcome]
212 _log_failed_attempt(attempt, attempts, outcome, backoff)
214 return BundlesDoc(None, errors)