Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 12 additions & 36 deletions src/routeplane/resources/finops.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@

from __future__ import annotations

import math
import warnings
from datetime import datetime
from typing import Literal, Optional, TypedDict

from ._base import BaseResource, prune_none
Expand Down Expand Up @@ -98,6 +95,12 @@ class DailyPricingCoverage(TypedDict):

class DailyPricingComponentCoverage(TypedDict):
input_output_split_available: bool
cost_split_coverage_state: Literal["known", "legacy_unknown", "corrupt"]
input_attributed_count: int
output_attributed_count: int
invalid_input_count: int
invalid_output_count: int
missing_reasons: list[str]
inr_view_available: bool


Expand Down Expand Up @@ -208,27 +211,6 @@ class SaverMetrics(TypedDict):
)


def _legacy_window_mins(from_date: str, to_date: str) -> int:
"""Convert a legacy absolute range to the relative window the server supports."""

def parse(value: str) -> datetime:
try:
# Python 3.9's fromisoformat does not accept the common trailing Z.
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError("legacy timeseries dates must be ISO-8601 values") from exc

start = parse(from_date)
end = parse(to_date)
try:
seconds = (end - start).total_seconds()
except TypeError as exc:
raise ValueError("legacy timeseries dates must use compatible timezones") from exc
if seconds < 0:
raise ValueError("legacy timeseries from_date must not be after to_date")
return max(1, math.ceil(seconds / 60))


class FinopsResource(BaseResource):
"""Usage rollups, cost time-series, and cost-saver metrics."""

Expand Down Expand Up @@ -265,21 +247,15 @@ def timeseries(
"""Return the recent process-local cost/usage time series.

``window_mins`` and ``buckets`` are the gateway's native query contract.
The legacy ``from_date``/``to_date`` pair remains accepted for source
compatibility, but is converted to a relative duration and emits a
:class:`DeprecationWarning`; it cannot select absolute durable history.
The legacy ``from_date``/``to_date`` parameters remain in the signature
for source compatibility but are rejected: this endpoint cannot select
an absolute period. Use :meth:`usage_daily` for durable date ranges.
"""
if from_date is not None or to_date is not None:
if window_mins is not None or buckets is not None:
raise ValueError("do not combine timeseries date-range and window options")
if from_date is None or to_date is None:
raise ValueError("legacy timeseries ranges require both from_date and to_date")
warnings.warn(
"timeseries(from_date=..., to_date=...) is deprecated; use window_mins=...",
DeprecationWarning,
stacklevel=2,
raise ValueError(
"absolute timeseries date ranges are unsupported; use usage_daily("
"from_date=..., to_date=...)"
)
window_mins = _legacy_window_mins(from_date, to_date)
params = prune_none({"window_mins": window_mins, "buckets": buckets})
data: TimeseriesData = self._get("finops/timeseries", params=params).json()
return data
Expand Down
23 changes: 15 additions & 8 deletions tests/test_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ def test_finops_usage_daily_range():
},
"component_coverage": {
"input_output_split_available": False,
"cost_split_coverage_state": "legacy_unknown",
"input_attributed_count": 0,
"output_attributed_count": 0,
"invalid_input_count": 0,
"invalid_output_count": 0,
"missing_reasons": ["legacy_cost_split_coverage_unknown"],
"inr_view_available": False,
},
}
Expand Down Expand Up @@ -237,6 +243,12 @@ def test_finops_usage_daily_preserves_unavailable_cost_and_usage():
},
"component_coverage": {
"input_output_split_available": False,
"cost_split_coverage_state": "corrupt",
"input_attributed_count": 0,
"output_attributed_count": 0,
"invalid_input_count": 1,
"invalid_output_count": 1,
"missing_reasons": ["cost_split_coverage_corrupt", "total_pricing_unavailable"],
"inr_view_available": False,
},
}
Expand Down Expand Up @@ -276,20 +288,15 @@ def test_finops_timeseries():
assert dict(_sent(route).url.params) == {"window_mins": "60", "buckets": "12"}


@respx.mock
def test_finops_timeseries_legacy_range_is_explicitly_converted():
route = respx.get(f"{BASE}/finops/timeseries").mock(
return_value=httpx.Response(200, json={"buckets": []})
)
with pytest.warns(DeprecationWarning, match="window_mins"):
def test_finops_timeseries_rejects_legacy_range_instead_of_moving_it_to_now():
with pytest.raises(ValueError, match="absolute timeseries date ranges are unsupported"):
FinopsResource(**_kwargs()).timeseries(
from_date="2026-07-01T00:00:00Z", to_date="2026-07-01T02:00:00Z"
)
assert dict(_sent(route).url.params) == {"window_mins": "120"}


def test_finops_timeseries_rejects_a_partial_legacy_range():
with pytest.raises(ValueError, match="require both"):
with pytest.raises(ValueError, match="absolute timeseries date ranges are unsupported"):
FinopsResource(**_kwargs()).timeseries(from_date="2026-07-01")


Expand Down
Loading