blob: 99dc63920078737bad7fff67517d285c326406f5 [file]
# Copyright 2024 The Pigweed Authors
#
# 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
#
# https://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.
"""Recipe module for updating dependencies in Bazel WORKSPACE or MODULE.bazel files.
This module provides the `BazelRollApi` class, which facilitates the automated
rolling of dependencies declared in Bazel build files. It supports updating:
- `git_repository` (or `git_override`) rules by changing their `commit` attribute
to the latest hash from a specified remote and branch.
- `cipd_repository` rules by updating their `tag` attribute to the latest
version resolved via `cipd_roll.select_version`.
Key features include:
- Reading and parsing Bazel files (WORKSPACE, MODULE.bazel).
- Locating specific dependency entries based on attributes like 'remote' (for Git)
or 'path' (for CIPD).
- Modifying the relevant version attribute (e.g., 'commit', 'tag').
- Adding or updating metadata comments near the modified lines to indicate
automatic updates.
- Regenerating `MODULE.bazel.lock`.
- Generating `Roll` objects for standardized commit messages.
- Flexible matching of repository URLs and delayed file writes.
"""
from __future__ import annotations
import dataclasses
import re
from collections.abc import Callable, Sequence
from typing import TYPE_CHECKING
from recipe_engine import config_types, recipe_api
from PB.recipe_modules.pigweed.bazel_roll.cipd_package import BazelCipdPackage
from PB.recipe_modules.pigweed.bazel_roll.git_repository import GitRepository
from RECIPE_MODULES.fuchsia.git_roll_util import api as git_roll_util_api
if TYPE_CHECKING: # pragma: no cover
from RECIPE_MODULES.pigweed.checkout import api as checkout_api
DEFAULT_NUM_NEARBY_LINES = 10
def nwise(iterable, n):
# nwise('ABCDEFG', 3) → ABC BCD CDE DEF EFG
# See also
# https://docs.python.org/3/library/itertools.html#itertools.pairwise
iterator = iter(iterable)
initial_items = [None]
for _ in range(1, n):
initial_items.append(next(iterator, None))
items = tuple(initial_items)
for x in iterator:
items = (*items[1:], x)
yield items
def trim_nearby_lines(lines: Sequence[LineProxy]) -> list[LineProxy]:
start = 0
end = -1
# Find the first blank line in the second half.
for i in range(len(lines) // 2, len(lines)):
text = lines[i].text or ''
if not text.strip():
end = i
break
# Find the last blank line in the first half.
for i in range(len(lines) // 2, 0, -1):
text = lines[i].text or ''
if not text.strip():
start = i
break
return lines[start:end]
def proximity_sort_nearby_lines(
lines: Sequence[LineProxy],
center: int,
) -> list[LineProxy]:
# Shift the order to be center-out instead of ascending.
lines = [(abs(center - x.idx), x.idx, x) for x in lines]
return [x[-1] for x in sorted(lines)]
@dataclasses.dataclass
class UpdateResult:
"""Holds the result of an attempt to update a value in a Bazel file.
Attributes:
old_value: The original value that was found and potentially replaced.
project_name: The name of the project/repository associated with the
update, extracted from attributes like 'name' or 'module_name'.
finalize: An optional callable. If provided (e.g., when `delay_write`
is True), this function must be called to actually perform the file
modification.
"""
old_value: str
project_name: str | None
finalize: Callable[[], None] | None = None
GIT_HASH_PATTERN = '[0-9a-fA-F]{40}'
class LineProxy:
"""A proxy object to interact with a line within a list of strings.
This allows modifying a line in a list indirectly, which can be useful
when passing around references to lines that might be replaced or
deleted from the original list.
"""
def __init__(self, lines: list[str | None], idx: int):
"""Initializes a LineProxy.
Args:
lines: The list of strings (lines of a file).
idx: The index of the line this proxy represents.
"""
self._lines = lines
self._idx = idx
@property
def idx(self) -> int:
"""The index of the line in the original list."""
return self._idx
@property
def text(self) -> str | None:
"""The text content of the line. Can be set to a new string or None."""
return self._lines[self._idx]
@text.setter
def text(self, value: str | None) -> None:
"""Sets the text content of the line."""
self._lines[self._idx] = value
def __repr__(self) -> str:
"""String representation showing index and text."""
return f'{self.idx} {self.text}'
def proxy(lines: list[str | None]) -> list[LineProxy]:
"""Creates a list of LineProxy objects from a list of strings."""
return [LineProxy(lines, i) for i in range(len(lines))]
class BazelRollApi(recipe_api.RecipeApi):
"""API for updating dependencies in Bazel WORKSPACE or MODULE.bazel files.
This includes updating `git_repository` commit hashes and `cipd_repository`
tags. It provides utilities for reading these files, finding relevant
dependency entries, and modifying their versions.
"""
UpdateResult = UpdateResult
GIT_HASH_PATTERN = GIT_HASH_PATTERN
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._read_cache: dict[config_types.Path, Sequence[str]] = {}
def workspace_path(
self,
root: config_types.Path,
value: str,
) -> config_types.Path:
"""Figure out the location of the WORKSPACE or MODULE.bazel file.
If value is '', look for root / 'WORKSPACE' and root / 'MODULE.bazel'.
If exactly one exists, return it. If not, error out.
If root / value is a file, return it.
If root / value is a directory, set root = root / value and apply the
above logic. This enables applying this logic to subdirectories.
Args:
api: Recipe API object.
root: Checkout root.
value: Relative path specified in properties.
Returns:
Path to the WORKSPACE or MODULE.bazel file.
"""
if value:
value_path = root / value
self.m.path.mock_add_file(value_path)
if self.m.path.isfile(value_path):
return value_path
elif self.m.path.isdir(value_path): # pragma: no cover
root = value_path
else:
self.m.step.empty( # pragma: no cover
f'{value_path} does not exist',
status='FAILURE',
)
workspace = root / 'WORKSPACE'
module_bazel = root / 'MODULE.bazel'
self.m.path.mock_add_file(workspace)
if self.m.path.isfile(module_bazel) and self.m.path.isfile(workspace):
self.m.step.empty( # pragma: no cover
f'{module_bazel} and {workspace} both exist',
status='FAILURE',
)
if self.m.path.isfile(module_bazel):
return module_bazel # pragma: no cover
if self.m.path.isfile(workspace):
return workspace
self.m.step.empty( # pragma: no cover
'no WORKSPACE or MODULE.bazel file found',
status='FAILURE',
)
def update_cipd_package(
self,
checkout: checkout_api.CheckoutContext,
package: BazelCipdPackage,
platforms: Sequence[str] | None = None,
) -> list[git_roll_util_api.Roll]:
"""Updates a `cipd_repository` entry in a Bazel workspace file.
This method finds a `cipd_repository` rule in the specified
WORKSPACE or MODULE.bazel file that matches the given package spec.
It then determines the new version tag for this CIPD package (using
`cipd_roll.select_version`) and updates the `tag` attribute in the
Bazel file.
Args:
checkout: The checkout context.
package: A protobuf message defining the CIPD package to update,
including its name, spec, workspace path, ref, and tag key.
platforms: An optional list of platforms to consider for the CIPD
package. Defaults to a standard set of Mac and Linux platforms.
Returns:
A list containing a `Roll` object (from `cipd_roll`) if the
package was updated, or an empty list if no update was needed.
"""
if not package.name:
package.name = self.m.cipd_roll.package_name(package.spec)
# TODO: b/234874582 - Support Windows.
if platforms is None:
platforms = ('mac-amd64', 'mac-arm64', 'linux-amd64')
with self.m.step.nest(package.name):
workspace_path = self.workspace_path(
checkout.root,
package.workspace_path,
)
attrs = self.retrieve_attributes(
project_value=package.spec,
path=workspace_path,
search_key='path',
)
assert len(attrs) == 1, repr(attrs)
old_version = attrs[0]['tag']
new_version = self.m.cipd_roll.select_version(
spec=package.spec,
ref=package.ref,
tag=package.tag,
allow_mismatched_refs=package.allow_mismatched_refs,
platforms=platforms,
old_version=old_version,
)
if not new_version:
# select_version() failing is adequately covered by the
# cipd_roll module.
return [] # pragma: no cover
update_result = self.update_value(
project_value=package.spec,
new_value=new_version,
path=workspace_path,
delay_write=True,
search_key='path',
value_key='tag',
value_pattern=f'{package.tag}:.+',
matches=checkout.remotes_equivalent,
)
if not update_result:
# update_value() failing is adequately covered by
# update_git_repository().
self.m.step.empty( # pragma: no cover
'failed to update commit hash',
status='FAILURE',
)
roll = self.m.cipd_roll.Roll(
package_name=package.name,
package_spec=package.spec,
old_version=old_version,
new_version=new_version,
)
update_result.finalize()
return [roll]
def update_git_repository(
self,
checkout: checkout_api.CheckoutContext,
git_repository: GitRepository,
) -> list[git_roll_util_api.Roll]:
"""Updates a `git_repository` or `git_override` entry in a Bazel file.
This method finds a `git_repository` (or `git_override`) rule in the
specified WORKSPACE or MODULE.bazel file that matches the given remote URL.
It then resolves the latest commit hash for the repository's specified
branch and updates the `commit` attribute in the Bazel file.
Args:
checkout: The checkout context.
git_repository: A protobuf message defining the Git repository to
update, including its remote URL, branch, workspace path, and
name.
Returns:
A list containing a `Roll` object (from `git_roll_util`) if the
repository was updated, or an empty list if no update was needed
(e.g., already up-to-date or a backwards roll).
Raises:
StepFailure: If the `update_value` call fails to find or update the
commit hash.
"""
project_name = git_repository.name
if not project_name:
project_name = git_repository.remote.split('/')[-1]
project_name = project_name.removesuffix('.git')
with self.m.step.nest(project_name):
workspace_path = self.workspace_path(
checkout.root,
git_repository.workspace_path,
)
git_repository.branch = git_repository.branch or 'main'
new_revision = self.m.git_roll_util.resolve_new_revision(
git_repository.remote,
git_repository.branch,
checkout.remotes_equivalent,
)
update_result = self.update_value(
project_value=git_repository.remote,
new_value=new_revision,
path=workspace_path,
delay_write=True,
search_key='remote',
value_key='commit',
value_pattern=GIT_HASH_PATTERN,
matches=checkout.remotes_equivalent,
)
if not update_result:
self.m.step.empty(
'failed to update commit hash',
status='FAILURE',
)
try:
roll = self.m.git_roll_util.get_roll(
repo_url=git_repository.remote,
repo_short_name=project_name,
old_rev=update_result.old_value,
new_rev=new_revision,
)
except self.m.git_roll_util.BackwardsRollError:
props = self.m.step.empty('output property').presentation
props.properties[project_name] = {
'remote': git_repository.remote,
'revision': new_revision,
}
return []
update_result.finalize()
return [roll]
def _read(
self,
path: config_types.Path,
num_nearby_lines: int,
allow_cache: bool = True,
) -> list[str | None]:
"""Reads a file and pads it with empty lines for context searching.
The file content is cached in `self._read_cache` to avoid redundant
reads. `num_nearby_lines` empty strings are added to the beginning and
end of the actual file lines. This padding simplifies searching for
patterns near the start or end of the file by ensuring there's always
enough "context" lines available for `nwise` iteration.
Args:
path: The path to the file to read.
num_nearby_lines: The number of empty lines to pad at the beginning
and end.
allow_cache: If True, uses the cached content if available.
Returns:
A list of strings representing the lines of the file, padded with
empty strings.
"""
lines: list[str | None] = [''] * num_nearby_lines
if allow_cache and path in self._read_cache:
lines.extend(self._read_cache[path])
else:
self._read_cache[path] = (
self.m.file.read_text(
f'read old {path.name}',
path,
test_data=self.m.bazel_roll.test_api.TEST_WORKSPACE_FILE,
)
.strip()
.splitlines()
)
lines.extend(self._read_cache[path])
lines.extend([''] * num_nearby_lines)
return lines
def _get_matching_groups(
self,
lines: Sequence[str],
num_nearby_lines: int,
project_value: str,
search_key: str,
replace: bool = False,
matches: Callable[[str, str], bool] | None = None,
) -> list[tuple[LineProxy, list[LineProxy]]]:
"""Get a list of "groups" of lines from a Bazel file.
Example for search_key "abc" and matches "def:.+". These would be
ignored:
foo(
abc = "def",
foo = "bar",
uvw = "xyz",
)
foo(
abc = "abc",
foo = "foo",
xyz = "xyz",
)
And this would match:
foo(
abc = "def:123",
foo = "foo",
xyz = "xyz",
)
Retrieve the matching groups.
"""
matching_groups: list[tuple[LineProxy, list[LineProxy]]] = []
# This is more constraining than necessary, but ensures there aren't
# special characters that confuse the regex.
assert search_key.isidentifier(), search_key
for nearby_lines in nwise(proxy(lines), num_nearby_lines * 2 + 1):
curr = nearby_lines[len(nearby_lines) // 2]
match = re.search(
rf'^\s*{search_key}\s*=\s*"(?P<search>[^"]+)",?\s*$',
curr.text,
)
if not match:
continue
match_value = match.group('search')
def equals(x, y):
# TODO: b/376915917 - Remove no cover pragma.
return x == y # pragma: no cover
if not matches:
# TODO: b/376915917 - Remove no cover pragma.
matches = equals # pragma: no cover
if matches(match_value, project_value):
pres = self.m.step.empty(
f'found matching {search_key} {match_value!r}'
).presentation
pres.logs['lines'] = [repr(x) for x in nearby_lines]
matching_groups.append((curr, nearby_lines))
if replace and match_value != project_value:
curr.text = curr.text.replace(match_value, project_value)
else:
pres = self.m.step.empty(
f'found other {search_key} {match_value!r}'
).presentation
pres.logs['lines'] = [repr(x) for x in nearby_lines]
return matching_groups
def _process_nearby_lines(
self,
nearby_lines: Sequence[LineProxy],
) -> list[LineProxy]:
"""Processes a list of nearby lines for attribute extraction.
This involves:
1. Trimming blank lines from the periphery of the `nearby_lines` list.
2. Sorting the remaining lines by their proximity to the original
center line (the line that matched the `search_key`).
This helps in prioritizing lines closer to the `search_key` match when
looking for other attributes like `value_key` or `title_keys`.
Args:
nearby_lines: A sequence of `LineProxy` objects representing the
context around a matched line.
Returns:
A processed list of `LineProxy` objects, trimmed and sorted.
"""
pres = self.m.step.empty('lines').presentation
center = nearby_lines[len(nearby_lines) // 2].idx
pres.logs['0_center'] = [str(center)]
pres.logs['1_orig'] = [repr(x) for x in nearby_lines]
nearby_lines = trim_nearby_lines(nearby_lines)
pres.logs['2_trimmed'] = [repr(x) for x in nearby_lines]
nearby_lines = proximity_sort_nearby_lines(nearby_lines, center)
pres.logs['3_sorted'] = [repr(x) for x in nearby_lines]
return nearby_lines
def retrieve_attributes(
self,
project_value: str,
path: config_types.Path,
search_key: str,
num_nearby_lines: int = DEFAULT_NUM_NEARBY_LINES,
title_keys: Sequence[str] = ('name', 'module_name'),
matches: Callable[[str, str], bool] | None = None,
) -> list[dict[str, str]]:
"""Retrieves attributes from matching groups in a Bazel file.
This function searches a Bazel file (WORKSPACE or MODULE.bazel) for
blocks of attributes (like `git_repository` or `cipd_repository`
rules). It identifies these blocks by a `search_key` (e.g., 'remote'
or 'path') matching a `project_value`. For each matching block, it
extracts all key-value attributes.
Args:
project_value: The value to match for the `search_key` (e.g., a
specific remote URL or CIPD package path).
path: The path to the Bazel file.
search_key: The attribute key used to identify relevant blocks
(e.g., 'remote', 'path').
num_nearby_lines: The number of lines around a `search_key` match to
consider as part of the attribute block.
title_keys: A sequence of attribute keys (e.g., 'name',
'module_name') that, if found, will also populate an 'name'
field in the returned dictionary for that block.
matches: An optional callable `(found_value, project_value) -> bool`
to determine if a `search_key`'s value matches the target
`project_value`. Defaults to exact string equality.
Returns:
A list of dictionaries, where each dictionary represents a matched
attribute block. Keys are attribute names, and values are their
corresponding string values. An additional 'name' key might be
present if one of the `title_keys` was found.
"""
lines = self._read(path, num_nearby_lines)
assert isinstance(title_keys, (list, tuple)), repr(title_keys)
matching_groups = self._get_matching_groups(
lines=lines,
num_nearby_lines=num_nearby_lines,
project_value=project_value,
search_key=search_key,
matches=matches,
)
results: list[dict[str, str]] = []
for _, nearby_lines in matching_groups:
entry = {}
nearby_lines = self._process_nearby_lines(nearby_lines)
for line in nearby_lines:
if match := re.search(
r'^\s*([\w_]+)\s*=\s*(\S.*),\s*$',
line.text,
):
entry[match.group(1)] = match.group(2).strip('"')
if match.group(1) in title_keys:
entry['name'] = match.group(2).strip('"')
if entry:
results.append(entry)
return results
def update_value(
self,
*,
project_value: str,
new_value: str,
path: config_types.Path,
search_key: str,
value_key: str,
num_nearby_lines: int = DEFAULT_NUM_NEARBY_LINES,
replace: bool = False,
delay_write: bool = False,
title_keys: Sequence[str] = ('name', 'module_name'),
value_pattern: str | None = None,
matches: Callable[[str, str], bool] | None = None,
) -> UpdateResult | None:
"""Updates a specific attribute's value within a matching block in a Bazel file.
This function locates a block of attributes (e.g., a `git_repository`
rule) using `project_value` and `search_key`. Within that block, it
finds the line corresponding to `value_key` (e.g., 'commit' or 'tag')
and updates its value to `new_value`. It also adds/updates metadata
comments near the modified line.
Args:
project_value: The value for the `search_key` to identify the block.
new_value: The new value to set for the `value_key` attribute.
path: The path to the Bazel file.
search_key: The attribute key to identify the block (e.g., 'remote').
value_key: The attribute key whose value needs to be updated (e.g., 'commit').
num_nearby_lines: Context lines to consider around matches.
replace: If True, and `search_key`'s value doesn't exactly match
`project_value` but `matches()` returns True, the
`search_key`'s value will be updated to `project_value`.
delay_write: If True, the file modification is not performed
immediately. Instead, a `finalize` callable is returned
in `UpdateResult`, which must be called to write changes.
title_keys: Keys to identify the project name for `UpdateResult`.
value_pattern: A regex pattern to match the existing value of `value_key`.
Defaults to a generic pattern if None.
matches: A callable for flexible matching of `project_value`.
Returns:
An `UpdateResult` object containing the old value and project name,
and potentially a `finalize` function if `delay_write` is True.
Returns None if the `project_value` or `value_key` is not found.
"""
lines = self._read(path, num_nearby_lines)
matching_groups = self._get_matching_groups(
lines=lines,
num_nearby_lines=num_nearby_lines,
project_value=project_value,
replace=replace,
search_key=search_key,
matches=matches,
)
if not matching_groups:
self.m.step.empty(
f'could not find {search_key} {project_value} in {path}',
)
return None
project_names: list[str] = []
if value_pattern is None:
# TODO: b/376915917 - Remove no cover pragma.
value_pattern = '[^"]+' # pragma: no cover
for matching_line, nearby_lines in matching_groups:
nearby_lines = self._process_nearby_lines(nearby_lines)
value_rx = re.compile(
rf'^(?P<prefix>\s*{value_key}\s*=\s*")'
rf'(?P<value>{value_pattern})'
r'(?P<suffix>",?\s*)$'
)
for line in nearby_lines:
if match := value_rx.search(line.text):
idx = line.idx
break
else:
pres = self.m.step.empty(
f'could not find {value_key} line adjacent to '
f'{matching_line.text!r} in {path}',
).presentation
pres.step_summary_text = repr(nearby_lines)
return None
old_value = match.group('value')
prefix = match.group('prefix')
suffix = match.group('suffix')
lines[idx] = f'{prefix}{new_value}{suffix}'
# Remove all existing metadata lines in this git_repository() entry.
idx2 = idx - 1
while lines[idx2].strip().startswith('# ROLL: '):
lines[idx2] = None
idx2 -= 1
ws_prefix = re.search(r'^\s*', prefix).group(0)
comment_prefix = f'{ws_prefix}# ROLL: '
comment_lines = (
f'{comment_prefix}Warning: this entry is automatically '
'updated by',
f'{comment_prefix}{self.m.buildbucket.builder_url()}',
)
lines[idx] = '\n'.join(comment_lines + (lines[idx],))
assert isinstance(title_keys, (list, tuple)), repr(title_keys)
# This is more restrictive than necessary but helps keep the regex
# simple.
for key in title_keys:
assert isinstance(key, str), repr(key)
assert key.isidentifier(), repr(key)
for line in nearby_lines:
keys = '|'.join(title_keys)
if match := re.search(
rf'^\s*(?:{keys})\s*=\s*"(?P<value>[^"]+)",?\s*$',
line.text or '',
):
project_names.append(match.group('value'))
break
def write():
self.m.file.write_text(
f'write new {path.name}',
path,
''.join(
f'{x}\n'
for x in lines[num_nearby_lines:-num_nearby_lines]
if x is not None
),
)
if delay_write:
return UpdateResult(
old_value=old_value,
project_name=', '.join(project_names),
finalize=write,
)
else:
write()
return UpdateResult(
old_value=old_value,
project_name=', '.join(project_names),
)