diff --git a/docs/architecture.md b/docs/architecture.md index 5c7b7d8e..8839272f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,6 +16,8 @@ This package contains application (aka executable) related classes and everythin `ahriman.application.application.application.Application` (god class) is used for any interaction from parsers with repository, web etc. It is divided into multiple traits by functions (package related and repository related) in the same package. +`ahriman.application.formatters` package provides `Printer` sub-classes for printing data (e.g. package properties) to stdout which are used by some handlers. + `ahriman.application.ahriman` contains only command line parses and executes specified `Handler` on success, `ahriman.application.lock.Lock` is additional class which provides file-based lock and also performs some common checks. ## `ahriman.core` package diff --git a/docs/faq.md b/docs/faq.md index 704a9e37..ddd057ab 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -399,6 +399,17 @@ Don't know, haven't tried it. But it lacks of documentation at least. * `archrepo2` actively uses direct shell calls and `yaourt` components. * It has constantly running process instead of timer process (it is not pro or con). +#### [repoctl](https://github.com/cassava/repoctl) + +* Web interface. +* No reporting. +* Local packages and patches support. +* Some actions are not fully automated (e.g. package update still requires manual intervention for the build itself). +* `repoctl` has better AUR interaction features. With colors! +* `repoctl` has much easier configuration and even completion. +* `repoctl` is able to store old packages. +* Ability to host repository from same command vs external services (e.g. nginx) in `ahriman`. + #### [repo-scripts](https://github.com/arcan1s/repo-scripts) Though originally I've created ahriman by trying to improve the project, it still lacks a lot of features: diff --git a/src/ahriman/application/ahriman.py b/src/ahriman/application/ahriman.py index 069d2a53..53d7f127 100644 --- a/src/ahriman/application/ahriman.py +++ b/src/ahriman/application/ahriman.py @@ -60,7 +60,8 @@ def _parser() -> argparse.ArgumentParser: parser.add_argument("-l", "--lock", help="lock file", type=Path, default=Path(tempfile.gettempdir()) / "ahriman.lock") parser.add_argument("--no-report", help="force disable reporting to web service", action="store_true") - parser.add_argument("-q", "--quiet", help="force disable any logging", action="store_true") + parser.add_argument("-q", "--quiet", help="force disable any logging", action=argparse.BooleanOptionalAction, + default=False) # sometimes we would like to run not quiet even if it is disabled by default parser.add_argument("--unsafe", help="allow to run ahriman as non-ahriman user. Some actions might be unavailable", action="store_true") parser.add_argument("-v", "--version", action="version", version=version.__version__) @@ -104,7 +105,12 @@ def _set_aur_search_parser(root: SubParserAction) -> argparse.ArgumentParser: """ parser = root.add_parser("aur-search", aliases=["search"], help="search for package", description="search for package in AUR using API", formatter_class=_formatter) - parser.add_argument("search", help="search terms, can be specified multiple times", nargs="+") + parser.add_argument("search", help="search terms, can be specified multiple times, result will match all terms", + nargs="+") + parser.add_argument("-i", "--info", help="show additional package information", action="store_true") + parser.add_argument("--sort-by", help="sort field by this field. In case if two packages have the same value of " + "the specified field, they will be always sorted by name", + default="name", choices=sorted(handlers.Search.SORT_FIELDS)) parser.set_defaults(handler=handlers.Search, architecture=[""], lock=None, no_report=True, quiet=True, unsafe=True) return parser @@ -181,6 +187,7 @@ def _set_package_status_parser(root: SubParserAction) -> argparse.ArgumentParser formatter_class=_formatter) parser.add_argument("package", help="filter status by package base", nargs="*") parser.add_argument("--ahriman", help="get service status itself", action="store_true") + parser.add_argument("-i", "--info", help="show additional package information", action="store_true") parser.add_argument("-s", "--status", help="filter packages by status", type=BuildStatusEnum, choices=BuildStatusEnum) parser.set_defaults(handler=handlers.Status, lock=None, no_report=True, quiet=True, unsafe=True) @@ -354,6 +361,7 @@ def _set_repo_remove_unknown_parser(root: SubParserAction) -> argparse.ArgumentP description="remove packages which are missing in AUR and do not have local PKGBUILDs", formatter_class=_formatter) parser.add_argument("--dry-run", help="just perform check for packages without removal", action="store_true") + parser.add_argument("-i", "--info", help="show additional package information", action="store_true") parser.set_defaults(handler=handlers.RemoveUnknown) return parser diff --git a/src/ahriman/application/formatters/__init__.py b/src/ahriman/application/formatters/__init__.py new file mode 100644 index 00000000..fb32931e --- /dev/null +++ b/src/ahriman/application/formatters/__init__.py @@ -0,0 +1,19 @@ +# +# Copyright (c) 2021 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# 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 3 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. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# diff --git a/src/ahriman/application/formatters/aur_printer.py b/src/ahriman/application/formatters/aur_printer.py new file mode 100644 index 00000000..dddc077c --- /dev/null +++ b/src/ahriman/application/formatters/aur_printer.py @@ -0,0 +1,62 @@ +# +# Copyright (c) 2021 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# 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 3 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. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +import aur # type: ignore + +from typing import List, Optional + +from ahriman.application.formatters.printer import Printer +from ahriman.core.util import pretty_datetime +from ahriman.models.property import Property + + +class AurPrinter(Printer): + """ + print content of the AUR package + """ + + def __init__(self, package: aur.Package) -> None: + """ + default constructor + :param package: AUR package description + """ + self.content = package + + def properties(self) -> List[Property]: + """ + convert content into printable data + :return: list of content properties + """ + return [ + Property("Package base", self.content.package_base), + Property("Description", self.content.description, is_required=True), + Property("Upstream URL", self.content.url), + Property("Licenses", self.content.license), # it should be actually a list + Property("Maintainer", self.content.maintainer or ""), # I think it is optional + Property("First submitted", pretty_datetime(self.content.first_submitted)), + Property("Last updated", pretty_datetime(self.content.last_modified)), + # more fields coming https://github.com/cdown/aur/pull/29 + ] + + def title(self) -> Optional[str]: + """ + generate entry title from content + :return: content title if it can be generated and None otherwise + """ + return f"{self.content.name} {self.content.version} ({self.content.num_votes})" diff --git a/src/ahriman/application/formatters/configuration_printer.py b/src/ahriman/application/formatters/configuration_printer.py new file mode 100644 index 00000000..6a725747 --- /dev/null +++ b/src/ahriman/application/formatters/configuration_printer.py @@ -0,0 +1,55 @@ +# +# Copyright (c) 2021 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# 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 3 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. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from typing import Dict, List, Optional + +from ahriman.application.formatters.printer import Printer +from ahriman.models.property import Property + + +class ConfigurationPrinter(Printer): + """ + print content of the configuration section + """ + + def __init__(self, section: str, values: Dict[str, str]) -> None: + """ + default constructor + :param section: section name + :param values: configuration values dictionary + """ + self.section = section + self.content = values + + def properties(self) -> List[Property]: + """ + convert content into printable data + :return: list of content properties + """ + return [ + Property(key, value, is_required=True) + for key, value in sorted(self.content.items()) + ] + + def title(self) -> Optional[str]: + """ + generate entry title from content + :return: content title if it can be generated and None otherwise + """ + return f"[{self.section}]" diff --git a/src/ahriman/application/formatters/package_printer.py b/src/ahriman/application/formatters/package_printer.py new file mode 100644 index 00000000..d5fe80a0 --- /dev/null +++ b/src/ahriman/application/formatters/package_printer.py @@ -0,0 +1,60 @@ +# +# Copyright (c) 2021 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# 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 3 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. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from typing import List, Optional + +from ahriman.application.formatters.printer import Printer +from ahriman.models.build_status import BuildStatus +from ahriman.models.package import Package +from ahriman.models.property import Property + + +class PackagePrinter(Printer): + """ + print content of the internal package object + """ + + def __init__(self, package: Package, status: BuildStatus) -> None: + """ + default constructor + :param package: package description + :param status: build status + """ + self.content = package + self.status = status + + def properties(self) -> List[Property]: + """ + convert content into printable data + :return: list of content properties + """ + return [ + Property("Version", self.content.version, is_required=True), + Property("Groups", " ".join(self.content.groups)), + Property("Licenses", " ".join(self.content.licenses)), + Property("Depends", " ".join(self.content.depends)), + Property("Status", self.status.pretty_print(), is_required=True), + ] + + def title(self) -> Optional[str]: + """ + generate entry title from content + :return: content title if it can be generated and None otherwise + """ + return self.content.pretty_print() diff --git a/src/ahriman/application/formatters/printer.py b/src/ahriman/application/formatters/printer.py new file mode 100644 index 00000000..c0404dfe --- /dev/null +++ b/src/ahriman/application/formatters/printer.py @@ -0,0 +1,55 @@ +# +# Copyright (c) 2021 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# 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 3 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. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from typing import Callable, List, Optional + +from ahriman.models.property import Property + + +class Printer: + """ + base class for formatters + """ + + def print(self, verbose: bool, log_fn: Callable[[str], None] = print, separator: str = ": ") -> None: + """ + print content + :param verbose: print all fields + :param log_fn: logger function to log data + :param separator: separator for property name and property value + """ + if (title := self.title()) is not None: + log_fn(title) + for prop in self.properties(): + if not verbose and not prop.is_required: + continue + log_fn(f"\t{prop.name}{separator}{prop.value}") + + def properties(self) -> List[Property]: # pylint: disable=no-self-use + """ + convert content into printable data + :return: list of content properties + """ + return [] + + def title(self) -> Optional[str]: + """ + generate entry title from content + :return: content title if it can be generated and None otherwise + """ diff --git a/src/ahriman/application/formatters/status_printer.py b/src/ahriman/application/formatters/status_printer.py new file mode 100644 index 00000000..9293b0ea --- /dev/null +++ b/src/ahriman/application/formatters/status_printer.py @@ -0,0 +1,51 @@ +# +# Copyright (c) 2021 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# 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 3 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. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from typing import List, Optional + +from ahriman.application.formatters.printer import Printer +from ahriman.models.build_status import BuildStatus +from ahriman.models.property import Property + + +class StatusPrinter(Printer): + """ + print content of the status object + """ + + def __init__(self, status: BuildStatus) -> None: + """ + default constructor + :param status: build status + """ + self.content = status + + def properties(self) -> List[Property]: + """ + convert content into printable data + :return: list of content properties + """ + return [] + + def title(self) -> Optional[str]: + """ + generate entry title from content + :return: content title if it can be generated and None otherwise + """ + return self.content.pretty_print() diff --git a/src/ahriman/application/handlers/dump.py b/src/ahriman/application/handlers/dump.py index 0e920faf..42ae504e 100644 --- a/src/ahriman/application/handlers/dump.py +++ b/src/ahriman/application/handlers/dump.py @@ -21,6 +21,7 @@ import argparse from typing import Type +from ahriman.application.formatters.configuration_printer import ConfigurationPrinter from ahriman.application.handlers.handler import Handler from ahriman.core.configuration import Configuration @@ -32,8 +33,6 @@ class Dump(Handler): ALLOW_AUTO_ARCHITECTURE_RUN = False - _print = print - @classmethod def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration, no_report: bool) -> None: @@ -46,7 +45,4 @@ class Dump(Handler): """ dump = configuration.dump() for section, values in sorted(dump.items()): - Dump._print(f"[{section}]") - for key, value in sorted(values.items()): - Dump._print(f"{key} = {value}") - Dump._print() + ConfigurationPrinter(section, values).print(verbose=False, separator=" = ") diff --git a/src/ahriman/application/handlers/remove_unknown.py b/src/ahriman/application/handlers/remove_unknown.py index 39cc2a60..b7ece2d9 100644 --- a/src/ahriman/application/handlers/remove_unknown.py +++ b/src/ahriman/application/handlers/remove_unknown.py @@ -22,9 +22,10 @@ import argparse from typing import Type from ahriman.application.application import Application +from ahriman.application.formatters.package_printer import PackagePrinter from ahriman.application.handlers.handler import Handler from ahriman.core.configuration import Configuration -from ahriman.models.package import Package +from ahriman.models.build_status import BuildStatus class RemoveUnknown(Handler): @@ -46,16 +47,7 @@ class RemoveUnknown(Handler): unknown_packages = application.unknown() if args.dry_run: for package in unknown_packages: - RemoveUnknown.log_fn(package) + PackagePrinter(package, BuildStatus()).print(args.info) return application.remove(package.base for package in unknown_packages) - - @staticmethod - def log_fn(package: Package) -> None: - """ - log package information - :param package: package object to log - """ - print(f"=> {package.base} {package.version}") - print(f" {package.web_url}") diff --git a/src/ahriman/application/handlers/search.py b/src/ahriman/application/handlers/search.py index ce47dbe5..a999dc53 100644 --- a/src/ahriman/application/handlers/search.py +++ b/src/ahriman/application/handlers/search.py @@ -20,10 +20,12 @@ import argparse import aur # type: ignore -from typing import Callable, Type +from typing import Callable, Dict, Iterable, List, Tuple, Type +from ahriman.application.formatters.aur_printer import AurPrinter from ahriman.application.handlers.handler import Handler from ahriman.core.configuration import Configuration +from ahriman.core.exceptions import InvalidOption class Search(Handler): @@ -32,6 +34,7 @@ class Search(Handler): """ ALLOW_AUTO_ARCHITECTURE_RUN = False # it should be called only as "no-architecture" + SORT_FIELDS = set(aur.Package._fields) # later we will have to remove some fields from here (lists) @classmethod def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, @@ -43,20 +46,32 @@ class Search(Handler): :param configuration: configuration instance :param no_report: force disable reporting """ - search = " ".join(args.search) - packages = aur.search(search) + packages: Dict[str, aur.Package] = {} + # see https://bugs.archlinux.org/task/49133 + for search in args.search: + portion = aur.search(search) + packages = { + package.package_base: package + for package in portion + if package.package_base in packages or not packages + } - # it actually always should return string - # explicit cast to string just to avoid mypy warning for untyped library - comparator: Callable[[aur.Package], str] = lambda item: str(item.package_base) - for package in sorted(packages, key=comparator): - Search.log_fn(package) + packages_list = list(packages.values()) # explicit conversion for the tests + for package in Search.sort(packages_list, args.sort_by): + AurPrinter(package).print(args.info) @staticmethod - def log_fn(package: aur.Package) -> None: + def sort(packages: Iterable[aur.Package], sort_by: str) -> List[aur.Package]: """ - log package information - :param package: package object as from AUR + sort package list by specified field + :param packages: packages list to sort + :param sort_by: AUR package field name to sort by + :return: sorted list for packages """ - print(f"=> {package.package_base} {package.version}") - print(f" {package.description}") + if sort_by not in Search.SORT_FIELDS: + raise InvalidOption(sort_by) + # always sort by package name at the last + # well technically it is not a string, but we can deal with it + comparator: Callable[[aur.Package], Tuple[str, str]] =\ + lambda package: (getattr(package, sort_by), package.name) + return sorted(packages, key=comparator) diff --git a/src/ahriman/application/handlers/status.py b/src/ahriman/application/handlers/status.py index 47d1b745..31b8c435 100644 --- a/src/ahriman/application/handlers/status.py +++ b/src/ahriman/application/handlers/status.py @@ -22,6 +22,8 @@ import argparse from typing import Callable, Iterable, Tuple, Type from ahriman.application.application import Application +from ahriman.application.formatters.package_printer import PackagePrinter +from ahriman.application.formatters.status_printer import StatusPrinter from ahriman.application.handlers.handler import Handler from ahriman.core.configuration import Configuration from ahriman.models.build_status import BuildStatus @@ -49,8 +51,7 @@ class Status(Handler): client = Application(architecture, configuration, no_report=False).repository.reporter if args.ahriman: ahriman = client.get_self() - print(ahriman.pretty_print()) - print() + StatusPrinter(ahriman).print(args.info) if args.package: packages: Iterable[Tuple[Package, BuildStatus]] = sum( [client.get(base) for base in args.package], @@ -62,6 +63,4 @@ class Status(Handler): filter_fn: Callable[[Tuple[Package, BuildStatus]], bool] =\ lambda item: args.status is None or item[1].status == args.status for package, package_status in sorted(filter(filter_fn, packages), key=comparator): - print(package.pretty_print()) - print(f"\t{package.version}") - print(f"\t{package_status.pretty_print()}") + PackagePrinter(package, package_status).print(args.info) diff --git a/src/ahriman/application/handlers/user.py b/src/ahriman/application/handlers/user.py index c1768624..7ecfa8b4 100644 --- a/src/ahriman/application/handlers/user.py +++ b/src/ahriman/application/handlers/user.py @@ -113,8 +113,7 @@ class User(Handler): :param salt_length: salt length :return: current salt """ - salt = configuration.get("auth", "salt", fallback=None) - if salt: + if salt := configuration.get("auth", "salt", fallback=None): return salt return MUser.generate_password(salt_length) diff --git a/src/ahriman/core/configuration.py b/src/ahriman/core/configuration.py index 0cc1cb4c..874a47f9 100644 --- a/src/ahriman/core/configuration.py +++ b/src/ahriman/core/configuration.py @@ -175,7 +175,7 @@ class Configuration(configparser.RawConfigParser): level=self.DEFAULT_LOG_LEVEL) logging.exception("could not load logging from configuration, fallback to stderr") if quiet: - logging.disable() + logging.disable(logging.WARNING) # only print errors here def merge_sections(self, architecture: str) -> None: """ diff --git a/src/ahriman/core/repository/executor.py b/src/ahriman/core/repository/executor.py index ac67c399..3509e532 100644 --- a/src/ahriman/core/repository/executor.py +++ b/src/ahriman/core/repository/executor.py @@ -138,17 +138,17 @@ class Executor(Cleaner): :param packages: list of filenames to run :return: path to repository database """ - def update_single(fn: Optional[str], base: str) -> None: - if fn is None: + def update_single(name: Optional[str], base: str) -> None: + if name is None: self.logger.warning("received empty package name for base %s", base) return # suppress type checking, it never can be none actually # in theory it might be NOT packages directory, but we suppose it is - full_path = self.paths.packages / fn + full_path = self.paths.packages / name files = self.sign.process_sign_package(full_path, base) for src in files: dst = self.paths.repository / src.name shutil.move(src, dst) - package_path = self.paths.repository / fn + package_path = self.paths.repository / name self.repo.add(package_path) # we are iterating over bases, not single packages diff --git a/src/ahriman/core/repository/update_handler.py b/src/ahriman/core/repository/update_handler.py index 22bb0ac6..c9537b26 100644 --- a/src/ahriman/core/repository/update_handler.py +++ b/src/ahriman/core/repository/update_handler.py @@ -72,16 +72,16 @@ class UpdateHandler(Cleaner): result: List[Package] = [] known_bases = {package.base for package in self.packages()} - for fn in self.paths.manual.iterdir(): + for filename in self.paths.manual.iterdir(): try: - local = Package.load(fn, self.pacman, self.aur_url) + local = Package.load(filename, self.pacman, self.aur_url) result.append(local) if local.base not in known_bases: self.reporter.set_unknown(local) else: self.reporter.set_pending(local.base) except Exception: - self.logger.exception("could not add package from %s", fn) + self.logger.exception("could not add package from %s", filename) self.clear_manual() return result diff --git a/src/ahriman/core/status/watcher.py b/src/ahriman/core/status/watcher.py index ad62ba76..d3c767ab 100644 --- a/src/ahriman/core/status/watcher.py +++ b/src/ahriman/core/status/watcher.py @@ -126,11 +126,10 @@ class Watcher: """ for package in self.repository.packages(): # get status of build or assign unknown - current = self.known.get(package.base) - if current is None: - status = BuildStatus() - else: + if (current := self.known.get(package.base)) is not None: _, status = current + else: + status = BuildStatus() self.known[package.base] = (package, status) self._cache_load() diff --git a/src/ahriman/core/util.py b/src/ahriman/core/util.py index 125619c5..bed5aa65 100644 --- a/src/ahriman/core/util.py +++ b/src/ahriman/core/util.py @@ -24,7 +24,7 @@ import requests from logging import Logger from pathlib import Path -from typing import Generator, Optional, Union +from typing import Any, Dict, Generator, Iterable, Optional, Union from ahriman.core.exceptions import InvalidOption, UnsafeRun @@ -78,6 +78,16 @@ def exception_response_text(exception: requests.exceptions.HTTPError) -> str: return result +def filter_json(source: Dict[str, Any], known_fields: Iterable[str]) -> Dict[str, Any]: + """ + filter json object by fields used for json-to-object conversion + :param source: raw json object + :param known_fields: list of fields which have to be known for the target object + :return: json object without unknown and empty fields + """ + return {key: value for key, value in source.items() if key in known_fields and value is not None} + + def package_like(filename: Path) -> bool: """ check if file looks like package @@ -88,13 +98,17 @@ def package_like(filename: Path) -> bool: return ".pkg." in name and not name.endswith(".sig") -def pretty_datetime(timestamp: Optional[Union[float, int]]) -> str: +def pretty_datetime(timestamp: Optional[Union[datetime.datetime, float, int]]) -> str: """ convert datetime object to string :param timestamp: datetime to convert :return: pretty printable datetime as string """ - return "" if timestamp is None else datetime.datetime.utcfromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + if timestamp is None: + return "" + if isinstance(timestamp, (int, float)): + timestamp = datetime.datetime.utcfromtimestamp(timestamp) + return timestamp.strftime("%Y-%m-%d %H:%M:%S") def pretty_size(size: Optional[float], level: int = 0) -> str: diff --git a/src/ahriman/models/build_status.py b/src/ahriman/models/build_status.py index 1bd2bf93..262b8efc 100644 --- a/src/ahriman/models/build_status.py +++ b/src/ahriman/models/build_status.py @@ -21,10 +21,11 @@ from __future__ import annotations import datetime +from dataclasses import dataclass, fields from enum import Enum -from typing import Any, Dict, Optional, Type, Union +from typing import Any, Dict, Type -from ahriman.core.util import pretty_datetime +from ahriman.core.util import filter_json, pretty_datetime class BuildStatusEnum(Enum): @@ -74,6 +75,7 @@ class BuildStatusEnum(Enum): return "secondary" +@dataclass class BuildStatus: """ build status holder @@ -81,15 +83,14 @@ class BuildStatus: :ivar timestamp: build status update time """ - def __init__(self, status: Union[BuildStatusEnum, str, None] = None, - timestamp: Optional[int] = None) -> None: + status: BuildStatusEnum = BuildStatusEnum.Unknown + timestamp: int = int(datetime.datetime.utcnow().timestamp()) + + def __post_init__(self) -> None: """ - default constructor - :param status: current build status if known. `BuildStatusEnum.Unknown` will be used if not set - :param timestamp: build status timestamp. Current timestamp will be used if not set + convert status to enum type """ - self.status = BuildStatusEnum(status) if status else BuildStatusEnum.Unknown - self.timestamp = timestamp or int(datetime.datetime.utcnow().timestamp()) + self.status = BuildStatusEnum(self.status) @classmethod def from_json(cls: Type[BuildStatus], dump: Dict[str, Any]) -> BuildStatus: @@ -98,7 +99,8 @@ class BuildStatus: :param dump: json dump body :return: status properties """ - return cls(dump.get("status"), dump.get("timestamp")) + known_fields = [pair.name for pair in fields(cls)] + return cls(**filter_json(dump, known_fields)) def pretty_print(self) -> str: """ @@ -116,20 +118,3 @@ class BuildStatus: "status": self.status.value, "timestamp": self.timestamp } - - def __eq__(self, other: Any) -> bool: - """ - compare object to other - :param other: other object to compare - :return: True in case if objects are equal - """ - if not isinstance(other, BuildStatus): - return False - return self.status == other.status and self.timestamp == other.timestamp - - def __repr__(self) -> str: - """ - generate string representation of object - :return: unique string representation - """ - return f"BuildStatus(status={self.status.value}, timestamp={self.timestamp})" diff --git a/src/ahriman/models/counters.py b/src/ahriman/models/counters.py index 13e7e344..5ae671dc 100644 --- a/src/ahriman/models/counters.py +++ b/src/ahriman/models/counters.py @@ -22,6 +22,7 @@ from __future__ import annotations from dataclasses import dataclass, fields from typing import Any, Dict, List, Tuple, Type +from ahriman.core.util import filter_json from ahriman.models.build_status import BuildStatus from ahriman.models.package import Package @@ -54,8 +55,7 @@ class Counters: """ # filter to only known fields known_fields = [pair.name for pair in fields(cls)] - dump = {key: value for key, value in dump.items() if key in known_fields} - return cls(**dump) + return cls(**filter_json(dump, known_fields)) @classmethod def from_packages(cls: Type[Counters], packages: List[Tuple[Package, BuildStatus]]) -> Counters: diff --git a/src/ahriman/models/package_description.py b/src/ahriman/models/package_description.py index dbd9ff6f..eb4d6bc4 100644 --- a/src/ahriman/models/package_description.py +++ b/src/ahriman/models/package_description.py @@ -24,6 +24,8 @@ from pathlib import Path from pyalpm import Package # type: ignore from typing import Any, Dict, List, Optional, Type +from ahriman.core.util import filter_json + @dataclass class PackageDescription: @@ -70,8 +72,7 @@ class PackageDescription: """ # filter to only known fields known_fields = [pair.name for pair in fields(cls)] - dump = {key: value for key, value in dump.items() if key in known_fields} - return cls(**dump) + return cls(**filter_json(dump, known_fields)) @classmethod def from_package(cls: Type[PackageDescription], package: Package, path: Path) -> PackageDescription: diff --git a/src/ahriman/models/property.py b/src/ahriman/models/property.py new file mode 100644 index 00000000..af6a0591 --- /dev/null +++ b/src/ahriman/models/property.py @@ -0,0 +1,31 @@ +# (see https://github.com/arcan1s/ahriman). +# +# 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 3 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. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from dataclasses import dataclass +from typing import Any + + +@dataclass +class Property: + """ + holder of object properties descriptor + :ivar name: name of the property + :ivar value: property value + :ivar is_required: if set to True then this property is required + """ + + name: str + value: Any + is_required: bool = False diff --git a/src/ahriman/web/middlewares/auth_handler.py b/src/ahriman/web/middlewares/auth_handler.py index da01c9b7..f2cbdc2c 100644 --- a/src/ahriman/web/middlewares/auth_handler.py +++ b/src/ahriman/web/middlewares/auth_handler.py @@ -81,8 +81,7 @@ def auth_handler() -> MiddlewareType: """ @middleware async def handle(request: Request, handler: HandlerType) -> StreamResponse: - permission_method = getattr(handler, "get_permission", None) - if permission_method is not None: + if (permission_method := getattr(handler, "get_permission", None)) is not None: permission = await permission_method(request) elif isinstance(handler, types.MethodType): # additional wrapper for static resources handler_instance = getattr(handler, "__self__", None) diff --git a/src/ahriman/web/views/user/login.py b/src/ahriman/web/views/user/login.py index 76593488..ef91aabc 100644 --- a/src/ahriman/web/views/user/login.py +++ b/src/ahriman/web/views/user/login.py @@ -45,11 +45,11 @@ class LoginView(BaseView): """ from ahriman.core.auth.oauth import OAuth - code = self.request.query.getone("code", default=None) oauth_provider = self.validator if not isinstance(oauth_provider, OAuth): # there is actually property, but mypy does not like it anyway raise HTTPMethodNotAllowed(self.request.method, ["POST"]) + code = self.request.query.getone("code", default=None) if not code: raise HTTPFound(oauth_provider.get_oauth_url()) diff --git a/tests/ahriman/application/formatters/conftest.py b/tests/ahriman/application/formatters/conftest.py new file mode 100644 index 00000000..d6186288 --- /dev/null +++ b/tests/ahriman/application/formatters/conftest.py @@ -0,0 +1,47 @@ +import aur +import pytest + +from ahriman.application.formatters.aur_printer import AurPrinter +from ahriman.application.formatters.configuration_printer import ConfigurationPrinter +from ahriman.application.formatters.package_printer import PackagePrinter +from ahriman.application.formatters.status_printer import StatusPrinter +from ahriman.models.build_status import BuildStatus +from ahriman.models.package import Package + + +@pytest.fixture +def aur_package_ahriman_printer(aur_package_ahriman: aur.Package) -> AurPrinter: + """ + fixture for AUR package printer + :param aur_package_ahriman: AUR package fixture + :return: AUR package printer test instance + """ + return AurPrinter(aur_package_ahriman) + + +@pytest.fixture +def configuration_printer() -> ConfigurationPrinter: + """ + fixture for configuration printer + :return: configuration printer test instance + """ + return ConfigurationPrinter("section", {"key_one": "value_one", "key_two": "value_two"}) + + +@pytest.fixture +def package_ahriman_printer(package_ahriman: Package) -> PackagePrinter: + """ + fixture for package printer + :param package_ahriman: package fixture + :return: package printer test instance + """ + return PackagePrinter(package_ahriman, BuildStatus()) + + +@pytest.fixture +def status_printer(package_ahriman: Package) -> StatusPrinter: + """ + fixture for build status printer + :return: build status printer test instance + """ + return StatusPrinter(BuildStatus()) diff --git a/tests/ahriman/application/formatters/test_aur_printer.py b/tests/ahriman/application/formatters/test_aur_printer.py new file mode 100644 index 00000000..8725dff6 --- /dev/null +++ b/tests/ahriman/application/formatters/test_aur_printer.py @@ -0,0 +1,15 @@ +from ahriman.application.formatters.aur_printer import AurPrinter + + +def test_properties(aur_package_ahriman_printer: AurPrinter) -> None: + """ + must return non empty properties list + """ + assert aur_package_ahriman_printer.properties() + + +def test_title(aur_package_ahriman_printer: AurPrinter) -> None: + """ + must return non empty title + """ + assert aur_package_ahriman_printer.title() is not None diff --git a/tests/ahriman/application/formatters/test_configuration_printer.py b/tests/ahriman/application/formatters/test_configuration_printer.py new file mode 100644 index 00000000..b7117d4a --- /dev/null +++ b/tests/ahriman/application/formatters/test_configuration_printer.py @@ -0,0 +1,22 @@ +from ahriman.application.formatters.configuration_printer import ConfigurationPrinter + + +def test_properties(configuration_printer: ConfigurationPrinter) -> None: + """ + must return non empty properties list + """ + assert configuration_printer.properties() + + +def test_properties_required(configuration_printer: ConfigurationPrinter) -> None: + """ + must return all properties as required + """ + assert all(prop.is_required for prop in configuration_printer.properties()) + + +def test_title(configuration_printer: ConfigurationPrinter) -> None: + """ + must return non empty title + """ + assert configuration_printer.title() == "[section]" diff --git a/tests/ahriman/application/formatters/test_package_printer.py b/tests/ahriman/application/formatters/test_package_printer.py new file mode 100644 index 00000000..06525a6b --- /dev/null +++ b/tests/ahriman/application/formatters/test_package_printer.py @@ -0,0 +1,15 @@ +from ahriman.application.formatters.package_printer import PackagePrinter + + +def test_properties(package_ahriman_printer: PackagePrinter) -> None: + """ + must return non empty properties list + """ + assert package_ahriman_printer.properties() + + +def test_title(package_ahriman_printer: PackagePrinter) -> None: + """ + must return non empty title + """ + assert package_ahriman_printer.title() is not None diff --git a/tests/ahriman/application/formatters/test_printer.py b/tests/ahriman/application/formatters/test_printer.py new file mode 100644 index 00000000..92b3d31f --- /dev/null +++ b/tests/ahriman/application/formatters/test_printer.py @@ -0,0 +1,45 @@ +from unittest.mock import MagicMock + +from ahriman.application.formatters.package_printer import PackagePrinter +from ahriman.application.formatters.printer import Printer + + +def test_print(package_ahriman_printer: PackagePrinter) -> None: + """ + must print content + """ + log_mock = MagicMock() + package_ahriman_printer.print(verbose=False, log_fn=log_mock) + log_mock.assert_called() + + +def test_print_empty() -> None: + """ + must not print empty object + """ + log_mock = MagicMock() + Printer().print(verbose=True, log_fn=log_mock) + log_mock.assert_not_called() + + +def test_print_verbose(package_ahriman_printer: PackagePrinter) -> None: + """ + must print content with increased verbosity + """ + log_mock = MagicMock() + package_ahriman_printer.print(verbose=True, log_fn=log_mock) + log_mock.assert_called() + + +def test_properties() -> None: + """ + must return empty properties list + """ + assert Printer().properties() == [] + + +def test_title() -> None: + """ + must return empty title + """ + assert Printer().title() is None diff --git a/tests/ahriman/application/formatters/test_status_printer.py b/tests/ahriman/application/formatters/test_status_printer.py new file mode 100644 index 00000000..b3026b9b --- /dev/null +++ b/tests/ahriman/application/formatters/test_status_printer.py @@ -0,0 +1,15 @@ +from ahriman.application.formatters.status_printer import StatusPrinter + + +def test_properties(status_printer: StatusPrinter) -> None: + """ + must return empty properties list + """ + assert not status_printer.properties() + + +def test_title(status_printer: StatusPrinter) -> None: + """ + must return non empty title + """ + assert status_printer.title() is not None diff --git a/tests/ahriman/application/handlers/test_handler_dump.py b/tests/ahriman/application/handlers/test_handler_dump.py index 9c52bf01..b8de3c80 100644 --- a/tests/ahriman/application/handlers/test_handler_dump.py +++ b/tests/ahriman/application/handlers/test_handler_dump.py @@ -11,7 +11,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc must run command """ mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") - print_mock = mocker.patch("ahriman.application.handlers.dump.Dump._print") + print_mock = mocker.patch("ahriman.application.formatters.printer.Printer.print") application_mock = mocker.patch("ahriman.core.configuration.Configuration.dump", return_value=configuration.dump()) diff --git a/tests/ahriman/application/handlers/test_handler_remove_unknown.py b/tests/ahriman/application/handlers/test_handler_remove_unknown.py index 64d34376..51af2860 100644 --- a/tests/ahriman/application/handlers/test_handler_remove_unknown.py +++ b/tests/ahriman/application/handlers/test_handler_remove_unknown.py @@ -14,6 +14,7 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace: :return: generated arguments for these test cases """ args.dry_run = False + args.info = False return args @@ -42,19 +43,29 @@ def test_run_dry_run(args: argparse.Namespace, configuration: Configuration, pac application_mock = mocker.patch("ahriman.application.application.Application.unknown", return_value=[package_ahriman]) remove_mock = mocker.patch("ahriman.application.application.Application.remove") - log_fn_mock = mocker.patch("ahriman.application.handlers.remove_unknown.RemoveUnknown.log_fn") + print_mock = mocker.patch("ahriman.application.formatters.printer.Printer.print") RemoveUnknown.run(args, "x86_64", configuration, True) application_mock.assert_called_once() remove_mock.assert_not_called() - log_fn_mock.assert_called_once_with(package_ahriman) + print_mock.assert_called_once_with(False) -def test_log_fn(package_ahriman: Package, mocker: MockerFixture) -> None: +def test_run_dry_run_verbose(args: argparse.Namespace, configuration: Configuration, package_ahriman: Package, + mocker: MockerFixture) -> None: """ - log function must call print built-in + must run simplified command with increased verbosity """ - print_mock = mocker.patch("builtins.print") + args = _default_args(args) + args.dry_run = True + args.info = True + mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") + application_mock = mocker.patch("ahriman.application.application.Application.unknown", + return_value=[package_ahriman]) + remove_mock = mocker.patch("ahriman.application.application.Application.remove") + print_mock = mocker.patch("ahriman.application.formatters.printer.Printer.print") - RemoveUnknown.log_fn(package_ahriman) - print_mock.assert_called() # we don't really care about call details tbh + RemoveUnknown.run(args, "x86_64", configuration, True) + application_mock.assert_called_once() + remove_mock.assert_not_called() + print_mock.assert_called_once_with(True) diff --git a/tests/ahriman/application/handlers/test_handler_search.py b/tests/ahriman/application/handlers/test_handler_search.py index aff5d29f..fc836c86 100644 --- a/tests/ahriman/application/handlers/test_handler_search.py +++ b/tests/ahriman/application/handlers/test_handler_search.py @@ -1,10 +1,13 @@ import argparse import aur +import pytest from pytest_mock import MockerFixture +from unittest import mock from ahriman.application.handlers import Search from ahriman.core.configuration import Configuration +from ahriman.core.exceptions import InvalidOption def _default_args(args: argparse.Namespace) -> argparse.Namespace: @@ -14,6 +17,8 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace: :return: generated arguments for these test cases """ args.search = ["ahriman"] + args.info = False + args.sort_by = "name" return args @@ -24,35 +29,71 @@ def test_run(args: argparse.Namespace, configuration: Configuration, aur_package """ args = _default_args(args) mocker.patch("aur.search", return_value=[aur_package_ahriman]) - log_mock = mocker.patch("ahriman.application.handlers.search.Search.log_fn") + print_mock = mocker.patch("ahriman.application.formatters.printer.Printer.print") Search.run(args, "x86_64", configuration, True) - log_mock.assert_called_once() + print_mock.assert_called_once() -def test_run_multiple_search(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: +def test_run_multiple_search(args: argparse.Namespace, configuration: Configuration, aur_package_ahriman: aur.Package, + mocker: MockerFixture) -> None: """ must run command with multiple search arguments """ args = _default_args(args) args.search = ["ahriman", "is", "cool"] - search_mock = mocker.patch("aur.search") + search_mock = mocker.patch("aur.search", return_value=[aur_package_ahriman]) Search.run(args, "x86_64", configuration, True) - search_mock.assert_called_once_with(" ".join(args.search)) + search_mock.assert_has_calls([mock.call(term) for term in args.search]) -def test_log_fn(args: argparse.Namespace, configuration: Configuration, aur_package_ahriman: aur.Package, - mocker: MockerFixture) -> None: +def test_run_sort(args: argparse.Namespace, configuration: Configuration, aur_package_ahriman: aur.Package, + mocker: MockerFixture) -> None: """ - log function must call print built-in + must run command with sorting """ args = _default_args(args) mocker.patch("aur.search", return_value=[aur_package_ahriman]) - print_mock = mocker.patch("builtins.print") + sort_mock = mocker.patch("ahriman.application.handlers.search.Search.sort") Search.run(args, "x86_64", configuration, True) - print_mock.assert_called() # we don't really care about call details tbh + sort_mock.assert_called_once_with([aur_package_ahriman], "name") + + +def test_run_sort_by(args: argparse.Namespace, configuration: Configuration, aur_package_ahriman: aur.Package, + mocker: MockerFixture) -> None: + """ + must run command with sorting by specified field + """ + args = _default_args(args) + args.sort_by = "field" + mocker.patch("aur.search", return_value=[aur_package_ahriman]) + sort_mock = mocker.patch("ahriman.application.handlers.search.Search.sort") + + Search.run(args, "x86_64", configuration, True) + sort_mock.assert_called_once_with([aur_package_ahriman], "field") + + +def test_sort(aur_package_ahriman: aur.Package) -> None: + """ + must sort package list + """ + another = aur_package_ahriman._replace(name="1", package_base="base") + # sort by name + assert Search.sort([aur_package_ahriman, another], "name") == [another, aur_package_ahriman] + # sort by another field + assert Search.sort([aur_package_ahriman, another], "package_base") == [aur_package_ahriman, another] + # sort by field with the same values + assert Search.sort([aur_package_ahriman, another], "version") == [another, aur_package_ahriman] + + +def test_sort_exception(aur_package_ahriman: aur.Package) -> None: + """ + must raise an exception on unknown sorting field + """ + with pytest.raises(InvalidOption): + Search.sort([aur_package_ahriman], "random_field") def test_disallow_auto_architecture_run() -> None: @@ -60,3 +101,10 @@ def test_disallow_auto_architecture_run() -> None: must not allow multi architecture run """ assert not Search.ALLOW_AUTO_ARCHITECTURE_RUN + + +def test_sort_fields() -> None: + """ + must store valid field list which are allowed to be used for sorting + """ + assert all(field in aur.Package._fields for field in Search.SORT_FIELDS) diff --git a/tests/ahriman/application/handlers/test_handler_status.py b/tests/ahriman/application/handlers/test_handler_status.py index d31722bc..791f697d 100644 --- a/tests/ahriman/application/handlers/test_handler_status.py +++ b/tests/ahriman/application/handlers/test_handler_status.py @@ -1,6 +1,7 @@ import argparse from pytest_mock import MockerFixture +from unittest import mock from ahriman.application.handlers import Status from ahriman.core.configuration import Configuration @@ -15,6 +16,7 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace: :return: generated arguments for these test cases """ args.ahriman = True + args.info = False args.package = [] args.status = None return args @@ -31,12 +33,28 @@ def test_run(args: argparse.Namespace, configuration: Configuration, package_ahr packages_mock = mocker.patch("ahriman.core.status.client.Client.get", return_value=[(package_ahriman, BuildStatus(BuildStatusEnum.Success)), (package_python_schedule, BuildStatus(BuildStatusEnum.Failed))]) - pretty_print_mock = mocker.patch("ahriman.models.package.Package.pretty_print") + print_mock = mocker.patch("ahriman.application.formatters.printer.Printer.print") Status.run(args, "x86_64", configuration, True) application_mock.assert_called_once() packages_mock.assert_called_once() - pretty_print_mock.assert_called() + print_mock.assert_has_calls([mock.call(False) for _ in range(3)]) + + +def test_run_verbose(args: argparse.Namespace, configuration: Configuration, package_ahriman: Package, + mocker: MockerFixture) -> None: + """ + must run command + """ + args = _default_args(args) + args.info = True + mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") + mocker.patch("ahriman.core.status.client.Client.get", + return_value=[(package_ahriman, BuildStatus(BuildStatusEnum.Success))]) + print_mock = mocker.patch("ahriman.application.formatters.printer.Printer.print") + + Status.run(args, "x86_64", configuration, True) + print_mock.assert_has_calls([mock.call(True) for _ in range(2)]) def test_run_with_package_filter(args: argparse.Namespace, configuration: Configuration, package_ahriman: Package, @@ -65,10 +83,10 @@ def test_run_by_status(args: argparse.Namespace, configuration: Configuration, p return_value=[(package_ahriman, BuildStatus(BuildStatusEnum.Success)), (package_python_schedule, BuildStatus(BuildStatusEnum.Failed))]) mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") - pretty_print_mock = mocker.patch("ahriman.models.package.Package.pretty_print") + print_mock = mocker.patch("ahriman.application.formatters.printer.Printer.print") Status.run(args, "x86_64", configuration, True) - pretty_print_mock.assert_called_once() + print_mock.assert_has_calls([mock.call(False) for _ in range(2)]) def test_imply_with_report(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: diff --git a/tests/ahriman/core/test_configuration.py b/tests/ahriman/core/test_configuration.py index 7c1695ff..1da9e74f 100644 --- a/tests/ahriman/core/test_configuration.py +++ b/tests/ahriman/core/test_configuration.py @@ -1,4 +1,5 @@ import configparser +import logging import pytest from pathlib import Path @@ -194,7 +195,7 @@ def test_load_logging_quiet(configuration: Configuration, mocker: MockerFixture) """ disable_mock = mocker.patch("logging.disable") configuration.load_logging(quiet=True) - disable_mock.assert_called_once() + disable_mock.assert_called_once_with(logging.WARNING) def test_merge_sections_missing(configuration: Configuration) -> None: diff --git a/tests/ahriman/core/test_util.py b/tests/ahriman/core/test_util.py index 44d2f7ce..432e4a7f 100644 --- a/tests/ahriman/core/test_util.py +++ b/tests/ahriman/core/test_util.py @@ -1,3 +1,4 @@ +import datetime import logging import pytest import subprocess @@ -6,7 +7,7 @@ from pathlib import Path from pytest_mock import MockerFixture from ahriman.core.exceptions import InvalidOption, UnsafeRun -from ahriman.core.util import check_output, check_user, package_like, pretty_datetime, pretty_size, walk +from ahriman.core.util import check_output, check_user, filter_json, package_like, pretty_datetime, pretty_size, walk from ahriman.models.package import Package @@ -79,6 +80,26 @@ def test_check_user_exception(mocker: MockerFixture) -> None: check_user(cwd) +def test_filter_json(package_ahriman: Package) -> None: + """ + must filter fields by known list + """ + expected = package_ahriman.view() + probe = package_ahriman.view() + probe["unknown_field"] = "value" + + assert expected == filter_json(probe, expected.keys()) + + +def test_filter_json_empty_value(package_ahriman: Package) -> None: + """ + must return empty values from object + """ + probe = package_ahriman.view() + probe["base"] = None + assert "base" not in filter_json(probe, probe.keys()) + + def test_package_like(package_ahriman: Package) -> None: """ package_like must return true for archives @@ -102,6 +123,13 @@ def test_pretty_datetime() -> None: assert pretty_datetime(0) == "1970-01-01 00:00:00" +def test_pretty_datetime_datetime() -> None: + """ + must generate string from datetime object + """ + assert pretty_datetime(datetime.datetime(1970, 1, 1, 0, 0, 0)) == "1970-01-01 00:00:00" + + def test_pretty_datetime_empty() -> None: """ must generate empty string from None timestamp diff --git a/tests/ahriman/models/test_property.py b/tests/ahriman/models/test_property.py new file mode 100644 index 00000000..e69de29b