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
2 changes: 1 addition & 1 deletion .agents/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ description: Bazel test execution, integration test flags, and lockfiles
modified or bumped by Dependabot, update the integration lockfile by running
`bazel mod deps --lockfile_mode=update` in `tests/integration/bzlmod_lockfile`
and verify with
`bazel test //tests/integration:bzlmod_lockfile_test_bazel_9.1.0`.
`bazel test //tests/integration:bzlmod_lockfile_test_bazel_9.3.1`.

## Documentation Flake Handling
* When building `//docs:docs` fails with exit code 2, treat it as a known
Expand Down
4 changes: 2 additions & 2 deletions MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ bazel_binaries.local(
)
bazel_binaries.download(version = "7.7.0")
bazel_binaries.download(version = "8.5.1")
bazel_binaries.download(version = "9.1.0")
bazel_binaries.download(version = "9.3.1")
bazel_binaries.download(version = "10.0.0rc2")
use_repo(
bazel_binaries,
Expand All @@ -443,7 +443,7 @@ use_repo(
"build_bazel_bazel_10_0_0rc2",
"build_bazel_bazel_7_7_0",
"build_bazel_bazel_8_5_1",
"build_bazel_bazel_9_1_0",
"build_bazel_bazel_9_3_1",
# "build_bazel_bazel_rolling",
"build_bazel_bazel_self",
)
Expand Down
4 changes: 4 additions & 0 deletions news/4394.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
(bzlmod) Flattened and compacted {obj}`pip` extension facts in
`MODULE.bazel.lock` so Bazel's lockfile merge driver merges concurrent
dependency changes cleanly
([#4162](https://github.com/bazel-contrib/rules_python/issues/4162)).
10 changes: 5 additions & 5 deletions python/private/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -251,11 +251,6 @@ py_library(
],
)

bzl_library(
name = "artifacts_manifest",
srcs = ["artifacts_manifest.bzl"],
)

bzl_library(
name = "attr_builders",
srcs = ["attr_builders.bzl"],
Expand Down Expand Up @@ -945,6 +940,11 @@ bzl_library(
deps = [":py_info"],
)

bzl_library(
name = "artifacts_manifest",
srcs = ["artifacts_manifest.bzl"],
)

bzl_library(
name = "bzlmod_enabled",
srcs = ["bzlmod_enabled.bzl"],
Expand Down
143 changes: 92 additions & 51 deletions python/private/pypi/pypi_cache.bzl
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""A cache for the PyPI index contents evaluation.

This is design to work as the following:
This is designed to work as the following:
- in-memory cache for results of PyPI index queries, so that we are not calling PyPI multiple times
for the same package for different hub repos.

Expand All @@ -15,8 +15,14 @@ load(":version_from_filename.bzl", "version_from_filename")
# recomputed.
#
# v2: the `dist_hashes` values and the `dist_yanked` keys are canonical
# `<algo>:<digest>` strings instead of bare sha256 hex digests.
_FACT_VERSION = "v2"
# `<algo>:<digest>` strings instead of bare sha256 hex digests.
# v3: facts are shallow and compact. Index URL facts are keyed by
# `<package>#<index_url>` and distribution facts are keyed by
# `<root_url> <distribution>`. Each distribution URL maps to a single
# string with only the information that cannot be derived from the URL:
# `<digest>`, `<filename>|<digest>`, `|<digest>|<yanked>` or
# `<filename>|<digest>|<yanked>`.
_FACT_VERSION = "v3"

def pypi_cache(mctx = None, store = None):
"""The cache for PyPI index queries.
Expand Down Expand Up @@ -210,8 +216,14 @@ def _get_from_facts(facts, known_facts, index_url, requested_versions, facts_ver
return None

if type(requested_versions) == "dict":
raw_index_urls = {}
for pkg in requested_versions.keys():
fact_key = pkg + "#" + index_url
if fact_key in known_facts:
raw_index_urls[pkg] = known_facts[fact_key]

result = _filter_packages(
dists = known_facts.get("index_urls", {}).get(index_url, {}),
dists = raw_index_urls,
requested_versions = requested_versions,
)
Comment on lines +219 to 228

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: since raw_index_urls already iterates over requested_versions.keys(), _filter_packages is a no-op copy here—returning None inside the loop when fact_key not in known_facts avoids the extra pass and len(result) != len(requested_versions) check.

This is an agent-generated comment reviewed and approved by the user.

if result:
Expand All @@ -233,18 +245,17 @@ def _get_from_facts(facts, known_facts, index_url, requested_versions, facts_ver
distribution = distribution.rstrip("/")
root_url = root_url.rstrip("/")

fact_key = root_url + " " + distribution

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to your comment about using <distribution>@<root_url> (distribution + "@" + root_url) instead of root_url + " " + distribution—it matches the package-first ordering of pkg + "#" + index_url (while keeping @ distinct from # so index-page and package-page keys can never collide), groups lockfile entries by package name, and avoids another fact_version bump later. (On stripping index_url prefixes from file URLs: since pypi.org/simple serves wheels from files.pythonhosted.org, prefix stripping wouldn't shrink standard PyPI entries anyway.) Worth switching to distribution + "@" + root_url before merging v3?

This is an agent-generated comment reviewed and approved by the user.

retrieved_versions = {}

for url, digest in known_facts.get("dist_hashes", {}).get(root_url, {}).get(distribution, {}).items():
filename = known_facts.get("dist_filenames", {}).get(root_url, {}).get(distribution, {}).get(url)
if not filename:
_, _, filename = url.rpartition("/")
for url, entry in known_facts.get(fact_key, {}).items():
if type(entry) != "string":

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the type check? compatibility with an older lock file? That's fine, just put an explanatory comment

continue

filename, digest, yanked = _decode_dist_entry(entry, url)

version = version_from_filename(filename)
if version not in requested_versions:
# TODO @aignas 2026-01-21: do the check by requested digests at some point
# We don't have sufficient info in the lock file, need to call the API
#
continue

retrieved_versions[version] = True
Expand All @@ -254,7 +265,8 @@ def _get_from_facts(facts, known_facts, index_url, requested_versions, facts_ver
else:
dists = known_sources.setdefault("sdists", {})

known_sources.setdefault("hashes_by_version", {}).setdefault(version, []).append(digest)
if digest:
known_sources.setdefault("hashes_by_version", {}).setdefault(version, []).append(digest)
Comment on lines +268 to +269

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping empty digest values here makes _get_from_facts diverge from parse_simpleapi_html (parse_simpleapi_html.bzl:124) and _filter_packages (pypi_cache.bzl:156,163), which both record [""] in hashes_by_version[version]. parse_requirements.bzl:693-703 iterates hashes_by_version[version] and looks up index_urls.whls.get(digest), so omitting "" causes lockfile hits for hashless SimpleAPI entries + hashless requirements.txt to miss the wheel and fall back to pip. Removing if digest: restores round-trip parity (and lets pypi_cache_tests.bzl:553 expect {"1.0.0": [""]}).

This is an agent-generated comment reviewed and approved by the user.


dists.setdefault(digest, struct(
digest = digest,
Expand All @@ -263,7 +275,7 @@ def _get_from_facts(facts, known_facts, index_url, requested_versions, facts_ver
metadata_url = "",
metadata_sha256 = "",
url = url,
yanked = known_facts.get("dist_yanked", {}).get(root_url, {}).get(distribution, {}).get(digest),
yanked = yanked,
))

if not known_sources:
Expand Down Expand Up @@ -301,51 +313,80 @@ def _store_facts(facts, fact_version, index_url, value):
facts["fact_version"] = fact_version

if type(value) == "dict":
# facts: {
# "index_urls": {
# "<index_url>": {
# "<pkg_normalized>": "<dist_url>",
# },
# },
# },
for pkg, url in value.items():
facts.setdefault("index_urls", {}).setdefault(index_url, {})[pkg] = url
facts[pkg + "#" + index_url] = url
return value

root_url, _, distribution = index_url.rstrip("/").rpartition("/")
distribution = distribution.rstrip("/")
root_url = root_url.rstrip("/")

# The schema is

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These schema comments seem important for understanding. Can we keep them somewhere?

# facts: {
# "dist_hashes": {
# "<index_url>": {
# "<last segment>": {
# # An empty string if the index does not advertise any digest.
# "<dist url>": "<algo>:<digest>",
# },
# },
# },
# "dist_filenames": {
# "<index_url>": {
# "<last segment>": {
# "<dist url>": "<filename>", # if it is different from the URL
# },
# },
# },
# "dist_yanked": {
# "<index_url>": {
# "<last segment>": {
# "<algo>:<digest>": "<reason>", # if the package is yanked
# },
# },
# },
# },
fact_key = root_url + " " + distribution

for digest, d in (value.sdists | value.whls).items():
facts.setdefault("dist_hashes", {}).setdefault(root_url, {}).setdefault(distribution, {}).setdefault(d.url, digest)
if not d.url.endswith(d.filename):
facts.setdefault("dist_filenames", {}).setdefault(root_url, {}).setdefault(distribution, {}).setdefault(d.url, d.filename)
if d.yanked != None:
facts.setdefault("dist_yanked", {}).setdefault(root_url, {}).setdefault(distribution, {}).setdefault(digest, d.yanked)
facts.setdefault(fact_key, {})[d.url] = _encode_dist_entry(digest, d)

return value

def _encode_dist_entry(digest, dist):
"""Encode a distribution into a compact string.

Only the digest is stored if the filename can be derived from the URL and
the distribution is not yanked. Otherwise the extra fields are appended in
a fixed order so that reading the value back is unambiguous:

* `<digest>` - the filename is the URL basename and it is not yanked.
* `<filename>|<digest>` - the filename differs from the URL basename.
* `|<digest>|<yanked>` - the filename is the URL basename and it is yanked.
* `<filename>|<digest>|<yanked>` - both differ.

The empty filename field in the yanked case keeps the fields in a fixed
order and means that the yank reason may itself contain `|`.

Args:
digest: {type}`str` The canonical `<algo>:<digest>` value.
dist: {type}`struct` The distribution struct to encode.

Returns:
{type}`str` The compact representation of the distribution.
"""
_, _, default_filename = dist.url.rpartition("/")
has_diff = dist.filename != default_filename
is_yanked = dist.yanked != None

parts = []
if has_diff:
parts.append(dist.filename)
elif is_yanked:
parts.append("")

parts.append(digest)

if is_yanked:
parts.append(dist.yanked)

return "|".join(parts)

def _decode_dist_entry(entry, url):
"""Decode a distribution from its compact string representation.

Args:
entry: {type}`str` The compact representation created by
`_encode_dist_entry`.
url: {type}`str` The distribution URL, used to derive the default
filename.

Returns:
A tuple of `(filename, digest, yanked)`.
"""
_, _, default_filename = url.rpartition("/")
parts = entry.split("|", 2)

if len(parts) == 1:
return default_filename, parts[0], None

filename = parts[0] or default_filename
if len(parts) == 2:
return filename, parts[1], None

return filename, parts[1], parts[2]
4 changes: 2 additions & 2 deletions tests/integration/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@ default_test_runner(

rules_python_integration_test(
name = "bzlmod_lockfile_test",
bazel_versions = ["9.1.0"],
bazel_versions = ["9.3.1"],
py_main = "bzlmod_lockfile_test.py",
)

test_suite(
name = "subset",
tags = ["manual"],
tests = [
"bzlmod_lockfile_test_bazel_9.1.0",
"bzlmod_lockfile_test_bazel_9.3.1",
"local_toolchains_test_bazel_self",
"uv_lock_test_bazel_self",
"validate_test_main_test_bazel_self",
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/bzlmod_lockfile/.bazelversion
Original file line number Diff line number Diff line change
@@ -1 +1 @@
9.1.0
9.3.1
Comment thread
rickeylev marked this conversation as resolved.
Resolved
Loading
Loading