Skip to content

fix(outliers): make the optional-import fallback actually work - #2094

Merged
lmeyerov merged 1 commit into
masterfrom
fix/pyright-lazy-import-guards
Sep 18, 2026
Merged

lmeyerov merged 1 commit into
masterfrom
fix/pyright-lazy-import-guards

Conversation

@lmeyerov

Copy link
Copy Markdown
Contributor

First of the pyright-ratchet cleanup series (after #2092, #2093). Ratchet 273 → 271.

The real bug

graphistry/outliers.py guards matplotlib/sklearn/numpy behind try/except and binds each name to None on failure, so the module is meant to import without them. It did not — and matplotlib is not a dependency, so that is the default install:

$ python -c "import graphistry.outliers"
AttributeError: 'NoneType' object has no attribute 'ndarray'

Two separate defects:

  1. import matplotlib.font_manager binds the name matplotlib, and that name was missing from the except block — every other name in the try had a fallback. outliers.py:162 uses matplotlib.font_manager.FontProperties directly, so that path raised NameError.
  2. get_outliers annotates Union[np.ndarray, pd.DataFrame], which is evaluated at def time. With np = None the module raised AttributeError before defining anything — so the try/except could never have helped.

Fixed by completing the fallback list and making annotations lazy (from __future__ import annotations). The module now imports and degrades as written; calls still fail without matplotlib, which is the documented intent of the None bindings.

Nothing inside graphistry/ imports this module, so only direct callers of graphistry.outliers were affected. That is why it went unnoticed.

Tests

test_outliers_imports_when_matplotlib_is_missing blocks matplotlib via sys.modules and re-imports. It fails on master with the AttributeError above.

test_every_name_the_optional_import_binds_has_a_fallback pins the defect class rather than the instance: it walks the module's AST and asserts no name is bound on only one path of that try/except. That is the check that was missing.

Also here

if polars: import polars as pl in gfql_unified.py makes pl a function-local, so the pandas path left it unbound. Both arms are guarded by the same flag, so nothing changes at runtime; the binding is made explicit. Verified by exercising both arms directly (polars: pl.col(...) mirrors the prefixed column; pandas: pl stays untouched), plus 7091 polars tests.

Deliberately not here

The six skrub instances in feature_utils.py are the same shape, but their else branch is unreachable in every lane the changed-line-coverage gate consumes — it needs skrub absent, while the enclosing function needs sklearn, which only the umap/ai lanes have, and those do not feed that gate. Carrying it here would mean a # pragma: no cover on a line I can't justify skipping. feature_utils.py is already the subject of a later PR in this series (it holds 12 more findings); it gets fixed there, with its test lanes considered as a whole.

Verification

bin/lint.sh (ruff, type-hygiene 4448, comment-density 1151) · pyright guard 271 · mypy 351 files · changed-line-coverage 3/3 = 100% · 7091 polars tests in graphistry/tests/compute/.

🤖 Generated with Claude Code

https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud

graphistry/outliers.py guards matplotlib/sklearn/numpy behind try/except and
binds each name to None on failure, so the module is meant to import without
them. It did not.

`import matplotlib.font_manager` binds the name `matplotlib`, and that name was
missing from the except block, so outliers.py:162 would have raised NameError.
Worse, `get_outliers` annotates `Union[np.ndarray, pd.DataFrame]`, which is
evaluated at def time: with `np = None` the module raised AttributeError before
defining anything. matplotlib is not a dependency, so `import
graphistry.outliers` failed on a default install. Nothing inside graphistry/
imports it, so only direct callers were affected.

Annotations are now lazy and the fallback list is complete. A test imports the
module with matplotlib blocked, and a second test pins the defect CLASS: no
name may be bound on only one path of that try/except.

One more instance of the same shape, a no-op at runtime: `if polars: import
polars as pl` in gfql_unified.py makes `pl` a function-local, so the pandas
path left it unbound. Both arms are guarded by the same flag and both are
exercised directly in verification.

The six skrub instances in feature_utils.py are deliberately NOT here. Their
`else` branch is unreachable in every lane the changed-line-coverage gate
consumes -- it needs skrub absent, while the enclosing function needs sklearn,
which only the umap/ai lanes have, and those do not feed that gate. That file
is already the subject of a later PR in this series; it gets fixed there, with
its test lanes considered as a whole, rather than carried here behind a pragma.

Pyright ratchet 273 -> 271 (-2).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud
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