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

Reply via email to