commit: 29cf861e558d8dc98be2483db22b594e0b25c3d3
Author: Arthur Zamarin <arthurzam <AT> gentoo <DOT> org>
AuthorDate: Thu Dec 15 19:29:44 2022 +0000
Commit: Arthur Zamarin <arthurzam <AT> gentoo <DOT> org>
CommitDate: Thu Jan 5 16:22:37 2023 +0000
URL:
https://gitweb.gentoo.org/proj/pkgcore/pkgdev.git/commit/?id=29cf861e
pkgdev tatt: new tool for package testing
Signed-off-by: Arthur Zamarin <arthurzam <AT> gentoo.org>
data/share/bash-completion/completions/pkgdev | 35 +++
pyproject.toml | 6 +-
src/pkgdev/scripts/pkgdev_tatt.py | 368 ++++++++++++++++++++++++++
src/pkgdev/tatt/__init__.py | 0
src/pkgdev/tatt/template.sh.jinja | 123 +++++++++
5 files changed, 531 insertions(+), 1 deletion(-)
diff --git a/data/share/bash-completion/completions/pkgdev
b/data/share/bash-completion/completions/pkgdev
index 9094ab1..37e7a4b 100644
--- a/data/share/bash-completion/completions/pkgdev
+++ b/data/share/bash-completion/completions/pkgdev
@@ -12,6 +12,7 @@ _pkgdev() {
mask
push
showkw
+ tatt
"
local base_options="
@@ -194,6 +195,40 @@ _pkgdev() {
;;
esac
;;
+ tatt)
+ subcmd_options="
+ --api-key
+ -j --job-name
+ -b --bug
+ -t --test
+ -u --use-combos
+ --ignore-prefixes
+ --use-default
+ --use-random
+ --use-expand-random
+ -p --package
+ -s --stablereq
+ -k --keywording
+ --template-file
+ --logs-dir
+ --emerge-opts
+ "
+
+ case "${prev}" in
+ -[jbup] | --api-key | --job-name | --bug | --use-combos |
--package | --emerge-opts)
+ COMPREPLY=()
+ ;;
+ --template-file)
+ COMPREPLY=($(compgen -f -- "${cur}"))
+ ;;
+ --logs-dir)
+ COMPREPLY=($(compgen -d -- "${cur}"))
+ ;;
+ *)
+ COMPREPLY+=($(compgen -W "${subcmd_options}" -- "${cur}"))
+ ;;
+ esac
+ ;;
esac
}
complete -F _pkgdev pkgdev
diff --git a/pyproject.toml b/pyproject.toml
index b078b4f..c6f7fba 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,7 +28,7 @@ classifiers = [
dynamic = ["version"]
dependencies = [
- "snakeoil~=0.10.3",
+ "snakeoil~=0.10.4",
"pkgcore~=0.12.16",
"pkgcheck~=0.10.16",
]
@@ -42,6 +42,10 @@ doc = [
"sphinx",
"tomli; python_version < '3.11'"
]
+tatt = [
+ "nattka",
+ "Jinja2",
+]
[project.urls]
Homepage = "https://github.com/pkgcore/pkgdev"
diff --git a/src/pkgdev/scripts/pkgdev_tatt.py
b/src/pkgdev/scripts/pkgdev_tatt.py
new file mode 100644
index 0000000..93d8dd2
--- /dev/null
+++ b/src/pkgdev/scripts/pkgdev_tatt.py
@@ -0,0 +1,368 @@
+"""package testing tool"""
+
+import os
+import random
+import stat
+from collections import defaultdict
+from importlib.resources import read_text
+from itertools import islice
+from pathlib import Path
+
+from pkgcore.restrictions import boolean, packages, values
+from pkgcore.restrictions.required_use import find_constraint_satisfaction
+from pkgcore.util import commandline
+from pkgcore.util import packages as pkgutils
+from snakeoil.cli import arghparse
+
+from ..cli import ArgumentParser
+
+tatt = ArgumentParser(
+ prog="pkgdev tatt", description=__doc__, verbose=False, quiet=False
+)
+tatt.add_argument(
+ "--api-key",
+ metavar="KEY",
+ help="Bugzilla API key",
+ docs="""
+ The Bugzilla API key to use for authentication. Used mainly to overcome
+ rate limiting done by bugzilla server. This tool doesn't perform any
+ bug editing, just fetching info for the bug.
+ """,
+)
+tatt.add_argument(
+ "-j",
+ "--job-name",
+ metavar="NAME",
+ default="{PN}-{BUGNO}",
+ help="Name template for created job script",
+ docs="""
+ The job name to use for the job script and report. The name can use
+ the variables ``{PN}`` (package name) and ``{BUGNO}`` (bug number)
+ to created variable names.
+ """,
+)
+tatt.add_argument(
+ "-b",
+ "--bug",
+ type=arghparse.positive_int,
+ metavar="BUG",
+ help="Single bug to take package list from",
+)
+
+use_opts = tatt.add_argument_group("Use flags options")
+use_opts.add_argument(
+ "-t",
+ "--test",
+ action="store_true",
+ help="Run test phase for the packages",
+ docs="""
+ Include a test run for packages which define ``src_test`` phase
+ (in the ebuild or inherited from eclass).
+ """,
+)
+use_opts.add_argument(
+ "-u",
+ "--use-combos",
+ default=0,
+ type=arghparse.positive_int,
+ metavar="NUMBER",
+ help="Maximal number USE combinations to be tested",
+)
+use_opts.add_argument(
+ "--ignore-prefixes",
+ default=[],
+ action=arghparse.CommaSeparatedValuesAppend,
+ help="USE flags prefixes that won't be randomized",
+ docs="""
+ Comma separated USE flags prefixes that won't be randomized. This is
+ useful for USE flags such as ``python_targets_``. Note that this
+ doesn't affect preference, but because of specific REQUIRED_USE will
+ still be changed from defaults.
+ """,
+)
+random_use_opts = use_opts.add_mutually_exclusive_group()
+random_use_opts.add_argument(
+ "--use-default",
+ dest="random_use",
+ const="d",
+ action="store_const",
+ help="Prefer to use default use flags configuration",
+)
+random_use_opts.add_argument(
+ "--use-random",
+ dest="random_use",
+ const="r",
+ action="store_const",
+ help="Turn on random use flags, with default USE_EXPAND",
+)
+random_use_opts.add_argument(
+ "--use-expand-random",
+ dest="random_use",
+ const="R",
+ action="store_const",
+ help="Turn on random use flags, including USE_EXPAND",
+)
+random_use_opts.set_defaults(random_use="r")
+
+packages_opts = tatt.add_argument_group("manual packages options")
+packages_opts.add_argument(
+ "-p",
+ "--packages",
+ metavar="TARGET",
+ nargs="+",
+ help="extended atom matching of packages",
+)
+bug_state = packages_opts.add_mutually_exclusive_group()
+bug_state.add_argument(
+ "-s",
+ "--stablereq",
+ dest="keywording",
+ default=None,
+ action="store_false",
+ help="Test packages for stable keywording requests",
+)
+bug_state.add_argument(
+ "-k",
+ "--keywording",
+ dest="keywording",
+ default=None,
+ action="store_true",
+ help="Test packages for keywording requests",
+)
+
+template_opts = tatt.add_argument_group("template options")
+template_opts.add_argument(
+ "--template-file",
+ type=arghparse.existent_path,
+ help="Template file to use for the job script",
+ docs="""
+ Template file to use for the job script. The template file is a
+ Jinja template file, which can use the following variables:
+
+ .. glossary::
+
+ ``jobs``
+ A list of jobs to be run. Each job is a tuple consisting of
+ USE flags values, is a testing job, and the atom to build.
+
+ ``report_file``
+ The path to the report file.
+
+ ``emerge_opts``
+ Options to be passed to emerge invocations. Taken from
+ ``--emerge-opts``.
+
+ ``log_dir``
+ irectory to save build logs for failing tasks. Taken from
+ ``--logs-dir``.
+
+ ``cleanup_files``
+ A list of files to be removed after the job script is done.
+ """,
+)
+template_opts.add_argument(
+ "--logs-dir",
+ default="~/logs",
+ help="Directory to save build logs for failing tasks",
+)
+template_opts.add_argument(
+ "--emerge-opts",
+ default="",
+ help="Options to be passed to emerge invocations",
+ docs="""
+ Space separated single argument, consisting og options to be passed
+ to ``emerge`` invocations.
+ """,
+)
+
+accept_keywords = Path("/etc/portage/package.accept_keywords")
+
+
[email protected]_final_check
+def _validate_args(parser, namespace):
+ if namespace.bug is not None:
+ if namespace.keywording is not None:
+ parser.error("cannot use --bug with --keywording or --stablereq")
+ if namespace.packages:
+ parser.error("cannot use --bug with --packages")
+ elif not namespace.packages:
+ parser.error("no action requested, use --bug or --packages")
+
+ if not namespace.test and not namespace.use_combos:
+ parser.error("no action requested, use --test or --use-combos")
+
+ if namespace.packages:
+ arch = namespace.domain.arch
+ if namespace.keywording:
+ keywords_restrict = packages.PackageRestriction(
+ "keywords",
+ values.ContainmentMatch((f"~{arch}", f"-{arch}", arch),
negate=True),
+ )
+ else:
+ keywords_restrict = packages.PackageRestriction(
+ "keywords", values.ContainmentMatch((f"~{arch}", arch))
+ )
+ namespace.restrict = boolean.AndRestriction(
+
boolean.OrRestriction(*commandline.convert_to_restrict(namespace.packages)),
+ packages.PackageRestriction(
+ "properties", values.ContainmentMatch("live", negate=True)
+ ),
+ keywords_restrict,
+ )
+
+
+def _get_bugzilla_packages(namespace):
+ from nattka.bugzilla import BugCategory, NattkaBugzilla
+ from nattka.package import match_package_list
+
+ nattka_bugzilla = NattkaBugzilla(api_key=namespace.api_key)
+ bug = next(iter(nattka_bugzilla.find_bugs(bugs=[namespace.bug]).values()))
+ namespace.keywording = bug.category == BugCategory.KEYWORDREQ
+ repo = namespace.domain.repos["gentoo"].raw_repo
+ return dict(
+ match_package_list(
+ repo, bug, only_new=True, filter_arch=[namespace.domain.arch]
+ )
+ ).keys()
+
+
+def _get_cmd_packages(namespace):
+ repos = namespace.domain.source_repos_raw
+ for pkgs in pkgutils.groupby_pkg(
+ repos.itermatch(namespace.restrict, sorter=sorted)
+ ):
+ pkg = max(pkgs)
+ yield pkg.repo.match(pkg.versioned_atom)[0]
+
+
+def _groupby_use_expand(
+ assignment: dict[str, bool],
+ use_expand_prefixes: tuple[str, ...],
+ domain_enabled: frozenset[str],
+ iuse: frozenset[str],
+) -> dict[str, set[str]]:
+ use_expand_dict = defaultdict(set)
+ for var, state in assignment.items():
+ if var not in iuse:
+ continue
+ if state == (var in domain_enabled):
+ continue
+ for use_expand in use_expand_prefixes:
+ if var.startswith(use_expand):
+ if state:
+
use_expand_dict[use_expand[:-1]].add(var.removeprefix(use_expand))
+ break
+ else:
+ use_expand_dict["USE"].add(("" if state else "-") + var)
+ return use_expand_dict
+
+
+def _build_job(namespace, pkg, is_test):
+ use_expand_prefixes = tuple(
+ s.lower() + "_" for s in namespace.domain.profile.use_expand
+ )
+ default_on_iuse = tuple(use[1:] for use in pkg.iuse if use.startswith("+"))
+ immutable, enabled, _disabled =
namespace.domain.get_package_use_unconfigured(pkg)
+
+ iuse = frozenset(pkg.iuse_stripped)
+ force_true = immutable.union(("test",) if is_test else ())
+ force_false = ("test",) if not is_test else ()
+
+ if namespace.random_use == "d":
+ prefer_true = enabled.union(default_on_iuse)
+ elif namespace.random_use in "rR":
+ ignore_prefixes = set(namespace.ignore_prefixes)
+ if namespace.random_use == "r":
+ ignore_prefixes.update(use_expand_prefixes)
+ ignore_prefixes = tuple(ignore_prefixes)
+
+ prefer_true = [
+ use
+ for use in iuse.difference(force_true, force_false)
+ if not use.startswith(ignore_prefixes)
+ ]
+ if prefer_true:
+ random.shuffle(prefer_true)
+ prefer_true = prefer_true[: random.randint(0, len(prefer_true) -
1)]
+ prefer_true.extend(
+ use
+ for use in enabled.union(default_on_iuse)
+ if use.startswith(ignore_prefixes)
+ )
+
+ solutions = find_constraint_satisfaction(
+ pkg.required_use,
+ iuse.union(immutable),
+ force_true,
+ force_false,
+ frozenset(prefer_true),
+ )
+ for solution in solutions:
+ yield " ".join(
+ f'{var.upper()}="{" ".join(vals)}"'
+ for var, vals in _groupby_use_expand(
+ solution, use_expand_prefixes, enabled, iuse
+ ).items()
+ )
+
+
+def _build_jobs(namespace, pkgs):
+ for pkg in pkgs:
+ if namespace.test and "test" in pkg.defined_phases:
+ yield pkg.versioned_atom, True, next(iter(_build_job(namespace,
pkg, True)))
+
+ for flags in islice(_build_job(namespace, pkg, False),
namespace.use_combos):
+ yield pkg.versioned_atom, False, flags
+
+
+def _create_config_files(pkgs, job_name, is_keywording):
+ if not accept_keywords.exists():
+ accept_keywords.mkdir(parents=True)
+ elif not accept_keywords.is_dir():
+ raise NotADirectoryError(f"{accept_keywords} is not a directory")
+ with (res := accept_keywords /
f"pkgdev_tatt_{job_name}.keywords").open("w") as f:
+ f.write(f"# Job created by pkgdev tatt for {job_name!r}\n")
+ for pkg in pkgs:
+ f.write(f'{pkg.versioned_atom} {"**" if is_keywording else ""}\n')
+ return str(res)
+
+
[email protected]_main_func
+def main(options, out, err):
+ if options.bug is not None:
+ pkgs = tuple(_get_bugzilla_packages(options))
+ else:
+ pkgs = tuple(_get_cmd_packages(options))
+
+ if not pkgs:
+ return err.error("package query resulted in empty package list")
+
+ job_name = options.job_name.format(PN=pkgs[0].package, BUGNO=options.bug
or "")
+ cleanup_files = []
+
+ try:
+ config_file = _create_config_files(pkgs, job_name, options.keywording)
+ out.write("created config ", out.fg("green"), config_file, out.reset)
+ cleanup_files.append(config_file)
+ except Exception as exc:
+ err.error(f"failed to create config files: {exc}")
+
+ if options.template_file:
+ with open(options.template_file) as output:
+ template = output.read()
+ else:
+ template = read_text("pkgdev.tatt", "template.sh.jinja")
+
+ from jinja2 import Template
+
+ script = Template(template, trim_blocks=True, lstrip_blocks=True).render(
+ jobs=list(_build_jobs(options, pkgs)),
+ report_file=job_name + ".report",
+ log_dir=options.logs_dir,
+ emerge_opts=options.emerge_opts,
+ cleanup_files=cleanup_files,
+ )
+ with open(script_name := job_name + ".sh", "w") as output:
+ output.write(script)
+ os.chmod(script_name, os.stat(script_name).st_mode | stat.S_IEXEC)
+ out.write("created script ", out.fg("green"), script_name, out.reset)
diff --git a/src/pkgdev/tatt/__init__.py b/src/pkgdev/tatt/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/pkgdev/tatt/template.sh.jinja
b/src/pkgdev/tatt/template.sh.jinja
new file mode 100644
index 0000000..7a43043
--- /dev/null
+++ b/src/pkgdev/tatt/template.sh.jinja
@@ -0,0 +1,123 @@
+{#
+Copyright (C) 2010-2022 Gentoo tatt project
+https://gitweb.gentoo.org/proj/tatt.git/
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License
+as published by the Free Software Foundation; either version 2
+of the License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+#}
+#!/bin/bash
+
+main() {
+ trap "echo 'signal captured, exiting the entire script...'; exit" SIGHUP
SIGINT SIGTERM
+ echo -e "USE tests started on $(date)\n" >> "{{ report_file }}"
+
+ local test_ret=0
+
+ {% for atom, is_test, use_flags in jobs %}
+ {% if is_test %}
+ {{ use_flags }} tatt_test_pkg --test '{{ atom }}' || test_ret=1
+ {% else %}
+ {{ use_flags }} tatt_test_pkg '{{ atom }}' || test_ret=1
+ {% endif %}
+ {% endfor %}
+
+ exit ${test_ret}
+}
+
+cleanup() {
+ echo "Cleaning up"
+ {% for file in cleanup_files %}
+ rm -v -f '{{ file }}'
+ {% endfor %}
+ rm -v -f $0
+}
+
+tatt_pkg_error() {
+ local eout=${2}
+
+ echo "${eout}"
+
+ if [[ -n ${USE} ]]; then
+ echo -n "USE='${USE}'" >> "{{ report_file }}"
+ fi
+ if [[ -n ${FEATURES} ]]; then
+ echo -n " FEATURES='${FEATURES}'" >> "{{ report_file }}"
+ fi
+
+ if [[ ${eout} =~ REQUIRED_USE ]] ; then
+ echo " : REQUIRED_USE not satisfied (probably) for ${1:?}" >> "{{
report_file }}"
+ elif [[ ${eout} =~ USE\ changes ]] ; then
+ echo " : USE dependencies not satisfied (probably) for ${1:?}" >> "{{
report_file }}"
+ elif [[ ${eout} =~ keyword\ changes ]]; then
+ echo " : unkeyworded dependencies (probably) for ${1:?}" >> "{{
report_file }}"
+ elif [[ ${eout} =~ Error:\ circular\ dependencies: ]]; then
+ echo " : circular dependencies (probably) for ${1:?}" >> "{{
report_file }}"
+ elif [[ ${eout} =~ Blocked\ Packages ]]; then
+ echo " : blocked packages (probably) for ${1:?}" >> "{{ report_file }}"
+ else
+ echo " failed for ${1:?}" >> "{{ report_file }}"
+ fi
+
+ local CP=${1#=}
+ local BUILDDIR=/var/tmp/portage/${CP}
+ local BUILDLOG=${BUILDDIR}/temp/build.log
+ if [[ -s ${BUILDLOG} ]]; then
+ mkdir -p {{ log_dir }}
+ local LOGNAME=$(mktemp -p {{ log_dir }} "${CP/\//_}_use_XXXXX")
+ mv "${BUILDLOG}" "${LOGNAME}"
+ echo " log has been saved as ${LOGNAME}" >> "{{ report_file }}"
+ TESTLOGS=($(find ${BUILDDIR}/work -iname '*test*log*'))
+{% raw %}
+ if [[ ${#TESTLOGS[@]} -gt 0 ]]; then
+ tar cf ${LOGNAME}.tar ${TESTLOGS[@]}
+ echo " test-suite logs have been saved as ${LOGNAME}.tar" >>
"{{ report_file }}"
+ fi
+ fi
+{% endraw %}
+}
+
+tatt_test_pkg() {
+ if [[ ${1:?} == "--test" ]]; then
+ shift
+
+ # Do a first pass to avoid circular dependencies
+ # --onlydeps should mean we're avoiding (too much) duplicate work
+ USE="${USE} minimal -doc" emerge --onlydeps -q1 --with-test-deps {{
emerge_opts }} "${1:?}"
+
+ if ! emerge --onlydeps -q1 --with-test-deps {{ emerge_opts }}
"${1:?}"; then
+ echo "merging test dependencies of ${1} failed" >> "{{ report_file
}}"
+ return 1
+ fi
+ TFEATURES="${FEATURES} test"
+ else
+ TFEATURES="${FEATURES}"
+ fi
+
+ # --usepkg-exclude needs the package name, so let's extract it
+ # from the atom we have
+ local name=$(portageq pquery "${1:?}" -n)
+
+ eout=$( FEATURES="${TFEATURES}" emerge -1 --getbinpkg=n
--usepkg-exclude="${name}" {{ emerge_opts }} "${1:?}" 2>&1 1>/dev/tty )
+ if [[ $? == 0 ]] ; then
+ if [[ -n ${TFEATURES} ]]; then
+ echo -n "FEATURES='${TFEATURES}' " >> "{{ report_file }}"
+ fi
+ echo "USE='${USE}' succeeded for ${1:?}" >> "{{ report_file }}"
+ else
+ FEATURES="${TFEATURES}" tatt_pkg_error "${1:?}" "${eout}"
+ return 1
+ fi
+}
+
+if [[ ${1} == "--clean" ]]; then
+ cleanup
+else
+ main
+fi