Skip to content

Commit 1900395

Browse files
Merge pull request #28 from mozilla/yaml-models
Using pydantic-yaml and validations through pydantic.validators.
2 parents 3ff2b79 + 0c59e68 commit 1900395

File tree

9 files changed

+314
-183
lines changed

9 files changed

+314
-183
lines changed

poetry.lock

Lines changed: 214 additions & 96 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,8 @@ prometheus-client = "^0.13.1"
1515
python-bugzilla = "^3.2.0"
1616
atlassian-python-api = "^3.20.1"
1717
dockerflow = "2022.1.0"
18-
PyYAML = "^6.0"
19-
types-PyYAML = "^6.0.4"
2018
Jinja2 = "^3.0.3"
19+
pydantic-yaml = {extras = ["pyyaml","ruamel"], version = "^0.6.1"}
2120

2221

2322
[tool.poetry.dev-dependencies]

src/app/monitor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from fastapi.responses import JSONResponse
88

99
from src.app import environment
10-
from src.jbi.services import jbi_service_health_map
10+
from src.jbi.service import jbi_service_health_map
1111

1212
api_router = APIRouter(tags=["Monitor"])
1313

src/jbi/configuration.py

Lines changed: 19 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,38 @@
1-
"""Parsing and validating the YAML configuration occurs within this module"""
2-
import importlib
1+
"""
2+
Parsing and validating the YAML configuration occurs within this module
3+
"""
34
import logging
4-
from inspect import signature
5-
from types import ModuleType
6-
from typing import Any, Dict, Optional
5+
from functools import lru_cache
76

8-
import yaml
9-
from yaml import Loader
7+
from pydantic import ValidationError
108

119
from src.app import environment
10+
from src.jbi.model import Actions
1211

1312
settings = environment.get_settings()
1413
jbi_logger = logging.getLogger("src.jbi")
1514

1615

1716
class ConfigError(Exception):
18-
"""Errors used to determine when the Configuration is invalid"""
17+
"""Error when an exception occurs during processing config"""
1918

2019

21-
class ProcessError(Exception):
22-
"""Error when an exception occurs during processing"""
23-
24-
25-
def get_yaml_configurations(
20+
@lru_cache
21+
def get_actions(
2622
jbi_config_file: str = f"config/config.{settings.env}.yaml",
27-
) -> Dict[str, Dict]:
28-
"""Convert and validate YAML configuration to python dict"""
23+
) -> Actions:
24+
"""Convert and validate YAML configuration to `Action` objects"""
2925

3026
with open(jbi_config_file, encoding="utf-8") as file:
3127
try:
32-
file_data = file.read()
33-
data = yaml.load(file_data, Loader)
34-
validated_action_dict = process_actions(
35-
action_configuration=data.get("actions")
36-
)
37-
return validated_action_dict
38-
except (ValueError, ConfigError, yaml.YAMLError) as exception:
28+
yaml_data = file.read()
29+
actions: Actions = Actions.parse_raw(yaml_data)
30+
return actions
31+
except ValidationError as exception:
3932
jbi_logger.exception(exception)
40-
raise ProcessError("Errors exist.") from exception
41-
42-
43-
def process_actions(action_configuration) -> Dict[str, Dict]:
44-
"""Validate `actions` section of the YAML config"""
45-
requested_actions = {}
46-
for yaml_action_key, inner_action_dict in action_configuration.items():
47-
inner_action_dict.setdefault("action", "src.jbi.whiteboard_actions.default")
48-
inner_action_dict.setdefault("enabled", False)
49-
inner_action_dict.setdefault("parameters", {})
50-
validate_action_yaml_jbi_naming(
51-
yaml_action_key=yaml_action_key, action_dict=inner_action_dict
52-
)
53-
validate_action_yaml_module(action_dict=inner_action_dict)
54-
requested_actions[yaml_action_key] = inner_action_dict
55-
return requested_actions
56-
57-
58-
def validate_action_yaml_jbi_naming(yaml_action_key, action_dict):
59-
"""Validate yaml_action_key == parameters.whiteboard_tag"""
60-
wb_tag = action_dict["parameters"].get("whiteboard_tag")
61-
if yaml_action_key != wb_tag:
62-
raise ConfigError(
63-
f"Expected action key '{wb_tag}', found `{yaml_action_key}."
64-
"(from the `parameters.whiteboard_tag` field)."
65-
)
66-
33+
raise ConfigError("Errors exist.") from exception
6734

68-
def validate_action_yaml_module(action_dict: Dict[str, Any]):
69-
"""Validate action: exists, has init function, and has expected params"""
70-
try:
71-
action: str = action_dict.get("action") # type: ignore
72-
action_parameters: Optional[Dict[str, Any]] = action_dict.get("parameters")
73-
action_module: ModuleType = importlib.import_module(action)
74-
if not action_module:
75-
raise TypeError("Module is not found.")
76-
if not hasattr(action_module, "init"):
77-
raise TypeError("Module is missing `init` method.")
7835

79-
signature(action_module.init).bind(**action_parameters) # type: ignore
80-
except ImportError as exception:
81-
raise ConfigError(f"Unknown action `{action}`.") from exception
82-
except (TypeError, AttributeError) as exception:
83-
raise ConfigError("Action is not properly setup.") from exception
36+
def get_actions_dict():
37+
"""Returns dict of `get_actions()`"""
38+
return get_actions().dict()["actions"]

src/jbi/model.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""
2+
Python Module for Pydantic Models and validation
3+
"""
4+
import importlib
5+
from inspect import signature
6+
from types import ModuleType
7+
from typing import Any, Dict, Optional
8+
9+
from pydantic import Extra, ValidationError, root_validator, validator
10+
from pydantic_yaml import YamlModel
11+
12+
13+
class Action(YamlModel, extra=Extra.allow):
14+
"""
15+
Action is the inner model for each action in the configuration file"""
16+
17+
action: str = "src.jbi.whiteboard_actions.default"
18+
enabled: bool = False
19+
parameters: dict = {}
20+
21+
@root_validator
22+
def validate_action_config(
23+
cls, values
24+
): # pylint: disable=no-self-argument, no-self-use
25+
"""Validate action: exists, has init function, and has expected params"""
26+
try:
27+
action: str = values["action"] # type: ignore
28+
action_parameters: Optional[Dict[str, Any]] = values["parameters"]
29+
action_module: ModuleType = importlib.import_module(action)
30+
if not action_module:
31+
raise TypeError("Module is not found.")
32+
if not hasattr(action_module, "init"):
33+
raise TypeError("Module is missing `init` method.")
34+
35+
signature(action_module.init).bind(**action_parameters) # type: ignore
36+
except ImportError as exception:
37+
raise ValidationError(f"unknown action `{action}`.") from exception
38+
except (TypeError, AttributeError) as exception:
39+
raise ValidationError("action is not properly setup.") from exception
40+
return values
41+
42+
43+
class Actions(YamlModel):
44+
"""
45+
Actions is the overall model for the list of `actions` in the configuration file
46+
"""
47+
48+
actions: Dict[str, Action]
49+
50+
@validator("actions")
51+
def validate_action_matches_whiteboard(
52+
cls, actions
53+
): # pylint: disable=no-self-argument, no-self-use
54+
"""
55+
Validate that the inner actions are named as expected
56+
"""
57+
if not actions:
58+
raise ValidationError("no actions configured")
59+
for name, action in actions.items():
60+
if name != action.parameters["whiteboard_tag"]:
61+
raise ValidationError("action name must match whiteboard tag")
62+
63+
return actions

src/jbi/router.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,39 +35,37 @@ def bugzilla_webhook(
3535
def get_whiteboard_tag(
3636
whiteboard_tag: Optional[str] = None,
3737
):
38-
"""API for viewing whiteboard_tags"""
39-
data = configuration.get_yaml_configurations()
38+
"""API for viewing whiteboard_tags and associated data"""
39+
actions = configuration.get_actions_dict()
4040
if whiteboard_tag:
41-
wb_val = data.get(whiteboard_tag)
41+
wb_val = actions.get(whiteboard_tag)
4242
if wb_val:
43-
data = wb_val
44-
return data
43+
actions = wb_val
44+
return actions
4545

4646

4747
@api_router.get("/actions/")
4848
def get_actions_by_type(action_type: Optional[str] = None):
49-
"""API for viewing actions"""
50-
configured_actions = configuration.get_yaml_configurations()
49+
"""API for viewing actions within the config; `action_type` matched on end of action identifier"""
50+
actions = configuration.get_actions_dict()
5151
if action_type:
5252
data = [
53-
a["action"]
54-
for a in configured_actions.values()
55-
if a["action"].endswith(action_type)
53+
a["action"] for a in actions.values() if a["action"].endswith(action_type)
5654
]
5755
else:
58-
data = [a["action"] for a in configured_actions.values()]
56+
data = [a["action"] for a in actions.values()]
5957
return data
6058

6159

6260
@api_router.get("/powered_by_jbi", response_class=HTMLResponse)
6361
def powered_by_jbi(request: Request, enabled: Optional[bool] = None):
6462
"""API for `Powered By` endpoint"""
65-
data = configuration.get_yaml_configurations()
63+
actions = configuration.get_actions_dict()
6664
context = {
6765
"request": request,
6866
"title": "Powered by JBI",
69-
"num_configs": len(data),
70-
"data": data,
67+
"num_configs": len(actions),
68+
"data": actions,
7169
"enable_query": enabled,
7270
}
7371
return templates.TemplateResponse("powered_by_template.html", context)
File renamed without changes.

src/templates/powered_by_template.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ <h1>{{title}}</h1>
2020
</thead>
2121
<tbody class="list">
2222
{% for key, value in data.items() %}
23-
{% if enable_query is none or value.enabled is enable_query %}
23+
{% if enable_query == none or value.enabled == enable_query %}
2424
<tr>
2525
<td class="identifier">{{key}}</td>
2626
<td class="action">{{value.action}}</td>

tests/unit/jbi/test_configuration.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,10 @@
88

99

1010
def test_mock_jbi_files():
11-
with pytest.raises(configuration.ProcessError):
12-
configuration.get_yaml_configurations(
13-
jbi_config_file="tests/unit/jbi/test-config.yaml"
14-
)
11+
with pytest.raises(configuration.ConfigError):
12+
configuration.get_actions(jbi_config_file="tests/unit/jbi/test-config.yaml")
1513

1614

1715
def test_actual_jbi_files():
18-
jbi_map = configuration.get_yaml_configurations()
16+
jbi_map = configuration.get_actions()
1917
assert jbi_map

0 commit comments

Comments
 (0)