Skip to content

bug: all_estimators() / all_displays() / all_functions() crash with ImportError when matplotlib is not installed#283

Description

@Gnpd

Summary

chemotools.utils.discovery.all_estimators() (and all_displays(), all_functions()) raise an uncaught ImportError if matplotlib is not installed, even though the caller may only be interested in non-plotting estimators (e.g. transformers in chemotools.augmentation, chemotools.baseline, etc.). matplotlib is declared as an optional dependency (the viz extra in pyproject.toml), but discovery doesn't treat it as optional.

Steps to reproduce

# with chemotools installed WITHOUT the `viz` extra (no matplotlib) 
from chemotools.utils.discovery import all_estimators

all_estimators()

Actual behavior

ImportError: 'chemotools.inspector' requires the optional dependency 'matplotlib'. Install it with: pip install chemotools[viz]

Confirmed on chemotools 0.4.0, reproduced by blocking matplotlib imports in a subprocess and calling all_estimators().

Expected behavior

all_estimators() should return the list of estimators that can be imported (everything outside chemotools.plotting and chemotools.inspector). Discovery shouldn't hard-fail because of an optional, unrelated dependency.

Root cause

chemotools/utils/discovery.py walks every submodule with pkgutil.walk_packages and calls import_module(module_name) on each one with no error handling:

chemotools.plotting and chemotools.inspector guard their init.py with a package-import-time check:

import_optional_dependency(
      "matplotlib",
      caller_name="chemotools.plotting",
      extra_name="viz",
  )

pkgutil.walk_packages itself tolerates this fine internally (it uses onerror=None when recursing into packages, so it silently gives up trying to descend into chemotools.plotting / chemotools.inspector). But it still yields "chemotools.plotting" and "chemotools.inspector" as module names from the parent package's listing. The discovery loop then calls import_module() on those names itself, hits the same ImportError again, and this time nothing catches it, so the whole all_estimators() call fails.

Comparison with scikit-learn

chemotools' discovery code is adapted directly from sklearn's:

Note that sklearn's all_estimators / all_displays / all_functions also call import_module() with no try/except around it, so at first glance the same "bug" exists there. It never triggers, though, because sklearn never makes a package import conditional on matplotlib.

sklearn's optional-matplotlib helper is:

The crucial difference is where it's called: lazily, inside .plot(), .from_estimator(), or .from_predictions() of *Display classes, never at module or package import time. Axes / Figure type hints in those classes are deferred (from future import annotations plus TYPE_CHECKING), so sklearn.metrics and friends import cleanly with zero matplotlib installed. RocCurveDisplay etc. are always discoverable via all_displays(); only calling .plot() on them requires matplotlib and raises the helpful error.

scikit-image went through the same exercise when making matplotlib optional:

In chemotools, by contrast, matplotlib is imported at module scope throughout chemotools/plotting/* and chemotools/inspector/* (import matplotlib.pyplot as plt, from matplotlib.axes import Axes, from matplotlib.figure import Figure, even a colormap registration side effect in chemotools/plotting/_utils.py), and the package init.py files turn that into a hard import-time failure for the whole subpackage. That's what breaks the sklearn-derived discovery pattern.

CI gap

This isn't caught by CI because:

  • .github/workflows/ci.yml only runs the tests-X.Y and tests-min-sklearn-X.Y nox sessions, both of which install with extras=("viz",), so matplotlib is always present.
  • noxfile.py has a tests-core session intended for the no-viz case, but it's not part of the CI matrix at all.
  • Even if it were run, tests-core ignores tests/plotting and tests/inspector but not tests/utils/test_discovery.py, which would itself fail on all_estimators() / all_displays() / all_functions() for the same reason.

Possible fixes

  1. Minimal: in chemotools/utils/discovery.py, wrap each import_module(module_name) in try/except ImportError: continue. This mirrors the tolerance pkgutil.walk_packages(onerror=None) already applies internally, and immediately fixes the crash for chemotools.plotting / chemotools.inspector (and any future optional-dependency-gated subpackage).
  2. sklearn-parity (larger): make chemotools.plotting / chemotools.inspector always importable without matplotlib by dropping the package-level import_optional_dependency("matplotlib", ...) guards, deferring Axes / Figure typing via TYPE_CHECKING, and moving matplotlib usage into the .show() / .render() methods (raising via import_optional_dependency only when a user actually requests a plot). This gives full parity with sklearn's Display pattern, so all_displays() would list SpectraPlot, ScoresPlot, etc. even without matplotlib, but it touches roughly 20 files.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions