diff --git a/package/share/bash-completion/completions/_ahriman b/package/share/bash-completion/completions/_ahriman index 72301b94..5fedf474 100644 --- a/package/share/bash-completion/completions/_ahriman +++ b/package/share/bash-completion/completions/_ahriman @@ -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') diff --git a/package/share/man/man1/ahriman.1 b/package/share/man/man1/ahriman.1 index 93d108dc..cab20277 100644 --- a/package/share/man/man1/ahriman.1 +++ b/package/share/man/man1/ahriman.1 @@ -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 ...] diff --git a/package/share/zsh/site-functions/_ahriman b/package/share/zsh/site-functions/_ahriman index 91316737..a6649f6f 100644 --- a/package/share/zsh/site-functions/_ahriman +++ b/package/share/zsh/site-functions/_ahriman @@ -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=( diff --git a/src/ahriman/application/ahriman.py b/src/ahriman/application/ahriman.py index e1879057..09f6df9e 100644 --- a/src/ahriman/application/ahriman.py +++ b/src/ahriman/application/ahriman.py @@ -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 diff --git a/src/ahriman/application/handlers/structure.py b/src/ahriman/application/handlers/structure.py index 260b293e..596a0344 100644 --- a/src/ahriman/application/handlers/structure.py +++ b/src/ahriman/application/handlers/structure.py @@ -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 num, level in enumerate(tree): - TreePrinter(num, level).print(verbose=True, separator=" ") + 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) diff --git a/src/ahriman/core/exceptions.py b/src/ahriman/core/exceptions.py index cd736a82..2be3fbc9 100644 --- a/src/ahriman/core/exceptions.py +++ b/src/ahriman/core/exceptions.py @@ -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 diff --git a/src/ahriman/core/formatters/tree_printer.py b/src/ahriman/core/formatters/tree_printer.py index 3a9ac902..571ef908 100644 --- a/src/ahriman/core/formatters/tree_printer.py +++ b/src/ahriman/core/formatters/tree_printer.py @@ -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]: diff --git a/src/ahriman/core/tree.py b/src/ahriman/core/tree.py index e1550b06..eb964f6c 100644 --- a/src/ahriman/core/tree.py +++ b/src/ahriman/core/tree.py @@ -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) diff --git a/src/ahriman/core/util.py b/src/ahriman/core/util.py index 37872d3a..57ba8c64 100644 --- a/src/ahriman/core/util.py +++ b/src/ahriman/core/util.py @@ -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: diff --git a/tests/ahriman/application/handlers/test_handler_structure.py b/tests/ahriman/application/handlers/test_handler_structure.py index b6269b0b..49c66a16 100644 --- a/tests/ahriman/application/handlers/test_handler_structure.py +++ b/tests/ahriman/application/handlers/test_handler_structure.py @@ -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: diff --git a/tests/ahriman/application/test_ahriman.py b/tests/ahriman/application/test_ahriman.py index 0a6bbab5..69a452ae 100644 --- a/tests/ahriman/application/test_ahriman.py +++ b/tests/ahriman/application/test_ahriman.py @@ -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 diff --git a/tests/ahriman/core/test_tree.py b/tests/ahriman/core/test_tree.py index cc227cbb..906e21d9 100644 --- a/tests/ahriman/core/test_tree.py +++ b/tests/ahriman/core/test_tree.py @@ -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] diff --git a/tests/ahriman/core/test_util.py b/tests/ahriman/core/test_util.py index 13315d65..ddced37c 100644 --- a/tests/ahriman/core/test_util.py +++ b/tests/ahriman/core/test_util.py @@ -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