Skip to content
Closed
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
32 changes: 22 additions & 10 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -187,18 +187,28 @@ <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>');
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\[35m/g, '<span style="color:#800080;font-weight:bold">') // magenta
.replace(/\x1b\[36m/g, '<span style="color:#008080;font-weight:bold">') // cyan
.replace(/\x1b\[94m/g, '<span style="color:#1E90FF;font-weight:bold">') // light blue
.replace(/\x1b\[95m/g, '<span style="color:#EE82EE;font-weight:bold">') // light magenta
.replace(/\x1b\[96m/g, '<span style="color:#20B2AA;font-weight:bold">') // light cyan
.replace(/\x1b\[37m/g, '<span style="color:#CCCCCC;font-weight:bold">') // white/light grey
.replace(/\x1b\[33m/g, '<span class="ansi-yellow">') // colon yellow
.replace(/\x1b\[0m/g, '</span>'); // reset

output.innerHTML += cleanText + "\n";
output.scrollTop = output.scrollHeight;
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 = ""; } // Changed to innerHTML for spans

Expand All @@ -217,6 +227,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
39 changes: 31 additions & 8 deletions src/eligibility_signposting_api/model/campaign_config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import re
import typing
from collections import Counter
from datetime import UTC, date, datetime
Expand Down Expand Up @@ -268,24 +269,35 @@ 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:
if isinstance(v, date):
return v
return datetime.strptime(v, "%Y%m%d").date() # noqa: DTZ007

v_str = str(v)

if not re.fullmatch(r"\d{8}", v_str):
msg = f"Invalid format: {v_str}. Must be YYYYMMDD with 8 digits."
raise ValueError(msg)

try:
return datetime.strptime(v_str, "%Y%m%d").date() # noqa: DTZ007
except ValueError as err:
msg = f"Invalid date value: {v_str}. Must be a valid calendar date in YYYYMMDD format."
raise ValueError(msg) from err

@field_serializer("iteration_date", when_used="always")
@staticmethod
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 Expand Up @@ -316,7 +328,18 @@ class CampaignConfig(BaseModel):
def parse_dates(cls, v: str | date) -> date:
if isinstance(v, date):
return v
return datetime.strptime(v, "%Y%m%d").date() # noqa: DTZ007

v_str = str(v)

if not re.fullmatch(r"\d{8}", v_str):
msg = f"Invalid format: {v_str}. Must be YYYYMMDD with 8 digits."
raise ValueError(msg)

try:
return datetime.strptime(v_str, "%Y%m%d").date() # noqa: DTZ007
except ValueError as err:
msg = f"Invalid date value: {v_str}. Must be a valid calendar date in YYYYMMDD format."
raise ValueError(msg) from err

@field_serializer("start_date", "end_date", when_used="always")
@staticmethod
Expand Down
53 changes: 46 additions & 7 deletions src/rules_validation_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,38 @@
import json
import logging
import sys
from collections import defaultdict
from pathlib import Path

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

logging.basicConfig(
level=logging.INFO, # or DEBUG for more detail
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
force=True,
)

GREEN = "\033[92m" # pragma: no cover
RESET = "\033[0m" # pragma: no cover
YELLOW = "\033[93m" # pragma: no cover
RED = "\033[91m" # pragma: no cover
GREEN = "\033[92m"
RESET = "\033[0m"
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
CLASS_COLORS = [
"\033[34m", # blue
"\033[35m", # magenta
"\033[36m", # cyan
"\033[94m", # light blue
"\033[95m", # light magenta
"\033[96m", # light cyan
"\033[37m", # white/light grey
]

def main() -> None: # pragma: no cover
def main() -> None:
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()
Expand All @@ -28,9 +43,33 @@ def main() -> None: # pragma: no cover
json_data = json.load(file)
RulesValidation(**json_data)
sys.stdout.write(f"{GREEN}Valid Config{RESET}\n")

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

# Assign colors to classes
cls_color_map = {}
for i, cls_name in enumerate(sorted(grouped.keys(), reverse=True)):
cls_color_map[cls_name] = CLASS_COLORS[i % len(CLASS_COLORS)]

# 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"{cls_color_map[cls_name]}{cls_name}{RESET}{COLON_COLOR}:{RESET}{RIGHT_COLOR}{first}{RESET}"
print(colored)
# Rest methods indented
for method_name in methods[1:]:
colored = f"{' ' * len(cls_name)}{COLON_COLOR}:{RESET}{RIGHT_COLOR}{method_name}{RESET}"
print(colored)

except ValueError as e:
sys.stderr.write(f"{YELLOW}Validation Error:{RESET} {RED}{e}{RESET}\n")


if __name__ == "__main__": # pragma: no cover
if __name__ == "__main__":
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), {})
Loading