Repository navigation
Typing: 173 mypy errors visible under newer pandas-stubs that CI's pinned lockfile does not see #1791
Description
Activity
Triage — and the premise is wrong in an interesting way
Short version: this is not a stub-pin problem. CI's type gate is switched off.
CI's own pinned stubs already see 140 errors
environment mypy pandas-stubs result CI, as it actually runs today 2.3.0 (uvx) none installed Success: no issues found in 324 source filesCI's own locked env ( requirements/test-py3.12.lock, run #30287083453 onf89e6378)2.1.0 3.0.3.260530 140 errors in 10 files test-rapids-official:26.02-gfql-polars1.19.1 3.0.0.260204 173 errors in 12 files CI's
pandas-stubsis newer than the container's (Mar 30 vs Feb 4 build), and it reports fewer errors. Bumping the pin is not the lever.Root cause:
bin/typecheck.shprefersuvx mypyif command -v uvx >/dev/null 2>&1; then MYPY_CMD="uvx mypy"
CI's install step is
python -m pip install --upgrade pip uv, which putsuvxon PATH.uvxruns mypy in an isolated ephemeral environment, so theuv pip install --require-hashes -r requirements/test-py3.12.lockinstall ofpandas-stubs/types-requests/types-tqdm/types-defusedxml, anduv pip install -e .of the project's own deps, are all invisible to it.mypy.inithen hasignore_missing_imports = Trueforpandas.*,numpy.*,pyarrow.*,requests.*, … so every external symbol degrades toAnyand 324 files pass vacuously.Two independent confirmations:
- The CI log installs
mypy==2.1.0from the lockfile, then the run printsmypy 2.3.0 (compiled: yes). Different interpreter, different mypy. mypy --no-site-packagesreproduces CI's output byte for byte, including the file count:Success: no issues found in 324 source files.
bin/lint.shhas the sameuvx ruffshape. Much less harmful — ruff needs no imports and still readspyproject.toml— but the locked ruff version is likewise bypassed, so a new ruff release can turn CI red with no repo change, and a lockfile ruff bump cannot fix it.cypher-frontend-strict-typingcalls baremypy(the venv's, with stubs), so that job is a genuine gate — but only overgfql/ir/*.py+cypher/binder.pywith--follow-imports=skip.
Triage table — 140 errors under CI's own pin
Pattern count, not error count. Four patterns explain 137 of 140.
# cluster bucket count evidence P1 polars API called on a DataFrameT-annotated valuenot a stub artifact — the repo's alias is the lie ~124 see below P2 hand-rolled ArrayLikeProtocol vs concrete numpymodelling gap (neither stub nor code bug) 6 traverse.py:165,167,193,195,209,engine_arrays.py:69B1 search_any.py:patbound to both a module and astrin one scopeB — real defect 6 genuine shadowed name B2 graphviz.py:row[g._node]where_nodeisOptional[str]B — real defect, reproducible 3 KeyError: NoneC1 search_any.pyparams typeddtype: objectannotation correctness ( objectis banned here)3 :25,:30A1 join.py:102,105—DataFrame.take(Series)A — genuine stub artifact 2 works at runtime, stub is stricter C2 cast()-induced declared-type narrowingsymptom of the banned cast1 reentry/execution.py:349P1 — the 124-error mega-cluster (88%)
compute/typing.pydeclares, underTYPE_CHECKINGonly:DataFrameT = pd.DataFrame
…as a stand-in for "whichever engine's frame". Every polars-only fast path is annotated
DataFrameTbut receives apl.DataFrame. Under stubs that actually model pandas, each polars call is an error. It decomposes into exactly three shapes:shape count why "Series[Any]" not callable[operator]49 .select×20,.unique×11,.len×5,.group_by×5,.cast×3,.lazy×2,.with_columns,.join. pandas-stubs typesDataFrame.__getattr__ -> Series[Any]for attribute-style column access, so any polars method name resolves to a Series and "calling" it errors."bool" not callable(traverse.py:163,191) is the same thing one type deeper:(s == v)→Series[bool],.fill_null→bool.Unexpected keyword argument "left_on"/"right_on" for "join"[call-arg]36 polars is join(other, left_on=, right_on=); pandasjointakeson=only (left_on/right_onaremerge).Argument "how" ... "Literal['semi']"[arg-type]27 how="semi"is polars.The stub is right and the annotation is wrong — the opposite of the guess in the issue body. pandas
DataFrame.joingenuinely has noleft_on;how="semi"genuinely does not exist in pandas or cuDF (cuDF spells it"leftsemi"onmerge). And every offending call site is inside a polars gate:if engine in POLARS_ENGINES,if engine in (Engine.POLARS, Engine.POLARS_GPU), or a function carrying# pragma: no cover - polars-only, covered by polars lane.So P1 is neither a stub bug nor a runtime bug — it is
DataFrameT = pd.DataFrameused where the value is provably polars. The fix is the real refactor: type the polars-only functions withpl.DataFrame/ thePolarsFrame/PolarsTaliases fromlazy/engine/polars/dtypes.py, exactly as #1789 did for the chain combine helpers. That also deletes the ~30 existingcast(DataFrameT, ...)calls in these files, which are on the banned list anyway. Not attempted here — it is a large, mechanical, separately-reviewable change.A related corroboration that error-code-pinned suppression rots:
Engine.py:920and:930already carry# type: ignore[attr-defined,no-any-return], written against some earlier stub set. Under real stubs the emitted code is[operator], so those ignores are silently dead and the errors come through anyway.B1 —
search_any.py, real shadowed name (6 errors)import pandas.api.types as pat # line 95 -> module ... or bool(pat.is_bool_dtype(dt))): # line 100 -> module ... pat, case = term, case_sensitive # line 110 -> str pred = Contains(pat, case=case, ...) # line 118
Exactly the shape predicted in the issue. Ordering saves it today — the module use strictly precedes the rebind — so it is a latent hazard, not a live failure, and I am not going to overclaim it. But it is one statement reorder (or one new dtype check after line 110) from
AttributeError: 'str' object has no attribute 'is_bool_dtype'. Fixed in #1792 by renaming the alias topd_types. No behaviour change, so no test; the mypy differential is the evidence.B2 —
graphviz.py, real reproducible defect (3 errors)g_to_pgvasserts_nodes/_edgesare set, but not that the bindings are.g.nodes(df)with nonode=leaves_nodeNone while_nodesis set — andlayout_graphviz/render_graphvizonly callmaterialize_nodes()when_nodesis None. So that graph sails through torow[None]:g = graphistry.edges(edf, 's', 'd').nodes(pd.DataFrame({'id': ['a','b']})) g._node = None g_to_pgv(g) -> KeyError : NoneSame for
g.edges(df)with no source/destination (_source/_destinationat:78,:79). Fixed in #1792 with an actionableValueError, plus two regression tests that fail on master.A1 — the one genuine stub artifact
join.py:102,105:Argument 1 to "take" of "NDFrame" has incompatible type "Series[Any]". Verified at runtime on pandas 2.3.3 —df.take(pd.Series([2,0]))returns[30, 10]fine. Stub is stricter than pandas. Two errors. This is the only cluster where "the stub is wrong" is the correct answer.
Recommendation on the CI stub pin
Do not bump
pandas-stubs. Stop bypassing it.setup.pyhasstubs = ['pandas-stubs', ...]unpinned, resolved per-Python-version bybin/generate-lockfiles.shunder a 6-day--exclude-newercooldown. That is a reasonable policy and it already lands on a current stub set. The pin is not the problem; nothing reads it.Suggested sequencing, since a straight fix flips CI from green to 128 errors:
- Make the feedback visible first. Add a
typecheck-with-stubsjob that runs the venv's mypy (python -m mypy --config-file mypy.ini graphistry/) against the same locked env, withcontinue-on-error: true. Zero risk, and the 128 stop being invisible today. - Drive it to zero, P1 first (~124 of 128 — one mechanical refactor across
gfql_fast_paths.py+chain_fast_paths.py, retyping polars-only helpers toPolarsFrame/PolarsTand deleting thecast(DataFrameT, ...)calls on the way). - Then fix the resolution order in
bin/typecheck.sh— prefer the active interpreter's mypy, fall back touvxonly when none is installed — and dropcontinue-on-error. Keepuvxas the fallback so local contributors without a venv still get a check; just never let it silently outrank the locked one. - Same reordering for
bin/lint.sh, so the locked ruff is what runs. Lower priority, but it is the same class of bug and it makes CI non-reproducible in the other direction. - Optional hardening:
warn_unused_ignores = Trueinmypy.inionce (2) lands, which would have caught the deadEngine.py:920ignores.
One more thing worth knowing about the lockfile job: its cache key is
hashFiles('setup.py', 'pyproject.toml', 'requirements/*.txt', 'requirements/*.in', 'bin/generate-lockfiles.sh'). Those change rarely, so the 6-day cooldown does not mean "stubs are at most 6 days old" — it means "at most 6 days older than the last time one of those files changed". Not a problem today, but it means the effective stub age drifts on an unrelated schedule.
PR
#1792 fixes B1 + B2 + C1 — 12 of the 140, zero suppressions, zero
Any/cast/getattr/setattr/object. mypy 140 → 128 under CI's own pin with zero new errors (error-set diff), ruff clean,graphistry/tests/compute --gpus allat9 failed / 7030 passed, identical to the master baseline.P1 (124) and P2 (6) are deliberately left — they want the real refactor described above, not suppression.
Reproducing any of this
docker run --rm --entrypoint bash \ -v <tree>:/opt/pygraphistry -e PYTHONPATH=/opt/pygraphistry -e PYTHONDONTWRITEBYTECODE=1 \ -w /opt/pygraphistry graphistry/test-rapids-official:26.02-gfql-polars -lc ' python3 -m venv /tmp/v /tmp/v/bin/pip -q install mypy==2.1.0 pandas-stubs==3.0.3.260530 \ types-requests types-defusedxml types-tqdm echo "--- CI\x27s locked env ---" /tmp/v/bin/mypy --cache-dir=/tmp/mc1 --config-file mypy.ini graphistry/ | tail -1 echo "--- what CI actually runs (uvx isolation) ---" /tmp/v/bin/mypy --no-site-packages --cache-dir=/tmp/mc2 --config-file mypy.ini graphistry/ | tail -1 '
--- CI's locked env --- Found 140 errors in 10 files (checked 324 source files) --- what CI actually runs (uvx isolation) --- Success: no issues found in 324 source files🤖 Generated with Claude Code
- The CI log installs
- added a commit that references this issue
on Jul 27, 2026
What
Running the repo's own mypy config inside
graphistry/test-rapids-official:26.02-gfql-polars(mypy 1.19.1) reports 173 errors across 324 files on masterf89e6378:graphistry/compute/gfql_fast_paths.pygraphistry/compute/chain_fast_paths.pygraphistry/compute/gfql/search_any.pygraphistry/compute/gfql/index/traverse.pygraphistry/Engine.pygraphistry/compute/gfql/index/engine_arrays.pygraphistry/compute/dataframe/join.pygraphistry/plugins/graphviz.pyBy code:
arg-type55,operator51,call-arg36,assignment15,union-attr6,index3.Representative shapes:
"Series[Any]" not callable;Unexpected keyword argument "left_on" for "join" of "DataFrame";Argument "how" to "join" has incompatible type Literal['semi'];No overload variant of "astype" of "Series" matches argument type "str".Why it matters
CI's
python-lint-typesis green on all 7 Python versions. The difference is stub versions: CI installs fromrequirements/test-py3.12.lockwith--require-hashes, the container has its own newerpandas-stubs. So the gate is only as good as the pinned stub, and a whole class of typing feedback is invisible to it.Two possibilities, and they need different fixes:
how="semi"is a cuDF/legit extension the pandas stub doesn't model). Fix: targeted, documented per-site ignores, or stub-version-aware config.Series[Any] not callablecan indicate a genuine shadowed-name bug. Fix: fix the code.Almost certainly a mix. The first task is deciding which bucket each cluster falls in — not bulk-suppressing them.
Constraints on any fix
Per repo convention, these are explicitly not solutions:
Any,cast,getattr,setattr, barelist,Dict[str, Any],object. Use engine-agnostic aliases (graphistry/compute/gfql/lazy/engine/polars/dtypes.pyhas the pattern:PolarsFrame,PolarsT, andis_lazy -> TypeIs[pl.LazyFrame]), real generics, or a real refactor.Suggested sequencing
gfql_fast_paths.pyfirst — it is 62% of the total and likely a handful of repeated patterns.pandas-stubsso this feedback is not invisible; that is arguably the highest-value part of the issue.Found while verifying that PR #1789's typing change added zero errors — it did (173 on both trees, diff-identical error sets), which is how the pre-existing 173 surfaced.
🤖 Generated with Claude Code
https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB