blob: feb46f42ba9088740b49dcb23aaeb528623a4185 [file] [log] [blame] [edit]
# Copyright 2025 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 version pins stored as variables in text files.
This module provides the `VariableRollApi` for scenarios where a dependency's
commit hash (or other version string) is embedded directly within a text-based
file as a variable assignment. For example:
MY_VARIABLE = "commit_hash_or_version_string"
# or
OTHER_VARIABLE = 'another_hash'
The API can:
- Locate such variable assignments within a file.
- Resolve the latest commit hash from a specified remote Git repository and branch.
- Update the variable's value to this new hash.
- Add or update metadata comments near the variable to indicate it's
automatically managed.
- Generate `Roll` objects for standardized commit messages.
"""
from __future__ import annotations
import dataclasses
import re
from collections.abc import Callable, Sequence
from recipe_engine import config_types, recipe_api
from PB.recipe_modules.pigweed.variable_roll.variable_entry import (
CipdVariableEntry,
VariableEntry,
)
from RECIPE_MODULES.fuchsia.git_roll_util import api as git_roll_util_api
from RECIPE_MODULES.pigweed.checkout import api as checkout_api
@dataclasses.dataclass
class RevisionChange:
"""Represents a change in a revision string, typically a commit hash.
Attributes:
old: The old revision string.
new: The new revision string.
finalize: A callable that, when invoked, performs the actual update
(e.g., writing the new revision to a file). This allows
deferring the write operation.
"""
old: str
new: str
finalize: Callable[[], None] = dataclasses.field(repr=False)
class VariableRollApi(recipe_api.RecipeApi):
"""API for updating version pins stored as variables in text files.
This module handles scenarios where a dependency's commit hash is stored
in a file like:
MY_VARIABLE = "commit_hash_here"
It can find such variables, update them to the latest commit from a
specified remote and branch, and add metadata comments.
"""
def _regex(
self,
variable: str,
value_regex: str,
) -> re.Pattern:
return re.compile(
r'^'
r'(?P<prefix>\s*)'
rf'(?P<variable>{variable})'
r'(?P<equal>\s*=\s*)'
r'(?P<quote>[\'"]?)'
rf'(?P<value>{value_regex})'
r'(?P=quote)'
r'(?P<suffix>\s*)'
r'$',
)
def _get_variable_value(
self,
*,
path: config_types.Path,
variable: str,
value_regex: str,
test_data: str | None = None,
) -> str | None:
lines = self.m.file.read_text(
'read',
path,
test_data=test_data,
).splitlines()
for line in lines:
regex = self._regex(
variable=variable,
value_regex=value_regex,
)
if match := regex.search(line):
return match.group('value')
return None # pragma: no cover
def _update_variable(
self,
*,
path: config_types.Path,
variable: str,
new_revision: str,
value_regex: str,
comment_prefix: str,
test_data: str | None = None,
) -> RevisionChange | None:
"""Internal helper to find and prepare an update for a variable in a file.
Reads the specified file, searches for a line matching the variable
assignment pattern (e.g., `VAR = 'hash'`), and if found, prepares
the lines to be written with the new revision and metadata comments.
Args:
path: The absolute path to the file to update.
variable: The name of the variable to search for.
new_revision: The new commit hash to set for the variable.
value_regex: The pattern the current variable value is expected to
match.
comment_prefix: The string to use for starting comment lines
(e.g., '#').
test_data: Optional test data to use for file content in tests.
Returns:
A `RevisionChange` object if the variable was found and an update
is pending, or None if the variable was not found.
"""
lines = self.m.file.read_text(
'read',
path,
test_data=test_data,
).splitlines()
for i, line in enumerate(lines):
if line.strip().startswith(f'{comment_prefix} ROLL:'):
lines[i] = None
continue
match = self._regex(
variable=variable,
value_regex=value_regex,
).search(line)
if not match:
continue
metadata_prefix = f'{match.group("prefix")}{comment_prefix} ROLL: '
now = self.m.time.utcnow().strftime('%Y-%m-%d')
new_lines = [
f'{metadata_prefix}Warning: this variable is automatically '
'updated.',
f'{metadata_prefix}Last updated {now}.',
f'{metadata_prefix}By {self.m.buildbucket.build_url()}.',
]
new_lines.append(
''.join(
[
match.group('prefix'),
match.group('variable'),
match.group('equal'),
match.group('quote'),
new_revision,
match.group('quote'),
match.group('suffix'),
],
),
)
lines[i] = '\n'.join(new_lines)
def update_file():
self.m.file.write_text(
'write new revision',
path,
''.join(f'{x}\n' for x in lines if x is not None),
)
return RevisionChange(
old=match.group('value'),
new=new_revision,
finalize=update_file,
)
def update(
self,
checkout: checkout_api.CheckoutContext,
variable_entry: VariableEntry,
test_data: str | None = None,
) -> list[git_roll_util_api.Roll]:
"""Updates a variable in a file to a new commit hash from a remote.
This function orchestrates the update of a version pin. It resolves the
latest revision from the specified remote and branch, then uses
`_update_variable` to find and modify the variable in the target file.
If an update occurs, it generates a `Roll` object for commit message
formatting.
Args:
checkout: The checkout context.
variable_entry: A protobuf message defining the variable to update,
including file path, variable name, remote URL,
branch, and comment prefix.
test_data: Optional test data for file content, used during testing.
Returns:
A list containing a `Roll` object if the variable was updated,
or an empty list if no update was needed (e.g., already up-to-date,
backwards roll, or variable not found).
"""
variable_entry.comment_prefix = variable_entry.comment_prefix or '#'
with self.m.step.nest(
f'{variable_entry.path}[{variable_entry.variable}]',
):
branch = variable_entry.branch or 'main'
new_revision = self.m.git_roll_util.resolve_new_revision(
variable_entry.remote,
branch,
checkout.remotes_equivalent,
)
full_path = checkout.root / variable_entry.path
if test_data is None:
test_data = (
f"BAR = '{'1' * 40}'\n"
'# ROLL: Comment\n'
f'FOO = "{"0" * 40}"\n'
f'BAZ = "{"2" * 40}"\n'
)
change = self._update_variable(
path=full_path,
variable=variable_entry.variable,
new_revision=new_revision,
value_regex=r'[0-9a-fA-F]{40}',
comment_prefix=variable_entry.comment_prefix,
test_data=test_data,
)
if not change:
self.m.step.empty(
f'variable {variable_entry.variable} not found',
)
return []
short_name = f'{variable_entry.path}[{variable_entry.variable}]'
try:
roll = self.m.git_roll_util.get_roll(
repo_url=variable_entry.remote,
repo_short_name=short_name,
old_rev=change.old,
new_rev=new_revision,
)
except self.m.git_roll_util.BackwardsRollError:
props = self.m.step.empty('output property').presentation
props.properties[short_name] = {
'remote': variable_entry.remote,
'revision': new_revision,
}
return []
change.finalize()
return [roll]
def update_cipd(
self,
checkout: checkout_api.CheckoutContext,
variable_entry: CipdVariableEntry,
platforms: Sequence[str] | None = None,
test_data: str | None = None,
):
with self.m.step.nest(
f'{variable_entry.path}[{variable_entry.variable}]',
):
variable_entry.comment_prefix = variable_entry.comment_prefix or '#'
if not variable_entry.name:
variable_entry.name = self.m.cipd_roll.package_name(
variable_entry.spec,
)
chars = r'@.,\w-'
value_regex = rf'[{chars}]+(?::[{chars}]+)?'
# TODO: b/234874582 - Support Windows.
if platforms is None:
platforms = ('mac-amd64', 'mac-arm64', 'linux-amd64')
if test_data is None:
test_data = (
f"BAR = 'git_revision:{'1' * 40}'\n"
'# ROLL: Comment\n'
f'FOO = "git_revision:{"0" * 40}"\n'
f'BAZ = "git_revision:{"2" * 40}"\n'
)
path = checkout.root / variable_entry.path
old_version = self._get_variable_value(
path=path,
variable=variable_entry.variable,
value_regex=value_regex,
test_data=test_data,
)
if not old_version: # pragma: no cover
self.m.step.empty(f"couldn't find {variable_entry.variable}")
return []
new_version = self.m.cipd_roll.select_version(
spec=variable_entry.spec,
ref=variable_entry.ref,
tag=variable_entry.tag,
allow_mismatched_refs=variable_entry.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
change = self._update_variable(
path=path,
variable=variable_entry.variable,
new_revision=new_version,
value_regex=value_regex,
comment_prefix=variable_entry.comment_prefix,
test_data=test_data,
)
assert change, 'should never get here: already found variable'
roll = self.m.cipd_roll.Roll(
package_name=variable_entry.name,
package_spec=variable_entry.spec,
old_version=old_version,
new_version=new_version,
)
change.finalize()
return [roll]