tango-python 1.0.0__py3-none-any.whl → 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tango/__init__.py CHANGED
@@ -9,6 +9,7 @@ from .exceptions import (
9
9
  TangoValidationError,
10
10
  )
11
11
  from .models import (
12
+ BudgetAccount,
12
13
  GsaElibraryContract,
13
14
  ITDashboardInvestment,
14
15
  PaginatedResponse,
@@ -43,7 +44,7 @@ from .webhooks import (
43
44
  )
44
45
  from .webhooks.receiver import Delivery, WebhookReceiver
45
46
 
46
- __version__ = "1.0.0"
47
+ __version__ = "1.1.0"
47
48
  __all__ = [
48
49
  "TangoClient",
49
50
  "TangoAPIError",
@@ -54,6 +55,7 @@ __all__ = [
54
55
  "RateLimitInfo",
55
56
  "ResolveCandidate",
56
57
  "ResolveResult",
58
+ "BudgetAccount",
57
59
  "GsaElibraryContract",
58
60
  "ITDashboardInvestment",
59
61
  "PaginatedResponse",
tango/client.py CHANGED
@@ -4,7 +4,7 @@ import os
4
4
  import warnings
5
5
  from datetime import date, datetime
6
6
  from decimal import Decimal
7
- from typing import Any
7
+ from typing import Any, Literal, cast
8
8
  from urllib.parse import urljoin
9
9
 
10
10
  import httpx
@@ -21,6 +21,7 @@ from tango.models import (
21
21
  OTA,
22
22
  OTIDV,
23
23
  Agency,
24
+ BudgetAccount,
24
25
  BusinessType,
25
26
  Contract,
26
27
  Entity,
@@ -220,9 +221,7 @@ class TangoClient:
220
221
  picking one — that ambiguity would hide caller bugs.
221
222
  """
222
223
  if json_data is not None and json is not None:
223
- raise TangoValidationError(
224
- "_post: pass `json_data` or `json`, not both."
225
- )
224
+ raise TangoValidationError("_post: pass `json_data` or `json`, not both.")
226
225
  body = json_data if json_data is not None else json
227
226
  if body is None:
228
227
  body = {}
@@ -243,9 +242,7 @@ class TangoClient:
243
242
  picking one — that ambiguity would hide caller bugs.
244
243
  """
245
244
  if json_data is not None and json is not None:
246
- raise TangoValidationError(
247
- "_patch: pass `json_data` or `json`, not both."
248
- )
245
+ raise TangoValidationError("_patch: pass `json_data` or `json`, not both.")
249
246
  body = json_data if json_data is not None else json
250
247
  if body is None:
251
248
  body = {}
@@ -673,8 +670,9 @@ class TangoClient:
673
670
  params: dict[str, Any] = {"limit": min(limit, 100)}
674
671
  if cursor:
675
672
  params["cursor"] = cursor
676
- else:
677
- params["page"] = 1
673
+ # /api/contracts/ is cursor-only (KeysetPagination). When no cursor is
674
+ # supplied, send neither page nor cursor — the API returns the first
675
+ # page by default. (Previously sent page=1, which the endpoint ignores.)
678
676
 
679
677
  # Handle legacy filters parameter (backward compatibility)
680
678
  filter_dict: dict[str, Any] = {}
@@ -772,6 +770,89 @@ class TangoClient:
772
770
  cursor=data.get("cursor"),
773
771
  )
774
772
 
773
+ def get_contract(
774
+ self,
775
+ key: str,
776
+ shape: str | None = None,
777
+ flat: bool = False,
778
+ flat_lists: bool = False,
779
+ joiner: str = ".",
780
+ ) -> Any:
781
+ """Get a single contract by key (`/api/contracts/{key}/`)."""
782
+ params: dict[str, Any] = {}
783
+ if shape is None:
784
+ shape = ShapeConfig.CONTRACTS_MINIMAL
785
+ if shape:
786
+ params["shape"] = shape
787
+ if flat:
788
+ params["flat"] = "true"
789
+ if joiner:
790
+ params["joiner"] = joiner
791
+ if flat_lists:
792
+ params["flat_lists"] = "true"
793
+ data = self._get(f"/api/contracts/{key}/", params)
794
+ return self._parse_response_with_shape(
795
+ data, shape, Contract, flat, flat_lists, joiner=joiner
796
+ )
797
+
798
+ def get_contract_subawards(
799
+ self,
800
+ key: str,
801
+ page: int = 1,
802
+ limit: int = 25,
803
+ shape: str | None = None,
804
+ flat: bool = False,
805
+ flat_lists: bool = False,
806
+ ordering: str | None = None,
807
+ ) -> PaginatedResponse:
808
+ """List subawards under a contract (`/api/contracts/{key}/subawards/`)."""
809
+ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
810
+ if shape is None:
811
+ shape = ShapeConfig.SUBAWARDS_MINIMAL
812
+ if shape:
813
+ params["shape"] = shape
814
+ if flat:
815
+ params["flat"] = "true"
816
+ if flat_lists:
817
+ params["flat_lists"] = "true"
818
+ if ordering:
819
+ params["ordering"] = ordering
820
+ data = self._get(f"/api/contracts/{key}/subawards/", params)
821
+ raw_results = data.get("results") or []
822
+ results = [
823
+ self._parse_response_with_shape(obj, shape, Subaward, flat, flat_lists)
824
+ for obj in raw_results
825
+ ]
826
+ return PaginatedResponse(
827
+ count=int(data.get("count") or len(results)),
828
+ next=data.get("next"),
829
+ previous=data.get("previous"),
830
+ results=results,
831
+ cursor=data.get("cursor"),
832
+ )
833
+
834
+ def get_contract_transactions(
835
+ self,
836
+ key: str,
837
+ limit: int = 100,
838
+ cursor: str | None = None,
839
+ ordering: str | None = None,
840
+ ) -> PaginatedResponse:
841
+ """List transactions under a contract (`/api/contracts/{key}/transactions/`)."""
842
+ params: dict[str, Any] = {"limit": min(limit, 500)}
843
+ if cursor:
844
+ params["cursor"] = cursor
845
+ if ordering:
846
+ params["ordering"] = ordering
847
+ data = self._get(f"/api/contracts/{key}/transactions/", params)
848
+ return PaginatedResponse(
849
+ count=int(data.get("count") or len(data.get("results") or [])),
850
+ next=data.get("next"),
851
+ previous=data.get("previous"),
852
+ results=data.get("results") or [],
853
+ cursor=data.get("cursor"),
854
+ )
855
+
775
856
  # ============================================================================
776
857
  # IDVs (Awards)
777
858
  # ============================================================================
@@ -1246,6 +1327,59 @@ class TangoClient:
1246
1327
  data = self._get(f"/api/otidvs/{key}/", params)
1247
1328
  return self._parse_response_with_shape(data, shape, OTIDV, flat, flat_lists, joiner=joiner)
1248
1329
 
1330
+ def list_otidv_awards(
1331
+ self,
1332
+ key: str,
1333
+ limit: int = 25,
1334
+ cursor: str | None = None,
1335
+ shape: str | None = None,
1336
+ flat: bool = False,
1337
+ flat_lists: bool = False,
1338
+ joiner: str = ".",
1339
+ ordering: str | None = None,
1340
+ ) -> PaginatedResponse:
1341
+ """List child awards under an OTIDV (`/api/otidvs/{key}/awards/`).
1342
+
1343
+ Args:
1344
+ key: The OTIDV key.
1345
+ limit: Results per page (max 100).
1346
+ cursor: Cursor token for keyset pagination.
1347
+ shape: Response shape string (defaults to minimal contract shape).
1348
+ flat: If True, flatten nested objects using dot notation.
1349
+ flat_lists: If True, flatten arrays using indexed keys.
1350
+ joiner: Separator used when flattening.
1351
+ ordering: Server-side sort (prefix with '-' for descending).
1352
+ """
1353
+ params: dict[str, Any] = {"limit": min(limit, 100)}
1354
+ if cursor:
1355
+ params["cursor"] = cursor
1356
+ if shape is None:
1357
+ shape = ShapeConfig.CONTRACTS_MINIMAL
1358
+ if shape:
1359
+ params["shape"] = shape
1360
+ if flat:
1361
+ params["flat"] = "true"
1362
+ if joiner:
1363
+ params["joiner"] = joiner
1364
+ if flat_lists:
1365
+ params["flat_lists"] = "true"
1366
+ if ordering:
1367
+ params["ordering"] = ordering
1368
+ data = self._get(f"/api/otidvs/{key}/awards/", params)
1369
+ raw_results = data.get("results") or []
1370
+ results = [
1371
+ self._parse_response_with_shape(obj, shape, Contract, flat, flat_lists, joiner=joiner)
1372
+ for obj in raw_results
1373
+ ]
1374
+ return PaginatedResponse(
1375
+ count=int(data.get("count") or len(results)),
1376
+ next=data.get("next"),
1377
+ previous=data.get("previous"),
1378
+ results=results,
1379
+ cursor=data.get("cursor"),
1380
+ page_metadata=data.get("page_metadata"),
1381
+ )
1382
+
1249
1383
  def list_subawards(
1250
1384
  self,
1251
1385
  page: int = 1,
@@ -1300,6 +1434,31 @@ class TangoClient:
1300
1434
  results=results,
1301
1435
  )
1302
1436
 
1437
+ def get_subaward(
1438
+ self,
1439
+ key: str,
1440
+ shape: str | None = None,
1441
+ flat: bool = False,
1442
+ flat_lists: bool = False,
1443
+ joiner: str = ".",
1444
+ ) -> Any:
1445
+ """Get a single subaward by key (`/api/subawards/{key}/`)."""
1446
+ params: dict[str, Any] = {}
1447
+ if shape is None:
1448
+ shape = ShapeConfig.SUBAWARDS_MINIMAL
1449
+ if shape:
1450
+ params["shape"] = shape
1451
+ if flat:
1452
+ params["flat"] = "true"
1453
+ if joiner:
1454
+ params["joiner"] = joiner
1455
+ if flat_lists:
1456
+ params["flat_lists"] = "true"
1457
+ data = self._get(f"/api/subawards/{key}/", params)
1458
+ return self._parse_response_with_shape(
1459
+ data, shape, Subaward, flat, flat_lists, joiner=joiner
1460
+ )
1461
+
1303
1462
  # ============================================================================
1304
1463
  # GSA eLibrary Contracts
1305
1464
  # ============================================================================
@@ -1922,6 +2081,12 @@ class TangoClient:
1922
2081
  data = self._get(f"/api/entities/{key}/", params)
1923
2082
  return self._parse_response_with_shape(data, shape, Entity, flat, flat_lists)
1924
2083
 
2084
+ def get_entity_budget_flows(self, uei: str) -> dict[str, Any]:
2085
+ """Get budget flows for an entity (`/api/entities/{uei}/budget-flows/`)."""
2086
+ if not uei:
2087
+ raise TangoValidationError("UEI is required")
2088
+ return self._get(f"/api/entities/{uei}/budget-flows/")
2089
+
1925
2090
  # Forecast endpoints
1926
2091
  def list_forecasts(
1927
2092
  self,
@@ -2014,6 +2179,31 @@ class TangoClient:
2014
2179
  results=results,
2015
2180
  )
2016
2181
 
2182
+ def get_forecast(
2183
+ self,
2184
+ id: str,
2185
+ shape: str | None = None,
2186
+ flat: bool = False,
2187
+ flat_lists: bool = False,
2188
+ joiner: str = ".",
2189
+ ) -> Any:
2190
+ """Get a single forecast by id (`/api/forecasts/{id}/`)."""
2191
+ params: dict[str, Any] = {}
2192
+ if shape is None:
2193
+ shape = ShapeConfig.FORECASTS_MINIMAL
2194
+ if shape:
2195
+ params["shape"] = shape
2196
+ if flat:
2197
+ params["flat"] = "true"
2198
+ if joiner:
2199
+ params["joiner"] = joiner
2200
+ if flat_lists:
2201
+ params["flat_lists"] = "true"
2202
+ data = self._get(f"/api/forecasts/{id}/", params)
2203
+ return self._parse_response_with_shape(
2204
+ data, shape, Forecast, flat, flat_lists, joiner=joiner
2205
+ )
2206
+
2017
2207
  # Opportunity endpoints
2018
2208
  def list_opportunities(
2019
2209
  self,
@@ -2112,6 +2302,31 @@ class TangoClient:
2112
2302
  results=results,
2113
2303
  )
2114
2304
 
2305
+ def get_opportunity(
2306
+ self,
2307
+ opportunity_id: str,
2308
+ shape: str | None = None,
2309
+ flat: bool = False,
2310
+ flat_lists: bool = False,
2311
+ joiner: str = ".",
2312
+ ) -> Any:
2313
+ """Get a single opportunity by id (`/api/opportunities/{opportunity_id}/`)."""
2314
+ params: dict[str, Any] = {}
2315
+ if shape is None:
2316
+ shape = ShapeConfig.OPPORTUNITIES_MINIMAL
2317
+ if shape:
2318
+ params["shape"] = shape
2319
+ if flat:
2320
+ params["flat"] = "true"
2321
+ if joiner:
2322
+ params["joiner"] = joiner
2323
+ if flat_lists:
2324
+ params["flat_lists"] = "true"
2325
+ data = self._get(f"/api/opportunities/{opportunity_id}/", params)
2326
+ return self._parse_response_with_shape(
2327
+ data, shape, Opportunity, flat, flat_lists, joiner=joiner
2328
+ )
2329
+
2115
2330
  # Notice endpoints
2116
2331
  def list_notices(
2117
2332
  self,
@@ -2201,6 +2416,29 @@ class TangoClient:
2201
2416
  results=results,
2202
2417
  )
2203
2418
 
2419
+ def get_notice(
2420
+ self,
2421
+ notice_id: str,
2422
+ shape: str | None = None,
2423
+ flat: bool = False,
2424
+ flat_lists: bool = False,
2425
+ joiner: str = ".",
2426
+ ) -> Any:
2427
+ """Get a single notice by id (`/api/notices/{notice_id}/`)."""
2428
+ params: dict[str, Any] = {}
2429
+ if shape is None:
2430
+ shape = ShapeConfig.NOTICES_MINIMAL
2431
+ if shape:
2432
+ params["shape"] = shape
2433
+ if flat:
2434
+ params["flat"] = "true"
2435
+ if joiner:
2436
+ params["joiner"] = joiner
2437
+ if flat_lists:
2438
+ params["flat_lists"] = "true"
2439
+ data = self._get(f"/api/notices/{notice_id}/", params)
2440
+ return self._parse_response_with_shape(data, shape, Notice, flat, flat_lists, joiner=joiner)
2441
+
2204
2442
  # Protest endpoints
2205
2443
  # See https://tango.makegov.com/docs/api-reference/protests.md
2206
2444
  # Note: Protests API does not support ordering (returns 400 if provided).
@@ -2328,6 +2566,170 @@ class TangoClient:
2328
2566
  data = self._get(f"/api/protests/{case_id}/", params)
2329
2567
  return self._parse_response_with_shape(data, shape, Protest, flat, flat_lists)
2330
2568
 
2569
+ # ============================================================================
2570
+ # Budget (federal account x fiscal year rollups)
2571
+ # ============================================================================
2572
+
2573
+ def list_budget_accounts(
2574
+ self,
2575
+ page: int = 1,
2576
+ limit: int = 25,
2577
+ shape: str | None = None,
2578
+ flat: bool = False,
2579
+ flat_lists: bool = False,
2580
+ federal_account_symbol: str | None = None,
2581
+ fiscal_year: int | None = None,
2582
+ fiscal_year_gte: int | None = None,
2583
+ fiscal_year_lte: int | None = None,
2584
+ agency_code: str | None = None,
2585
+ bureau_name: str | None = None,
2586
+ account_title: str | None = None,
2587
+ bea_category: str | None = None,
2588
+ on_off_budget: str | None = None,
2589
+ subfunction_code: str | None = None,
2590
+ search: str | None = None,
2591
+ ordering: str | None = None,
2592
+ ) -> PaginatedResponse:
2593
+ """List budget accounts (`/api/budget/accounts/`).
2594
+
2595
+ One row per ``(federal_account_symbol, fiscal_year)`` covering the full
2596
+ budget lifecycle, pre-computed ratios + trends, the
2597
+ contract/assistance/unlinked breakdown, and request-vs-actual spend.
2598
+
2599
+ Args:
2600
+ page: Page number.
2601
+ limit: Results per page (max 100).
2602
+ shape: Response shape string (defaults to minimal shape).
2603
+ flat: If True, flatten nested objects using dot notation.
2604
+ flat_lists: If True, flatten arrays using indexed keys.
2605
+ federal_account_symbol: Exact federal account symbol.
2606
+ fiscal_year: Fiscal year (exact).
2607
+ fiscal_year_gte: Fiscal year >=.
2608
+ fiscal_year_lte: Fiscal year <=.
2609
+ agency_code: Agency code (exact).
2610
+ bureau_name: Bureau name (exact).
2611
+ account_title: Account title (icontains).
2612
+ bea_category: BEA category (exact).
2613
+ on_off_budget: On/off budget flag (exact).
2614
+ subfunction_code: Subfunction code (exact).
2615
+ search: Full-text search over account_title/agency_name/bureau_name.
2616
+ ordering: Sort field (prefix with '-' for descending).
2617
+ """
2618
+ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
2619
+ if shape is None:
2620
+ shape = ShapeConfig.BUDGET_ACCOUNTS_MINIMAL
2621
+ if shape:
2622
+ params["shape"] = shape
2623
+ if flat:
2624
+ params["flat"] = "true"
2625
+ if flat_lists:
2626
+ params["flat_lists"] = "true"
2627
+ for key, val in (
2628
+ ("federal_account_symbol", federal_account_symbol),
2629
+ ("fiscal_year", fiscal_year),
2630
+ ("fiscal_year__gte", fiscal_year_gte),
2631
+ ("fiscal_year__lte", fiscal_year_lte),
2632
+ ("agency_code", agency_code),
2633
+ ("bureau_name", bureau_name),
2634
+ ("account_title__icontains", account_title),
2635
+ ("bea_category", bea_category),
2636
+ ("on_off_budget", on_off_budget),
2637
+ ("subfunction_code", subfunction_code),
2638
+ ("search", search),
2639
+ ("ordering", ordering),
2640
+ ):
2641
+ if val is not None:
2642
+ params[key] = val
2643
+ data = self._get("/api/budget/accounts/", params)
2644
+ results = [
2645
+ self._parse_response_with_shape(obj, shape, BudgetAccount, flat, flat_lists)
2646
+ for obj in data.get("results", [])
2647
+ ]
2648
+ return PaginatedResponse(
2649
+ count=data.get("count", 0),
2650
+ next=data.get("next"),
2651
+ previous=data.get("previous"),
2652
+ results=results,
2653
+ )
2654
+
2655
+ def get_budget_account(
2656
+ self,
2657
+ id: str | int,
2658
+ shape: str | None = None,
2659
+ flat: bool = False,
2660
+ flat_lists: bool = False,
2661
+ joiner: str = ".",
2662
+ ) -> Any:
2663
+ """Get a single budget account by id (`/api/budget/accounts/{id}/`)."""
2664
+ params: dict[str, Any] = {}
2665
+ if shape is None:
2666
+ shape = ShapeConfig.BUDGET_ACCOUNTS_MINIMAL
2667
+ if shape:
2668
+ params["shape"] = shape
2669
+ if flat:
2670
+ params["flat"] = "true"
2671
+ if joiner:
2672
+ params["joiner"] = joiner
2673
+ if flat_lists:
2674
+ params["flat_lists"] = "true"
2675
+ data = self._get(f"/api/budget/accounts/{id}/", params)
2676
+ return self._parse_response_with_shape(
2677
+ data, shape, BudgetAccount, flat, flat_lists, joiner=joiner
2678
+ )
2679
+
2680
+ def get_budget_account_quarters(
2681
+ self,
2682
+ id: str | int,
2683
+ tas: str | None = None,
2684
+ limit: int = 25,
2685
+ ) -> PaginatedResponse:
2686
+ """Get quarterly TAS-grain flow for a budget account.
2687
+
2688
+ (`/api/budget/accounts/{id}/quarters/`). FY21+ only.
2689
+
2690
+ Args:
2691
+ id: Budget account id.
2692
+ tas: Narrow to a single Treasury Account Symbol.
2693
+ limit: Results per page (max 100).
2694
+ """
2695
+ params: dict[str, Any] = {"limit": min(limit, 100)}
2696
+ if tas:
2697
+ params["tas"] = tas
2698
+ data = self._get(f"/api/budget/accounts/{id}/quarters/", params)
2699
+ return PaginatedResponse(
2700
+ count=int(data.get("count") or len(data.get("results") or [])),
2701
+ next=data.get("next"),
2702
+ previous=data.get("previous"),
2703
+ results=data.get("results") or [],
2704
+ )
2705
+
2706
+ def get_budget_account_recipients(
2707
+ self,
2708
+ id: str | int,
2709
+ funding_organization_id: str | None = None,
2710
+ limit: int = 25,
2711
+ ) -> PaginatedResponse:
2712
+ """Get funding-office x recipient contract-flow detail for a budget account.
2713
+
2714
+ (`/api/budget/accounts/{id}/recipients/`).
2715
+
2716
+ Args:
2717
+ id: Budget account id.
2718
+ funding_organization_id: Narrow to a single funding office
2719
+ (Organization UUID).
2720
+ limit: Results per page (max 100).
2721
+ """
2722
+ params: dict[str, Any] = {"limit": min(limit, 100)}
2723
+ if funding_organization_id:
2724
+ params["funding_organization_id"] = funding_organization_id
2725
+ data = self._get(f"/api/budget/accounts/{id}/recipients/", params)
2726
+ return PaginatedResponse(
2727
+ count=int(data.get("count") or len(data.get("results") or [])),
2728
+ next=data.get("next"),
2729
+ previous=data.get("previous"),
2730
+ results=data.get("results") or [],
2731
+ )
2732
+
2331
2733
  # Grant endpoints
2332
2734
  def list_grants(
2333
2735
  self,
@@ -2341,6 +2743,7 @@ class TangoClient:
2341
2743
  cfda_number: str | None = None,
2342
2744
  funding_categories: str | None = None,
2343
2745
  funding_instruments: str | None = None,
2746
+ grant_id: str | None = None,
2344
2747
  opportunity_number: str | None = None,
2345
2748
  ordering: str | None = None,
2346
2749
  posted_date_after: str | None = None,
@@ -2364,6 +2767,8 @@ class TangoClient:
2364
2767
  cfda_number: CFDA number filter
2365
2768
  funding_categories: Funding categories filter
2366
2769
  funding_instruments: Funding instruments filter
2770
+ grant_id: Filter by grant ID (matches the detail-endpoint
2771
+ identifier). Supports multi-value OR via '|' (e.g. "123|456").
2367
2772
  opportunity_number: Opportunity number filter
2368
2773
  ordering: Sort field (prefix with '-' for descending)
2369
2774
  posted_date_after: Posted date after
@@ -2390,6 +2795,7 @@ class TangoClient:
2390
2795
  ("cfda_number", cfda_number),
2391
2796
  ("funding_categories", funding_categories),
2392
2797
  ("funding_instruments", funding_instruments),
2798
+ ("grant_id", grant_id),
2393
2799
  ("opportunity_number", opportunity_number),
2394
2800
  ("ordering", ordering),
2395
2801
  ("posted_date_after", posted_date_after),
@@ -2417,6 +2823,29 @@ class TangoClient:
2417
2823
  results=results,
2418
2824
  )
2419
2825
 
2826
+ def get_grant(
2827
+ self,
2828
+ grant_id: str,
2829
+ shape: str | None = None,
2830
+ flat: bool = False,
2831
+ flat_lists: bool = False,
2832
+ joiner: str = ".",
2833
+ ) -> Any:
2834
+ """Get a single grant by its grant id (`/api/grants/{grant_id}/`)."""
2835
+ params: dict[str, Any] = {}
2836
+ if shape is None:
2837
+ shape = ShapeConfig.GRANTS_MINIMAL
2838
+ if shape:
2839
+ params["shape"] = shape
2840
+ if flat:
2841
+ params["flat"] = "true"
2842
+ if joiner:
2843
+ params["joiner"] = joiner
2844
+ if flat_lists:
2845
+ params["flat_lists"] = "true"
2846
+ data = self._get(f"/api/grants/{grant_id}/", params)
2847
+ return self._parse_response_with_shape(data, shape, Grant, flat, flat_lists, joiner=joiner)
2848
+
2420
2849
  # ============================================================================
2421
2850
  # Webhooks (v2)
2422
2851
  # ============================================================================
@@ -2630,13 +3059,13 @@ class TangoClient:
2630
3059
  return WebhookAlert(
2631
3060
  alert_id=str(data.get("alert_id") or data.get("id") or ""),
2632
3061
  name=str(data.get("name") or data.get("subscription_name") or ""),
2633
- query_type=(str(data["query_type"]) if data.get("query_type") is not None else None),
2634
- filters=data.get("filters") or data.get("filter_definition"),
3062
+ query_type=str(data.get("query_type") or ""),
3063
+ filters=data.get("filters") or data.get("filter_definition") or {},
2635
3064
  frequency=str(data.get("frequency", "realtime")),
2636
3065
  cron_expression=(
2637
3066
  str(data["cron_expression"]) if data.get("cron_expression") is not None else None
2638
3067
  ),
2639
- status=str(data.get("status", "active")),
3068
+ status=cast("Literal['active', 'paused']", data.get("status", "active")),
2640
3069
  created_at=str(data.get("created_at", "")),
2641
3070
  last_checked_at=(
2642
3071
  str(data["last_checked_at"]) if data.get("last_checked_at") is not None else None
tango/models.py CHANGED
@@ -281,21 +281,66 @@ class AwardTransaction:
281
281
  obligated: Decimal | None = None
282
282
 
283
283
 
284
+ @dataclass
285
+ class OrganizationOfficePayload:
286
+ """Schema definition for OrganizationOfficePayload (not used for instances).
287
+
288
+ Returned for a contract's ``awarding_office`` / ``funding_office``.
289
+ """
290
+
291
+ organization_id: str | None = None
292
+ office_code: str | None = None
293
+ office_name: str | None = None
294
+ agency_code: str | None = None
295
+ agency_name: str | None = None
296
+ department_code: int | None = None
297
+ department_name: str | None = None
298
+
299
+
284
300
  @dataclass
285
301
  class Contract:
286
- """Schema definition for Contract (not used for instances)"""
302
+ """Schema definition for Contract (not used for instances).
287
303
 
288
- id: str
289
- award_id: str
290
- recipient_name: str
291
- description: str
292
- award_amount: Decimal | None = None
304
+ Mirrors the API ``ContractList`` schema (``ContractListSerializer``).
305
+ ``/api/contracts/`` is shape-on-demand: which fields appear in a response
306
+ depends on the ``?shape=`` query param, so every field is optional.
307
+ """
308
+
309
+ key: str | None = None
310
+ piid: str | None = None
293
311
  award_date: date | None = None
294
312
  fiscal_year: int | None = None
313
+ obligated: Decimal | None = None
314
+ base_and_exercised_options_value: Decimal | None = None
315
+ total_contract_value: Decimal | None = None
316
+ naics_code: int | None = None
317
+ psc_code: str | None = None
318
+ set_aside: str | None = None
319
+ solicitation_identifier: str | None = None
320
+ description: str | None = None
321
+ awarding_office: OrganizationOfficePayload | None = None
322
+ funding_office: OrganizationOfficePayload | None = None
295
323
  recipient: RecipientProfile | None = None
324
+ parent_award: ParentAward | None = None
325
+ legislative_mandates: LegislativeMandates | None = None
326
+ place_of_performance: PlaceOfPerformance | None = None
327
+ subawards_summary: SubawardsSummary | None = None
328
+
329
+ # --- Deprecated fields (never returned by the API; removed in 2.0.0) ---
330
+ # Declared with None defaults so existing consumers reading these do not
331
+ # raise AttributeError; they always resolve to None.
332
+ id: str | None = None
333
+ """.. deprecated:: Never returned by the API. Removed in 2.0.0."""
334
+ award_id: str | None = None
335
+ """.. deprecated:: Never returned by the API. Removed in 2.0.0."""
336
+ recipient_name: str | None = None
337
+ """.. deprecated:: Use ``recipient.display_name``. Removed in 2.0.0."""
338
+ award_amount: Decimal | None = None
339
+ """.. deprecated:: Use ``obligated`` / ``total_contract_value``. Removed in 2.0.0."""
296
340
  awarding_agency: Agency | None = None
341
+ """.. deprecated:: Use ``awarding_office``. Removed in 2.0.0."""
297
342
  funding_agency: Agency | None = None
298
- place_of_performance: Location | None = None
343
+ """.. deprecated:: Use ``funding_office``. Removed in 2.0.0."""
299
344
 
300
345
 
301
346
  @dataclass
@@ -626,9 +671,7 @@ class WebhookSamplePayloadAllResponse(TypedDict):
626
671
  note: str
627
672
 
628
673
 
629
- WebhookSamplePayloadResponse = (
630
- WebhookSamplePayloadSingleResponse | WebhookSamplePayloadAllResponse
631
- )
674
+ WebhookSamplePayloadResponse = WebhookSamplePayloadSingleResponse | WebhookSamplePayloadAllResponse
632
675
 
633
676
 
634
677
  @dataclass
@@ -713,6 +756,90 @@ class ValidateResult:
713
756
  errors: list[str] | None = None
714
757
 
715
758
 
759
+ @dataclass
760
+ class BudgetAccount:
761
+ """Schema definition for BudgetAccount (not used for instances).
762
+
763
+ Federal account x fiscal year budget rollup. ``/api/budget/accounts/`` is
764
+ shape-on-demand: which fields appear depends on the ``?shape=`` query param,
765
+ so every field is optional. Mirrors the API ``BudgetAccount`` schema.
766
+ """
767
+
768
+ id: int | None = None
769
+ federal_account_symbol: str | None = None
770
+ fiscal_year: int | None = None
771
+ agency_code: str | None = None
772
+ agency_name: str | None = None
773
+ bureau_name: str | None = None
774
+ account_title: str | None = None
775
+ bea_category: str | None = None
776
+ on_off_budget: str | None = None
777
+ subfunction_code: str | None = None
778
+ # Lifecycle
779
+ requested_ba: Decimal | None = None
780
+ enacted_ba: Decimal | None = None
781
+ apportioned: Decimal | None = None
782
+ obligated_total: Decimal | None = None
783
+ outlayed_total: Decimal | None = None
784
+ unobligated_balance: Decimal | None = None
785
+ # Contract / assistance / unlinked breakdown
786
+ contract_obligated: Decimal | None = None
787
+ contract_outlayed: Decimal | None = None
788
+ n_contracts: int | None = None
789
+ n_unique_contract_recipients: int | None = None
790
+ assistance_obligated: Decimal | None = None
791
+ assistance_outlayed: Decimal | None = None
792
+ n_grants: int | None = None
793
+ n_unique_grant_recipients: int | None = None
794
+ unlinked_obligated: Decimal | None = None
795
+ contract_share_of_obligated: Decimal | None = None
796
+ contract_share_of_obligated_capped: Decimal | None = None
797
+ contract_share_capped_flag: bool | None = None
798
+ assistance_share_of_obligated: Decimal | None = None
799
+ assistance_share_of_obligated_capped: Decimal | None = None
800
+ assistance_share_capped_flag: bool | None = None
801
+ # Forward-look
802
+ next_year_requested_ba: Decimal | None = None
803
+ ba_growth_next_year: Decimal | None = None
804
+ ba_growth_next_year_pct: Decimal | None = None
805
+ # Ratios
806
+ enacted_to_requested_pct: Decimal | None = None
807
+ enacted_to_requested_pct_capped: Decimal | None = None
808
+ enacted_to_requested_pct_capped_flag: bool | None = None
809
+ apportioned_to_enacted_pct: Decimal | None = None
810
+ apportioned_to_enacted_pct_capped: Decimal | None = None
811
+ apportioned_to_enacted_pct_capped_flag: bool | None = None
812
+ obligated_to_apportioned_pct: Decimal | None = None
813
+ obligated_to_apportioned_pct_capped: Decimal | None = None
814
+ obligated_to_apportioned_pct_capped_flag: bool | None = None
815
+ obligated_to_enacted_pct: Decimal | None = None
816
+ obligated_to_enacted_pct_capped: Decimal | None = None
817
+ obligated_to_enacted_pct_capped_flag: bool | None = None
818
+ outlayed_to_obligated_pct: Decimal | None = None
819
+ outlayed_to_obligated_pct_capped: Decimal | None = None
820
+ outlayed_to_obligated_pct_capped_flag: bool | None = None
821
+ unobligated_pct: Decimal | None = None
822
+ # Trends
823
+ enacted_ba_yoy_pct: Decimal | None = None
824
+ obligated_yoy_pct: Decimal | None = None
825
+ contract_obligated_yoy_pct: Decimal | None = None
826
+ enacted_ba_5yr_cagr: Decimal | None = None
827
+ contract_obligated_5yr_cagr: Decimal | None = None
828
+ # Request-vs-actual
829
+ requested_contractual_services: Decimal | None = None
830
+ requested_personnel_share: Decimal | None = None
831
+ actual_vs_requested_contract: Decimal | None = None
832
+ actual_vs_requested_contract_capped: Decimal | None = None
833
+ actual_vs_requested_contract_capped_flag: bool | None = None
834
+ # Provenance + narrative
835
+ appendix_pdf_url: str | None = None
836
+ account_narrative_excerpt: str | None = None
837
+ top_contract_recipients: list[Any] | None = None
838
+ top_grant_recipients: list[Any] | None = None
839
+ created: str | None = None
840
+ modified: str | None = None
841
+
842
+
716
843
  @dataclass
717
844
  class PaginatedResponse[T]:
718
845
  """Paginated API response
@@ -831,6 +958,18 @@ class ShapeConfig:
831
958
  "key,piid,award_date,obligated,total_contract_value,description,recipient(display_name,uei)"
832
959
  )
833
960
 
961
+ # Default for list_budget_accounts() / get_budget_account()
962
+ # Mirrors the API's BUDGET_ACCOUNT_DEFAULT_SHAPE.
963
+ BUDGET_ACCOUNTS_MINIMAL: Final = (
964
+ "id,federal_account_symbol,fiscal_year,agency_code,agency_name,bureau_name,"
965
+ "account_title,bea_category,on_off_budget,subfunction_code,"
966
+ "requested_ba,enacted_ba,apportioned,obligated_total,outlayed_total,"
967
+ "unobligated_balance,contract_obligated,contract_share_of_obligated_capped,"
968
+ "assistance_obligated,obligated_to_apportioned_pct_capped,"
969
+ "obligated_to_enacted_pct_capped,outlayed_to_obligated_pct_capped,"
970
+ "ba_growth_next_year_pct"
971
+ )
972
+
834
973
  # Default for list_organizations()
835
974
  ORGANIZATIONS_MINIMAL: Final = "key,fh_key,name,level,type,short_name"
836
975
 
@@ -1315,12 +1315,8 @@ SUBAWARD_SCHEMA: dict[str, FieldSchema] = {
1315
1315
  "recipient_dba_name": FieldSchema(
1316
1316
  name="recipient_dba_name", type=str, is_optional=True, is_list=False
1317
1317
  ),
1318
- "recipient_duns": FieldSchema(
1319
- name="recipient_duns", type=str, is_optional=True, is_list=False
1320
- ),
1321
- "recipient_name": FieldSchema(
1322
- name="recipient_name", type=str, is_optional=True, is_list=False
1323
- ),
1318
+ "recipient_duns": FieldSchema(name="recipient_duns", type=str, is_optional=True, is_list=False),
1319
+ "recipient_name": FieldSchema(name="recipient_name", type=str, is_optional=True, is_list=False),
1324
1320
  "recipient_parent_duns": FieldSchema(
1325
1321
  name="recipient_parent_duns", type=str, is_optional=True, is_list=False
1326
1322
  ),
@@ -1330,9 +1326,7 @@ SUBAWARD_SCHEMA: dict[str, FieldSchema] = {
1330
1326
  "recipient_parent_uei": FieldSchema(
1331
1327
  name="recipient_parent_uei", type=str, is_optional=True, is_list=False
1332
1328
  ),
1333
- "recipient_uei": FieldSchema(
1334
- name="recipient_uei", type=str, is_optional=True, is_list=False
1335
- ),
1329
+ "recipient_uei": FieldSchema(name="recipient_uei", type=str, is_optional=True, is_list=False),
1336
1330
  # Expandable nested objects
1337
1331
  "awarding_office": FieldSchema(
1338
1332
  name="awarding_office",
@@ -1493,6 +1487,10 @@ EXPLICIT_SCHEMAS: dict[str, dict[str, FieldSchema]] = {
1493
1487
  "Location": LOCATION_SCHEMA,
1494
1488
  "PlaceOfPerformance": PLACE_OF_PERFORMANCE_SCHEMA,
1495
1489
  "Competition": COMPETITION_SCHEMA,
1490
+ # Alias: CONTRACT_SCHEMA/IDV_SCHEMA reference the competition leaf as
1491
+ # "ContractOrIDVCompetition" (the models.py dataclass name); it is the same
1492
+ # field set as Competition. Register both so nested shape selection resolves.
1493
+ "ContractOrIDVCompetition": COMPETITION_SCHEMA,
1496
1494
  "ParentAward": PARENT_AWARD_SCHEMA,
1497
1495
  "LegislativeMandates": LEGISLATIVE_MANDATES_SCHEMA,
1498
1496
  "SubawardsSummary": SUBAWARDS_SUMMARY_SCHEMA,
tango/webhooks/cli.py CHANGED
@@ -12,7 +12,7 @@ import json
12
12
  import sys
13
13
  import threading
14
14
  from pathlib import Path
15
- from typing import Any
15
+ from typing import Any, cast
16
16
 
17
17
  try:
18
18
  import click
@@ -197,7 +197,7 @@ def simulate_cmd(
197
197
  from tango import TangoClient
198
198
 
199
199
  client = TangoClient(api_key=api_key, base_url=base_url)
200
- payload = client.get_webhook_sample_payload(event_type=event_type)
200
+ payload = cast("dict[str, Any]", client.get_webhook_sample_payload(event_type=event_type))
201
201
  else:
202
202
  payload = {"events": [{"event_type": "tango.cli.simulated"}]}
203
203
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tango-python
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Python SDK for the Tango API
5
5
  Project-URL: Homepage, https://github.com/makegov/tango-python
6
6
  Project-URL: Documentation, https://docs.makegov.com/tango-python
@@ -57,6 +57,10 @@ Description-Content-Type: text/markdown
57
57
 
58
58
  # Tango Python SDK
59
59
 
60
+ [![PyPI](https://img.shields.io/pypi/v/tango-python.svg)](https://pypi.org/project/tango-python/)
61
+ [![Python Versions](https://img.shields.io/pypi/pyversions/tango-python.svg)](https://pypi.org/project/tango-python/)
62
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
63
+
60
64
  A modern Python SDK for the [Tango API](https://tango.makegov.com) by MakeGov, featuring dynamic response shaping and comprehensive type hints.
61
65
 
62
66
  ## Features
@@ -1,9 +1,9 @@
1
- tango/__init__.py,sha256=36kjHn7zU7Fs9TTHShpW6cv_erODBUbfsBfQSyoVdeM,1872
2
- tango/client.py,sha256=52q9b2Lu9g6paqzKq8Okh_9GEbLeXsw7H0mvwzu0p5s,127938
1
+ tango/__init__.py,sha256=izUkwgWLTAsCiBQaJ67N_bmnWnc7uS2DUvSESzmeSFs,1912
2
+ tango/client.py,sha256=CAejxkwhyMwisVMGSdVqlKGkSNfNT7G60MtZb6BtEYk,143984
3
3
  tango/exceptions.py,sha256=aRvDm0dUCEtNDfRVYCX7SEDdd1WlIVVY6sN78Tzo-a0,3114
4
- tango/models.py,sha256=-UreQ2AWmvL3ikJNUp4H2GmlkdLysWAguBUk3VFS6Ro,27833
4
+ tango/models.py,sha256=QqUPtO7HJJDUaJDAMUkzvqR7pj1YIr8BetbS4palxc0,34261
5
5
  tango/shapes/__init__.py,sha256=7ea1WU74jp4znhNw-gXruag6m6eyPZtbVgbDFmFUWro,1072
6
- tango/shapes/explicit_schemas.py,sha256=D8CF5EertojSh5o37DkFnA32PcqbtoBUbfezfa9Vfy4,71175
6
+ tango/shapes/explicit_schemas.py,sha256=8HgxKpAi2U80JP_MnABnW1JpYJA05aoOZSJsJIcyIWQ,71421
7
7
  tango/shapes/factory.py,sha256=ytpMi5Uw72XZ8MimhuSsLDVXF3zO_Zt3_tAL6NF7LnU,34318
8
8
  tango/shapes/generator.py,sha256=61V1T3lm8Ps_KSMJAezQJLQVFbNKt1jtoLyhiqNtFTs,23380
9
9
  tango/shapes/models.py,sha256=h3pIhOqrrdlN953Y6r0oney5HFbKPOD-frRndRWimJ0,3018
@@ -11,12 +11,12 @@ tango/shapes/parser.py,sha256=-2Ap5jgeAvKsKtA-MaXPGE6PRB93GPV8GK99Z0geW_s,27468
11
11
  tango/shapes/schema.py,sha256=VRPOB1sBdjFyimNchrZKIpTHn83CyX4RfU9077aQtIU,14136
12
12
  tango/shapes/types.py,sha256=27jrAE0VIdrKaLjR_FK71hfIIGX2Tg3ex7REEBV1TFE,1301
13
13
  tango/webhooks/__init__.py,sha256=3bbiiGoB3s5iqqmQceroN0-MCSm-ZOZQx3M6JAknIUo,774
14
- tango/webhooks/cli.py,sha256=9J5jdgxeC2zHM0iif7GMpg_Mh7bv0HPQnADEvlv9OaE,13828
14
+ tango/webhooks/cli.py,sha256=f_vQbJ2AeSQjKnQo7MxHFyUB2SAKcgHYmGQULS0isqQ,13858
15
15
  tango/webhooks/receiver.py,sha256=5yhsVhlLyoxmOCGvmbynWAIlDB2OaCPVf1H4GA1SxmU,9279
16
16
  tango/webhooks/signing.py,sha256=92Ee-0B6PR7ZkvY3Np3gzl88-mtfKkh-I7lxqCe2lGw,2374
17
17
  tango/webhooks/simulate.py,sha256=g2Osa0FYU5mJuon07T2aUCtmkUoTEzsY261tlp76fF0,3165
18
- tango_python-1.0.0.dist-info/METADATA,sha256=tbMMjm-Trd9JgpynHZFaShwxAIlaBU0p4QQV8E_DEI8,20435
19
- tango_python-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
20
- tango_python-1.0.0.dist-info/entry_points.txt,sha256=kGLUbglUjuaAqEFvOZ1QuSW0vWb6VeSpCIFKaOFkKoQ,50
21
- tango_python-1.0.0.dist-info/licenses/LICENSE,sha256=j2kYVHMwTkoGn3ZNScnrdIueG0k2XzB_LCPFoyBc2wk,1064
22
- tango_python-1.0.0.dist-info/RECORD,,
18
+ tango_python-1.1.0.dist-info/METADATA,sha256=Oun2OfqXz2cZ3i4kOBkiZeMcZJxAwOsjvoHdUHOKjsg,20732
19
+ tango_python-1.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
20
+ tango_python-1.1.0.dist-info/entry_points.txt,sha256=kGLUbglUjuaAqEFvOZ1QuSW0vWb6VeSpCIFKaOFkKoQ,50
21
+ tango_python-1.1.0.dist-info/licenses/LICENSE,sha256=j2kYVHMwTkoGn3ZNScnrdIueG0k2XzB_LCPFoyBc2wk,1064
22
+ tango_python-1.1.0.dist-info/RECORD,,