blob: 773ffb4b4d119ff0f610fcc71033535c4391ba13 [file]
# Copyright 2026 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.
"""Data models for the checkout recipe module."""
from __future__ import annotations
import dataclasses
import re
from typing import Any
from recipe_engine import config_types, recipe_api
from PB.recipe_modules.pigweed.checkout.options import Options
def to_dict(obj) -> dict[str, Any]:
try:
# Modifications to the dict returned by the built-in vars() function
# modify the original data structure. Always create a copy for this
# function to return.
return __builtins__['vars'](obj).copy()
except TypeError: # pragma: no cover
keys = [x for x in obj.__slots__ if not x.startswith('__')]
return {k: getattr(obj, k) for k in keys}
@dataclasses.dataclass
class Manifest:
remotes: dict[str, Remote] = dataclasses.field(default_factory=dict)
projects: list[Project] = dataclasses.field(default_factory=list)
def dict(self) -> dict[str, Any]:
return {
'remotes': {k: v.dict() for k, v in self.remotes.items()},
'projects': [x.dict() for x in self.projects],
}
class Url:
def __init__(self, url: str, *args, **kwargs):
super().__init__(*args, **kwargs)
self.url: str = url
self.https: str | None = None
def dict(self) -> dict[str, Any]:
return to_dict(self)
@dataclasses.dataclass
class Remote:
"""Remote config from manifest."""
name: str
fetch: Url
review: str | None = None
revision: str | None = None
alias: str | None = None
def dict(self) -> dict[str, Any]:
res = to_dict(self)
res['fetch'] = res['fetch'].dict()
return res
@dataclasses.dataclass
class Project:
"""Key variables describing a repository/project."""
name: str
path: str
remote: str
revision: str
upstream: str
url: str | None = None
def path_object(self, root: config_types.Path) -> config_types.Path:
return root / self.path
def dict(self) -> dict[str, Any]:
return to_dict(self)
def _str_or_none(x: Any | None) -> str | None:
if x is None:
return x
return str(x)
def _int_or_none(x: Any | None) -> int | None:
if x is None:
return x
return int(x)
@dataclasses.dataclass
class Change:
"""Data from buildbucket."""
number: int
remote: str | None
ref: str | None
rebase: bool | None = None
project: str | None = None
branch: str | None = None
gerrit_name: str | None = None
submitted: bool = False
patchset: int | None = None
applied: bool = dataclasses.field(default=False, repr=False)
path: str | None = None
base: str | None = None
base_type: str | None = None
is_merge: bool = False
commit_message: str = ''
topic: str | None = None
current_revision: str | None = None
primary: bool = False
submit_requirements: dict[str, str] = dataclasses.field(
default_factory=dict,
)
uploader: str | None = None
def __post_init__(self):
self.number = _int_or_none(self.number)
self.remote = _str_or_none(self.remote)
self.ref = _str_or_none(self.ref)
self.branch = _str_or_none(self.branch)
self.gerrit_name = _str_or_none(self.gerrit_name)
self.patchset = _int_or_none(self.patchset)
self.base = _str_or_none(self.base)
self.base_type = _str_or_none(self.base_type)
@property
def gerrit_host(self) -> str:
return f'https://{self.gerrit_name}-review.googlesource.com'
@property
def gerrit_url(self) -> str:
if not self.number:
return self.gitiles_url
return f'{self.gerrit_host}/c/{self.number}'
@property
def gitiles_url(self) -> str:
return f'{self.remote}/+/{self.ref}'
@property
def name(self) -> str:
return f'{self.gerrit_name}:{self.number}'
@property
def name_with_path(self) -> str:
return f'{self.name} ({self.path})'
@dataclasses.dataclass
class Submodule:
"""Submodule properties."""
api: recipe_api.RecipeApi = dataclasses.field(repr=False)
hash: str
relative_path: str
path: config_types.Path
name: str
describe: str
remote: str
initialized: bool
modified: bool
conflict: bool
branch: str
url: str
update: str
ignore: str
shallow: bool
fetchRecurseSubmodules: bool
def __lt__(self, other: Submodule) -> bool:
return (self.relative_path, self.url) < (
other.relative_path,
other.url,
)
@dataclasses.dataclass
class StatusOfChanges:
"""Changes that were applied or not applied."""
applied: tuple[Change, ...]
not_applied: tuple[Change, ...]
@dataclasses.dataclass(slots=False)
class CheckoutContext:
_api: recipe_api.RecipeApi = dataclasses.field(repr=False)
options: Options | None = None
changes: list[Change] | None = None # List of triggering changes.
top: config_types.Path = None # Actual checkout root.
# Logical checkout root. Usually identical to 'top', but occasionally a
# subdirectory instead.
root: config_types.Path = None
# Which triggering changes were applied or not applied.
status: StatusOfChanges | None = None
# Remotes that should be treated identically.
equivalent_remotes: dict[str, list[str]] | None = dataclasses.field(
default_factory=dict
)
manifest: Manifest | None = None # Parsed repo manifest.
# Path to a JSON file containing metadata about the triggering changes.
changes_json: config_types.Path | None = None
bazel_overrides: dict[str, config_types.Path] = dataclasses.field(
default_factory=dict
)
# Current revision number.
def revision(self) -> str:
if hasattr(self, '_revision'):
return self._revision
self._revision = self._api.checkout.get_revision(self.top)
return self._revision
@property
def manifest_path(self) -> config_types.Path:
return self.top / self.options.manifest_file
def applied_changes(self) -> list[Change]:
return [x for x in self.changes if x.applied]
# Repo manifest with all projects pinned.
def manifest_snapshot(self):
if not self.options.use_repo:
return None
if hasattr(self, '_manifest_snapshot'):
return self._manifest_snapshot
with self._api.context(cwd=self.top):
self._manifest_snapshot = self._api.repo.manifest_snapshot()
return self._manifest_snapshot
# Equivalent of manifest_snapshot() but not as strictly formatted.
def submodule_snapshot(self):
if self.options.use_repo:
return None
if hasattr(self, '_submodule_snapshot'):
return self._submodule_snapshot
with self._api.context(cwd=self.top):
# To get step_test_data line to pass pylint.
raw_io_stream_output = self._api.raw_io.test_api.stream_output_text
self._submodule_snapshot = (
self._api.git(
'submodule-status',
'submodule',
'status',
'--recursive',
stdout=self._api.raw_io.output_text(),
step_test_data=lambda: raw_io_stream_output(
'submodule status filler text',
),
ok_ret='any',
).stdout.strip()
or ''
)
return self._submodule_snapshot
def snapshot_to_dir(self, directory: config_types.Path) -> None:
self._api.file.ensure_directory('mkdir', directory)
if self.manifest_snapshot():
self._api.file.write_text(
'write manifest.xml',
directory / 'manifest.xml',
self.manifest_snapshot(),
)
if self.submodule_snapshot():
self._api.file.write_text(
'write submodule snapshot',
directory / 'submodules.log',
self.submodule_snapshot(),
)
with self._api.context(cwd=self.top):
log = self._api.git(
'log',
'log',
'--oneline',
'-n',
'10',
stdout=self._api.raw_io.output_text(),
ok_ret='any',
).stdout
self._api.file.write_text(
'write git log',
directory / 'git.log',
log,
)
def submodules(self, recursive: bool = False) -> list[Submodule]:
"""Return data about all submodules."""
cmd = [
'python3',
self._api.checkout.resource('submodule_status.py'),
self.top,
self._api.json.output(),
]
if recursive:
cmd.append('--recursive')
submodules = []
submodule_status = self._api.step(
'submodule status',
cmd,
step_test_data=lambda: self._api.json.test_api.output({}),
).json.output
for sub in submodule_status.values():
sub['remote'] = self._api.sso.sso_to_https(sub['remote'])
if sub['remote'].endswith('.git'):
sub['remote'] = sub['remote'][:-4]
sub['relative_path'] = sub['path']
sub['path'] = self.top / sub['path']
try:
submodules.append(Submodule(self._api, **sub))
except Exception as exc: # pragma: no cover
self._api.step.empty(
'exception',
status='FAILURE',
raise_on_failure=False,
).presentation.step_summary_text = repr(exc)
raise ValueError(f'submodule {sub["path"]}: {sub}')
return submodules
_REMOTE_REGEX = re.compile(r'^https://(?P<host>[^/]+)/(?P<project>.+)$')
def gerrit_host(self) -> str | None:
match = self._REMOTE_REGEX.match(self.options.remote)
if not match:
return # pragma: no cover
gerrit_review_host = f'{match.group("host")}'
if '-review' not in gerrit_review_host:
gerrit_review_host = gerrit_review_host.replace('.', '-review.', 1)
return gerrit_review_host
def gerrit_project(self) -> str | None:
match = self._REMOTE_REGEX.match(self.options.remote)
if not match:
return # pragma: no cover
return match.group('project')
def remotes_equivalent(
self,
remote1: str | None,
remote2: str | None,
) -> bool:
return self._api.checkout.remotes_equivalent(
self.equivalent_remotes,
remote1,
remote2,
)