From 31e7fd76122cca42ec96435de58ef46d90b3479e Mon Sep 17 00:00:00 2001 From: Ben Maydan Date: Fri, 31 Jul 2026 14:35:45 +0000 Subject: [PATCH 1/3] feat(SDK): support getting monthly billing report for an organization --- python/lightning_sdk/api/org_api.py | 53 ++++++++++++++++++++++++++++ python/lightning_sdk/organization.py | 20 +++++++++++ 2 files changed, 73 insertions(+) diff --git a/python/lightning_sdk/api/org_api.py b/python/lightning_sdk/api/org_api.py index 6fecb734..c46983d9 100644 --- a/python/lightning_sdk/api/org_api.py +++ b/python/lightning_sdk/api/org_api.py @@ -1,3 +1,5 @@ +from datetime import datetime +import typing from lightning_sdk.lightning_cloud.openapi import ( V1CreateProjectRequest, V1Organization, @@ -49,3 +51,54 @@ 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. + """ + 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 is None or range_end is None: + raise ValueError("A range requires both range_start and range_end.") + kwargs["time_filter_range_filter_range_start"] = range_start + kwargs["time_filter_range_filter_range_end"] = range_end + else: + if pivot is None or pivot_direction is None: + raise ValueError("A pivot requires both pivot and pivot_direction.") + if pivot_direction not in ("BEFORE", "AFTER"): + raise ValueError('pivot_direction must be "BEFORE" or "AFTER".') + 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() diff --git a/python/lightning_sdk/organization.py b/python/lightning_sdk/organization.py index 47e50ba1..540b85e1 100644 --- a/python/lightning_sdk/organization.py +++ b/python/lightning_sdk/organization.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import TYPE_CHECKING, Optional from lightning_sdk.api import OrgApi @@ -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.""" From 0d4046090cd538c4b7eb51afb3af72f45c3b025b Mon Sep 17 00:00:00 2001 From: Ben Maydan Date: Fri, 31 Jul 2026 15:50:41 +0000 Subject: [PATCH 2/3] added more test cases for invalid duration (> 2 years) --- python/tests/api/test_org_api.py | 127 +++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/python/tests/api/test_org_api.py b/python/tests/api/test_org_api.py index d86a62dc..f478fce5 100644 --- a/python/tests/api/test_org_api.py +++ b/python/tests/api/test_org_api.py @@ -1,3 +1,4 @@ +from datetime import datetime, timedelta, timezone from unittest import mock import pytest @@ -33,3 +34,129 @@ 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") From b7ee72f37242479ccf8d71c83b5c016ab810c837 Mon Sep 17 00:00:00 2001 From: Ben Maydan Date: Fri, 31 Jul 2026 22:17:41 +0000 Subject: [PATCH 3/3] fixes to tests --- python/lightning_sdk/api/org_api.py | 25 ++++++++++--- python/tests/api/test_org_api.py | 58 +++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/python/lightning_sdk/api/org_api.py b/python/lightning_sdk/api/org_api.py index c46983d9..eefdbbb1 100644 --- a/python/lightning_sdk/api/org_api.py +++ b/python/lightning_sdk/api/org_api.py @@ -1,5 +1,8 @@ -from datetime import datetime +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, @@ -78,7 +81,9 @@ def get_monthly_summary( dict: The monthly summary as a plain dictionary. Raises: - ValueError: If not exactly one valid filter mode is provided. + 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 @@ -89,15 +94,23 @@ def get_monthly_summary( kwargs = {"org_id": organization_id} if has_range: - if range_start is None or range_end is None: - raise ValueError("A range requires both range_start and range_end.") + 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 is None or pivot_direction is None: - raise ValueError("A pivot requires both pivot and pivot_direction.") 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 diff --git a/python/tests/api/test_org_api.py b/python/tests/api/test_org_api.py index f478fce5..61b2f5ee 100644 --- a/python/tests/api/test_org_api.py +++ b/python/tests/api/test_org_api.py @@ -160,3 +160,61 @@ def test_monthly_summary_future_after_pivot_naive(mock_client): 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()