tango-python 0.4.4__py3-none-any.whl → 0.6.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
@@ -10,10 +10,13 @@ from .exceptions import (
10
10
  )
11
11
  from .models import (
12
12
  GsaElibraryContract,
13
+ ITDashboardInvestment,
13
14
  PaginatedResponse,
14
15
  RateLimitInfo,
15
16
  SearchFilters,
16
17
  ShapeConfig,
18
+ Vehicle,
19
+ VehicleMetrics,
17
20
  WebhookEndpoint,
18
21
  WebhookEventType,
19
22
  WebhookEventTypesResponse,
@@ -27,8 +30,14 @@ from .shapes import (
27
30
  ShapeParser,
28
31
  TypeGenerator,
29
32
  )
33
+ from .webhooks import (
34
+ generate_signature,
35
+ parse_signature_header,
36
+ verify_signature,
37
+ )
38
+ from .webhooks.receiver import Delivery, WebhookReceiver
30
39
 
31
- __version__ = "0.4.3"
40
+ __version__ = "0.6.0"
32
41
  __all__ = [
33
42
  "TangoClient",
34
43
  "TangoAPIError",
@@ -38,9 +47,12 @@ __all__ = [
38
47
  "TangoRateLimitError",
39
48
  "RateLimitInfo",
40
49
  "GsaElibraryContract",
50
+ "ITDashboardInvestment",
41
51
  "PaginatedResponse",
42
52
  "SearchFilters",
43
53
  "ShapeConfig",
54
+ "Vehicle",
55
+ "VehicleMetrics",
44
56
  "WebhookEndpoint",
45
57
  "WebhookEventType",
46
58
  "WebhookEventTypesResponse",
@@ -51,4 +63,9 @@ __all__ = [
51
63
  "ModelFactory",
52
64
  "TypeGenerator",
53
65
  "SchemaRegistry",
66
+ "Delivery",
67
+ "WebhookReceiver",
68
+ "generate_signature",
69
+ "parse_signature_header",
70
+ "verify_signature",
54
71
  ]
tango/client.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Tango API Client"""
2
2
 
3
3
  import os
4
+ import warnings
4
5
  from datetime import date, datetime
5
6
  from decimal import Decimal
6
7
  from typing import Any
@@ -26,6 +27,7 @@ from tango.models import (
26
27
  Forecast,
27
28
  Grant,
28
29
  GsaElibraryContract,
30
+ ITDashboardInvestment,
29
31
  Location,
30
32
  Notice,
31
33
  Opportunity,
@@ -122,6 +124,7 @@ class TangoClient:
122
124
  @staticmethod
123
125
  def _parse_rate_limit_headers(headers: httpx.Headers) -> RateLimitInfo:
124
126
  """Extract rate limit info from response headers."""
127
+
125
128
  def _int_or_none(val: str | None) -> int | None:
126
129
  if val is None:
127
130
  return None
@@ -1336,10 +1339,150 @@ class TangoClient:
1336
1339
  data, shape, GsaElibraryContract, flat, flat_lists, joiner=joiner
1337
1340
  )
1338
1341
 
1342
+ # ============================================================================
1343
+ # IT Dashboard Investments
1344
+ # ============================================================================
1345
+
1346
+ def list_itdashboard_investments(
1347
+ self,
1348
+ page: int = 1,
1349
+ limit: int = 25,
1350
+ shape: str | None = None,
1351
+ flat: bool = False,
1352
+ flat_lists: bool = False,
1353
+ joiner: str = ".",
1354
+ search: str | None = None,
1355
+ agency_code: int | None = None,
1356
+ agency_name: str | None = None,
1357
+ type_of_investment: str | None = None,
1358
+ updated_time_after: str | date | datetime | None = None,
1359
+ updated_time_before: str | date | datetime | None = None,
1360
+ cio_rating: int | None = None,
1361
+ cio_rating_max: int | None = None,
1362
+ performance_risk: bool | None = None,
1363
+ ) -> PaginatedResponse:
1364
+ """List federal IT investments from the IT Dashboard (`/api/itdashboard/`).
1365
+
1366
+ Filters are tier-gated by the API:
1367
+
1368
+ - **Free**: ``search`` (full-text across UII, title, description, agency, bureau)
1369
+ - **Pro**: ``agency_code``, ``type_of_investment``,
1370
+ ``updated_time_after`` / ``updated_time_before``
1371
+ - **Business+**: ``agency_name`` (text), ``cio_rating``,
1372
+ ``cio_rating_max``, ``performance_risk``
1373
+
1374
+ Hitting a gated filter on a lower tier returns a 403 with upgrade info.
1375
+
1376
+ CIO ratings: 1=High Risk, 2=Moderately High, 3=Medium, 4=Moderately Low, 5=Low.
1377
+ ``performance_risk=True`` returns investments with at least one NOT MET metric.
1378
+ """
1379
+ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
1380
+ if shape is None:
1381
+ shape = ShapeConfig.ITDASHBOARD_INVESTMENTS_MINIMAL
1382
+ if shape:
1383
+ params["shape"] = shape
1384
+ if flat:
1385
+ params["flat"] = "true"
1386
+ if joiner:
1387
+ params["joiner"] = joiner
1388
+ if flat_lists:
1389
+ params["flat_lists"] = "true"
1390
+ for k, val in (
1391
+ ("search", search),
1392
+ ("agency_code", agency_code),
1393
+ ("agency_name", agency_name),
1394
+ ("type_of_investment", type_of_investment),
1395
+ ("updated_time_after", updated_time_after),
1396
+ ("updated_time_before", updated_time_before),
1397
+ ("cio_rating", cio_rating),
1398
+ ("cio_rating_max", cio_rating_max),
1399
+ ("performance_risk", performance_risk),
1400
+ ):
1401
+ if val is None:
1402
+ continue
1403
+ if isinstance(val, bool):
1404
+ params[k] = "true" if val else "false"
1405
+ elif isinstance(val, (date, datetime)):
1406
+ params[k] = val.isoformat()
1407
+ else:
1408
+ params[k] = val
1409
+ data = self._get("/api/itdashboard/", params)
1410
+ results = [
1411
+ self._parse_response_with_shape(
1412
+ obj, shape, ITDashboardInvestment, flat, flat_lists, joiner=joiner
1413
+ )
1414
+ for obj in data.get("results", [])
1415
+ ]
1416
+ return PaginatedResponse(
1417
+ count=data.get("count", 0),
1418
+ next=data.get("next"),
1419
+ previous=data.get("previous"),
1420
+ results=results,
1421
+ )
1422
+
1423
+ def get_itdashboard_investment(
1424
+ self,
1425
+ uii: str,
1426
+ shape: str | None = None,
1427
+ flat: bool = False,
1428
+ flat_lists: bool = False,
1429
+ joiner: str = ".",
1430
+ ) -> Any:
1431
+ """Get a single IT Dashboard investment by UII (`/api/itdashboard/{uii}/`)."""
1432
+ params: dict[str, Any] = {}
1433
+ if shape is None:
1434
+ shape = ShapeConfig.ITDASHBOARD_INVESTMENTS_COMPREHENSIVE
1435
+ if shape:
1436
+ params["shape"] = shape
1437
+ if flat:
1438
+ params["flat"] = "true"
1439
+ if joiner:
1440
+ params["joiner"] = joiner
1441
+ if flat_lists:
1442
+ params["flat_lists"] = "true"
1443
+ data = self._get(f"/api/itdashboard/{uii}/", params)
1444
+ return self._parse_response_with_shape(
1445
+ data, shape, ITDashboardInvestment, flat, flat_lists, joiner=joiner
1446
+ )
1447
+
1339
1448
  # ============================================================================
1340
1449
  # Vehicles (Awards)
1341
1450
  # ============================================================================
1342
1451
 
1452
+ @staticmethod
1453
+ def _warn_deprecated_vehicle_shape(shape: str | None) -> None:
1454
+ # Upstream sends `Deprecation: true` for these fields/expansions; warn
1455
+ # callers who request them explicitly so they have time to migrate
1456
+ # before tango publishes a Sunset timeline.
1457
+ from tango.shapes.explicit_schemas import DEPRECATED_VEHICLE_SHAPE_FIELDS
1458
+
1459
+ if not shape:
1460
+ return
1461
+ # Match top-level field tokens, ignoring nesting inside parentheses.
1462
+ depth = 0
1463
+ token = ""
1464
+ tokens: list[str] = []
1465
+ for ch in shape:
1466
+ if ch == "(":
1467
+ depth += 1
1468
+ elif ch == ")":
1469
+ depth = max(0, depth - 1)
1470
+ elif ch == "," and depth == 0:
1471
+ tokens.append(token.strip())
1472
+ token = ""
1473
+ continue
1474
+ token += ch
1475
+ tokens.append(token.strip())
1476
+ used = {t.split("(", 1)[0] for t in tokens} & DEPRECATED_VEHICLE_SHAPE_FIELDS
1477
+ if used:
1478
+ warnings.warn(
1479
+ f"Vehicle shape field(s) {sorted(used)!r} are deprecated upstream "
1480
+ "and may be removed in a future tango API version. The API currently "
1481
+ "returns a `Deprecation: true` header for these.",
1482
+ DeprecationWarning,
1483
+ stacklevel=3,
1484
+ )
1485
+
1343
1486
  def list_vehicles(
1344
1487
  self,
1345
1488
  page: int = 1,
@@ -1349,12 +1492,46 @@ class TangoClient:
1349
1492
  flat_lists: bool = False,
1350
1493
  joiner: str = ".",
1351
1494
  search: str | None = None,
1495
+ vehicle_type: str | None = None,
1496
+ type_of_idc: str | None = None,
1497
+ contract_type: str | None = None,
1498
+ set_aside: str | None = None,
1499
+ who_can_use: str | None = None,
1500
+ naics_code: int | None = None,
1501
+ psc_code: str | None = None,
1502
+ program_acronym: str | None = None,
1503
+ agency: str | None = None,
1504
+ organization_id: str | None = None,
1505
+ total_obligated_min: float | int | Decimal | None = None,
1506
+ total_obligated_max: float | int | Decimal | None = None,
1507
+ idv_count_min: int | None = None,
1508
+ idv_count_max: int | None = None,
1509
+ order_count_min: int | None = None,
1510
+ order_count_max: int | None = None,
1511
+ fiscal_year: int | None = None,
1512
+ award_date_after: str | date | datetime | None = None,
1513
+ award_date_before: str | date | datetime | None = None,
1514
+ last_date_to_order_after: str | date | datetime | None = None,
1515
+ last_date_to_order_before: str | date | datetime | None = None,
1516
+ ordering: str | None = None,
1352
1517
  ) -> PaginatedResponse:
1353
- """List Vehicles (solicitation-centric groupings of IDVs)."""
1518
+ """List Vehicles (solicitation-centric groupings of IDVs).
1519
+
1520
+ Multi-value filters (``vehicle_type``, ``type_of_idc``, ``contract_type``,
1521
+ ``set_aside``) accept pipe-separated values for OR semantics, e.g.
1522
+ ``vehicle_type="A|B|C"``.
1523
+
1524
+ ``ordering`` accepts: ``vehicle_obligations``, ``latest_award_date``,
1525
+ ``total_obligated``, ``award_date``, ``last_date_to_order``,
1526
+ ``fiscal_year``, ``idv_count``, ``order_count``. Prefix with ``-`` for
1527
+ descending.
1528
+ """
1354
1529
  params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
1355
1530
 
1356
1531
  if shape is None:
1357
1532
  shape = ShapeConfig.VEHICLES_MINIMAL
1533
+ else:
1534
+ self._warn_deprecated_vehicle_shape(shape)
1358
1535
  if shape:
1359
1536
  params["shape"] = shape
1360
1537
  if flat:
@@ -1364,8 +1541,37 @@ class TangoClient:
1364
1541
  if flat_lists:
1365
1542
  params["flat_lists"] = "true"
1366
1543
 
1367
- if search:
1368
- params["search"] = search
1544
+ for k, val in (
1545
+ ("search", search),
1546
+ ("vehicle_type", vehicle_type),
1547
+ ("type_of_idc", type_of_idc),
1548
+ ("contract_type", contract_type),
1549
+ ("set_aside", set_aside),
1550
+ ("who_can_use", who_can_use),
1551
+ ("naics_code", naics_code),
1552
+ ("psc_code", psc_code),
1553
+ ("program_acronym", program_acronym),
1554
+ ("agency", agency),
1555
+ ("organization_id", organization_id),
1556
+ ("total_obligated_min", total_obligated_min),
1557
+ ("total_obligated_max", total_obligated_max),
1558
+ ("idv_count_min", idv_count_min),
1559
+ ("idv_count_max", idv_count_max),
1560
+ ("order_count_min", order_count_min),
1561
+ ("order_count_max", order_count_max),
1562
+ ("fiscal_year", fiscal_year),
1563
+ ("award_date_after", award_date_after),
1564
+ ("award_date_before", award_date_before),
1565
+ ("last_date_to_order_after", last_date_to_order_after),
1566
+ ("last_date_to_order_before", last_date_to_order_before),
1567
+ ("ordering", ordering),
1568
+ ):
1569
+ if val is None:
1570
+ continue
1571
+ if isinstance(val, (date, datetime)):
1572
+ params[k] = val.isoformat()
1573
+ else:
1574
+ params[k] = val
1369
1575
 
1370
1576
  data = self._get("/api/vehicles/", params)
1371
1577
 
@@ -1397,6 +1603,8 @@ class TangoClient:
1397
1603
 
1398
1604
  if shape is None:
1399
1605
  shape = ShapeConfig.VEHICLES_COMPREHENSIVE
1606
+ else:
1607
+ self._warn_deprecated_vehicle_shape(shape)
1400
1608
  if shape:
1401
1609
  params["shape"] = shape
1402
1610
  if flat:
@@ -1424,8 +1632,14 @@ class TangoClient:
1424
1632
  flat: bool = False,
1425
1633
  flat_lists: bool = False,
1426
1634
  joiner: str = ".",
1635
+ search: str | None = None,
1427
1636
  ) -> PaginatedResponse:
1428
- """List the IDV awardees for a Vehicle (`/api/vehicles/{uuid}/awardees/`)."""
1637
+ """List the IDV awardees for a Vehicle (`/api/vehicles/{uuid}/awardees/`).
1638
+
1639
+ ``search`` runs entity-aware full-text search across IDV fields
1640
+ (PIID, key, solicitation_identifier, NAICS, PSC, idv_type,
1641
+ fiscal_year) and recipient entity details (name, address).
1642
+ """
1429
1643
  params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
1430
1644
 
1431
1645
  if shape is None:
@@ -1439,6 +1653,9 @@ class TangoClient:
1439
1653
  if flat_lists:
1440
1654
  params["flat_lists"] = "true"
1441
1655
 
1656
+ if search:
1657
+ params["search"] = search
1658
+
1442
1659
  data = self._get(f"/api/vehicles/{uuid}/awardees/", params)
1443
1660
 
1444
1661
  results = [
@@ -1453,6 +1670,54 @@ class TangoClient:
1453
1670
  results=results,
1454
1671
  )
1455
1672
 
1673
+ def list_vehicle_orders(
1674
+ self,
1675
+ uuid: str,
1676
+ page: int = 1,
1677
+ limit: int = 25,
1678
+ shape: str | None = None,
1679
+ flat: bool = False,
1680
+ flat_lists: bool = False,
1681
+ joiner: str = ".",
1682
+ ordering: str | None = None,
1683
+ ) -> PaginatedResponse:
1684
+ """List task orders under a Vehicle's IDVs (``/api/vehicles/{uuid}/orders/``).
1685
+
1686
+ Args:
1687
+ ordering: Server-side sort. Allowed: ``award_date`` (default),
1688
+ ``obligated``, ``total_contract_value``. Prefix with ``-`` for
1689
+ descending.
1690
+ """
1691
+ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
1692
+
1693
+ if shape is None:
1694
+ shape = ShapeConfig.VEHICLE_ORDERS_MINIMAL
1695
+ if shape:
1696
+ params["shape"] = shape
1697
+ if flat:
1698
+ params["flat"] = "true"
1699
+ if joiner:
1700
+ params["joiner"] = joiner
1701
+ if flat_lists:
1702
+ params["flat_lists"] = "true"
1703
+
1704
+ if ordering:
1705
+ params["ordering"] = ordering
1706
+
1707
+ data = self._get(f"/api/vehicles/{uuid}/orders/", params)
1708
+
1709
+ results = [
1710
+ self._parse_response_with_shape(order, shape, Contract, flat, flat_lists, joiner=joiner)
1711
+ for order in data["results"]
1712
+ ]
1713
+
1714
+ return PaginatedResponse(
1715
+ count=data["count"],
1716
+ next=data.get("next"),
1717
+ previous=data.get("previous"),
1718
+ results=results,
1719
+ )
1720
+
1456
1721
  # Business Types endpoints
1457
1722
  def list_business_types(self, page: int = 1, limit: int = 25) -> PaginatedResponse:
1458
1723
  """List business types"""
tango/models.py CHANGED
@@ -367,6 +367,45 @@ class GsaElibraryContract:
367
367
  sins: list[str] | None = None
368
368
 
369
369
 
370
+ @dataclass
371
+ class ITDashboardInvestment:
372
+ """Schema definition for IT Dashboard Investment (not used for instances)
373
+
374
+ Federal IT investment from itdashboard.gov, exposed at /api/itdashboard/.
375
+ Identified by ``uii`` (Unique Investment Identifier).
376
+
377
+ Tier-gated shape expansions:
378
+ Free base fields only
379
+ Pro+ ``funding`` and ``details`` expansions
380
+ Business+ nested sub-tables (``cio_evaluation``, ``contracts``,
381
+ ``projects``, ``cost_pools_towers``, ``funding_sources``,
382
+ ``performance_metrics``, ``performance_actual``,
383
+ ``operational_analysis``) and ``business_case_html``
384
+ """
385
+
386
+ uii: str
387
+ agency_code: int | None = None
388
+ agency_name: str | None = None
389
+ bureau_code: int | None = None
390
+ bureau_name: str | None = None
391
+ investment_title: str | None = None
392
+ type_of_investment: str | None = None
393
+ part_of_it_portfolio: str | None = None
394
+ updated_time: datetime | None = None
395
+ url: str | None = None
396
+ business_case_html: str | None = None
397
+ funding: dict[str, Any] | None = None
398
+ details: dict[str, Any] | None = None
399
+ cio_evaluation: list[dict[str, Any]] | None = None
400
+ contracts: list[dict[str, Any]] | None = None
401
+ projects: list[dict[str, Any]] | None = None
402
+ cost_pools_towers: list[dict[str, Any]] | None = None
403
+ funding_sources: list[dict[str, Any]] | None = None
404
+ performance_metrics: list[dict[str, Any]] | None = None
405
+ performance_actual: list[dict[str, Any]] | None = None
406
+ operational_analysis: list[dict[str, Any]] | None = None
407
+
408
+
370
409
  @dataclass
371
410
  class Vehicle:
372
411
  """Schema definition for Vehicle (not used for instances)"""
@@ -374,6 +413,12 @@ class Vehicle:
374
413
  uuid: str
375
414
  solicitation_identifier: str
376
415
  agency_id: str
416
+ is_synthetic_solicitation: bool | None = None
417
+ program_acronym: str | None = None
418
+ description: str | None = None
419
+ idv_count: int | None = None
420
+ total_obligated: Decimal | None = None
421
+ latest_award_date: date | None = None
377
422
  solicitation_title: str | None = None
378
423
  solicitation_date: date | None = None
379
424
  award_date: date | None = None
@@ -381,6 +426,24 @@ class Vehicle:
381
426
  fiscal_year: int | None = None
382
427
 
383
428
 
429
+ @dataclass
430
+ class VehicleMetrics:
431
+ """Schema definition for the Vehicle `metrics(*)` expansion (not used for instances)"""
432
+
433
+ avg_offers_received: float | None = None
434
+ award_concentration_hhi: float | None = None
435
+ order_concentration_hhi: float | None = None
436
+ competed_rate: float | None = None
437
+ using_agency_count: int | None = None
438
+ avg_order_value: float | None = None
439
+ max_order_value: float | None = None
440
+ top_recipient_share: float | None = None
441
+ recent_obligations_24mo: float | None = None
442
+ recent_orders_24mo: int | None = None
443
+ days_since_last_order: int | None = None
444
+ obligation_to_ceiling_ratio: float | None = None
445
+
446
+
384
447
  @dataclass
385
448
  class Entity:
386
449
  """Schema definition for Entity (not used for instances)"""
@@ -651,21 +714,34 @@ class ShapeConfig:
651
714
 
652
715
  # Default for list_vehicles()
653
716
  VEHICLES_MINIMAL: Final = (
654
- "uuid,solicitation_identifier,organization_id,awardee_count,order_count,"
655
- "vehicle_obligations,vehicle_contracts_value,solicitation_title,solicitation_date"
717
+ "uuid,solicitation_identifier,is_synthetic_solicitation,program_acronym,"
718
+ "organization_id,organization,vehicle_type,description,"
719
+ "idv_count,awardee_count,order_count,total_obligated,"
720
+ "vehicle_obligations,vehicle_contracts_value,latest_award_date,"
721
+ "solicitation_title,solicitation_date"
656
722
  )
657
723
 
658
724
  # Default for get_vehicle()
659
725
  VEHICLES_COMPREHENSIVE: Final = (
660
- "uuid,solicitation_identifier,agency_id,organization_id,vehicle_type,who_can_use,"
661
- "solicitation_title,solicitation_description,solicitation_date,naics_code,psc_code,set_aside,"
662
- "fiscal_year,award_date,last_date_to_order,awardee_count,order_count,vehicle_obligations,vehicle_contracts_value,"
663
- "type_of_idc,contract_type,competition_details(*)"
726
+ "uuid,solicitation_identifier,is_synthetic_solicitation,agency_id,program_acronym,"
727
+ "organization_id,organization(*),vehicle_type,who_can_use,"
728
+ "solicitation_title,solicitation_description,solicitation_date,opportunity_id,"
729
+ "naics_code,psc_code,set_aside,"
730
+ "fiscal_year,award_date,latest_award_date,last_date_to_order,"
731
+ "description,idv_count,awardee_count,order_count,total_obligated,"
732
+ "vehicle_obligations,vehicle_contracts_value,"
733
+ "type_of_idc,contract_type,metrics(*)"
664
734
  )
665
735
 
666
736
  # Default for list_vehicle_awardees()
667
737
  VEHICLE_AWARDEES_MINIMAL: Final = "uuid,key,piid,award_date,title,order_count,idv_obligations,idv_contracts_value,recipient(display_name,uei)"
668
738
 
739
+ # Default for list_vehicle_orders()
740
+ VEHICLE_ORDERS_MINIMAL: Final = (
741
+ "key,piid,award_date,obligated,total_contract_value,description,"
742
+ "recipient(display_name,uei)"
743
+ )
744
+
669
745
  # Default for list_organizations()
670
746
  ORGANIZATIONS_MINIMAL: Final = "key,fh_key,name,level,type,short_name"
671
747
 
@@ -687,3 +763,18 @@ class ShapeConfig:
687
763
  GSA_ELIBRARY_CONTRACTS_MINIMAL: Final = (
688
764
  "uuid,contract_number,schedule,recipient(display_name,uei),idv(key,award_date)"
689
765
  )
766
+
767
+ # Default for list_itdashboard_investments()
768
+ # Free-tier safe: matches the API's INVESTMENT_LIST_DEFAULT_SHAPE.
769
+ ITDASHBOARD_INVESTMENTS_MINIMAL: Final = (
770
+ "uii,agency_name,bureau_name,investment_title,"
771
+ "type_of_investment,part_of_it_portfolio,updated_time,url"
772
+ )
773
+
774
+ # Default for get_itdashboard_investment()
775
+ # Free-tier safe: matches the API's INVESTMENT_RETRIEVE_DEFAULT_SHAPE.
776
+ ITDASHBOARD_INVESTMENTS_COMPREHENSIVE: Final = (
777
+ "uii,agency_code,agency_name,bureau_code,bureau_name,"
778
+ "investment_title,type_of_investment,part_of_it_portfolio,"
779
+ "updated_time,url"
780
+ )