| 1 | #!/usr/bin/env python3 |
| 2 | """Forbid bare reqwest client constructors outside crates/release. |
| 3 | |
| 4 | The workspace builds reqwest with `rustls-no-provider`, so a bare |
| 5 | `reqwest::Client::builder()` (or `::new()`, or the blocking counterparts) |
| 6 | panics in `default_rustls_crypto_provider` whenever it runs before any other |
| 7 | client has installed the crypto provider. Every client must therefore go |
| 8 | through `codewhale_release::tls` (`reqwest_client_builder()`, |
| 9 | `reqwest_blocking_client_builder()`, `reqwest_client()`), which installs the |
| 10 | provider exactly once before the first build. The only sanctioned bare |
| 11 | constructors are the platform builders in `crates/release/src/`. |
| 12 | |
| 13 | See #6153 (0.9.13 shipped this panic on the first-run Ollama probe). |
| 14 | """ |
| 15 | |
| 16 | from __future__ import annotations |
| 17 | |
| 18 | import re |
| 19 | import sys |
| 20 | from pathlib import Path |
| 21 | |
| 22 | ROOT = Path(__file__).resolve().parents[1] |
| 23 | CRATES = ROOT / "crates" |
| 24 | # The TLS-owner crate: its platform builders are the sanctioned constructors. |
| 25 | ALLOWED_PREFIX = CRATES / "release" / "src" |
| 26 | |
| 27 | # Fully-qualified bare constructors. Always a violation outside ALLOWED_PREFIX. |
| 28 | QUALIFIED_RE = re.compile( |
| 29 | r"reqwest::(?:blocking::)?Client::(?:builder|new)\s*\(" |
| 30 | ) |
| 31 | # Bare `Client::builder()` / `Client::new()` spellings. Only a violation in a |
| 32 | # file that imports reqwest's Client, so custom `FooClient` types never match |
| 33 | # (`(?<![\w:])` also keeps `FixedSummaryClient::default()`-style hits out). |
| 34 | BARE_RE = re.compile(r"(?<![\w:])Client::(?:builder|new)\s*\(") |
| 35 | REQWEST_CLIENT_IMPORT_RE = re.compile(r"use\s+reqwest::[^\n;]*\bClient\b") |
| 36 | |
| 37 | |
| 38 | def _is_comment_only(line: str) -> bool: |
| 39 | stripped = line.lstrip() |
| 40 | return stripped.startswith("//") |
| 41 | |
| 42 | |
| 43 | def file_violations(path: Path, allowed_prefix: Path = ALLOWED_PREFIX) -> list[str]: |
| 44 | """Return `path:line: <match>` entries for bare constructors in one file.""" |
| 45 | try: |
| 46 | text = path.read_text(encoding="utf-8") |
| 47 | except (OSError, UnicodeDecodeError): |
| 48 | return [] |
| 49 | try: |
| 50 | rel = path.relative_to(ROOT) |
| 51 | except ValueError: |
| 52 | rel = path |
| 53 | if allowed_prefix in path.parents or path == allowed_prefix: |
| 54 | return [] |
| 55 | hits: list[str] = [] |
| 56 | bare_allowed = REQWEST_CLIENT_IMPORT_RE.search(text) is None |
| 57 | for lineno, line in enumerate(text.splitlines(), start=1): |
| 58 | if _is_comment_only(line): |
| 59 | continue |
| 60 | match = QUALIFIED_RE.search(line) |
| 61 | if match is None and not bare_allowed: |
| 62 | match = BARE_RE.search(line) |
| 63 | if match is not None: |
| 64 | hits.append(f"{rel}:{lineno}: {match.group(0)}") |
| 65 | return hits |
| 66 | |
| 67 | |
| 68 | def find_violations( |
| 69 | root: Path = CRATES, allowed_prefix: Path = ALLOWED_PREFIX |
| 70 | ) -> list[str]: |
| 71 | """Walk `root/**/*.rs` and collect every bare-constructor violation.""" |
| 72 | violations: list[str] = [] |
| 73 | for path in sorted(root.rglob("*.rs")): |
| 74 | violations.extend(file_violations(path, allowed_prefix)) |
| 75 | return violations |
| 76 | |
| 77 | |
| 78 | def main() -> int: |
| 79 | violations = find_violations() |
| 80 | if violations: |
| 81 | print( |
| 82 | "bare reqwest client constructor(s) outside crates/release/src " |
| 83 | "(use codewhale_release::tls instead; see #6153):", |
| 84 | file=sys.stderr, |
| 85 | ) |
| 86 | for violation in violations: |
| 87 | print(f" {violation}", file=sys.stderr) |
| 88 | return 1 |
| 89 | print("reqwest builder check OK: no bare Client::builder()/new() outside crates/release/src.") |
| 90 | return 0 |
| 91 | |
| 92 | |
| 93 | if __name__ == "__main__": |
| 94 | sys.exit(main()) |
| 95 |