This is an automated email from the ASF dual-hosted git repository.

Gerrrr pushed a commit to branch pydantic-and-split-persistence-report-json
in repository https://gitbox.apache.org/repos/asf/otava.git

commit b0761a0c1f7650e22f35e52968045eeddaf52a46
Author: Alex Sorokoumov <[email protected]>
AuthorDate: Tue Aug 18 20:04:54 2026 -0700

    Convert analysis options to Pydantic
    
    Make AnalysisOptions inherit from the Pydantic v2 AnalysisOptionsModel, 
with defaults defined on the model itself.
    
    Reject unknown option fields and validate assignment so deserialized and 
CLI-populated options use the same typed contract.
    
    Use model_dump(mode="json") when serializing analyzed-series options, and 
rebuild options in from_json() with AnalysisOptions.model_validate(...) instead 
of manual field copying.
    
    Keep the scope narrower than PR #93: reuse the Pydantic direction, but 
avoid its broader unrelated model conversions and old Pydantic v1 APIs.
---
 otava/serialization.py | 10 ++++++----
 otava/series.py        | 36 ++++++++----------------------------
 tests/series_test.py   | 45 ++++++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 58 insertions(+), 33 deletions(-)

diff --git a/otava/serialization.py b/otava/serialization.py
index 8ed4546..362230b 100644
--- a/otava/serialization.py
+++ b/otava/serialization.py
@@ -22,10 +22,12 @@ from pydantic import BaseModel, ConfigDict
 
 
 class AnalysisOptionsModel(BaseModel):
-    window_len: int
-    max_pvalue: float
-    min_magnitude: float
-    orig_edivisive: bool
+    model_config = ConfigDict(extra="forbid", validate_assignment=True)
+
+    window_len: int = 50
+    max_pvalue: float = 0.001
+    min_magnitude: float = 0.0
+    orig_edivisive: bool = False
 
 
 class MetricModel(BaseModel):
diff --git a/otava/series.py b/otava/series.py
index e89eedd..ac2e18b 100644
--- a/otava/series.py
+++ b/otava/series.py
@@ -32,29 +32,11 @@ from otava.change_point_divisive.base import (
     ChangePointsByMetric,
     ChangePointsByTime,
 )
-from otava.serialization import AnalyzedSeriesModel
+from otava.serialization import AnalysisOptionsModel, AnalyzedSeriesModel
 
 
-@dataclass
-class AnalysisOptions:
-    window_len: int
-    max_pvalue: float
-    min_magnitude: float
-    orig_edivisive: bool
-
-    def __init__(self):
-        self.window_len = 50
-        self.max_pvalue = 0.001
-        self.min_magnitude = 0.0
-        self.orig_edivisive = False
-
-    def to_json(self):
-        return {
-            "window_len": self.window_len,
-            "max_pvalue": self.max_pvalue,
-            "min_magnitude": self.min_magnitude,
-            "orig_edivisive": self.orig_edivisive,
-        }
+class AnalysisOptions(AnalysisOptionsModel):
+    pass
 
 
 @dataclass
@@ -125,7 +107,9 @@ class Series:
                 result.append(i)
         return result
 
-    def analyze(self, options: AnalysisOptions = AnalysisOptions()) -> 
"AnalyzedSeries":
+    def analyze(self, options: Optional[AnalysisOptions] = None) -> 
"AnalyzedSeries":
+        if options is None:
+            options = AnalysisOptions()
         logging.info(f"Computing change points for test {self.test_name}...")
         return AnalyzedSeries(self, options)
 
@@ -394,7 +378,7 @@ class AnalyzedSeries:
             "time": self.time(),
             "change_points_timestamp": self.change_points_timestamp,
             "branch_name": self.branch_name(),
-            "options": self.options.to_json(),
+            "options": self.options.model_dump(mode="json"),
             "metrics": metrics_json,
             "attributes": self.__series.attributes,
             "data": data_json,
@@ -470,11 +454,7 @@ class AnalyzedSeries:
             analyzed_json["attributes"],
         )
 
-        new_options = AnalysisOptions()
-        new_options.window_len = analyzed_json["options"]["window_len"]
-        new_options.max_pvalue = analyzed_json["options"]["max_pvalue"]
-        new_options.min_magnitude = analyzed_json["options"]["min_magnitude"]
-        new_options.orig_edivisive = analyzed_json["options"]["orig_edivisive"]
+        new_options = AnalysisOptions.model_validate(analyzed_json["options"])
 
         new_change_points = 
change_points_from_json(analyzed_json["change_points"])
         new_weak_change_points = change_points_from_json(
diff --git a/tests/series_test.py b/tests/series_test.py
index 83f1acd..3f2fb68 100644
--- a/tests/series_test.py
+++ b/tests/series_test.py
@@ -20,9 +20,10 @@ import time
 from random import random
 
 import pytest
+from pydantic import ValidationError
 
 from otava.change_point_divisive.base import ChangePointSerializer
-from otava.serialization import AnalyzedSeriesModel
+from otava.serialization import AnalysisOptionsModel, AnalyzedSeriesModel
 from otava.series import AnalysisOptions, AnalyzedSeries, Metric, Series
 
 
@@ -47,6 +48,28 @@ def test_change_point_detection():
     assert cps._change_points[1].changes["series1"].metric == "series1"
 
 
+def test_analysis_options_is_pydantic_model():
+    options = AnalysisOptions(
+        window_len="25",
+        max_pvalue="0.05",
+        min_magnitude=1,
+        orig_edivisive=True,
+    )
+
+    assert isinstance(options, AnalysisOptionsModel)
+    assert options.model_dump(mode="json") == {
+        "window_len": 25,
+        "max_pvalue": 0.05,
+        "min_magnitude": 1.0,
+        "orig_edivisive": True,
+    }
+
+
+def test_analysis_options_rejects_unknown_fields():
+    with pytest.raises(ValidationError):
+        AnalysisOptions(no_such_option=True)
+
+
 def test_change_point_detection_many():
     series_3 = [
         1,
@@ -296,6 +319,26 @@ def 
test_analyzed_series_json_round_trip_through_json_module():
     assert restored.to_json()["weak_change_points"] == 
decoded["weak_change_points"]
 
 
+def test_analyzed_series_from_json_validates_options_with_pydantic():
+    series_1 = [1.02, 0.95, 0.99, 1.00, 1.12, 0.90, 0.50, 0.51, 0.48, 0.48, 
0.55]
+    time = list(range(len(series_1)))
+    series = Series(
+        "test",
+        branch=None,
+        time=time,
+        metrics={"series1": Metric(1, 1.0)},
+        data={"series1": series_1},
+        attributes={},
+    )
+
+    payload = series.analyze().to_json()
+    payload["options"]["window_len"] = "25"
+    restored = AnalyzedSeries.from_json(payload)
+
+    assert isinstance(restored.options, AnalysisOptions)
+    assert restored.options.window_len == 25
+
+
 def test_analyzed_series_json_uses_pydantic_model_shape():
     series_1 = [1.02, 0.95, 0.99, 1.00, 1.12, 0.90, 0.50, 0.51, 0.48, 0.48, 
0.55]
     series_2 = [2.02, 2.03, 2.01, 2.04, 1.82, 1.85, 1.79, 1.81, 1.80, 1.76, 
1.78]

Reply via email to