Skip to content
Open
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
1 change: 1 addition & 0 deletions e2e/cases/MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ include("//freethreaded-805:setup.MODULE.bazel")
include("//inactive-marker:setup.MODULE.bazel")
include("//interpreter-local-pyvenv-home:setup.MODULE.bazel")
include("//multi-project-hub:setup.MODULE.bazel")
include("//namespace-init-collision:setup.MODULE.bazel")
include("//oci:setup.MODULE.bazel")
include("//pbs-cc-toolchain:setup.MODULE.bazel")
include("//pth-namespace-547:setup.MODULE.bazel")
Expand Down
14 changes: 14 additions & 0 deletions e2e/cases/namespace-init-collision/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
load("@aspect_rules_py//py:defs.bzl", "py_test")

py_test(
name = "test",
srcs = ["__test__.py"],
dep_group = "namespace-init-collision",
main = "__test__.py",
package_collisions = "error",
python_version = "3.11",
deps = [
"@pypi_namespace_init_collision//backports_shutil_get_terminal_size",
"@pypi_namespace_init_collision//backports_weakref",
],
)
17 changes: 17 additions & 0 deletions e2e/cases/namespace-init-collision/__test__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Regression test for namespace package __init__.py stub collisions.

Both backports.weakref and backports.shutil-get-terminal-size install a
backports/__init__.py that declares a pkgutil namespace package. The files are
not byte-identical, but they are semantically equivalent, so the venv must not
treat them as a collision.
"""


def test_namespace_imports():
import backports.shutil_get_terminal_size # noqa: F401
import backports.weakref # noqa: F401


if __name__ == "__main__":
test_namespace_imports()
print("PASS: namespace package stubs did not collide")
8 changes: 8 additions & 0 deletions e2e/cases/namespace-init-collision/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[project]
name = "namespace-init-collision"
version = "0.0.0"
requires-python = ">=3.11"
dependencies = [
"backports.weakref",
"backports.shutil-get-terminal-size",
]
8 changes: 8 additions & 0 deletions e2e/cases/namespace-init-collision/setup.MODULE.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
uv = use_extension("@aspect_rules_py//uv:extensions.bzl", "uv")
uv.declare_hub(hub_name = "pypi_namespace_init_collision")
uv.project(
hub_name = "pypi_namespace_init_collision",
lock = "//namespace-init-collision:uv.lock",
pyproject = "//namespace-init-collision:pyproject.toml",
)
use_repo(uv, "pypi_namespace_init_collision")
36 changes: 36 additions & 0 deletions e2e/cases/namespace-init-collision/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion py/private/providers.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ handling gives the later distinct element in the flattened sequence precedence.
Duplicate dependency edges do not create another precedence position. Fields:
* `top_levels`: tuple[str] — complete set of immediate `site-packages`
entry names when nonempty; an empty tuple means the layout is unknown.
* `namespace_top_levels`: tuple[str] — subset of top_levels that are PEP 420 namespace packages.
* `namespace_top_levels`: tuple[str] — subset of top_levels that are PEP 420 namespace packages
(no `<toplevel>/__init__.py`) or pkgutil-style namespace packages (an `__init__.py` that
only calls `pkgutil.extend_path` / `pkg_resources.declare_namespace`).
* `pkgutil_namespace_top_levels`: tuple[str] — subset of `namespace_top_levels` that carry a
pkgutil-style `__init__.py`. venv assembly symlinks this `__init__.py` into the merged
namespace directory; may be absent on structs from older producers.
* `namespace_entries`: tuple[str] — `/`-joined paths of the concrete entries beneath
the namespace top-levels (e.g. `jaraco/functools`), used to materialise a merged
namespace directory out of per-entry symlinks. May be absent on structs from
Expand Down
15 changes: 14 additions & 1 deletion py/private/py_unpacked_wheel.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ def _py_unpacked_wheel_impl(ctx):
wheels = depset(direct = [struct(
top_levels = tuple(ctx.attr.top_levels),
namespace_top_levels = tuple(ctx.attr.namespace_top_levels),
pkgutil_namespace_top_levels = tuple(ctx.attr.pkgutil_namespace_top_levels),
namespace_entries = tuple(ctx.attr.namespace_entries),
site_packages_rfpath = site_packages_rfpath,
console_scripts = tuple(ctx.attr.console_scripts),
Expand Down Expand Up @@ -128,12 +129,24 @@ wrappers under `<venv>/bin/<name>`. Typically populated from the wheel's
default = [],
),
"namespace_top_levels": attr.string_list(
doc = """Subset of `top_levels` that are PEP 420 namespace packages.
doc = """Subset of `top_levels` that are namespace packages.

Includes both PEP 420 namespaces (no `__init__.py`) and
pkgutil/pkg_resources-style namespaces (an `__init__.py` that only
calls `pkgutil.extend_path` or `pkg_resources.declare_namespace`).
See the equivalent attribute on the `whl_install` rule for the full
story; short version: names listed here suppress collision errors when
multiple wheels claim the same top-level, because Python's namespace
machinery is meant to merge their contributions.
""",
default = [],
),
"pkgutil_namespace_top_levels": attr.string_list(
doc = """Subset of `namespace_top_levels` that carry a pkgutil-style `__init__.py`.

These top-levels are merged across wheels like PEP 420 namespaces, but
venv assembly also symlinks the stub `__init__.py` into the merged
directory so the namespace package is importable at runtime.
""",
default = [],
),
Expand Down
16 changes: 15 additions & 1 deletion py/private/py_venv/venv.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,14 @@ def _resolve_wheel_collisions(ctx, wheels, package_collisions):

# Pass 1: bucket claimants per import root, distribution metadata entry,
# and console-script name.
tl_claimants = {} # tl -> list of struct(site_packages, is_ns, ns_entries)
tl_claimants = {} # tl -> list of struct(site_packages, is_ns, is_pkgutil_ns, ns_entries)
metadata_claimants = {} # metadata entry -> ordered site_packages paths
cs_claimants = {} # name -> list of struct(site_packages, module, func)
wheel_by_sp = {} # site_packages_rfpath -> wheel struct
for w in wheels:
wheel_by_sp[w.site_packages_rfpath] = w
ns_set = {tl: True for tl in getattr(w, "namespace_top_levels", ())}
pkgutil_ns_set = {tl: True for tl in getattr(w, "pkgutil_namespace_top_levels", ())}
ns_entries_by_tl = {}
for entry in getattr(w, "namespace_entries", ()):
ns_entries_by_tl.setdefault(entry.split("/")[0], []).append(entry)
Expand All @@ -153,6 +154,7 @@ def _resolve_wheel_collisions(ctx, wheels, package_collisions):
tl_claimants.setdefault(tl, []).append(struct(
site_packages = w.site_packages_rfpath,
is_ns = tl in ns_set,
is_pkgutil_ns = tl in pkgutil_ns_set,
ns_entries = tuple(ns_entries_by_tl.get(tl, [])),
))
for entry in getattr(w, "console_scripts", ()):
Expand Down Expand Up @@ -251,6 +253,11 @@ def _resolve_wheel_collisions(ctx, wheels, package_collisions):
entry_owner[entry] = c
for entry, c in entry_owner.items():
top_level_to_site_pkgs[entry] = c.site_packages

for c in unique_claimants:
if c.is_pkgutil_ns:
top_level_to_site_pkgs[tl + "/__init__.py"] = c.site_packages
break
continue

# Pure PEP 420 namespace (no regular package spanning wheels).
Expand Down Expand Up @@ -327,6 +334,13 @@ def _resolve_wheel_collisions(ctx, wheels, package_collisions):
for entry, c in entry_owner.items():
top_level_to_site_pkgs[entry] = c.site_packages

init_owner = None
for c in unique_claimants:
if c.is_pkgutil_ns:
init_owner = c
if init_owner:
top_level_to_site_pkgs[tl + "/__init__.py"] = init_owner.site_packages

# Entries-bearing claimants that kept every one of their
# entries are fully represented by the merged directory; record
# per-wheel coverage so pass 3 can drop them from the .pth
Expand Down
Loading
Loading