Skip to content

refactor(typing): canonical polars frame vocabulary + is_polars_df TypeGuard (Wave 0) - #1799

Open
lmeyerov wants to merge 4 commits into
masterfrom
typing/wave0-polars-frame
Open

refactor(typing): canonical polars frame vocabulary + is_polars_df TypeGuard (Wave 0)#1799
lmeyerov wants to merge 4 commits into
masterfrom
typing/wave0-polars-frame

Conversation

@lmeyerov

Copy link
Copy Markdown
Contributor

Typing Wave 0 (W0.1 + W0.2). Typing only — no runtime behaviour change, no public API change. W0.3 (the bin/typecheck.sh / lockfile / ci.yml gate fix) is deliberately NOT in this PR.

Reproduced baseline (exact commands, origin/master = 233b64c8)

Three environments, same tree:

lane command result
what CI runs today (uvx mypy, isolated — no pandas-stubs, no polars) MYPY_CMD="uvx mypy" ./bin/mypy.sh Success: no issues found in 325 source files
stubs visible, polars not installed mypy --config-file mypy.ini graphistry in a venv with mypy pandas-stubs types-defusedxml types-requests types-tqdm 128 errors in 8 files
stubs visible + polars (1.43.1) same, plus polars 163 errors in 11 files

All three reproduced exactly (mypy 2.3.0, py3.13).

What changed

W0.1 — graphistry/compute/typing.py gains a canonical engine-frame vocabulary: PolarsFrame (pl.DataFrame | pl.LazyFrame), a constrained PolarsT TypeVar, and PolarsSeriesT. Defined TYPE_CHECKING-only (the shape already proven in polars/dtypes.py), so polars stays an optional dependency and no runtime import or typing-extensions version floor is introduced. polars/dtypes.py, which had defined PolarsFrame/PolarsT first, now re-exports them so there is ONE definition.

DataFrameT is not widened. Turning it into a cross-engine union fans out across all 325 checked modules; the fix for a polars-only helper is a NEW name, not a blurred old one.

W0.2 — graphistry/Engine.py: is_polars_df(df: Any) -> bool becomes is_polars_df(df: object) -> TypeGuard["PolarsFrame"].

TypeGuard, not TypeIs (PEP 742) — and this reasoning should survive review: TypeIs additionally narrows the negative branch, and to do that soundly it requires the narrowed type to be consistent with the declared input type. These call sites declare DataFrameT/SeriesT (pandas at checking time), and a polars frame is not a subtype of a pandas one, so TypeIs is rejected outright. That is exactly what distinguishes this from dtypes.is_lazy (whose input is already a PolarsFrame, so TypeIs is correct there) and from #1789.

A companion is_polars_series — the identical import-light module check — narrows the SERIES call sites to pl.Series. Without it the frame guard would promise .height/.columns on a value that is a column.

Dead ignores removed. The six per-branch # type: ignore[attr-defined,no-any-return] comments in Engine.py's dispatch block were written against an older stub set; the real codes are [operator]/[return-value], so those ignores were suppressing nothing. They are now scoped to the one thing that genuinely cannot typecheck: the polars value flowing out of a DataFrameT/SeriesT signature. Two more (frame_ops.count_table) are deleted outright — the guard now narrows, so collect_schema()/select() typecheck on their own.

PolarsT over-constraint. _pl_nan_to_null now takes/returns the PolarsFrame union rather than the constrained TypeVar, which a union argument cannot bind. An @overload set would keep per-flavour precision, and I tried it — it is unusable here: on the polars-less type-lint lane every signature collapses to Any -> Any and mypy rejects the set as unmatchable (overload-cannot-match), which turned the currently-green isolated CI lane red. Recorded in the docstring so it is not re-attempted.

Two rebindings restructured (gfql/row/frame_ops.py:rows, gfql_unified.py optional-match seed): they mixed a polars result into a pandas-typed local, which widened the variable and broke the pandas branch below (TypeGuard narrows only the positive branch). Both now return / branch directly — same calls, same arguments, same results.

Re-measured residual — and the honest finding

lane before after
isolated (CI today) no issues, 325 files no issues, 325 files (unchanged)
stubs + polars 163 in 11 files 157 in 9 files
stubs, no polars 128 in 8 files 124 in 6 files

The payoff is 6, not the projected ~24 — and the projection's premise was wrong, not merely optimistic. BW4 expected ~24 errors to evaporate across Engine.py, gfql/index/traverse.py, gfql/index/engine_arrays.py, dataframe/join.py, gfql_fast_paths.py and gfql_unified.py with no per-site edit. Measured: four of those six files never call is_polars_df at all. traverse.py (8), engine_arrays.py (4) and dataframe/join.py (4) have zero call sites; gfql_fast_paths.py calls it twice, for a plain boolean, and none of its 108 errors touch it. Their error counts are byte-for-byte unchanged by this PR, because those errors come from unguarded polars-only helpers annotated DataFrameT — Wave 1 per-file work, which W0.1's new vocabulary now exists to serve. The narrowing only ever reached Engine.py (5) and gfql_unified.py (1).

There is also a second-order effect worth naming: Plottable._edges/._nodes are declared Any, so the TypeGuard narrowed previously-unchecked Any and surfaced 9 new errors in ComputeMixin.py, frame_ops.py, row_pipeline.py and gfql_unified.py that master never reported. All are resolved here (that is why the intermediate measurement went 163 → 179 before coming back down to 157). This is more checking, not less, but it means Wave 1's sizing should assume narrowing reveals work as well as removing it.

Verification

  • bin/lint.sh — clean.
  • Full suite, py3.11 + polars 1.43.1: 7408 passed, 905 skipped, 20 xfailed, 3 failed. All 3 failures reproduce identically on 233b64c8 with the same env (test_explicit_polars_gpu_declines_indexed_helper_and_falls_back, two test_case_regex_unicode_trick_matrix params) — pre-existing, not caused by this change.
  • Runtime smoke: both guards, df_to_engine(pa.Table -> POLARS), and a polars chain/gfql round trip.
  • --warn-unused-ignores diffed against baseline: no newly-dead ignores introduced (the only deltas are line shifts of pre-existing ones); 7 dead ignores retired.

Untouched, per scope: bin/, lockfiles, workflows, and gfql_fast_paths.py.

🤖 Generated with Claude Code

https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB

lmeyerov added a commit that referenced this pull request Jul 28, 2026
…erywhere (#1805)

* test(gfql): make the polars lane see the 280 tests it was skipping everywhere

polars is installed in exactly ONE CI lane (test-polars, driven by bin/test-polars.sh),
and that lane runs a hand-maintained file list. Every other lane collection-skips a
module-level `pytest.importorskip("polars")` and every polars/polars-gpu parameter. A
polars-gated test the list omits therefore runs NOWHERE while both lanes report green.

Measured on run 30311675717 (PR #1799): 280 tests executed locally with polars installed
and appear in ZERO job logs of that run, including six whole modules — four of them named
`test_engine_polars_*`. Two real defects were sitting in the blind spot:

  - `test_explicit_polars_gpu_declines_indexed_helper_and_falls_back` executes
    engine='polars-gpu' but only gates on `importorskip("polars")`, so it raises
    ImportError wherever polars is present without the RAPIDS cudf_polars stack. It has
    never once run: 9 SKIPPED, 0 executed, across the whole run.
  - `test_case_regex_unicode_trick_matrix[toupper-eq-ss-fold]` and
    `[tolower-turkish-dotted-i]` catch a genuine silent cross-engine divergence: under
    pandas>=3 the pandas engine answers [9] where polars answers [8, 9] for
    `toUpper(n.name) = 'STRASSE'`. Filed as #1802 with the repro; both rows are marked
    xfail(strict=True) keyed on the pandas accessor's own behaviour, so the fix XPASSes
    and retires the marks.

Changes:
  - bin/test-polars.sh gains the six invisible modules plus the four whose polars
    parameters were invisible.
  - New `test_polars_lane_completeness.py` parses POLARS_TEST_FILES and fails when a
    polars-mentioning test module is neither in the lane nor in a documented exemption,
    when a module-level polars gate sits outside the lane, when a listed path is gone, or
    when a polars-gated test in the `-k polars` phase is unselectable by that filter.
    Mutation-checked: dropping one entry turns two of its assertions red.
  - Split the polars-gpu test so the CPU half (helper must decline) runs wherever polars
    does, and the execution half skips honestly without cudf_polars.
  - Renamed the two test_lowering.py polars tests `-k polars` could not select.

Test-only; no product code touched, so no CHANGELOG entry.

Widened lane, py3.11 + polars 1.43.1 + pandas 3.0.5: 2726 passed, 142 skipped, 3 xfailed,
0 failed (was 1636 passed on the same box). ORDERING NOTE: #1797 records that the py3.12
coverage cell is already at its 10-minute ceiling and that adding a single file tipped it
into a timeout; the pending ci.yml split (comment on PR #1794) must land BEFORE this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB

* test(gfql): granular AST types in the polars-lane completeness guard

Replace the getattr/ast.AST pairing in the guard's two helpers with the concrete node
types they are actually called with: _node_source takes ast.stmt | ast.expr and reads
.lineno/.end_lineno directly (end_lineno is Optional[int], so the None case is handled
rather than defaulted through getattr), and _selected_by_k_polars takes a FuncDef alias
so .name and .decorator_list are checked attributes instead of getattr lookups with
invented fallbacks. No behaviour change; guard still 8 passed, ruff clean, mypy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…peGuard

Typing Wave 0. No runtime behaviour change, no public API change.

W0.1 -- graphistry/compute/typing.py gains PolarsFrame (pl.DataFrame |
pl.LazyFrame), a constrained PolarsT TypeVar and PolarsSeriesT, defined
TYPE_CHECKING-only so polars stays optional and no runtime import or
typing-extensions floor is added. polars/dtypes.py, which had defined
PolarsFrame/PolarsT first, now re-exports them so there is ONE definition.
DataFrameT is deliberately NOT widened: turning it into a cross-engine union
fans out across all 325 checked modules. The fix for a polars-only helper is
a new name, not a blurred old one.

W0.2 -- Engine.is_polars_df becomes
    (df: object) -> TypeGuard["PolarsFrame"]
so the polars branch of an engine-dispatching helper is CHECKED against the
polars API instead of silently accepted. TypeGuard, NOT TypeIs (PEP 742):
TypeIs also narrows the negative branch, and to do that soundly it requires
the narrowed type to be consistent with the declared input type -- a polars
frame is not a subtype of the pandas type these call sites declare. (That is
the difference from dtypes.is_lazy, whose input is already a PolarsFrame.)
A companion is_polars_series -- same import-light module check -- narrows the
SERIES call sites to pl.Series, which the frame guard would have mistyped.

Fallout, all typing-only:
- The six per-branch ignores in Engine.py's dispatch block were written
  against an older stub set and named codes (attr-defined, no-any-return)
  that no longer applied: they were DEAD, suppressing nothing. They are now
  scoped to the one thing that genuinely cannot typecheck -- the polars value
  flowing out of a DataFrameT/SeriesT signature.
- _pl_nan_to_null takes the PolarsFrame union instead of the constrained
  PolarsT TypeVar, which a union argument cannot bind. An @overload set would
  keep per-flavour precision but is unusable: on the polars-less type-lint
  lane every signature collapses to Any -> Any and mypy rejects the set.
- Two rebindings that mixed a polars result into a pandas-typed local
  (gfql/row/frame_ops.py rows(), gfql_unified.py optional-match seed) now
  return / branch directly instead. Same calls, same arguments, same results.
- Two ignores in frame_ops.count_table are removed: the guard now narrows, so
  collect_schema()/select() typecheck on their own.

Measured, same tree, three environments:
  isolated (what CI runs today): no issues in 325 files, before AND after
  stub-visible + polars importable:  163 in 11 files -> 157 in 9 files
  stub-visible, polars absent:       128 in  8 files -> 124 in 6 files

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB
@lmeyerov
lmeyerov force-pushed the typing/wave0-polars-frame branch from 20876e5 to 578c677 Compare July 28, 2026 03:01
lmeyerov and others added 3 commits July 28, 2026 01:45
Conflicting files: CHANGELOG.md (both sides prepended to '### Changed'; took master's
file and re-inserted this branch's entry) and
graphistry/compute/gfql/lazy/engine/polars/row_pipeline.py (master's #1794 bounded
var-length decline gate + this branch's is_polars_df TypeGuard retype; disjoint
hunks, both kept).

No substance change to the branch. Verified on the merged tree: ruff clean repo-wide,
mypy clean repo-wide with polars absent (the config the python-lint-types lane runs),
and with polars stubs visible 4 -> 2 errors vs master (both remaining are pre-existing
in degrees.py, untouched here).
Only CHANGELOG.md changed on both sides. Resolved structurally, not
textually: took master#\s file whole and re-inserted this branch#\s single
### Changed entry at the top of that section, so the diff vs master is
+1/-0 on CHANGELOG. The eight source files this branch retypes were
untouched by #1811 (which only moved test_index.py and the CHANGELOG),
so they carry through unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant