Skip to content

Commit 73dba89

Browse files
committed
fix: classify user-package import failures in load_python_modules
`load_python_modules` has three `importlib.import_module(<user-module>)` call sites. #1168 gave two of them the broad "anything raised inside a user module is a user error" treatment, but the third — the branch that imports a *directory* as a package when it contains no top-level `.py` files — kept its original narrow handler: except (ValueError, ModuleNotFoundError): pass That leaks in both directions: 1. Any other exception from the user's `__init__.py` (`TypeError`, `OSError`, `SyntaxError`, `PydanticUserError`, yaml `ScannerError`, ...) escapes `load_python_modules` raw and crash-reports to Sentry, even though it is by definition a bug in the user's code or one of its third-party imports. This is the same class the Sentry corpus shows for the other two sites (FLYTE-SDK-39 and friends), which #1168 already closed. 2. A `ModuleNotFoundError` raised from *inside* the package — a dependency the user has not installed — is swallowed by the bare `pass`. Deploy then proceeds with zero environments and no explanation. The silent half is arguably worse than the crash. The narrow catch was not wrong when it was written: it is control flow for "this directory is not an importable package, skip it". This keeps that, and only that: - `ValueError` is now scoped to the `relative_to` call that actually raises it, so a `ValueError` from user code at import time is no longer mistaken for a path-outside-the-root. - `ModuleNotFoundError` is skipped only when it names the target module (or a parent package of it) — i.e. the directory genuinely is not importable here. `ModuleNotFoundError.name` distinguishes that from a missing inner import. - Everything else is recorded in `failed_paths`, consistent with the sibling directory branch, so `flyte deploy` surfaces a clean `ClickException` and honours `--ignore-load-errors` instead of crash-reporting. No Sentry issue is currently firing on this branch, so nothing is claimed as fixed — this closes the site #1168 missed, plus the silent-skip bug next to it. Six tests; the two that pin the leak and the silent skip fail on main, and the four that pin the preserved skip-and-continue behaviour pass on both sides by design. Signed-off-by: Haytham Abuelfutuh <haytham@afutuh.com>
1 parent c86f22d commit 73dba89

2 files changed

Lines changed: 169 additions & 4 deletions

File tree

src/flyte/_utils/module_loader.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@
1313
from flyte._logging import logger
1414

1515

16+
def _names_missing_module(err: ModuleNotFoundError, mod: str) -> bool:
17+
"""Whether `err` reports that `mod` itself -- rather than one of its imports -- is absent.
18+
19+
`importlib.import_module(mod)` raises `ModuleNotFoundError` both when `mod` does not exist
20+
and when `mod` imports something that does not exist. Only the first means "this directory
21+
is not an importable package"; the second is a user dependency error worth surfacing.
22+
`ModuleNotFoundError.name` distinguishes them.
23+
"""
24+
name = err.name
25+
if not name:
26+
# No name to compare against, so keep the historical skip-and-continue behaviour.
27+
return True
28+
return mod == name or mod.startswith(f"{name}.")
29+
30+
1631
def _relative_to_root(path: Path, root_dir: Path) -> Path:
1732
"""Resolve `path` relative to `root_dir` and translate `ValueError` into a clear ClickException.
1833
@@ -92,11 +107,31 @@ def load_python_modules(
92107
# If no .py files found, try importing as a module
93108
try:
94109
rel_path = path.resolve().relative_to(root_dir)
110+
except ValueError:
111+
# Outside the root, so there is no module name to import it under.
112+
rel_path = None
113+
if rel_path is not None:
95114
mod = ".".join(rel_path.parts)
96-
imported_module = importlib.import_module(mod)
97-
loaded_modules.append(imported_module)
98-
except (ValueError, ModuleNotFoundError):
99-
pass
115+
try:
116+
imported_module = importlib.import_module(mod)
117+
loaded_modules.append(imported_module)
118+
except flyte.errors.ModuleLoadError as e:
119+
failed_paths.append((path, str(e)))
120+
except ModuleNotFoundError as e:
121+
# `mod` itself not being importable just means this directory is not a
122+
# package, which is the case this branch has always skipped. A module
123+
# missing from *inside* the package's `__init__.py` is a different thing
124+
# -- a user dependency error -- and swallowing it silently deploys zero
125+
# environments with no explanation.
126+
if not _names_missing_module(e, mod):
127+
failed_paths.append((path, f"{type(e).__name__}: {e}"))
128+
except Exception as e:
129+
# Anything else raised inside `importlib.import_module(<user-package>)` is an
130+
# error in the user's `__init__.py` or one of its third-party imports, not an
131+
# SDK bug. Record it as a load failure (consistent with the .py-file branch
132+
# below) so deploy surfaces a clean message via `--ignore-load-errors`
133+
# instead of crash-reporting to Sentry.
134+
failed_paths.append((path, f"{type(e).__name__}: {e}"))
100135
else:
101136
with Progress(
102137
TextColumn("[progress.description]{task.description}"),

tests/flyte/utils/test_module_loader.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,3 +366,133 @@ def test_load_python_modules_single_file_wraps_runtime_error(tmp_path):
366366
assert "workflow.py" in msg
367367
assert "RuntimeError" in msg
368368
assert "union.sandbox" in msg
369+
370+
371+
# ---------------------------------------------------------------------------
372+
# Directory-with-no-.py-files branch: `flyte deploy ./somepkg` where the package's
373+
# code lives in subpackages, so the directory is imported as a module instead.
374+
# ---------------------------------------------------------------------------
375+
376+
377+
def _package_project(tmp_path, name, init_body):
378+
"""Build `project/<name>/__init__.py` with no top-level .py files under `<name>`.
379+
380+
Each test uses its own package name so a cached `sys.modules` entry from one test
381+
cannot change what another one imports.
382+
"""
383+
root = tmp_path / "project"
384+
root.mkdir()
385+
pkg = root / name
386+
pkg.mkdir()
387+
(pkg / "__init__.py").write_text(init_body)
388+
return root, pkg
389+
390+
391+
def test_load_python_modules_package_records_user_init_errors(tmp_path):
392+
"""An exception raised by the package's own __init__.py is a user-code bug.
393+
394+
It used to escape `load_python_modules` raw -- the branch only caught
395+
(ValueError, ModuleNotFoundError) -- and crash-reported to Sentry."""
396+
root, pkg = _package_project(tmp_path, "pkg_init_raises", "raise TypeError('user init blew up')\n")
397+
398+
sys.path.insert(0, str(root))
399+
try:
400+
modules, failed = load_python_modules(pkg, root_dir=root, recursive=False)
401+
finally:
402+
sys.path.remove(str(root))
403+
sys.modules.pop("pkg_init_raises", None)
404+
405+
assert modules == []
406+
assert len(failed) == 1
407+
failed_path, failed_msg = failed[0]
408+
assert failed_path == pkg
409+
assert "TypeError" in failed_msg
410+
assert "user init blew up" in failed_msg
411+
412+
413+
def test_load_python_modules_package_records_missing_dependency(tmp_path):
414+
"""A dependency missing from inside the package's __init__.py is a user error.
415+
416+
It used to be swallowed by the bare `except ModuleNotFoundError: pass`, so deploy
417+
silently proceeded with zero environments and no explanation."""
418+
root, pkg = _package_project(tmp_path, "pkg_missing_dep", "import totally_missing_dep_xyz\n")
419+
420+
sys.path.insert(0, str(root))
421+
try:
422+
modules, failed = load_python_modules(pkg, root_dir=root, recursive=False)
423+
finally:
424+
sys.path.remove(str(root))
425+
sys.modules.pop("pkg_missing_dep", None)
426+
427+
assert modules == []
428+
assert len(failed) == 1
429+
failed_path, failed_msg = failed[0]
430+
assert failed_path == pkg
431+
assert "totally_missing_dep_xyz" in failed_msg
432+
433+
434+
def test_load_python_modules_package_skips_when_not_importable(tmp_path):
435+
"""The directory simply not being an importable package stays a silent skip.
436+
437+
This is the case the original `except ModuleNotFoundError: pass` existed for and
438+
it must keep working -- only errors from *inside* the package are now surfaced."""
439+
root, pkg = _package_project(tmp_path, "pkg_not_importable", "VALUE = 1\n")
440+
441+
# `root` is never put on sys.path, so the package itself cannot be imported.
442+
def fake_import(mod_name):
443+
raise ModuleNotFoundError(f"No module named '{mod_name}'", name=mod_name)
444+
445+
with patch("importlib.import_module", side_effect=fake_import):
446+
modules, failed = load_python_modules(pkg, root_dir=root, recursive=False)
447+
448+
assert modules == []
449+
assert failed == []
450+
451+
452+
def test_load_python_modules_package_skips_parent_package_missing(tmp_path):
453+
"""A missing *parent* of the target also means "not importable here" -- still a skip."""
454+
root = tmp_path / "project"
455+
root.mkdir()
456+
pkg = root / "outer" / "inner"
457+
pkg.mkdir(parents=True)
458+
(pkg / "__init__.py").write_text("VALUE = 1\n")
459+
460+
def fake_import(mod_name):
461+
assert mod_name == "outer.inner"
462+
raise ModuleNotFoundError("No module named 'outer'", name="outer")
463+
464+
with patch("importlib.import_module", side_effect=fake_import):
465+
modules, failed = load_python_modules(pkg, root_dir=root, recursive=False)
466+
467+
assert modules == []
468+
assert failed == []
469+
470+
471+
def test_load_python_modules_package_outside_root_is_skipped(tmp_path):
472+
"""A package directory outside the root has no module name, so it is skipped."""
473+
root = tmp_path / "project"
474+
root.mkdir()
475+
other = tmp_path / "elsewhere" / "pkg_outside_root"
476+
other.mkdir(parents=True)
477+
(other / "__init__.py").write_text("VALUE = 1\n")
478+
479+
modules, failed = load_python_modules(other, root_dir=root, recursive=False)
480+
481+
assert modules == []
482+
assert failed == []
483+
484+
485+
def test_load_python_modules_package_loads_healthy_package(tmp_path):
486+
"""The happy path is unchanged: an importable package is loaded."""
487+
root, pkg = _package_project(tmp_path, "pkg_healthy", "VALUE = 1\n")
488+
489+
sys.path.insert(0, str(root))
490+
try:
491+
modules, failed = load_python_modules(pkg, root_dir=root, recursive=False)
492+
finally:
493+
sys.path.remove(str(root))
494+
sys.modules.pop("pkg_healthy", None)
495+
496+
assert failed == []
497+
assert len(modules) == 1
498+
assert modules[0].VALUE == 1

0 commit comments

Comments
 (0)