Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions index.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta charset="UTF-8" />
<title>Campaign Config Validator - NHS Digital</title>
<script src="https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js"></script>
<style>
Expand Down Expand Up @@ -216,18 +216,22 @@ <h3>Visualiser Output</h3>
const jsonInput = document.getElementById("jsonfile");
const runBtn = document.getElementById("run");

function log(text) {
let cleanText = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// Handle ANSI Colors for Pydantic output
cleanText = cleanText
.replace(/\x1b\[92m/g, '<span class="ansi-green">')
.replace(/\x1b\[93m/g, '<span class="ansi-yellow">')
.replace(/\x1b\[91m/g, '<span class="ansi-red">')
.replace(/\x1b\[0m/g, '</span>');

output.innerHTML += cleanText + "\n";
output.scrollTop = output.scrollHeight;
function log(text) {
let cleanText = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// ANSI color replacements
cleanText = cleanText
.replace(/\x1b\[92m/g, '<span class="ansi-grey">') // validator/method green
.replace(/\x1b\[93m/g, '<span class="ansi-yellow">') // general yellow
.replace(/\x1b\[34m/g, '<span style="color:#005eb8;font-weight:bold">') // blue
.replace(/\x1b\[33m/g, '<span class="ansi-yellow">') // colon yellow
.replace(/\x1b\[0m/g, '</span>'); // reset

if (cleanText.includes("Valid Config")) {
cleanText = cleanText.replace(/Valid Config/g, '<span style="font-size:2em;font-weight:bold;color:#007f3b">Valid Config</span>');
}
output.innerHTML += cleanText + "\n";
output.scrollTop = output.scrollHeight;
}

function clearLog() {
output.innerHTML = "";
Expand All @@ -248,6 +252,8 @@ <h3>Visualiser Output</h3>
"src/eligibility_signposting_api/model/campaign_config.py",
"src/eligibility_signposting_api/config/__init__.py",
"src/eligibility_signposting_api/config/constants.py",
"src/rules_validation_api/decorators/__init__.py",
"src/rules_validation_api/decorators/tracker.py",
"src/rules_validation_api/__init__.py",
"src/rules_validation_api/validators/__init__.py",
"src/rules_validation_api/validators/rules_validator.py",
Expand Down
7 changes: 6 additions & 1 deletion src/eligibility_signposting_api/logging/logs_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any] | None


class EnrichedJsonFormatter(JsonFormatter):
def add_fields(self, log_data: dict[str, Any], record: logging.LogRecord, message_dict: dict[str, Any]) -> None:
def add_fields(
self,
log_data: dict[str, Any],
record: logging.LogRecord,
message_dict: dict[str, Any],
) -> None:
log_data["request_id"] = request_id_context_var.get() or "-"
super().add_fields(log_data, record, message_dict)

Expand Down
14 changes: 7 additions & 7 deletions src/eligibility_signposting_api/model/campaign_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ class StatusText(BaseModel):
not_actionable: str | None = Field(None, alias="NotActionable")
actionable: str | None = Field(None, alias="Actionable")

model_config = {"populate_by_name": True}
model_config = {"populate_by_name": True, "extra": "ignore"}


class RuleEntry(BaseModel):
Expand Down Expand Up @@ -277,6 +277,12 @@ class Iteration(BaseModel):

model_config = {"populate_by_name": True, "arbitrary_types_allowed": True, "extra": "ignore"}

def __init__(self, **data: dict[str, typing.Any]) -> None:
super().__init__(**data)
# Ensure each rule knows its parent iteration
for rule in self.iteration_rules:
rule.set_parent(self)

@field_validator("iteration_date", mode="before")
@classmethod
def parse_dates(cls, v: str | date) -> date:
Expand All @@ -300,12 +306,6 @@ def parse_dates(cls, v: str | date) -> date:
def serialize_dates(v: date, _info: SerializationInfo) -> str:
return v.strftime("%Y%m%d")

@model_validator(mode="after")
def attach_rule_parents(self) -> Iteration:
for rule in self.iteration_rules:
rule.set_parent(self)
return self

def __str__(self) -> str:
return json.dumps(self.model_dump(by_alias=True), indent=2)

Expand Down
33 changes: 29 additions & 4 deletions src/rules_validation_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
import json
import logging
import sys
from collections import defaultdict
from pathlib import Path

from pydantic import ValidationError

from rules_validation_api.decorators.tracker import VALIDATORS_CALLED
from rules_validation_api.validators.rules_validator import RulesValidation

logging.basicConfig(
Expand All @@ -18,11 +20,12 @@
RESET = "\033[0m"
YELLOW = "\033[93m"
RED = "\033[91m"
BLUE = "\033[34m"


def refine_error(e: ValidationError) -> str:
"""Return a very short, single-line error message."""
lines = [f"Validation Error: {len(e.errors())} validation error(s)"]
lines = [f"Validation Error: {len(e.errors())} validation error(s)"]

for err in e.errors():
loc = ".".join(str(x) for x in err["loc"])
Expand All @@ -34,21 +37,43 @@ def refine_error(e: ValidationError) -> str:
return "\n".join(lines)


def main() -> None:
def main() -> None: # pragma: no cover
parser = argparse.ArgumentParser(description="Validate campaign configuration.")
parser.add_argument("--config_path", required=True, help="Path to the campaign config JSON file")
args = parser.parse_args()

try:
with Path(args.config_path).open() as file:
json_data = json.load(file)
RulesValidation(**json_data)
result = RulesValidation(**json_data)
sys.stdout.write(f"{GREEN}Valid Config{RESET}\n")
sys.stdout.write(
f"{YELLOW}Current Iteration Number is {RESET}{GREEN}"
f"{result.campaign_config.current_iteration.iteration_number}{RESET}\n"
)

# Group by class
grouped = defaultdict(list)
for v in VALIDATORS_CALLED:
cls, method = v.split(":", 1)
grouped[cls].append(method.strip())

# Print grouped
for cls_name in sorted(grouped.keys(), reverse=True):
methods = sorted(grouped[cls_name])
# First method prints class name
first = methods[0]
colored = f"{BLUE}{cls_name}{RESET}{YELLOW}:{RESET}{GREEN}{first}{RESET}\n"
sys.stdout.write(colored)
# Rest methods indented
for method_name in methods[1:]:
colored = f"{' ' * len(cls_name)}{YELLOW}:{RESET}{GREEN}{method_name}{RESET}\n"
sys.stdout.write(colored)

except ValidationError as e:
clean = refine_error(e)
sys.stderr.write(f"{YELLOW}{clean}{RESET}\n")


if __name__ == "__main__":
if __name__ == "__main__": # pragma: no cover
main()
Empty file.
28 changes: 28 additions & 0 deletions src/rules_validation_api/decorators/tracker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from typing import Self

from pydantic import model_validator

VALIDATORS_CALLED: list[str] = []


# --- Mixin and decorator to track validators ---
class TrackValidatorsMixin:
"""
Mixin to track all validator names in a Pydantic model.
"""

@model_validator(mode="after")
def _track_validators(self) -> Self:
for name in dir(self):
if name.startswith(("validate_", "check_")) and callable(getattr(self, name)):
full_name = f"{self.__class__.__name__}:{name}"
if full_name not in VALIDATORS_CALLED:
VALIDATORS_CALLED.append(full_name)
return self


def track_validators(cls) -> type: # noqa:ANN001
"""
Decorator to add the tracking mixin to a Pydantic model.
"""
return type(cls.__name__, (TrackValidatorsMixin, cls), {})
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from pydantic import ValidationError, model_validator

from eligibility_signposting_api.model.campaign_config import ActionsMapper
from rules_validation_api.decorators.tracker import track_validators
from rules_validation_api.validators.available_action_validator import AvailableActionValidation


@track_validators
class ActionsMapperValidation(ActionsMapper):
@model_validator(mode="after")
def validate_keys(self) -> "ActionsMapperValidation":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
from pydantic import field_validator

from eligibility_signposting_api.model.campaign_config import AvailableAction
from rules_validation_api.decorators.tracker import track_validators


@track_validators
class AvailableActionValidation(AvailableAction):
@field_validator("action_description")
@classmethod
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
from pydantic import field_validator, model_validator

from eligibility_signposting_api.model.campaign_config import CampaignConfig, Iteration
from rules_validation_api.decorators.tracker import track_validators
from rules_validation_api.validators.iteration_validator import IterationValidation


@track_validators
class CampaignConfigValidation(CampaignConfig):
@field_validator("iterations")
@classmethod
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from eligibility_signposting_api.model.campaign_config import IterationCohort
from rules_validation_api.decorators.tracker import track_validators


@track_validators
class IterationCohortValidation(IterationCohort):
pass
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
RuleAttributeName,
RuleType,
)
from rules_validation_api.decorators.tracker import track_validators


@track_validators
class IterationRuleValidation(IterationRule):
@field_validator("attribute_target")
@classmethod
Expand Down
2 changes: 2 additions & 0 deletions src/rules_validation_api/validators/iteration_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@
IterationRule,
RuleType,
)
from rules_validation_api.decorators.tracker import track_validators
from rules_validation_api.validators.actions_mapper_validator import ActionsMapperValidation
from rules_validation_api.validators.available_action_validator import AvailableActionValidation
from rules_validation_api.validators.iteration_cohort_validator import IterationCohortValidation
from rules_validation_api.validators.iteration_rules_validator import IterationRuleValidation


@track_validators
class IterationValidation(Iteration):
@field_validator("iteration_rules")
@classmethod
Expand Down
2 changes: 2 additions & 0 deletions src/rules_validation_api/validators/rules_validator.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from pydantic import field_validator

from eligibility_signposting_api.model.campaign_config import CampaignConfig, Rules
from rules_validation_api.decorators.tracker import track_validators
from rules_validation_api.validators.campaign_config_validator import CampaignConfigValidation


@track_validators
class RulesValidation(Rules):
@field_validator("campaign_config")
@classmethod
Expand Down
72 changes: 72 additions & 0 deletions tests/unit/validation/test_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from pydantic import BaseModel, ValidationError

from rules_validation_api.app import refine_error


def _raise_validation_error(model_cls, **kwargs) -> ValidationError:
try:
model_cls(**kwargs)
except ValidationError as e:
return e
msg = "ValidationError was not raised"
raise AssertionError(msg)


def test_refine_error_single_error():
class Model(BaseModel):
x: int

error = _raise_validation_error(Model, x="not-an-int")

result = refine_error(error)

assert "Validation Error: 1 validation error(s)" in result
assert "x" in result
assert "type=" in result


def test_refine_error_multiple_errors():
class Model(BaseModel):
a: int
b: int

error = _raise_validation_error(Model, a="bad", b="also-bad")

result = refine_error(error)

assert "Validation Error: 2 validation error(s)" in result
assert "a" in result
assert "b" in result
expected_error_count = 2
assert result.count("type=") == expected_error_count


def test_refine_error_nested_location():
class Inner(BaseModel):
value: int

class Outer(BaseModel):
inner: Inner

error = _raise_validation_error(Outer, inner={"value": "bad"})

result = refine_error(error)

assert "inner.value" in result
assert "type=" in result


def test_refine_error_output_structure():
class Model(BaseModel):
x: int
y: int

error = _raise_validation_error(Model, x="bad", y="bad")

result = refine_error(error)

lines = result.splitlines()

expected_no_lines = 3
assert len(lines) == expected_no_lines
assert lines[0].startswith("❌Validation Error:")
Loading