blob: 06df02282e9f45fe4fa41684244b6c879e1824bd [file] [log] [blame]
# Copyright 2023 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Various ebuild house keeping tasks."""
import enum
import functools
import logging
from pathlib import Path
import re
from typing import Iterable, List, Optional
from chromite.lib import commandline
from chromite.lib import git
from chromite.lib.parser import package_info
def logging_dryrun(*args, **kwargs):
"""Helper method for logging dryrun statements in a consistent format."""
logging.info("(dryrun) " + args[0], *args[1:], **kwargs)
def get_var(lines: List[str], var: str) -> Optional[str]:
"""Lookup the value of |var| and return it.
This only supports one-liners, and no arrays. It's extremely basic.
"""
found_lines = [x for x in lines if x.startswith(f"{var}=")]
if not found_lines:
return None
assert len(found_lines) == 1, found_lines
line = found_lines[0]
return line.split("=")[1].strip('"')
class Ebuild:
"""Container representing a single ebuild."""
def __init__(self, path: Path) -> None:
self.path = path
self.cpv = package_info.parse(path)
@functools.cached_property
def name(self) -> str:
return self.path.name
@functools.cached_property
def is_symlink(self) -> bool:
return self.path.is_symlink()
@functools.cached_property
def is_workon(self) -> bool:
return self.name.endswith("-9999.ebuild")
@functools.cached_property
def content(self) -> str:
return self.path.read_text(encoding="utf-8")
@functools.cached_property
def lines(self) -> List[str]:
return self.content.strip().splitlines()
def write_lines(self, lines: List[str], dryrun: bool = False) -> None:
if dryrun:
logging_dryrun("%s: rewrote %s", self.cpv, self.path.name)
else:
self.path.write_text("\n".join(lines) + "\n", encoding="utf-8")
@functools.cached_property
def eapi(self) -> str:
return get_var(self.lines, "EAPI")
@functools.cached_property
def rev0_path(self) -> Path:
return self.path.with_name(self.cpv.with_rev0().ebuild)
@functools.cached_property
def rev_next_path(self) -> Path:
return self.path.with_name(self.cpv.revision_bump().ebuild)
class Package:
"""Container representing a package in an overlay.
Can operate on multiple ebuilds for a single package.
"""
def __init__(self, path: Path) -> None:
self.path = path
@functools.cached_property
def category(self) -> str:
return self.path.parent.name
@functools.cached_property
def pn(self) -> str:
return self.path.name
@functools.cached_property
def cp(self) -> str:
return f"{self.category}/{self.pn}"
def iterebuilds(self, symlinks: bool = False):
yield from (
Ebuild(x)
for x in self.path.glob("*.ebuild")
if symlinks or not x.is_symlink()
)
@functools.cached_property
def is_metapackage(self) -> bool:
return self.path.parent.name == "virtual"
@functools.cached_property
def is_workon(self) -> bool:
return any(x.is_workon for x in self.iterebuilds())
@functools.cached_property
def workon_ebuild(self) -> Ebuild:
return Ebuild(self.path / f"{self.pn}-9999.ebuild")
def git_mv(cwd: Path, src: Path, dst: Path, dryrun: bool = False) -> None:
git.RunGit(cwd, ["mv", src, dst], dryrun=dryrun)
def git_add(pkg: Package, dryrun: bool = False) -> None:
if not dryrun:
git.AddPath(pkg.path)
def ebuild_bump(
pkg: Package,
ebuilds: List[Ebuild],
dryrun: bool = False,
force: bool = False, # pylint: disable=unused-argument
) -> None:
"""Revbump the package."""
if len(ebuilds) == 1:
ebuild = ebuilds[0]
rev_path = ebuild.rev_next_path
if dryrun:
logging_dryrun(
"%s: Symlinking %s -> %s",
pkg.cp,
rev_path.name,
ebuild.name,
)
else:
rev_path.symlink_to(ebuild.name)
else:
ebuild = ebuilds[1]
git_mv(pkg.path, ebuild.name, ebuild.rev_next_path.name, dryrun=dryrun)
def normalize(
pkg: Package,
dryrun: bool = False,
force: bool = False, # pylint: disable=unused-argument
) -> bool:
"""Normalize how the revbump is handled.
We want the base ebuild to never have a -r# component, and then use a
symlink to it to force the revbumping.
"""
if pkg.is_workon:
return False
files = list(pkg.iterebuilds(symlinks=True))
if len(files) not in (1, 2):
logging.error(
"%s: too many ebuilds found: %s", pkg.cp, [x.cpv for x in files]
)
return False
if files[0].is_symlink:
files = [files[1], files[0]]
src_ebuild = files[0]
cpv = src_ebuild.cpv
if cpv.revision:
logging.notice("%s: normalize -r ebuilds", cpv)
if len(files) != 1:
logging.error(
"%s: too many ebuilds found: %s", cpv, [x.cpv for x in files]
)
return False
rev0_path = src_ebuild.rev0_path
if dryrun:
logging_dryrun(
"%s: Renaming %s -> %s", cpv, src_ebuild.name, rev0_path.name
)
logging_dryrun(
"%s: Symlinking %s -> %s", cpv, src_ebuild.name, rev0_path.name
)
else:
git_mv(pkg.path, src_ebuild.name, rev0_path)
src_ebuild.path.symlink_to(rev0_path.name)
git_add(pkg)
return True
return False
def eapi_7_safe(pkg: Package, ebuild: Ebuild, force: bool = False) -> bool:
"""Try and guess whether it's safe to upgrade to EAPI=7."""
log_prefix = "EAPI=7 checks"
# If it's already upgraded, then there's no need to upgrade again.
if ebuild.eapi == "7":
return False
lines = ebuild.lines
issues = []
BAD_CONTENT = (
("STRIP_MASK=", "7; use `dostrip -x`"),
(
"prune_libtool_files",
"use `find \"${ED}\" -name '*.la' -delete || die`",
),
("ltprune", "use `find \"${ED}\" -name '*.la' -delete || die`"),
("epatch", "use `eapply` or `PATCHES=(...)`"),
("epatch_user", "use `eapply_user`"),
("versionator", "use `ver_xxx` helpers"),
("eapi7-ver", "don't need this eclass at all"),
("dohtml", "use `dodoc`"),
("einstall", 'use `emake DESTDIR="${ED}" install`'),
)
for entry, replacement in BAD_CONTENT:
if any(entry in x for x in lines):
issues += [f"{entry} banned in EAPI=7; {replacement}"]
if any(
x.startswith("src_")
and not x.startswith("src_install()")
and not x.startswith("src_unpack()")
for x in lines
):
if ebuild.eapi == "6":
logging.warning(
"%s: %s: assuming EAPI=6 -> EAPI=7 is easy; please review!",
pkg.cp,
log_prefix,
)
else:
issues += ["src_xxx funcs require manual review"]
for issue in issues:
if not force:
logging.error("%s: %s: skipping: %s", pkg.cp, log_prefix, issue)
else:
logging.warning(
"%s: %s: %s; please review!", pkg.cp, log_prefix, issue
)
ret = force or not bool(issues)
if not ret:
logging.error(
"%s: %s: use --force to upgrade anyways", pkg.cp, log_prefix
)
return ret
def general_bump_eapi(
pkg: Package, dryrun: bool = False, force: bool = False
) -> bool:
"""Update EAPI for normal (non-virtual & non-cros-workon) packages."""
log_prefix = "general EAPI update"
if pkg.is_workon or pkg.category == "virtual":
return False
files = list(pkg.iterebuilds(symlinks=True))
if len(files) not in (1, 2):
logging.error(
"%s: %s: too many ebuilds found: %s",
pkg.cp,
log_prefix,
[x.cpv for x in files],
)
return False
if files[0].is_symlink:
files = [files[1], files[0]]
ebuild = files[0]
if not eapi_7_safe(pkg, ebuild, force=force):
return False
logging.notice("%s: %s", pkg.cp, log_prefix)
lines = ['EAPI="7"' if x.startswith("EAPI=") else x for x in ebuild.lines]
ebuild.write_lines(lines, dryrun=dryrun)
ebuild_bump(pkg, files, dryrun=dryrun)
git_add(pkg, dryrun=dryrun)
return True
def cros_workon_bump_eapi(
pkg: Package, dryrun: bool = False, force: bool = False
) -> bool:
"""Update EAPI for cros-workon packages."""
log_prefix = "cros-workon EAPI update"
if not pkg.is_workon:
return False
ebuild = pkg.workon_ebuild
if not eapi_7_safe(pkg, ebuild, force=force):
return False
logging.notice("%s: %s", pkg.cp, log_prefix)
lines = ['EAPI="7"' if x.startswith("EAPI=") else x for x in ebuild.lines]
ebuild.write_lines(lines, dryrun=dryrun)
git_add(pkg, dryrun=dryrun)
return True
def virtual_bump_eapi(
pkg: Package,
dryrun: bool = False,
force: bool = False, # pylint: disable=unused-argument
) -> bool:
"""Update EAPI for virtual packages."""
if pkg.category != "virtual":
return False
files = list(pkg.iterebuilds(symlinks=True))
if len(files) not in (1, 2):
logging.error(
"%s: too many ebuilds found: %s", pkg.cp, [x.cpv for x in files]
)
return False
if files[0].is_symlink:
files = [files[1], files[0]]
src_ebuild = files[0]
if src_ebuild.eapi == "7":
return False
cpv = src_ebuild.cpv
logging.notice("%s: virtual EAPI update", cpv)
lines = src_ebuild.lines
lines = ['EAPI="7"' if x.startswith("EAPI=") else x for x in lines]
src_ebuild.write_lines(lines, dryrun=dryrun)
ebuild_bump(pkg, files, dryrun=dryrun)
git_add(pkg, dryrun=dryrun)
return True
def set_license(
pkg: Package,
dryrun: bool = False,
force: bool = False, # pylint: disable=unused-argument
) -> bool:
"""Set the LICENSE to the right value.
This handles metapackages only atm.
"""
# If the package isn't a metapackage (e.g. virtual), nothing to do!
if not pkg.is_metapackage:
return False
files = list(pkg.iterebuilds(symlinks=True))
if len(files) not in (1, 2):
logging.error(
"%s: too many ebuilds found: %s", pkg.cp, [x.cpv for x in files]
)
return False
if files[0].is_symlink:
files = [files[1], files[0]]
src_ebuild = files[0]
lines = src_ebuild.lines
lic = get_var(lines, "LICENSE")
if lic == "metapackage":
# If the package is already using metapackage, nothing to do!
return False
cpv = src_ebuild.cpv
logging.notice(
'%s: changing LICENSE="%s" to LICENSE="metapackage"', cpv, lic or ""
)
# If the LICENSE= line already exists, replace it.
# If it doesn't, try to insert it just before the SLOT= line.=
lines = [
'LICENSE="metapackage"' if x.startswith("LICENSE=") else x
for x in lines
]
try:
i = lines.index('LICENSE="metapackage"')
except ValueError:
for i, line in enumerate(lines):
if line.startswith("SLOT="):
break
else:
logging.error("%s: can't find place to insert LICENSE=", cpv)
return False
lines.insert(i, 'LICENSE="metapackage"')
src_ebuild.write_lines(lines, dryrun=dryrun)
ebuild_bump(pkg, files, dryrun=dryrun)
git_add(pkg, dryrun=dryrun)
return True
@enum.unique
class RunMode(enum.Enum):
"""Which cleanup task to run."""
NORMALIZE = enum.auto()
VIRTUAL_EAPI = enum.auto()
CROS_WORKON_EAPI = enum.auto()
GENERAL_EAPI = enum.auto()
META_LICENSE = enum.auto()
ACTION_MAP = {
RunMode.NORMALIZE: (normalize, "normalize ebuild symlinks"),
RunMode.VIRTUAL_EAPI: (virtual_bump_eapi, "update to EAPI=7"),
RunMode.META_LICENSE: (set_license, "set LICENSE=metapackage"),
RunMode.CROS_WORKON_EAPI: (cros_workon_bump_eapi, "update to EAPI=7"),
RunMode.GENERAL_EAPI: (general_bump_eapi, "update to EAPI=7"),
}
def enumerate_packages(overlay: Path) -> Iterable[Package]:
"""Return all the unique packages in this overlay."""
yield from (
Package(x)
for x in sorted({x.parent for x in overlay.glob("*/*/*.ebuild")})
)
def process_overlay(opts, mode: RunMode, overlay: Path) -> None:
"""Process |overlay|."""
logging.debug("%s: checking", overlay.name)
for pkg in enumerate_packages(overlay):
logging.debug("%s: checking", pkg.cp)
if opts.grep and not any(
opts.grep.search(x.content) for x in pkg.iterebuilds()
):
logging.debug("%s: ignoring due to --grep not matching", pkg.cp)
continue
func, msg = ACTION_MAP[mode]
if func(pkg, dryrun=opts.dryrun, force=opts.force):
if not opts.dryrun:
git.Commit(
overlay,
(
f"{pkg.pn}: {msg}\n\n"
f"BUG={opts.bug_tag}\nTEST={opts.test_tag}"
),
)
def get_parser():
"""Get CLI parser."""
actions = [
f"{str(x.name).lower()}: {ACTION_MAP[x][0].__doc__.strip()}"
for x in RunMode
]
parser = commandline.ArgumentParser(
description=__doc__, epilog="\n\n".join(actions), dryrun=True
)
parser.add_argument(
"--force", action="store_true", help="Ignore safety checks"
)
parser.add_argument(
"--grep", help="Only process ebuilds matching a regular expression"
)
parser.add_argument("--bug-tag", default="None", help="Which bug to use")
parser.add_argument(
"--test-tag", default="CQ passes", help="What testing is used"
)
parser.add_argument(
"mode",
action="enum",
enum=RunMode,
help="Which housekeeping task to run",
)
parser.add_argument("overlays", nargs="+", help="Which overlays to cleanup")
return parser
def main(argv):
"""The main entry point for scripts."""
parser = get_parser()
opts = parser.parse_args(argv)
opts.overlays = [Path(x).resolve() for x in opts.overlays]
if opts.grep:
opts.grep = re.compile(opts.grep)
opts.Freeze()
for overlay in opts.overlays:
process_overlay(opts, opts.mode, overlay)