add ability to partition tree before calculationn

This commit is contained in:
Evgenii Alekseev 2023-08-27 01:12:12 +03:00
parent f6081507c0
commit a1db4dc8b8
13 changed files with 393 additions and 45 deletions

View File

@ -43,7 +43,7 @@ _shtab_ahriman_sign_option_strings=('-h' '--help')
_shtab_ahriman_repo_status_update_option_strings=('-h' '--help' '-s' '--status')
_shtab_ahriman_repo_sync_option_strings=('-h' '--help')
_shtab_ahriman_sync_option_strings=('-h' '--help')
_shtab_ahriman_repo_tree_option_strings=('-h' '--help')
_shtab_ahriman_repo_tree_option_strings=('-h' '--help' '-p' '--partitions')
_shtab_ahriman_repo_triggers_option_strings=('-h' '--help')
_shtab_ahriman_repo_update_option_strings=('-h' '--help' '--aur' '--no-aur' '--dependencies' '--no-dependencies' '--dry-run' '-e' '--exit-code' '--increment' '--no-increment' '--local' '--no-local' '--manual' '--no-manual' '-u' '--username' '--vcs' '--no-vcs' '-y' '--refresh')
_shtab_ahriman_update_option_strings=('-h' '--help' '--aur' '--no-aur' '--dependencies' '--no-dependencies' '--dry-run' '-e' '--exit-code' '--increment' '--no-increment' '--local' '--no-local' '--manual' '--no-manual' '-u' '--username' '--vcs' '--no-vcs' '-y' '--refresh')

View File

@ -1,4 +1,4 @@
.TH AHRIMAN "1" "2023\-08\-19" "ahriman" "Generated Python Manual"
.TH AHRIMAN "1" "2023\-08\-26" "ahriman" "Generated Python Manual"
.SH NAME
ahriman
.SH SYNOPSIS
@ -43,7 +43,7 @@ allow to run ahriman as non\-ahriman user. Some actions might be unavailable
.TP
\fB\-\-wait\-timeout\fR \fI\,WAIT_TIMEOUT\/\fR
wait for lock to be free. Negative value will lead to immediate application run even if there is lock file. In case of
zero value, tthe application will wait infinitely
zero value, the application will wait infinitely
.TP
\fB\-V\fR, \fB\-\-version\fR
@ -558,10 +558,15 @@ usage: ahriman repo\-sync [\-h]
sync repository files to remote server according to current settings
.SH COMMAND \fI\,'ahriman repo\-tree'\/\fR
usage: ahriman repo\-tree [\-h]
usage: ahriman repo\-tree [\-h] [\-p PARTITIONS]
dump repository tree based on packages dependencies
.SH OPTIONS \fI\,'ahriman repo\-tree'\/\fR
.TP
\fB\-p\fR \fI\,PARTITIONS\/\fR, \fB\-\-partitions\fR \fI\,PARTITIONS\/\fR
also divide packages by independent partitions
.SH COMMAND \fI\,'ahriman repo\-triggers'\/\fR
usage: ahriman repo\-triggers [\-h] [trigger ...]

View File

@ -85,7 +85,7 @@ _shtab_ahriman_options=(
{--report,--no-report}"[force enable or disable reporting to web service (default\: True)]:report:"
{-q,--quiet}"[force disable any logging (default\: False)]"
"--unsafe[allow to run ahriman as non-ahriman user. Some actions might be unavailable (default\: False)]"
"--wait-timeout[wait for lock to be free. Negative value will lead to immediate application run even if there is lock file. In case of zero value, tthe application will wait infinitely (default\: -1)]:wait_timeout:"
"--wait-timeout[wait for lock to be free. Negative value will lead to immediate application run even if there is lock file. In case of zero value, the application will wait infinitely (default\: -1)]:wait_timeout:"
"(- : *)"{-V,--version}"[show program\'s version number and exit]"
)
@ -415,6 +415,7 @@ _shtab_ahriman_repo_sync_options=(
_shtab_ahriman_repo_tree_options=(
"(- : *)"{-h,--help}"[show this help message and exit]"
{-p,--partitions}"[also divide packages by independent partitions (default\: 1)]:partitions:"
)
_shtab_ahriman_repo_triggers_options=(

View File

@ -715,6 +715,8 @@ def _set_repo_tree_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser = root.add_parser("repo-tree", help="dump repository tree",
description="dump repository tree based on packages dependencies",
formatter_class=_formatter)
parser.add_argument("-p", "--partitions", help="also divide packages by independent partitions",
type=int, default=1)
parser.set_defaults(handler=handlers.Structure, lock=None, report=False, quiet=True, unsafe=True)
return parser

View File

@ -22,7 +22,7 @@ import argparse
from ahriman.application.application import Application
from ahriman.application.handlers import Handler
from ahriman.core.configuration import Configuration
from ahriman.core.formatters import TreePrinter
from ahriman.core.formatters import StringPrinter, TreePrinter
from ahriman.core.tree import Tree
@ -45,8 +45,14 @@ class Structure(Handler):
report(bool): force enable or disable reporting
"""
application = Application(architecture, configuration, report=report)
packages = application.repository.packages()
partitions = Tree.partition(application.repository.packages(), count=args.partitions)
tree = Tree.resolve(packages)
for partition_id, partition in enumerate(partitions):
StringPrinter(f"partition #{partition_id}").print(verbose=False)
tree = Tree.resolve(partition)
for num, level in enumerate(tree):
TreePrinter(num, level).print(verbose=True, separator=" ")
# empty line
StringPrinter("").print(verbose=False)

View File

@ -244,6 +244,21 @@ class PasswordError(ValueError):
ValueError.__init__(self, f"Password error: {details}")
class PartitionError(RuntimeError):
"""
exception raised during packages partition actions
"""
def __init__(self, count: int) -> None:
"""
default constructor
Args:
count(int): count of partitions
"""
RuntimeError.__init__(self, f"Could not divide packages into {count} partitions")
class PkgbuildGeneratorError(RuntimeError):
"""
exception class for support type triggers

View File

@ -38,7 +38,7 @@ class TreePrinter(StringPrinter):
level(int): dependencies tree level
packages(list[Package]): packages which belong to this level
"""
StringPrinter.__init__(self, f"level {level}")
StringPrinter.__init__(self, f"level #{level}")
self.packages = packages
def properties(self) -> list[Property]:

View File

@ -21,9 +21,10 @@ from __future__ import annotations
import functools
from collections.abc import Callable, Iterable
from collections.abc import Iterable
from ahriman.core.util import partition
from ahriman.core.exceptions import PartitionError
from ahriman.core.util import minmax, partition
from ahriman.models.package import Package
@ -128,6 +129,75 @@ class Tree:
"""
self.leaves = leaves
@staticmethod
def balance(partitions: list[list[Leaf]]) -> list[list[Leaf]]:
"""
balance partitions. This method tries to find the longest and the shortest lists and move free leaves between
them if possible. In case if there are no free packages (i.e. the ones which don't depend on any other in
partition and are not dependency of any), it will drop it as it is. This method is guaranteed to produce the
same unsorted sequences for same unsorted input
Args:
partitions(list[list[Leaf]]): source unbalanced partitions
Returns:
list[list[Leaf]]: balanced partitions
"""
# to make sure that we will have same sequences after balance we need to ensure that list is sorted
partitions = [
sorted(part, key=lambda leaf: leaf.package.base)
for part in partitions if part
]
while True:
min_part, max_part = minmax(partitions, key=len)
if len(max_part) - len(min_part) <= 1: # there is nothing to balance
break
# find first package from max list which is not dependency and doesn't depend on any other package
free_index = next(
(
index
for index, leaf in enumerate(max_part)
if not leaf.is_dependency(max_part) and leaf.is_root(max_part)
),
None
)
if free_index is None: # impossible to balance between the shortest and the longest
break
min_part.append(max_part.pop(free_index))
return partitions
@staticmethod
def partition(packages: Iterable[Package], *, count: int) -> list[list[Package]]:
"""
partition tree into independent chunks of more or less equal amount of packages. The packages in produced
partitions don't depend on any package from other partitions
Args:
packages(Iterable[Package]): packages list
count(int): maximal amount of partitions
Returns:
list[list[Package]]: list of packages lists based on their dependencies. The amount of elements in each
sublist is less or equal to ``count``
Raises:
PartitionError: in case if it is impossible to divide tree by specified amount of partitions
"""
if count < 1:
raise PartitionError(count)
# special case
if count == 1:
return [sorted(packages, key=lambda package: package.base)]
leaves = [Leaf(package) for package in packages]
instance = Tree(leaves)
return instance.partitions(count=count)
@staticmethod
def resolve(packages: Iterable[Package]) -> list[list[Package]]:
"""
@ -143,6 +213,22 @@ class Tree:
instance = Tree(leaves)
return instance.levels()
@staticmethod
def sort(leaves: list[list[Leaf]]) -> list[list[Package]]:
"""
sort given list of leaves by package base
Args:
leaves(list[list[Leaf]]): leaves to sort
Returns:
list[list[Package]]: sorted list of packages on each level
"""
return [
sorted([leaf.package for leaf in level], key=lambda package: package.base)
for level in leaves if level
]
def levels(self) -> list[list[Package]]:
"""
get build levels starting from the packages which do not require any other package to build
@ -155,8 +241,10 @@ class Tree:
# build initial tree
unprocessed = self.leaves[:]
while unprocessed:
unsorted.append([leaf for leaf in unprocessed if leaf.is_root(unprocessed)])
unprocessed = [leaf for leaf in unprocessed if not leaf.is_root(unprocessed)]
# additional workaround with partial in order to hide cell-var-from-loop pylint warning
predicate = functools.partial(Leaf.is_root, packages=unprocessed)
new_level, unprocessed = partition(unprocessed, predicate)
unsorted.append(new_level)
# move leaves to the end if they are not required at the next level
for current_num, current_level in enumerate(unsorted[:-1]):
@ -164,13 +252,47 @@ class Tree:
next_level = unsorted[next_num]
# change lists inside the collection
# additional workaround with partial in order to hide cell-var-from-loop pylint warning
predicate = functools.partial(Leaf.is_dependency, packages=next_level)
unsorted[current_num], to_be_moved = partition(current_level, predicate)
unsorted[next_num].extend(to_be_moved)
comparator: Callable[[Package], str] = lambda package: package.base
return [
sorted([leaf.package for leaf in level], key=comparator)
for level in unsorted if level
]
return self.sort(unsorted)
def partitions(self, *, count: int) -> list[list[Package]]:
"""
partition tree into (more or less) equal chunks of packages which don't depend on each other
Args:
count(int): maximal amount of partitions
Returns:
list[list[Package]]: sorted list of packages partitions
"""
unsorted: list[list[Leaf]] = [[] for _ in range(count)]
# in order to keep result stable we will need to sort packages all times
unprocessed = sorted(self.leaves, key=lambda leaf: leaf.package.base)
while unprocessed:
# pick one and append it to the most free partition and build chunk
leaf = unprocessed.pop()
chunk = [leaf]
while True: # python doesn't allow to use walrus operator to unpack tuples
# get packages which depend on packages in chunk
predicate = functools.partial(Leaf.is_root, packages=chunk)
unprocessed, new_dependent = partition(unprocessed, predicate)
chunk.extend(new_dependent)
# get packages which are dependency of packages in chunk
predicate = functools.partial(Leaf.is_dependency, packages=chunk)
new_dependencies, unprocessed = partition(unprocessed, predicate)
chunk.extend(new_dependencies)
if not new_dependent and not new_dependencies:
break
part = min(unsorted, key=len)
part.extend(chunk)
balanced = self.balance(unsorted)
return self.sort(balanced)

View File

@ -46,6 +46,7 @@ __all__ = [
"extract_user",
"filter_json",
"full_version",
"minmax",
"package_like",
"parse_version",
"partition",
@ -263,6 +264,22 @@ def full_version(epoch: str | int | None, pkgver: str, pkgrel: str) -> str:
return f"{prefix}{pkgver}-{pkgrel}"
def minmax(source: Iterable[T], *, key: Callable[[T], Any] | None = None) -> tuple[T, T]:
"""
get min and max value from iterable
Args:
source(Iterable[T]): source list to find min and max values
key(Callable[[T], Any] | None, optional): key to sort (Default value = None)
Returns:
tuple[T, T]: min and max values for sequence
"""
first_iter, second_iter = itertools.tee(source)
# typing doesn't expose SupportLessThan, so we just ignore this in typecheck
return min(first_iter, key=key), max(second_iter, key=key) # type: ignore
def package_like(filename: Path) -> bool:
"""
check if file looks like package
@ -296,12 +313,12 @@ def parse_version(version: str) -> tuple[str | None, str, str]:
return epoch, pkgver, pkgrel
def partition(source: list[T], predicate: Callable[[T], bool]) -> tuple[list[T], list[T]]:
def partition(source: Iterable[T], predicate: Callable[[T], bool]) -> tuple[list[T], list[T]]:
"""
partition list into two based on predicate, based on https://docs.python.org/dev/library/itertools.html#itertools-recipes
Args:
source(list[T]): source list to be partitioned
source(Iterable[T]): source list to be partitioned
predicate(Callable[[T], bool]): filter function
Returns:

View File

@ -1,6 +1,7 @@
import argparse
from pytest_mock import MockerFixture
from unittest.mock import call as MockCall
from ahriman.application.handlers import Structure
from ahriman.core.configuration import Configuration
@ -8,19 +9,40 @@ from ahriman.core.repository import Repository
from ahriman.models.package import Package
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
Args:
args(argparse.Namespace): command line arguments fixture
Returns:
argparse.Namespace: generated arguments for these test cases
"""
args.partitions = 1
return args
def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository,
package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
mocker.patch("ahriman.core.repository.Repository.packages", return_value=[package_ahriman])
packages_mock = mocker.patch("ahriman.core.tree.Tree.partition", return_value=[[package_ahriman]])
application_mock = mocker.patch("ahriman.core.tree.Tree.resolve", return_value=[[package_ahriman]])
print_mock = mocker.patch("ahriman.core.formatters.Printer.print")
Structure.run(args, "x86_64", configuration, report=False)
packages_mock.assert_called_once_with([package_ahriman], count=args.partitions)
application_mock.assert_called_once_with([package_ahriman])
print_mock.assert_called_once_with(verbose=True, separator=" ")
print_mock.assert_has_calls([
MockCall(verbose=False),
MockCall(verbose=True, separator=" "),
MockCall(verbose=False),
])
def test_disallow_auto_architecture_run() -> None:

View File

@ -600,6 +600,16 @@ def test_subparsers_repo_tree_architecture(parser: argparse.ArgumentParser) -> N
assert args.architecture == ["x86_64"]
def test_subparsers_repo_tree_option_partitions(parser: argparse.ArgumentParser) -> None:
"""
must convert partitions option to int instance
"""
args = parser.parse_args(["repo-tree"])
assert isinstance(args.partitions, int)
args = parser.parse_args(["repo-tree", "--partitions", "42"])
assert isinstance(args.partitions, int)
def test_subparsers_repo_triggers_architecture(parser: argparse.ArgumentParser) -> None:
"""
repo-triggers command must correctly parse architecture list

View File

@ -1,6 +1,11 @@
import pytest
from ahriman.core.exceptions import PartitionError
from ahriman.core.tree import Leaf, Tree
from ahriman.models.package import Package
from ahriman.models.package_description import PackageDescription
from ahriman.models.package_source import PackageSource
from ahriman.models.remote_source import RemoteSource
def test_leaf_is_root_empty(leaf_ahriman: Leaf) -> None:
@ -33,15 +38,100 @@ def test_leaf_is_root_true(leaf_ahriman: Leaf, leaf_python_schedule: Leaf) -> No
assert not leaf_ahriman.is_root([leaf_python_schedule])
def test_tree_balance() -> None:
"""
must balance partitions
"""
leaf1 = Leaf(
Package(
base="package1",
version="1.0.0",
remote=RemoteSource(source=PackageSource.AUR),
packages={"package1": PackageDescription(depends=[])},
)
)
leaf2 = Leaf(
Package(
base="package2",
version="1.0.0",
remote=RemoteSource(source=PackageSource.AUR),
packages={"package2": PackageDescription(depends=[])},
)
)
leaf3 = Leaf(
Package(
base="package3",
version="1.0.0",
remote=RemoteSource(source=PackageSource.AUR),
packages={"package3": PackageDescription(depends=["package1"])},
)
)
leaf4 = Leaf(
Package(
base="package4",
version="1.0.0",
remote=RemoteSource(source=PackageSource.AUR),
packages={"package4": PackageDescription(depends=[])},
)
)
leaf5 = Leaf(
Package(
base="package5",
version="1.0.0",
remote=RemoteSource(source=PackageSource.AUR),
packages={"package5": PackageDescription(depends=[])},
)
)
first, second, third = Tree.balance([[leaf4], [leaf1, leaf2, leaf3], [leaf5]])
assert first == [leaf4, leaf2]
assert second == [leaf1, leaf3]
assert third == [leaf5]
def test_tree_partition(package_ahriman: Package, package_python_schedule: Package) -> None:
"""
must partition dependencies tree
"""
partitions = Tree.partition([package_ahriman, package_python_schedule], count=1)
assert len(partitions) == 1
assert len(partitions[0]) == 2
partitions = Tree.partition([package_ahriman, package_python_schedule], count=2)
assert len(partitions) == 2
assert all(len(partition) < 2 for partition in partitions)
partitions = Tree.partition([package_ahriman, package_python_schedule], count=3)
assert len(partitions) == 2
assert all(len(partition) < 2 for partition in partitions)
def test_tree_partition_invalid_count() -> None:
"""
must raise PartitionError exception if count is invalid
"""
with pytest.raises(PartitionError):
Tree.partition([], count=0)
with pytest.raises(PartitionError):
Tree.partition([], count=-1)
def test_tree_resolve(package_ahriman: Package, package_python_schedule: Package) -> None:
"""
must resolve denendecnies tree
must resolve dependencies tree
"""
tree = Tree.resolve([package_ahriman, package_python_schedule])
assert len(tree) == 1
assert len(tree[0]) == 2
def test_tree_sort(leaf_ahriman: Leaf, leaf_python_schedule: Leaf) -> None:
"""
must sort leaves and return packages
"""
assert Tree.sort([[leaf_python_schedule, leaf_ahriman]]) == [[leaf_ahriman.package, leaf_python_schedule.package]]
def test_tree_levels(leaf_ahriman: Leaf, leaf_python_schedule: Leaf) -> None:
"""
must generate correct levels in the simples case
@ -62,32 +152,32 @@ def test_tree_levels_sorted() -> None:
Package(
base="package1",
version="1.0.0",
remote=None,
packages={"package1": PackageDescription(depends=[])}
remote=RemoteSource(source=PackageSource.AUR),
packages={"package1": PackageDescription(depends=[])},
)
)
leaf2 = Leaf(
Package(
base="package2",
version="1.0.0",
remote=None,
packages={"package2": PackageDescription(depends=["package1"])}
remote=RemoteSource(source=PackageSource.AUR),
packages={"package2": PackageDescription(depends=["package1"])},
)
)
leaf3 = Leaf(
Package(
base="package3",
version="1.0.0",
remote=None,
packages={"package3": PackageDescription(depends=["package1"])}
remote=RemoteSource(source=PackageSource.AUR),
packages={"package3": PackageDescription(depends=["package1"])},
)
)
leaf4 = Leaf(
Package(
base="package4",
version="1.0.0",
remote=None,
packages={"package4": PackageDescription(depends=["package3"])}
remote=RemoteSource(source=PackageSource.AUR),
packages={"package4": PackageDescription(depends=["package3"])},
)
)
@ -96,3 +186,54 @@ def test_tree_levels_sorted() -> None:
assert first == [leaf1.package]
assert second == [leaf3.package]
assert third == [leaf2.package, leaf4.package]
def test_tree_partitions() -> None:
"""
must divide tree into partitions
"""
leaf1 = Leaf(
Package(
base="package1",
version="1.0.0",
remote=RemoteSource(source=PackageSource.AUR),
packages={"package1": PackageDescription(depends=[])},
)
)
leaf2 = Leaf(
Package(
base="package2",
version="1.0.0",
remote=RemoteSource(source=PackageSource.AUR),
packages={"package2": PackageDescription(depends=["package1"])},
)
)
leaf3 = Leaf(
Package(
base="package3",
version="1.0.0",
remote=RemoteSource(source=PackageSource.AUR),
packages={"package3": PackageDescription(depends=["package1"])},
)
)
leaf4 = Leaf(
Package(
base="package4",
version="1.0.0",
remote=RemoteSource(source=PackageSource.AUR),
packages={"package4": PackageDescription(depends=[])},
)
)
leaf5 = Leaf(
Package(
base="package5",
version="1.0.0",
remote=RemoteSource(source=PackageSource.AUR),
packages={"package5": PackageDescription(depends=["package2"])},
)
)
tree = Tree([leaf1, leaf2, leaf3, leaf4, leaf5])
first, second = tree.partitions(count=3)
assert first == [leaf1.package, leaf2.package, leaf3.package, leaf5.package]
assert second == [leaf4.package]

View File

@ -2,16 +2,15 @@ import datetime
import logging
import os
import pytest
import requests
from pathlib import Path
from pytest_mock import MockerFixture
from typing import Any
from unittest.mock import MagicMock, call as MockCall
from unittest.mock import call as MockCall
from ahriman.core.exceptions import BuildError, CalledProcessError, OptionError, UnsafeRunError
from ahriman.core.util import check_output, check_user, dataclass_view, enum_values, extract_user, filter_json, \
full_version, package_like, parse_version, partition, pretty_datetime, pretty_size, safe_filename, \
full_version, minmax, package_like, parse_version, partition, pretty_datetime, pretty_size, safe_filename, \
srcinfo_property, srcinfo_property_list, trim_package, utcnow, walk
from ahriman.models.package import Package
from ahriman.models.package_source import PackageSource
@ -204,6 +203,15 @@ def test_dataclass_view_without_none(package_ahriman: Package) -> None:
assert Package.from_json(result) == package_ahriman
def test_enum_values() -> None:
"""
must correctly generate choices from enumeration classes
"""
values = enum_values(PackageSource)
for value in values:
assert PackageSource(value).value == value
def test_extract_user() -> None:
"""
must extract user from system environment
@ -241,15 +249,6 @@ def test_filter_json_empty_value(package_ahriman: Package) -> None:
assert "base" not in filter_json(probe, probe.keys())
def test_enum_values() -> None:
"""
must correctly generate choices from enumeration classes
"""
values = enum_values(PackageSource)
for value in values:
assert PackageSource(value).value == value
def test_full_version() -> None:
"""
must construct full version
@ -260,6 +259,14 @@ def test_full_version() -> None:
assert full_version(1, "0.12.1", "1") == "1:0.12.1-1"
def test_minmax() -> None:
"""
must correctly define minimal and maximal value
"""
assert minmax([1, 4, 3, 2]) == (1, 4)
assert minmax([[1, 2, 3], [4, 5], [6, 7, 8, 9]], key=len) == ([4, 5], [6, 7, 8, 9])
def test_package_like(package_ahriman: Package) -> None:
"""
package_like must return true for archives