From 5d1d34f1ad3910a9a65f34a2bef93c6050b9ed1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 8 Nov 2026 09:12:28 -0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(provider):=20load=20each=20p?= =?UTF-8?q?roject's=20own=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.1.1 refused a provider module the process had imported from another project, which breaks a test suite that builds copies of one project, as virtualenv's does. The cached module and its submodules are now dropped so the project's own copy loads; a provider built earlier keeps the modules it references. git output is decoded as UTF-8 explicitly. --- .../+scoped-provider-loading.bugfix.rst | 3 +++ src/python_sbom/_collect/_content.py | 21 ++++++++-------- src/python_sbom/_collect/_revision.py | 2 +- tests/_collect/test_content.py | 25 ++++++++++++------- 4 files changed, 30 insertions(+), 21 deletions(-) create mode 100644 docs/changelog.d/+scoped-provider-loading.bugfix.rst diff --git a/docs/changelog.d/+scoped-provider-loading.bugfix.rst b/docs/changelog.d/+scoped-provider-loading.bugfix.rst new file mode 100644 index 0000000..996d1e5 --- /dev/null +++ b/docs/changelog.d/+scoped-provider-loading.bugfix.rst @@ -0,0 +1,3 @@ +Load a project's own content provider when the process already imported a module of the same name from another +project, as a test suite building copies of one project does, instead of refusing it. The module cached from elsewhere +is dropped with its submodules, and a provider loaded earlier keeps working. diff --git a/src/python_sbom/_collect/_content.py b/src/python_sbom/_collect/_content.py index efbcbec..5bfed7e 100644 --- a/src/python_sbom/_collect/_content.py +++ b/src/python_sbom/_collect/_content.py @@ -34,12 +34,13 @@ def _load(spec: str, root: Path) -> ContentProvider: if not (module_name and attribute): msg = f"content provider {spec!r} must be `module:attribute`" raise SbomError(msg) - if (imported := _imported_elsewhere(module_name, root)) is not None: - msg = ( - f"content provider {spec!r} cannot load from {root}: this process already imported {imported}, a module of" - " the same name, and would run that instead; build each such project in a process of its own" - ) - raise SbomError(msg) + # the import cache is per process, so a hatch build of a second project, or a test suite building copies of one, + # would get the provider module the first project imported; the outermost module the project provides that the + # cache holds from elsewhere is dropped with its submodules, so the project's own copy loads, while a provider built + # earlier keeps the module objects it already references + if (other := _imported_elsewhere(module_name, root)) is not None: + for cached in [name for name in sys.modules if name == other or name.startswith(f"{other}.")]: + del sys.modules[cached] sys.path.insert(0, str(root)) try: provider = pkgutil.resolve_name(spec) @@ -58,10 +59,8 @@ def _load(spec: str, root: Path) -> ContentProvider: def _imported_elsewhere(module_name: str, root: Path) -> str | None: - # the import cache is per process, so a hatch build of a second project with a provider module of the same name - # would run the first project's; each package on the way down to the module, and the module itself, that the project - # provides is compared with what is already imported under its name; namespace packages merge, so only modules and - # regular packages can clash + # each package on the way down to the module, and the module itself, that the project provides is compared with what + # is already imported under its name; namespace packages merge, so only modules and regular packages can clash search: list[str] | None = [str(root)] parts: Final[list[str]] = module_name.split(".") for depth in range(1, len(parts) + 1): @@ -73,7 +72,7 @@ def _imported_elsewhere(module_name: str, root: Path) -> str | None: and imported is not None and Path(imported).resolve() != Path(found.origin).resolve() ): - return imported + return found.name search = None if found.submodule_search_locations is None else list(found.submodule_search_locations) return None diff --git a/src/python_sbom/_collect/_revision.py b/src/python_sbom/_collect/_revision.py index 142fb88..5f80406 100644 --- a/src/python_sbom/_collect/_revision.py +++ b/src/python_sbom/_collect/_revision.py @@ -73,7 +73,7 @@ def _git(git: str, root: Path, *arguments: str) -> str: [git, "-c", "core.fsmonitor=false", "--no-optional-locks", "-C", str(root), *arguments], check=True, capture_output=True, - text=True, + encoding="utf-8", ).stdout.strip() diff --git a/tests/_collect/test_content.py b/tests/_collect/test_content.py index 71b06f5..9709b64 100644 --- a/tests/_collect/test_content.py +++ b/tests/_collect/test_content.py @@ -120,23 +120,30 @@ def test_provider_without_source_is_no_tool(tmp_path: Path, settings: Settings) assert resolve_provider(provider, settings, tmp_path)[1] == () -def test_provider_of_another_project_is_not_reused(tmp_path: Path, settings: Settings) -> None: - # two projects whose providers share a module name, built in one process as hatch builds them +@pytest.mark.parametrize( + "layout", + [pytest.param("{name}.py", id="module"), pytest.param("{name}/__init__.py", id="package")], +) +def test_provider_of_another_project_loads_its_own(tmp_path: Path, settings: Settings, layout: str) -> None: + # two projects whose providers share a module name, built in one process as hatch builds them or a test suite builds + # copies of one project name = f"provider_{tmp_path.name}" for project, value in (("first", "1"), ("second", "2")): - (tmp_path / project).mkdir() - (tmp_path / project / f"{name}.py").write_text( + (source := tmp_path / project / layout.format(name=name)).parent.mkdir(parents=True, exist_ok=True) + source.write_text( f"from python_sbom import ContentProvider\n\n\nclass Content(ContentProvider):\n value = {value}\n", encoding="utf-8", ) try: - resolve_provider(f"{name}:Content", settings, tmp_path / "first") - with pytest.raises( - SbomError, match=rf"this process already imported .*first.*{name}\.py, a module of the same" - ): - resolve_provider(f"{name}:Content", settings, tmp_path / "second") + first, _ = resolve_provider(f"{name}:Content", settings, tmp_path / "first") + second, scripts = resolve_provider(f"{name}:Content", settings, tmp_path / "second") finally: sys.modules.pop(name, None) + assert (getattr(first, "value", None), getattr(second, "value", None), scripts) == ( + 1, + 2, + (PurePosixPath(layout.format(name=name)),), + ) def test_provider_of_the_same_project_loads_again(tmp_path: Path, module: str, settings: Settings) -> None: