| 1 | // Finding the Python the third-party oracles run under. |
| 2 | // |
| 3 | // The oracles are what stop the QR writer and the PDF writer being checked |
| 4 | // only against themselves: zxing-cpp decodes what we drew, python-qrcode |
| 5 | // draws what we drew and we compare matrices, PyMuPDF and pypdf read the |
| 6 | // documents back. An encoder tested against its own decoder proves the two |
| 7 | // agree and nothing else. |
| 8 | // |
| 9 | // They need python3 with numpy, zxing-cpp, qrcode, pillow, pymupdf and |
| 10 | // pypdf. A machine — or a shared CI runner — may not have those, and the |
| 11 | // failure was a raw ModuleNotFoundError traceback that said nothing about |
| 12 | // what to do. This resolves the interpreter and turns a missing dependency |
| 13 | // into an instruction. |
| 14 | // |
| 15 | // Order: $CONTRA_PYTHON, then .venv/bin/python3 if `make oracles` has been |
| 16 | // run, then python3. So a developer whose system already has the packages |
| 17 | // needs no venv, and CI gets a reproducible one without changing the box. |
| 18 | |
| 19 | import { execFileSync } from "node:child_process"; |
| 20 | import { existsSync } from "node:fs"; |
| 21 | import { fileURLToPath } from "node:url"; |
| 22 | import { dirname, join } from "node:path"; |
| 23 | |
| 24 | const here = dirname(fileURLToPath(import.meta.url)); |
| 25 | const repo = join(here, ".."); |
| 26 | |
| 27 | export function oraclePython() { |
| 28 | if (process.env.CONTRA_PYTHON) return process.env.CONTRA_PYTHON; |
| 29 | const venv = join(repo, ".venv", "bin", "python3"); |
| 30 | if (existsSync(venv)) return venv; |
| 31 | return "python3"; |
| 32 | } |
| 33 | |
| 34 | // oracleEnv adds .oracles/ to PYTHONPATH when `make oracles` had to fall back |
| 35 | // to `pip --target` — which it does on a python3 with no ensurepip, where a |
| 36 | // venv cannot be created at all. Harmless when the directory is absent. |
| 37 | function oracleEnv() { |
| 38 | const target = join(repo, ".oracles"); |
| 39 | if (!existsSync(target)) return process.env; |
| 40 | const existing = process.env.PYTHONPATH; |
| 41 | return { |
| 42 | ...process.env, |
| 43 | PYTHONPATH: existing ? `${target}:${existing}` : target, |
| 44 | }; |
| 45 | } |
| 46 | |
| 47 | // run executes one oracle and returns its stdout. |
| 48 | // |
| 49 | // It does NOT skip when the packages are missing. A skipped oracle is |
| 50 | // indistinguishable from a passing one, and these guard the two documents a |
| 51 | // person's whole account depends on being able to read back — that is |
| 52 | // exactly the class of check this repo refuses to let degrade silently. |
| 53 | // What it does is fail with the command that fixes it. |
| 54 | export function run(script, input) { |
| 55 | const python = oraclePython(); |
| 56 | try { |
| 57 | return execFileSync(python, [join(here, script)], { |
| 58 | input: JSON.stringify(input), |
| 59 | maxBuffer: 64 << 20, |
| 60 | env: oracleEnv(), |
| 61 | }).toString(); |
| 62 | } catch (err) { |
| 63 | const text = String(err.stderr || err.message || ""); |
| 64 | if (/ModuleNotFoundError|No module named|ENOENT/.test(text)) { |
| 65 | throw new Error( |
| 66 | `the ${script} oracle cannot run: its Python packages are missing.\n` + |
| 67 | ` Interpreter tried: ${python}\n` + |
| 68 | ` Fix it with: make oracles\n` + |
| 69 | ` (or set CONTRA_PYTHON to an interpreter that has numpy, zxing-cpp,\n` + |
| 70 | ` qrcode, pillow, pymupdf and pypdf)\n\n` + text); |
| 71 | } |
| 72 | throw err; |
| 73 | } |
| 74 | } |
| 75 | |