Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/changelog.d/+scoped-provider-loading.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 10 additions & 11 deletions src/python_sbom/_collect/_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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):
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/python_sbom/_collect/_revision.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand Down
25 changes: 16 additions & 9 deletions tests/_collect/test_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading