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
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ Unreleased changes are tracked as individual files in the [news/](./news)
directory, or view the [latest generated
changelog](https://rules-python.readthedocs.io/en/latest/changelog.html).

{#v2-3-2}
## [2.3.2] - 2026-08-22

[2.3.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.3.2

{#v2-3-2-fixed}
### Fixed
* (pypi) Fixed analysis failures in {obj}`pip.parse` for source-less wheels with
dependencies. ([#4053](https://github.com/bazel-contrib/rules_python/issues/4053))
* (pypi) Fixed the handling of optional args for the {obj}`pip_archive` and {obj}`whl_archive`
repository rules within the {obj}`whl_library`. From now on we are dropping unsupported args.
* (pypi) Fixed {obj}`pip.parse` repository names for Git sources in `uv.lock`
files by excluding URL query and fragment components
([#4084](https://github.com/bazel-contrib/rules_python/issues/4084)).

{#v2-3-1}
## [2.3.1] - 2026-08-14

Expand Down Expand Up @@ -2609,4 +2624,4 @@ Breaking changes:
* (pip) Create all_data_requirements alias
* Expose Python C headers through the toolchain.

[0.24.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.24.0
[0.24.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.24.0
2 changes: 2 additions & 0 deletions docs/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ sphinx_stardocs(
"//python/private/api:py_common_api",
"//python/private/pypi:config_settings",
"//python/private/pypi:env_marker_info",
"//python/private/pypi:pip_archive",
"//python/private/pypi:pkg_aliases",
"//python/private/pypi:whl_archive",
"//python/private/pypi:whl_config_setting",
"//python/private/pypi:whl_library",
"//python/private/zipapp:py_zipapp_rule",
Expand Down
38 changes: 38 additions & 0 deletions python/private/py_library.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,34 @@ This allows optimizing the generation of symlinks to be cheaper at analysis time
},
)

def _validate_srcs(ctx):
"""Validate that srcs targets provide Python sources or Python metadata."""
for target in ctx.attr.srcs:
files = target[DefaultInfo].files.to_list()
if not files and (
PyInfo in target or
(BuiltinPyInfo != None and BuiltinPyInfo in target)
):
continue

found_match = False
for file in files:
if file.is_directory or file.extension in ("py", "py3"):
found_match = True
break

if found_match:
continue

fail(
("{} does not produce any py_library srcs files " +
"(expected .py or .py3) and is not an empty target providing " +
"PyInfo").format(
target.label,
),
attr = "srcs",
)

def py_library_impl(ctx):
"""Abstract implementation of py_library rule.

Expand All @@ -127,6 +155,7 @@ def py_library_impl(ctx):
Returns:
A list of modern providers to propagate.
"""
_validate_srcs(ctx)
direct_sources = filter_to_py_srcs(ctx.files.srcs)

precompile_result = maybe_precompile(ctx, direct_sources)
Expand Down Expand Up @@ -297,4 +326,13 @@ def create_py_library_rule_builder():
ruleb.ToolchainType(EXEC_TOOLS_TOOLCHAIN_TYPE, mandatory = False),
],
)
srcs_attr = builder.attrs.get("srcs")
srcs_attr.set_allow_files(True)
srcs_attr.set_doc(srcs_attr.doc() + """

:::{versionchanged} 2.3.2
As an exception, empty targets in `srcs` that provide {obj}`PyInfo` are
allowed. Ordinary library dependencies should remain in `deps`.
:::
""")
return builder
71 changes: 57 additions & 14 deletions python/private/pypi/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -464,20 +464,8 @@ bzl_library(
name = "whl_library",
srcs = ["whl_library.bzl"],
deps = [
":attrs",
":deps",
":generate_whl_library_build_bazel",
":patch_whl",
":pep508_requirement",
":pypi_repo_utils",
":urllib",
":whl_extract",
":whl_metadata",
"//python/private:auth",
"//python/private:envsubst",
"//python/private:is_standalone_interpreter",
"//python/private:normalize_name",
"//python/private:repo_utils",
":pip_archive",
":whl_archive",
],
)

Expand Down Expand Up @@ -528,6 +516,61 @@ bzl_library(
deps = [":hash"],
)

bzl_library(
name = "pip_archive",
srcs = ["pip_archive.bzl"],
deps = [
":attrs",
":deps",
":patch_and_extract_whl",
":pypi_repo_utils",
":urllib",
":whl_archive",
"//python/private:auth",
"//python/private:envsubst",
"//python/private:is_standalone_interpreter",
"//python/private:repo_utils",
],
)

bzl_library(
name = "whl_archive",
srcs = ["whl_archive.bzl"],
deps = [
":attrs",
":patch_and_extract_whl",
":urllib",
":whl_deps_repo",
"//python/private:auth",
"//python/private:repo_utils",
],
)

bzl_library(
name = "whl_deps_repo",
srcs = ["whl_deps_repo.bzl"],
deps = [
":generate_whl_library_build_bazel",
":pep508_requirement",
"//python/private:repo_utils",
],
)

bzl_library(
name = "patch_and_extract_whl",
srcs = ["patch_and_extract_whl.bzl"],
deps = [
":generate_whl_library_build_bazel",
":patch_whl",
":pep508_requirement",
":pypi_repo_utils",
":whl_extract",
":whl_metadata",
"//python/private:normalize_name",
"//python/private:repo_utils",
],
)

bzl_library(
name = "argparse",
srcs = ["argparse.bzl"],
Expand Down
6 changes: 5 additions & 1 deletion python/private/pypi/parse_requirements.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,11 @@ def _parse_uv_lock_json(uv_lock, all_platforms, logger, extra_pip_args = None, p
git_struct = None
if pkg.get("source", {}).get("git"):
url = pkg["source"]["git"]
_, _, filename = url.rpartition("/")

# Keep the revision in the URL, but exclude it from the repository filename.
url_path, _, _ = url.partition("?")
url_path, _, _ = url_path.partition("#")
_, _, filename = url_path.rpartition("/")
git_struct = struct(
filename = filename,
url = url,
Expand Down
164 changes: 164 additions & 0 deletions python/private/pypi/patch_and_extract_whl.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
""

load("//python/private:normalize_name.bzl", "normalize_name")
load("//python/private:repo_utils.bzl", "repo_utils")
load(":generate_whl_library_build_bazel.bzl", "generate_whl_library_build_bazel")
load(":patch_whl.bzl", "patch_whl")
load(":pep508_requirement.bzl", "requirement")
load(":pypi_repo_utils.bzl", "pypi_repo_utils")
load(":whl_extract.bzl", "whl_extract")
load(":whl_metadata.bzl", "parse_entry_points", "whl_metadata")

def _get_entry_points(rctx, install_dir_path, metadata):
dist_info_dir = "{}-{}.dist-info".format(
metadata.name.replace("-", "_"),
metadata.version.replace("-", "_"),
)
entry_points_txt = install_dir_path.get_child(dist_info_dir).get_child("entry_points.txt")
if entry_points_txt.exists:
return parse_entry_points(rctx.read(entry_points_txt))
return {}

def _move_scripts_needing_shebang_rewrite(rctx, entry_points):
bin_dir = rctx.path("bin")
if not bin_dir.exists:
return

ep_names = {name.lower(): True for name in entry_points}
for script in bin_dir.readdir():
if script.is_dir:
continue
if script.basename.lower() in ep_names:
rctx.delete(script)
continue
if script.basename.endswith(".exe") or script.basename.endswith(".dll"):
continue
content = rctx.read(script)
if content.startswith("#!python"):
rewrite_bin_dir = rctx.path("rewrite-bin")
repo_utils.mkdir(rctx, rewrite_bin_dir)
repo_utils.rename(rctx, script, rctx.path("rewrite-bin/" + script.basename))

def _to_purl(*, index, metadata, filename):
"""
Produce a PyPI PURL from the metadata.

https://github.com/package-url/purl-spec/blob/main/types-doc/pypi-definition.md
"""

# https://github.com/package-url/purl-spec/blob/main/types-doc/pypi-definition.md#name-definition
name = normalize_name(metadata.name).replace("_", "-")

qualifiers = {}
if index:
qualifiers["repository_url"] = index
if filename:
qualifiers["file_name"] = filename

return "pkg:pypi/{}@{}?{}".format(name, metadata.version, "&".join(["{}={}".format(key, val) for key, val in qualifiers.items()]))

def _remove_files(rctx, *basenames):
paths = list(rctx.path(".").readdir())
for _ in range(10000000):
if not paths:
break
path = paths.pop()

if path.basename in basenames:
rctx.delete(path)
elif path.is_dir:
paths.extend(path.readdir())

def patch_and_extract_whl(rctx, *, whl_path, logger, sdist_filename = None):
"""Extract the wheel, apply patches and generate BUILD.bazel files.

Reused in pip and http wheel download code.

Args:
rctx: the repository ctx.
whl_path: the whl path to extract.
logger: The logger to use
sdist_filename: The filename to ignore in the BUILD.bazel files as sources.

Returns:
The repository metadata if the extraction is reproducible
"""
if rctx.attr.whl_patches:
patches = {}
for patch_file, json_args in rctx.attr.whl_patches.items():
patch_dst = struct(**json.decode(json_args))
if whl_path.basename in patch_dst.whls:
patches[patch_file] = patch_dst.patch_strip

if patches:
whl_path = patch_whl(
rctx,
whl_path = whl_path,
patches = patches,
)

whl_extract(rctx, whl_path = whl_path, logger = logger)

install_dir_path = whl_path.dirname.get_child("site-packages")
metadata = whl_metadata(
install_dir = install_dir_path,
read_fn = rctx.read,
logger = logger,
)
rctx.file("metadata.json", json.encode_indent({
"name": metadata.name,
"provides_extra": metadata.provides_extra,
"requires_dist": metadata.requires_dist,
"version": metadata.version,
}))
namespace_package_files = pypi_repo_utils.find_namespace_package_files(rctx, install_dir_path)

entry_points = _get_entry_points(rctx, install_dir_path, metadata)
_move_scripts_needing_shebang_rewrite(rctx, entry_points)

build_file_contents = generate_whl_library_build_bazel(
name = whl_path.basename,
dep_template = rctx.attr.dep_template,
sdist_filename = sdist_filename,
config_load = rctx.attr.config_load,
metadata_name = metadata.name,
metadata_version = metadata.version,
requires_dist = metadata.requires_dist,
# TODO @aignas 2025-05-17: maybe have a build flag for this instead
enable_implicit_namespace_pkgs = rctx.attr.enable_implicit_namespace_pkgs,
# TODO @aignas 2025-04-14: load through the hub:
annotation = None if not rctx.attr.annotation else struct(**json.decode(rctx.read(rctx.attr.annotation))),
data_exclude = rctx.attr.pip_data_exclude,
group_deps = rctx.attr.group_deps,
group_name = rctx.attr.group_name,
namespace_package_files = namespace_package_files,
extras = requirement(rctx.attr.requirement).extras,
entry_points = entry_points,
purl = _to_purl(
index = rctx.attr.index_url,
metadata = metadata,
filename = sdist_filename or whl_path.basename,
),
)

# Delete these in case the wheel had them. They generally don't cause
# a problem, but let's avoid the chance of that happening.
rctx.file("WORKSPACE")
rctx.file("WORKSPACE.bazel")
rctx.file("MODULE.bazel")
rctx.file("REPO.bazel", """\
repo(
default_package_metadata = [
"//:package_metadata",
],
)
""")

# BUILD files interfere with globbing and Bazel package boundaries.
_remove_files(rctx, "BUILD", "BUILD.bazel")
rctx.file("BUILD.bazel", build_file_contents)

if hasattr(rctx, "repo_metadata"):
return rctx.repo_metadata(reproducible = True)

return None
Loading