ADR-0007: Optional dependencies are lazily imported¶
- Status: accepted
- Date: 2026-06-14
Context¶
lp2graph derives many artefacts from the canonical Formulation: NetworkX /
PyG / DGL exports, a PuLP/HiGHS solver back-end, Pyomo import, and the mining
extensions (NLTK/WordNet, HDBSCAN, scikit-learn, NetworkX isomorphism). Most of
those libraries are heavyweight (torch, dgl), platform-sensitive, or rarely
needed by a given user. If they were hard dependencies, import lp2graph would
require the whole stack, the install would be slow and fragile, and CI on the
core path would be coupled to GPU/ML wheels.
The codebase already follows a lazy-import convention, but it was only recorded
as a coding note in CLAUDE.md, not as an architectural decision with rationale
and consequences.
Decision¶
Core install is pydantic + jsonschema only. Everything heavier is an
optional extra, imported inside the function that uses it. Specifically:
- Optional libraries are declared as
[project.optional-dependencies]extras (networkx,pyg,dgl,pyomo,solver,mining,all). - The import statement lives inside the function/method that needs the library,
not at module top level, so
import lp2graphand unrelated modules work with only the core deps installed. - A missing optional dep raises a clear
ImportErrornaming the extra to install — it must not surface as an opaqueModuleNotFoundErrormid-call. - Tests for optional-dep paths are gated with
pytest.importorskip("<dep>"). - Each optional library is listed in
[[tool.mypy.overrides]]ignore_missing_importssomypy --strictpasses without it installed.
Rationale¶
- Keeps the core importable and the canonical model usable with a tiny, reproducible dependency footprint.
- Decouples the determinism-critical core from large ML/solver toolchains.
- Lets CI exercise the core path on all Python versions without installing
torch/dgleverywhere.
Consequences¶
- Adding a new optional backend is a four-step checklist: (a) add an extra in
pyproject.toml, (b) lazy-import it inside its function with a helpfulImportError, (c) gate its tests withpytest.importorskip, (d) add it to the mypyignore_missing_importslist. STACK.md tracks the current set. - A small runtime cost on first use (the import happens on the call path); this is negligible relative to the work those functions do.
- Module-level type hints referring to optional types rely on
from __future__ import annotations(already mandatory) so they are not evaluated at import time.
Alternatives considered¶
- Everything as hard deps: rejected — heavy, fragile install; couples the core to ML/solver wheels.
- A single
extrasbucket: rejected — users installing only the solver should not pulltorch; granular extras keep installs minimal. - Try/except top-level import with a sentinel
None: rejected in favour of in-function imports, which keep failure local to the feature being used and avoid module-load-order surprises.