tango-python 0.5.0__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
@@ -15,6 +15,8 @@ from .models import (
15
15
  RateLimitInfo,
16
16
  SearchFilters,
17
17
  ShapeConfig,
18
+ Vehicle,
19
+ VehicleMetrics,
18
20
  WebhookEndpoint,
19
21
  WebhookEventType,
20
22
  WebhookEventTypesResponse,
@@ -28,8 +30,14 @@ from .shapes import (
28
30
  ShapeParser,
29
31
  TypeGenerator,
30
32
  )
33
+ from .webhooks import (
34
+ generate_signature,
35
+ parse_signature_header,
36
+ verify_signature,
37
+ )
38
+ from .webhooks.receiver import Delivery, WebhookReceiver
31
39
 
32
- __version__ = "0.5.0"
40
+ __version__ = "0.6.0"
33
41
  __all__ = [
34
42
  "TangoClient",
35
43
  "TangoAPIError",
@@ -43,6 +51,8 @@ __all__ = [
43
51
  "PaginatedResponse",
44
52
  "SearchFilters",
45
53
  "ShapeConfig",
54
+ "Vehicle",
55
+ "VehicleMetrics",
46
56
  "WebhookEndpoint",
47
57
  "WebhookEventType",
48
58
  "WebhookEventTypesResponse",
@@ -53,4 +63,9 @@ __all__ = [
53
63
  "ModelFactory",
54
64
  "TypeGenerator",
55
65
  "SchemaRegistry",
66
+ "Delivery",
67
+ "WebhookReceiver",
68
+ "generate_signature",
69
+ "parse_signature_header",
70
+ "verify_signature",
56
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
@@ -123,6 +124,7 @@ class TangoClient:
123
124
  @staticmethod
124
125
  def _parse_rate_limit_headers(headers: httpx.Headers) -> RateLimitInfo:
125
126
  """Extract rate limit info from response headers."""
127
+
126
128
  def _int_or_none(val: str | None) -> int | None:
127
129
  if val is None:
128
130
  return None
@@ -1447,6 +1449,40 @@ class TangoClient:
1447
1449
  # Vehicles (Awards)
1448
1450
  # ============================================================================
1449
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
+
1450
1486
  def list_vehicles(
1451
1487
  self,
1452
1488
  page: int = 1,
@@ -1456,12 +1492,46 @@ class TangoClient:
1456
1492
  flat_lists: bool = False,
1457
1493
  joiner: str = ".",
1458
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,
1459
1517
  ) -> PaginatedResponse:
1460
- """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
+ """
1461
1529
  params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
1462
1530
 
1463
1531
  if shape is None:
1464
1532
  shape = ShapeConfig.VEHICLES_MINIMAL
1533
+ else:
1534
+ self._warn_deprecated_vehicle_shape(shape)
1465
1535
  if shape:
1466
1536
  params["shape"] = shape
1467
1537
  if flat:
@@ -1471,8 +1541,37 @@ class TangoClient:
1471
1541
  if flat_lists:
1472
1542
  params["flat_lists"] = "true"
1473
1543
 
1474
- if search:
1475
- 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
1476
1575
 
1477
1576
  data = self._get("/api/vehicles/", params)
1478
1577
 
@@ -1504,6 +1603,8 @@ class TangoClient:
1504
1603
 
1505
1604
  if shape is None:
1506
1605
  shape = ShapeConfig.VEHICLES_COMPREHENSIVE
1606
+ else:
1607
+ self._warn_deprecated_vehicle_shape(shape)
1507
1608
  if shape:
1508
1609
  params["shape"] = shape
1509
1610
  if flat:
@@ -1531,8 +1632,14 @@ class TangoClient:
1531
1632
  flat: bool = False,
1532
1633
  flat_lists: bool = False,
1533
1634
  joiner: str = ".",
1635
+ search: str | None = None,
1534
1636
  ) -> PaginatedResponse:
1535
- """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
+ """
1536
1643
  params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
1537
1644
 
1538
1645
  if shape is None:
@@ -1546,6 +1653,9 @@ class TangoClient:
1546
1653
  if flat_lists:
1547
1654
  params["flat_lists"] = "true"
1548
1655
 
1656
+ if search:
1657
+ params["search"] = search
1658
+
1549
1659
  data = self._get(f"/api/vehicles/{uuid}/awardees/", params)
1550
1660
 
1551
1661
  results = [
@@ -1560,6 +1670,54 @@ class TangoClient:
1560
1670
  results=results,
1561
1671
  )
1562
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
+
1563
1721
  # Business Types endpoints
1564
1722
  def list_business_types(self, page: int = 1, limit: int = 25) -> PaginatedResponse:
1565
1723
  """List business types"""
tango/models.py CHANGED
@@ -413,6 +413,12 @@ class Vehicle:
413
413
  uuid: str
414
414
  solicitation_identifier: str
415
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
416
422
  solicitation_title: str | None = None
417
423
  solicitation_date: date | None = None
418
424
  award_date: date | None = None
@@ -420,6 +426,24 @@ class Vehicle:
420
426
  fiscal_year: int | None = None
421
427
 
422
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
+
423
447
  @dataclass
424
448
  class Entity:
425
449
  """Schema definition for Entity (not used for instances)"""
@@ -690,21 +714,34 @@ class ShapeConfig:
690
714
 
691
715
  # Default for list_vehicles()
692
716
  VEHICLES_MINIMAL: Final = (
693
- "uuid,solicitation_identifier,organization_id,awardee_count,order_count,"
694
- "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"
695
722
  )
696
723
 
697
724
  # Default for get_vehicle()
698
725
  VEHICLES_COMPREHENSIVE: Final = (
699
- "uuid,solicitation_identifier,agency_id,organization_id,vehicle_type,who_can_use,"
700
- "solicitation_title,solicitation_description,solicitation_date,naics_code,psc_code,set_aside,"
701
- "fiscal_year,award_date,last_date_to_order,awardee_count,order_count,vehicle_obligations,vehicle_contracts_value,"
702
- "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(*)"
703
734
  )
704
735
 
705
736
  # Default for list_vehicle_awardees()
706
737
  VEHICLE_AWARDEES_MINIMAL: Final = "uuid,key,piid,award_date,title,order_count,idv_obligations,idv_contracts_value,recipient(display_name,uei)"
707
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
+
708
745
  # Default for list_organizations()
709
746
  ORGANIZATIONS_MINIMAL: Final = "key,fh_key,name,level,type,short_name"
710
747
 
@@ -37,6 +37,25 @@ AWARD_OFFICE_SCHEMA: dict[str, FieldSchema] = {
37
37
  ),
38
38
  }
39
39
 
40
+ # Canonical 7-key office payload returned by the `organization(...)` shape
41
+ # expand on awards, vehicles, forecasts, grants, IT Dashboard, and protests.
42
+ # Resolved deterministically from the resource's organization_id.
43
+ ORGANIZATION_OFFICE_SCHEMA: dict[str, FieldSchema] = {
44
+ "organization_id": FieldSchema(
45
+ name="organization_id", type=str, is_optional=True, is_list=False
46
+ ),
47
+ "office_code": FieldSchema(name="office_code", type=str, is_optional=True, is_list=False),
48
+ "office_name": FieldSchema(name="office_name", type=str, is_optional=True, is_list=False),
49
+ "agency_code": FieldSchema(name="agency_code", type=str, is_optional=True, is_list=False),
50
+ "agency_name": FieldSchema(name="agency_name", type=str, is_optional=True, is_list=False),
51
+ "department_code": FieldSchema(
52
+ name="department_code", type=str, is_optional=True, is_list=False
53
+ ),
54
+ "department_name": FieldSchema(
55
+ name="department_name", type=str, is_optional=True, is_list=False
56
+ ),
57
+ }
58
+
40
59
  PERIOD_OF_PERFORMANCE_IDV_SCHEMA: dict[str, FieldSchema] = {
41
60
  "start_date": FieldSchema(name="start_date", type=date, is_optional=True, is_list=False),
42
61
  "last_date_to_order": FieldSchema(
@@ -396,6 +415,13 @@ CONTRACT_SCHEMA: dict[str, FieldSchema] = {
396
415
  "undefinitized_action": FieldSchema(
397
416
  name="undefinitized_action", type=str, is_optional=True, is_list=False
398
417
  ),
418
+ "vehicle": FieldSchema(
419
+ name="vehicle",
420
+ type=dict,
421
+ is_optional=True,
422
+ is_list=False,
423
+ nested_model="Vehicle",
424
+ ),
399
425
  }
400
426
 
401
427
 
@@ -556,6 +582,13 @@ FORECAST_SCHEMA: dict[str, FieldSchema] = {
556
582
  "source_system": FieldSchema(name="source_system", type=str, is_optional=False, is_list=False),
557
583
  "status": FieldSchema(name="status", type=str, is_optional=True, is_list=False),
558
584
  "title": FieldSchema(name="title", type=str, is_optional=False, is_list=False),
585
+ "organization": FieldSchema(
586
+ name="organization",
587
+ type=dict,
588
+ is_optional=True,
589
+ is_list=False,
590
+ nested_model="OrganizationOffice",
591
+ ),
559
592
  }
560
593
 
561
594
 
@@ -685,6 +718,13 @@ PROTEST_SCHEMA: dict[str, FieldSchema] = {
685
718
  "dockets": FieldSchema(
686
719
  name="dockets", type=dict, is_optional=True, is_list=True, nested_model="ProtestDocket"
687
720
  ),
721
+ "organization": FieldSchema(
722
+ name="organization",
723
+ type=dict,
724
+ is_optional=True,
725
+ is_list=False,
726
+ nested_model="OrganizationOffice",
727
+ ),
688
728
  }
689
729
 
690
730
 
@@ -785,6 +825,13 @@ GRANT_SCHEMA: dict[str, FieldSchema] = {
785
825
  is_list=True,
786
826
  nested_model="GrantAttachment",
787
827
  ),
828
+ "organization": FieldSchema(
829
+ name="organization",
830
+ type=dict,
831
+ is_optional=True,
832
+ is_list=False,
833
+ nested_model="OrganizationOffice",
834
+ ),
788
835
  }
789
836
 
790
837
 
@@ -838,6 +885,45 @@ VEHICLE_COMPETITION_DETAILS_SCHEMA: dict[str, FieldSchema] = {
838
885
  }
839
886
 
840
887
 
888
+ # Vehicles expose a "metrics(...)" expansion bundling computed metrics.
889
+ VEHICLE_METRICS_SCHEMA: dict[str, FieldSchema] = {
890
+ "avg_offers_received": FieldSchema(
891
+ name="avg_offers_received", type=float, is_optional=True, is_list=False
892
+ ),
893
+ "award_concentration_hhi": FieldSchema(
894
+ name="award_concentration_hhi", type=float, is_optional=True, is_list=False
895
+ ),
896
+ "order_concentration_hhi": FieldSchema(
897
+ name="order_concentration_hhi", type=float, is_optional=True, is_list=False
898
+ ),
899
+ "competed_rate": FieldSchema(name="competed_rate", type=float, is_optional=True, is_list=False),
900
+ "using_agency_count": FieldSchema(
901
+ name="using_agency_count", type=int, is_optional=True, is_list=False
902
+ ),
903
+ "avg_order_value": FieldSchema(
904
+ name="avg_order_value", type=float, is_optional=True, is_list=False
905
+ ),
906
+ "max_order_value": FieldSchema(
907
+ name="max_order_value", type=float, is_optional=True, is_list=False
908
+ ),
909
+ "top_recipient_share": FieldSchema(
910
+ name="top_recipient_share", type=float, is_optional=True, is_list=False
911
+ ),
912
+ "recent_obligations_24mo": FieldSchema(
913
+ name="recent_obligations_24mo", type=float, is_optional=True, is_list=False
914
+ ),
915
+ "recent_orders_24mo": FieldSchema(
916
+ name="recent_orders_24mo", type=int, is_optional=True, is_list=False
917
+ ),
918
+ "days_since_last_order": FieldSchema(
919
+ name="days_since_last_order", type=int, is_optional=True, is_list=False
920
+ ),
921
+ "obligation_to_ceiling_ratio": FieldSchema(
922
+ name="obligation_to_ceiling_ratio", type=float, is_optional=True, is_list=False
923
+ ),
924
+ }
925
+
926
+
841
927
  # IDV schema (used for `/api/idvs/`, and also by Vehicles awardees shaping).
842
928
  IDV_SCHEMA: dict[str, FieldSchema] = {
843
929
  # Identifiers
@@ -970,26 +1056,52 @@ VEHICLE_SCHEMA: dict[str, FieldSchema] = {
970
1056
  "solicitation_identifier": FieldSchema(
971
1057
  name="solicitation_identifier", type=str, is_optional=False, is_list=False
972
1058
  ),
1059
+ "is_synthetic_solicitation": FieldSchema(
1060
+ name="is_synthetic_solicitation", type=bool, is_optional=True, is_list=False
1061
+ ),
973
1062
  "agency_id": FieldSchema(name="agency_id", type=str, is_optional=False, is_list=False),
974
1063
  "organization_id": FieldSchema(
975
1064
  name="organization_id", type=str, is_optional=True, is_list=False
976
1065
  ),
1066
+ # Live awarding-org snapshot — canonical 7-key office payload. Selectable
1067
+ # as the bare leaf (`shape=...,organization` returns the full dict) or as
1068
+ # a sub-selectable expansion (`shape=...,organization(office_code,...)`).
1069
+ "organization": FieldSchema(
1070
+ name="organization",
1071
+ type=dict,
1072
+ is_optional=True,
1073
+ is_list=False,
1074
+ nested_model="OrganizationOffice",
1075
+ ),
977
1076
  # Choice fields are returned as {code, description} objects.
978
1077
  "vehicle_type": FieldSchema(name="vehicle_type", type=dict, is_optional=True, is_list=False),
1078
+ "program_acronym": FieldSchema(
1079
+ name="program_acronym", type=str, is_optional=True, is_list=False
1080
+ ),
979
1081
  "who_can_use": FieldSchema(name="who_can_use", type=dict, is_optional=True, is_list=False),
980
1082
  "type_of_idc": FieldSchema(name="type_of_idc", type=dict, is_optional=True, is_list=False),
981
1083
  "contract_type": FieldSchema(name="contract_type", type=dict, is_optional=True, is_list=False),
1084
+ # Deprecated: recomputed from IDVs at request time. Upstream sends `Deprecation: true`.
982
1085
  "agency_details": FieldSchema(
983
1086
  name="agency_details", type=dict, is_optional=True, is_list=False
984
1087
  ),
1088
+ "description": FieldSchema(name="description", type=str, is_optional=True, is_list=False),
985
1089
  "descriptions": FieldSchema(name="descriptions", type=str, is_optional=True, is_list=True),
986
1090
  "fiscal_year": FieldSchema(name="fiscal_year", type=int, is_optional=True, is_list=False),
987
1091
  "award_date": FieldSchema(name="award_date", type=date, is_optional=True, is_list=False),
1092
+ "latest_award_date": FieldSchema(
1093
+ name="latest_award_date", type=date, is_optional=True, is_list=False
1094
+ ),
988
1095
  "last_date_to_order": FieldSchema(
989
1096
  name="last_date_to_order", type=date, is_optional=True, is_list=False
990
1097
  ),
1098
+ # Denormalized rollups
1099
+ "idv_count": FieldSchema(name="idv_count", type=int, is_optional=True, is_list=False),
991
1100
  "awardee_count": FieldSchema(name="awardee_count", type=int, is_optional=True, is_list=False),
992
1101
  "order_count": FieldSchema(name="order_count", type=int, is_optional=True, is_list=False),
1102
+ "total_obligated": FieldSchema(
1103
+ name="total_obligated", type=Decimal, is_optional=True, is_list=False
1104
+ ),
993
1105
  "vehicle_obligations": FieldSchema(
994
1106
  name="vehicle_obligations", type=Decimal, is_optional=True, is_list=False
995
1107
  ),
@@ -1006,6 +1118,7 @@ VEHICLE_SCHEMA: dict[str, FieldSchema] = {
1006
1118
  "solicitation_date": FieldSchema(
1007
1119
  name="solicitation_date", type=date, is_optional=True, is_list=False
1008
1120
  ),
1121
+ "opportunity_id": FieldSchema(name="opportunity_id", type=str, is_optional=True, is_list=False),
1009
1122
  "naics_code": FieldSchema(name="naics_code", type=int, is_optional=True, is_list=False),
1010
1123
  "psc_code": FieldSchema(name="psc_code", type=str, is_optional=True, is_list=False),
1011
1124
  "set_aside": FieldSchema(name="set_aside", type=str, is_optional=True, is_list=False),
@@ -1013,6 +1126,14 @@ VEHICLE_SCHEMA: dict[str, FieldSchema] = {
1013
1126
  "awardees": FieldSchema(
1014
1127
  name="awardees", type=dict, is_optional=True, is_list=True, nested_model="IDV"
1015
1128
  ),
1129
+ "metrics": FieldSchema(
1130
+ name="metrics",
1131
+ type=dict,
1132
+ is_optional=True,
1133
+ is_list=False,
1134
+ nested_model="VehicleMetrics",
1135
+ ),
1136
+ # Deprecated expansions (upstream sends `Deprecation: true`).
1016
1137
  "opportunity": FieldSchema(
1017
1138
  name="opportunity", type=dict, is_optional=True, is_list=False, nested_model="Opportunity"
1018
1139
  ),
@@ -1026,6 +1147,14 @@ VEHICLE_SCHEMA: dict[str, FieldSchema] = {
1026
1147
  }
1027
1148
 
1028
1149
 
1150
+ # Top-level vehicle shape fields that upstream marks deprecated (the API sends
1151
+ # `Deprecation: true` and recomputes them from IDVs at request time). The client
1152
+ # emits a `DeprecationWarning` when callers explicitly include these in `shape`.
1153
+ DEPRECATED_VEHICLE_SHAPE_FIELDS: frozenset[str] = frozenset(
1154
+ {"agency_details", "competition_details", "opportunity"}
1155
+ )
1156
+
1157
+
1029
1158
  # Organization (agencies hierarchy)
1030
1159
  ORGANIZATION_SCHEMA: dict[str, FieldSchema] = {
1031
1160
  "key": FieldSchema(name="key", type=str, is_optional=True, is_list=False),
@@ -1135,18 +1264,10 @@ GSA_ELIBRARY_CONTRACT_SCHEMA: dict[str, FieldSchema] = {
1135
1264
  # IT Dashboard Investment
1136
1265
  ITDASHBOARD_INVESTMENT_SCHEMA: dict[str, FieldSchema] = {
1137
1266
  "uii": FieldSchema(name="uii", type=str, is_optional=False, is_list=False),
1138
- "agency_code": FieldSchema(
1139
- name="agency_code", type=int, is_optional=True, is_list=False
1140
- ),
1141
- "agency_name": FieldSchema(
1142
- name="agency_name", type=str, is_optional=True, is_list=False
1143
- ),
1144
- "bureau_code": FieldSchema(
1145
- name="bureau_code", type=int, is_optional=True, is_list=False
1146
- ),
1147
- "bureau_name": FieldSchema(
1148
- name="bureau_name", type=str, is_optional=True, is_list=False
1149
- ),
1267
+ "agency_code": FieldSchema(name="agency_code", type=int, is_optional=True, is_list=False),
1268
+ "agency_name": FieldSchema(name="agency_name", type=str, is_optional=True, is_list=False),
1269
+ "bureau_code": FieldSchema(name="bureau_code", type=int, is_optional=True, is_list=False),
1270
+ "bureau_name": FieldSchema(name="bureau_name", type=str, is_optional=True, is_list=False),
1150
1271
  "investment_title": FieldSchema(
1151
1272
  name="investment_title", type=str, is_optional=True, is_list=False
1152
1273
  ),
@@ -1167,15 +1288,9 @@ ITDASHBOARD_INVESTMENT_SCHEMA: dict[str, FieldSchema] = {
1167
1288
  # Modeled as opaque dict/list since their inner shapes are dynamic.
1168
1289
  "funding": FieldSchema(name="funding", type=dict, is_optional=True, is_list=False),
1169
1290
  "details": FieldSchema(name="details", type=dict, is_optional=True, is_list=False),
1170
- "cio_evaluation": FieldSchema(
1171
- name="cio_evaluation", type=list, is_optional=True, is_list=True
1172
- ),
1173
- "contracts": FieldSchema(
1174
- name="contracts", type=list, is_optional=True, is_list=True
1175
- ),
1176
- "projects": FieldSchema(
1177
- name="projects", type=list, is_optional=True, is_list=True
1178
- ),
1291
+ "cio_evaluation": FieldSchema(name="cio_evaluation", type=list, is_optional=True, is_list=True),
1292
+ "contracts": FieldSchema(name="contracts", type=list, is_optional=True, is_list=True),
1293
+ "projects": FieldSchema(name="projects", type=list, is_optional=True, is_list=True),
1179
1294
  "cost_pools_towers": FieldSchema(
1180
1295
  name="cost_pools_towers", type=list, is_optional=True, is_list=True
1181
1296
  ),
@@ -1191,6 +1306,13 @@ ITDASHBOARD_INVESTMENT_SCHEMA: dict[str, FieldSchema] = {
1191
1306
  "operational_analysis": FieldSchema(
1192
1307
  name="operational_analysis", type=list, is_optional=True, is_list=True
1193
1308
  ),
1309
+ "organization": FieldSchema(
1310
+ name="organization",
1311
+ type=dict,
1312
+ is_optional=True,
1313
+ is_list=False,
1314
+ nested_model="OrganizationOffice",
1315
+ ),
1194
1316
  }
1195
1317
 
1196
1318
  # ============================================================================
@@ -1200,6 +1322,7 @@ ITDASHBOARD_INVESTMENT_SCHEMA: dict[str, FieldSchema] = {
1200
1322
  EXPLICIT_SCHEMAS: dict[str, dict[str, FieldSchema]] = {
1201
1323
  "Office": OFFICE_SCHEMA,
1202
1324
  "AwardOffice": AWARD_OFFICE_SCHEMA,
1325
+ "OrganizationOffice": ORGANIZATION_OFFICE_SCHEMA,
1203
1326
  "Location": LOCATION_SCHEMA,
1204
1327
  "PlaceOfPerformance": PLACE_OF_PERFORMANCE_SCHEMA,
1205
1328
  "Competition": COMPETITION_SCHEMA,
@@ -1225,6 +1348,7 @@ EXPLICIT_SCHEMAS: dict[str, dict[str, FieldSchema]] = {
1225
1348
  "Vehicle": VEHICLE_SCHEMA,
1226
1349
  "IDV": IDV_SCHEMA,
1227
1350
  "VehicleCompetitionDetails": VEHICLE_COMPETITION_DETAILS_SCHEMA,
1351
+ "VehicleMetrics": VEHICLE_METRICS_SCHEMA,
1228
1352
  # Nested schemas for Grant fields
1229
1353
  "CFDANumber": CFDA_NUMBER_SCHEMA,
1230
1354
  "CodeDescription": CODE_DESCRIPTION_SCHEMA,
@@ -0,0 +1,27 @@
1
+ """Tango webhooks: signature helpers and developer tooling.
2
+
3
+ The signing helpers (:func:`verify_signature`, :func:`generate_signature`,
4
+ :func:`parse_signature_header`) are pure stdlib and importable from a default
5
+ ``pip install tango``. The CLI (``tango webhooks ...``) and the in-process
6
+ :class:`~tango.webhooks.receiver.WebhookReceiver` ship with the
7
+ ``tango[webhooks]`` extra.
8
+ """
9
+
10
+ from tango.webhooks.signing import (
11
+ SIGNATURE_HEADER,
12
+ SIGNATURE_PREFIX,
13
+ generate_signature,
14
+ parse_signature_header,
15
+ verify_signature,
16
+ )
17
+ from tango.webhooks.simulate import SignedRequest, sign
18
+
19
+ __all__ = [
20
+ "SIGNATURE_HEADER",
21
+ "SIGNATURE_PREFIX",
22
+ "SignedRequest",
23
+ "generate_signature",
24
+ "parse_signature_header",
25
+ "sign",
26
+ "verify_signature",
27
+ ]