diff --git a/ahriman-core/src/ahriman/core/log/journal_handler.py b/ahriman-core/src/ahriman/core/log/journal_handler.py index d123ee06..0c1fcc91 100644 --- a/ahriman-core/src/ahriman/core/log/journal_handler.py +++ b/ahriman-core/src/ahriman/core/log/journal_handler.py @@ -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 diff --git a/ahriman-core/src/ahriman/core/log/log_loader.py b/ahriman-core/src/ahriman/core/log/log_loader.py index 6662c43a..9db4bc50 100644 --- a/ahriman-core/src/ahriman/core/log/log_loader.py +++ b/ahriman-core/src/ahriman/core/log/log_loader.py @@ -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,14 +66,11 @@ 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 + if LogLoader.DEFAULT_SYSLOG_DEVICE.exists(): + return LogHandler.Syslog + return LogHandler.Console @staticmethod def load(repository_id: RepositoryId, configuration: Configuration, handler: LogHandler, *, diff --git a/ahriman-core/src/ahriman/core/module_loader.py b/ahriman-core/src/ahriman/core/module_loader.py index ed66e71e..9cb72e93 100644 --- a/ahriman-core/src/ahriman/core/module_loader.py +++ b/ahriman-core/src/ahriman/core/module_loader.py @@ -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 diff --git a/ahriman-core/tests/ahriman/core/test_module_loader.py b/ahriman-core/tests/ahriman/core/test_module_loader.py index 72c34929..a1d47682 100644 --- a/ahriman-core/tests/ahriman/core/test_module_loader.py +++ b/ahriman-core/tests/ahriman/core/test_module_loader.py @@ -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 diff --git a/ahriman-web/src/ahriman/core/auth/helpers.py b/ahriman-web/src/ahriman/core/auth/helpers.py index 617f4404..6e91af2c 100644 --- a/ahriman-web/src/ahriman/core/auth/helpers.py +++ b/ahriman-web/src/ahriman/core/auth/helpers.py @@ -17,18 +17,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -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 diff --git a/ahriman-web/src/ahriman/web/apispec/__init__.py b/ahriman-web/src/ahriman/web/apispec/__init__.py index 1839b1f1..613e9001 100644 --- a/ahriman-web/src/ahriman/web/apispec/__init__.py +++ b/ahriman-web/src/ahriman/web/apispec/__init__.py @@ -17,15 +17,20 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -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() diff --git a/ahriman-web/src/ahriman/web/apispec/decorators.py b/ahriman-web/src/ahriman/web/apispec/decorators.py index adc9baaf..7c5a787f 100644 --- a/ahriman-web/src/ahriman/web/apispec/decorators.py +++ b/ahriman-web/src/ahriman/web/apispec/decorators.py @@ -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( diff --git a/ahriman-web/src/ahriman/web/apispec/info.py b/ahriman-web/src/ahriman/web/apispec/info.py index 4812d3fa..72374acd 100644 --- a/ahriman-web/src/ahriman/web/apispec/info.py +++ b/ahriman-web/src/ahriman/web/apispec/info.py @@ -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", diff --git a/ahriman-web/src/ahriman/web/middlewares/metrics_handler.py b/ahriman-web/src/ahriman/web/middlewares/metrics_handler.py index 1fba10d3..de29f123 100644 --- a/ahriman-web/src/ahriman/web/middlewares/metrics_handler.py +++ b/ahriman-web/src/ahriman/web/middlewares/metrics_handler.py @@ -17,14 +17,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -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 diff --git a/ahriman-web/src/ahriman/web/server_info.py b/ahriman-web/src/ahriman/web/server_info.py index 63677d03..7c08322b 100644 --- a/ahriman-web/src/ahriman/web/server_info.py +++ b/ahriman-web/src/ahriman/web/server_info.py @@ -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": [ { diff --git a/ahriman-web/src/ahriman/web/views/api/docs.py b/ahriman-web/src/ahriman/web/views/api/docs.py index fa873349..bac36094 100644 --- a/ahriman-web/src/ahriman/web/views/api/docs.py +++ b/ahriman-web/src/ahriman/web/views/api/docs.py @@ -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 diff --git a/ahriman-web/src/ahriman/web/views/api/swagger.py b/ahriman-web/src/ahriman/web/views/api/swagger.py index c1b91a5c..3599496e 100644 --- a/ahriman-web/src/ahriman/web/views/api/swagger.py +++ b/ahriman-web/src/ahriman/web/views/api/swagger.py @@ -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 diff --git a/ahriman-web/src/ahriman/web/views/v1/user/login.py b/ahriman-web/src/ahriman/web/views/v1/user/login.py index 47742306..feb693c5 100644 --- a/ahriman-web/src/ahriman/web/views/v1/user/login.py +++ b/ahriman-web/src/ahriman/web/views/v1/user/login.py @@ -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) diff --git a/ahriman-web/tests/ahriman/web/apispec/test_info.py b/ahriman-web/tests/ahriman/web/apispec/test_info.py index 5372ddaf..e4f3023c 100644 --- a/ahriman-web/tests/ahriman/web/apispec/test_info.py +++ b/ahriman-web/tests/ahriman/web/apispec/test_info.py @@ -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", diff --git a/ahriman-web/tests/ahriman/web/views/v1/user/test_view_v1_user_login.py b/ahriman-web/tests/ahriman/web/views/v1/user/test_view_v1_user_login.py index 5ff6d26d..d64dd02d 100644 --- a/ahriman-web/tests/ahriman/web/views/v1/user/test_view_v1_user_login.py +++ b/ahriman-web/tests/ahriman/web/views/v1/user/test_view_v1_user_login.py @@ -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 diff --git a/tools/pytest_plugins/ahriman_fixtures/__init__.py b/tools/pytest_plugins/ahriman_fixtures/__init__.py index 80d202f7..102d3734 100644 --- a/tools/pytest_plugins/ahriman_fixtures/__init__.py +++ b/tools/pytest_plugins/ahriman_fixtures/__init__.py @@ -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)