Skip to content

Commit b681755

Browse files
authored
Move initialized action callable to private field (#145)
* Move initialized callable to private field When we included the initialized callable in a public model field, we ran into a bug where `jsonable_encoder` couldn't serialzie an action with an initalized `requests.Session` object. In this commit, we move the initialized caller to a private field so that it's excluded during serialization. * Remove now-irrelevant Config from Action model * Improve fake Bugzilla action object - change name - caller takes and returns a payload * Make action caller a property Pydantic does not include properties in the serialized object, which is exactly what we need in this case. This commit also changes the callable name to `caller` so we avoid mirroring the built-in `callable` function
1 parent c158de9 commit b681755

File tree

6 files changed

+68
-34
lines changed

6 files changed

+68
-34
lines changed

src/app/api.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import sentry_sdk
1111
import uvicorn # type: ignore
1212
from fastapi import Body, Depends, FastAPI, Request
13+
from fastapi.encoders import jsonable_encoder
1314
from fastapi.responses import HTMLResponse
1415
from fastapi.staticfiles import StaticFiles
1516
from fastapi.templating import Jinja2Templates
@@ -109,10 +110,8 @@ def get_whiteboard_tags(
109110
):
110111
"""API for viewing whiteboard_tags and associated data"""
111112
if existing := actions.get(whiteboard_tag):
112-
filtered = {whiteboard_tag: existing}
113-
else:
114-
filtered = actions.by_tag # type: ignore
115-
return {k: v.dict(exclude={"callable"}) for k, v in filtered.items()}
113+
return {whiteboard_tag: existing}
114+
return actions.by_tag
116115

117116

118117
@app.get("/jira_projects/")
@@ -132,7 +131,7 @@ def powered_by_jbi(
132131
context = {
133132
"request": request,
134133
"title": "Powered by JBI",
135-
"actions": [action.dict(exclude={"callable"}) for action in actions],
134+
"actions": jsonable_encoder(actions),
136135
"enable_query": enabled,
137136
}
138137
return templates.TemplateResponse("powered_by_template.html", context)

src/jbi/models.py

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from types import ModuleType
99
from typing import Any, Callable, Dict, List, Literal, Mapping, Optional, Set, Union
1010

11-
from pydantic import EmailStr, Extra, Field, root_validator, validator
11+
from pydantic import EmailStr, Field, PrivateAttr, root_validator, validator
1212
from pydantic_yaml import YamlModel
1313

1414

@@ -24,13 +24,16 @@ class Action(YamlModel):
2424
enabled: bool = False
2525
allow_private: bool = False
2626
parameters: dict = {}
27+
_caller: Callable = PrivateAttr(default=None)
2728

28-
@functools.cached_property
29-
def callable(self) -> Callable:
29+
@property
30+
def caller(self) -> Callable:
3031
"""Return the initialized callable for this action."""
31-
action_module: ModuleType = importlib.import_module(self.module)
32-
initialized: Callable = action_module.init(**self.parameters) # type: ignore
33-
return initialized
32+
if not self._caller:
33+
action_module: ModuleType = importlib.import_module(self.module)
34+
initialized: Callable = action_module.init(**self.parameters) # type: ignore
35+
self._caller = initialized
36+
return self._caller
3437

3538
@root_validator
3639
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
5154
raise ValueError(f"action is not properly setup.{exception}") from exception
5255
return values
5356

54-
class Config:
55-
"""Pydantic configuration"""
56-
57-
extra = Extra.allow
58-
keep_untouched = (functools.cached_property,)
59-
fields = {"callable": {"exclude": True}}
60-
6157

6258
class Actions(YamlModel):
6359
"""

src/jbi/runner.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def execute_action(
7777
extra={"operation": Operations.EXECUTE, **log_context},
7878
)
7979

80-
content = action.callable(payload=request)
80+
content = action.caller(payload=request)
8181

8282
logger.info(
8383
"Action %r executed successfully for Bug %s",

tests/unit/conftest.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@
88

99
from src.app.api import app
1010
from src.app.environment import Settings
11-
from src.jbi.bugzilla import BugzillaBug, BugzillaWebhookComment, BugzillaWebhookRequest
12-
from src.jbi.models import Actions
13-
from src.jbi.services import get_bugzilla
11+
from src.jbi.bugzilla import BugzillaWebhookComment, BugzillaWebhookRequest
12+
from src.jbi.models import Action, Actions
1413

1514

1615
@pytest.fixture
@@ -25,13 +24,13 @@ def settings():
2524
return Settings()
2625

2726

28-
@pytest.fixture
27+
@pytest.fixture(autouse=True)
2928
def mocked_bugzilla():
3029
with mock.patch("src.jbi.services.rh_bugzilla.Bugzilla") as mocked_bz:
3130
yield mocked_bz
3231

3332

34-
@pytest.fixture
33+
@pytest.fixture(autouse=True)
3534
def mocked_jira():
3635
with mock.patch("src.jbi.services.Jira") as mocked_jira:
3736
yield mocked_jira
@@ -167,14 +166,17 @@ def webhook_modify_private_example(
167166

168167

169168
@pytest.fixture
170-
def actions_example() -> Actions:
171-
return Actions.parse_obj(
172-
[
173-
{
174-
"whiteboard_tag": "devtest",
175-
"contact": "[email protected]",
176-
"description": "test config",
177-
"module": "tests.unit.jbi.noop_action",
178-
}
179-
]
169+
def action_example() -> Action:
170+
return Action.parse_obj(
171+
{
172+
"whiteboard_tag": "devtest",
173+
"contact": "[email protected]",
174+
"description": "test config",
175+
"module": "tests.unit.jbi.noop_action",
176+
}
180177
)
178+
179+
180+
@pytest.fixture
181+
def actions_example(action_example) -> Actions:
182+
return Actions.parse_obj([action_example])

tests/unit/jbi/bugzilla_action.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from bugzilla import Bugzilla
2+
from requests import Session
3+
4+
session = Session()
5+
6+
7+
class FakeBugzillaAction:
8+
def __init__(self, **params):
9+
self.bz = Bugzilla(url=None, requests_session=session)
10+
11+
def __call__(self, payload):
12+
return {"payload": payload}
13+
14+
15+
def init():
16+
return FakeBugzillaAction()

tests/unit/jbi/test_models.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from unittest.mock import patch
2+
3+
from fastapi.encoders import jsonable_encoder
4+
5+
from src.jbi.models import Action
6+
7+
8+
def test_model_serializes():
9+
"""Regression test to assert that action with initialzed Bugzilla client serializes"""
10+
action = Action.parse_obj(
11+
{
12+
"whiteboard_tag": "devtest",
13+
"contact": "[email protected]",
14+
"description": "test config",
15+
"module": "tests.unit.jbi.bugzilla_action",
16+
}
17+
)
18+
action.caller(payload=action)
19+
serialized_action = jsonable_encoder(action)
20+
assert not serialized_action.get("_caller")
21+
assert not serialized_action.get("caller")

0 commit comments

Comments
 (0)