Put mypy on the whole tree, and stop the suite phoning a real daemon - #415
Merged
Conversation
PR #413 fixed one test that opened a TCP connection to 127.0.0.1:11434 and noted nine other files that set OLLAMA_MODELS the same way but had not been checked. Instrumenting socket.connect across a full run answered it: the suite made 102 outbound connects to that port. 46x _get_ollama_capabilities <- ollama_supports_tools 46x _get_ollama_capabilities <- ollama_supports_embedding 10x _get_ollama_context_window <- _ollama_effective_context_window #413 only closed the third one, and only inside test_providers.py. Two things made the other 92 expensive rather than merely wrong. Each attempt costs a ~2s TCP connect on a machine with nothing listening. And _get_ollama_capabilities deliberately does not cache a probe failure - correct in production, where a daemon may simply have been restarting, but it means every check pays that cost again rather than once. It was also a quiet correctness problem: a developer running Ollama locally got different answers out of these three functions than CI did, from the same code, and nothing in the suite would have shown it. backend/tests/conftest.py now makes ollama.show raise, next to the existing _never_touch_the_real_user_data_dir fixture and for the same reason. That is the same "daemon unavailable" outcome the failed connect produced, minus the socket. Patched on the real module rather than replacing api_provider.ollama wholesale, because other code reaches through that module object for ollama.chat; tests that want their own show() (test_context_budget.py) override it and still win. backend/tests/test_no_network_in_tests.py asserts the fixture is working, phrased as "no socket was opened" rather than "show raised" so it keeps holding if the probing moves to another client or another function. It also pins that the stub still answers "unavailable" rather than fabricating a capability set, and that a test can still supply its own show(). Verified: with the fixture removed, all three socket assertions fail. With it, a full run makes ZERO outbound connects - the only remaining socket.connect calls in the suite are asyncio's own Windows self-pipe. The suite also got faster, which was not the point but is worth recording: 103.2s to 84.0s, about 19%. Test plan: full suite, 3,285 passed / 20 skipped. ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last of the type sweep. `mypy backend` went from 179 errors to zero, and [tool.mypy].files - four files when this audit started - now lists every Python root in the repo: 339 source files, checked in CI. WHAT WAS ACTUALLY WRONG, because "179 type errors" is not a description: api_provider.py held 32 of them and was the module worth the most care. Four `base_url: str = None` signatures that PEP 484 has prohibited since 2018. Three module-level caches with no declared shape - one of which, _LLAMA_CPP_CLIENT_CACHE, I first annotated dict[str, Any] and mypy rejected, because llama_cpp_runtime keys it by a five-tuple. LLAMA_CPP_ SETTINGS inferred as dict[str, object] from its mixed literal, which made every .get() on it unusable at the far end and accounted for most of provider_runtime's errors too. And three "Missing return statement" reports that were all one thing: _translate_chat_exception's docstring says "Always raises ... never returns normally", which is exactly what NoReturn is for. A REAL BUG, found by the types and fixed here. backend/harness/ transcript.py did `from graphlink_version import __version__`. There is no __version__ - graphlink_version.py holds one line, APP_VERSION - and the name appears nowhere else in the repo. The import raised ImportError on every call, the surrounding `except Exception` swallowed it, and every harness transcript ever written recorded "app": "" instead of the version that field exists to record. about.py, diagnostic_bundle.py and workspace_archive.py all import the right name. Verified before and after: the meta record now says v1.0.8. THE ONE COERCION, called out because it is the only line here that changes what runs: AnthropicProvider and GeminiProvider take api_key: str, and the snapshot's api_key is str | None - genuinely, since API_KEY starts as None and only initialize_api sets it. Eight call sites now pass `state.api_key or ""`. Both providers store the value verbatim and send it as a header, so None and "" both end in an auth failure; "" is the one the type system can describe, and it fails the same way rather than as a TypeError inside the SDK. Everything else is annotations, renamed locals that had been reused at two different types, and containers that never said what they held. 28 `# type: ignore` comments were added and every one carries the invariant it rests on - sqlite's cursor.lastrowid being Optional only before an INSERT, the optional-dependency `x = None` fallback, duck-typed attributes whose declared home is a subclass. I checked each; the reasoning holds in all 28. backend/knowledge_embeddings.py is worth singling out for what was NOT done: embed() lives on two of the five providers, so widening the shared Provider protocol would have broken the other three. It gets a local two-line protocol and a cast instead, guarded - as it already was - by an explicit `if not provider.capabilities.embedding: raise` before each call. [tool.mypy] also gains follow_imports = "silent". Without it, listing a clean module drags its whole import closure into the gate; with everything listed it is belt and braces for whatever is added next. Verified the gate still bites by injecting a deliberate type error into five different modules - api_provider, session_save, seed_demo_graph, graphlink_desktop, provider_runtime/reasoning - and it was caught every time. Test plan: full suite, 3,285 passed / 20 skipped. ruff clean. mypy clean across 339 source files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Two things, both found while finishing the type sweep.
The test suite made real network calls. PR #413 fixed one test that opened a
TCP connection to
127.0.0.1:11434and noted nine other files that setOLLAMA_MODELSthe same way but had not been checked. Instrumentingsocket.connectacross a full run answered it — the suite made 102 outboundconnects to that port:
#413 had closed only the third, and only inside one file.
179 mypy errors remained, across 47 files.
Change
The network
Each connect costs ~2s on a machine with nothing listening, and
_get_ollama_capabilitiesdeliberately does not cache a probe failure — correctin production, where a daemon may be restarting, but it means every check pays
again. It was also a quiet correctness problem: a developer running Ollama
locally got different answers out of these three functions than CI did, from the
same code.
backend/tests/conftest.pynow makesollama.showraise, beside the existing_never_touch_the_real_user_data_dirfixture and for the same reason.test_no_network_in_tests.pyasserts it, phrased as "no socket was opened" so itkeeps holding if the probing moves. A full run now makes zero outbound
connects — the only remaining
socket.connectcalls are asyncio's Windowsself-pipe. Verified the gate is not vacuous: with the fixture removed, all three
socket assertions fail.
The suite also got ~19% faster (103.2s → 84.0s), which was not the point.
The types
mypynow covers 339 source files, up from 4 when this audit started.api_provider.pyheld 32 errors and deserved the most care. Fourbase_url: str = Nonesignatures. Three module-level caches with no declaredshape — one of which I first annotated
dict[str, Any]and mypy rejected,because
llama_cpp_runtimekeys it by a five-tuple.LLAMA_CPP_SETTINGSinferring as
dict[str, object]from its mixed literal, which made every.get()unusable at the far end and accounted for most of
provider_runtime's errorstoo. And three "Missing return statement" reports that were one thing:
_translate_chat_exception's docstring says "Always raises … never returnsnormally" — which is what
NoReturnis for.A real bug, fixed
backend/harness/transcript.pydidfrom graphlink_version import __version__.There is no
__version__—graphlink_version.pyholds one line,APP_VERSION—and the name appears nowhere else in the repo. The import raised
ImportErroron every call, the surrounding
except Exceptionswallowed it, and everyharness transcript ever written recorded
"app": ""instead of the versionthat field exists to record.
about.py,diagnostic_bundle.pyandworkspace_archive.pyall import the right name. Verified before and after: themeta record now says
v1.0.8.The one coercion
AnthropicProviderandGeminiProvidertakeapi_key: str; the snapshot's isstr | None— genuinely, sinceAPI_KEYstarts asNoneand onlyinitialize_apisets it. Eight call sites now passstate.api_key or "". Bothproviders store the value verbatim and send it as a header, so
Noneand""both end in an auth failure;
""is the one the type system can describe, and itfails the same way rather than as a
TypeErrorinside the SDK.Everything else is annotations, renamed locals that had been reused at two
different types, and containers that never said what they held.
On the 28
# type: ignoresEvery one carries the invariant it rests on — sqlite's
cursor.lastrowidbeingOptional only before an INSERT, the optional-dependency
x = Nonefallback,duck-typed attributes whose declared home is a subclass. I read all 28 and
verified the reasoning, including running
reasoning_budget_hintto confirm theone that claimed
"low"and"high"can never returnNone.backend/knowledge_embeddings.pyis worth singling out for what was notdone:
embed()lives on two of the five providers, so widening the sharedProviderprotocol would have broken the other three. It gets a local two-lineprotocol and a cast instead — guarded, as it already was, by an explicit
if not provider.capabilities.embedding: raisebefore each call.Gate integrity
[tool.mypy]gainsfollow_imports = "silent"; without it, listing a cleanmodule drags its whole import closure into the gate. Verified this does not
neuter it by injecting a deliberate type error into five different modules —
api_provider,session_save,seed_demo_graph,graphlink_desktop,provider_runtime/reasoning— caught every time.Result
mypyerrorsTest plan
ruff check .clean.mypyclean across 339 source files.🤖 Generated with Claude Code