import shlex
import subprocess
from collections import defaultdict
from functools import partial
from pkgcore.ebuild.atom import atom as atom_cls
from pkgcore.ebuild.eapi import EAPI
from pkgcore.ebuild.eclass import EclassDoc
from snakeoil.sequences import iflatten_instance
from snakeoil.strings import pluralism
from .. import addons, bash, results, sources
from ..base import LogMap, LogReports
from . import Check
from .codingstyle import VariableScope, VariableScopeCheck
[docs]
class DeprecatedEclass(results.VersionResult, results.Warning):
"""Package uses an eclass that is deprecated/abandoned."""
def __init__(self, eclass, replacement, **kwargs):
super().__init__(**kwargs)
self.eclass = eclass
self.replacement = replacement
@property
def desc(self):
if self.replacement is not None:
replacement = f"migrate to {self.replacement}"
else:
replacement = "no replacement"
return f"uses deprecated eclass: {self.eclass} ({replacement})"
[docs]
class DeprecatedEclassVariable(results.LineResult, results.Warning):
"""Package uses a deprecated variable from an eclass."""
def __init__(self, variable, replacement, **kwargs):
super().__init__(**kwargs)
self.variable = variable
self.replacement = replacement
@property
def desc(self):
if self.replacement is not None:
replacement = f"migrate to {self.replacement}"
else:
replacement = "no replacement"
return f"uses deprecated variable on line {self.lineno}: {self.variable} ({replacement})"
[docs]
class EclassUserVariableUsage(results.LineResult, results.Warning):
"""Package uses a user variable from an eclass."""
def __init__(self, eclass, **kwargs):
super().__init__(**kwargs)
self.eclass = eclass
@property
def desc(self):
return f"line {self.lineno}: uses user variable {self.line!r} from eclass {self.eclass!r}"
[docs]
class DeprecatedEclassFunction(results.LineResult, results.Warning):
"""Package uses a deprecated function from an eclass."""
def __init__(self, function, replacement, **kwargs):
super().__init__(**kwargs)
self.function = function
self.replacement = replacement
@property
def desc(self):
if self.replacement is not None:
replacement = f"migrate to {self.replacement}"
else:
replacement = "no replacement"
return f"uses deprecated function on line {self.lineno}: {self.function} ({replacement})"
[docs]
class DuplicateEclassInherit(results.LineResult, results.Style):
"""An ebuild directly inherits the same eclass multiple times.
Note that this will flag ebuilds that conditionalize global metadata by
package version (or some other fashion) while inheriting the same eclass
under both branches, e.g. conditional live ebuilds. In this case, shared
eclasses should be loaded in a separate, unconditional inherit call.
"""
def __init__(self, eclass, **kwargs):
super().__init__(**kwargs)
self.eclass = eclass
@property
def desc(self):
return f"duplicate eclass inherit {self.eclass!r}, line {self.lineno}"
[docs]
class MisplacedEclassVar(results.LineResult, results.Error):
"""Invalid placement of pre-inherit eclass variable in an ebuild.
All eclass variables tagged with @PRE_INHERIT must be set
before the first inherit call in an ebuild.
"""
def __init__(self, variable, **kwargs):
super().__init__(**kwargs)
self.variable = variable
@property
def desc(self):
return f"invalid pre-inherit placement, line {self.lineno}: {self.line!r}"
[docs]
class ProvidedEclassInherit(results.LineResult, results.Style):
"""Ebuild inherits an eclass which is already provided by another eclass.
When inheriting an eclass which declares ``@PROVIDES``, those referenced
eclasses are guaranteed to be provided by the eclass. Therefore, inheriting
them in ebuilds is redundant and should be removed.
"""
def __init__(self, provider, **kwargs):
super().__init__(**kwargs)
self.provider = provider
@property
def desc(self):
return f"line {self.lineno}: redundant eclass inherit {self.line!r}, provided by {self.provider!r}"
[docs]
class EclassUsageCheck(Check):
"""Scan packages for various eclass-related issues."""
_source = sources.EbuildParseRepoSource
known_results = frozenset(
{
DeprecatedEclass,
DeprecatedEclassVariable,
DeprecatedEclassFunction,
DuplicateEclassInherit,
EclassUserVariableUsage,
MisplacedEclassVar,
ProvidedEclassInherit,
}
)
required_addons = (addons.eclass.EclassAddon,)
def __init__(self, *args, eclass_addon):
super().__init__(*args)
self.deprecated_eclasses = eclass_addon.deprecated
self.eclass_cache = eclass_addon.eclasses
[docs]
def check_pre_inherits(self, pkg, inherits: list[tuple[list[str], int]]):
"""Check for invalid @PRE_INHERIT variable placement."""
# determine if any inherited eclasses have @PRE_INHERIT variables
pre_inherits = {
var.name: lineno
for eclasses, lineno in inherits
for eclass in eclasses
for var in self.eclass_cache[eclass].variables
if var.pre_inherit
}
# scan for any misplaced @PRE_INHERIT variables
if pre_inherits:
for node, _ in bash.var_assign_query.captures(pkg.tree.root_node):
var_name = pkg.node_str(node.child_by_field_name("name"))
lineno, _colno = node.start_point
if var_name in pre_inherits and lineno > pre_inherits[var_name]:
line = pkg.node_str(node)
yield MisplacedEclassVar(var_name, line=line, lineno=lineno + 1, pkg=pkg)
[docs]
def check_user_variables(self, pkg: bash.ParseTree, inherits: list[tuple[list[str], int]]):
"""Check for usage of @USER_VARIABLE variables."""
# determine if any inherited eclasses have @USER_VARIABLE variables
user_variables = {
var.name: eclass
for eclasses, _ in inherits
for eclass in eclasses
for var in self.eclass_cache[eclass].variables
if var.user_variable
}
# scan for usage of @USER_VARIABLE variables
if user_variables:
for node, _ in bash.var_assign_query.captures(pkg.tree.root_node):
var_name = pkg.node_str(node.child_by_field_name("name"))
if var_name in user_variables:
lineno, _colno = node.start_point
yield EclassUserVariableUsage(
user_variables[var_name], line=var_name, lineno=lineno + 1, pkg=pkg
)
[docs]
def check_deprecated_variables(self, pkg, inherits: list[tuple[list[str], int]]):
"""Check for usage of @DEPRECATED variables."""
# determine if any inherited eclasses have @DEPRECATED variables
deprecated = {
var.name: var.deprecated
for eclasses, _ in inherits
for eclass in eclasses
for var in self.eclass_cache[eclass].variables
if var.deprecated
}
# scan for usage of @DEPRECATED variables
if deprecated:
for node, _ in bash.var_query.captures(pkg.tree.root_node):
var_name = pkg.node_str(node)
if var_name in deprecated:
lineno, _colno = node.start_point
line = pkg.node_str(node)
replacement = deprecated[var_name]
if not isinstance(replacement, str):
replacement = None
yield DeprecatedEclassVariable(
var_name, replacement, line=line, lineno=lineno + 1, pkg=pkg
)
[docs]
def check_deprecated_functions(self, pkg, inherits: list[tuple[list[str], int]]):
"""Check for usage of @DEPRECATED functions."""
# determine if any inherited eclasses have @DEPRECATED functions
deprecated = {
func.name: func.deprecated
for eclasses, _ in inherits
for eclass in eclasses
for func in self.eclass_cache[eclass].functions
if func.deprecated
}
# scan for usage of @DEPRECATED functions
if deprecated:
for node, _ in bash.cmd_query.captures(pkg.tree.root_node):
func_name = pkg.node_str(node.child_by_field_name("name"))
if func_name in deprecated:
lineno, _colno = node.start_point
line = pkg.node_str(node)
replacement = deprecated[func_name]
if not isinstance(replacement, str):
replacement = None
yield DeprecatedEclassFunction(
func_name, replacement, line=line, lineno=lineno + 1, pkg=pkg
)
[docs]
def check_provided_eclasses(self, pkg, inherits: list[tuple[list[str], int]]):
"""Check for usage of eclasses (i.e. redundant inherits) that are
provided by another inherited eclass."""
provided_eclasses = {
provided: (eclass, lineno + 1)
for eclasses, lineno in inherits
for eclass in eclasses
for provided in pkg.inherit.intersection(self.eclass_cache[eclass].provides)
}
for provided, (eclass, lineno) in provided_eclasses.items():
yield ProvidedEclassInherit(eclass, pkg=pkg, line=provided, lineno=lineno)
[docs]
def feed(self, pkg):
if pkg.inherit:
inherited: set[str] = set()
inherits: list[tuple[list[str], int]] = []
for node, _ in bash.cmd_query.captures(pkg.tree.root_node):
name = pkg.node_str(node.child_by_field_name("name"))
if name == "inherit":
call = pkg.node_str(node)
# filter out line continuations and conditional inherits
if eclasses := [x for x in call.split()[1:] if x in pkg.inherit]:
lineno, _colno = node.start_point
if not inherited and eclasses[0] == pkg.inherit[0]:
inherits.append((eclasses, lineno))
for eclass in eclasses:
if eclass not in inherited:
inherited.add(eclass)
else:
yield DuplicateEclassInherit(
eclass, line=call, lineno=lineno + 1, pkg=pkg
)
yield from self.check_provided_eclasses(pkg, inherits)
yield from self.check_user_variables(pkg, inherits)
# verify @PRE_INHERIT variable placement
yield from self.check_pre_inherits(pkg, inherits)
# verify @DEPRECATED variables or functions
yield from self.check_deprecated_variables(pkg, inherits)
yield from self.check_deprecated_functions(pkg, inherits)
for eclass in pkg.inherit.intersection(self.deprecated_eclasses):
replacement = self.deprecated_eclasses[eclass]
if not isinstance(replacement, str):
replacement = None
yield DeprecatedEclass(eclass, replacement, pkg=pkg)
[docs]
class EclassVariableScope(VariableScope, results.EclassResult):
"""Eclass using variable outside its defined scope."""
@property
def desc(self):
return f"{self.eclass}: {super().desc}"
[docs]
class EclassExportFuncsBeforeInherit(results.EclassResult, results.Error):
"""EXPORT_FUNCTIONS called before inherit.
The EXPORT_FUNCTIONS call should occur after all inherits are done in order
to guarantee consistent behavior across all package managers.
"""
def __init__(self, export_line, inherit_line, **kwargs):
super().__init__(**kwargs)
self.export_line = export_line
self.inherit_line = inherit_line
@property
def desc(self):
return (
f"{self.eclass}: EXPORT_FUNCTIONS (line {self.export_line}) called before inherit (line "
f"{self.inherit_line})"
)
[docs]
class EclassParseCheck(Check):
"""Scan eclasses variables that are only allowed in certain scopes."""
_source = sources.EclassParseRepoSource
known_results = frozenset([EclassVariableScope, EclassExportFuncsBeforeInherit])
required_addons = (addons.eclass.EclassAddon,)
def __init__(self, *args, eclass_addon):
super().__init__(*args)
self.eclass_cache = eclass_addon.eclasses
[docs]
def eclass_phase_vars(self, eclass, phase):
"""Return set of bad variables for a given eclass and potential phase function."""
eapis = map(EAPI.known_eapis.get, self.eclass_cache[eclass.name].supported_eapis)
if not eapis:
eapis = EAPI.known_eapis.values()
variables = set()
for eapi in eapis:
variables.update(VariableScopeCheck.scoped_vars[eapi].get(phase, ()))
return variables
[docs]
def feed(self, eclass):
func_prefix = f"{eclass.name}_"
for func_node, _ in bash.func_query.captures(eclass.tree.root_node):
func_name = eclass.node_str(func_node.child_by_field_name("name"))
if not func_name.startswith(func_prefix):
continue
phase = func_name[len(func_prefix) :]
if variables := self.eclass_phase_vars(eclass, phase):
usage = defaultdict(set)
for var_node, _ in bash.var_query.captures(func_node):
var_name = eclass.node_str(var_node)
if var_name in variables:
lineno, _colno = var_node.start_point
usage[var_name].add(lineno + 1)
for var, lines in sorted(usage.items()):
yield EclassVariableScope(
var, func_name, lines=sorted(lines), eclass=eclass.name
)
export_funcs_called = None
for node in eclass.global_query(bash.cmd_query):
call = eclass.node_str(node)
if call.startswith("EXPORT_FUNCTIONS"):
export_funcs_called = node.start_point[0] + 1
elif call.startswith("inherit"):
if export_funcs_called is not None:
yield EclassExportFuncsBeforeInherit(
export_funcs_called, node.start_point[0] + 1, eclass=eclass.name
)
break
[docs]
class EclassBashSyntaxError(results.EclassResult, results.Error):
"""Bash syntax error in the related eclass."""
def __init__(self, lineno, error, **kwargs):
super().__init__(**kwargs)
self.lineno = lineno
self.error = error
@property
def desc(self):
return f"{self.eclass}: bash syntax error, line {self.lineno}: {self.error}"
[docs]
class EclassDocError(results.EclassResult, results.Warning):
"""Error when parsing docs for the related eclass.
Eclass docs are parsed as specified by the devmanual [#]_.
.. [#] https://devmanual.gentoo.org/eclass-writing/#documenting-eclasses
"""
def __init__(self, error, **kwargs):
super().__init__(**kwargs)
self.error = error
@property
def desc(self):
return f"{self.eclass}: failed parsing eclass docs: {self.error}"
[docs]
class EclassDocMissingFunc(results.EclassResult, results.Warning):
"""Undocumented function(s) in the related eclass."""
def __init__(self, functions, **kwargs):
super().__init__(**kwargs)
self.functions = tuple(functions)
@property
def desc(self):
s = pluralism(self.functions)
funcs = ", ".join(self.functions)
return f"{self.eclass}: undocumented function{s}: {funcs}"
[docs]
class EclassDocMissingVar(results.EclassResult, results.Warning):
"""Undocumented variable(s) in the related eclass.
All exported variables in an eclass should be documented using eclass doc
tags. Temporary variables should be unset after use so they aren't
exported.
"""
def __init__(self, variables, **kwargs):
super().__init__(**kwargs)
self.variables = tuple(variables)
@property
def desc(self):
s = pluralism(self.variables)
variables = ", ".join(self.variables)
return f"{self.eclass}: undocumented variable{s}: {variables}"
[docs]
class EclassCheck(Check):
"""Scan eclasses for various issues."""
_source = sources.EclassRepoSource
known_results = frozenset(
[
EclassBashSyntaxError,
EclassDocError,
EclassDocMissingFunc,
EclassDocMissingVar,
]
)
def __init__(self, *args):
super().__init__(*args)
latest_eapi = EAPI.known_eapis[sorted(EAPI.known_eapis)[-1]]
# all known build phases, e.g. src_configure
self.known_phases = list(latest_eapi.phases_rev)
# metadata variables allowed to be set in eclasses, e.g. SRC_URI
self.eclass_keys = latest_eapi.eclass_keys
[docs]
def feed(self, eclass):
# check for eclass bash syntax errors
p = subprocess.run(
["bash", "-n", shlex.quote(eclass.path)],
stderr=subprocess.PIPE,
stdout=subprocess.DEVNULL,
env={"LC_ALL": "C"},
encoding="utf8",
)
if p.returncode != 0 and p.stderr:
lineno = 0
error = []
for line in p.stderr.splitlines():
_path, line, msg = line.split(": ", 2)
lineno = line[5:]
error.append(msg.strip("\n"))
error = ": ".join(error)
yield EclassBashSyntaxError(lineno, error, eclass=eclass)
report_logs = (
LogMap("pkgcore.log.logger.error", partial(EclassDocError, eclass=eclass)),
LogMap("pkgcore.log.logger.warning", partial(EclassDocError, eclass=eclass)),
)
with LogReports(*report_logs) as log_reports:
eclass_obj = EclassDoc(eclass.path, sourced=True)
yield from log_reports
phase_funcs = {f"{eclass}_{phase}" for phase in self.known_phases}
funcs_missing_docs = (
eclass_obj.exported_function_names - phase_funcs - eclass_obj.function_names
)
if funcs_missing_docs:
yield EclassDocMissingFunc(sorted(funcs_missing_docs), eclass=eclass)
# ignore underscore-prefixed vars (mostly used for avoiding multiple inherits)
exported_vars = {x for x in eclass_obj.exported_variable_names if not x.startswith("_")}
vars_missing_docs = (
exported_vars
- self.eclass_keys
- eclass_obj.variable_names
- eclass_obj.function_variable_names
)
if vars_missing_docs:
yield EclassDocMissingVar(sorted(vars_missing_docs), eclass=eclass)
[docs]
class GoMissingDeps(results.VersionResult, results.Warning):
"""Package sets ``GO_OPTIONAL`` but does not depend on ``dev-lang/go``."""
desc = "sets GO_OPTIONAL but does not depend on dev-lang/go"
[docs]
class RubyMissingDeps(results.VersionResult, results.Warning):
"""Package sets ``RUBY_OPTIONAL`` but does not depend on ``dev-lang/ruby``
or ``virtual/rubygems``."""
desc = "sets RUBY_OPTIONAL but does not depend on dev-lang/ruby or virtual/rubygems"
[docs]
class RustMissingDeps(results.VersionResult, results.Warning):
"""Package sets ``CARGO_OPTIONAL`` but does not depend on ``virtual/rust``."""
desc = "sets CARGO_OPTIONAL but does not depend on virtual/rust"
[docs]
class TmpfilesMissingDeps(results.VersionResult, results.Warning):
"""Package sets ``TMPFILES_OPTIONAL`` but does not depend on ``virtual/tmpfiles``."""
desc = "sets TMPFILES_OPTIONAL but does not depend on virtual/tmpfiles"
[docs]
class EclassManualDepsCheck(Check):
"""Check for missing deps when inheriting eclasses in special mode."""
_source = sources.EbuildParseRepoSource
known_results = frozenset(
{
GoMissingDeps,
RustMissingDeps,
RubyMissingDeps,
TmpfilesMissingDeps,
}
)
dependencies = (
# eclass, variable, one of deps, class
("cargo", "CARGO_OPTIONAL", {"virtual/rust"}, RustMissingDeps),
("go-module", "GO_OPTIONAL", {"dev-lang/go"}, GoMissingDeps),
(
"ruby-ng",
"RUBY_OPTIONAL",
{"dev-lang/ruby", "virtual/rubygems", "dev-ruby"},
RubyMissingDeps,
),
("tmpfiles", "TMPFILES_OPTIONAL", {"virtual/tmpfiles"}, TmpfilesMissingDeps),
)
def __init__(self, options, **kwargs):
super().__init__(options, **kwargs)
self.queries_by_eclass = defaultdict(list)
for eclass, variable, deps, cls in self.dependencies:
pkgs = frozenset({x for x in deps if "/" in x})
categories = frozenset({x for x in deps if "/" not in x})
self.queries_by_eclass[eclass].append(
(
bash.query(
# has variable assignment to a variable named
f'(variable_assignment name: (variable_name) @name (.eq? @name "{variable}"))'
),
pkgs,
categories,
cls,
)
)
[docs]
def feed(self, pkg: bash.ParseTree):
for eclass, queries in self.queries_by_eclass.items():
if eclass not in pkg.inherited:
continue
for query, pkgs, categories, cls in queries:
# is the variable assigned in global scope
try:
next(pkg.global_query(query))
except StopIteration:
continue
# does any dep attr have any of the deps
if all(
atom.key not in pkgs and atom.category not in categories
for attr in pkg.eapi.dep_keys
for atom in iflatten_instance(getattr(pkg, attr.lower()), atom_cls)
):
yield cls(pkg)