This is an automated email from the ASF dual-hosted git repository.
Gerrrr pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/otava.git
The following commit(s) were added to refs/heads/master by this push:
new 19882e9 Split persistence and report JSON serialization (+introduce
Pydantic) (#171)
19882e9 is described below
commit 19882e91dabd810622373665237dc96f653155ea
Author: Alex Sorokoumov <[email protected]>
AuthorDate: Sun Aug 23 21:53:43 2026 -0700
Split persistence and report JSON serialization (+introduce Pydantic) (#171)
* 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.
* Test analyzed series JSON round trip through stdlib
* 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.
* 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.
* 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/change_point_divisive/base.py | 127 ++++++++++++------------
otava/report.py | 32 +++++-
otava/serialization.py | 72 ++++++++++++++
otava/series.py | 87 ++++++++--------
pyproject.toml | 1 +
tests/change_point_classes_test.py | 38 ++++---
tests/report_test.py | 88 +++++++++++------
tests/series_test.py | 192 ++++++++++++++++++++++++++++++++++++
uv.lock | 154 +++++++++++++++++++++++++++++
9 files changed, 642 insertions(+), 149 deletions(-)
diff --git a/otava/change_point_divisive/base.py
b/otava/change_point_divisive/base.py
index d5e0eb2..b2a088e 100644
--- a/otava/change_point_divisive/base.py
+++ b/otava/change_point_divisive/base.py
@@ -201,9 +201,8 @@ class ChangePoint(CandidateChangePoint,
Generic[GenericStats]):
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 @@ class ChangePointSerializer(ChangePoint):
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 @@ class ChangePoints:
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 @@ class ChangePoints:
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 @@ class ChangePoints:
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 @@ class ChangePoints:
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 @@ class ChangePoints:
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 @@ class ChangePointsByTime(ChangePoints):
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 @@ class ChangePointsByMetric(ChangePoints):
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 @@ class ChangePointsByMetric(ChangePoints):
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 @@ class ChangePointsByMetric(ChangePoints):
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 @@ class SignificanceTester(Generic[GenericStats]):
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 5431b82..1f1e3dc 100644
--- a/otava/report.py
+++ b/otava/report.py
@@ -103,7 +103,37 @@ class Report:
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():.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:
output = []
diff --git a/otava/serialization.py b/otava/serialization.py
new file mode 100644
index 0000000..1b95ad8
--- /dev/null
+++ b/otava/serialization.py
@@ -0,0 +1,72 @@
+# 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
+
+JsonScalar = str | int | float | bool | None
+
+
+class AnalysisOptionsModel(BaseModel):
+ 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):
+ 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: int | float
+ attributes: Dict[str, JsonScalar]
+ changes: List[ChangePointModel]
+
+
+class AnalyzedSeriesModel(BaseModel):
+ model_config = ConfigDict(arbitrary_types_allowed=False)
+
+ test_name: str
+ time: List[int | float]
+ change_points_timestamp: datetime
+ branch_name: Optional[str] = None
+ options: AnalysisOptionsModel
+ metrics: Dict[str, MetricModel]
+ 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 e3d9e82..cb10d7b 100644
--- a/otava/series.py
+++ b/otava/series.py
@@ -20,6 +20,8 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Dict, Iterable, List, Optional
+from pydantic import TypeAdapter
+
from otava.analysis import (
TTestStats,
compute_change_points,
@@ -32,28 +34,13 @@ from otava.change_point_divisive.base import (
ChangePointsByMetric,
ChangePointsByTime,
)
+from otava.serialization import AnalysisOptionsModel, AnalyzedSeriesModel,
JsonScalar
+_datetime_adapter = TypeAdapter(datetime)
-@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
@@ -65,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}
@@ -80,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
@@ -103,7 +90,7 @@ class Series:
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]
@@ -124,7 +111,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)
@@ -344,8 +333,8 @@ class AnalyzedSeries:
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]]
@@ -353,10 +342,10 @@ class AnalyzedSeries:
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]:
@@ -371,31 +360,36 @@ class AnalyzedSeries:
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,
+ "options": self.options.model_dump(mode="json"),
+ "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="json")
@classmethod
def from_json(cls, analyzed_json):
@@ -445,8 +439,15 @@ class AnalyzedSeries:
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"],
@@ -457,11 +458,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(
@@ -472,7 +469,9 @@ class AnalyzedSeries:
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/pyproject.toml b/pyproject.toml
index 449f06f..cf9ad1b 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 c548a57..6e06c0a 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 31263b0..d3d9e6f 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 cab900a..0497380 100644
--- a/tests/series_test.py
+++ b/tests/series_test.py
@@ -15,12 +15,16 @@
# specific language governing permissions and limitations
# under the License.
+import json
import time
+from datetime import datetime
from random import random
import pytest
+from pydantic import ValidationError
from otava.change_point_divisive.base import ChangePointSerializer
+from otava.serialization import AnalysisOptionsModel, AnalyzedSeriesModel
from otava.series import AnalysisOptions, AnalyzedSeries, Metric, Series
@@ -45,6 +49,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,
@@ -268,6 +294,172 @@ 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 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"]
+
+
+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)))
+ 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]
+ 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 f9001c4..5802fbb 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"