Add ability to show more info in search and status subcommands

This feature also introduces the followiing changes
* aur-search command now works as expected with multiterms
* printer classes for managing of data print
* --sort-by argument for aur-search subcommand instead of using package
  name
* --quiet argument now has also --no-quite option
* if --quite is supplied, the log level will be set to warn instead of
  critical to be able to see error messages
* pretty_datetime function now also supports datetime objects
* BuildStatus is now pure dataclass
This commit is contained in:
Evgenii Alekseev 2021-10-26 03:56:55 +03:00
parent 9057ecf67a
commit 7d782f120d
38 changed files with 730 additions and 112 deletions

View File

@ -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.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.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 ## `ahriman.core` package

View File

@ -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. * `archrepo2` actively uses direct shell calls and `yaourt` components.
* It has constantly running process instead of timer process (it is not pro or con). * 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) #### [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: Though originally I've created ahriman by trying to improve the project, it still lacks a lot of features:

View File

@ -60,7 +60,8 @@ def _parser() -> argparse.ArgumentParser:
parser.add_argument("-l", "--lock", help="lock file", type=Path, parser.add_argument("-l", "--lock", help="lock file", type=Path,
default=Path(tempfile.gettempdir()) / "ahriman.lock") default=Path(tempfile.gettempdir()) / "ahriman.lock")
parser.add_argument("--no-report", help="force disable reporting to web service", action="store_true") 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", parser.add_argument("--unsafe", help="allow to run ahriman as non-ahriman user. Some actions might be unavailable",
action="store_true") action="store_true")
parser.add_argument("-v", "--version", action="version", version=version.__version__) 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", parser = root.add_parser("aur-search", aliases=["search"], help="search for package",
description="search for package in AUR using API", formatter_class=_formatter) 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) parser.set_defaults(handler=handlers.Search, architecture=[""], lock=None, no_report=True, quiet=True, unsafe=True)
return parser return parser
@ -181,6 +187,7 @@ def _set_package_status_parser(root: SubParserAction) -> argparse.ArgumentParser
formatter_class=_formatter) formatter_class=_formatter)
parser.add_argument("package", help="filter status by package base", nargs="*") 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("--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", parser.add_argument("-s", "--status", help="filter packages by status",
type=BuildStatusEnum, choices=BuildStatusEnum) type=BuildStatusEnum, choices=BuildStatusEnum)
parser.set_defaults(handler=handlers.Status, lock=None, no_report=True, quiet=True, unsafe=True) 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", description="remove packages which are missing in AUR and do not have local PKGBUILDs",
formatter_class=_formatter) formatter_class=_formatter)
parser.add_argument("--dry-run", help="just perform check for packages without removal", action="store_true") 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) parser.set_defaults(handler=handlers.RemoveUnknown)
return parser return parser

View File

@ -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 <http://www.gnu.org/licenses/>.
#

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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})"

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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}]"

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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()

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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
"""

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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()

View File

@ -21,6 +21,7 @@ import argparse
from typing import Type from typing import Type
from ahriman.application.formatters.configuration_printer import ConfigurationPrinter
from ahriman.application.handlers.handler import Handler from ahriman.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
@ -32,8 +33,6 @@ class Dump(Handler):
ALLOW_AUTO_ARCHITECTURE_RUN = False ALLOW_AUTO_ARCHITECTURE_RUN = False
_print = print
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None: configuration: Configuration, no_report: bool) -> None:
@ -46,7 +45,4 @@ class Dump(Handler):
""" """
dump = configuration.dump() dump = configuration.dump()
for section, values in sorted(dump.items()): for section, values in sorted(dump.items()):
Dump._print(f"[{section}]") ConfigurationPrinter(section, values).print(verbose=False, separator=" = ")
for key, value in sorted(values.items()):
Dump._print(f"{key} = {value}")
Dump._print()

View File

@ -22,9 +22,10 @@ import argparse
from typing import Type from typing import Type
from ahriman.application.application import Application from ahriman.application.application import Application
from ahriman.application.formatters.package_printer import PackagePrinter
from ahriman.application.handlers.handler import Handler from ahriman.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.models.package import Package from ahriman.models.build_status import BuildStatus
class RemoveUnknown(Handler): class RemoveUnknown(Handler):
@ -46,16 +47,7 @@ class RemoveUnknown(Handler):
unknown_packages = application.unknown() unknown_packages = application.unknown()
if args.dry_run: if args.dry_run:
for package in unknown_packages: for package in unknown_packages:
RemoveUnknown.log_fn(package) PackagePrinter(package, BuildStatus()).print(args.info)
return return
application.remove(package.base for package in unknown_packages) 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}")

View File

@ -20,10 +20,12 @@
import argparse import argparse
import aur # type: ignore 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.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import InvalidOption
class Search(Handler): class Search(Handler):
@ -32,6 +34,7 @@ class Search(Handler):
""" """
ALLOW_AUTO_ARCHITECTURE_RUN = False # it should be called only as "no-architecture" 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 @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
@ -43,20 +46,32 @@ class Search(Handler):
:param configuration: configuration instance :param configuration: configuration instance
:param no_report: force disable reporting :param no_report: force disable reporting
""" """
search = " ".join(args.search) packages: Dict[str, aur.Package] = {}
packages = aur.search(search) # 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 packages_list = list(packages.values()) # explicit conversion for the tests
# explicit cast to string just to avoid mypy warning for untyped library for package in Search.sort(packages_list, args.sort_by):
comparator: Callable[[aur.Package], str] = lambda item: str(item.package_base) AurPrinter(package).print(args.info)
for package in sorted(packages, key=comparator):
Search.log_fn(package)
@staticmethod @staticmethod
def log_fn(package: aur.Package) -> None: def sort(packages: Iterable[aur.Package], sort_by: str) -> List[aur.Package]:
""" """
log package information sort package list by specified field
:param package: package object as from AUR :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}") if sort_by not in Search.SORT_FIELDS:
print(f" {package.description}") 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)

View File

@ -22,6 +22,8 @@ import argparse
from typing import Callable, Iterable, Tuple, Type from typing import Callable, Iterable, Tuple, Type
from ahriman.application.application import Application 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.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.models.build_status import BuildStatus from ahriman.models.build_status import BuildStatus
@ -49,8 +51,7 @@ class Status(Handler):
client = Application(architecture, configuration, no_report=False).repository.reporter client = Application(architecture, configuration, no_report=False).repository.reporter
if args.ahriman: if args.ahriman:
ahriman = client.get_self() ahriman = client.get_self()
print(ahriman.pretty_print()) StatusPrinter(ahriman).print(args.info)
print()
if args.package: if args.package:
packages: Iterable[Tuple[Package, BuildStatus]] = sum( packages: Iterable[Tuple[Package, BuildStatus]] = sum(
[client.get(base) for base in args.package], [client.get(base) for base in args.package],
@ -62,6 +63,4 @@ class Status(Handler):
filter_fn: Callable[[Tuple[Package, BuildStatus]], bool] =\ filter_fn: Callable[[Tuple[Package, BuildStatus]], bool] =\
lambda item: args.status is None or item[1].status == args.status lambda item: args.status is None or item[1].status == args.status
for package, package_status in sorted(filter(filter_fn, packages), key=comparator): for package, package_status in sorted(filter(filter_fn, packages), key=comparator):
print(package.pretty_print()) PackagePrinter(package, package_status).print(args.info)
print(f"\t{package.version}")
print(f"\t{package_status.pretty_print()}")

View File

@ -113,8 +113,7 @@ class User(Handler):
:param salt_length: salt length :param salt_length: salt length
:return: current salt :return: current salt
""" """
salt = configuration.get("auth", "salt", fallback=None) if salt := configuration.get("auth", "salt", fallback=None):
if salt:
return salt return salt
return MUser.generate_password(salt_length) return MUser.generate_password(salt_length)

View File

@ -175,7 +175,7 @@ class Configuration(configparser.RawConfigParser):
level=self.DEFAULT_LOG_LEVEL) level=self.DEFAULT_LOG_LEVEL)
logging.exception("could not load logging from configuration, fallback to stderr") logging.exception("could not load logging from configuration, fallback to stderr")
if quiet: if quiet:
logging.disable() logging.disable(logging.WARNING) # only print errors here
def merge_sections(self, architecture: str) -> None: def merge_sections(self, architecture: str) -> None:
""" """

View File

@ -138,17 +138,17 @@ class Executor(Cleaner):
:param packages: list of filenames to run :param packages: list of filenames to run
:return: path to repository database :return: path to repository database
""" """
def update_single(fn: Optional[str], base: str) -> None: def update_single(name: Optional[str], base: str) -> None:
if fn is None: if name is None:
self.logger.warning("received empty package name for base %s", base) self.logger.warning("received empty package name for base %s", base)
return # suppress type checking, it never can be none actually return # suppress type checking, it never can be none actually
# in theory it might be NOT packages directory, but we suppose it is # 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) files = self.sign.process_sign_package(full_path, base)
for src in files: for src in files:
dst = self.paths.repository / src.name dst = self.paths.repository / src.name
shutil.move(src, dst) shutil.move(src, dst)
package_path = self.paths.repository / fn package_path = self.paths.repository / name
self.repo.add(package_path) self.repo.add(package_path)
# we are iterating over bases, not single packages # we are iterating over bases, not single packages

View File

@ -72,16 +72,16 @@ class UpdateHandler(Cleaner):
result: List[Package] = [] result: List[Package] = []
known_bases = {package.base for package in self.packages()} known_bases = {package.base for package in self.packages()}
for fn in self.paths.manual.iterdir(): for filename in self.paths.manual.iterdir():
try: try:
local = Package.load(fn, self.pacman, self.aur_url) local = Package.load(filename, self.pacman, self.aur_url)
result.append(local) result.append(local)
if local.base not in known_bases: if local.base not in known_bases:
self.reporter.set_unknown(local) self.reporter.set_unknown(local)
else: else:
self.reporter.set_pending(local.base) self.reporter.set_pending(local.base)
except Exception: 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() self.clear_manual()
return result return result

View File

@ -126,11 +126,10 @@ class Watcher:
""" """
for package in self.repository.packages(): for package in self.repository.packages():
# get status of build or assign unknown # get status of build or assign unknown
current = self.known.get(package.base) if (current := self.known.get(package.base)) is not None:
if current is None:
status = BuildStatus()
else:
_, status = current _, status = current
else:
status = BuildStatus()
self.known[package.base] = (package, status) self.known[package.base] = (package, status)
self._cache_load() self._cache_load()

View File

@ -24,7 +24,7 @@ import requests
from logging import Logger from logging import Logger
from pathlib import Path 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 from ahriman.core.exceptions import InvalidOption, UnsafeRun
@ -78,6 +78,16 @@ def exception_response_text(exception: requests.exceptions.HTTPError) -> str:
return result 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: def package_like(filename: Path) -> bool:
""" """
check if file looks like package 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") 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 convert datetime object to string
:param timestamp: datetime to convert :param timestamp: datetime to convert
:return: pretty printable datetime as string :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: def pretty_size(size: Optional[float], level: int = 0) -> str:

View File

@ -21,10 +21,11 @@ from __future__ import annotations
import datetime import datetime
from dataclasses import dataclass, fields
from enum import Enum 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): class BuildStatusEnum(Enum):
@ -74,6 +75,7 @@ class BuildStatusEnum(Enum):
return "secondary" return "secondary"
@dataclass
class BuildStatus: class BuildStatus:
""" """
build status holder build status holder
@ -81,15 +83,14 @@ class BuildStatus:
:ivar timestamp: build status update time :ivar timestamp: build status update time
""" """
def __init__(self, status: Union[BuildStatusEnum, str, None] = None, status: BuildStatusEnum = BuildStatusEnum.Unknown
timestamp: Optional[int] = None) -> None: timestamp: int = int(datetime.datetime.utcnow().timestamp())
def __post_init__(self) -> None:
""" """
default constructor convert status to enum type
: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
""" """
self.status = BuildStatusEnum(status) if status else BuildStatusEnum.Unknown self.status = BuildStatusEnum(self.status)
self.timestamp = timestamp or int(datetime.datetime.utcnow().timestamp())
@classmethod @classmethod
def from_json(cls: Type[BuildStatus], dump: Dict[str, Any]) -> BuildStatus: def from_json(cls: Type[BuildStatus], dump: Dict[str, Any]) -> BuildStatus:
@ -98,7 +99,8 @@ class BuildStatus:
:param dump: json dump body :param dump: json dump body
:return: status properties :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: def pretty_print(self) -> str:
""" """
@ -116,20 +118,3 @@ class BuildStatus:
"status": self.status.value, "status": self.status.value,
"timestamp": self.timestamp "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})"

View File

@ -22,6 +22,7 @@ from __future__ import annotations
from dataclasses import dataclass, fields from dataclasses import dataclass, fields
from typing import Any, Dict, List, Tuple, Type 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.build_status import BuildStatus
from ahriman.models.package import Package from ahriman.models.package import Package
@ -54,8 +55,7 @@ class Counters:
""" """
# filter to only known fields # filter to only known fields
known_fields = [pair.name for pair in fields(cls)] 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(**filter_json(dump, known_fields))
return cls(**dump)
@classmethod @classmethod
def from_packages(cls: Type[Counters], packages: List[Tuple[Package, BuildStatus]]) -> Counters: def from_packages(cls: Type[Counters], packages: List[Tuple[Package, BuildStatus]]) -> Counters:

View File

@ -24,6 +24,8 @@ from pathlib import Path
from pyalpm import Package # type: ignore from pyalpm import Package # type: ignore
from typing import Any, Dict, List, Optional, Type from typing import Any, Dict, List, Optional, Type
from ahriman.core.util import filter_json
@dataclass @dataclass
class PackageDescription: class PackageDescription:
@ -70,8 +72,7 @@ class PackageDescription:
""" """
# filter to only known fields # filter to only known fields
known_fields = [pair.name for pair in fields(cls)] 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(**filter_json(dump, known_fields))
return cls(**dump)
@classmethod @classmethod
def from_package(cls: Type[PackageDescription], package: Package, path: Path) -> PackageDescription: def from_package(cls: Type[PackageDescription], package: Package, path: Path) -> PackageDescription:

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

View File

@ -81,8 +81,7 @@ def auth_handler() -> MiddlewareType:
""" """
@middleware @middleware
async def handle(request: Request, handler: HandlerType) -> StreamResponse: async def handle(request: Request, handler: HandlerType) -> StreamResponse:
permission_method = getattr(handler, "get_permission", None) if (permission_method := getattr(handler, "get_permission", None)) is not None:
if permission_method is not None:
permission = await permission_method(request) permission = await permission_method(request)
elif isinstance(handler, types.MethodType): # additional wrapper for static resources elif isinstance(handler, types.MethodType): # additional wrapper for static resources
handler_instance = getattr(handler, "__self__", None) handler_instance = getattr(handler, "__self__", None)

View File

@ -45,11 +45,11 @@ class LoginView(BaseView):
""" """
from ahriman.core.auth.oauth import OAuth from ahriman.core.auth.oauth import OAuth
code = self.request.query.getone("code", default=None)
oauth_provider = self.validator oauth_provider = self.validator
if not isinstance(oauth_provider, OAuth): # there is actually property, but mypy does not like it anyway if not isinstance(oauth_provider, OAuth): # there is actually property, but mypy does not like it anyway
raise HTTPMethodNotAllowed(self.request.method, ["POST"]) raise HTTPMethodNotAllowed(self.request.method, ["POST"])
code = self.request.query.getone("code", default=None)
if not code: if not code:
raise HTTPFound(oauth_provider.get_oauth_url()) raise HTTPFound(oauth_provider.get_oauth_url())

View File

@ -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())

View File

@ -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

View File

@ -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]"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -11,7 +11,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc
must run command must run command
""" """
mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") 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", application_mock = mocker.patch("ahriman.core.configuration.Configuration.dump",
return_value=configuration.dump()) return_value=configuration.dump())

View File

@ -14,6 +14,7 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace:
:return: generated arguments for these test cases :return: generated arguments for these test cases
""" """
args.dry_run = False args.dry_run = False
args.info = False
return args 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", application_mock = mocker.patch("ahriman.application.application.Application.unknown",
return_value=[package_ahriman]) return_value=[package_ahriman])
remove_mock = mocker.patch("ahriman.application.application.Application.remove") 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) RemoveUnknown.run(args, "x86_64", configuration, True)
application_mock.assert_called_once() application_mock.assert_called_once()
remove_mock.assert_not_called() 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) RemoveUnknown.run(args, "x86_64", configuration, True)
print_mock.assert_called() # we don't really care about call details tbh application_mock.assert_called_once()
remove_mock.assert_not_called()
print_mock.assert_called_once_with(True)

View File

@ -1,10 +1,13 @@
import argparse import argparse
import aur import aur
import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from unittest import mock
from ahriman.application.handlers import Search from ahriman.application.handlers import Search
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import InvalidOption
def _default_args(args: argparse.Namespace) -> argparse.Namespace: 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 :return: generated arguments for these test cases
""" """
args.search = ["ahriman"] args.search = ["ahriman"]
args.info = False
args.sort_by = "name"
return args return args
@ -24,35 +29,71 @@ def test_run(args: argparse.Namespace, configuration: Configuration, aur_package
""" """
args = _default_args(args) args = _default_args(args)
mocker.patch("aur.search", return_value=[aur_package_ahriman]) 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) 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 must run command with multiple search arguments
""" """
args = _default_args(args) args = _default_args(args)
args.search = ["ahriman", "is", "cool"] 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.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, def test_run_sort(args: argparse.Namespace, configuration: Configuration, aur_package_ahriman: aur.Package,
mocker: MockerFixture) -> None: mocker: MockerFixture) -> None:
""" """
log function must call print built-in must run command with sorting
""" """
args = _default_args(args) args = _default_args(args)
mocker.patch("aur.search", return_value=[aur_package_ahriman]) 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) 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: def test_disallow_auto_architecture_run() -> None:
@ -60,3 +101,10 @@ def test_disallow_auto_architecture_run() -> None:
must not allow multi architecture run must not allow multi architecture run
""" """
assert not Search.ALLOW_AUTO_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)

View File

@ -1,6 +1,7 @@
import argparse import argparse
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from unittest import mock
from ahriman.application.handlers import Status from ahriman.application.handlers import Status
from ahriman.core.configuration import Configuration 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 :return: generated arguments for these test cases
""" """
args.ahriman = True args.ahriman = True
args.info = False
args.package = [] args.package = []
args.status = None args.status = None
return args 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", packages_mock = mocker.patch("ahriman.core.status.client.Client.get",
return_value=[(package_ahriman, BuildStatus(BuildStatusEnum.Success)), return_value=[(package_ahriman, BuildStatus(BuildStatusEnum.Success)),
(package_python_schedule, BuildStatus(BuildStatusEnum.Failed))]) (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) Status.run(args, "x86_64", configuration, True)
application_mock.assert_called_once() application_mock.assert_called_once()
packages_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, 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)), return_value=[(package_ahriman, BuildStatus(BuildStatusEnum.Success)),
(package_python_schedule, BuildStatus(BuildStatusEnum.Failed))]) (package_python_schedule, BuildStatus(BuildStatusEnum.Failed))])
mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") 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) 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: def test_imply_with_report(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:

View File

@ -1,4 +1,5 @@
import configparser import configparser
import logging
import pytest import pytest
from pathlib import Path from pathlib import Path
@ -194,7 +195,7 @@ def test_load_logging_quiet(configuration: Configuration, mocker: MockerFixture)
""" """
disable_mock = mocker.patch("logging.disable") disable_mock = mocker.patch("logging.disable")
configuration.load_logging(quiet=True) 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: def test_merge_sections_missing(configuration: Configuration) -> None:

View File

@ -1,3 +1,4 @@
import datetime
import logging import logging
import pytest import pytest
import subprocess import subprocess
@ -6,7 +7,7 @@ from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from ahriman.core.exceptions import InvalidOption, UnsafeRun 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 from ahriman.models.package import Package
@ -79,6 +80,26 @@ def test_check_user_exception(mocker: MockerFixture) -> None:
check_user(cwd) 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: def test_package_like(package_ahriman: Package) -> None:
""" """
package_like must return true for archives 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" 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: def test_pretty_datetime_empty() -> None:
""" """
must generate empty string from None timestamp must generate empty string from None timestamp

View File