tango-python 1.8.0__tar.gz → 1.9.0__tar.gz

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.
Files changed (207) hide show
  1. {tango_python-1.8.0 → tango_python-1.9.0}/CHANGELOG.md +8 -0
  2. {tango_python-1.8.0 → tango_python-1.9.0}/PKG-INFO +10 -1
  3. {tango_python-1.8.0 → tango_python-1.9.0}/README.md +9 -0
  4. {tango_python-1.8.0 → tango_python-1.9.0}/docs/API_REFERENCE.md +115 -0
  5. {tango_python-1.8.0 → tango_python-1.9.0}/docs/WEBHOOKS.md +1 -0
  6. {tango_python-1.8.0 → tango_python-1.9.0}/pyproject.toml +1 -1
  7. {tango_python-1.8.0 → tango_python-1.9.0}/scripts/check_filter_shape_conformance.py +1 -0
  8. {tango_python-1.8.0 → tango_python-1.9.0}/scripts/check_shape_coverage.py +1 -0
  9. {tango_python-1.8.0 → tango_python-1.9.0}/tango/__init__.py +9 -1
  10. {tango_python-1.8.0 → tango_python-1.9.0}/tango/client.py +259 -45
  11. {tango_python-1.8.0 → tango_python-1.9.0}/tango/exceptions.py +10 -0
  12. {tango_python-1.8.0 → tango_python-1.9.0}/tango/models.py +111 -0
  13. {tango_python-1.8.0 → tango_python-1.9.0}/tango/shapes/explicit_schemas.py +93 -0
  14. {tango_python-1.8.0 → tango_python-1.9.0}/tango/shapes/generated_overlay.py +7 -19
  15. tango_python-1.9.0/tests/test_ebuy.py +322 -0
  16. {tango_python-1.8.0 → tango_python-1.9.0}/.gitignore +0 -0
  17. {tango_python-1.8.0 → tango_python-1.9.0}/LICENSE +0 -0
  18. {tango_python-1.8.0 → tango_python-1.9.0}/docs/CLIENT.md +0 -0
  19. {tango_python-1.8.0 → tango_python-1.9.0}/docs/DYNAMIC_MODELS.md +0 -0
  20. {tango_python-1.8.0 → tango_python-1.9.0}/docs/ERRORS.md +0 -0
  21. {tango_python-1.8.0 → tango_python-1.9.0}/docs/PAGINATION.md +0 -0
  22. {tango_python-1.8.0 → tango_python-1.9.0}/docs/SHAPES.md +0 -0
  23. {tango_python-1.8.0 → tango_python-1.9.0}/scripts/README.md +0 -0
  24. {tango_python-1.8.0 → tango_python-1.9.0}/scripts/fetch_api_schema.py +0 -0
  25. {tango_python-1.8.0 → tango_python-1.9.0}/scripts/generate_schemas_from_api.py +0 -0
  26. {tango_python-1.8.0 → tango_python-1.9.0}/scripts/generate_shape_overlay.py +0 -0
  27. {tango_python-1.8.0 → tango_python-1.9.0}/scripts/pr_review.py +0 -0
  28. {tango_python-1.8.0 → tango_python-1.9.0}/scripts/probe_shape_types.py +0 -0
  29. {tango_python-1.8.0 → tango_python-1.9.0}/scripts/refresh_contract.py +0 -0
  30. {tango_python-1.8.0 → tango_python-1.9.0}/scripts/smoke_api_parity.py +0 -0
  31. {tango_python-1.8.0 → tango_python-1.9.0}/scripts/test_production.py +0 -0
  32. {tango_python-1.8.0 → tango_python-1.9.0}/tango/shapes/__init__.py +0 -0
  33. {tango_python-1.8.0 → tango_python-1.9.0}/tango/shapes/factory.py +0 -0
  34. {tango_python-1.8.0 → tango_python-1.9.0}/tango/shapes/generator.py +0 -0
  35. {tango_python-1.8.0 → tango_python-1.9.0}/tango/shapes/models.py +0 -0
  36. {tango_python-1.8.0 → tango_python-1.9.0}/tango/shapes/parser.py +0 -0
  37. {tango_python-1.8.0 → tango_python-1.9.0}/tango/shapes/schema.py +0 -0
  38. {tango_python-1.8.0 → tango_python-1.9.0}/tango/shapes/types.py +0 -0
  39. {tango_python-1.8.0 → tango_python-1.9.0}/tango/webhooks/__init__.py +0 -0
  40. {tango_python-1.8.0 → tango_python-1.9.0}/tango/webhooks/cli.py +0 -0
  41. {tango_python-1.8.0 → tango_python-1.9.0}/tango/webhooks/receiver.py +0 -0
  42. {tango_python-1.8.0 → tango_python-1.9.0}/tango/webhooks/signing.py +0 -0
  43. {tango_python-1.8.0 → tango_python-1.9.0}/tango/webhooks/simulate.py +0 -0
  44. {tango_python-1.8.0 → tango_python-1.9.0}/tests/__init__.py +0 -0
  45. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestAgenciesIntegration.test_get_agency +0 -0
  46. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestAgenciesIntegration.test_list_agencies +0 -0
  47. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestBusinessTypesIntegration.test_business_type_field_type_validation +0 -0
  48. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestBusinessTypesIntegration.test_business_type_parsing_consistency +0 -0
  49. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestBusinessTypesIntegration.test_list_business_types +0 -0
  50. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_combined_filters_work_together +0 -0
  51. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_contract_cursor_pagination +0 -0
  52. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_contract_data_object_parsing +0 -0
  53. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_contract_field_types +0 -0
  54. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_filter_parameter_mappings[keyword-software] +0 -0
  55. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_filter_parameter_mappings[psc_code-R425] +0 -0
  56. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_list_contracts_with_awarding_agency_filter +0 -0
  57. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_list_contracts_with_date_range_filter +0 -0
  58. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_list_contracts_with_flat +0 -0
  59. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_list_contracts_with_naics_code_filter +0 -0
  60. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_list_contracts_with_shapes[custom-key,piid,recipient(display_name),total_contract_value,award_date] +0 -0
  61. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_list_contracts_with_shapes[default-None] +0 -0
  62. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_list_contracts_with_shapes[detailed-key,piid,award_date,description,total_contract_value,obligated,fiscal_year,set_aside,recipient(display_name,uei),awarding_office(-),place_of_performa...ce114a3c47e2037aaa3c15d00b7031bd +0 -0
  63. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_list_contracts_with_shapes[minimal-key,piid,award_date,recipient(display_name),description,total_contract_value] +0 -0
  64. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_new_expiring_filters +0 -0
  65. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_new_fiscal_year_range_filters +0 -0
  66. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_new_identifier_filters +0 -0
  67. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_search_contracts_with_filters +0 -0
  68. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_search_filters_object_with_new_parameters +0 -0
  69. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_sort_and_order_mapped_to_ordering[asc-] +0 -0
  70. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestContractsIntegration.test_sort_and_order_mapped_to_ordering[desc--] +0 -0
  71. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEdgeCasesIntegration.test_api_schema_stability_detection_contracts +0 -0
  72. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEdgeCasesIntegration.test_api_schema_stability_detection_entities +0 -0
  73. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEdgeCasesIntegration.test_date_field_parsing_edge_cases +0 -0
  74. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEdgeCasesIntegration.test_decimal_field_parsing_edge_cases +0 -0
  75. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEdgeCasesIntegration.test_empty_list_responses +0 -0
  76. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEdgeCasesIntegration.test_entity_parsing_with_various_address_formats +0 -0
  77. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEdgeCasesIntegration.test_flattened_responses_with_flat_lists +0 -0
  78. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEdgeCasesIntegration.test_list_field_parsing_consistency +0 -0
  79. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEdgeCasesIntegration.test_parsing_nested_objects_with_missing_data +0 -0
  80. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEdgeCasesIntegration.test_parsing_null_missing_fields_in_contracts +0 -0
  81. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEdgeCasesIntegration.test_parsing_with_minimal_shape_sparse_data +0 -0
  82. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEntitiesIntegration.test_entity_field_types +0 -0
  83. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEntitiesIntegration.test_entity_location_parsing +0 -0
  84. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEntitiesIntegration.test_entity_parsing_with_business_types +0 -0
  85. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEntitiesIntegration.test_entity_with_various_identifiers +0 -0
  86. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEntitiesIntegration.test_get_entity_budget_flows +0 -0
  87. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEntitiesIntegration.test_get_entity_by_uei +0 -0
  88. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEntitiesIntegration.test_list_entities_with_flat +0 -0
  89. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEntitiesIntegration.test_list_entities_with_search +0 -0
  90. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEntitiesIntegration.test_list_entities_with_shapes[comprehensive-uei,legal_business_name,dba_name,cage_code,business_types,primary_naics,naics_codes,psc_codes,email_address,entity_url,description,capabilities,ke...1603a7d52e211cf2b3bc7d32080238aa +0 -0
  91. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEntitiesIntegration.test_list_entities_with_shapes[custom-uei,legal_business_name,cage_code] +0 -0
  92. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEntitiesIntegration.test_list_entities_with_shapes[minimal-uei,legal_business_name,cage_code,business_types] +0 -0
  93. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestEntitiesIntegration.test_list_entities_with_shapes[with_address-uei,legal_business_name,cage_code,business_types,physical_address] +0 -0
  94. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestForecastsIntegration.test_forecast_field_types +0 -0
  95. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestForecastsIntegration.test_list_forecasts_with_shapes[custom-id,title,anticipated_award_date] +0 -0
  96. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestForecastsIntegration.test_list_forecasts_with_shapes[default-None] +0 -0
  97. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestForecastsIntegration.test_list_forecasts_with_shapes[detailed-id,source_system,external_id,title,description,anticipated_award_date,fiscal_year,naics_code,status,is_active] +0 -0
  98. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestForecastsIntegration.test_list_forecasts_with_shapes[minimal-id,title,anticipated_award_date,fiscal_year,naics_code,status] +0 -0
  99. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestGrantsIntegration.test_grant_field_types +0 -0
  100. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestGrantsIntegration.test_grant_pagination +0 -0
  101. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestGrantsIntegration.test_list_grants_with_shapes[custom-grant_id,title,opportunity_number] +0 -0
  102. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestGrantsIntegration.test_list_grants_with_shapes[default-None] +0 -0
  103. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestGrantsIntegration.test_list_grants_with_shapes[detailed-grant_id,opportunity_number,title,status(-),agency_code,description,last_updated,cfda_numbers(number,title),applicant_types(-),funding_categories(-)] +0 -0
  104. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestGrantsIntegration.test_list_grants_with_shapes[minimal-grant_id,opportunity_number,title,status(-),agency_code] +0 -0
  105. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestIDVsIntegration.test_get_idv_uses_default_shape +0 -0
  106. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestIDVsIntegration.test_list_idv_awards_uses_default_shape +0 -0
  107. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestIDVsIntegration.test_list_idv_child_idvs_uses_default_shape +0 -0
  108. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestIDVsIntegration.test_list_idv_transactions +0 -0
  109. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestIDVsIntegration.test_list_idvs_uses_default_shape_and_keyset_params +0 -0
  110. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestITDashboardIntegration.test_filter_by_agency_code +0 -0
  111. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestITDashboardIntegration.test_filter_by_agency_name_text +0 -0
  112. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestITDashboardIntegration.test_filter_by_cio_rating +0 -0
  113. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestITDashboardIntegration.test_filter_by_cio_rating_max +0 -0
  114. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestITDashboardIntegration.test_filter_by_performance_risk +0 -0
  115. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestITDashboardIntegration.test_filter_by_type_of_investment +0 -0
  116. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestITDashboardIntegration.test_filter_by_updated_time_range +0 -0
  117. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestITDashboardIntegration.test_funding_and_cio_evaluation_expansions +0 -0
  118. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestITDashboardIntegration.test_get_itdashboard_investment_by_uii +0 -0
  119. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestITDashboardIntegration.test_itdashboard_pagination +0 -0
  120. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestITDashboardIntegration.test_list_itdashboard_investments_with_search +0 -0
  121. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestITDashboardIntegration.test_list_itdashboard_investments_with_shapes[custom-uii,agency_name,investment_title,updated_time] +0 -0
  122. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestITDashboardIntegration.test_list_itdashboard_investments_with_shapes[default-None] +0 -0
  123. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestITDashboardIntegration.test_list_itdashboard_investments_with_shapes[minimal-uii,agency_name,bureau_name,investment_title,type_of_investment,part_of_it_portfolio,updated_time,url] +0 -0
  124. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestNaicsIntegration.test_list_naics +0 -0
  125. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestNoticesIntegration.test_list_notices_with_shapes[custom-notice_id,title,solicitation_number] +0 -0
  126. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestNoticesIntegration.test_list_notices_with_shapes[default-None] +0 -0
  127. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestNoticesIntegration.test_list_notices_with_shapes[detailed-notice_id,title,description,solicitation_number,posted_date,naics_code,set_aside,office(-),place_of_performance(-)] +0 -0
  128. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestNoticesIntegration.test_list_notices_with_shapes[minimal-notice_id,title,solicitation_number,posted_date] +0 -0
  129. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestNoticesIntegration.test_notice_field_types +0 -0
  130. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestNoticesIntegration.test_notice_pagination +0 -0
  131. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestNoticesIntegration.test_notice_with_meta_fields +0 -0
  132. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestOTAsIntegration.test_get_ota +0 -0
  133. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestOTAsIntegration.test_list_otas +0 -0
  134. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestOTIDVsIntegration.test_get_otidv +0 -0
  135. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestOTIDVsIntegration.test_list_otidvs +0 -0
  136. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestOfficesIntegration.test_get_office +0 -0
  137. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestOfficesIntegration.test_list_offices +0 -0
  138. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestOpportunitiesIntegration.test_list_opportunities_with_shapes[custom-opportunity_id,title,solicitation_number] +0 -0
  139. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestOpportunitiesIntegration.test_list_opportunities_with_shapes[default-None] +0 -0
  140. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestOpportunitiesIntegration.test_list_opportunities_with_shapes[detailed-opportunity_id,title,description,solicitation_number,response_deadline,first_notice_date,last_notice_date,active,naics_code,psc_code,set_asid...23b6b4502ddd665b7184afcff6c6d8d9 +0 -0
  141. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestOpportunitiesIntegration.test_list_opportunities_with_shapes[minimal-opportunity_id,title,solicitation_number,response_deadline,active] +0 -0
  142. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestOpportunitiesIntegration.test_opportunity_field_types +0 -0
  143. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestOrganizationsIntegration.test_get_organization +0 -0
  144. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestOrganizationsIntegration.test_list_organizations +0 -0
  145. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestProtestsIntegration.test_get_protest_by_case_id +0 -0
  146. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestProtestsIntegration.test_list_protests_with_filter +0 -0
  147. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestProtestsIntegration.test_list_protests_with_shapes[custom-case_id,title,source_system,outcome] +0 -0
  148. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestProtestsIntegration.test_list_protests_with_shapes[default-None] +0 -0
  149. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestProtestsIntegration.test_list_protests_with_shapes[minimal-case_id,case_number,title,source_system,outcome,filed_date] +0 -0
  150. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestProtestsIntegration.test_list_protests_with_shapes[with_dockets-case_id,case_number,title,outcome,filed_date,dockets(docket_number,filed_date,outcome)] +0 -0
  151. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestProtestsIntegration.test_protest_pagination +0 -0
  152. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestSubawardsIntegration.test_list_subawards +0 -0
  153. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestTypeHintsIntegration.test_contracts_dict_access[custom-key,piid,description] +0 -0
  154. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestTypeHintsIntegration.test_contracts_dict_access[minimal-key,piid,award_date,recipient(display_name),description,total_contract_value] +0 -0
  155. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestTypeHintsIntegration.test_contracts_dict_access[ultra_minimal-key,piid,recipient(display_name),total_contract_value] +0 -0
  156. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestTypeHintsIntegration.test_entities_dict_access[minimal-uei,legal_business_name,cage_code,business_types] +0 -0
  157. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestTypeHintsIntegration.test_entities_dict_access[with_address-uei,legal_business_name,cage_code,business_types,physical_address] +0 -0
  158. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestTypeHintsIntegration.test_notices_dict_access[detailed-notice_id,title,description,solicitation_number,posted_date,naics_code,set_aside,office(-),place_of_performance(-)] +0 -0
  159. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestTypeHintsIntegration.test_notices_dict_access[minimal-notice_id,title,solicitation_number,posted_date] +0 -0
  160. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestTypeHintsIntegration.test_opportunities_dict_access[detailed-opportunity_id,title,description,solicitation_number,response_deadline,first_notice_date,last_notice_date,active,naics_code,psc_code,set_aside,sam_url,office(-),place_of_performance(-)] +0 -0
  161. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestTypeHintsIntegration.test_opportunities_dict_access[minimal-opportunity_id,title,solicitation_number,response_deadline,active] +0 -0
  162. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestVehiclesIntegration.test_get_vehicle_supports_joiner_and_flat_lists +0 -0
  163. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestVehiclesIntegration.test_get_vehicle_with_metrics_expansion +0 -0
  164. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestVehiclesIntegration.test_list_vehicle_awardees_uses_default_shape +0 -0
  165. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestVehiclesIntegration.test_list_vehicle_orders_uses_default_shape +0 -0
  166. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestVehiclesIntegration.test_list_vehicles_uses_default_shape_and_search +0 -0
  167. {tango_python-1.8.0 → tango_python-1.9.0}/tests/cassettes/TestVehiclesIntegration.test_list_vehicles_with_ordering +0 -0
  168. {tango_python-1.8.0 → tango_python-1.9.0}/tests/conftest.py +0 -0
  169. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/README.md +0 -0
  170. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/__init__.py +0 -0
  171. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/conftest.py +0 -0
  172. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/test_agencies_integration.py +0 -0
  173. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/test_contracts_integration.py +0 -0
  174. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/test_edge_cases_integration.py +0 -0
  175. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/test_entities_integration.py +0 -0
  176. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/test_forecasts_integration.py +0 -0
  177. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/test_grants_integration.py +0 -0
  178. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/test_itdashboard_integration.py +0 -0
  179. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/test_naics_integration.py +0 -0
  180. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/test_notices_integration.py +0 -0
  181. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/test_offices_integration.py +0 -0
  182. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/test_opportunities_integration.py +0 -0
  183. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/test_organizations_integration.py +0 -0
  184. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/test_otas_otidvs_integration.py +0 -0
  185. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/test_protests_integration.py +0 -0
  186. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/test_reference_data_integration.py +0 -0
  187. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/test_subawards_integration.py +0 -0
  188. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/test_vehicles_idvs_integration.py +0 -0
  189. {tango_python-1.8.0 → tango_python-1.9.0}/tests/integration/validation.py +0 -0
  190. {tango_python-1.8.0 → tango_python-1.9.0}/tests/production/__init__.py +0 -0
  191. {tango_python-1.8.0 → tango_python-1.9.0}/tests/production/conftest.py +0 -0
  192. {tango_python-1.8.0 → tango_python-1.9.0}/tests/production/test_production_smoke.py +0 -0
  193. {tango_python-1.8.0 → tango_python-1.9.0}/tests/test_api_parity.py +0 -0
  194. {tango_python-1.8.0 → tango_python-1.9.0}/tests/test_client.py +0 -0
  195. {tango_python-1.8.0 → tango_python-1.9.0}/tests/test_contract_appeals.py +0 -0
  196. {tango_python-1.8.0 → tango_python-1.9.0}/tests/test_dibbs_exclusions_sbir.py +0 -0
  197. {tango_python-1.8.0 → tango_python-1.9.0}/tests/test_federal_register.py +0 -0
  198. {tango_python-1.8.0 → tango_python-1.9.0}/tests/test_models.py +0 -0
  199. {tango_python-1.8.0 → tango_python-1.9.0}/tests/test_opportunity_attachments.py +0 -0
  200. {tango_python-1.8.0 → tango_python-1.9.0}/tests/test_organization_scope.py +0 -0
  201. {tango_python-1.8.0 → tango_python-1.9.0}/tests/test_shape_coverage_gate.py +0 -0
  202. {tango_python-1.8.0 → tango_python-1.9.0}/tests/test_shapes.py +0 -0
  203. {tango_python-1.8.0 → tango_python-1.9.0}/tests/test_sled.py +0 -0
  204. {tango_python-1.8.0 → tango_python-1.9.0}/tests/test_webhooks_cli.py +0 -0
  205. {tango_python-1.8.0 → tango_python-1.9.0}/tests/test_webhooks_receiver.py +0 -0
  206. {tango_python-1.8.0 → tango_python-1.9.0}/tests/test_webhooks_signing.py +0 -0
  207. {tango_python-1.8.0 → tango_python-1.9.0}/tests/test_webhooks_simulate.py +0 -0
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.9.0] - 2026-09-28
11
+
12
+ ### Added
13
+ - **GSA eBuy requests** (Tango API 5.1.0). Four methods over the `/api/ebuy/` resource: `list_ebuy_requests()`, `get_ebuy_request()`, `get_ebuy_attachment_url()` and `get_ebuy_access()`, plus `EbuyRequest` / `EbuyAttachment` schemas, an `EbuyAccess` result type, and the `EBUY_REQUESTS_MINIMAL` / `EBUY_REQUESTS_COMPREHENSIVE` defaults. These are the RFQs, RFPs and RFIs posted to GSA eBuy. All fourteen of the API's filters are explicit named parameters, including `search` (which also matches attachment text), `reference_number`, `sin`, `agency` and `contract_number`.
14
+
15
+ Access is scoped to your account: you see only requests posted under the GSA schedule contracts linked to it, and the endpoints require the Pro tier or above. With no linked contract, `list_ebuy_requests()` returns an empty page rather than an error, so `get_ebuy_access()` reports whether access is enabled, why not (`tier_required` or `no_contract_grant`), and which contracts are linked. `status` is frozen at the last state a request was seen in, because a request that closes stops appearing rather than getting a final row; read `last_seen` for staleness. `get_ebuy_attachment_url()` returns the short-lived signed URL the API redirects to without downloading the document, and raises the new `TangoAttachmentLinkError` (a `TangoValidationError`) with the link on `.url` when the entry is an external link rather than a stored document. The `agency` filter requires Tango API 5.3.0.
16
+ - **`alerts.ebuy_request.match` is an alertable event type.** `create_webhook_alert(query_type="ebuy_request", ...)` works.
17
+
10
18
  ## [1.8.0] - 2026-09-28
11
19
 
12
20
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tango-python
3
- Version: 1.8.0
3
+ Version: 1.9.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
@@ -308,6 +308,15 @@ protests = client.list_protests(source_system="gao", outcome="Sustained", limit=
308
308
  protest = client.get_protest("CASE_UUID")
309
309
  ```
310
310
 
311
+ ### GSA eBuy
312
+
313
+ ```python
314
+ access = client.get_ebuy_access() # scoped to the schedule contracts linked to your account
315
+ requests = client.list_ebuy_requests(status="Open", sin="54151S", limit=25)
316
+ request = client.get_ebuy_request("RFQ1835158")
317
+ url = client.get_ebuy_attachment_url("RFQ1835158", doc_seq_num=1) # short-lived signed URL
318
+ ```
319
+
311
320
  ### GSA eLibrary Contracts
312
321
 
313
322
  ```python
@@ -254,6 +254,15 @@ protests = client.list_protests(source_system="gao", outcome="Sustained", limit=
254
254
  protest = client.get_protest("CASE_UUID")
255
255
  ```
256
256
 
257
+ ### GSA eBuy
258
+
259
+ ```python
260
+ access = client.get_ebuy_access() # scoped to the schedule contracts linked to your account
261
+ requests = client.list_ebuy_requests(status="Open", sin="54151S", limit=25)
262
+ request = client.get_ebuy_request("RFQ1835158")
263
+ url = client.get_ebuy_attachment_url("RFQ1835158", doc_seq_num=1) # short-lived signed URL
264
+ ```
265
+
257
266
  ### GSA eLibrary Contracts
258
267
 
259
268
  ```python
@@ -24,6 +24,7 @@ Complete reference for all Tango Python SDK methods and functionality.
24
24
  - [Protests](#protests)
25
25
  - [Contract Appeals](#contract-appeals)
26
26
  - [Federal Register](#federal-register)
27
+ - [GSA eBuy](#gsa-ebuy)
27
28
  - [Budget](#budget)
28
29
  - [Business Types](#business-types)
29
30
  - [NAICS](#naics)
@@ -1602,6 +1603,118 @@ forecast = client.get_sled_forecast("FORECAST_UUID")
1602
1603
 
1603
1604
  ---
1604
1605
 
1606
+ ## GSA eBuy
1607
+
1608
+ GSA eBuy requests for quotes, proposals and information (RFQs, RFPs, RFIs), keyed by `rfq_id`.
1609
+
1610
+ **Access is scoped to your account.** You see only requests posted under the GSA schedule contracts linked to your account, and the endpoints require the Pro tier or above (below it they return 403). With no linked contract, `list_ebuy_requests()` returns an empty page rather than an error, and `get_ebuy_request()` raises `TangoNotFoundError` for a request outside your scope, the same as for an id that does not exist. Use `get_ebuy_access()` to tell "no access" from "no matches".
1611
+
1612
+ ### list_ebuy_requests()
1613
+
1614
+ List requests with filtering and shaping.
1615
+
1616
+ ```python
1617
+ requests = client.list_ebuy_requests(
1618
+ page=1,
1619
+ limit=25,
1620
+ shape=ShapeConfig.EBUY_REQUESTS_MINIMAL,
1621
+ # Filter parameters (all optional)
1622
+ search=None,
1623
+ rfq_id=None,
1624
+ reference_number=None,
1625
+ request_type=None,
1626
+ status=None,
1627
+ sin=None,
1628
+ schedule=None,
1629
+ buyer_agency=None,
1630
+ agency=None,
1631
+ contract_number=None,
1632
+ issue_date_after=None,
1633
+ issue_date_before=None,
1634
+ close_date_after=None,
1635
+ close_date_before=None,
1636
+ ordering=None,
1637
+ )
1638
+ ```
1639
+
1640
+ **Filter Parameters:**
1641
+ - `search` - Full-text search over the title, description, reference number, request id and attachment text. Results rank by relevance unless `ordering` is given
1642
+ - `rfq_id` - Exact request id, e.g. `"RFQ1835158"`
1643
+ - `reference_number` - The buyer's own solicitation number; dashes are ignored
1644
+ - `request_type` - `"RFQ"`, `"RFP"` or `"RFI"`
1645
+ - `status` - `"Open"` or `"Cancelled"`, as last seen (see the note below)
1646
+ - `sin` - Special Item Number, e.g. `"54151S"`
1647
+ - `schedule` - GSA schedule
1648
+ - `buyer_agency` - The buyer agency as eBuy names it (free text)
1649
+ - `agency` - A Tango agency name, abbreviation, code or organization key, e.g. `"GSA"`. Matches the whole organization subtree, so a department includes its sub-agencies. Requires Tango API 5.3.0
1650
+ - `contract_number` - Narrow to requests posted under one of your own linked contracts. A contract not linked to your account returns an empty page, not an error
1651
+ - `issue_date_after` / `issue_date_before` - Issue date range (`YYYY-MM-DD`, inclusive)
1652
+ - `close_date_after` / `close_date_before` - Close date range (`YYYY-MM-DD`, inclusive)
1653
+ - `ordering` - `issue_date` (the default, as `-issue_date`), `close_date`, `last_seen` or `modified`; prefix `-` for descending
1654
+
1655
+ String filters accept several values joined with `|` (OR).
1656
+
1657
+ **Returns:** [PaginatedResponse](#paginatedresponse) with request dictionaries
1658
+
1659
+ **Example:**
1660
+ ```python
1661
+ requests = client.list_ebuy_requests(status="Open", sin="54151S", ordering="close_date")
1662
+
1663
+ for req in requests.results:
1664
+ print(f"{req['rfq_id']} closes {req['close_date']}: {req['title']}")
1665
+ print(f" last seen {req['last_seen']}")
1666
+ ```
1667
+
1668
+ **Notes:**
1669
+ - **`status` is frozen at the last state the request was seen in.** Only currently-active requests are carried, so a request that closes stops appearing rather than getting a final row. `Open` means "open the last time it was seen", not "open now"; read `last_seen` for staleness.
1670
+ - The contract number a request was posted under is never returned in any payload.
1671
+ - `buyer_agency_code` and some other buyer and contact fields are sparse on older requests.
1672
+
1673
+ ### get_ebuy_request()
1674
+
1675
+ Get a single request by `rfq_id`.
1676
+
1677
+ ```python
1678
+ request = client.get_ebuy_request(
1679
+ "RFQ1835158",
1680
+ shape=ShapeConfig.EBUY_REQUESTS_COMPREHENSIVE,
1681
+ )
1682
+
1683
+ for attachment in request["attachments"]:
1684
+ print(attachment["doc_seq_num"], attachment["doc_name"], attachment["is_link"])
1685
+ ```
1686
+
1687
+ The default shape returns every field plus two expands: `organization` (the buyer office, with the same seven keys as other resources' `organization` expand) and `attachments`. Each attachment carries `doc_seq_num`, `doc_name`, `doc_type`, `doc_path`, `is_link` and `doc_session_date`. `is_link=True` means `doc_path` is an outbound URL with no stored document behind it. `amendments`, `line_items` and `addresses` are lists of objects, served as eBuy publishes them.
1688
+
1689
+ ### get_ebuy_attachment_url()
1690
+
1691
+ Get a short-lived download URL for one stored attachment.
1692
+
1693
+ ```python
1694
+ url = client.get_ebuy_attachment_url("RFQ1835158", doc_seq_num=1)
1695
+ ```
1696
+
1697
+ **Returns:** The signed URL the API redirects to, as a string. The SDK reads the redirect without following it, so no document is downloaded. The URL expires after about five minutes: fetch it promptly, and call this again rather than storing it.
1698
+
1699
+ **Raises:**
1700
+ - `TangoAttachmentLinkError` (a `TangoValidationError`) - The entry is an external link (`is_link`), not a stored document. The link is on `error.url`
1701
+ - `TangoNotFoundError` - The request is unknown or outside your scope, the attachment does not exist, or its document has not been captured yet
1702
+
1703
+ ### get_ebuy_access()
1704
+
1705
+ Check whether your account can read eBuy requests.
1706
+
1707
+ ```python
1708
+ access = client.get_ebuy_access()
1709
+ if not access.enabled:
1710
+ print(access.reason) # "tier_required" or "no_contract_grant"
1711
+ print(access.contracts) # your own linked contracts, sorted
1712
+ ```
1713
+
1714
+ **Returns:** `EbuyAccess` with `enabled` (bool), `reason` (`"tier_required"`, `"no_contract_grant"` or `None`; `tier_required` wins when both apply) and `contracts` (list of str).
1715
+
1716
+ ---
1717
+
1605
1718
  ## Budget
1606
1719
 
1607
1720
  Federal account × fiscal year budget rollups, covering the full budget lifecycle (requested → enacted → apportioned → obligated → outlayed), pre-computed ratios and trends, the contract / assistance / unlinked breakdown, and request-vs-actual spend.
@@ -2401,6 +2514,8 @@ entity = client.get_entity("UEI_KEY", shape=ShapeConfig.ENTITIES_COMPREHENSIVE)
2401
2514
  | `CONTRACT_APPEALS_COMPREHENSIVE` | `get_contract_appeal` | The list fields plus docket_raw, docket_source, decision_date_repaired, decision_type_raw, listing_year, first_listed_at, listed, text_status, text_char_count (omits `decision_text`, which needs an Enterprise plan) |
2402
2515
  | `FEDERAL_REGISTER_MINIMAL` | `list_federal_register_documents` | uuid, document_number, publication_date, type, subtype, title, abstract, action, agencies, cfr_references, citation, significant, comments_close_on, effective_on, html_url, pdf_url |
2403
2516
  | `FEDERAL_REGISTER_COMPREHENSIVE` | `get_federal_register_document` | The list fields plus dates, signing_date, start_page, end_page, volume, docket_ids, dockets, regulation_id_numbers, topics, correction_of, corrections, executive_order_number, presidential_document_number, proclamation_number, comment_url, regulations_dot_gov_url, raw_text_url, body_html_url (omits `full_text`) |
2517
+ | `EBUY_REQUESTS_MINIMAL` | `list_ebuy_requests` | rfq_id, request_type, title, schedule, sin, status, buyer_name, buyer_agency, buyer_agency_code, reference_number, issue_date, close_date, attachment_count, link_count, last_seen |
2518
+ | `EBUY_REQUESTS_COMPREHENSIVE` | `get_ebuy_request` | Every field, plus the `organization` and `attachments` expands |
2404
2519
  | `BUDGET_ACCOUNTS_MINIMAL` | `list_budget_accounts`, `get_budget_account` | id, federal_account_symbol, fiscal_year, agency_code/name, bureau_name, account_title, bea_category, on_off_budget, subfunction_code, lifecycle (requested/enacted/apportioned/obligated/outlayed/unobligated), contract & assistance rollups, key ratios, next-year growth |
2405
2520
  | `VEHICLE_ORDERS_MINIMAL` | `list_vehicle_orders` | key, piid, award_date, recipient(display_name,uei), total_contract_value, obligated |
2406
2521
  | `ITDASHBOARD_INVESTMENTS_MINIMAL` | `list_itdashboard_investments` | Minimal IT Dashboard investment fields |
@@ -90,6 +90,7 @@ tango webhooks list-event-types
90
90
  # alerts.sled_opportunity.match State/local/education solicitation matched a saved alert
91
91
  # alerts.contract_appeal.match CBCA/ASBCA appeal decision matched a saved alert
92
92
  # alerts.federal_register.match Federal Register document matched a saved alert
93
+ # alerts.ebuy_request.match GSA eBuy request matched a saved alert
93
94
  ```
94
95
 
95
96
  This list is served by the API, so it is always current — the SDK does not hardcode
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tango-python"
7
- version = "1.8.0"
7
+ version = "1.9.0"
8
8
  description = "Python SDK for the Tango API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -79,6 +79,7 @@ RESOURCE_TO_METHOD: dict[str, str | None] = {
79
79
  "protests": "list_protests",
80
80
  "contract_appeals": "list_contract_appeals",
81
81
  "federal_register": "list_federal_register_documents",
82
+ "ebuy/requests": "list_ebuy_requests",
82
83
  "offices": "list_offices",
83
84
  "psc": "list_psc",
84
85
  "mas_sins": "list_mas_sins",
@@ -70,6 +70,7 @@ RESOURCE_TO_MODEL: dict[str, str] = {
70
70
  "protests": "Protest",
71
71
  "contract_appeals": "ContractAppeal",
72
72
  "federal_register": "FederalRegisterDocument",
73
+ "ebuy/requests": "EbuyRequest",
73
74
  "offices": "Office",
74
75
  "assistance_listings": "AssistanceListing",
75
76
  "business_types": "BusinessType",
@@ -3,6 +3,7 @@
3
3
  from .client import TangoClient
4
4
  from .exceptions import (
5
5
  TangoAPIError,
6
+ TangoAttachmentLinkError,
6
7
  TangoAuthError,
7
8
  TangoNotFoundError,
8
9
  TangoRateLimitError,
@@ -14,6 +15,9 @@ from .models import (
14
15
  DibbsAward,
15
16
  DibbsRfp,
16
17
  DibbsRfq,
18
+ EbuyAccess,
19
+ EbuyAttachment,
20
+ EbuyRequest,
17
21
  Exclusion,
18
22
  FederalRegisterDocument,
19
23
  GsaElibraryContract,
@@ -55,13 +59,14 @@ from .webhooks import (
55
59
  )
56
60
  from .webhooks.receiver import Delivery, WebhookReceiver
57
61
 
58
- __version__ = "1.8.0"
62
+ __version__ = "1.9.0"
59
63
  __all__ = [
60
64
  "TangoClient",
61
65
  "TangoAPIError",
62
66
  "TangoAuthError",
63
67
  "TangoNotFoundError",
64
68
  "TangoValidationError",
69
+ "TangoAttachmentLinkError",
65
70
  "TangoRateLimitError",
66
71
  "RateLimitInfo",
67
72
  "ResolveCandidate",
@@ -69,6 +74,9 @@ __all__ = [
69
74
  "BudgetAccount",
70
75
  "ContractAppeal",
71
76
  "FederalRegisterDocument",
77
+ "EbuyAccess",
78
+ "EbuyAttachment",
79
+ "EbuyRequest",
72
80
  "DibbsAward",
73
81
  "DibbsRfp",
74
82
  "DibbsRfq",
@@ -5,12 +5,13 @@ import warnings
5
5
  from datetime import date, datetime
6
6
  from decimal import Decimal
7
7
  from typing import Any, Literal, cast
8
- from urllib.parse import urljoin
8
+ from urllib.parse import quote, urljoin
9
9
 
10
10
  import httpx
11
11
 
12
12
  from tango.exceptions import (
13
13
  TangoAPIError,
14
+ TangoAttachmentLinkError,
14
15
  TangoAuthError,
15
16
  TangoNotFoundError,
16
17
  TangoRateLimitError,
@@ -28,6 +29,8 @@ from tango.models import (
28
29
  DibbsAward,
29
30
  DibbsRfp,
30
31
  DibbsRfq,
32
+ EbuyAccess,
33
+ EbuyRequest,
31
34
  Entity,
32
35
  Exclusion,
33
36
  FederalRegisterDocument,
@@ -175,55 +178,57 @@ class TangoClient:
175
178
  self._last_response_headers = response.headers
176
179
  self._last_rate_limit_info = self._parse_rate_limit_headers(response.headers)
177
180
 
178
- if response.status_code == 401:
179
- raise TangoAuthError(
180
- "Invalid API key or authentication required", response.status_code
181
- )
182
- elif response.status_code == 404:
183
- raise TangoNotFoundError("Resource not found", response.status_code)
184
- elif response.status_code == 400:
185
- error_data = response.json() if response.content else {}
186
- error_msg = "Invalid request parameters"
187
- if error_data:
188
- # Try to extract a more specific error message
189
- if isinstance(error_data, dict):
190
- detail = (
191
- error_data.get("detail")
192
- or error_data.get("message")
193
- or error_data.get("error")
194
- )
195
- if detail:
196
- error_msg = f"Invalid request parameters: {detail}"
197
- issues = error_data.get("issues")
198
- if isinstance(issues, list):
199
- rejected = [
200
- f"{issue['path']} ({issue['reason']})"
201
- if issue.get("reason")
202
- else str(issue["path"])
203
- for issue in issues
204
- if isinstance(issue, dict) and issue.get("path")
205
- ]
206
- if rejected:
207
- error_msg = f"{error_msg}: {', '.join(rejected)}"
208
- raise TangoValidationError(
209
- error_msg,
210
- response.status_code,
211
- error_data,
212
- )
213
- elif response.status_code == 429:
214
- error_data = response.json() if response.content else {}
215
- detail = error_data.get("detail", "Rate limit exceeded")
216
- raise TangoRateLimitError(detail, response.status_code, error_data)
217
- elif not response.is_success:
218
- raise TangoAPIError(
219
- f"API request failed with status {response.status_code}", response.status_code
220
- )
181
+ self._raise_for_status(response)
221
182
 
222
183
  return response.json() if response.content else {}
223
184
 
224
185
  except httpx.HTTPError as e:
225
186
  raise TangoAPIError(f"Request failed: {str(e)}") from e
226
187
 
188
+ def _raise_for_status(self, response: httpx.Response) -> None:
189
+ """Raise the SDK exception matching a non-success response; return quietly on success."""
190
+ if response.status_code == 401:
191
+ raise TangoAuthError("Invalid API key or authentication required", response.status_code)
192
+ elif response.status_code == 404:
193
+ raise TangoNotFoundError("Resource not found", response.status_code)
194
+ elif response.status_code == 400:
195
+ error_data = response.json() if response.content else {}
196
+ error_msg = "Invalid request parameters"
197
+ if error_data:
198
+ # Try to extract a more specific error message
199
+ if isinstance(error_data, dict):
200
+ detail = (
201
+ error_data.get("detail")
202
+ or error_data.get("message")
203
+ or error_data.get("error")
204
+ )
205
+ if detail:
206
+ error_msg = f"Invalid request parameters: {detail}"
207
+ issues = error_data.get("issues")
208
+ if isinstance(issues, list):
209
+ rejected = [
210
+ f"{issue['path']} ({issue['reason']})"
211
+ if issue.get("reason")
212
+ else str(issue["path"])
213
+ for issue in issues
214
+ if isinstance(issue, dict) and issue.get("path")
215
+ ]
216
+ if rejected:
217
+ error_msg = f"{error_msg}: {', '.join(rejected)}"
218
+ raise TangoValidationError(
219
+ error_msg,
220
+ response.status_code,
221
+ error_data,
222
+ )
223
+ elif response.status_code == 429:
224
+ error_data = response.json() if response.content else {}
225
+ detail = error_data.get("detail", "Rate limit exceeded")
226
+ raise TangoRateLimitError(detail, response.status_code, error_data)
227
+ elif not response.is_success:
228
+ raise TangoAPIError(
229
+ f"API request failed with status {response.status_code}", response.status_code
230
+ )
231
+
227
232
  def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
228
233
  """Make a GET request"""
229
234
  return self._request("GET", endpoint, params=params)
@@ -2963,6 +2968,215 @@ class TangoClient:
2963
2968
  data, shape, FederalRegisterDocument, flat, flat_lists
2964
2969
  )
2965
2970
 
2971
+ # ============================================================================
2972
+ # GSA eBuy requests
2973
+ # ============================================================================
2974
+
2975
+ def list_ebuy_requests(
2976
+ self,
2977
+ page: int = 1,
2978
+ limit: int = 25,
2979
+ shape: str | None = None,
2980
+ flat: bool = False,
2981
+ flat_lists: bool = False,
2982
+ search: str | None = None,
2983
+ rfq_id: str | None = None,
2984
+ reference_number: str | None = None,
2985
+ request_type: str | None = None,
2986
+ status: str | None = None,
2987
+ sin: str | None = None,
2988
+ schedule: str | None = None,
2989
+ buyer_agency: str | None = None,
2990
+ agency: str | None = None,
2991
+ contract_number: str | None = None,
2992
+ issue_date_after: str | None = None,
2993
+ issue_date_before: str | None = None,
2994
+ close_date_after: str | None = None,
2995
+ close_date_before: str | None = None,
2996
+ ordering: str | None = None,
2997
+ ) -> PaginatedResponse:
2998
+ """
2999
+ List GSA eBuy requests (RFQs, RFPs and RFIs).
3000
+
3001
+ Requires the Pro tier or above; below it the API returns 403.
3002
+ Results are scoped to your account: you see only requests posted under the GSA schedule contracts linked to it.
3003
+ With no linked contract the page is empty rather than an error, so use ``get_ebuy_access()`` to tell "no access" from "no matches".
3004
+
3005
+ ``status`` is frozen at the last state the request was seen in.
3006
+ Only currently-active requests are carried, so a request that closes stops appearing rather than getting a final row, and ``Open`` means "open the last time it was seen".
3007
+ Read ``last_seen`` for staleness.
3008
+
3009
+ Args:
3010
+ page: Page number
3011
+ limit: Results per page (max 100)
3012
+ shape: Response shape string (defaults to minimal shape)
3013
+ flat: If True, flatten nested objects in shaped response
3014
+ flat_lists: If True, flatten arrays using indexed keys
3015
+ search: Full-text search over the title, description, reference number, request id and attachment text. Results rank by relevance unless ``ordering`` is given
3016
+ rfq_id: Exact request id, e.g. ``RFQ1835158``. OR several with ``|``
3017
+ reference_number: The buyer's own solicitation number; dashes are ignored
3018
+ request_type: ``RFQ``, ``RFP`` or ``RFI``. OR several with ``|``
3019
+ status: ``Open`` or ``Cancelled``, as last seen. OR several with ``|``
3020
+ sin: Special Item Number, e.g. ``54151S``. OR several with ``|``
3021
+ schedule: GSA schedule. OR several with ``|``
3022
+ buyer_agency: The buyer agency as eBuy names it (free text). OR several with ``|``
3023
+ agency: A Tango agency name, abbreviation, code or organization key, e.g. ``GSA``. Matches the whole organization subtree, so a department includes its sub-agencies. OR several with ``|``. Requires Tango API 5.3.0
3024
+ contract_number: Narrow to requests posted under one of your own linked contracts. A contract not linked to your account returns an empty page, not an error
3025
+ issue_date_after: Issued on or after (YYYY-MM-DD)
3026
+ issue_date_before: Issued on or before (YYYY-MM-DD)
3027
+ close_date_after: Closes on or after (YYYY-MM-DD)
3028
+ close_date_before: Closes on or before (YYYY-MM-DD)
3029
+ ordering: Sort field — ``issue_date`` (the default, as ``-issue_date``), ``close_date``, ``last_seen`` or ``modified``. Prefix ``-`` for descending
3030
+ """
3031
+ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
3032
+
3033
+ if shape is None:
3034
+ shape = ShapeConfig.EBUY_REQUESTS_MINIMAL
3035
+ if shape:
3036
+ params["shape"] = shape
3037
+ if flat:
3038
+ params["flat"] = "true"
3039
+ if flat_lists:
3040
+ params["flat_lists"] = "true"
3041
+
3042
+ for key, val in (
3043
+ ("search", search),
3044
+ ("rfq_id", rfq_id),
3045
+ ("reference_number", reference_number),
3046
+ ("request_type", request_type),
3047
+ ("status", status),
3048
+ ("sin", sin),
3049
+ ("schedule", schedule),
3050
+ ("buyer_agency", buyer_agency),
3051
+ ("agency", agency),
3052
+ ("contract_number", contract_number),
3053
+ ("issue_date_after", issue_date_after),
3054
+ ("issue_date_before", issue_date_before),
3055
+ ("close_date_after", close_date_after),
3056
+ ("close_date_before", close_date_before),
3057
+ ("ordering", ordering),
3058
+ ):
3059
+ if val is not None:
3060
+ params[key] = val
3061
+
3062
+ data = self._get("/api/ebuy/requests/", params)
3063
+
3064
+ results = [
3065
+ self._parse_response_with_shape(item, shape, EbuyRequest, flat, flat_lists)
3066
+ for item in data["results"]
3067
+ ]
3068
+
3069
+ return PaginatedResponse(
3070
+ count=data["count"],
3071
+ next=data.get("next"),
3072
+ previous=data.get("previous"),
3073
+ results=results,
3074
+ meta=data.get("meta"),
3075
+ )
3076
+
3077
+ def get_ebuy_request(
3078
+ self,
3079
+ rfq_id: str,
3080
+ shape: str | None = None,
3081
+ flat: bool = False,
3082
+ flat_lists: bool = False,
3083
+ ) -> Any:
3084
+ """
3085
+ Get a single GSA eBuy request by its request id.
3086
+
3087
+ A request outside your account's scope raises ``TangoNotFoundError``, the same as an id that does not exist.
3088
+
3089
+ Args:
3090
+ rfq_id: Request id, e.g. ``RFQ1835158``
3091
+ shape: Response shape string (defaults to the comprehensive shape: every field plus the ``organization`` and ``attachments`` expands)
3092
+ flat: If True, flatten nested objects in shaped response
3093
+ flat_lists: If True, flatten arrays using indexed keys
3094
+ """
3095
+ params: dict[str, Any] = {}
3096
+ if shape is None:
3097
+ shape = ShapeConfig.EBUY_REQUESTS_COMPREHENSIVE
3098
+ if shape:
3099
+ params["shape"] = shape
3100
+ if flat:
3101
+ params["flat"] = "true"
3102
+ if flat_lists:
3103
+ params["flat_lists"] = "true"
3104
+
3105
+ data = self._get(f"/api/ebuy/requests/{quote(rfq_id, safe='')}/", params)
3106
+ return self._parse_response_with_shape(data, shape, EbuyRequest, flat, flat_lists)
3107
+
3108
+ def get_ebuy_attachment_url(self, rfq_id: str, doc_seq_num: int) -> str:
3109
+ """
3110
+ Get a short-lived download URL for one attachment on a GSA eBuy request.
3111
+
3112
+ The API answers with a redirect to a signed URL that expires after about five minutes; this returns that URL without downloading the document.
3113
+ Fetch it promptly, and call this again rather than storing it.
3114
+
3115
+ Args:
3116
+ rfq_id: Request id, e.g. ``RFQ1835158``
3117
+ doc_seq_num: The attachment's ``doc_seq_num`` from ``get_ebuy_request()``
3118
+
3119
+ Raises:
3120
+ TangoAttachmentLinkError: The entry is an external link (``is_link``), not a stored document. The link is on ``.url`` and in the message. Subclasses ``TangoValidationError``.
3121
+ TangoNotFoundError: The request is unknown or outside your scope, the attachment does not exist, or its document has not been captured yet.
3122
+ """
3123
+ endpoint = (
3124
+ f"/api/ebuy/requests/{quote(rfq_id, safe='')}/attachments/{int(doc_seq_num)}/download/"
3125
+ )
3126
+ url = urljoin(f"{self.base_url}/", endpoint.lstrip("/"))
3127
+
3128
+ try:
3129
+ response = self.client.request(method="GET", url=url, follow_redirects=False)
3130
+ except httpx.HTTPError as e:
3131
+ raise TangoAPIError(f"Request failed: {str(e)}") from e
3132
+ self._last_response_headers = response.headers
3133
+ self._last_rate_limit_info = self._parse_rate_limit_headers(response.headers)
3134
+
3135
+ location = response.headers.get("Location")
3136
+ if response.is_redirect and location:
3137
+ return str(location)
3138
+
3139
+ if response.status_code in (400, 404):
3140
+ try:
3141
+ error_data = response.json() if response.content else {}
3142
+ except ValueError:
3143
+ error_data = {}
3144
+ if not isinstance(error_data, dict):
3145
+ error_data = {}
3146
+ if response.status_code == 400 and error_data.get("url"):
3147
+ raise TangoAttachmentLinkError(
3148
+ f"Attachment {doc_seq_num} on {rfq_id} is an external link, not a stored document: {error_data['url']}",
3149
+ response.status_code,
3150
+ error_data,
3151
+ )
3152
+ if response.status_code == 404:
3153
+ raise TangoNotFoundError(
3154
+ error_data.get("detail") or "Resource not found",
3155
+ response.status_code,
3156
+ error_data,
3157
+ )
3158
+
3159
+ self._raise_for_status(response)
3160
+ raise TangoAPIError(
3161
+ f"Expected a redirect to the attachment, got status {response.status_code}",
3162
+ response.status_code,
3163
+ )
3164
+
3165
+ def get_ebuy_access(self) -> EbuyAccess:
3166
+ """
3167
+ Check whether your account can read GSA eBuy requests.
3168
+
3169
+ ``list_ebuy_requests()`` returns an empty page, not an error, when no contract is linked; this tells the two apart.
3170
+ ``reason`` is ``"tier_required"`` below the Pro tier (it wins when both apply), ``"no_contract_grant"`` when no contract is linked, and ``None`` when ``enabled`` is true.
3171
+ ``contracts`` lists your own linked contracts, sorted.
3172
+ """
3173
+ data = self._get("/api/ebuy/access/")
3174
+ return EbuyAccess(
3175
+ enabled=bool(data.get("enabled")),
3176
+ reason=data.get("reason"),
3177
+ contracts=list(data.get("contracts") or []),
3178
+ )
3179
+
2966
3180
  # ============================================================================
2967
3181
  # DLA DIBBS (RFQs, RFPs, awards)
2968
3182
  # ============================================================================
@@ -5022,7 +5236,7 @@ class TangoClient:
5022
5236
  query_type: One of ``opportunity``, ``contract``, ``idv``, ``ota``,
5023
5237
  ``otidv``, ``entity``, ``grant``, ``forecast``, ``exclusion``,
5024
5238
  ``dibbs_rfq``, ``dibbs_rfp``, ``sled_opportunity``,
5025
- ``contract_appeal`` or ``federal_register``. :meth:`list_webhook_event_types` is the
5239
+ ``contract_appeal``, ``federal_register`` or ``ebuy_request``. :meth:`list_webhook_event_types` is the
5026
5240
  current authority — this list can only go stale.
5027
5241
  filters: Dict of query parameters that the alert matches against
5028
5242
  (e.g. ``{"naics": "541330", "set_aside": "SBA"}``).
@@ -53,6 +53,16 @@ class TangoValidationError(TangoAPIError):
53
53
  return val if isinstance(val, dict) else None
54
54
 
55
55
 
56
+ class TangoAttachmentLinkError(TangoValidationError):
57
+ """The requested attachment is an external link, not a stored document, so there is nothing to download."""
58
+
59
+ @property
60
+ def url(self) -> str | None:
61
+ """The external link the entry points at."""
62
+ val = self.response_data.get("url")
63
+ return val if isinstance(val, str) else None
64
+
65
+
56
66
  class TangoRateLimitError(TangoAPIError):
57
67
  """Rate limit exceeded error"""
58
68