Skip to content

Commit 0974388

Browse files
committed
Improve Step validation, added unit test for the input
1 parent 1a64663 commit 0974388

6 files changed

Lines changed: 374 additions & 24 deletions

File tree

src/app/config/constants.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ class Distribution(StrEnum):
6969
# are accepted by the Pydantic schema.
7070

7171

72-
class EndpointIO(StrEnum):
72+
class EndpointStepIO(StrEnum):
7373
"""
7474
I/O-bound operation categories that can occur inside an endpoint *step*.
7575
@@ -100,7 +100,7 @@ class EndpointIO(StrEnum):
100100
CACHE = "io_cache"
101101

102102

103-
class EndpointCPU(StrEnum):
103+
class EndpointStepCPU(StrEnum):
104104
"""
105105
CPU-bound operation categories inside an endpoint step.
106106
@@ -112,7 +112,7 @@ class EndpointCPU(StrEnum):
112112
CPU_BOUND_OPERATION = "cpu_bound_operation"
113113

114114

115-
class EndpointRAM(StrEnum):
115+
class EndpointStepRAM(StrEnum):
116116
"""
117117
Memory-related operations inside a step.
118118
@@ -123,7 +123,7 @@ class EndpointRAM(StrEnum):
123123
RAM = "ram"
124124

125125

126-
class MetricKeys(StrEnum):
126+
class Metrics(StrEnum):
127127
"""
128128
Keys used inside the ``metrics`` dictionary of a *step*.
129129
@@ -137,6 +137,7 @@ class MetricKeys(StrEnum):
137137

138138
NETWORK_LATENCY = "network_latency"
139139
CPU_TIME = "cpu_time"
140+
IO_WAITING_TIME = "io_waiting_time"
140141
NECESSARY_RAM = "necessary_ram"
141142

142143
# ======================================================================

src/app/schemas/system_topology_schema/endpoint_schema.py

Lines changed: 72 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,86 @@
11
"""Defining the input schema for the requests handler"""
22

3-
from pydantic import BaseModel, PositiveFloat, PositiveInt, field_validator
3+
from pydantic import (
4+
BaseModel,
5+
PositiveFloat,
6+
PositiveInt,
7+
field_validator,
8+
model_validator,
9+
)
410

5-
from app.config.constants import EndpointCPU, EndpointIO, EndpointRAM, MetricKeys
11+
from app.config.constants import (
12+
EndpointStepCPU,
13+
EndpointStepIO,
14+
EndpointStepRAM,
15+
Metrics,
16+
)
617

718

819
class Step(BaseModel):
9-
"""Full step structure to be validated with pydantic"""
20+
"""
21+
Steps to be executed inside an endpoint in terms of
22+
the resources needed to accomplish the single step
23+
"""
1024

11-
# TODO ADD validation to couple kind and metrics only when they are
12-
# valid, for example ram cannot have cpu time and so on
25+
kind: EndpointStepIO | EndpointStepCPU | EndpointStepRAM
26+
step_metrics: dict[Metrics, PositiveFloat | PositiveInt]
1327

14-
kind: EndpointIO | EndpointCPU | EndpointRAM
15-
metrics: dict[MetricKeys, PositiveFloat | PositiveInt]
28+
@field_validator("step_metrics", mode="before")
29+
def ensure_non_empty(
30+
cls, # noqa: N805
31+
v: dict[Metrics, PositiveFloat | PositiveInt],
32+
) -> dict[Metrics, PositiveFloat | PositiveInt]:
33+
"""Ensure the dict step metrics exist"""
34+
if not v:
35+
msg = "step_metrics cannot be empty"
36+
raise ValueError(msg)
37+
return v
1638

17-
@field_validator("metrics", mode="before")
18-
def ensure_metric_exist_positive(
39+
@model_validator(mode="after") # type: ignore[arg-type]
40+
def ensure_coherence_kind_metrics(
1941
cls, # noqa: N805
20-
v: dict[MetricKeys, float | int],
21-
) -> dict[MetricKeys, float | int]:
22-
"""Ensure the measure of an operation exist and is positive"""
23-
for key, value in v.items():
24-
if not value:
25-
msg = f"{key} must be a positive number"
42+
model: "Step",
43+
) -> "Step":
44+
"""
45+
Validation to couple kind and metrics only when they are
46+
valid for example ram cannot have associated a cpu time
47+
"""
48+
metrics_keys = set(model.step_metrics)
49+
50+
# Control of the length of the set to be sure only on key is passed
51+
if len(metrics_keys) != 1:
52+
msg = "step_metrics must contain exactly one entry"
53+
raise ValueError(msg)
54+
55+
# Coherence CPU bound operation and metric
56+
if isinstance(model.kind, EndpointStepCPU):
57+
if metrics_keys != {Metrics.CPU_TIME}:
58+
msg = (
59+
"The metric to quantify a CPU BOUND step"
60+
f"must be {Metrics.CPU_TIME}"
61+
)
2662
raise ValueError(msg)
27-
return v
63+
64+
# Coherence RAM operation and metric
65+
elif isinstance(model.kind, EndpointStepRAM):
66+
if metrics_keys != {Metrics.NECESSARY_RAM}:
67+
msg = (
68+
"The metric to quantify a RAM step"
69+
f"must be {Metrics.NECESSARY_RAM}"
70+
)
71+
raise ValueError(msg)
72+
73+
# Coherence I/O operation and metric
74+
elif metrics_keys != {Metrics.IO_WAITING_TIME}:
75+
msg = (
76+
"The metric to quantify an I/O step"
77+
f"must be {Metrics.IO_WAITING_TIME}"
78+
)
79+
raise ValueError(msg)
80+
81+
return model
82+
83+
2884

2985

3086
class Endpoint(BaseModel):

src/app/schemas/system_topology_schema/full_system_topology_schema.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from pydantic import (
1010
BaseModel,
11+
ConfigDict,
1112
Field,
1213
PositiveInt,
1314
field_validator,
@@ -130,10 +131,7 @@ def unique_ids(
130131
raise ValueError(msg)
131132
return model
132133

133-
class Config:
134-
"""strict control over the acceptable keys"""
135-
136-
extra = "forbid"
134+
model_config = ConfigDict(extra="forbid")
137135

138136

139137
#-------------------------------------------------------------
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Unit tests for the Endpoint and Step Pydantic schemas."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
from pydantic import ValidationError
7+
8+
from app.config.constants import (
9+
EndpointStepCPU,
10+
EndpointStepIO,
11+
EndpointStepRAM,
12+
Metrics,
13+
)
14+
from app.schemas.system_topology_schema.endpoint_schema import Endpoint, Step
15+
16+
17+
# --------------------------------------------------------------------------- #
18+
# Helper functions to build minimal valid Step objects
19+
# --------------------------------------------------------------------------- #
20+
def cpu_step(value: float = 0.1) -> Step:
21+
"""Return a minimal valid CPU-bound Step."""
22+
return Step(
23+
kind=EndpointStepCPU.CPU_BOUND_OPERATION,
24+
step_metrics={Metrics.CPU_TIME: value},
25+
)
26+
27+
28+
def ram_step(value: int = 128) -> Step:
29+
"""Return a minimal valid RAM Step."""
30+
return Step(
31+
kind=EndpointStepRAM.RAM,
32+
step_metrics={Metrics.NECESSARY_RAM: value},
33+
)
34+
35+
36+
def io_step(value: float = 0.05) -> Step:
37+
"""Return a minimal valid I/O Step."""
38+
return Step(
39+
kind=EndpointStepIO.WAIT,
40+
step_metrics={Metrics.IO_WAITING_TIME: value},
41+
)
42+
43+
44+
# --------------------------------------------------------------------------- #
45+
# Positive test cases
46+
# --------------------------------------------------------------------------- #
47+
def test_valid_cpu_step() -> None:
48+
"""Test that a CPU step with correct 'cpu_time' metric passes validation."""
49+
step = cpu_step()
50+
# The metric value must match the input
51+
assert step.step_metrics[Metrics.CPU_TIME] == 0.1
52+
53+
54+
def test_valid_ram_step() -> None:
55+
"""Test that a RAM step with correct 'necessary_ram' metric passes validation."""
56+
step = ram_step()
57+
assert step.step_metrics[Metrics.NECESSARY_RAM] == 128
58+
59+
60+
def test_valid_io_step() -> None:
61+
"""Test that an I/O step with correct 'io_waiting_time' metric passes validation."""
62+
step = io_step()
63+
assert step.step_metrics[Metrics.IO_WAITING_TIME] == 0.05
64+
65+
66+
def test_endpoint_with_mixed_steps() -> None:
67+
"""Test that an Endpoint with multiple valid Step instances normalizes the name."""
68+
ep = Endpoint(
69+
endpoint_name="/Predict",
70+
steps=[cpu_step(), ram_step(), io_step()],
71+
)
72+
# endpoint_name should be lowercased by the validator
73+
assert ep.endpoint_name == "/predict"
74+
# All steps should be present in the list
75+
assert len(ep.steps) == 3
76+
77+
78+
# --------------------------------------------------------------------------- #
79+
# Negative test cases
80+
# --------------------------------------------------------------------------- #
81+
@pytest.mark.parametrize(
82+
("kind", "bad_metrics"),
83+
[
84+
# CPU step with RAM metric
85+
(EndpointStepCPU.CPU_BOUND_OPERATION, {Metrics.NECESSARY_RAM: 64}),
86+
# RAM step with CPU metric
87+
(EndpointStepRAM.RAM, {Metrics.CPU_TIME: 0.2}),
88+
# I/O step with CPU metric
89+
(EndpointStepIO.DB, {Metrics.CPU_TIME: 0.05}),
90+
],
91+
)
92+
def test_incoherent_kind_metric_pair(
93+
kind: EndpointStepCPU | EndpointStepRAM | EndpointStepIO,
94+
bad_metrics: dict[Metrics, float | int],
95+
) -> None:
96+
"""Test that mismatched kind and metric combinations raise ValidationError."""
97+
with pytest.raises(ValidationError):
98+
Step(kind=kind, step_metrics=bad_metrics)
99+
100+
101+
def test_multiple_metrics_not_allowed() -> None:
102+
"""Test that providing multiple metrics in a single Step raises ValidationError."""
103+
with pytest.raises(ValidationError):
104+
Step(
105+
kind=EndpointStepCPU.CPU_BOUND_OPERATION,
106+
step_metrics={
107+
Metrics.CPU_TIME: 0.1,
108+
Metrics.NECESSARY_RAM: 64,
109+
},
110+
)
111+
112+
113+
def test_empty_metrics_rejected() -> None:
114+
"""Test that an empty metrics dict is rejected by the validator."""
115+
with pytest.raises(ValidationError):
116+
Step(kind=EndpointStepCPU.CPU_BOUND_OPERATION, step_metrics={})
117+
118+
119+
def test_wrong_metric_name_for_io() -> None:
120+
"""Test that an I/O step with a non-I/O metric key is rejected."""
121+
with pytest.raises(ValidationError):
122+
Step(
123+
kind=EndpointStepIO.CACHE,
124+
step_metrics={Metrics.NECESSARY_RAM: 64},
125+
)

0 commit comments

Comments
 (0)