| # 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), |
| ], |
| ) |