Skip to content
Draft
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
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
32 changes: 30 additions & 2 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 @@ -19,10 +21,15 @@
YELLOW = "\033[93m"
RED = "\033[91m"

# ANSI color codes
LEFT_COLOR = "\033[34m" # Blue for class name
COLON_COLOR = "\033[33m" # Yellow for colon
RIGHT_COLOR = "\033[92m" # Milk green for validator


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 @@ -42,8 +49,29 @@ def main() -> None:
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"{COLON_COLOR}Current Iteration Number is {RESET}{RIGHT_COLOR}"
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"{LEFT_COLOR}{cls_name}{RESET}{COLON_COLOR}:{RESET}{RIGHT_COLOR}{first}{RESET}\n"
sys.stdout.write(colored)
# Rest methods indented
for method_name in methods[1:]:
colored = f"{' ' * len(cls_name)}{COLON_COLOR}:{RESET}{RIGHT_COLOR}{method_name}{RESET}\n"
sys.stdout.write(colored)

except ValidationError as e:
clean = refine_error(e)
Expand Down
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