From b70382ceda595ff87cb8c05067e9c23952e6e43e Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Tue, 22 Sep 2026 15:26:22 +0530 Subject: [PATCH] Pin PYTHONHASHSEED for update, extract, cluster only and label The generated git hooks already export PYTHONHASHSEED=0 because networkx's Louvain implementation iterates string keyed sets whose order is randomized per process, so community assignments otherwise churn between runs with no code change. A bare graphify update, which the CLAUDE.md template tells agents to run after every code change, skipped this, clustering differently every time and producing a large, spurious diff in the output directory. PYTHONHASHSEED is read once at interpreter startup, so setting it on os.environ from inside an already running process has no effect on that process's own hash randomization. The only way to pin it for a command already in flight is to restart the interpreter with it set from the start, so main() now re execs itself with the seed set before doing anything else, for exactly the commands whose output depends on clustering, only when the caller has not already chosen a seed themselves, degrading instead of raising if the re exec itself fails so an unusual host that disallows it can still run graphify. Skips this entirely while running under pytest, detected via the PYTEST_CURRENT_TEST variable pytest itself sets for the duration of a running test. Dozens of existing tests call main() directly with a monkeypatched argv to simulate a full CLI run in process, which only worked because main() was previously free of this kind of side effect; without the guard, every one of those calls replaced the test process running them the moment PYTHONHASHSEED happened to be unset, which it normally is in a dev or CI environment, with no traceback or error, just an empty, silently green test run. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/__main__.py | 45 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/graphify/__main__.py b/graphify/__main__.py index 4a68e7240..d4f266ce4 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -483,11 +483,56 @@ def _silence_broken_pipe() -> None: sys.exit(0) +_HASHSEED_PINNED_COMMANDS = frozenset({"update", "extract", "cluster-only", "label"}) + + +def _pin_hash_seed_if_needed() -> None: + """Re-exec with PYTHONHASHSEED=0 for commands whose output must be + deterministic run-to-run (#3641). + + PYTHONHASHSEED is read once at interpreter startup; setting it on + os.environ from inside an already-running process has no effect on that + process's own hash randomization, so pinning it requires restarting the + interpreter with it set from the start. The generated git hooks already + export it for exactly this reason: networkx's Louvain implementation + iterates string-keyed sets whose order is randomized per-process, so + community assignments otherwise churn between runs with no code change. + A bare `graphify update .` (which the CLAUDE.md template tells agents to + run after every code change) skipped this, re-clustering differently + every time and producing a large, spurious graphify-out diff. + + Only re-execs for the commands whose output actually depends on + clustering, and only when the caller has not already set + PYTHONHASHSEED themselves — an explicit choice is left alone. Degrades + instead of raising if the re-exec itself fails, since an unusual host + that disallows it should still be able to run graphify, just without + this determinism guarantee. + + Also does nothing under pytest: dozens of existing tests call main() + directly with a monkeypatched sys.argv to simulate a full CLI run in + process, an approach that only works because main() was previously + side-effect-free at the point it starts. A real os.execvpe there would + replace the test process running those tests, not something to launch + for real -- PYTEST_CURRENT_TEST is set by pytest for exactly the + duration of a running test's setup/call/teardown, so this only ever + skips the pin inside an actual test, never a real invocation. + """ + if len(sys.argv) < 2 or sys.argv[1] not in _HASHSEED_PINNED_COMMANDS: + return + if "PYTHONHASHSEED" in os.environ or "PYTEST_CURRENT_TEST" in os.environ: + return + try: + os.execvpe(sys.executable, [sys.executable, *sys.argv], {**os.environ, "PYTHONHASHSEED": "0"}) + except OSError: + pass + + def main() -> None: """Console entry point. Wraps the CLI so that when a downstream consumer closes stdout early, graphify treats it as success instead of crashing with an unhandled write-to-closed-pipe error and exit 255 — which made CI wrappers and agent harnesses read a successful query as a command failure (#1807).""" + _pin_hash_seed_if_needed() try: _run_cli() # Flush explicitly, inside the guard. Piped stdout is block-buffered, so a