diff --git a/blacksheep/utils/meta.py b/blacksheep/utils/meta.py index 36ee5700..e220317f 100644 --- a/blacksheep/utils/meta.py +++ b/blacksheep/utils/meta.py @@ -15,6 +15,22 @@ def get_parent_file(): return "" +def _get_dotted_path(root_path: Path) -> str: + """ + Builds the dotted module path for a package folder by walking up its + parent directories while they contain an `__init__.py` file, so the + package can be imported by its fully qualified name regardless of the + current working directory of the running process. + """ + root_path = root_path.resolve() + parts = [root_path.name] + current = root_path.parent + while (current / "__init__.py").is_file(): + parts.append(current.name) + current = current.parent + return ".".join(reversed(parts)) + + def import_child_modules(root_path: Path): """ Import automatically all modules defined @@ -26,7 +42,7 @@ def import_child_modules(root_path: Path): for f in glob.glob(path + "/*.py") if not os.path.basename(f).startswith("_") ] - stripped_path = os.path.relpath(path).replace("/", ".").replace("\\", ".") + stripped_path = _get_dotted_path(root_path) for module in modules: __import__(stripped_path + "." + module) diff --git a/tests/test_utils.py b/tests/test_utils.py index 1c0eb9e2..56ba26a1 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,8 +1,10 @@ +import sys from typing import AnyStr, Sequence import pytest from blacksheep.utils import ensure_bytes, ensure_str, join_fragments +from blacksheep.utils.meta import import_child_modules @pytest.mark.parametrize( @@ -45,3 +47,38 @@ def test_ensure_bytes_throws_for_invalid_value(): def test_ensure_str_throws_for_invalid_value(): with pytest.raises(ValueError): ensure_str(True) # type: ignore + + +def test_import_child_modules_independent_of_cwd(tmp_path, monkeypatch): + """ + import_child_modules must resolve the dotted module path of a package + folder from its own location on disk, not from the current working + directory of the process. This reproduces the crash reported when a + blacksheep app is installed as a package (e.g. with `uv tool install`) + and started from an unrelated working directory: os.getcwd()-based + relative paths produced bogus dotted paths such as ".local.share...", + raising ModuleNotFoundError. + """ + package_root = tmp_path / "myapp_pkg" + package = package_root / "myapp" + routes_folder = package / "routes" + routes_folder.mkdir(parents=True) + + (package / "__init__.py").write_text("") + (routes_folder / "__init__.py").write_text("") + (routes_folder / "home.py").write_text("imported = True\n") + + unrelated_cwd = tmp_path / "somewhere_else" + unrelated_cwd.mkdir() + + monkeypatch.chdir(unrelated_cwd) + monkeypatch.syspath_prepend(str(package_root)) + + try: + import_child_modules(routes_folder) + assert "myapp.routes.home" in sys.modules + assert sys.modules["myapp.routes.home"].imported is True + finally: + for name in list(sys.modules): + if name == "myapp" or name.startswith("myapp."): + del sys.modules[name]