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]
