blob: e8d76eeba5b67a6c43e5c5e16dcb6848db505633 [file]
# Copyright 2023 The Bazel Authors. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""A small utility to patch a file in the repository context natively.
This replaces the previous approach that depended on running Python to
repack patched wheels. Instead, after extracting and patching, we fix
the RECORD file natively in Starlark and repack using system zip.
"""
load("@rules_python_internal//:rules_python_config.bzl", rp_config = "config")
load("//python/private:repo_utils.bzl", "repo_utils")
load(":parse_whl_name.bzl", "parse_whl_name")
_DUMMY_SHA256 = "sha256=0"
_RECORD_EXCLUDES = {
"INSTALLER": None,
"RECORD": None,
"RECORD.jws": None,
"RECORD.p7s": None,
"REQUESTED": None,
}
def patched_whl_name(original_whl_name):
"""Return the new filename to output the patched wheel.
Args:
original_whl_name: {type}`str` the whl name of the original file.
Returns:
{type}`str` an output name to write the patched wheel to.
"""
parsed_whl = parse_whl_name(original_whl_name)
version = parsed_whl.version
suffix = "patched"
if "+" in version:
version = "{}.{}".format(version, suffix)
else:
version = "{}+{}".format(version, suffix)
return "{distribution}-{version}-{python_tag}-{abi_tag}-{platform_tag}.whl".format(
distribution = parsed_whl.distribution,
version = version,
python_tag = parsed_whl.python_tag,
abi_tag = parsed_whl.abi_tag,
platform_tag = parsed_whl.platform_tag,
)
def patch_whl(rctx, *, whl_path, patches):
"""Patch a whl file and repack it natively.
The wheel is extracted, patched, missing RECORD entries are added
with dummy sha256 and size values, and finally the wheel is
repacked using system zip.
Args:
rctx: repository_ctx
whl_path: The whl file name to be patched.
patches: a label-keyed-int dict that has the patch files as keys and
the patch_strip as the value.
Returns:
value of the repackaging action.
"""
whl_input = rctx.path(whl_path)
repo_utils.extract(
rctx,
archive = whl_input,
supports_whl_extraction = rp_config.supports_whl_extraction,
extract_needs_chmod = rp_config.extract_needs_chmod,
)
if not patches:
fail("Trying to patch wheel without any patches")
for patch_file, patch_strip in patches.items():
rctx.patch(patch_file, strip = patch_strip)
_fix_record(rctx)
whl_patched = patched_whl_name(whl_input.basename)
rctx.delete(whl_input)
_repack_whl(rctx, output = whl_patched)
return rctx.path(whl_patched)
def _fix_record(rctx):
"""Add missing file entries to RECORD with dummy sha256 and file size."""
for entry in rctx.path(".").readdir():
if not entry.basename.endswith(".dist-info"):
continue
record_path = entry.get_child("RECORD")
if not record_path.exists:
continue
all_files = _collect_files(rctx)
record_rel = repo_utils.repo_root_relative_path(rctx, record_path)
new_content = fix_record_content(
record_content = rctx.read(record_path),
all_files = all_files,
record_rel = record_rel,
)
if new_content != None:
rctx.file(record_path, new_content)
def fix_record_content(record_content, all_files, record_rel):
"""Add missing file entries to RECORD content with dummy sha256 and size.
Args:
record_content: {type}`str` The existing RECORD file content.
all_files: {type}`dict[str, bool]` All files in the directory, keys
are repo-root-relative paths.
record_rel: {type}`str` The repo-root-relative path to the RECORD file.
Returns:
{type}`str | None` The new RECORD content if entries were added,
or None if no changes were needed.
"""
has_trailing_newline = record_content.endswith("\n")
lines = record_content.split("\n")
is_all_quoted = True
has_content = False
existing = {}
for line in lines:
stripped = line.strip()
if not stripped:
continue
has_content = True
if not stripped.startswith('"'):
is_all_quoted = False
parts = stripped.split(",")
if len(parts) >= 1:
fname = parts[0].strip('"')
existing[fname] = True
if not has_content:
is_all_quoted = False
existing[record_rel] = True
added = []
for fpath in sorted(all_files.keys()):
if fpath in existing:
continue
basename = fpath.split("/")[-1] if "/" in fpath else fpath
if basename in _RECORD_EXCLUDES:
continue
if fpath.endswith(".whl"):
continue
added.append(fpath)
if not added:
return None
new_lines = list(lines)
if not has_trailing_newline:
new_lines.append("")
if is_all_quoted:
for fpath in added:
new_lines.append('"{file}",{sha256},0'.format(
file = fpath,
sha256 = _DUMMY_SHA256,
))
else:
for fpath in added:
new_lines.append("{file},{sha256},0".format(
file = fpath,
sha256 = _DUMMY_SHA256,
))
new_lines.append("")
return "\n".join(new_lines)
def _collect_files(rctx):
"""Collect all file paths relative to the repo root (iterative)."""
result = {}
paths = [(rctx.path("."), "")]
for _ in range(10000000):
if not paths:
break
path, prefix = paths.pop()
for entry in path.readdir():
rel = prefix + "/" + entry.basename if prefix else entry.basename
if entry.is_dir:
paths.append((entry, rel))
else:
result[rel] = True
return result
def _repack_whl(rctx, *, output):
"""Repack the current directory into a wheel file using system zip."""
os_name = repo_utils.get_platforms_os_name(rctx)
if os_name == "windows":
_repack_whl_windows(rctx, output = output)
else:
_repack_whl_unix(rctx, output = output)
def _repack_whl_unix(rctx, *, output):
repo_utils.execute_checked(
rctx,
op = "PatchWhl",
arguments = [
"zip",
"-0",
"-X",
str(output),
"-r",
".",
],
)
def _repack_whl_windows(rctx, *, output):
powershell_exe = rctx.which("powershell.exe") or rctx.which("powershell")
if not powershell_exe:
fail("powershell not found on PATH")
script_path = rctx.path(Label("//python/private/pypi:repack_whl.ps1"))
repo_utils.execute_checked(
rctx,
op = "PatchWhl",
arguments = [
powershell_exe,
"-NoProfile",
"-File",
str(script_path),
"-Output",
str(output),
],
)