diff --git a/src/app/api.py b/src/app/api.py index 25ee7d09..21da2df5 100644 --- a/src/app/api.py +++ b/src/app/api.py @@ -10,6 +10,7 @@ import sentry_sdk import uvicorn # type: ignore from fastapi import Body, Depends, FastAPI, Request +from fastapi.encoders import jsonable_encoder from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates @@ -109,10 +110,8 @@ def get_whiteboard_tags( ): """API for viewing whiteboard_tags and associated data""" if existing := actions.get(whiteboard_tag): - filtered = {whiteboard_tag: existing} - else: - filtered = actions.by_tag # type: ignore - return {k: v.dict(exclude={"callable"}) for k, v in filtered.items()} + return {whiteboard_tag: existing} + return actions.by_tag @app.get("/jira_projects/") @@ -132,7 +131,7 @@ def powered_by_jbi( context = { "request": request, "title": "Powered by JBI", - "actions": [action.dict(exclude={"callable"}) for action in actions], + "actions": jsonable_encoder(actions), "enable_query": enabled, } return templates.TemplateResponse("powered_by_template.html", context) diff --git a/src/jbi/models.py b/src/jbi/models.py index 11186609..4724d419 100644 --- a/src/jbi/models.py +++ b/src/jbi/models.py @@ -8,7 +8,7 @@ from types import ModuleType from typing import Any, Callable, Dict, List, Literal, Mapping, Optional, Set, Union -from pydantic import EmailStr, Extra, Field, root_validator, validator +from pydantic import EmailStr, Field, PrivateAttr, root_validator, validator from pydantic_yaml import YamlModel @@ -24,13 +24,16 @@ class Action(YamlModel): enabled: bool = False allow_private: bool = False parameters: dict = {} + _caller: Callable = PrivateAttr(default=None) - @functools.cached_property - def callable(self) -> Callable: + @property + def caller(self) -> Callable: """Return the initialized callable for this action.""" - action_module: ModuleType = importlib.import_module(self.module) - initialized: Callable = action_module.init(**self.parameters) # type: ignore - return initialized + if not self._caller: + action_module: ModuleType = importlib.import_module(self.module) + initialized: Callable = action_module.init(**self.parameters) # type: ignore + self._caller = initialized + return self._caller @root_validator def validate_action_config(cls, values): # pylint: disable=no-self-argument @@ -51,13 +54,6 @@ def validate_action_config(cls, values): # pylint: disable=no-self-argument raise ValueError(f"action is not properly setup.{exception}") from exception return values - class Config: - """Pydantic configuration""" - - extra = Extra.allow - keep_untouched = (functools.cached_property,) - fields = {"callable": {"exclude": True}} - class Actions(YamlModel): """ diff --git a/src/jbi/runner.py b/src/jbi/runner.py index 6440b074..1617ea16 100644 --- a/src/jbi/runner.py +++ b/src/jbi/runner.py @@ -77,7 +77,7 @@ def execute_action( extra={"operation": Operations.EXECUTE, **log_context}, ) - content = action.callable(payload=request) + content = action.caller(payload=request) logger.info( "Action %r executed successfully for Bug %s", diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 64d8634a..0e32d819 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -8,9 +8,8 @@ from src.app.api import app from src.app.environment import Settings -from src.jbi.bugzilla import BugzillaBug, BugzillaWebhookComment, BugzillaWebhookRequest -from src.jbi.models import Actions -from src.jbi.services import get_bugzilla +from src.jbi.bugzilla import BugzillaWebhookComment, BugzillaWebhookRequest +from src.jbi.models import Action, Actions @pytest.fixture @@ -25,13 +24,13 @@ def settings(): return Settings() -@pytest.fixture +@pytest.fixture(autouse=True) def mocked_bugzilla(): with mock.patch("src.jbi.services.rh_bugzilla.Bugzilla") as mocked_bz: yield mocked_bz -@pytest.fixture +@pytest.fixture(autouse=True) def mocked_jira(): with mock.patch("src.jbi.services.Jira") as mocked_jira: yield mocked_jira @@ -167,14 +166,17 @@ def webhook_modify_private_example( @pytest.fixture -def actions_example() -> Actions: - return Actions.parse_obj( - [ - { - "whiteboard_tag": "devtest", - "contact": "contact@corp.com", - "description": "test config", - "module": "tests.unit.jbi.noop_action", - } - ] +def action_example() -> Action: + return Action.parse_obj( + { + "whiteboard_tag": "devtest", + "contact": "contact@corp.com", + "description": "test config", + "module": "tests.unit.jbi.noop_action", + } ) + + +@pytest.fixture +def actions_example(action_example) -> Actions: + return Actions.parse_obj([action_example]) diff --git a/tests/unit/jbi/bugzilla_action.py b/tests/unit/jbi/bugzilla_action.py new file mode 100644 index 00000000..1421f564 --- /dev/null +++ b/tests/unit/jbi/bugzilla_action.py @@ -0,0 +1,16 @@ +from bugzilla import Bugzilla +from requests import Session + +session = Session() + + +class FakeBugzillaAction: + def __init__(self, **params): + self.bz = Bugzilla(url=None, requests_session=session) + + def __call__(self, payload): + return {"payload": payload} + + +def init(): + return FakeBugzillaAction() diff --git a/tests/unit/jbi/test_models.py b/tests/unit/jbi/test_models.py new file mode 100644 index 00000000..1f5c3ffa --- /dev/null +++ b/tests/unit/jbi/test_models.py @@ -0,0 +1,21 @@ +from unittest.mock import patch + +from fastapi.encoders import jsonable_encoder + +from src.jbi.models import Action + + +def test_model_serializes(): + """Regression test to assert that action with initialzed Bugzilla client serializes""" + action = Action.parse_obj( + { + "whiteboard_tag": "devtest", + "contact": "person@example.com", + "description": "test config", + "module": "tests.unit.jbi.bugzilla_action", + } + ) + action.caller(payload=action) + serialized_action = jsonable_encoder(action) + assert not serialized_action.get("_caller") + assert not serialized_action.get("caller")