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
4 changes: 4 additions & 0 deletions python/docs/sdk/api/organization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ Organization
.. autoclass:: Organization
:members:
:show-inheritance:

.. autoclass:: lightning_sdk.api.org_api.MonthlySummary
:members:
:show-inheritance:
3 changes: 2 additions & 1 deletion python/lightning_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from lightning_sdk.k8s_cluster import K8sCluster
from lightning_sdk.machine import CloudProvider, Machine
from lightning_sdk.mmt import MMT
from lightning_sdk.organization import Organization
from lightning_sdk.organization import MonthlySummary, Organization
from lightning_sdk.status import Status
from lightning_sdk.studio import Studio
from lightning_sdk.teamspace import ConnectionType, FolderLocation, Teamspace
Expand All @@ -24,6 +24,7 @@
"Job",
"K8sCluster",
"Machine",
"MonthlySummary",
"Organization",
"SecretType",
"Status",
Expand Down
96 changes: 96 additions & 0 deletions python/lightning_sdk/api/org_api.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,33 @@
from datetime import datetime, timedelta
from typing import Any, Dict, Optional, TypedDict

from lightning_sdk.api.utils import cached_lightning_client
from lightning_sdk.lightning_cloud.openapi import (
V1CreateProjectRequest,
V1Organization,
)

# The billing summary API only supports querying up to 2 years of history.
_MAX_DURATION = timedelta(days=730)


class MonthlySummary(TypedDict):
"""A single month's billing summary of credits purchased, used, and remaining.

Attributes:
period_start: Start of the billing period (typically the first of the month).
period_end: End of the billing period (typically the end of the month).
total_credits_consumed: Total credits consumed during the monthly period.
total_credits_remaining: Total credits remaining at the end of the monthly period.
total_credits_purchased: Total credits purchased during the monthly period.
"""

period_start: datetime
period_end: datetime
total_credits_consumed: float
total_credits_remaining: float
total_credits_purchased: float


class OrgApi:
"""Internal API client for org requests (mainly http requests)."""
Expand Down Expand Up @@ -49,3 +73,75 @@ 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,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
) -> list[MonthlySummary]:
"""Get the monthly billing summary of an organization.

Exactly one of ``start`` and ``end`` must be supplied,
or both together:
- only ``start``: translated to a pivot filter with
``pivot_direction="AFTER"`` and ``pivot=start``.
- only ``end``: translated to a pivot filter with
``pivot_direction="BEFORE"`` and ``pivot=end``.
- both ``start`` and ``end``: translated to a normal
range filter.

Args:
organization_id: ID of the organization to summarize.
start: Start of the time range. If given without
``end``, acts as an "AFTER" pivot.
end: End of the time range. If given without
``start``, acts as a "BEFORE" pivot.

Returns:
A list of MonthlySummary dicts:
[
{
"period_start": datetime,
"period_end": datetime,
"total_credits_consumed": float,
"total_credits_remaining": float,
"total_credits_purchased": float,
},
...
]

Raises:
ValueError: If neither ``start`` nor ``end`` is
provided, if ``start`` is after ``end``, or if
an "AFTER" pivot (derived from a lone ``start``) is in
the future.
"""
if start is None and end is None:
raise ValueError("Provide at least one of start or end.")

kwargs: Dict[str, Any] = {"org_id": organization_id}

if start is not None and end is not None:
if start > end:
raise ValueError("start must not be after end.")
if end - start > _MAX_DURATION:
raise ValueError("the time range must not be longer than 2 years.")
kwargs["time_filter_range_filter_range_start"] = start
kwargs["time_filter_range_filter_range_end"] = end
elif start is not None:
pivot = start
# 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("start must not be in the future.")
if now - pivot > _MAX_DURATION:
raise ValueError("start must not be more than 2 years in the past.")
kwargs["time_filter_pivot_filter_pivot"] = pivot
kwargs["time_filter_pivot_filter_pivot_direction"] = "AFTER"
else:
kwargs["time_filter_pivot_filter_pivot"] = end
kwargs["time_filter_pivot_filter_pivot_direction"] = "BEFORE"

return self._client.billing_service_get_monthly_summary(**kwargs).to_dict()["monthly_summaries"]
49 changes: 49 additions & 0 deletions python/lightning_sdk/organization.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from datetime import datetime
from typing import TYPE_CHECKING, Optional

from lightning_sdk.api import OrgApi
from lightning_sdk.api.org_api import MonthlySummary
from lightning_sdk.owner import Owner
from lightning_sdk.utils.resolve import _resolve_org_name

Expand Down Expand Up @@ -66,6 +68,53 @@ 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(
Comment thread
bmaydan marked this conversation as resolved.
Resolved
self,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
) -> list[MonthlySummary]:
"""Returns a monthly summary of credits purchased, used, and remaining.

Exactly one of ``start`` and ``end`` must be supplied,
or both together:

- only ``start``: grabs monthly summaries for months after ``pivot=start``.
- only ``end``: grabs monthly summaries for months before ``pivot=end``.
- both ``start`` and ``end``: acts as a normal range.

Args:
start: Start of the time range. If given without
``end``, acts as an "AFTER" pivot.
end: End of the time range. If given without
``start``, acts as a "BEFORE" pivot.

Returns:
A list of :class:`~lightning_sdk.api.org_api.MonthlySummary` dicts,
one per month in the requested range::

[
{
"period_start": datetime,
"period_end": datetime,
"total_credits_consumed": float,
"total_credits_remaining": float,
"total_credits_purchased": float,
},
...
]

Raises:
ValueError: If neither ``start`` nor ``end`` is
provided, if ``start`` is after ``end``, or if
an "AFTER" pivot (derived from a lone ``start``) is in
the future.
"""
return self._org_api.get_monthly_summary(
self.id,
start=start,
end=end,
)

def __repr__(self) -> str:
"""Returns reader friendly representation."""
return f"Organization(name={self.name})"
166 changes: 166 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,168 @@ 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.utils.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", start=start, 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.utils.LightningClient")
def test_monthly_summary_only_start_forwards_after_pivot(mock_client):
org_api = OrgApi()
start = datetime(2026, 1, 1, tzinfo=timezone.utc)

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

call_args = mock_client().billing_service_get_monthly_summary.call_args[1]
assert call_args["time_filter_pivot_filter_pivot"] == start
assert call_args["time_filter_pivot_filter_pivot_direction"] == "AFTER"


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

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

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


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

with pytest.raises(ValueError, match="at least one"):
org_api.get_monthly_summary("org-123")

mock_client().billing_service_get_monthly_summary.assert_not_called()


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

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

mock_client().billing_service_get_monthly_summary.assert_not_called()


@mock.patch("lightning_sdk.api.utils.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", start=same, end=same)

mock_client().billing_service_get_monthly_summary.assert_called_once()


@mock.patch("lightning_sdk.api.utils.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", start=future)

mock_client().billing_service_get_monthly_summary.assert_not_called()


@mock.patch("lightning_sdk.api.utils.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", end=future)

mock_client().billing_service_get_monthly_summary.assert_called_once()


@mock.patch("lightning_sdk.api.utils.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", start=future)


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


@mock.patch("lightning_sdk.api.utils.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", start=start, end=end)

mock_client().billing_service_get_monthly_summary.assert_not_called()


@mock.patch("lightning_sdk.api.utils.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", start=start, end=end)

mock_client().billing_service_get_monthly_summary.assert_called_once()


@mock.patch("lightning_sdk.api.utils.LightningClient")
def test_monthly_summary_rejects_after_pivot_over_two_years(mock_client):
org_api = OrgApi()
start = 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", start=start)

mock_client().billing_service_get_monthly_summary.assert_not_called()


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

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

mock_client().billing_service_get_monthly_summary.assert_called_once()


@mock.patch("lightning_sdk.api.utils.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()
end = datetime(2000, 1, 1, tzinfo=timezone.utc) # decades ago

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

mock_client().billing_service_get_monthly_summary.assert_called_once()
Loading