Summary
grimp.build_graph() panics (pyo3_runtime.PanicException) instead of raising a Python
exception (or handling the import gracefully) when all of the following hold:
- the root package name is dotted (a child of a namespace package, e.g.
ns.inner),
include_external_packages=True,
- a scanned file contains a
from x import y statement where x is a missing module
inside the root package (so both x and its parent fail to resolve as internal modules).
thread '<unnamed>' (73) panicked at src/import_scanning.rs:330:74:
index out of bounds: the len is 0 but the index is 0
...
pyo3_runtime.PanicException: index out of bounds: the len is 0 but the index is 0
Reproduction (grimp 3.15)
mkdir -p ns/inner
echo 'from ns.inner.missing_module import something' > ns/inner/__init__.py
python -c "import grimp; grimp.build_graph('ns.inner', include_external_packages=True)"
The import statement may equivalently be relative (from .missing_module import something) —
it panics after resolution to the same absolute name.
Boundary conditions (all verified on 3.15)
| Variation |
Result |
from ns.inner.missing_module import something |
panic |
from .missing_module import something |
panic |
import ns.inner.missing_module |
ok |
from ns.inner import missing_module |
ok |
same import, include_external_packages=False |
ok |
same import, single-component root (mypackage) |
ok |
The import / single-level from forms survive because _get_internal_module resolves them
via the one-level parent trim; the deeper from form fails both the exact and parent lookup,
falls through to the external branch, and reaches _distill_external_module with a module
name that shares its full prefix with the root package.
Analysis
In rust/src/import_scanning.rs, _distill_external_module walks the shared namespace
prefix:
while external_path_components[0] == internal_path_components[0] {
external_namespace_components.push(external_path_components.remove(0));
internal_path_components.remove(0);
}
external_namespace_components.push(external_path_components[0]);
For an "external" module that is actually inside the root package (here
ns.inner.missing_module.something vs. root ns.inner), the loop consumes every component of
one of the vectors and the next [0] access panics. The earlier
"parent of an internal package" guard only catches proper ancestors of the root, not
descendants-that-failed-internal-resolution, and _get_internal_module only trims one
trailing component before giving up.
Real-world impact
We hit this in CI in two independent ways, both reducing to "a .py module referenced by a
from … import … doesn't exist on disk":
- A setuptools-scm-generated version file (
from ._version import __version__ in the package
root) that doesn't exist yet when lint-imports runs — whether the file exists depends on
whether the build backend ran, so the job fails only on package-cache hits, which made this
look like nondeterministic CI flakiness for months.
- Running import-linter against a package installed from a binary-compiled wheel (Nuitka),
where most modules exist only as .so: every from <pkg>.<module> import x in the
remaining .py files references a module grimp cannot see.
Because the panic is a pyo3_runtime.PanicException rather than a normal exception, it also
aborts the rayon worker threads mid-scan and can't be caught meaningfully downstream
(import-linter shows the raw traceback).
Expected behavior
Either of these would be fine:
- Treat an unresolvable module that is a descendant of a found package as internal (matching
the behavior of the plain-import form, which squashes to the nearest existing ancestor); or
- At minimum, bounds-check the loop and raise a proper Python exception naming the offending
module and file.
Environment
- grimp: 3.15 (also reproduced via import-linter 2.13
lint-imports)
- Python: 3.13.9 (CPython)
- OS: reproduced on both macOS 26 (arm64) and Linux x86_64 (Debian-based CI image)
Summary
grimp.build_graph()panics (pyo3_runtime.PanicException) instead of raising a Pythonexception (or handling the import gracefully) when all of the following hold:
ns.inner),include_external_packages=True,from x import ystatement wherexis a missing moduleinside the root package (so both
xand its parent fail to resolve as internal modules).Reproduction (grimp 3.15)
The import statement may equivalently be relative (
from .missing_module import something) —it panics after resolution to the same absolute name.
Boundary conditions (all verified on 3.15)
from ns.inner.missing_module import somethingfrom .missing_module import somethingimport ns.inner.missing_modulefrom ns.inner import missing_moduleinclude_external_packages=Falsemypackage)The
import/ single-levelfromforms survive because_get_internal_moduleresolves themvia the one-level parent trim; the deeper
fromform fails both the exact and parent lookup,falls through to the external branch, and reaches
_distill_external_modulewith a modulename that shares its full prefix with the root package.
Analysis
In
rust/src/import_scanning.rs,_distill_external_modulewalks the shared namespaceprefix:
For an "external" module that is actually inside the root package (here
ns.inner.missing_module.somethingvs. rootns.inner), the loop consumes every component ofone of the vectors and the next
[0]access panics. The earlier"parent of an internal package" guard only catches proper ancestors of the root, not
descendants-that-failed-internal-resolution, and
_get_internal_moduleonly trims onetrailing component before giving up.
Real-world impact
We hit this in CI in two independent ways, both reducing to "a
.pymodule referenced by afrom … import …doesn't exist on disk":from ._version import __version__in the packageroot) that doesn't exist yet when
lint-importsruns — whether the file exists depends onwhether the build backend ran, so the job fails only on package-cache hits, which made this
look like nondeterministic CI flakiness for months.
where most modules exist only as
.so: everyfrom <pkg>.<module> import xin theremaining
.pyfiles references a module grimp cannot see.Because the panic is a
pyo3_runtime.PanicExceptionrather than a normal exception, it alsoaborts the rayon worker threads mid-scan and can't be caught meaningfully downstream
(import-linter shows the raw traceback).
Expected behavior
Either of these would be fine:
the behavior of the plain-
importform, which squashes to the nearest existing ancestor); ormodule and file.
Environment
lint-imports)