diff --git a/CHANGELOG.md b/CHANGELOG.md index 263a0ab..d14cf13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.19.0] - 2026-10-21 + +### Added + +- **`group_by`, `fiscal_year` and `rolling` on `get_entity_metrics()`, `get_naics_metrics()` and `get_psc_metrics()`.** The metrics endpoints have always accepted these query parameters, but the SDK built only the path, so callers could not split obligations by funding `"agency"` or `"department"` or bucket them by federal fiscal year. Each is optional; existing calls send the same request as before. + ## [1.18.2] - 2026-10-19 ### Fixed diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 2cb7d6f..2ddeffa 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -2071,6 +2071,16 @@ psc = client.get_psc("D302") metrics = client.get_psc_metrics(code="D302", months=12, period_grouping="month") ``` +**Parameters:** +- `code` (str): PSC code. Required. +- `months` (int): Trailing window in months. +- `period_grouping` (str): `"month"`, `"quarter"` or `"year"`. +- `group_by` (`"agency"` | `"department"` | list | None): Split each period by funding organization. `agency` is an L2 short code such as `"2100"`; `department` is the CGAC code as an int, e.g. `97` for DoD. +- `fiscal_year` (bool): Bucket by federal fiscal year instead of calendar periods. Default `False`. +- `rolling` (bool): Add rolling averages. The API skips them, with a `warning`, when `group_by` is set. Default `False`. + +`get_naics_metrics()` and `get_entity_metrics()` take the same options. + --- ## MAS SINs @@ -2203,8 +2213,14 @@ lcats = client.list_entity_lcats("ABCDEF123456", limit=25) ```python metrics = client.get_entity_metrics("ABCDEF123456", months=12, period_grouping="month") +# five fiscal years of obligations, split by funding department +by_dept = client.get_entity_metrics( + "ABCDEF123456", months=60, period_grouping="year", group_by="department", fiscal_year=True +) ``` +Takes the same `group_by`, `fiscal_year` and `rolling` options as [`get_psc_metrics()`](#get_psc_metrics). + ### get_entity_budget_flows() Get budget flows for an entity (`/api/entities/{uei}/budget-flows/`) — the federal accounts that funded contracts and assistance awarded to this entity. diff --git a/pyproject.toml b/pyproject.toml index 93c95b7..a696593 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "tango-python" -version = "1.18.2" +version = "1.19.0" description = "Python SDK for the Tango API" readme = "README.md" requires-python = ">=3.12" diff --git a/tango/__init__.py b/tango/__init__.py index 5f43718..07f9069 100644 --- a/tango/__init__.py +++ b/tango/__init__.py @@ -62,7 +62,7 @@ ) from .webhooks.receiver import Delivery, WebhookReceiver -__version__ = "1.18.2" +__version__ = "1.19.0" __all__ = [ "TangoClient", "TangoAPIError", diff --git a/tango/client.py b/tango/client.py index 1fbf405..32c96b8 100644 --- a/tango/client.py +++ b/tango/client.py @@ -100,6 +100,10 @@ def _error_reason(error_data: dict[str, Any]) -> str | None: return None +# Funding-organization keys the metrics endpoints accept in `group_by`. +MetricsGroupBy = Literal["agency", "department"] + + class TangoClient: """Tango API Client""" @@ -5615,7 +5619,15 @@ def get_psc( params["flat_lists"] = "true" return self._get(f"/api/psc/{code}/", params) - def get_psc_metrics(self, code: str, months: int, period_grouping: str) -> dict[str, Any]: + def get_psc_metrics( + self, + code: str, + months: int, + period_grouping: str, + group_by: MetricsGroupBy | list[MetricsGroupBy] | None = None, + fiscal_year: bool = False, + rolling: bool = False, + ) -> dict[str, Any]: """Get rolling PSC metrics (`/api/psc/{code}/metrics/{months}/{period_grouping}/`). Args: @@ -5623,10 +5635,32 @@ def get_psc_metrics(self, code: str, months: int, period_grouping: str) -> dict[ months: Window size in months (e.g. 6, 12, 24, 36). period_grouping: ``"month"``, ``"quarter"``, or ``"year"`` (the values the API accepts on the path segment). + group_by: Split each period by the funding ``"agency"`` (an L2 short code such as ``"2100"``) or ``"department"`` (the CGAC code as an int, e.g. ``97`` for DoD), or both. + fiscal_year: Bucket by federal fiscal year (October-September) instead of calendar periods. + rolling: Add rolling averages. The API skips them, with a ``warning``, when ``group_by`` is set. """ if not code: raise TangoValidationError("PSC code is required") - return self._get(f"/api/psc/{code}/metrics/{months}/{period_grouping}/") + return self._get( + f"/api/psc/{code}/metrics/{months}/{period_grouping}/", + self._metrics_params(group_by, fiscal_year, rolling), + ) + + @staticmethod + def _metrics_params( + group_by: MetricsGroupBy | list[MetricsGroupBy] | None, + fiscal_year: bool, + rolling: bool, + ) -> dict[str, Any]: + """Query params shared by the entity, NAICS and PSC metrics endpoints.""" + params: dict[str, Any] = {} + if group_by: + params["group_by"] = group_by if isinstance(group_by, str) else ",".join(group_by) + if fiscal_year: + params["fiscal_year"] = "true" + if rolling: + params["rolling"] = "true" + return params def get_naics( self, @@ -5647,11 +5681,25 @@ def get_naics( params["flat_lists"] = "true" return self._get(f"/api/naics/{code}/", params) - def get_naics_metrics(self, code: str, months: int, period_grouping: str) -> dict[str, Any]: - """Get rolling NAICS metrics (`/api/naics/{code}/metrics/{months}/{period_grouping}/`).""" + def get_naics_metrics( + self, + code: str, + months: int, + period_grouping: str, + group_by: MetricsGroupBy | list[MetricsGroupBy] | None = None, + fiscal_year: bool = False, + rolling: bool = False, + ) -> dict[str, Any]: + """Get rolling NAICS metrics (`/api/naics/{code}/metrics/{months}/{period_grouping}/`). + + Takes the same ``group_by``, ``fiscal_year`` and ``rolling`` options as :meth:`get_psc_metrics`. + """ if not code: raise TangoValidationError("NAICS code is required") - return self._get(f"/api/naics/{code}/metrics/{months}/{period_grouping}/") + return self._get( + f"/api/naics/{code}/metrics/{months}/{period_grouping}/", + self._metrics_params(group_by, fiscal_year, rolling), + ) def get_business_type( self, @@ -6026,13 +6074,26 @@ def list_entity_lcats( meta=data.get("meta"), ) - def get_entity_metrics(self, uei: str, months: int, period_grouping: str) -> dict[str, Any]: + def get_entity_metrics( + self, + uei: str, + months: int, + period_grouping: str, + group_by: MetricsGroupBy | list[MetricsGroupBy] | None = None, + fiscal_year: bool = False, + rolling: bool = False, + ) -> dict[str, Any]: """Get rolling metrics for an entity (`/api/entities/{uei}/metrics/{months}/{period_grouping}/`). + + Takes the same ``group_by``, ``fiscal_year`` and ``rolling`` options as :meth:`get_psc_metrics`. """ if not uei: raise TangoValidationError("UEI is required") - return self._get(f"/api/entities/{uei}/metrics/{months}/{period_grouping}/") + return self._get( + f"/api/entities/{uei}/metrics/{months}/{period_grouping}/", + self._metrics_params(group_by, fiscal_year, rolling), + ) # ============================================================================ # IDV sub-resources diff --git a/tests/test_api_parity.py b/tests/test_api_parity.py index 1987eb3..5d64093 100644 --- a/tests/test_api_parity.py +++ b/tests/test_api_parity.py @@ -372,6 +372,23 @@ def test_get_psc_metrics(self, mock_request: Mock) -> None: client = TangoClient(api_key="x", base_url="https://t.example") client.get_psc_metrics("R425", 12, "month") assert mock_request.call_args[1]["url"].endswith("/api/psc/R425/metrics/12/month/") + assert not mock_request.call_args[1]["params"] + + def test_metrics_query_params(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"results": []}) + client = TangoClient(api_key="x", base_url="https://t.example") + client.get_psc_metrics("R425", 12, "year", group_by="department", fiscal_year=True) + assert mock_request.call_args[1]["params"] == { + "group_by": "department", + "fiscal_year": "true", + } + client.get_naics_metrics( + "541511", 12, "year", group_by=["agency", "department"], rolling=True + ) + assert mock_request.call_args[1]["params"] == { + "group_by": "agency,department", + "rolling": "true", + } def test_get_naics(self, mock_request: Mock) -> None: mock_request.return_value = _mock_response({"code": "541511"}) @@ -409,6 +426,15 @@ def test_get_entity_metrics(self, mock_request: Mock) -> None: client.get_entity_metrics("UEI1", 24, "quarter") assert mock_request.call_args[1]["url"].endswith("/api/entities/UEI1/metrics/24/quarter/") + def test_get_entity_metrics_by_department(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"results": []}) + client = TangoClient(api_key="x", base_url="https://t.example") + client.get_entity_metrics("UEI1", 60, "year", group_by="department", fiscal_year=True) + assert mock_request.call_args[1]["params"] == { + "group_by": "department", + "fiscal_year": "true", + } + def test_list_entity_lcats(self, mock_request: Mock) -> None: mock_request.return_value = _mock_response( {"count": 0, "next": None, "previous": None, "results": []} diff --git a/uv.lock b/uv.lock index 66928ee..395068d 100644 --- a/uv.lock +++ b/uv.lock @@ -515,7 +515,7 @@ wheels = [ [[package]] name = "tango-python" -version = "1.18.2" +version = "1.19.0" source = { editable = "." } dependencies = [ { name = "httpx" },