From e411d13dfe151dc45079032432679cab4de5fc63 Mon Sep 17 00:00:00 2001 From: Alex Sorokoumov Date: Tue, 18 Aug 2026 19:52:58 -0700 Subject: [PATCH 1/5] Split persistence and report JSON serialization Make domain to_json() methods return the precise persistence representation: ChangePoint and ChangePointGroup now emit numeric fields, include qhat, and no longer use rounded=True/False as a presentation switch. Move rounded JSON output into Report, alongside the other report renderers. ReportType.JSON now owns display-only fields such as backward_change_percent and formats human-facing values as strings. Add Pydantic v2 schema models for the persisted analyzed-series payload and validate AnalyzedSeries.to_json() against that schema before returning JSON-safe data. Update tests to pin the split: persistence JSON stays numeric and round-trippable, while report JSON keeps the existing rounded output shape. --- otava/change_point_divisive/base.py | 127 ++++++++++++----------- otava/report.py | 32 +++++- otava/serialization.py | 68 ++++++++++++ otava/series.py | 27 +++-- pyproject.toml | 1 + tests/change_point_classes_test.py | 38 ++++--- tests/report_test.py | 88 ++++++++++------ tests/series_test.py | 31 ++++++ uv.lock | 154 ++++++++++++++++++++++++++++ 9 files changed, 454 insertions(+), 112 deletions(-) create mode 100644 otava/serialization.py diff --git a/otava/change_point_divisive/base.py b/otava/change_point_divisive/base.py index d5e0eb20..b2a088e3 100644 --- a/otava/change_point_divisive/base.py +++ b/otava/change_point_divisive/base.py @@ -201,9 +201,8 @@ def to_candidate(self) -> CandidateChangePoint: data = {f.name: getattr(self, f.name) for f in fields(CandidateChangePoint)} return CandidateChangePoint(**data) - def to_json(self, rounded=True): - cps = ChangePointSerializer(self) - return cps.to_json(rounded) + def to_json(self): + return ChangePointSerializer(self).to_json() class ChangePointSerializer(ChangePoint): @@ -217,6 +216,7 @@ class ChangePointSerializer(ChangePoint): def __init__(self, cp: ChangePoint[GenericStats]): self.stats = cp.stats self.index = cp.index + self.qhat = cp.qhat self.metric = cp.metric def forward_change_percent(self) -> float: @@ -243,33 +243,19 @@ def stddev_after(self): def pvalue(self): return self.stats.pvalue - def to_json(self, rounded=True): - if rounded: - return { - "metric": self.metric, - "index": int(self.index), - "forward_change_percent": f"{self.forward_change_percent():.0f}", - "backward_change_percent": f"{self.backward_change_percent():.0f}", - "magnitude": f"{self.magnitude():-0f}", - "mean_before": f"{self.mean_before():-0f}", - "stddev_before": f"{self.stddev_before():-0f}", - "mean_after": f"{self.mean_after():-0f}", - "stddev_after": f"{self.stddev_after():-0f}", - "pvalue": f"{self.pvalue():-0f}", - } - - else: - return { - "metric": self.metric, - "index": int(self.index), - "forward_change_percent": self.forward_change_percent(), - "magnitude": self.magnitude(), - "mean_before": self.mean_before(), - "stddev_before": self.stddev_before(), - "mean_after": self.mean_after(), - "stddev_after": self.stddev_after(), - "pvalue": self.pvalue(), - } + def to_json(self): + return { + "metric": self.metric, + "index": int(self.index), + "qhat": self.qhat, + "forward_change_percent": self.forward_change_percent(), + "magnitude": self.magnitude(), + "mean_before": self.mean_before(), + "stddev_before": self.stddev_before(), + "mean_after": self.mean_after(), + "stddev_after": self.stddev_after(), + "pvalue": self.pvalue(), + } @dataclass @@ -288,15 +274,16 @@ class ChangePointGroup: :param attributes: The attributes of the test result at this timestamp. Commit and test metadata. :param changes: For each metric that has a change point at this time(stamp), the ChangePoint object. """ + time: float attributes: Dict[str, str] # ChangePointGroup.changes.keys() stores the set of metrics that were used at this ChangePointGroup.time. changes: Dict[str, ChangePoint] - def to_json(self, rounded=False): + def to_json(self): changes = [] for metric, cp in self.changes.items(): - changes.append(cp.to_json(rounded=rounded)) + changes.append(cp.to_json()) return { "time": self.time, @@ -365,9 +352,7 @@ def __init__(self): def from_list(cls, cps: list[ChangePointGroup]): """Build from a list of ChangePointGroup objects, ordered by time.""" if not isinstance(cps, list): - raise TypeError( - f"from_list() argument must be a list. Got {type(cps)}." - ) + raise TypeError(f"from_list() argument must be a list. Got {type(cps)}.") for cpg in cps: if not isinstance(cpg, ChangePointGroup): raise TypeError( @@ -396,12 +381,12 @@ def from_dict(cls, cps: dict): for metric, cpglist in cps.items(): for cpg in cpglist: if not isinstance(cpg, ChangePointGroup): - raise TypeError( - "from_dict() takes a dict[str, list[ChangePointGroup]]." - ) + raise TypeError("from_dict() takes a dict[str, list[ChangePointGroup]].") for cp in cpg: if cp.metric and cp.metric != metric: - raise ValueError(f"metric field is not internally consistent. {cp.metric} != {metric} at {cpg.time}") + raise ValueError( + f"metric field is not internally consistent. {cp.metric} != {metric} at {cpg.time}" + ) # store each metric's groups sorted by timestamp obj._change_points[metric] = sorted(cpglist, key=lambda cpg: cpg.time) return obj @@ -522,7 +507,9 @@ def get_change_points_for_metric(self, m: str): for cpg in single_metric._change_points: for metric, cp in cpg.changes.items(): if cp.metric and cp.metric != metric: - raise ValueError(f"metric field is not internally consistent. {cp.metric} != {metric} at {cpg.time}") + raise ValueError( + f"metric field is not internally consistent. {cp.metric} != {metric} at {cpg.time}" + ) metric_change_points.append(cp) return metric_change_points @@ -536,7 +523,7 @@ def at_timestamp(self, t: float): def at_commit(self, sha: str): for row in self: - if row.attributes['commit'] == sha: + if row.attributes["commit"] == sha: return row raise LookupError(sha) @@ -555,17 +542,18 @@ def pivot(self): class ChangePointsByTime(ChangePoints): """ - Implementation of ChangePoints class where the internal structure is ordered by time/commit. + Implementation of ChangePoints class where the internal structure is ordered by time/commit. - In fact, this is the default way, and therefore all of this class' implementation is already - in its parent class ChangePoints. However, you can still create instances from this class - to make it explicit that your code at that point explicitly wanted a collection of ChangePoints - ordered by time. + In fact, this is the default way, and therefore all of this class' implementation is already + in its parent class ChangePoints. However, you can still create instances from this class + to make it explicit that your code at that point explicitly wanted a collection of ChangePoints + ordered by time. - The pivot() method returns a new view holding the same ChangePoint objects, but ordered by metrics - as the primary and optimized axis. The method by_metric() can be used for the same purpose. Use - copy().pivot() for an isolated copy. The method by_time() is a no-op and returns self. + The pivot() method returns a new view holding the same ChangePoint objects, but ordered by metrics + as the primary and optimized axis. The method by_metric() can be used for the same purpose. Use + copy().pivot() for an isolated copy. The method by_time() is a no-op and returns self. """ + @classmethod def from_dict(cls, cps: dict): """ @@ -579,14 +567,14 @@ def from_dict(cls, cps: dict): class ChangePointsByMetric(ChangePoints): """ - Provides same interface as ChangePointsByTime, but internally stores with metric first. + Provides same interface as ChangePointsByTime, but internally stores with metric first. - You can create empty instances of this class, or you can also use the factory method - `ChangePoints.from_dict()` to get an instance of this type. + You can create empty instances of this class, or you can also use the factory method + `ChangePoints.from_dict()` to get an instance of this type. - The pivot() method returns a new view holding the same ChangePoint objects, but ordered by time - as the primary and optimized axis. The method by_time() can be used for the same purpose. Use - copy().pivot() for an isolated copy. The method by_metric() is a no-op and returns self. + The pivot() method returns a new view holding the same ChangePoint objects, but ordered by time + as the primary and optimized axis. The method by_time() can be used for the same purpose. Use + copy().pivot() for an isolated copy. The method by_metric() is a no-op and returns self. """ def __init__(self): @@ -636,7 +624,9 @@ def append(self, cpg: ChangePointGroup): self._change_points[metric] = [] for metric1, cp in cpg.changes.items(): if metric1 != cp.metric: - raise ValueError(f"metric field is not internally consistent. {metric1} != {cp.metric} at {cpg.time}") + raise ValueError( + f"metric field is not internally consistent. {metric1} != {cp.metric} at {cpg.time}" + ) if (not self._change_points[metric]) or cpg.time > self._change_points[metric][-1].time: self._change_points[metric].append(cpg.select_metrics(metric)) else: @@ -653,10 +643,14 @@ def extend(self, cps: list[ChangePointGroup]): raise TypeError(errmsg) for metric1, cp in cpg.changes.items(): if metric1 != cp.metric: - raise ValueError(f"metric field is not internally consistent. {metric1} != {cp.metric} at {cpg.time}") + raise ValueError( + f"metric field is not internally consistent. {metric1} != {cp.metric} at {cpg.time}" + ) if metric1 not in self._change_points: self._change_points[metric1] = [] - if (not self._change_points[metric1]) or cpg.time > self._change_points[metric1][-1].time: + if (not self._change_points[metric1]) or cpg.time > self._change_points[metric1][ + -1 + ].time: self._change_points[metric1].append(cpg.select_metrics(metric1)) else: raise ValueError( @@ -728,7 +722,9 @@ def get_change_points_for_metric(self, m: str): for cpg in cpglist: for metric2, cp in cpg.changes.items(): if metric1 != metric2 or cp.metric and metric1 != cp.metric: - raise ValueError(f"metric field is not internally consistent. {metric1} != {cp.metric} at {cpg.time}") + raise ValueError( + f"metric field is not internally consistent. {metric1} != {cp.metric} at {cpg.time}" + ) metric_change_points.append(cp) return metric_change_points @@ -752,13 +748,18 @@ class SignificanceTester(Generic[GenericStats]): def __init__(self, max_pvalue: float): self.max_pvalue = max_pvalue - def compare(self, left: Sequence[SupportsFloat], right: Sequence[SupportsFloat], p: float = None) -> GenericStats: + def compare( + self, left: Sequence[SupportsFloat], right: Sequence[SupportsFloat], p: float = None + ) -> GenericStats: if len(left) == 0 or len(right) == 0: raise ValueError return BaseStats.calculate(left, right, p) def get_sides( - self, candidate: CandidateChangePoint, series: Sequence[SupportsFloat], intervals: List[slice] + self, + candidate: CandidateChangePoint, + series: Sequence[SupportsFloat], + intervals: List[slice], ) -> (Sequence, Sequence): """ Computes properties of the change point if the Candidate Change Point based on the provided intervals. @@ -779,7 +780,9 @@ def get_sides( left_interval = interval right_interval = intervals[i + 1] break - elif (interval.start is None or interval.start < candidate.index) and (interval.stop is None or candidate.index < interval.stop): + elif (interval.start is None or interval.start < candidate.index) and ( + interval.stop is None or candidate.index < interval.stop + ): # Split step # Note: handles slices with omitted indexes: # diff --git a/otava/report.py b/otava/report.py index 5431b825..e46db72d 100644 --- a/otava/report.py +++ b/otava/report.py @@ -103,7 +103,37 @@ def __format_log_annotated(self, test_name: str) -> str: def __format_json(self, test_name: str) -> str: import json - return json.dumps({test_name: [cpg.to_json(rounded=True) for cpg in self.__change_points]}) + return json.dumps( + { + test_name: [ + self.__format_change_point_group_json(cpg) for cpg in self.__change_points + ] + } + ) + + @staticmethod + def __format_change_point_group_json(cpg): + return { + "time": cpg.time, + "attributes": cpg.attributes, + "changes": [Report.__format_change_point_json(cp) for cp in cpg.changes.values()], + } + + @staticmethod + def __format_change_point_json(cp): + cp = ChangePointSerializer(cp) + return { + "metric": cp.metric, + "index": int(cp.index), + "forward_change_percent": f"{cp.forward_change_percent():.0f}", + "backward_change_percent": f"{cp.backward_change_percent():.0f}", + "magnitude": f"{cp.magnitude():-0f}", + "mean_before": f"{cp.mean_before():-0f}", + "stddev_before": f"{cp.stddev_before():-0f}", + "mean_after": f"{cp.mean_after():-0f}", + "stddev_after": f"{cp.stddev_after():-0f}", + "pvalue": f"{cp.pvalue():-0f}", + } def __format_regressions_only(self, test_name: str) -> str: output = [] diff --git a/otava/serialization.py b/otava/serialization.py new file mode 100644 index 00000000..8ed4546d --- /dev/null +++ b/otava/serialization.py @@ -0,0 +1,68 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from datetime import datetime +from typing import Dict, List, Optional + +from pydantic import BaseModel, ConfigDict + + +class AnalysisOptionsModel(BaseModel): + window_len: int + max_pvalue: float + min_magnitude: float + orig_edivisive: bool + + +class MetricModel(BaseModel): + direction: Optional[int] = None + scale: Optional[float] = None + unit: str = "" + + +class ChangePointModel(BaseModel): + metric: Optional[str] = None + index: int + qhat: float + forward_change_percent: float + magnitude: float + mean_before: float + stddev_before: float + mean_after: float + stddev_after: float + pvalue: float + + +class ChangePointGroupModel(BaseModel): + time: float + attributes: Dict[str, str] + changes: List[ChangePointModel] + + +class AnalyzedSeriesModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=False) + + test_name: str + time: List[int] + change_points_timestamp: datetime + branch_name: Optional[str] = None + options: AnalysisOptionsModel + metrics: Dict[str, MetricModel] + attributes: Dict[str, List[str]] + data: Dict[str, List[Optional[float]]] + change_points: Dict[str, List[ChangePointGroupModel]] + weak_change_points: Dict[str, List[ChangePointGroupModel]] diff --git a/otava/series.py b/otava/series.py index e3d9e828..dbc68222 100644 --- a/otava/series.py +++ b/otava/series.py @@ -32,6 +32,7 @@ ChangePointsByMetric, ChangePointsByTime, ) +from otava.serialization import AnalyzedSeriesModel @dataclass @@ -371,31 +372,36 @@ def to_json(self): for metric_name in self.change_points.metrics(): change_points_json[metric_name] = [] for cp in cpbm.select_metrics(metric_name): - change_points_json[metric_name].append(cp.to_json(rounded=False)) + change_points_json[metric_name].append(cp.to_json()) weak_change_points_json = {} wcpbm = self.weak_change_points.by_metric() for metric_name in self.weak_change_points.metrics(): weak_change_points_json[metric_name] = [] for cp in wcpbm.select_metrics(metric_name): - weak_change_points_json[metric_name].append(cp.to_json(rounded=False)) + weak_change_points_json[metric_name].append(cp.to_json()) data_json = {} for metric, datapoints in self.__series.data.items(): data_json[metric] = [float(d) if d is not None else None for d in datapoints] - return { + metrics_json = {} + for metric, unit in self.__series.metrics.items(): + metrics_json[metric] = unit.to_json() + + payload = { "test_name": self.test_name(), "time": self.time(), "change_points_timestamp": self.change_points_timestamp, "branch_name": self.branch_name(), "options": self.options.to_json(), - "metrics": self.__series.metrics, + "metrics": metrics_json, "attributes": self.__series.attributes, - "data": self.__series.data, + "data": data_json, "change_points": change_points_json, "weak_change_points": weak_change_points_json, } + return AnalyzedSeriesModel.model_validate(payload).model_dump(mode="python") @classmethod def from_json(cls, analyzed_json): @@ -445,8 +451,15 @@ def change_points_from_json(change_points_json): new_metrics = {} - for metric_name, unit in analyzed_json["metrics"].items(): - new_metrics[metric_name] = Metric(None, None, unit) + for metric_name, metric_json in analyzed_json["metrics"].items(): + if isinstance(metric_json, dict): + new_metrics[metric_name] = Metric( + metric_json.get("direction"), + metric_json.get("scale"), + metric_json.get("unit", ""), + ) + else: + new_metrics[metric_name] = Metric(None, None, metric_json) new_series = Series( analyzed_json["test_name"], diff --git a/pyproject.toml b/pyproject.toml index 449f06f9..cf9ad1b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dependencies = [ "google-cloud-bigquery>=3.38.0", "pg8000>=1.31.5", "configargparse>=1.7.1", + "pydantic>=2,<3", # For Python 3.10: last series that supports it "scipy>=1.15,<1.16; python_version < '3.11'", diff --git a/tests/change_point_classes_test.py b/tests/change_point_classes_test.py index c548a570..6e06c0a8 100644 --- a/tests/change_point_classes_test.py +++ b/tests/change_point_classes_test.py @@ -50,9 +50,7 @@ def make_stats(left=(1.0, 1.0), right=(5.0, 5.0), pvalue=0.01): def make_cp(metric="m", index=3, left=(1.0, 1.0), right=(5.0, 5.0), pvalue=0.01): - return ChangePoint( - index=index, qhat=1.0, stats=make_stats(left, right, pvalue), metric=metric - ) + return ChangePoint(index=index, qhat=1.0, stats=make_stats(left, right, pvalue), metric=metric) def make_group(time, metric="m", index=3, commit="sha"): @@ -306,20 +304,27 @@ def test_changepoint_copy_is_deep(): assert cp.stats.mean_1 != -1.0 -def test_changepoint_serializer_rounded_json(): +def test_changepoint_serializer_json_is_precise(): cp = make_cp(metric="m", index=3, left=(10.0, 10.0), right=(20.0, 20.0), pvalue=0.02) - j = ChangePointSerializer(cp).to_json(rounded=True) + j = ChangePointSerializer(cp).to_json() assert j["metric"] == "m" assert j["index"] == 3 - assert j["forward_change_percent"] == "100" - assert all(isinstance(j[k], str) for k in ("magnitude", "mean_before", "pvalue")) + assert j["qhat"] == 1.0 + assert j["forward_change_percent"] == 100.0 + assert j["magnitude"] == 1.0 + assert j["mean_before"] == 10.0 + assert j["mean_after"] == 20.0 + assert j["pvalue"] == 0.02 -def test_changepoint_serializer_unrounded_json(): +def test_changepoint_json_is_precise(): cp = make_cp(metric="m", index=3, left=(10.0, 10.0), right=(20.0, 20.0), pvalue=0.02) - j = ChangePointSerializer(cp).to_json(rounded=False) + j = cp.to_json() + assert j["metric"] == "m" assert j["index"] == 3 + assert j["qhat"] == 1.0 assert j["forward_change_percent"] == 100.0 + assert j["magnitude"] == 1.0 assert j["mean_before"] == 10.0 assert j["mean_after"] == 20.0 assert j["pvalue"] == 0.02 @@ -327,7 +332,7 @@ def test_changepoint_serializer_unrounded_json(): def test_changepoint_to_json_delegates_to_serializer(): cp = make_cp(metric="m", index=3, left=(10.0, 10.0), right=(20.0, 20.0), pvalue=0.02) - assert cp.to_json(rounded=False) == ChangePointSerializer(cp).to_json(rounded=False) + assert cp.to_json() == ChangePointSerializer(cp).to_json() # ChangePointGroup @@ -376,6 +381,9 @@ def test_group_to_json(): assert j["attributes"] == {"commit": "abc"} assert isinstance(j["changes"], list) and len(j["changes"]) == 1 assert j["changes"][0]["metric"] == "m" + assert j["changes"][0]["qhat"] == 1.0 + assert j["changes"][0]["forward_change_percent"] == 400.0 + assert isinstance(j["changes"][0]["mean_before"], float) def test_group_copy_is_deep(): @@ -505,7 +513,11 @@ def test_bytime_append_out_of_order(): def test_bytime_get_change_points_for_metric_sparse(): # 'a' appears at t=1 and t=3, 'b' only at t=2 -> sparse columns c = ChangePointsByTime.from_list( - [make_group(1.0, "a", index=1), make_group(2.0, "b", index=2), make_group(3.0, "a", index=3)] + [ + make_group(1.0, "a", index=1), + make_group(2.0, "b", index=2), + make_group(3.0, "a", index=3), + ] ) a_points = c.get_change_points_for_metric("a") assert [cp.index for cp in a_points] == [1, 3] @@ -562,7 +574,9 @@ def test_from_dict_is_metric_keyed_and_returns_by_metric(): def test_bymetric_list_constructor(): - bm = ChangePointsByMetric.from_list([make_group(1.0, "a"), make_group(2.0, "a"), make_group(2.0, "b")]) + bm = ChangePointsByMetric.from_list( + [make_group(1.0, "a"), make_group(2.0, "a"), make_group(2.0, "b")] + ) # from_list() returns the class it is called on (asymmetric with from_dict) assert isinstance(bm, ChangePointsByMetric) assert bm.metrics() == {"a", "b"} diff --git a/tests/report_test.py b/tests/report_test.py index 31263b0b..d3d9e6fc 100644 --- a/tests/report_test.py +++ b/tests/report_test.py @@ -70,30 +70,50 @@ def test_report(series, change_points): def test_json_report(report): output = report.produce_report("test_name_from_config", ReportType.JSON) obj = json.loads(output) - expected = {'test_name_from_config': [{'attributes': {}, - 'changes': [{'forward_change_percent': '-11', - 'backward_change_percent': '12', - 'index': 4, - 'magnitude': '0.124108', - 'mean_after': '1.801429', - 'mean_before': '2.025000', - 'metric': 'series2', - 'pvalue': '0.000000', - 'stddev_after': '0.026954', - 'stddev_before': '0.011180'}], - 'time': 4}, - {'attributes': {}, - 'changes': [{'forward_change_percent': '-49', - 'backward_change_percent': '98', - 'index': 6, - 'magnitude': '0.977513', - 'mean_after': '0.504000', - 'mean_before': '0.996667', - 'metric': 'series1', - 'pvalue': '0.000000', - 'stddev_after': '0.025768', - 'stddev_before': '0.067495'}], - 'time': 6}]} + first_change = obj["test_name_from_config"][0]["changes"][0] + assert first_change["forward_change_percent"] == "-11" + assert first_change["backward_change_percent"] == "12" + assert isinstance(first_change["mean_before"], str) + expected = { + "test_name_from_config": [ + { + "attributes": {}, + "changes": [ + { + "forward_change_percent": "-11", + "backward_change_percent": "12", + "index": 4, + "magnitude": "0.124108", + "mean_after": "1.801429", + "mean_before": "2.025000", + "metric": "series2", + "pvalue": "0.000000", + "stddev_after": "0.026954", + "stddev_before": "0.011180", + } + ], + "time": 4, + }, + { + "attributes": {}, + "changes": [ + { + "forward_change_percent": "-49", + "backward_change_percent": "98", + "index": 6, + "magnitude": "0.977513", + "mean_after": "0.504000", + "mean_before": "0.996667", + "metric": "series1", + "pvalue": "0.000000", + "stddev_after": "0.025768", + "stddev_before": "0.067495", + } + ], + "time": 6, + }, + ] + } assert isinstance(obj, dict) assert obj == expected @@ -104,12 +124,20 @@ def test_regression_only_report(report): def test_simplereport_regression_only(): - s = Series('t', None, list(range(11)), - {'a': Metric(1, 1.0), 'b': Metric(1, 1.0)}, - {'a': [1.02, 0.95, 0.99, 1.00, 1.12, 0.90, 0.50, 0.51, 0.48, 0.48, 0.55], - 'b': [2.02, 2.03, 2.01, 2.04, 1.82, 1.85, 1.79, 1.81, 1.80, 1.76, 1.78]}, - {}) - out = Report(s, s.analyze().change_points_by_time).produce_report('t', ReportType.REGRESSIONS_ONLY) + s = Series( + "t", + None, + list(range(11)), + {"a": Metric(1, 1.0), "b": Metric(1, 1.0)}, + { + "a": [1.02, 0.95, 0.99, 1.00, 1.12, 0.90, 0.50, 0.51, 0.48, 0.48, 0.55], + "b": [2.02, 2.03, 2.01, 2.04, 1.82, 1.85, 1.79, 1.81, 1.80, 1.76, 1.78], + }, + {}, + ) + out = Report(s, s.analyze().change_points_by_time).produce_report( + "t", ReportType.REGRESSIONS_ONLY + ) print(out) assert "Regressions" in out assert "-11.0" in out diff --git a/tests/series_test.py b/tests/series_test.py index cab900a9..9d48370f 100644 --- a/tests/series_test.py +++ b/tests/series_test.py @@ -21,6 +21,7 @@ import pytest from otava.change_point_divisive.base import ChangePointSerializer +from otava.serialization import AnalyzedSeriesModel from otava.series import AnalysisOptions, AnalyzedSeries, Metric, Series @@ -268,6 +269,36 @@ def test_analyzed_series_json_round_trip(): assert restored.to_json()["weak_change_points"] == payload["weak_change_points"] +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] + time = list(range(len(series_1))) + series = Series( + "test", + branch=None, + time=time, + metrics={"series1": Metric(1, 1.0), "series2": Metric(1, 1.0)}, + data={"series1": series_1, "series2": series_2}, + attributes={}, + ) + + analyzed_series = series.analyze() + analyzed_series.append( + time=[len(time)], new_data={"series1": [0.5], "series2": [1.97]}, attributes={} + ) + + payload = analyzed_series.to_json() + model = AnalyzedSeriesModel.model_validate(payload) + dumped = model.model_dump(mode="python") + + change = dumped["change_points"]["series2"][0]["changes"][0] + weak_change = dumped["weak_change_points"]["series2"][1]["changes"][0] + assert isinstance(change["mean_before"], float) + assert isinstance(change["forward_change_percent"], float) + assert isinstance(weak_change["pvalue"], float) + assert AnalyzedSeries.from_json(dumped).to_json()["change_points"] == dumped["change_points"] + + def test_validate(): 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] diff --git a/uv.lock b/uv.lock index f9001c49..5802fbbb 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,15 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "apache-otava" version = "0.8.0" @@ -19,6 +28,7 @@ dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "pg8000" }, + { name = "pydantic" }, { name = "pystache" }, { name = "python-dateutil" }, { name = "requests" }, @@ -62,6 +72,7 @@ requires-dist = [ { name = "numpy", marker = "python_full_version >= '3.14'", specifier = ">=2.3.2,<2.4" }, { name = "pg8000", specifier = ">=1.31.5" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.5.0" }, + { name = "pydantic", specifier = ">=2,<3" }, { name = "pystache", specifier = ">=0.6.8" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.1" }, { name = "pytest-benchmark", marker = "extra == 'dev'", specifier = ">=5.2.3" }, @@ -1044,6 +1055,137 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + [[package]] name = "pyflakes" version = "3.4.0" @@ -1704,6 +1846,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + [[package]] name = "tzdata" version = "2025.2" From e3104f1174ecd01f7c55cd496b9507d143508cf1 Mon Sep 17 00:00:00 2001 From: Alex Sorokoumov Date: Tue, 18 Aug 2026 19:59:13 -0700 Subject: [PATCH 2/5] Test analyzed series JSON round trip through stdlib --- otava/series.py | 2 +- tests/series_test.py | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/otava/series.py b/otava/series.py index dbc68222..e89eeddb 100644 --- a/otava/series.py +++ b/otava/series.py @@ -401,7 +401,7 @@ def to_json(self): "change_points": change_points_json, "weak_change_points": weak_change_points_json, } - return AnalyzedSeriesModel.model_validate(payload).model_dump(mode="python") + return AnalyzedSeriesModel.model_validate(payload).model_dump(mode="json") @classmethod def from_json(cls, analyzed_json): diff --git a/tests/series_test.py b/tests/series_test.py index 9d48370f..83f1acde 100644 --- a/tests/series_test.py +++ b/tests/series_test.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. +import json import time from random import random @@ -269,6 +270,32 @@ def test_analyzed_series_json_round_trip(): assert restored.to_json()["weak_change_points"] == payload["weak_change_points"] +def test_analyzed_series_json_round_trip_through_json_module(): + 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] + time = list(range(len(series_1))) + series = Series( + "test", + branch=None, + time=time, + metrics={"series1": Metric(1, 1.0), "series2": Metric(1, 1.0)}, + data={"series1": series_1, "series2": series_2}, + attributes={}, + ) + + analyzed_series = series.analyze() + analyzed_series.append( + time=[len(time)], new_data={"series1": [0.5], "series2": [1.97]}, attributes={} + ) + + payload = analyzed_series.to_json() + decoded = json.loads(json.dumps(payload)) + restored = AnalyzedSeries.from_json(decoded) + + assert restored.to_json()["change_points"] == decoded["change_points"] + assert restored.to_json()["weak_change_points"] == decoded["weak_change_points"] + + 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] From 485e020a6c9e88d12a34cd31a7ae7de3277f14c7 Mon Sep 17 00:00:00 2001 From: Alex Sorokoumov Date: Tue, 18 Aug 2026 20:04:54 -0700 Subject: [PATCH 3/5] 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 8ed4546d..362230b7 100644 --- a/otava/serialization.py +++ b/otava/serialization.py @@ -22,10 +22,12 @@ 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 e89eeddb..ac2e18b6 100644 --- a/otava/series.py +++ b/otava/series.py @@ -32,29 +32,11 @@ 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 @@ def find_by_attribute(self, name: str, value: str) -> List[int]: 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 @@ def to_json(self): "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 @@ def change_points_from_json(change_points_json): 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 83f1acde..3f2fb68e 100644 --- a/tests/series_test.py +++ b/tests/series_test.py @@ -20,9 +20,10 @@ 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] From ff88046e76f0235f9025dcd5536c9bb1cc31a53c Mon Sep 17 00:00:00 2001 From: Alex Sorokoumov Date: Tue, 18 Aug 2026 20:52:04 -0700 Subject: [PATCH 4/5] Address JSON serialization review Parse change point timestamps back to datetime objects when loading analyzed series from JSON-decoded payloads, so runtime state matches direct in-memory round trips. Also replace the ambiguous report JSON float formatter with explicit six-decimal formatting while preserving the existing report representation. --- otava/report.py | 12 ++++++------ otava/series.py | 8 +++++++- tests/series_test.py | 2 ++ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/otava/report.py b/otava/report.py index e46db72d..1f1e3dc8 100644 --- a/otava/report.py +++ b/otava/report.py @@ -127,12 +127,12 @@ def __format_change_point_json(cp): "index": int(cp.index), "forward_change_percent": f"{cp.forward_change_percent():.0f}", "backward_change_percent": f"{cp.backward_change_percent():.0f}", - "magnitude": f"{cp.magnitude():-0f}", - "mean_before": f"{cp.mean_before():-0f}", - "stddev_before": f"{cp.stddev_before():-0f}", - "mean_after": f"{cp.mean_after():-0f}", - "stddev_after": f"{cp.stddev_after():-0f}", - "pvalue": f"{cp.pvalue():-0f}", + "magnitude": f"{cp.magnitude():.6f}", + "mean_before": f"{cp.mean_before():.6f}", + "stddev_before": f"{cp.stddev_before():.6f}", + "mean_after": f"{cp.mean_after():.6f}", + "stddev_after": f"{cp.stddev_after():.6f}", + "pvalue": f"{cp.pvalue():.6f}", } def __format_regressions_only(self, test_name: str) -> str: diff --git a/otava/series.py b/otava/series.py index ac2e18b6..ef0d0ddd 100644 --- a/otava/series.py +++ b/otava/series.py @@ -20,6 +20,8 @@ from datetime import datetime, timezone from typing import Dict, Iterable, List, Optional +from pydantic import TypeAdapter + from otava.analysis import ( TTestStats, compute_change_points, @@ -34,6 +36,8 @@ ) from otava.serialization import AnalysisOptionsModel, AnalyzedSeriesModel +_datetime_adapter = TypeAdapter(datetime) + class AnalysisOptions(AnalysisOptionsModel): pass @@ -465,7 +469,9 @@ def change_points_from_json(change_points_json): analyzed_series.weak_change_points = new_weak_change_points if "change_points_timestamp" in analyzed_json.keys(): - analyzed_series.change_points_timestamp = analyzed_json["change_points_timestamp"] + analyzed_series.change_points_timestamp = _datetime_adapter.validate_python( + analyzed_json["change_points_timestamp"] + ) analyzed_series.change_points_by_time = AnalyzedSeries.__group_change_points_by_time( analyzed_series.__series, analyzed_series.change_points ) diff --git a/tests/series_test.py b/tests/series_test.py index 3f2fb68e..5f302c8b 100644 --- a/tests/series_test.py +++ b/tests/series_test.py @@ -17,6 +17,7 @@ import json import time +from datetime import datetime from random import random import pytest @@ -315,6 +316,7 @@ def test_analyzed_series_json_round_trip_through_json_module(): decoded = json.loads(json.dumps(payload)) restored = AnalyzedSeries.from_json(decoded) + assert isinstance(restored.change_points_timestamp, datetime) assert restored.to_json()["change_points"] == decoded["change_points"] assert restored.to_json()["weak_change_points"] == decoded["weak_change_points"] From 73d0fd9078a88ef2c1867e72c8c1a4452724b352 Mon Sep 17 00:00:00 2001 From: Alex Sorokoumov Date: Wed, 19 Aug 2026 21:03:56 -0700 Subject: [PATCH 5/5] Fix analyzed series persistence edge cases Allow nullable JSON scalar attributes, preserve integer and fractional timestamps, and retain metric units across current and legacy JSON round trips. --- otava/serialization.py | 10 +++-- otava/series.py | 22 +++++------ tests/series_test.py | 89 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 15 deletions(-) diff --git a/otava/serialization.py b/otava/serialization.py index 362230b7..1b95ad8b 100644 --- a/otava/serialization.py +++ b/otava/serialization.py @@ -20,6 +20,8 @@ from pydantic import BaseModel, ConfigDict +JsonScalar = str | int | float | bool | None + class AnalysisOptionsModel(BaseModel): model_config = ConfigDict(extra="forbid", validate_assignment=True) @@ -50,8 +52,8 @@ class ChangePointModel(BaseModel): class ChangePointGroupModel(BaseModel): - time: float - attributes: Dict[str, str] + time: int | float + attributes: Dict[str, JsonScalar] changes: List[ChangePointModel] @@ -59,12 +61,12 @@ class AnalyzedSeriesModel(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=False) test_name: str - time: List[int] + time: List[int | float] change_points_timestamp: datetime branch_name: Optional[str] = None options: AnalysisOptionsModel metrics: Dict[str, MetricModel] - attributes: Dict[str, List[str]] + attributes: Dict[str, List[JsonScalar]] data: Dict[str, List[Optional[float]]] change_points: Dict[str, List[ChangePointGroupModel]] weak_change_points: Dict[str, List[ChangePointGroupModel]] diff --git a/otava/series.py b/otava/series.py index ef0d0ddd..cb10d7b5 100644 --- a/otava/series.py +++ b/otava/series.py @@ -34,7 +34,7 @@ ChangePointsByMetric, ChangePointsByTime, ) -from otava.serialization import AnalysisOptionsModel, AnalyzedSeriesModel +from otava.serialization import AnalysisOptionsModel, AnalyzedSeriesModel, JsonScalar _datetime_adapter = TypeAdapter(datetime) @@ -52,7 +52,7 @@ class Metric: def __init__(self, direction: int = 1, scale: float = 1.0, unit: str = ""): self.direction = direction self.scale = scale - self.unit = "" + self.unit = unit def to_json(self): return {"direction": self.direction, "scale": self.scale, "unit": self.unit} @@ -67,19 +67,19 @@ class Series: test_name: str branch: Optional[str] - time: List[int] + time: List[int | float] metrics: Dict[str, Metric] - attributes: Dict[str, List[str]] + attributes: Dict[str, List[JsonScalar]] data: Dict[str, List[float]] def __init__( self, test_name: str, branch: Optional[str], - time: List[int], + time: List[int | float], metrics: Dict[str, Metric], data: Dict[str, List[float]], - attributes: Dict[str, List[str]], + attributes: Dict[str, List[JsonScalar]], ): self.test_name = test_name self.branch = branch @@ -90,7 +90,7 @@ def __init__( assert all(len(x) == len(time) for x in data.values()) assert all(len(x) == len(time) for x in attributes.values()) - def attributes_at(self, index: int) -> Dict[str, str]: + def attributes_at(self, index: int) -> Dict[str, JsonScalar]: result = {} for k, v in self.attributes.items(): result[k] = v[index] @@ -333,8 +333,8 @@ def branch_name(self) -> Optional[str]: def len(self) -> int: return len(self.__series.time) - def time(self) -> List[int]: - return [int(t) for t in self.__series.time] + def time(self) -> List[int | float]: + return list(self.__series.time) def data(self, metric: str) -> List[float]: return [float(d) for d in self.__series.data[metric]] @@ -342,10 +342,10 @@ def data(self, metric: str) -> List[float]: def attributes(self) -> Iterable[str]: return self.__series.attributes.keys() - def attributes_at(self, index: int) -> Dict[str, str]: + def attributes_at(self, index: int) -> Dict[str, JsonScalar]: return self.__series.attributes_at(index) - def attribute_values(self, attribute: str) -> List[str]: + def attribute_values(self, attribute: str) -> List[JsonScalar]: return self.__series.attributes[attribute] def metric_names(self) -> Iterable[str]: diff --git a/tests/series_test.py b/tests/series_test.py index 5f302c8b..0497380e 100644 --- a/tests/series_test.py +++ b/tests/series_test.py @@ -321,6 +321,95 @@ 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_json_round_trip_preserves_json_scalar_attributes(): + values = [1.02, 0.95, 0.99, 1.00, 1.12, 0.90, 0.50, 0.51, 0.48, 0.48, 0.55] + attributes = { + "commit": [None] * len(values), + "build": list(range(len(values))), + "passed": [True] * len(values), + } + series = Series( + "test", + branch=None, + time=list(range(len(values))), + metrics={"series": Metric(1, 1.0)}, + data={"series": values}, + attributes=attributes, + ) + + decoded = json.loads(json.dumps(series.analyze().to_json())) + restored = AnalyzedSeries.from_json(decoded) + + assert restored.to_json()["attributes"] == attributes + assert restored.to_json()["change_points"]["series"][0]["attributes"] == { + "commit": None, + "build": 6, + "passed": True, + } + + +def test_analyzed_series_json_round_trip_preserves_fractional_timestamps(): + values = [1.02, 0.95, 0.99, 1.00, 1.12, 0.90, 0.50, 0.51, 0.48, 0.48, 0.55] + timestamps = [1_700_000_000.25 + index for index in range(len(values))] + series = Series( + "test", + branch=None, + time=timestamps, + metrics={"series": Metric(1, 1.0)}, + data={"series": values}, + attributes={}, + ) + + payload = series.analyze().to_json() + restored = AnalyzedSeries.from_json(json.loads(json.dumps(payload))) + + assert payload["time"] == timestamps + assert restored.time() == timestamps + assert restored.to_json()["change_points"]["series"][0]["time"] == timestamps[6] + + +def test_analyzed_series_json_round_trip_preserves_integer_timestamp_types(): + values = [1.02, 0.95, 0.99, 1.00, 1.12, 0.90, 0.50, 0.51, 0.48, 0.48, 0.55] + timestamps = list(range(len(values))) + series = Series( + "test", + branch=None, + time=timestamps, + metrics={"series": Metric(1, 1.0)}, + data={"series": values}, + attributes={}, + ) + + payload = series.analyze().to_json() + restored_payload = AnalyzedSeries.from_json(payload).to_json() + + assert all(type(timestamp) is int for timestamp in restored_payload["time"]) + assert type(restored_payload["change_points"]["series"][0]["time"]) is int + + +def test_analyzed_series_json_round_trip_preserves_current_and_legacy_metric_units(): + series = Series( + "test", + branch=None, + time=[1, 2], + metrics={"latency": Metric(1, 1.0, "ms")}, + data={"latency": [1.0, 2.0]}, + attributes={}, + ) + + payload = series.analyze().to_json() + current = AnalyzedSeries.from_json(payload) + payload["metrics"]["latency"] = "seconds" + legacy = AnalyzedSeries.from_json(payload) + + assert ( + current.metric("latency").unit, + current.to_json()["metrics"]["latency"]["unit"], + legacy.metric("latency").unit, + legacy.to_json()["metrics"]["latency"]["unit"], + ) == ("ms", "ms", "seconds", "seconds") + + 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)))