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
This commit is contained in:
2026-07-22 16:33:59 +03:00
parent bd26e8da68
commit f37c5bd2a2
16 changed files with 83 additions and 83 deletions
@@ -21,6 +21,8 @@
from logging import NullHandler from logging import NullHandler
from typing import Any from typing import Any
from ahriman.core.module_loader import optional_module
__all__ = ["JournalHandler"] __all__ = ["JournalHandler"]
@@ -40,7 +42,5 @@ class _JournalHandler(NullHandler):
del args, kwargs del args, kwargs
try: systemd_journal = optional_module("systemd.journal")
from systemd.journal import JournalHandler # type: ignore[import-untyped] JournalHandler = systemd_journal.JournalHandler if systemd_journal else _JournalHandler
except ImportError:
JournalHandler = _JournalHandler
@@ -26,6 +26,7 @@ from typing import ClassVar, Literal
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.log.http_log_handler import HttpLogHandler from ahriman.core.log.http_log_handler import HttpLogHandler
from ahriman.core.log.log_context import LogContext 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.log_handler import LogHandler
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
@@ -65,14 +66,11 @@ class LogLoader:
if selected is not None: if selected is not None:
return selected return selected
try: if optional_module("systemd.journal"):
from systemd.journal import JournalHandler # type: ignore[import-untyped]
del JournalHandler
return LogHandler.Journald # journald import was found return LogHandler.Journald # journald import was found
except ImportError: if LogLoader.DEFAULT_SYSLOG_DEVICE.exists():
if LogLoader.DEFAULT_SYSLOG_DEVICE.exists(): return LogHandler.Syslog
return LogHandler.Syslog return LogHandler.Console
return LogHandler.Console
@staticmethod @staticmethod
def load(repository_id: RepositoryId, configuration: Configuration, handler: LogHandler, *, def load(repository_id: RepositoryId, configuration: Configuration, handler: LogHandler, *,
+17 -2
View File
@@ -26,8 +26,7 @@ from pkgutil import ModuleInfo, walk_packages
from types import ModuleType from types import ModuleType
from typing import Any, TypeGuard, TypeVar from typing import Any, TypeGuard, TypeVar
__all__ = ["implementations", "optional_module"]
__all__ = ["implementations"]
T = TypeVar("T") 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): for _, attribute in inspect.getmembers(module, is_base_class):
yield attribute 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 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 from ahriman.web.views.base import BaseView
@@ -23,3 +23,17 @@ def test_implementations() -> None:
assert routes assert routes
assert all(isinstance(view, type) for view in routes) assert all(isinstance(view, type) for view in routes)
assert all(issubclass(view, BaseView) 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 # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # 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 typing import Any
from ahriman.core.module_loader import optional_module
__all__ = [ __all__ = [
"authorized_userid", "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: async def authorized_userid(*args: Any, **kwargs: Any) -> Any:
""" """
handle aiohttp security methods handle aiohttp security methods
@@ -50,7 +46,7 @@ async def authorized_userid(*args: Any, **kwargs: Any) -> Any:
Returns: Returns:
Any: ``None`` in case if no aiohttp_security module found and function call otherwise 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 await aiohttp_security.authorized_userid(*args, **kwargs) # pylint: disable=no-value-for-parameter
return None return None
@@ -66,7 +62,7 @@ async def check_authorized(*args: Any, **kwargs: Any) -> Any:
Returns: Returns:
Any: ``None`` in case if no aiohttp_security module found and function call otherwise 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 await aiohttp_security.check_authorized(*args, **kwargs) # pylint: disable=no-value-for-parameter
return None return None
@@ -82,7 +78,7 @@ async def forget(*args: Any, **kwargs: Any) -> Any:
Returns: Returns:
Any: ``None`` in case if no aiohttp_security module found and function call otherwise 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 await aiohttp_security.forget(*args, **kwargs) # pylint: disable=no-value-for-parameter
return None return None
@@ -98,7 +94,7 @@ async def get_session(*args: Any, **kwargs: Any) -> Any:
Returns: Returns:
Any: empty dictionary in case if no aiohttp_session module found and function call otherwise 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 await aiohttp_session.get_session(*args, **kwargs)
return {} return {}
@@ -114,6 +110,6 @@ async def remember(*args: Any, **kwargs: Any) -> Any:
Returns: Returns:
Any: ``None`` in case if no aiohttp_security module found and function call otherwise 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 await aiohttp_security.remember(*args, **kwargs) # pylint: disable=no-value-for-parameter
return None return None
@@ -17,15 +17,20 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
try: from ahriman.core.module_loader import optional_module
import aiohttp_apispec # type: ignore[import-untyped]
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 from unittest.mock import Mock
Schema = Mock # type: ignore[misc] Schema = Mock # type: ignore[misc]
aiohttp_apispec = None
fields = Mock() fields = Mock()
@@ -115,7 +115,7 @@ def apidocs(*,
authorization_required = permission != UserAccess.Unauthorized authorization_required = permission != UserAccess.Unauthorized
def wrapper(handler: Callable[..., Any]) -> Callable[..., Any]: def wrapper(handler: Callable[..., Any]) -> Callable[..., Any]:
if aiohttp_apispec is None: if not aiohttp_apispec:
return handler # apispec is disabled return handler # apispec is disabled
responses = _response_schema( 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 setup swagger api specification
Args: Args:
application(Application): web application instance application(Application): web application instance
Returns:
Any: created specification instance if module is available
""" """
if aiohttp_apispec is None: if aiohttp_apispec is None:
return None return
return aiohttp_apispec.setup_aiohttp_apispec( aiohttp_apispec.setup_aiohttp_apispec(
application, application,
url="/api-docs/swagger.json", url="/api-docs/swagger.json",
openapi_version="3.0.2", openapi_version="3.0.2",
@@ -17,14 +17,10 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # 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.typedefs import Middleware
from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse, middleware from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse, middleware
from ahriman.core.module_loader import optional_module
from ahriman.web.middlewares import HandlerType from ahriman.web.middlewares import HandlerType
@@ -34,6 +30,9 @@ __all__ = [
] ]
aiohttp_openmetrics = optional_module("aiohttp_openmetrics")
async def metrics(request: Request) -> Response: async def metrics(request: Request) -> Response:
""" """
handler for returning metrics handler for returning metrics
@@ -47,7 +46,7 @@ async def metrics(request: Request) -> Response:
Raises: Raises:
HTTPNotFound: endpoint is disabled HTTPNotFound: endpoint is disabled
""" """
if aiohttp_openmetrics is None: if not aiohttp_openmetrics:
raise HTTPNotFound raise HTTPNotFound
return await aiohttp_openmetrics.metrics(request) return await aiohttp_openmetrics.metrics(request)
@@ -59,7 +58,7 @@ def metrics_handler() -> Middleware:
Returns: Returns:
Middleware: middleware function to handle server metrics Middleware: middleware function to handle server metrics
""" """
if aiohttp_openmetrics is not None: if aiohttp_openmetrics:
return aiohttp_openmetrics.metrics_middleware return aiohttp_openmetrics.metrics_middleware
@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), "username": await authorized_userid(view.request),
}, },
"autorefresh_intervals": sorted(autorefresh_intervals, key=comparator), "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), "index_url": view.configuration.get("web", "index_url", fallback=None),
"repositories": [ "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 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 and returns empty list otherwise
""" """
if aiohttp_apispec is None: if not aiohttp_apispec:
return [] return []
return cls.ROUTES 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 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 and returns empty list otherwise
""" """
if aiohttp_apispec is None: if not aiohttp_apispec:
return [] return []
return cls.ROUTES return cls.ROUTES
@@ -22,6 +22,7 @@ from secrets import token_urlsafe
from typing import ClassVar from typing import ClassVar
from ahriman.core.auth.helpers import get_session, remember 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.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs from ahriman.web.apispec.decorators import apidocs
from ahriman.web.schemas import LoginSchema, OAuth2Schema 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 HTTPMethodNotAllowed: in case if method is used, but OAuth is disabled
HTTPUnauthorized: if case of authorization error HTTPUnauthorized: if case of authorization error
""" """
try: oauth = optional_module("ahriman.core.auth.oauth")
from ahriman.core.auth.oauth import OAuth if not oauth:
except ImportError:
# no aioauth library found
raise HTTPMethodNotAllowed(self.request.method, ["POST"]) raise HTTPMethodNotAllowed(self.request.method, ["POST"])
oauth_provider = self.validator oauth_provider = self.validator
if not isinstance(oauth_provider, OAuth): if not isinstance(oauth_provider, oauth.OAuth):
raise HTTPMethodNotAllowed(self.request.method, ["POST"]) raise HTTPMethodNotAllowed(self.request.method, ["POST"])
session = await get_session(self.request) session = await get_session(self.request)
@@ -48,7 +48,8 @@ def test_setup_apispec(application: Application, mocker: MockerFixture) -> None:
must set api specification must set api specification
""" """
apispec_mock = mocker.patch("aiohttp_apispec.setup_aiohttp_apispec") apispec_mock = mocker.patch("aiohttp_apispec.setup_aiohttp_apispec")
assert setup_apispec(application)
setup_apispec(application)
apispec_mock.assert_called_once_with( apispec_mock.assert_called_once_with(
application, application,
url="/api-docs/swagger.json", 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 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") response = await client_with_auth.get("/api/v1/login")
assert response.status == 405 assert response.status == 405
@@ -117,36 +117,12 @@ def get_package_status_extended(package: Package) -> dict[str, Any]:
return {"status": BuildStatus().view(), "package": package.view()} 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) @pytest.hookimpl(trylast=True)
def pytest_configure() -> None: def pytest_configure() -> None:
""" """
register helpers after pytest-helpers-namespace has initialized 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) pytest.helpers.register(helper)