Skip to content
Open
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
66 changes: 66 additions & 0 deletions python/lightning_sdk/api/org_api.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
from datetime import datetime, timedelta
import typing

# The billing summary API only supports querying up to 2 years of history.
_MAX_DURATION = timedelta(days=730)
from lightning_sdk.lightning_cloud.openapi import (
V1CreateProjectRequest,
V1Organization,
Expand Down Expand Up @@ -49,3 +54,64 @@ def create_teamspace(self, name: str, organization_id: str) -> None:
self._client.projects_service_create_project(
body=V1CreateProjectRequest(name=name, organization_id=organization_id, display_name=name)
)

def get_monthly_summary(
self,
organization_id: str,
range_start: typing.Optional[datetime] = None,
range_end: typing.Optional[datetime] = None,
pivot: typing.Optional[datetime] = None,
pivot_direction: typing.Optional[str] = None, # "BEFORE" | "AFTER"
) -> dict:
"""Get the monthly billing summary of an organization.

Exactly one filter mode must be supplied (the API rejects a missing or
empty TimeFilter):
- a range: pass both ``range_start`` and ``range_end``
- a pivot: pass ``pivot`` and ``pivot_direction`` ("BEFORE" or "AFTER")

Args:
organization_id: ID of the organization to summarize.
range_start: Start of the time range (use with ``range_end``).
range_end: End of the time range (use with ``range_start``).
pivot: Pivot timestamp (use with ``pivot_direction``).
pivot_direction: "BEFORE" or "AFTER" the pivot.

Returns:
dict: The monthly summary as a plain dictionary.

Raises:
ValueError: If not exactly one valid filter mode is provided, if the
range start is after the range end, or if an "AFTER" pivot is in
the future.
"""
has_range = range_start is not None and range_end is not None
has_pivot = pivot is not None and pivot_direction is not None

if has_range == has_pivot:
raise ValueError("Provide exactly one of a time range or a pivot, not both/neither.")

kwargs = {"org_id": organization_id}

if has_range:
if range_start > range_end:
raise ValueError("range_start must not be after range_end.")
if range_end - range_start > _MAX_DURATION:
raise ValueError("the time range must not be longer than 2 years.")
kwargs["time_filter_range_filter_range_start"] = range_start
kwargs["time_filter_range_filter_range_end"] = range_end
else:
if pivot_direction not in ("BEFORE", "AFTER"):
raise ValueError('pivot_direction must be "BEFORE" or "AFTER".')
if pivot_direction == "AFTER":
# An "AFTER" pivot selects [pivot, now]; it must be in the past
# and no more than 2 years back.
now = datetime.now(pivot.tzinfo) if pivot.tzinfo is not None else datetime.now()
if pivot > now:
raise ValueError("pivot must not be in the future when pivot_direction is AFTER.")
if now - pivot > _MAX_DURATION:
raise ValueError("an AFTER pivot must not be more than 2 years in the past.")
kwargs["time_filter_pivot_filter_pivot"] = pivot
kwargs["time_filter_pivot_filter_pivot_direction"] = pivot_direction

return self._client.billing_service_get_monthly_summary(**kwargs).to_dict()
20 changes: 20 additions & 0 deletions python/lightning_sdk/organization.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime
from typing import TYPE_CHECKING, Optional

from lightning_sdk.api import OrgApi
Expand Down Expand Up @@ -65,6 +66,25 @@ def create_teamspace(self, name: str) -> "Teamspace":

self._org_api.create_teamspace(name, self.id)
return Teamspace(name=name, org=self)

def get_monthly_summary(
self,
range_start: Optional[datetime] = None,
range_end: Optional[datetime] = None,
pivot: Optional[datetime] = None,
pivot_direction: Optional[str] = None, # "BEFORE" | "AFTER"
) -> dict:
"""Returns a monthly summary of credits purchased, used, and remaining


"""
return self._org_api.get_monthly_summary(
self.id,
range_start=range_start,
range_end=range_end,
pivot=pivot,
pivot_direction=pivot_direction,
)

def __repr__(self) -> str:
"""Returns reader friendly representation."""
Expand Down
185 changes: 185 additions & 0 deletions python/tests/api/test_org_api.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime, timedelta, timezone
from unittest import mock

import pytest
Expand Down Expand Up @@ -33,3 +34,187 @@ def test_create_teamspace(mock_client):
assert call_args[1]["body"].name == "my-teamspace"
assert call_args[1]["body"].display_name == "my-teamspace"
assert call_args[1]["body"].organization_id == "org-123"


# ---- get_monthly_summary: time-filter validation --------------------------


@mock.patch("lightning_sdk.api.org_api.LightningClient")
def test_monthly_summary_range_forwards_bounds(mock_client):
org_api = OrgApi()
start = datetime(2026, 1, 1, tzinfo=timezone.utc)
end = datetime(2026, 2, 1, tzinfo=timezone.utc)

org_api.get_monthly_summary("org-123", range_start=start, range_end=end)

call_args = mock_client().billing_service_get_monthly_summary.call_args[1]
assert call_args["org_id"] == "org-123"
assert call_args["time_filter_range_filter_range_start"] == start
assert call_args["time_filter_range_filter_range_end"] == end


@mock.patch("lightning_sdk.api.org_api.LightningClient")
def test_monthly_summary_pivot_forwards_bounds(mock_client):
org_api = OrgApi()
pivot = datetime(2026, 1, 1, tzinfo=timezone.utc)

org_api.get_monthly_summary("org-123", pivot=pivot, pivot_direction="BEFORE")

call_args = mock_client().billing_service_get_monthly_summary.call_args[1]
assert call_args["time_filter_pivot_filter_pivot"] == pivot
assert call_args["time_filter_pivot_filter_pivot_direction"] == "BEFORE"


@mock.patch("lightning_sdk.api.org_api.LightningClient")
def test_monthly_summary_requires_exactly_one_filter(mock_client):
org_api = OrgApi()

# neither
with pytest.raises(ValueError, match="exactly one"):
org_api.get_monthly_summary("org-123")

# both
with pytest.raises(ValueError, match="exactly one"):
org_api.get_monthly_summary(
"org-123",
range_start=datetime(2026, 1, 1),
range_end=datetime(2026, 2, 1),
pivot=datetime(2026, 1, 1),
pivot_direction="BEFORE",
)

# partial range is treated as "no valid filter"
with pytest.raises(ValueError, match="exactly one"):
org_api.get_monthly_summary("org-123", range_start=datetime(2026, 1, 1))

# partial pivot is treated as "no valid filter"
with pytest.raises(ValueError, match="exactly one"):
org_api.get_monthly_summary("org-123", pivot=datetime(2026, 1, 1))

mock_client().billing_service_get_monthly_summary.assert_not_called()


@mock.patch("lightning_sdk.api.org_api.LightningClient")
def test_monthly_summary_rejects_reversed_range(mock_client):
org_api = OrgApi()

with pytest.raises(ValueError, match="range_start must not be after range_end"):
org_api.get_monthly_summary(
"org-123",
range_start=datetime(2026, 2, 1, tzinfo=timezone.utc),
range_end=datetime(2026, 1, 1, tzinfo=timezone.utc),
)

mock_client().billing_service_get_monthly_summary.assert_not_called()


@mock.patch("lightning_sdk.api.org_api.LightningClient")
def test_monthly_summary_allows_equal_range_bounds(mock_client):
org_api = OrgApi()
same = datetime(2026, 1, 1, tzinfo=timezone.utc)

org_api.get_monthly_summary("org-123", range_start=same, range_end=same)

mock_client().billing_service_get_monthly_summary.assert_called_once()


@mock.patch("lightning_sdk.api.org_api.LightningClient")
def test_monthly_summary_rejects_bad_pivot_direction(mock_client):
org_api = OrgApi()

with pytest.raises(ValueError, match='"BEFORE" or "AFTER"'):
org_api.get_monthly_summary(
"org-123", pivot=datetime(2026, 1, 1), pivot_direction="SIDEWAYS"
)

mock_client().billing_service_get_monthly_summary.assert_not_called()


@mock.patch("lightning_sdk.api.org_api.LightningClient")
def test_monthly_summary_rejects_future_after_pivot(mock_client):
org_api = OrgApi()
future = datetime.now(timezone.utc) + timedelta(days=1)

with pytest.raises(ValueError, match="must not be in the future"):
org_api.get_monthly_summary("org-123", pivot=future, pivot_direction="AFTER")

mock_client().billing_service_get_monthly_summary.assert_not_called()


@mock.patch("lightning_sdk.api.org_api.LightningClient")
def test_monthly_summary_allows_future_before_pivot(mock_client):
"""A future pivot is only rejected for AFTER; BEFORE may legitimately be in the future."""
org_api = OrgApi()
future = datetime.now(timezone.utc) + timedelta(days=1)

org_api.get_monthly_summary("org-123", pivot=future, pivot_direction="BEFORE")

mock_client().billing_service_get_monthly_summary.assert_called_once()


@mock.patch("lightning_sdk.api.org_api.LightningClient")
def test_monthly_summary_future_after_pivot_naive(mock_client):
"""Future check also works for naive datetimes (no tzinfo)."""
org_api = OrgApi()
future = datetime.now() + timedelta(days=1)

with pytest.raises(ValueError, match="must not be in the future"):
org_api.get_monthly_summary("org-123", pivot=future, pivot_direction="AFTER")


# ---- get_monthly_summary: 2-year duration limit --------------------------


@mock.patch("lightning_sdk.api.org_api.LightningClient")
def test_monthly_summary_rejects_range_over_two_years(mock_client):
org_api = OrgApi()
start = datetime(2024, 1, 1, tzinfo=timezone.utc)
end = start + timedelta(days=731) # just over 2 years

with pytest.raises(ValueError, match="not be longer than 2 years"):
org_api.get_monthly_summary("org-123", range_start=start, range_end=end)

mock_client().billing_service_get_monthly_summary.assert_not_called()


@mock.patch("lightning_sdk.api.org_api.LightningClient")
def test_monthly_summary_allows_range_exactly_two_years(mock_client):
org_api = OrgApi()
start = datetime(2024, 1, 1, tzinfo=timezone.utc)
end = start + timedelta(days=730) # exactly 2 years

org_api.get_monthly_summary("org-123", range_start=start, range_end=end)

mock_client().billing_service_get_monthly_summary.assert_called_once()


@mock.patch("lightning_sdk.api.org_api.LightningClient")
def test_monthly_summary_rejects_after_pivot_over_two_years(mock_client):
org_api = OrgApi()
pivot = datetime.now(timezone.utc) - timedelta(days=731) # just over 2 years ago

with pytest.raises(ValueError, match="more than 2 years in the past"):
org_api.get_monthly_summary("org-123", pivot=pivot, pivot_direction="AFTER")

mock_client().billing_service_get_monthly_summary.assert_not_called()


@mock.patch("lightning_sdk.api.org_api.LightningClient")
def test_monthly_summary_allows_after_pivot_within_two_years(mock_client):
org_api = OrgApi()
pivot = datetime.now(timezone.utc) - timedelta(days=700) # within 2 years

org_api.get_monthly_summary("org-123", pivot=pivot, pivot_direction="AFTER")

mock_client().billing_service_get_monthly_summary.assert_called_once()


@mock.patch("lightning_sdk.api.org_api.LightningClient")
def test_monthly_summary_before_pivot_not_limited_by_two_years(mock_client):
"""The 2-year limit only applies to AFTER pivots; a far-past BEFORE pivot is fine."""
org_api = OrgApi()
pivot = datetime(2000, 1, 1, tzinfo=timezone.utc) # decades ago

org_api.get_monthly_summary("org-123", pivot=pivot, pivot_direction="BEFORE")

mock_client().billing_service_get_monthly_summary.assert_called_once()
Loading