Compare commits

..
6 Commits
Author SHA1 Message Date
arcanis 9c3d1471e0 build: tox cleanup 2026-07-23 15:54:26 +03:00
arcanis f37c5bd2a2 refactor: implement generic optional import mechanisms
this project is actively using optional imports, which leads to
duplicate handling here and there. This commit implements logic in
special method to reduce complexity and simplify tests paths
2026-07-22 16:33:59 +03:00
arcanis bd26e8da68 build: handle local imports in pylint check correctly 2026-07-22 16:18:56 +03:00
arcanis 99365ea78f build: fix mypy type install 2026-07-22 15:54:00 +03:00
arcanis 2eb51ba85a Release 2.21.1 2026-07-21 10:46:21 +03:00
arcanis 36e28aba99 fix: always load tab on remount
it fixes a bug, which leads to missing tab content load when opening
package info
2026-07-21 10:44:20 +03:00
27 changed files with 146 additions and 119 deletions
@@ -1,4 +1,4 @@
.TH AHRIMAN "1" "2026\-07\-21" "ahriman 2.21.0" "ArcH linux ReposItory MANager"
.TH AHRIMAN "1" "2026\-07\-21" "ahriman 2.21.1" "ArcH linux ReposItory MANager"
.SH NAME
ahriman \- ArcH linux ReposItory MANager
.SH SYNOPSIS
+1 -1
View File
@@ -17,4 +17,4 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
__version__ = "2.21.0"
__version__ = "2.21.1"
@@ -21,6 +21,8 @@
from logging import NullHandler
from typing import Any
from ahriman.core.module_loader import optional_module
__all__ = ["JournalHandler"]
@@ -40,7 +42,5 @@ class _JournalHandler(NullHandler):
del args, kwargs
try:
from systemd.journal import JournalHandler # type: ignore[import-untyped]
except ImportError:
JournalHandler = _JournalHandler
systemd_journal = optional_module("systemd.journal")
JournalHandler = systemd_journal.JournalHandler if systemd_journal else _JournalHandler
@@ -26,6 +26,7 @@ from typing import ClassVar, Literal
from ahriman.core.configuration import Configuration
from ahriman.core.log.http_log_handler import HttpLogHandler
from ahriman.core.log.log_context import LogContext
from ahriman.core.module_loader import optional_module
from ahriman.models.log_handler import LogHandler
from ahriman.models.repository_id import RepositoryId
@@ -65,11 +66,8 @@ class LogLoader:
if selected is not None:
return selected
try:
from systemd.journal import JournalHandler # type: ignore[import-untyped]
del JournalHandler
if optional_module("systemd.journal"):
return LogHandler.Journald # journald import was found
except ImportError:
if LogLoader.DEFAULT_SYSLOG_DEVICE.exists():
return LogHandler.Syslog
return LogHandler.Console
+17 -2
View File
@@ -26,8 +26,7 @@ from pkgutil import ModuleInfo, walk_packages
from types import ModuleType
from typing import Any, TypeGuard, TypeVar
__all__ = ["implementations"]
__all__ = ["implementations", "optional_module"]
T = TypeVar("T")
@@ -74,3 +73,19 @@ def implementations(root_module: ModuleType, base_class: type[T]) -> Iterator[ty
for _, attribute in inspect.getmembers(module, is_base_class):
yield attribute
def optional_module(module_name: str) -> ModuleType | None:
"""
import an optional module
Args:
module_name(str): fully qualified module name
Returns:
ModuleType | None: imported module or ``None`` when it cannot be imported
"""
try:
return import_module(module_name)
except ImportError:
return None
@@ -2,7 +2,7 @@ import ahriman.web.views
from pathlib import Path
from ahriman.core.module_loader import _modules, implementations
from ahriman.core.module_loader import _modules, implementations, optional_module
from ahriman.web.views.base import BaseView
@@ -23,3 +23,17 @@ def test_implementations() -> None:
assert routes
assert all(isinstance(view, type) for view in routes)
assert all(issubclass(view, BaseView) for view in routes)
def test_optional_module() -> None:
"""
must import an available module
"""
assert optional_module("ahriman.web.views") is ahriman.web.views
def test_optional_module_fallback() -> None:
"""
must return none when the module cannot be imported
"""
assert optional_module("missing_ahriman_module") is None
+11 -15
View File
@@ -17,18 +17,10 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
try:
import aiohttp_security
except ImportError:
aiohttp_security = None # type: ignore[assignment]
try:
import aiohttp_session
except ImportError:
aiohttp_session = None # type: ignore[assignment]
from typing import Any
from ahriman.core.module_loader import optional_module
__all__ = [
"authorized_userid",
@@ -39,6 +31,10 @@ __all__ = [
]
aiohttp_security = optional_module("aiohttp_security")
aiohttp_session = optional_module("aiohttp_session")
async def authorized_userid(*args: Any, **kwargs: Any) -> Any:
"""
handle aiohttp security methods
@@ -50,7 +46,7 @@ async def authorized_userid(*args: Any, **kwargs: Any) -> Any:
Returns:
Any: ``None`` in case if no aiohttp_security module found and function call otherwise
"""
if aiohttp_security is not None:
if aiohttp_security:
return await aiohttp_security.authorized_userid(*args, **kwargs) # pylint: disable=no-value-for-parameter
return None
@@ -66,7 +62,7 @@ async def check_authorized(*args: Any, **kwargs: Any) -> Any:
Returns:
Any: ``None`` in case if no aiohttp_security module found and function call otherwise
"""
if aiohttp_security is not None:
if aiohttp_security:
return await aiohttp_security.check_authorized(*args, **kwargs) # pylint: disable=no-value-for-parameter
return None
@@ -82,7 +78,7 @@ async def forget(*args: Any, **kwargs: Any) -> Any:
Returns:
Any: ``None`` in case if no aiohttp_security module found and function call otherwise
"""
if aiohttp_security is not None:
if aiohttp_security:
return await aiohttp_security.forget(*args, **kwargs) # pylint: disable=no-value-for-parameter
return None
@@ -98,7 +94,7 @@ async def get_session(*args: Any, **kwargs: Any) -> Any:
Returns:
Any: empty dictionary in case if no aiohttp_session module found and function call otherwise
"""
if aiohttp_session is not None:
if aiohttp_session:
return await aiohttp_session.get_session(*args, **kwargs)
return {}
@@ -114,6 +110,6 @@ async def remember(*args: Any, **kwargs: Any) -> Any:
Returns:
Any: ``None`` in case if no aiohttp_security module found and function call otherwise
"""
if aiohttp_security is not None:
if aiohttp_security:
return await aiohttp_security.remember(*args, **kwargs) # pylint: disable=no-value-for-parameter
return None
@@ -17,15 +17,20 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
try:
import aiohttp_apispec # type: ignore[import-untyped]
from ahriman.core.module_loader import optional_module
from marshmallow import Schema, fields
except ImportError:
aiohttp_apispec = optional_module("aiohttp_apispec")
marshmallow = optional_module("marshmallow")
if aiohttp_apispec and marshmallow:
Schema = marshmallow.Schema
fields = marshmallow.fields
else:
from unittest.mock import Mock
Schema = Mock # type: ignore[misc]
aiohttp_apispec = None
fields = Mock()
@@ -115,7 +115,7 @@ def apidocs(*,
authorization_required = permission != UserAccess.Unauthorized
def wrapper(handler: Callable[..., Any]) -> Callable[..., Any]:
if aiohttp_apispec is None:
if not aiohttp_apispec:
return handler # apispec is disabled
responses = _response_schema(
+3 -6
View File
@@ -100,20 +100,17 @@ def _servers(application: Application) -> list[dict[str, Any]]:
}]
def setup_apispec(application: Application) -> Any:
def setup_apispec(application: Application) -> None:
"""
setup swagger api specification
Args:
application(Application): web application instance
Returns:
Any: created specification instance if module is available
"""
if aiohttp_apispec is None:
return None
return
return aiohttp_apispec.setup_aiohttp_apispec(
aiohttp_apispec.setup_aiohttp_apispec(
application,
url="/api-docs/swagger.json",
openapi_version="3.0.2",
@@ -17,14 +17,10 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
try:
import aiohttp_openmetrics
except ImportError:
aiohttp_openmetrics = None # type: ignore[assignment]
from aiohttp.typedefs import Middleware
from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse, middleware
from ahriman.core.module_loader import optional_module
from ahriman.web.middlewares import HandlerType
@@ -34,6 +30,9 @@ __all__ = [
]
aiohttp_openmetrics = optional_module("aiohttp_openmetrics")
async def metrics(request: Request) -> Response:
"""
handler for returning metrics
@@ -47,7 +46,7 @@ async def metrics(request: Request) -> Response:
Raises:
HTTPNotFound: endpoint is disabled
"""
if aiohttp_openmetrics is None:
if not aiohttp_openmetrics:
raise HTTPNotFound
return await aiohttp_openmetrics.metrics(request)
@@ -59,7 +58,7 @@ def metrics_handler() -> Middleware:
Returns:
Middleware: middleware function to handle server metrics
"""
if aiohttp_openmetrics is not None:
if aiohttp_openmetrics:
return aiohttp_openmetrics.metrics_middleware
@middleware
+1 -1
View File
@@ -60,7 +60,7 @@ async def server_info(view: BaseView) -> dict[str, Any]:
"username": await authorized_userid(view.request),
},
"autorefresh_intervals": sorted(autorefresh_intervals, key=comparator),
"docs_enabled": aiohttp_apispec is not None,
"docs_enabled": bool(aiohttp_apispec),
"index_url": view.configuration.get("web", "index_url", fallback=None),
"repositories": [
{
@@ -50,7 +50,7 @@ class DocsView(BaseView):
list[str]: list of routes defined for the view. By default, it tries to read :attr:`ROUTES` option if set
and returns empty list otherwise
"""
if aiohttp_apispec is None:
if not aiohttp_apispec:
return []
return cls.ROUTES
@@ -51,7 +51,7 @@ class SwaggerView(BaseView):
list[str]: list of routes defined for the view. By default, it tries to read :attr:`ROUTES` option if set
and returns empty list otherwise
"""
if aiohttp_apispec is None:
if not aiohttp_apispec:
return []
return cls.ROUTES
@@ -22,6 +22,7 @@ from secrets import token_urlsafe
from typing import ClassVar
from ahriman.core.auth.helpers import get_session, remember
from ahriman.core.module_loader import optional_module
from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs
from ahriman.web.schemas import LoginSchema, OAuth2Schema
@@ -62,14 +63,12 @@ class LoginView(BaseView):
HTTPMethodNotAllowed: in case if method is used, but OAuth is disabled
HTTPUnauthorized: if case of authorization error
"""
try:
from ahriman.core.auth.oauth import OAuth
except ImportError:
# no aioauth library found
oauth = optional_module("ahriman.core.auth.oauth")
if not oauth:
raise HTTPMethodNotAllowed(self.request.method, ["POST"])
oauth_provider = self.validator
if not isinstance(oauth_provider, OAuth):
if not isinstance(oauth_provider, oauth.OAuth):
raise HTTPMethodNotAllowed(self.request.method, ["POST"])
session = await get_session(self.request)
@@ -48,7 +48,8 @@ def test_setup_apispec(application: Application, mocker: MockerFixture) -> None:
must set api specification
"""
apispec_mock = mocker.patch("aiohttp_apispec.setup_aiohttp_apispec")
assert setup_apispec(application)
setup_apispec(application)
apispec_mock.assert_called_once_with(
application,
url="/api-docs/swagger.json",
@@ -37,7 +37,7 @@ async def test_get_import_error(client_with_auth: TestClient, mocker: MockerFixt
"""
must return 405 on import error
"""
pytest.helpers.import_error("ahriman.core.auth.oauth", ["OAuth"], mocker)
mocker.patch("ahriman.web.views.v1.user.login.optional_module", return_value=None)
response = await client_with_auth.get("/api/v1/login")
assert response.status == 405
+1 -1
View File
@@ -2,7 +2,7 @@
pkgbase='ahriman'
pkgname=('ahriman' 'ahriman-core' 'ahriman-triggers' 'ahriman-web')
pkgver=2.21.0
pkgver=2.21.1
pkgrel=1
pkgdesc="ArcH linux ReposItory MANager"
arch=('any')
+1 -1
View File
@@ -39,5 +39,5 @@
"preview": "vite preview"
},
"type": "module",
"version": "2.21.0"
"version": "2.21.1"
}
@@ -58,11 +58,7 @@ export default function PackageInfoDialog({
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const [localPackageBase, setLocalPackageBase] = useState(packageBase);
if (packageBase !== null && packageBase !== localPackageBase) {
setLocalPackageBase(packageBase);
}
const localPackageBase = packageBase;
const [activeTab, setActiveTab] = useState<TabKey>("logs");
const [refreshDatabase, setRefreshDatabase] = useState(true);
@@ -157,38 +153,42 @@ export default function PackageInfoDialog({
<DialogContent>
{pkg &&
<>
<PackageDetailsGrid dependencies={dependencies} pkg={pkg} />
}
{localPackageBase &&
<PackagePatchesList
editable={isAuthorized}
onDelete={key => void handleDeletePatch(key)}
patches={patches}
/>
}
{localPackageBase && currentRepository &&
<>
<Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}>
<Tabs onChange={(_, tab: TabKey) => setActiveTab(tab)} value={activeTab}>
{tabs.map(({ key, label }) => <Tab key={key} label={label} value={key} />)}
</Tabs>
</Box>
{activeTab === "logs" && localPackageBase && currentRepository &&
{activeTab === "logs" &&
<BuildLogsTab
packageBase={localPackageBase}
repository={currentRepository}
/>
}
{activeTab === "changes" && localPackageBase && currentRepository &&
{activeTab === "changes" &&
<ChangesTab packageBase={localPackageBase} repository={currentRepository} />
}
{activeTab === "pkgbuild" && localPackageBase && currentRepository &&
{activeTab === "pkgbuild" &&
<PkgbuildTab packageBase={localPackageBase} repository={currentRepository} />
}
{activeTab === "events" && localPackageBase && currentRepository &&
{activeTab === "events" &&
<EventsTab packageBase={localPackageBase} repository={currentRepository} />
}
{activeTab === "artifacts" && localPackageBase && currentRepository &&
{activeTab === "artifacts" &&
<ArtifactsTab
currentVersion={pkg.version}
currentVersion={pkg?.version}
packageBase={localPackageBase}
repository={currentRepository}
/>
@@ -32,7 +32,7 @@ import { useCallback, useMemo } from "react";
import { DETAIL_TABLE_PROPS } from "utils";
interface ArtifactsTabProps {
currentVersion: string;
currentVersion?: string;
packageBase: string;
repository: RepositoryId;
}
@@ -78,6 +78,7 @@ export default function ArtifactsTab({
})).reverse();
},
queryKey: QueryKeys.artifacts(packageBase, repository),
refetchOnMount: "always",
});
const handleRollback = useCallback(async (version: string): Promise<void> => {
@@ -101,7 +102,7 @@ export default function ArtifactsTab({
<Tooltip title={params.row.version === currentVersion ? "Current version" : "Rollback to this version"}>
<span>
<IconButton
disabled={params.row.version === currentVersion}
disabled={currentVersion === params.row.version}
onClick={() => void handleRollback(params.row.version)}
size="small"
>
@@ -61,6 +61,7 @@ export default function BuildLogsTab({
enabled: !!packageBase,
queryFn: () => client.fetch.fetchPackageLogs(packageBase, repository),
queryKey: QueryKeys.logs(packageBase, repository),
refetchOnMount: "always",
});
// Build version selectors from all logs
@@ -116,6 +117,7 @@ export default function BuildLogsTab({
)
: skipToken,
queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""),
refetchOnMount: "always",
});
// Derive displayed logs: prefer fresh polled data when available
@@ -54,6 +54,7 @@ export default function EventsTab({ packageBase, repository }: EventsTabProps):
enabled: !!packageBase,
queryFn: () => client.fetch.fetchPackageEvents(repository, packageBase, 30),
queryKey: QueryKeys.events(repository, packageBase),
refetchOnMount: "always",
});
const rows = useMemo<EventRow[]>(() => events.map((event, index) => ({
+1
View File
@@ -30,6 +30,7 @@ export function usePackageChanges(packageBase: string, repository: RepositoryId)
enabled: !!packageBase,
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
queryKey: QueryKeys.changes(packageBase, repository),
refetchOnMount: "always",
});
return data;
+21 -1
View File
@@ -93,6 +93,21 @@ class ImportOrder(BaseRawFileChecker):
)
)
@staticmethod
def import_context(statement: nodes.Import | nodes.ImportFrom) -> tuple[nodes.NodeNG, str]:
"""
extract the lexical block containing an import
Args:
statement(nodes.Import | nodes.ImportFrom): import node
Returns:
tuple[nodes.NodeNG, str]: parent node and its branch containing the import
"""
parent = statement.parent
field, _ = parent.locate_child(statement)
return parent, field
@staticmethod
def imports(source: Iterable[Any], start_lineno: int = 0) -> Iterable[nodes.Import | nodes.ImportFrom]:
"""
@@ -192,7 +207,12 @@ class ImportOrder(BaseRawFileChecker):
node(nodes.Module): module node to check
"""
root_module, *_ = node.qname().split(".")
self.check_imports(self.imports(node.values()), root_module)
contexts: dict[tuple[nodes.NodeNG, str], list[nodes.Import | nodes.ImportFrom]] = {}
for statement in self.imports(node.values()):
contexts.setdefault(self.import_context(statement), []).append(statement)
for imports in contexts.values():
self.check_imports(imports, root_module)
def register(linter: PyLinter) -> None:
@@ -117,36 +117,12 @@ def get_package_status_extended(package: Package) -> dict[str, Any]:
return {"status": BuildStatus().view(), "package": package.view()}
def import_error(package: str, components: list[str], mocker: MockerFixture) -> MagicMock:
"""
mock import error
Args:
package(str): package name to import
components(list[str]): component to import if any (e.g. from ... import ...)
mocker(MockerFixture): mocker object
Returns:
MagicMock: mocked object
"""
import builtins
_import = builtins.__import__
# pylint: disable=redefined-builtin
def test_import(name: str, globals: Any, locals: Any, from_list: list[str], level: Any):
if name == package and (not components or any(component in from_list for component in components)):
raise ImportError
return _import(name, globals, locals, from_list, level)
return mocker.patch.object(builtins, "__import__", test_import)
@pytest.hookimpl(trylast=True)
def pytest_configure() -> None:
"""
register helpers after pytest-helpers-namespace has initialized
"""
for helper in (anyvar, get_package_status, get_package_status_extended, import_error):
for helper in (anyvar, get_package_status, get_package_status_extended):
pytest.helpers.register(helper)
+13 -11
View File
@@ -2,7 +2,6 @@ env_list = [
"check",
"tests",
]
isolated_build = true
requires = [
"tox-uv",
]
@@ -33,6 +32,17 @@ bandit = [
"--configfile",
".bandit.toml",
]
coverage_report = [
"--show-missing",
"--skip-covered",
"--fail-under=100",
]
coverage_run = [
"--source",
"ahriman",
"-m",
"pytest",
]
manpage = [
"--author",
"{[project]name} team",
@@ -274,15 +284,12 @@ deps = [
{ replace = "ref", of = ["project", "extras"], extend = true },
]
pip_pre = true
recreate = true
commands = [
[
"sphinx-build",
"--builder",
"html",
"--fail-on-warning",
"--jobs",
"1",
"--write-all",
"docs",
"{envtmpdir}/html",
@@ -352,10 +359,7 @@ commands = [
[
"coverage",
"run",
"--source",
"ahriman",
"-m",
"pytest",
{ replace = "ref", of = ["flags", "coverage_run"], extend = true },
{ replace = "posargs", default = [
"tests/test_tests.py",
"ahriman-core/tests",
@@ -366,9 +370,7 @@ commands = [
[
"coverage",
"report",
"--show-missing",
"--skip-covered",
"--fail-under=100",
{ replace = "ref", of = ["flags", "coverage_report"], extend = true },
],
]