diff --git a/.pylint.toml b/.pylint.toml
index 85b0815a..95f4628c 100644
--- a/.pylint.toml
+++ b/.pylint.toml
@@ -1,10 +1,10 @@
[tool.pylint.main]
-init-hook = "sys.path.append('pylint_plugins')"
+init-hook = "sys.path.append('tools')"
load-plugins = [
"pylint.extensions.docparams",
"pylint.extensions.bad_builtin",
- "definition_order",
- "import_order",
+ "pylint_plugins.definition_order",
+ "pylint_plugins.import_order",
]
[tool.pylint.classes]
diff --git a/.pytest.ini b/.pytest.ini
new file mode 100644
index 00000000..85345048
--- /dev/null
+++ b/.pytest.ini
@@ -0,0 +1,5 @@
+[pytest]
+addopts = --cov=ahriman --cov-report=term-missing:skip-covered --no-cov-on-fail --cov-fail-under=100 --spec
+asyncio_default_fixture_loop_scope = function
+asyncio_mode = auto
+spec_test_format = {result} {docstring_summary}
diff --git a/package/share/man/man1/ahriman.1 b/package/share/man/man1/ahriman.1
index 65c25953..5d818711 100644
--- a/package/share/man/man1/ahriman.1
+++ b/package/share/man/man1/ahriman.1
@@ -1,6 +1,6 @@
-.TH AHRIMAN "1" "2025\-06\-16" "ahriman" "Generated Python Manual"
+.TH AHRIMAN "1" "2025\-06\-23" "ahriman 2.18.3" "ArcH linux ReposItory MANager"
.SH NAME
-ahriman
+ahriman \- ArcH linux ReposItory MANager
.SH SYNOPSIS
.B ahriman
[-h] [-a ARCHITECTURE] [-c CONFIGURATION] [--force] [-l LOCK] [--log-handler {console,syslog,journald}] [-q] [--report | --no-report] [-r REPOSITORY] [--unsafe] [-V] [--wait-timeout WAIT_TIMEOUT] {add,aur-search,check,clean,config,config-validate,copy,daemon,help,help-commands-unsafe,help-updates,help-version,init,key-import,package-add,package-changes,package-changes-remove,package-copy,package-remove,package-status,package-status-remove,package-status-update,package-update,patch-add,patch-list,patch-remove,patch-set-add,rebuild,remove,remove-unknown,repo-backup,repo-check,repo-clean,repo-config,repo-config-validate,repo-create-keyring,repo-create-mirrorlist,repo-daemon,repo-init,repo-rebuild,repo-remove-unknown,repo-report,repo-restore,repo-setup,repo-sign,repo-statistics,repo-status-update,repo-sync,repo-tree,repo-triggers,repo-update,report,run,search,service-clean,service-config,service-config-validate,service-key-import,service-repositories,service-run,service-setup,service-shell,service-tree-migrate,setup,shell,sign,status,status-update,sync,update,user-add,user-list,user-remove,version,web} ...
diff --git a/pyproject.toml b/pyproject.toml
index 6f359b2c..ae839788 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -58,23 +58,23 @@ web = [
"aiohttp_cors",
"aiohttp_jinja2",
]
-web_api-docs = [
- "ahriman[web]",
- "aiohttp-apispec",
- "setuptools", # required by aiohttp-apispec
-]
-web_auth = [
+web-auth = [
"ahriman[web]",
"aiohttp_session",
"aiohttp_security",
"cryptography",
]
-web_metrics = [
+web-docs = [
+ "ahriman[web]",
+ "aiohttp-apispec",
+ "setuptools", # required by aiohttp-apispec
+]
+web-metrics = [
"ahriman[web]",
"aiohttp-openmetrics",
]
-web_oauth2 = [
- "ahriman[web_auth]",
+web-oauth2 = [
+ "ahriman[web-auth]",
"aioauth-client",
]
diff --git a/subpackages.py b/subpackages.py
index c6910f2f..1e7659fb 100644
--- a/subpackages.py
+++ b/subpackages.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2021-2024 ahriman team.
+# Copyright (c) 2021-2025 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
diff --git a/tools/__init__.py b/tools/__init__.py
new file mode 100644
index 00000000..7413eea9
--- /dev/null
+++ b/tools/__init__.py
@@ -0,0 +1,19 @@
+#
+# Copyright (c) 2021-2025 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/pylint_plugins/definition_order.py b/tools/pylint_plugins/definition_order/__init__.py
similarity index 99%
rename from pylint_plugins/definition_order.py
rename to tools/pylint_plugins/definition_order/__init__.py
index e86685e5..6237fe9d 100644
--- a/pylint_plugins/definition_order.py
+++ b/tools/pylint_plugins/definition_order/__init__.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2021-2023 ahriman team.
+# Copyright (c) 2021-2025 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
diff --git a/pylint_plugins/import_order.py b/tools/pylint_plugins/import_order/__init__.py
similarity index 99%
rename from pylint_plugins/import_order.py
rename to tools/pylint_plugins/import_order/__init__.py
index 923cff79..53a981b6 100644
--- a/pylint_plugins/import_order.py
+++ b/tools/pylint_plugins/import_order/__init__.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2021-2023 ahriman team.
+# Copyright (c) 2021-2025 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index 886807a4..00000000
--- a/tox.ini
+++ /dev/null
@@ -1,123 +0,0 @@
-[tox]
-envlist = check, tests
-isolated_build = true
-labels =
- release = version, docs, publish
-dependencies = -e .[journald,pacman,reports,s3,shell,stats,unixsocket,validator,web,web_api-docs,web_auth,web_oauth2,web_metrics]
-project_name = ahriman
-
-[flags]
-autopep8 = --max-line-length 120 -aa --in-place
-bandit = --configfile .bandit.yml
-manpage = --author "ahriman team" --author-email "" --description "ArcH linux ReposItory MANager" --manual-title "ArcH linux ReposItory MANager" --project-name ahriman --url https://github.com/arcan1s/ahriman
-mypy = --implicit-reexport --strict --allow-untyped-decorators --allow-subclassing-any
-pydeps = --no-config --cluster
-pylint = --rcfile .pylint.toml
-shtab = --prefix ahriman --prog ahriman ahriman.application.ahriman._parser
-
-[pytest]
-addopts = --cov=ahriman --cov-report=term-missing:skip-covered --no-cov-on-fail --cov-fail-under=100 --spec
-asyncio_default_fixture_loop_scope = function
-asyncio_mode = auto
-spec_test_format = {result} {docstring_summary}
-
-[testenv:archive]
-description = Create source files tarball
-deps =
- build
-commands =
- python -m build --sdist
-
-[testenv:check]
-description = Run common checks like linter, mypy, etc
-dependency_groups =
- check
-deps =
- {[tox]dependencies}
-pip_pre = true
-setenv =
- CFLAGS="-Wno-unterminated-string-initialization"
- MYPYPATH=src
-commands =
- autopep8 {[flags]autopep8} --exit-code --jobs 0 --recursive "src/{[tox]project_name}" "tests/{[tox]project_name}"
- pylint {[flags]pylint} "src/{[tox]project_name}"
- bandit {[flags]bandit} --recursive "src/{[tox]project_name}"
- bandit {[flags]bandit} --skip B101,B105,B106 --recursive "tests/{[tox]project_name}"
- mypy {[flags]mypy} --install-types --non-interactive --package "{[tox]project_name}"
-
-[testenv:docs]
-description = Generate source files for documentation
-allowlist_externals =
- bash
- find
-dependency_groups =
- docs
-depends =
- version
-deps =
- {[tox]dependencies}
- uv
-pip_pre = true
-setenv =
- PYTHONPATH=src
- SPHINX_APIDOC_OPTIONS=members,no-undoc-members,show-inheritance
-commands =
- bash -c 'shtab {[flags]shtab} --shell bash > package/share/bash-completion/completions/_ahriman'
- bash -c 'shtab {[flags]shtab} --shell zsh > package/share/zsh/site-functions/_ahriman'
- argparse-manpage {[flags]manpage} --module ahriman.application.ahriman --function _parser --output ../package/share/man/man1/ahriman.1
- pydeps {[flags]pydeps} --no-output --show-dot --dot-output {tox_root}{/}docs/_static/architecture.dot src/ahriman
- # remove autogenerated modules rst files
- find docs -type f -name "{[tox]project_name}*.rst" -delete
- sphinx-apidoc --output-dir docs src
- # compile list of dependencies for rtd.io
- uv pip compile --group pyproject.toml:docs --extra s3 --extra validator --extra web --output-file docs/requirements.txt --quiet pyproject.toml
-
-[testenv:html]
-description = Generate html documentation
-dependency_groups =
- docs
-deps =
- {[tox]dependencies}
-pip_pre = true
-recreate = true
-commands =
- sphinx-build --builder html --write-all --jobs auto --fail-on-warning docs {envtmpdir}{/}html
-
-[testenv:publish]
-description = Create and publish release to GitHub
-allowlist_externals =
- git
-depends =
- docs
-passenv =
- SSH_AUTH_SOCK
-commands =
- git add package/archlinux/PKGBUILD src/ahriman/__init__.py docs/_static/architecture.dot package/share/man/man1/ahriman.1 package/share/bash-completion/completions/_ahriman package/share/zsh/site-functions/_ahriman
- git commit -m "Release {posargs}"
- git tag "{posargs}"
- git push
- git push --tags
-
-[testenv:tests]
-description = Run tests
-dependency_groups =
- tests
-deps =
- {[tox]dependencies}
-pip_pre = true
-setenv =
- CFLAGS="-Wno-unterminated-string-initialization"
-commands =
- pytest {posargs}
-
-[testenv:version]
-description = Bump package version
-allowlist_externals =
- sed
-deps =
- packaging
-commands =
- # check if version is set and validate it
- {envpython} -c 'from packaging.version import Version; Version("{posargs}")'
- sed -i 's/^__version__ = .*/__version__ = "{posargs}"/' src/ahriman/__init__.py
- sed -i "s/pkgver=.*/pkgver={posargs}/" package/archlinux/PKGBUILD
diff --git a/tox.toml b/tox.toml
new file mode 100644
index 00000000..594d9762
--- /dev/null
+++ b/tox.toml
@@ -0,0 +1,310 @@
+env_list = [
+ "check",
+ "tests",
+]
+isolated_build = true
+labels.release = [
+ "version",
+ "docs",
+ "publish",
+]
+
+[flags]
+autopep8 = [
+ "--max-line-length", "120",
+ "-aa",
+]
+bandit = [
+ "--configfile", ".bandit.yml",
+]
+manpage = [
+ "--author", "{[project]name} team",
+ "--author-email", "",
+ "--description", "ArcH linux ReposItory MANager",
+ "--manual-title", "ArcH linux ReposItory MANager",
+ "--project-name", "{[project]name}",
+ "--version", "{env:VERSION}",
+ "--url", "https://github.com/arcan1s/ahriman",
+]
+mypy = [
+ "--implicit-reexport",
+ "--strict",
+ "--allow-untyped-decorators",
+ "--allow-subclassing-any",
+]
+pydeps = [
+ "--no-config",
+ "--cluster",
+]
+pylint = [
+ "--rcfile", ".pylint.toml",
+]
+shtab = [
+ "--prefix", "{[project]name}",
+ "--prog", "{[project]name}",
+ "ahriman.application.ahriman._parser",
+]
+
+[project]
+extras = [
+ "journald",
+ "pacman",
+ "reports",
+ "s3",
+ "shell",
+ "stats",
+ "unixsocket",
+ "validator",
+ "web",
+ "web-auth",
+ "web-docs",
+ "web-oauth2",
+ "web-metrics",
+]
+name = "ahriman"
+
+[env.archive]
+description = "Create source files tarball"
+deps = [
+ "build",
+]
+commands = [
+ [
+ "{envpython}",
+ "-m", "build",
+ "--sdist",
+ ],
+]
+
+[env.check]
+description = "Run common checks like linter, mypy, etc"
+dependency_groups = [
+ "check",
+]
+extras = [
+ { replace = "ref", of = ["project", "extras"], extend = true },
+]
+pip_pre = true
+set_env.CFLAGS = "-Wno-unterminated-string-initialization"
+set_env.MYPYPATH = "src"
+commands = [
+ [
+ "autopep8",
+ { replace = "ref", of = ["flags", "autopep8"], extend = true },
+ "--exit-code",
+ "--in-place",
+ "--jobs", "0",
+ "--recursive",
+ "src/{[project]name}",
+ "tests/{[project]name}",
+ ],
+ [
+ "pylint",
+ { replace = "ref", of = ["flags", "pylint"], extend = true },
+ "src/{[project]name}",
+ ],
+ [
+ "bandit",
+ { replace = "ref", of = ["flags", "bandit"], extend = true },
+ "--recursive",
+ "src/{[project]name}",
+ ],
+ [
+ "bandit",
+ { replace = "ref", of = ["flags", "bandit"], extend = true },
+ "--skip", "B101,B105,B106",
+ "--recursive",
+ "src/{[project]name}",
+ ],
+ [
+ "mypy",
+ { replace = "ref", of = ["flags", "mypy"], extend = true },
+ "--install-types",
+ "--non-interactive",
+ "--package", "{[project]name}",
+ ],
+]
+
+[env.docs]
+description = "Generate source files for documentation"
+dependency_groups = [
+ "docs",
+]
+depends = [
+ "version",
+]
+deps = [
+ "uv",
+]
+dynamic_version = "{[project]name}.__version__"
+extras = [
+ { replace = "ref", of = ["project", "extras"], extend = true },
+]
+# TODO: steamline shlex usage after https://github.com/iterative/shtab/pull/192 merge
+handle_redirect = true
+pip_pre = true
+set_env.PYTHONPATH = "src"
+set_env.SPHINX_APIDOC_OPTIONS = "members,no-undoc-members,show-inheritance"
+commands = [
+ [
+ "shtab",
+ { replace = "ref", of = ["flags", "shtab"], extend = true },
+ "--shell",
+ "bash",
+ ">",
+ "package/share/bash-completion/completions/_ahriman",
+ ],
+ [
+ "shtab",
+ { replace = "ref", of = ["flags", "shtab"], extend = true },
+ "--shell",
+ "zsh",
+ ">",
+ "package/share/zsh/site-functions/_ahriman",
+ ],
+ [
+ "argparse-manpage",
+ { replace = "ref", of = ["flags", "manpage"], extend = true },
+ "--module", "ahriman.application.ahriman",
+ "--function", "_parser",
+ "--output", "package/share/man/man1/ahriman.1",
+ ],
+ [
+ "pydeps",
+ { replace = "ref", of = ["flags", "pydeps"], extend = true },
+ "--dot-output", "{tox_root}/docs/_static/architecture.dot",
+ "--no-output",
+ "--show-dot",
+ "src/ahriman",
+ ],
+ [
+ "sphinx-apidoc",
+ "--force",
+ "--no-toc",
+ "--output-dir", "docs",
+ "src",
+ ],
+ # compile list of dependencies for rtd.io
+ [
+ "uv",
+ "pip",
+ "compile",
+ "--group", "pyproject.toml:docs",
+ "--extra", "s3",
+ "--extra", "validator",
+ "--extra", "web",
+ "--output-file", "docs/requirements.txt",
+ "--quiet",
+ "pyproject.toml",
+ ],
+]
+
+[env.html]
+description = "Generate html documentation"
+dependency_groups = [
+ "docs",
+]
+extras = [
+ { replace = "ref", of = ["project", "extras"], extend = true },
+]
+pip_pre = true
+recreate = true
+commands = [
+ [
+ "sphinx-build",
+ "--builder", "html",
+ "--fail-on-warning",
+ "--jobs", "auto",
+ "--write-all",
+ "docs",
+ "{envtmpdir}/html",
+ ],
+]
+
+[env.publish]
+description = "Create and publish release to GitHub"
+allowlist_externals = [
+ "git",
+]
+depends = [
+ "docs",
+]
+pass_env = [
+ "SSH_AUTH_SOCK",
+]
+commands = [
+ [
+ "git",
+ "add",
+ "package/archlinux/PKGBUILD",
+ "src/ahriman/__init__.py",
+ "docs/_static/architecture.dot",
+ "package/share/man/man1/ahriman.1",
+ "package/share/bash-completion/completions/_ahriman",
+ "package/share/zsh/site-functions/_ahriman",
+ ],
+ [
+ "git",
+ "commit",
+ "--message", "Release {posargs}",
+ ],
+ [
+ "git",
+ "tag",
+ "{posargs}",
+ ],
+ [
+ "git",
+ "push",
+ ],
+ [
+ "git",
+ "push",
+ "--tags",
+ ],
+]
+
+[env.tests]
+description = "Run tests"
+dependency_groups = [
+ "tests",
+]
+extras = [
+ { replace = "ref", of = ["project", "extras"], extend = true },
+]
+pip_pre = true
+set_env.CFLAGS = "-Wno-unterminated-string-initialization"
+commands = [
+ [
+ "pytest",
+ { replace = "posargs", extend = true },
+ ],
+]
+
+[env.version]
+description = "Bump package version"
+allowlist_externals = [
+ "sed",
+]
+deps = [
+ "packaging",
+]
+commands = [
+ # check if version is set and validate it
+ [
+ "{envpython}",
+ "-c", "from packaging.version import Version; Version('{posargs}')",
+ ],
+ [
+ "sed",
+ "--in-place",
+ "s/^__version__ = .*/__version__ = \"{posargs}\"/",
+ "src/ahriman/__init__.py",
+ ],
+ [
+ "sed",
+ "--in-place",
+ "s/pkgver=.*/pkgver={posargs}/",
+ "package/archlinux/PKGBUILD",
+ ],
+]
diff --git a/toxfile.py b/toxfile.py
new file mode 100644
index 00000000..02e9d69c
--- /dev/null
+++ b/toxfile.py
@@ -0,0 +1,128 @@
+#
+# Copyright (c) 2021-2025 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 importlib
+import shlex
+import sys
+
+from tox.config.sets import EnvConfigSet
+from tox.config.types import Command
+from tox.plugin import impl
+from tox.session.state import State
+from tox.tox_env.api import ToxEnv
+
+
+def _extract_version(env_conf: EnvConfigSet, python_path: str | None = None) -> dict[str, str]:
+ """
+ extract version dynamically and set VERSION environment variable
+
+ Args:
+ env_conf(EnvConfigSet): the core configuration object
+ python_path(str | None): python path variable if available
+
+ Returns:
+ dict[str, str]: environment variables which must be inserted
+ """
+ import_path = env_conf["dynamic_version"]
+ if not import_path:
+ return {}
+
+ if python_path is not None:
+ sys.path.append(python_path)
+
+ module_name, variable_name = import_path.rsplit(".", maxsplit=1)
+ module = importlib.import_module(module_name)
+ version = getattr(module, variable_name)
+
+ # reset import paths
+ sys.path.pop()
+
+ return {"VERSION": version}
+
+
+def _wrap_commands(env_conf: EnvConfigSet, shell: str = "bash") -> None:
+ """
+ wrap commands into shell if there is redirect
+
+ Args:
+ env_conf(EnvConfigSet): the core configuration object
+ shell(str, optional): shell command to use (Default value = "bash")
+ """
+ if not env_conf["handle_redirect"]:
+ return
+
+ # append shell just in case
+ env_conf["allowlist_externals"].append(shell)
+
+ for command in env_conf["commands"]:
+ if len(command.args) < 3: # command itself, redirect and output
+ continue
+
+ redirect, output = command.args[-2:]
+ if redirect not in (">", "2>", "&>"):
+ continue
+
+ command.args = [
+ shell,
+ "-c",
+ f"{Command(command.args[:-2]).shell} {redirect} {shlex.quote(output)}",
+ ]
+
+
+@impl
+def tox_add_env_config(env_conf: EnvConfigSet, state: State) -> None:
+ """
+ add a command line argument. This is the first hook to be called,
+ right after the logging setup and config source discovery.
+
+ Args:
+ env_conf(EnvConfigSet): the core configuration object
+ state(State): the global tox state object
+ """
+ del state
+
+ env_conf.add_config(
+ keys=["dynamic_version"],
+ of_type=str,
+ default="",
+ desc="import path for the version variable",
+ )
+ env_conf.add_config(
+ keys=["handle_redirect"],
+ of_type=bool,
+ default=False,
+ desc="wrap commands to handle redirects if any",
+ )
+
+
+@impl
+def tox_before_run_commands(tox_env: ToxEnv) -> None:
+ """
+ called before the commands set is executed
+
+ Args:
+ tox_env(ToxEnv): the tox environment being executed
+ """
+ env_conf = tox_env.conf
+ set_env = env_conf["set_env"]
+
+ python_path = set_env.load("PYTHONPATH") if "PYTHONPATH" in set_env else None
+ set_env.update(_extract_version(env_conf, python_path))
+
+ _wrap_commands(env_conf)