diff --git a/src/ahriman/application/application.py b/src/ahriman/application/application.py index 660bdb13..76dd36ea 100644 --- a/src/ahriman/application/application.py +++ b/src/ahriman/application/application.py @@ -102,7 +102,7 @@ class Application: known_packages = self._known_packages() def add_directory(path: Path) -> None: - for full_path in filter(lambda p: package_like(p.name), path.iterdir()): + for full_path in filter(package_like, path.iterdir()): add_archive(full_path) def add_manual(name: str) -> Path: @@ -192,8 +192,7 @@ class Application: process_update(packages) # process manual packages - tree = Tree() - tree.load(updates) + tree = Tree.load(updates) for num, level in enumerate(tree.levels()): self.logger.info(f"processing level #{num} {[package.base for package in level]}") packages = self.repository.process_build(level) diff --git a/src/ahriman/core/configuration.py b/src/ahriman/core/configuration.py index 918c51ad..78e7d4a2 100644 --- a/src/ahriman/core/configuration.py +++ b/src/ahriman/core/configuration.py @@ -55,7 +55,8 @@ class Configuration(configparser.RawConfigParser): """ :return: path to directory with configuration includes """ - return Path(self.get("settings", "include")) + value = Path(self.get("settings", "include")) + return self.absolute_path_for(value) @classmethod def from_path(cls: Type[Configuration], path: Path, logfile: bool) -> Configuration: @@ -70,6 +71,16 @@ class Configuration(configparser.RawConfigParser): config.load_logging(logfile) return config + def absolute_path_for(self, path_part: Path) -> Path: + """ + helper to generate absolute configuration path for relative settings value + :param path_part: path to generate + :return: absolute path according to current path configuration + """ + if self.path is None or path_part.is_absolute(): + return path_part + return self.path.parent / path_part + def dump(self, architecture: str) -> Dict[str, Dict[str, str]]: """ dump configuration to dictionary @@ -137,8 +148,10 @@ class Configuration(configparser.RawConfigParser): """ def file_logger() -> None: try: - fileConfig(self.get("settings", "logging")) - except PermissionError: + value = Path(self.get("settings", "logging")) + config_path = self.absolute_path_for(value) + fileConfig(config_path) + except (FileNotFoundError, PermissionError): console_logger() logging.exception("could not create logfile, fallback to stderr") diff --git a/src/ahriman/core/repository/repository.py b/src/ahriman/core/repository/repository.py index 89f6d966..9db61d4a 100644 --- a/src/ahriman/core/repository/repository.py +++ b/src/ahriman/core/repository/repository.py @@ -38,7 +38,7 @@ class Repository(Executor, UpdateHandler): """ result: Dict[str, Package] = {} for full_path in self.paths.repository.iterdir(): - if not package_like(full_path.name): + if not package_like(full_path): continue try: local = Package.load(full_path, self.pacman, self.aur_url) diff --git a/src/ahriman/core/tree.py b/src/ahriman/core/tree.py index 85a21451..940891cd 100644 --- a/src/ahriman/core/tree.py +++ b/src/ahriman/core/tree.py @@ -23,7 +23,7 @@ import shutil import tempfile from pathlib import Path -from typing import Iterable, List, Set +from typing import Iterable, List, Set, Type from ahriman.core.build_tools.task import Task from ahriman.models.package import Package @@ -36,13 +36,14 @@ class Leaf: :ivar package: leaf package properties """ - def __init__(self, package: Package) -> None: + def __init__(self, package: Package, dependencies: Set[str]) -> None: """ default constructor :param package: package properties + :param dependencies: package dependencies """ self.package = package - self.dependencies: Set[str] = set() + self.dependencies = dependencies @property def items(self) -> Iterable[str]: @@ -51,6 +52,21 @@ class Leaf: """ return self.package.packages.keys() + @classmethod + def load(cls: Type[Leaf], package: Package) -> Leaf: + """ + load leaf from package with dependencies + :param package: package properties + :return: loaded class + """ + clone_dir = Path(tempfile.mkdtemp()) + try: + Task.fetch(clone_dir, package.git_url) + dependencies = Package.dependencies(clone_dir) + finally: + shutil.rmtree(clone_dir, ignore_errors=True) + return cls(package, dependencies) + def is_root(self, packages: Iterable[Leaf]) -> bool: """ check if package depends on any other package from list of not @@ -62,17 +78,6 @@ class Leaf: return False return True - def load_dependencies(self) -> None: - """ - load dependencies for the leaf - """ - clone_dir = Path(tempfile.mkdtemp()) - try: - Task.fetch(clone_dir, self.package.git_url) - self.dependencies = Package.dependencies(clone_dir) - finally: - shutil.rmtree(clone_dir, ignore_errors=True) - class Tree: """ @@ -80,11 +85,21 @@ class Tree: :ivar leaves: list of tree leaves """ - def __init__(self) -> None: + def __init__(self, leaves: List[Leaf]) -> None: """ default constructor + :param leaves: leaves to build the tree """ - self.leaves: List[Leaf] = [] + self.leaves = leaves + + @classmethod + def load(cls: Type[Tree], packages: Iterable[Package]) -> Tree: + """ + load tree from packages + :param packages: packages list + :return: loaded class + """ + return cls([Leaf.load(package) for package in packages]) def levels(self) -> List[List[Package]]: """ @@ -99,13 +114,3 @@ class Tree: unprocessed = [leaf for leaf in unprocessed if not leaf.is_root(unprocessed)] return result - - def load(self, packages: Iterable[Package]) -> None: - """ - load tree from packages - :param packages: packages list - """ - for package in packages: - leaf = Leaf(package) - leaf.load_dependencies() - self.leaves.append(leaf) diff --git a/src/ahriman/core/util.py b/src/ahriman/core/util.py index e12d914c..bd4b761f 100644 --- a/src/ahriman/core/util.py +++ b/src/ahriman/core/util.py @@ -28,19 +28,17 @@ from ahriman.core.exceptions import InvalidOption def check_output(*args: str, exception: Optional[Exception], - cwd: Optional[Path] = None, stderr: int = subprocess.STDOUT, - logger: Optional[Logger] = None) -> str: + cwd: Optional[Path] = None, logger: Optional[Logger] = None) -> str: """ subprocess wrapper :param args: command line arguments :param exception: exception which has to be reraised instead of default subprocess exception :param cwd: current working directory - :param stderr: standard error output mode :param logger: logger to log command result if required :return: command output """ try: - result = subprocess.check_output(args, cwd=cwd, stderr=stderr).decode("utf8").strip() + result = subprocess.check_output(args, cwd=cwd, stderr=subprocess.STDOUT).decode("utf8").strip() if logger is not None: for line in result.splitlines(): logger.debug(line) @@ -52,13 +50,14 @@ def check_output(*args: str, exception: Optional[Exception], return result -def package_like(filename: str) -> bool: +def package_like(filename: Path) -> bool: """ check if file looks like package :param filename: name of file to check :return: True in case if name contains `.pkg.` and not signature, False otherwise """ - return ".pkg." in filename and not filename.endswith(".sig") + name = filename.name + return ".pkg." in name and not name.endswith(".sig") def pretty_datetime(timestamp: Optional[int]) -> str: @@ -86,10 +85,10 @@ def pretty_size(size: Optional[float], level: int = 0) -> str: return "MiB" if level == 3: return "GiB" - raise InvalidOption(level) # I hope it will not be more than 1024 GiB + raise InvalidOption(level) # must never happen actually if size is None: return "" - if size < 1024: - return f"{round(size, 2)} {str_level()}" + if size < 1024 or level == 3: + return f"{size:.1f} {str_level()}" return pretty_size(size / 1024, level + 1) diff --git a/src/ahriman/models/package_desciption.py b/src/ahriman/models/package_desciption.py index 8f77b620..34aa0792 100644 --- a/src/ahriman/models/package_desciption.py +++ b/src/ahriman/models/package_desciption.py @@ -18,6 +18,7 @@ # along with this program. If not, see . # from dataclasses import dataclass +from pathlib import Path from typing import Optional @@ -35,3 +36,10 @@ class PackageDescription: build_date: Optional[int] = None filename: Optional[str] = None installed_size: Optional[int] = None + + @property + def filepath(self) -> Optional[Path]: + """ + :return: path object for current filename + """ + return Path(self.filename) if self.filename is not None else None diff --git a/tests/ahriman/core/conftest.py b/tests/ahriman/core/conftest.py index a05582f9..6ae80a27 100644 --- a/tests/ahriman/core/conftest.py +++ b/tests/ahriman/core/conftest.py @@ -6,6 +6,7 @@ from ahriman.core.alpm.pacman import Pacman from ahriman.core.alpm.repo import Repo from ahriman.core.build_tools.task import Task from ahriman.core.configuration import Configuration +from ahriman.core.tree import Leaf from ahriman.models.package import Package from ahriman.models.repository_paths import RepositoryPaths @@ -16,6 +17,16 @@ def configuration(resource_path_root: Path) -> Configuration: return Configuration.from_path(path=path, logfile=False) +@pytest.fixture +def leaf_ahriman(package_ahriman: Package) -> Leaf: + return Leaf(package_ahriman, set()) + + +@pytest.fixture +def leaf_python_schedule(package_python_schedule: Package) -> Leaf: + return Leaf(package_python_schedule, set()) + + @pytest.fixture def pacman(configuration: Configuration) -> Pacman: return Pacman(configuration) diff --git a/tests/ahriman/core/test_configuration.py b/tests/ahriman/core/test_configuration.py new file mode 100644 index 00000000..bb29eb58 --- /dev/null +++ b/tests/ahriman/core/test_configuration.py @@ -0,0 +1,127 @@ +from pathlib import Path + +from pytest_mock import MockerFixture + +from ahriman.core.configuration import Configuration + + +def test_from_path(mocker: MockerFixture) -> None: + """ + must load configuration + """ + read_mock = mocker.patch("configparser.RawConfigParser.read") + load_includes_mock = mocker.patch("ahriman.core.configuration.Configuration.load_includes") + load_logging_mock = mocker.patch("ahriman.core.configuration.Configuration.load_logging") + path = Path("path") + + config = Configuration.from_path(path, True) + assert config.path == path + read_mock.assert_called_with(path) + load_includes_mock.assert_called_once() + load_logging_mock.assert_called_once() + + +def test_absolute_path_for_absolute(configuration: Configuration) -> None: + """ + must not change path for absolute path in settings + """ + path = Path("/a/b/c") + assert configuration.absolute_path_for(path) == path + + +def test_absolute_path_for_relative(configuration: Configuration) -> None: + """ + must prepend root path to relative path + """ + path = Path("a") + result = configuration.absolute_path_for(path) + assert result.is_absolute() + assert result.parent == configuration.path.parent + assert result.name == path.name + + +def test_dump(configuration: Configuration) -> None: + """ + dump must not be empty + """ + assert configuration.dump("x86_64") + + +def test_dump_architecture_specific(configuration: Configuration) -> None: + """ + dump must contain architecture specific settings + """ + configuration.add_section("build_x86_64") + configuration.set("build_x86_64", "archbuild_flags", "") + + dump = configuration.dump("x86_64") + assert dump + assert "build" not in dump + assert "build_x86_64" in dump + + +def test_getlist(configuration: Configuration) -> None: + """ + must return list of string correctly + """ + configuration.set("build", "test_list", "a b c") + assert configuration.getlist("build", "test_list") == ["a", "b", "c"] + + +def test_getlist_empty(configuration: Configuration) -> None: + """ + must return list of string correctly for non-existing option + """ + assert configuration.getlist("build", "test_list") == [] + configuration.set("build", "test_list", "") + assert configuration.getlist("build", "test_list") == [] + + +def test_getlist_single(configuration: Configuration) -> None: + """ + must return list of strings for single string + """ + configuration.set("build", "test_list", "a") + assert configuration.getlist("build", "test_list") == ["a"] + + +def test_get_section_name(configuration: Configuration) -> None: + """ + must return architecture specific group + """ + configuration.add_section("build_x86_64") + configuration.set("build_x86_64", "archbuild_flags", "") + assert configuration.get_section_name("build", "x86_64") == "build_x86_64" + + +def test_get_section_name_missing(configuration: Configuration) -> None: + """ + must return default group if architecture depending group does not exist + """ + assert configuration.get_section_name("prefix", "suffix") == "prefix" + assert configuration.get_section_name("build", "x86_64") == "build" + + +def test_load_includes_missing(configuration: Configuration) -> None: + """ + must not fail if not include directory found + """ + configuration.set("settings", "include", "path") + configuration.load_includes() + + +def test_load_logging_fallback(configuration: Configuration, mocker: MockerFixture) -> None: + """ + must fallback to stderr without errors + """ + mocker.patch("logging.config.fileConfig", side_effect=PermissionError()) + configuration.load_logging(True) + + +def test_load_logging_stderr(configuration: Configuration, mocker: MockerFixture) -> None: + """ + must use stderr if flag set + """ + logging_mock = mocker.patch("logging.config.fileConfig") + configuration.load_logging(False) + logging_mock.assert_not_called() diff --git a/tests/ahriman/core/test_tree.py b/tests/ahriman/core/test_tree.py new file mode 100644 index 00000000..f6dc7069 --- /dev/null +++ b/tests/ahriman/core/test_tree.py @@ -0,0 +1,78 @@ +from pytest_mock import MockerFixture + +from ahriman.core.tree import Leaf, Tree +from ahriman.models.package import Package + + +def test_leaf_is_root_empty(leaf_ahriman: Leaf) -> None: + """ + must be root for empty packages list + """ + assert leaf_ahriman.is_root([]) + + +def test_leaf_is_root_false(leaf_ahriman: Leaf, leaf_python_schedule: Leaf) -> None: + """ + must be root for empty dependencies list or if does not depend on packages + """ + assert leaf_ahriman.is_root([leaf_python_schedule]) + leaf_ahriman.dependencies = {"ahriman-dependency"} + assert leaf_ahriman.is_root([leaf_python_schedule]) + + +def test_leaf_is_root_true(leaf_ahriman: Leaf, leaf_python_schedule: Leaf) -> None: + """ + must not be root if depends on packages + """ + leaf_ahriman.dependencies = {"python-schedule"} + assert not leaf_ahriman.is_root([leaf_python_schedule]) + + leaf_ahriman.dependencies = {"python2-schedule"} + assert not leaf_ahriman.is_root([leaf_python_schedule]) + + leaf_ahriman.dependencies = set(leaf_python_schedule.package.packages.keys()) + assert not leaf_ahriman.is_root([leaf_python_schedule]) + + +def test_leaf_load(package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must load with dependencies + """ + tempdir_mock = mocker.patch("tempfile.mkdtemp") + fetch_mock = mocker.patch("ahriman.core.build_tools.task.Task.fetch") + dependencies_mock = mocker.patch("ahriman.models.package.Package.dependencies", return_value={"ahriman-dependency"}) + rmtree_mock = mocker.patch("shutil.rmtree") + + leaf = Leaf.load(package_ahriman) + assert leaf.package == package_ahriman + assert leaf.dependencies == {"ahriman-dependency"} + tempdir_mock.assert_called_once() + fetch_mock.assert_called_once() + dependencies_mock.assert_called_once() + rmtree_mock.assert_called_once() + + +def test_tree_levels(leaf_ahriman: Leaf, leaf_python_schedule: Leaf, mocker: MockerFixture) -> None: + """ + must generate correct levels in the simples case + """ + leaf_ahriman.dependencies = set(leaf_python_schedule.package.packages.keys()) + + tree = Tree([leaf_ahriman, leaf_python_schedule]) + assert len(tree.levels()) == 2 + first, second = tree.levels() + assert first == [leaf_python_schedule.package] + assert second == [leaf_ahriman.package] + + +def test_tree_load(package_ahriman: Package, package_python_schedule: Package, mocker: MockerFixture) -> None: + """ + must package list + """ + mocker.patch("tempfile.mkdtemp") + mocker.patch("ahriman.core.build_tools.task.Task.fetch") + mocker.patch("ahriman.models.package.Package.dependencies") + mocker.patch("shutil.rmtree") + + tree = Tree.load([package_ahriman, package_python_schedule]) + assert len(tree.leaves) == 2 diff --git a/tests/ahriman/core/test_util.py b/tests/ahriman/core/test_util.py new file mode 100644 index 00000000..e79ce29e --- /dev/null +++ b/tests/ahriman/core/test_util.py @@ -0,0 +1,131 @@ +import logging +import pytest +import subprocess + +from pytest_mock import MockerFixture + +from ahriman.core.util import check_output, package_like, pretty_datetime, pretty_size +from ahriman.models.package import Package + + +def test_check_output(mocker: MockerFixture) -> None: + """ + must run command and log result + """ + logger_mock = mocker.patch("logging.Logger.debug") + + assert check_output("echo", "hello", exception=None) == "hello" + logger_mock.assert_not_called() + + assert check_output("echo", "hello", exception=None, logger=logging.getLogger("")) == "hello" + logger_mock.assert_called_once() + + +def test_check_output_failure(mocker: MockerFixture) -> None: + """ + must process exception correctly + """ + logger_mock = mocker.patch("logging.Logger.debug") + mocker.patch("subprocess.check_output", side_effect=subprocess.CalledProcessError(1, "echo")) + + with pytest.raises(subprocess.CalledProcessError): + check_output("echo", "hello", exception=None) + logger_mock.assert_not_called() + + with pytest.raises(subprocess.CalledProcessError): + check_output("echo", "hello", exception=None, logger=logging.getLogger("")) + logger_mock.assert_not_called() + + +def test_check_output_failure_log(mocker: MockerFixture) -> None: + """ + must process exception correctly and log it + """ + logger_mock = mocker.patch("logging.Logger.debug") + mocker.patch("subprocess.check_output", side_effect=subprocess.CalledProcessError(1, "echo", output=b"result")) + + with pytest.raises(subprocess.CalledProcessError): + check_output("echo", "hello", exception=None, logger=logging.getLogger("")) + logger_mock.assert_called_once() + + +def test_package_like(package_ahriman: Package) -> None: + """ + package_like must return true for archives + """ + assert package_like(package_ahriman.packages[package_ahriman.base].filepath) + + +def test_package_like_sig(package_ahriman: Package) -> None: + """ + package_like must return false for signature files + """ + package_file = package_ahriman.packages[package_ahriman.base].filepath + sig_file = package_file.parent / f"{package_file.name}.sig" + assert not package_like(sig_file) + + +def test_pretty_datetime() -> None: + """ + must generate string from timestamp value + """ + assert pretty_datetime(0) == "1970-01-01 00:00:00" + + +def test_pretty_datetime_empty() -> None: + """ + must generate empty string from None timestamp + """ + assert pretty_datetime(None) == "" + + +def test_pretty_size_bytes() -> None: + """ + must generate bytes string for bytes value + """ + value, abbrev = pretty_size(42).split() + assert value == "42.0" + assert abbrev == "B" + + +def test_pretty_size_kbytes() -> None: + """ + must generate kibibytes string for kibibytes value + """ + value, abbrev = pretty_size(42 * 1024).split() + assert value == "42.0" + assert abbrev == "KiB" + + +def test_pretty_size_mbytes() -> None: + """ + must generate mebibytes string for mebibytes value + """ + value, abbrev = pretty_size(42 * 1024 * 1024).split() + assert value == "42.0" + assert abbrev == "MiB" + + +def test_pretty_size_gbytes() -> None: + """ + must generate gibibytes string for gibibytes value + """ + value, abbrev = pretty_size(42 * 1024 * 1024 * 1024).split() + assert value == "42.0" + assert abbrev == "GiB" + + +def test_pretty_size_pbytes() -> None: + """ + must generate pebibytes string for pebibytes value + """ + value, abbrev = pretty_size(42 * 1024 * 1024 * 1024 * 1024).split() + assert value == "43008.0" + assert abbrev == "GiB" + + +def test_pretty_size_empty() -> None: + """ + must generate empty string for None value + """ + assert pretty_size(None) == "" diff --git a/tests/ahriman/models/test_package_desciption.py b/tests/ahriman/models/test_package_desciption.py index e69de29b..5808f752 100644 --- a/tests/ahriman/models/test_package_desciption.py +++ b/tests/ahriman/models/test_package_desciption.py @@ -0,0 +1,17 @@ +from ahriman.models.package_desciption import PackageDescription + + +def test_filepath(package_description_ahriman: PackageDescription) -> None: + """ + must generate correct filepath if set + """ + assert package_description_ahriman.filepath is not None + assert package_description_ahriman.filepath.name == package_description_ahriman.filename + + +def test_filepath_empty(package_description_ahriman: PackageDescription) -> None: + """ + must return None for missing filename + """ + package_description_ahriman.filename = None + assert package_description_ahriman.filepath is None