diff --git a/CHANGELOG.md b/CHANGELOG.md index 27f7efe..35043b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- `ordering` parameter on `list_forecasts`, `list_grants`, `list_subawards`, `list_gsa_elibrary_contracts`, `list_opportunities`, `list_notices`, and `list_protests`. Prefix with `-` for descending. Closes a parity gap with the API surface (these endpoints all accept `?ordering=` server-side). +- `create_webhook_endpoint` accepts `name=` (keyword-only). Required by the Tango API since multi-endpoint support landed; omitting it now emits a `DeprecationWarning` and will become an error in a future major version. +- `create_webhook_subscription` accepts `endpoint=`, `subscription_type=`, `query_type=`, `filter_definition=`, `frequency=`, `cron_expression=`, `is_active=` (keyword-only). Lets callers target a specific endpoint and create filter subscriptions through the canonical API. +- `update_webhook_subscription` accepts `frequency=`, `cron_expression=`, `is_active=` for filter-subscription updates. +- `update_webhook_endpoint` accepts `name=` for renaming an endpoint. +- Webhook alerts (filter subscriptions): `list_webhook_alerts`, `get_webhook_alert`, `create_webhook_alert`, `update_webhook_alert`, `delete_webhook_alert` — the convenience layer over `/api/webhooks/alerts/`. New `WebhookAlert` dataclass exported from the top-level package. +- `resolve(name, target_type, ...)` — POST `/api/resolve/` to rank entity / organization candidates from a free-text name. Returns `ResolveResult` with `ResolveCandidate` entries (both exported). +- `validate(identifier_type, value)` — POST `/api/validate/` to validate the format of a PIID, solicitation number, or UEI. Returns `ValidateResult` (exported). +- Reference data: `list_departments`, `get_department`, `list_psc`, `get_psc`, `get_psc_metrics`, `get_naics`, `get_naics_metrics`, `get_business_type`, `list_assistance_listings`, `get_assistance_listing`, `list_mas_sins`, `get_mas_sin`. +- Entity sub-resources: `list_entity_contracts`, `list_entity_idvs`, `list_entity_otas`, `list_entity_otidvs`, `list_entity_subawards`, `list_entity_lcats`, `get_entity_metrics`. All shape-aware where the underlying endpoint supports shaping. +- IDV sub-resources: `list_idv_lcats`. +- Agency sub-resources: `list_agency_awarding_contracts`, `list_agency_funding_contracts`. +- Misc: `search_opportunity_attachments(q, top_k, include_extracted_text)` for `/api/opportunities/attachment-search/`; `get_version()` for `/api/version/`; `list_api_keys()` for `/api/api-keys/`. + +### Fixed +- `TangoClient._post()` and `_patch()` now accept both `json_data=` (positional) and `json=` (keyword) for backward compatibility. Internal callers and docs examples that use `json=` no longer fail with `TypeError`. + ## [0.6.0] - 2026-05-07 ### Added diff --git a/README.md b/README.md index 4964e4e..8fce9a5 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ print(f"Agency: {agency['name']}") contracts = client.list_contracts( limit=10 ) +``` + ## Authentication Most endpoints require an API key. You can obtain one from the [Tango API portal](https://tango.makegov.com). @@ -204,13 +206,13 @@ opportunities = client.list_opportunities(agency="DOD", active=True, limit=25) ### Notices ```python -notices = client.list_notices(agency="DOD", notice_type="award", limit=25) +notices = client.list_notices(agency="DOD", notice_type="Presolicitation", limit=25) ``` ### Grants ```python -grants = client.list_grants(agency="HHS", status="forecasted", limit=25) +grants = client.list_grants(agency="HHS", status="F", limit=25) # F = Forecasted ``` ### Protests @@ -230,12 +232,45 @@ contract = client.get_gsa_elibrary_contract("UUID") ### Reference Data ```python -# Offices, organizations, NAICS, subawards, business types +# Offices, organizations, NAICS, PSC, subawards, business types offices = client.list_offices(search="acquisitions") organizations = client.list_organizations(level=1) naics = client.list_naics(search="software") +get_naics = client.get_naics("541511") +psc = client.list_psc() subawards = client.list_subawards(prime_uei="UEI123") business_types = client.list_business_types() +mas_sins = client.list_mas_sins() +assistance = client.list_assistance_listings() +departments = client.list_departments() +``` + +### Resolve / Validate + +```python +# Resolve a name to entity/org candidates +result = client.resolve(name="Lockheed Martin", target_type="entity") +for c in result.candidates: + print(c.identifier, c.display_name) + +# Validate an identifier +result = client.validate(identifier_type="uei", value="ABCDEF123456") +``` + +### IT Dashboard + +```python +investments = client.list_itdashboard_investments(search="cloud", limit=25) +investment = client.get_itdashboard_investment("023-000001234") +``` + +### Entity Sub-resources + +```python +contracts = client.list_entity_contracts("ABCDEF123456", limit=25) +idvs = client.list_entity_idvs("ABCDEF123456") +otas = client.list_entity_otas("ABCDEF123456") +metrics = client.get_entity_metrics("ABCDEF123456", months=12, period_grouping="month") ``` ## Pagination @@ -253,9 +288,9 @@ print(f"Previous page URL: {response.previous}") for contract in response.results: print(contract['description']) -# Get next page +# Get next page (contracts use keyset/cursor pagination) if response.next: - next_response = client.list_contracts(page=2, limit=25) + next_response = client.list_contracts(cursor=response.cursor, limit=25) ``` ## Error Handling @@ -282,7 +317,7 @@ except TangoNotFoundError: print("Resource not found") except TangoValidationError as e: print(f"Invalid parameters: {e.message}") - print(f"Details: {e.details}") + print(f"Details: {e.response_data}") except TangoRateLimitError: print("Rate limit exceeded") except TangoAPIError as e: @@ -314,22 +349,18 @@ contracts = client.list_contracts( ### Flattened Responses -Enable flattening to get dot-notation field names: +The `flat=True` parameter is passed to the API, which returns dot-notation keys in the raw response. The SDK still wraps the result in a `ShapedModel` — access nested fields via attribute or dict syntax, not dot-notation string keys: ```python contracts = client.list_contracts( shape="key,piid,recipient(display_name,uei)", flat=True ) -# Returns: {"key": "...", "piid": "...", "recipient.display_name": "...", "recipient.uei": "..."} - -# Flatten arrays with indexed keys -contracts = client.list_contracts( - shape="key,transactions(*)", - flat=True, - flat_lists=True -) -# Returns: {"key": "...", "transactions.0.action_date": "...", "transactions.0.obligated": "..."} +for contract in contracts.results: + # Attribute access + print(contract.recipient.display_name) + # Dict access (nested, not flat string keys) + print(contract['recipient']['display_name']) ``` ### Webhook Tooling diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 8416e25..2b68010 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -68,8 +68,9 @@ agencies = client.list_agencies(page=1, limit=25) **Parameters:** - `page` (int): Page number (default: 1) - `limit` (int): Results per page (default: 25, max: 100) +- `search` (str, optional): Search term to filter agencies by name -**Returns:** [PaginatedResponse](#paginatedresponse) with agency dictionaries +**Returns:** [PaginatedResponse](#paginatedresponse) with `Agency` dataclass objects **Example:** ```python @@ -77,7 +78,7 @@ agencies = client.list_agencies(limit=10) print(f"Found {agencies.count} total agencies") for agency in agencies.results: - print(f"{agency['code']}: {agency['name']}") + print(f"{agency.code}: {agency.name}") ``` ### get_agency() @@ -91,15 +92,15 @@ agency = client.get_agency(code="GSA") **Parameters:** - `code` (str): Agency code (e.g., "GSA", "DOD", "HHS") -**Returns:** Dictionary with agency details +**Returns:** `Agency` dataclass with agency details **Example:** ```python -gsa = client.get_agency("GSA") -print(f"Name: {gsa['name']}") -print(f"Abbreviation: {gsa.get('abbreviation', 'N/A')}") -if gsa.get('department'): - print(f"Department: {gsa['department']['name']}") +gsa = client.get_agency("4712") +print(f"Name: {gsa.name}") +print(f"Abbreviation: {gsa.abbreviation or 'N/A'}") +if gsa.department: + print(f"Department: {gsa.department.name}") ``` **Agency Fields:** @@ -212,7 +213,7 @@ Search and filter contracts with extensive options. ```python contracts = client.list_contracts( - page=1, + cursor=None, # keyset pagination token (not page number) limit=25, shape=None, flat=False, @@ -253,7 +254,7 @@ contracts = client.list_contracts( ``` **Common Parameters:** -- `page` (int): Page number +- `cursor` (str, optional): Keyset pagination token from `response.next` (contracts use keyset pagination, not page numbers) - `limit` (int): Results per page (max: 100) - `shape` (str): Fields to return (see [Shaping Guide](SHAPES.md)) - `flat` (bool): Flatten nested objects to dot-notation keys @@ -724,10 +725,10 @@ entities = client.list_entities( entities = client.list_entities(search="Booz Allen", limit=20) for entity in entities.results: - print(f"{entity['display_name']}") + print(f"{entity['legal_business_name']}") print(f"UEI: {entity.get('uei', 'N/A')}") if entity.get('business_types'): - print(f"Types: {', '.join(entity['business_types'])}") + print(f"Types: {', '.join(bt['code'] for bt in entity['business_types'])}") ``` ### get_entity() @@ -978,7 +979,7 @@ notices = client.list_notices( **Example:** ```python -notices = client.list_notices(agency="GSA", notice_type="award", limit=20) +notices = client.list_notices(agency="GSA", notice_type="Presolicitation", limit=20) for notice in notices.results: print(f"{notice['title']}") @@ -1050,7 +1051,7 @@ grants = client.list_grants( **Example:** ```python -grants = client.list_grants(agency="HHS", status="forecasted", limit=20) +grants = client.list_grants(agency="HHS", status="F", limit=20) # F = Forecasted, P = Posted for grant in grants.results: print(f"{grant['title']}") @@ -1234,7 +1235,7 @@ business_types = client.list_business_types(page=1, limit=25) business_types = client.list_business_types(limit=50) for biz_type in business_types.results: - print(f"{biz_type['code']}: {biz_type['name']}") + print(f"{biz_type.code}: {biz_type.name}") ``` **Business Type Fields:** @@ -1281,7 +1282,332 @@ naics = client.list_naics( naics = client.list_naics(search="software", limit=10) for code in naics.results: - print(f"{code['code']}: {code['title']}") + print(f"{code['code']}: {code['description']}") +``` + +### get_naics() + +Get a single NAICS code by code string. + +```python +naics = client.get_naics("541511") +``` + +**Returns:** Dictionary with NAICS code details. + +### get_naics_metrics() + +Get computed metrics for a NAICS code. + +```python +metrics = client.get_naics_metrics(code="541511", months=12, period_grouping="month") +``` + +--- + +## PSC + +Product and Service Codes. + +### list_psc() + +```python +psc = client.list_psc(page=1, limit=25) +``` + +### get_psc() + +```python +psc = client.get_psc("D302") +``` + +### get_psc_metrics() + +```python +metrics = client.get_psc_metrics(code="D302", months=12, period_grouping="month") +``` + +--- + +## MAS SINs + +GSA Multiple Award Schedule Special Item Numbers. + +### list_mas_sins() + +```python +sins = client.list_mas_sins(page=1, limit=25) +``` + +### get_mas_sin() + +```python +sin = client.get_mas_sin("54151S") +``` + +--- + +## Assistance Listings (CFDA) + +Catalog of Federal Domestic Assistance listings. + +### list_assistance_listings() + +```python +listings = client.list_assistance_listings(page=1, limit=25) +``` + +### get_assistance_listing() + +```python +listing = client.get_assistance_listing("10.310") +``` + +--- + +## Departments + +### list_departments() + +```python +depts = client.list_departments(page=1, limit=25) +``` + +### get_department() + +```python +dept = client.get_department("097") +``` + +--- + +## Business Types (by code) + +### get_business_type() + +Get a single business type by code. + +```python +bt = client.get_business_type("A6") +``` + +--- + +## IT Dashboard + +Federal IT investments from the OMB IT Dashboard. + +### list_itdashboard_investments() + +```python +investments = client.list_itdashboard_investments( + page=1, + limit=25, + search=None, + agency_code=None, + type_of_investment=None, + # Pro/Business+ tier-gated filters available +) +``` + +**Notes:** +- Filter tier-gating: `search` is free; `agency_code`, `type_of_investment` require Pro; `agency_name`, `cio_rating`, `performance_risk` require Business+. +- Shape defaults to `ShapeConfig.ITDASHBOARD_INVESTMENTS_MINIMAL`. + +### get_itdashboard_investment() + +```python +investment = client.get_itdashboard_investment("023-000001234") +``` + +--- + +## Entity Sub-resources + +### list_entity_contracts() + +```python +contracts = client.list_entity_contracts("ABCDEF123456", limit=25) +``` + +### list_entity_idvs() + +```python +idvs = client.list_entity_idvs("ABCDEF123456", limit=25) +``` + +### list_entity_otas() / list_entity_otidvs() + +```python +otas = client.list_entity_otas("ABCDEF123456", limit=25) +otidvs = client.list_entity_otidvs("ABCDEF123456", limit=25) +``` + +### list_entity_subawards() + +```python +subawards = client.list_entity_subawards("ABCDEF123456", limit=25) +``` + +### list_entity_lcats() + +```python +lcats = client.list_entity_lcats("ABCDEF123456", limit=25) +``` + +### get_entity_metrics() + +```python +metrics = client.get_entity_metrics("ABCDEF123456", months=12, period_grouping="month") +``` + +--- + +## IDV LCATs + +### list_idv_lcats() + +```python +lcats = client.list_idv_lcats("GS-00F-XXXX", limit=25) +``` + +--- + +## Agency Sub-resources + +### list_agency_awarding_contracts() + +List contracts where the agency is the awarding agency. + +```python +contracts = client.list_agency_awarding_contracts("4700", limit=25) +``` + +### list_agency_funding_contracts() + +List contracts where the agency is the funding agency. + +```python +contracts = client.list_agency_funding_contracts("4700", limit=25) +``` + +--- + +## Resolve / Validate + +### resolve() + +Resolve a free-text name to ranked entity or organization candidates. + +```python +result = client.resolve( + name="Lockheed Martin", + target_type="entity", # or "organization" + state="MD", # optional + city="Bethesda", # optional + context="defense contractor", # optional +) + +for candidate in result.candidates: + print(candidate.identifier, candidate.display_name) +``` + +**Notes:** +- Free-tier: up to 3 candidates with `identifier` and `display_name`. +- Pro+: up to 5 candidates with additional `match_tier` field. + +### validate() + +Validate the format of a PIID, solicitation number, or UEI. + +```python +result = client.validate(identifier_type="uei", value="ABCDEF123456") +# identifier_type is one of: "piid", "solicitation", "uei" +``` + +**Note:** The parameter is named `identifier_type` (not `type`) to avoid shadowing the Python builtin. + +--- + +## Opportunities (attachments) + +### search_opportunity_attachments() + +Semantic search over opportunity attachments. `q` is required. + +```python +results = client.search_opportunity_attachments( + q="cybersecurity", + top_k=10, + include_extracted_text=False, +) +``` + +**Parameters:** +- `q` (str): Search query (required) +- `top_k` (int, optional): Number of top results to return +- `include_extracted_text` (bool, optional): Whether to include extracted text from attachments in results + +**Returns:** dict with search results + +--- + +## Webhook Alerts + +The Alerts API is a filter-subscription convenience layer on top of subscriptions. + +### list_webhook_alerts() + +```python +alerts = client.list_webhook_alerts(page=1, page_size=25) +``` + +### get_webhook_alert() + +```python +alert = client.get_webhook_alert("ALERT_UUID") +``` + +### create_webhook_alert() + +```python +alert = client.create_webhook_alert( + name="New cloud IT contracts", + query_type="contract", + filters={"naics": "541511"}, +) +``` + +**Notes:** +- `name` and `query_type` are required. `query_type` is **singular** (e.g. `"contract"`, not `"contracts"`). +- Field naming differs from `create_webhook_subscription`: `name` / `filters` here vs `subscription_name` / `filter_definition`. + +### update_webhook_alert() + +```python +alert = client.update_webhook_alert("ALERT_UUID", name="Updated name") +``` + +### delete_webhook_alert() + +```python +client.delete_webhook_alert("ALERT_UUID") +``` + +--- + +## Utility + +### get_version() + +```python +version = client.get_version() +``` + +### list_api_keys() + +```python +keys = client.list_api_keys() ``` --- @@ -1440,7 +1766,8 @@ from tango.webhooks import ( A stdlib-based local HTTP receiver, useful in tests and during local development. ```python -from tango.webhooks import WebhookReceiver, Delivery +from tango import WebhookReceiver, Delivery # exported from top-level tango package +# or: from tango.webhooks.receiver import WebhookReceiver, Delivery with WebhookReceiver(secret="dev").run() as rx: # ... cause something to POST to rx.url ... @@ -1518,26 +1845,28 @@ print(f"Results on this page: {len(contracts.results)}") for contract in contracts.results: print(contract['piid']) -# Check for more pages +# Check for more pages (contracts use keyset pagination via cursor) if contracts.next: - next_page = client.list_contracts(page=2, limit=25) + next_page = client.list_contracts(cursor=contracts.cursor, limit=25) ``` -**Pagination Example:** +**Pagination Example (contracts use keyset pagination, not page numbers):** ```python -page = 1 +cursor = None all_results = [] +page_num = 1 while True: - response = client.list_contracts(page=page, limit=100) + response = client.list_contracts(cursor=cursor, limit=100) all_results.extend(response.results) - print(f"Page {page}: {len(response.results)} results") + print(f"Batch {page_num}: {len(response.results)} results") if not response.next: break - page += 1 + cursor = response.cursor # use cursor for next page + page_num += 1 print(f"Total collected: {len(all_results)} results") ``` @@ -1565,7 +1894,7 @@ entity = client.get_entity("UEI_KEY", shape=ShapeConfig.ENTITIES_COMPREHENSIVE) | Constant | Used by | Description | |----------|---------|-------------| -| `CONTRACTS_MINIMAL` | `list_contracts`, `search_contracts` | key, piid, award_date, recipient(display_name), description, total_contract_value | +| `CONTRACTS_MINIMAL` | `list_contracts` | key, piid, award_date, recipient(display_name), description, total_contract_value | | `ENTITIES_MINIMAL` | `list_entities` | uei, legal_business_name, cage_code, business_types | | `ENTITIES_COMPREHENSIVE` | `get_entity` | Full entity profile (addresses, naics, psc, obligations, etc.) | | `FORECASTS_MINIMAL` | `list_forecasts` | id, title, anticipated_award_date, fiscal_year, naics_code, status | @@ -1574,15 +1903,18 @@ entity = client.get_entity("UEI_KEY", shape=ShapeConfig.ENTITIES_COMPREHENSIVE) | `GRANTS_MINIMAL` | `list_grants` | grant_id, opportunity_number, title, status(*), agency_code | | `IDVS_MINIMAL` | `list_idvs`, `list_vehicle_awardees` | key, piid, award_date, recipient(display_name,uei), description, total_contract_value, obligated, idv_type | | `IDVS_COMPREHENSIVE` | `get_idv` | Full IDV with offices, place_of_performance, competition, transactions, etc. | -| `VEHICLES_MINIMAL` | `list_vehicles` | uuid, solicitation_identifier, organization_id, awardee_count, order_count, vehicle_obligations, vehicle_contracts_value, solicitation_title, solicitation_date | +| `VEHICLES_MINIMAL` | `list_vehicles` | uuid, solicitation_identifier, is_synthetic_solicitation, program_acronym, organization_id, organization, vehicle_type, description, idv_count, awardee_count, order_count, total_obligated, vehicle_obligations, vehicle_contracts_value, latest_award_date, solicitation_title, solicitation_date | | `VEHICLES_COMPREHENSIVE` | `get_vehicle` | Full vehicle with competition_details, fiscal_year, set_aside, etc. | | `VEHICLE_AWARDEES_MINIMAL` | `list_vehicle_awardees` | uuid, key, piid, award_date, title, order_count, idv_obligations, idv_contracts_value, recipient(display_name,uei) | -| `ORGANIZATIONS_MINIMAL` | `list_organizations`, `list_organization_offices` | key, fh_key, name, level, type, short_name | +| `ORGANIZATIONS_MINIMAL` | `list_organizations` | key, fh_key, name, level, type, short_name | | `OTAS_MINIMAL` | `list_otas` | key, piid, award_date, recipient(display_name,uei), description, total_contract_value, obligated | | `OTIDVS_MINIMAL` | `list_otidvs` | key, piid, award_date, recipient(display_name,uei), description, total_contract_value, obligated, idv_type | | `SUBAWARDS_MINIMAL` | `list_subawards` | award_key, prime_recipient(uei,display_name), subaward_recipient(uei,display_name) | | `GSA_ELIBRARY_CONTRACTS_MINIMAL` | `list_gsa_elibrary_contracts` | uuid, contract_number, schedule, recipient(display_name,uei), idv(key,award_date) | | `PROTESTS_MINIMAL` | `list_protests` | case_id, case_number, title, source_system, outcome, filed_date | +| `VEHICLE_ORDERS_MINIMAL` | `list_vehicle_orders` | key, piid, award_date, recipient(display_name,uei), total_contract_value, obligated | +| `ITDASHBOARD_INVESTMENTS_MINIMAL` | `list_itdashboard_investments` | Minimal IT Dashboard investment fields | +| `ITDASHBOARD_INVESTMENTS_COMPREHENSIVE` | `get_itdashboard_investment` | Full investment fields: uii, agency_code, agency_name, bureau_code, bureau_name, investment_title, type_of_investment, part_of_it_portfolio, updated_time, url | All predefined shapes are validated at SDK release time (see [Developer Guide](DEVELOPERS.md#sdk-conformance-maintainers)). For custom shapes, see the [Shaping Guide](SHAPES.md). @@ -1674,8 +2006,8 @@ try: ) except TangoValidationError as e: print(f"Validation error: {e.message}") - if e.details: - print(f"Details: {e.details}") + if e.response_data: + print(f"Details: {e.response_data}") # Handle rate limiting try: @@ -1719,15 +2051,17 @@ See [Shaping Guide](SHAPES.md) for details. Don't fetch all results at once - paginate responsibly: ```python -# ✅ Good - process page by page -page = 1 -while page <= 10: # Limit to 10 pages - contracts = client.list_contracts(page=page, limit=100) +# ✅ Good - process batch by batch (contracts use keyset/cursor pagination) +cursor = None +batches = 0 +while batches < 10: # Limit to 10 batches + contracts = client.list_contracts(cursor=cursor, limit=100) process_contracts(contracts.results) if not contracts.next: break - page += 1 + cursor = contracts.cursor + batches += 1 ``` ### 3. Use Filters to Narrow Results diff --git a/docs/DEVELOPERS.md b/docs/DEVELOPERS.md index f64b72e..81719c3 100644 --- a/docs/DEVELOPERS.md +++ b/docs/DEVELOPERS.md @@ -52,7 +52,6 @@ Get accurate autocomplete suggestions for only the fields in your shape: ```python contracts = client.list_contracts( shape=ShapeConfig.CONTRACTS_MINIMAL, - use_dynamic=True ) contract = contracts.results[0] # Typing "contract[" shows only: key, piid, award_date, award_type, @@ -75,7 +74,6 @@ Catch shape mismatches early with clear error messages: # If you request a field that doesn't exist contracts = client.list_contracts( shape="key,invalid_field", - use_dynamic=True ) # ShapeValidationError: Field 'invalid_field' does not exist in Contract ``` @@ -134,70 +132,41 @@ from tango import TangoClient, ShapeConfig client = TangoClient(api_key="your-key") -# Ultra-minimal for dropdowns -contracts = client.list_contracts( - shape=ShapeConfig.CONTRACTS_SUMMARY, - limit=100 -) -# Fields: key, piid, recipient(display_name), total_contract_value - -# Balanced for lists (recommended default) +# Default minimal shape for lists contracts = client.list_contracts( shape=ShapeConfig.CONTRACTS_MINIMAL, limit=100 ) -# Fields: key, piid, award_date, award_type, recipient(display_name), -# description, total_contract_value +# Fields: key, piid, award_date, recipient(display_name), description, total_contract_value -# Detailed with context +# Or use a custom shape for specific needs contracts = client.list_contracts( - shape=ShapeConfig.CONTRACTS_COMPREHENSIVE, + shape="key,piid,recipient(display_name),total_contract_value", limit=100 ) -# Fields: 16 fields including agencies, location, classification - -# Optimized for data analysis -contracts = client.list_contracts( - shape=ShapeConfig.CONTRACTS_FOR_ANALYSIS, - limit=1000 -) -# Fields: 13 analytical fields for research and statistics ``` ### Entities ```python -# Fast lookups +# Fast lookups (default for list_entities) entities = client.list_entities( shape=ShapeConfig.ENTITIES_MINIMAL, limit=50 ) -# Fields: uei, display_name, cage_code, business_types +# Fields: uei, legal_business_name, cage_code, business_types # Note: Entities do NOT have a 'key' field - use 'uei' as identifier -# Balanced profile info -entities = client.list_entities( - shape=ShapeConfig.ENTITIES_STANDARD, - limit=50 -) -# Fields: uei, display_name, legal_business_name, cage_code, -# business_types, physical_address(city,country_code) - -# Full vendor details +# Full vendor details (default for get_entity) entities = client.list_entities( shape=ShapeConfig.ENTITIES_COMPREHENSIVE, limit=50 ) -# Fields: All entity fields including: -# - Core: uei, display_name, legal_business_name, dba_name, cage_code -# - Registration: registered, registration_status, purpose_of_registration_code -# - Classification: primary_naics, naics_codes, psc_codes, business_types, sba_business_types -# - Contact: email_address, entity_url -# - Metadata: description, capabilities, keywords -# - Addresses: physical_address(*), mailing_address(*) -# - Dates: sam_activation_date, sam_registration_date, sam_expiration_date -# - Financial: federal_obligations, congressional_district -# - Relationships: relationships(relation,type,uei,display_name) +# Fields: uei, legal_business_name, dba_name, cage_code, +# business_types, primary_naics, naics_codes, psc_codes, +# email_address, entity_url, description, capabilities, keywords, +# physical_address, mailing_address, +# federal_obligations(*), congressional_district ``` ### Forecasts, Opportunities, Notices @@ -248,12 +217,15 @@ for contract in contracts.results: ### Multiple Nested Objects ```python -# Select from multiple nested relations with enhanced fields +# Select from multiple nested relations +# Note: awarding_office(*) returns organization_id, office_code, office_name, +# agency_code, agency_name, department_code, department_name +# Note: place_of_performance uses city_name (not city); no congressional_district custom_shape = ( "key,piid,award_date," "recipient(display_name,uei)," "awarding_office(office_code,office_name,agency_code,agency_name,department_code,department_name)," - "place_of_performance(city,city_name,state_code,state_name,country_code,country_name)" + "place_of_performance(city_name,state_code,state_name,country_code,country_name)" ) contracts = client.list_contracts(shape=custom_shape) @@ -264,7 +236,7 @@ for contract in contracts.results: print(f"Agency: {office.get('agency_name')} ({office.get('agency_code')})") print(f"Department: {office.get('department_name')}") location = contract.get('place_of_performance', {}) - print(f"Location: {location.get('city_name') or location.get('city')}, " + print(f"Location: {location.get('city_name')}, " f"{location.get('state_name') or location.get('state_code')}, " f"{location.get('country_name') or location.get('country_code')}") ``` @@ -411,8 +383,8 @@ For performance-critical applications, pre-generate types: # Pre-warm cache with common shapes common_shapes = [ ShapeConfig.CONTRACTS_MINIMAL, - ShapeConfig.CONTRACTS_COMPREHENSIVE, ShapeConfig.ENTITIES_MINIMAL, + ShapeConfig.IDVS_MINIMAL, ] for shape in common_shapes: @@ -446,94 +418,6 @@ contracts = client.list_contracts( **Solution:** Check the field name spelling and refer to the API documentation. -```python -# ✗ Wrong -contracts = client.list_contracts( - shape="key,piid,invalid_field", - use_dynamic=True -) -# ShapeValidationError: Field 'invalid_field' does not exist in Contract - -# ✓ Correct -contracts = client.list_contracts( - shape="key,piid,award_date", - use_dynamic=True -) -``` - -#### Issue: KeyError when accessing fields - -**Cause:** Trying to access a field that wasn't included in the shape. - -**Solution:** Add the field to your shape or check if the field exists before accessing. - -```python -# ✗ Wrong -contracts = client.list_contracts( - shape="key,piid", - use_dynamic=True -) -contract = contracts.results[0] -print(contract["award_date"]) # KeyError: 'award_date' - -# ✓ Correct - include field in shape -contracts = client.list_contracts( - shape="key,piid,award_date", - use_dynamic=True -) -contract = contracts.results[0] -print(contract["award_date"]) # Works - -# ✓ Correct - check before accessing -contract = contracts.results[0] -if "award_date" in contract: - print(contract["award_date"]) -``` - -#### Issue: Type checker doesn't recognize fields - -**Cause:** Using custom shapes without type annotations. - -**Solution:** Add type annotations for custom shapes or use predefined shapes. - -```python -from typing import TypedDict - -# Define your shape type -class MyCustomShape(TypedDict): - key: str - piid: str | None - award_date: str | None - -# Use type annotation -contracts = client.list_contracts( - shape="key,piid,award_date", - use_dynamic=True -) -contract: MyCustomShape = contracts.results[0] -# Now type checker understands the structure -``` - -#### Issue: Performance slower than expected - -**Cause:** Shapes not being reused or cache thrashing. - -**Solution:** Reuse shapes consistently. - -```python -# Reuse shapes -COMMON_SHAPE = "key,piid,recipient(display_name)" -contracts1 = client.list_contracts(shape=COMMON_SHAPE) -contracts2 = client.list_contracts(shape=COMMON_SHAPE) -# Second request uses cached type -``` - -#### Issue: "Field 'X' does not exist in Model" - -**Cause:** You requested a field that doesn't exist in the model schema. - -**Solution:** Check the field name spelling and refer to the API documentation. - ```python # ✗ Wrong contracts = client.list_contracts(shape="key,piid,invalid_field") diff --git a/docs/DYNAMIC_MODELS.md b/docs/DYNAMIC_MODELS.md new file mode 100644 index 0000000..77f966a --- /dev/null +++ b/docs/DYNAMIC_MODELS.md @@ -0,0 +1,205 @@ +# Tango Python SDK – Dynamic Models Guide + +This document explains how the **Python dynamic shaping system** works. +It mirrors the Node.js `DYNAMIC_MODELS.md` guide for the Python SDK. + +--- + +## Overview + +Tango's dynamic modeling allows you to: + +- Request _exactly the fields you want_ +- Validate the shape string against Tango's schemas +- Generate a typed model descriptor at runtime +- Materialize shaped objects using correct: + - date parsing + - datetime parsing + - decimal handling + - list vs scalar logic + - nested structure + +--- + +## Components + +### ShapeParser + +Parses shape strings into a `ShapeSpec`. + +```python +from tango.shapes import ShapeParser + +parser = ShapeParser() +spec = parser.parse("key,piid,recipient(display_name)") +``` + +### SchemaRegistry + +Holds the field schemas for all models. + +```python +from tango.shapes import SchemaRegistry +from tango.models import Contract + +registry = SchemaRegistry() +schema = registry.get_schema(Contract) +award_date_field = schema["award_date"] +# FieldSchema(name='award_date', type=date | None) +``` + +### TypeGenerator + +Builds a dynamic `TypedDict`-backed type from `(shape_spec, base_model)`. + +```python +from tango.shapes import ShapeParser, TypeGenerator +from tango.models import Contract + +parser = ShapeParser() +spec = parser.parse("key,piid,recipient(display_name)") + +gen = TypeGenerator() +dynamic_type = gen.generate_type( + shape_spec=spec, + base_model=Contract, + type_name="ContractShaped", +) +``` + +### ModelFactory + +Takes a dynamic type + raw API JSON and produces typed `ShapedModel` instances. +The `TangoClient` uses this pipeline automatically after fetching data. + +```python +from tango import TangoClient + +client = TangoClient(api_key="your-api-key") +contracts = client.list_contracts( + shape="key,award_date,recipient(display_name)", +) + +# contracts.results are ShapedModel instances materialized by ModelFactory: +# - date/datetime strings parsed to date/datetime objects +# - decimals normalized via Decimal +# - nested structures are themselves ShapedModel instances +``` + +--- + +## Example: Full Shaping Pipeline (manual) + +```python +from tango.shapes import ShapeParser, TypeGenerator, ModelFactory, create_default_parser_registry +from tango.models import Contract + +parser = ShapeParser() +spec = parser.parse("key,award_date,recipient(display_name)") + +gen = TypeGenerator() +dynamic_type = gen.generate_type( + shape_spec=spec, + base_model=Contract, + type_name="ContractShaped", +) + +parsers = create_default_parser_registry() +factory = ModelFactory(gen, parsers) + +shaped = factory.create_instance( + data={ + "key": "C-1", + "award_date": "2024-01-15", + "recipient": {"display_name": "Acme"}, + }, + shape_spec=spec, + base_model=Contract, + dynamic_type=dynamic_type, +) +``` + +`shaped` becomes: + +```python +ContractShaped(key='C-1', award_date=datetime.date(2024, 1, 15), recipient=ContractShaped_Recipient(display_name='Acme')) +``` + +--- + +## Attribute Access + +`ShapedModel` is a `dict` subclass with `__getattr__` so fields are accessible +both as dictionary keys and as attributes: + +```python +# Both styles work +shaped["key"] # "C-1" +shaped.key # "C-1" + +# Nested models are also ShapedModel instances +shaped.recipient["display_name"] # "Acme" +shaped.recipient.display_name # "Acme" +``` + +Accessing a field that was not included in your shape raises a descriptive +`AttributeError` with suggestions: + +```python +shaped.award_amount +# AttributeError: Field 'award_amount' not found in ContractShaped. +# Available fields: 'key', 'award_date', 'recipient' +# This field may not be included in your shape specification. +# To include this field, add it to your shape parameter. +``` + +--- + +## Type Safety + +The Python SDK enforces shape correctness at parse time via `ShapeParser.validate()`. +Nested structures are recursively materialized as `ShapedModel` instances, guaranteeing +the same access patterns at every depth. No static class generation happens at build time; +shapes are resolved at runtime. + +--- + +## Caching + +`TypeGenerator` caches descriptors using a thread-safe LRU cache (default: 100 entries). + +`ShapeParser` also caches parse results keyed on the raw shape string. + +--- + +## Nested Models + +If a field is nested in the schema (e.g. `"recipient"` → `RecipientProfile`), +the generator recursively builds the nested descriptor, naming it +`{ParentType}_{FieldName}` (e.g. `ContractShaped_Recipient`). Each nested object +is also a `ShapedModel`, so attribute access and `repr` work uniformly at every level. + +--- + +## Predefined Shape Constants + +`ShapeConfig` provides opinionated defaults for each resource's list and detail methods. +Each `TangoClient` method applies its corresponding default automatically; pass `shape=` +to override. + +```python +from tango import TangoClient, ShapeConfig + +client = TangoClient(api_key="your-api-key") + +# These are equivalent — list_contracts defaults to CONTRACTS_MINIMAL +contracts = client.list_contracts(limit=10) +contracts = client.list_contracts(shape=ShapeConfig.CONTRACTS_MINIMAL, limit=10) + +# Other resources +entities = client.list_entities(shape=ShapeConfig.ENTITIES_MINIMAL) +idvs = client.list_idvs(shape=ShapeConfig.IDVS_MINIMAL) +``` + +See [API Reference – ShapeConfig](API_REFERENCE.md#shapeconfig-predefined-shapes) for the +full table of constants. diff --git a/docs/SHAPES.md b/docs/SHAPES.md index 2334170..70bc40c 100644 --- a/docs/SHAPES.md +++ b/docs/SHAPES.md @@ -99,18 +99,18 @@ for contract in contracts.results: ### Multiple Levels -You can nest as deeply as needed: +You can nest as deeply as needed. Contract location information is on `place_of_performance` (not nested inside `recipient`): ```python -# Get location details from recipient +# Get place of performance details contracts = client.list_contracts( - shape="key,recipient(display_name,location(city,state_code,zip_code))", + shape="key,recipient(display_name),place_of_performance(city_name,state_code,zip_code)", limit=10 ) for contract in contracts.results: - location = contract['recipient']['location'] - print(f"{location['city']}, {location['state_code']} {location['zip_code']}") + location = contract['place_of_performance'] + print(f"{location['city_name']}, {location['state_code']} {location['zip_code']}") ``` ## Common Use Cases @@ -138,13 +138,13 @@ When analyzing contracts, focus on the metrics: ```python # Get financial and timing data contracts = client.list_contracts( - shape="key,piid,award_date,fiscal_year,total_contract_value,total_obligated", + shape="key,piid,award_date,fiscal_year,total_contract_value,obligated", awarding_agency="GSA", limit=1000 ) # Analyze -total_value = sum(c.get('total_contract_value', 0) for c in contracts.results) +total_value = sum(c.get('total_contract_value', 0) or 0 for c in contracts.results) print(f"Total contract value: ${total_value:,.2f}") ``` @@ -154,14 +154,15 @@ When you need location data: ```python # Get place of performance details +# Note: use city_name (not city); congressional_district is not a shape field contracts = client.list_contracts( - shape="key,piid,place_of_performance(city,state_code,congressional_district)", + shape="key,piid,place_of_performance(city_name,state_code)", limit=100 ) # Group by state from collections import Counter -states = Counter(c['place_of_performance']['state_code'] for c in contracts.results) +states = Counter(c['place_of_performance']['state_code'] for c in contracts.results if c.get('place_of_performance') and c['place_of_performance'].get('state_code')) print(f"Top states: {states.most_common(5)}") ``` @@ -171,17 +172,19 @@ When researching vendors and recipients: ```python # Get detailed vendor information +# Note: entity physical_address uses 'city' and 'state_or_province_code' +# business_types is a list of dicts with 'code' and 'description' entities = client.list_entities( - shape="uei,legal_business_name,dba_name,business_types,physical_address(city,state_code)", + shape="uei,legal_business_name,dba_name,business_types,physical_address(city,state_or_province_code)", limit=50 ) for entity in entities.results: print(f"{entity['legal_business_name']}") - print(f"Business Types: {', '.join(entity.get('business_types', []))}") + print(f"Business Types: {', '.join(bt['code'] for bt in entity.get('business_types', []))}") if entity.get('physical_address'): addr = entity['physical_address'] - print(f"Location: {addr.get('city')}, {addr.get('state_code')}") + print(f"Location: {addr.get('city')}, {addr.get('state_or_province_code')}") ``` ### 5. Agency Research @@ -190,8 +193,9 @@ When analyzing agency activity: ```python # Get agency and classification details +# Note: use awarding_office (not awarding_agency) for agency name/code sub-fields contracts = client.list_contracts( - shape="key,awarding_agency(name,code),naics(code,description),psc(code,description),total_contract_value", + shape="key,awarding_office(agency_name,agency_code),naics(code,description),psc(code,description),total_contract_value", fiscal_year=2024, limit=500 ) @@ -200,9 +204,9 @@ contracts = client.list_contracts( from collections import defaultdict by_agency = defaultdict(float) for contract in contracts.results: - if contract.get('awarding_agency'): - agency = contract['awarding_agency']['name'] - value = contract.get('total_contract_value', 0) + if contract.get('awarding_office'): + agency = contract['awarding_office']['agency_name'] + value = float(contract.get('total_contract_value', 0) or 0) by_agency[agency] += value # Top agencies by value @@ -295,9 +299,9 @@ Define shapes as constants for reuse: # Define your common shapes SHAPES = { 'list': "key,piid,recipient(display_name),total_contract_value", - 'detail': "key,piid,description,recipient(*),awarding_agency(*),total_contract_value,award_date", - 'analysis': "key,fiscal_year,total_contract_value,total_obligated,award_date", - 'geographic': "key,piid,place_of_performance(city,state_code,congressional_district)" + 'detail': "key,piid,description,recipient(*),awarding_office(*),total_contract_value,award_date", + 'analysis': "key,fiscal_year,total_contract_value,obligated,award_date", + 'geographic': "key,piid,place_of_performance(city_name,state_code)" } # Use them @@ -336,7 +340,7 @@ contracts = client.list_contracts(shape=DASHBOARD_SHAPE, limit=50) **Financial:** - `total_contract_value` - Total contract value -- `total_obligated` - Total obligated amount +- `obligated` - Total obligated amount (note: field is `obligated`, not `total_obligated`) - `award_amount` - Initial award amount **Parties:** @@ -433,14 +437,14 @@ display_name = contract.get('recipient', {}).get('display_name', 'Unknown') # Minimal for lists "key,piid,recipient(display_name),total_contract_value" -# For analysis -"key,fiscal_year,award_date,total_contract_value,total_obligated,naics(code)" +# For analysis (use 'obligated', not 'total_obligated') +"key,fiscal_year,award_date,total_contract_value,obligated,naics(code)" -# For geographic analysis -"key,piid,place_of_performance(city,state_code,congressional_district)" +# For geographic analysis (use city_name; congressional_district not available) +"key,piid,place_of_performance(city_name,state_code)" -# Full detail -"key,piid,description,recipient(*),awarding_agency(*),total_contract_value,award_date,naics(*),psc(*)" +# Full detail (use awarding_office for agency breakdown) +"key,piid,description,recipient(*),awarding_office(*),total_contract_value,award_date,naics(*),psc(*)" ``` ### Entities @@ -449,8 +453,8 @@ display_name = contract.get('recipient', {}).get('display_name', 'Unknown') # Minimal for lookups "uei,legal_business_name,cage_code,business_types" -# For vendor research -"uei,legal_business_name,dba_name,business_types,physical_address(city,state_code),primary_naics" +# For vendor research (entity physical_address uses state_or_province_code, not state_code) +"uei,legal_business_name,dba_name,business_types,physical_address(city,state_or_province_code),primary_naics" # Full profile "uei,legal_business_name,dba_name,cage_code,business_types,physical_address(*),email_address,entity_url" diff --git a/docs/WEBHOOKS.md b/docs/WEBHOOKS.md index 817b22c..4ae43fb 100644 --- a/docs/WEBHOOKS.md +++ b/docs/WEBHOOKS.md @@ -287,7 +287,8 @@ def handle_webhook(request): The CLI's `listen` command is a thin wrapper around `tango.webhooks.WebhookReceiver`, which is a context-manager-friendly local HTTP server. Use it directly in tests to verify your code emits webhook calls correctly, or to drive your handler with realistic deliveries. ```python -from tango.webhooks import WebhookReceiver, verify_signature +from tango import WebhookReceiver # WebhookReceiver is exported from the top-level tango package +from tango.webhooks import generate_signature, verify_signature import httpx def test_my_handler_processes_entity_update(): @@ -295,7 +296,6 @@ def test_my_handler_processes_entity_update(): # Trigger whatever in your code-under-test should send a webhook # (e.g. a publisher, or in this case a manual POST). body = b'{"events":[{"event_type":"entities.updated","uei":"ABC"}]}' - from tango.webhooks import generate_signature sig = generate_signature(body, "test_secret") httpx.post(rx.url, content=body, headers={"X-Tango-Signature": f"sha256={sig}"}) @@ -335,7 +335,8 @@ response = my_app.test_client().post( `simulate.deliver` does the same but POSTs the result to a URL — `WebhookReceiver` works as a target: ```python -from tango.webhooks import simulate, WebhookReceiver +from tango.webhooks import simulate +from tango import WebhookReceiver with WebhookReceiver(secret="s").run() as rx: result = simulate.deliver(target_url=rx.url, payload={...}, secret="s") @@ -389,7 +390,8 @@ tango webhooks simulate --secret dev --payload-file ./fixtures/edge.json \ In pytest, use `WebhookReceiver` and `simulate.deliver` together — both are pure-Python and don't talk to Tango: ```python -from tango.webhooks import simulate, WebhookReceiver +from tango.webhooks import simulate +from tango import WebhookReceiver def test_handler_round_trip(): with WebhookReceiver(secret="s").run() as rx: diff --git a/scripts/smoke_api_parity.py b/scripts/smoke_api_parity.py new file mode 100644 index 0000000..e256c65 --- /dev/null +++ b/scripts/smoke_api_parity.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python3 +"""Smoke test for API-parity work (feat/api-parity branch). + +Hits every method added or changed on the branch against a running local +Tango. For mutations (webhook endpoints / subscriptions / alerts) the +script creates a resource, verifies it, and tears it down. Prints +PASS/FAIL per method and exits non-zero on any failure. + +Usage: + TANGO_BASE_URL=http://localhost:8000 \\ + TANGO_API_KEY=... \\ + python scripts/smoke_api_parity.py +""" + +from __future__ import annotations + +import os +import sys +import time +import traceback +from collections.abc import Callable +from typing import Any + +from tango import TangoClient, WebhookAlert, WebhookEndpoint + +BASE_URL = os.getenv("TANGO_BASE_URL", "http://localhost:8000") +API_KEY = os.getenv("TANGO_API_KEY") +CALLBACK_URL = "http://example.test/smoke-python" + +if not API_KEY: + print("TANGO_API_KEY not set", file=sys.stderr) + sys.exit(2) + + +client = TangoClient(api_key=API_KEY, base_url=BASE_URL) + + +# ----- result tracking ----- +results: list[tuple[str, bool, str]] = [] + + +def run(label: str, fn: Callable[[], Any], *, skip_if_blank: str | None = None) -> Any: + """Run a smoke step; record PASS/FAIL; return its value (or None on fail).""" + if skip_if_blank is not None and not skip_if_blank: + results.append((label, True, "skipped: dependency unavailable")) + print(f" SKIP {label}") + return None + try: + out = fn() + results.append((label, True, "ok")) + print(f" PASS {label}") + return out + except Exception as exc: # noqa: BLE001 + results.append((label, False, f"{type(exc).__name__}: {exc}")) + print(f" FAIL {label} {type(exc).__name__}: {exc}") + traceback.print_exc(limit=2) + return None + + +# ============================================================================ +# Reference data +# ============================================================================ + +print("\n=== Reference data ===") + +dept_pager = run("list_departments(limit=2)", lambda: client.list_departments(limit=2)) +dept_code = "" +if dept_pager and dept_pager.results: + dept_code = str(dept_pager.results[0].get("code", "")) +run( + f"get_department({dept_code!r})", + lambda: client.get_department(dept_code), + skip_if_blank=dept_code, +) + +psc_pager = run("list_psc(limit=2)", lambda: client.list_psc(limit=2)) +psc_code = "" +if psc_pager and psc_pager.results: + psc_code = str(psc_pager.results[0].get("code", "")) +run( + f"get_psc({psc_code!r})", + lambda: client.get_psc(psc_code), + skip_if_blank=psc_code, +) +run( + f"get_psc_metrics({psc_code!r}, 12, 'month')", + lambda: client.get_psc_metrics(psc_code, 12, "month"), + skip_if_blank=psc_code, +) + +# A NAICS we know exists in seed data +NAICS_CODE = "541511" +run(f"get_naics({NAICS_CODE!r})", lambda: client.get_naics(NAICS_CODE)) +run( + f"get_naics_metrics({NAICS_CODE!r}, 12, 'month')", + lambda: client.get_naics_metrics(NAICS_CODE, 12, "month"), +) + +bt_pager = client.list_business_types(limit=1) +bt_code = "" +if bt_pager.results: + bt_code = str(bt_pager.results[0].code or "") +run( + f"get_business_type({bt_code!r})", + lambda: client.get_business_type(bt_code), + skip_if_blank=bt_code, +) + +al_pager = run( + "list_assistance_listings(limit=2)", lambda: client.list_assistance_listings(limit=2) +) +al_number = "" +if al_pager and al_pager.results: + al_number = str(al_pager.results[0].get("number", "")) +run( + f"get_assistance_listing({al_number!r})", + lambda: client.get_assistance_listing(al_number), + skip_if_blank=al_number, +) + +sin_pager = run("list_mas_sins(limit=2)", lambda: client.list_mas_sins(limit=2)) +sin = "" +if sin_pager and sin_pager.results: + r = sin_pager.results[0] + sin = str(r.get("sin") or r.get("code") or r.get("number") or "") +run( + f"get_mas_sin({sin!r})", + lambda: client.get_mas_sin(sin), + skip_if_blank=sin, +) + + +# ============================================================================ +# Entity sub-resources +# ============================================================================ + +print("\n=== Entity sub-resources ===") +ent_pager = client.list_entities(limit=1) +uei = "" +if ent_pager.results: + uei = str(ent_pager.results[0].get("uei") or "") + +run( + f"list_entity_contracts({uei!r}, limit=2)", + lambda: client.list_entity_contracts(uei, limit=2), + skip_if_blank=uei, +) +run( + f"list_entity_idvs({uei!r}, limit=2)", + lambda: client.list_entity_idvs(uei, limit=2), + skip_if_blank=uei, +) +run( + f"list_entity_otas({uei!r}, limit=2)", + lambda: client.list_entity_otas(uei, limit=2), + skip_if_blank=uei, +) +run( + f"list_entity_otidvs({uei!r}, limit=2)", + lambda: client.list_entity_otidvs(uei, limit=2), + skip_if_blank=uei, +) +run( + f"list_entity_subawards({uei!r}, limit=2)", + lambda: client.list_entity_subawards(uei, limit=2), + skip_if_blank=uei, +) +run( + f"list_entity_lcats({uei!r}, limit=2)", + lambda: client.list_entity_lcats(uei, limit=2), + skip_if_blank=uei, +) +run( + f"get_entity_metrics({uei!r}, 12, 'month')", + lambda: client.get_entity_metrics(uei, 12, "month"), + skip_if_blank=uei, +) + + +# ============================================================================ +# IDV / Agency sub-resources +# ============================================================================ + +print("\n=== IDV / Agency sub-resources ===") +idv_pager = client.list_idvs(limit=1) +idv_key = "" +if idv_pager.results: + idv_key = str(idv_pager.results[0]["key"]) +run( + f"list_idv_lcats({idv_key!r}, limit=2)", + lambda: client.list_idv_lcats(idv_key, limit=2), + skip_if_blank=idv_key, +) + +ag_pager = client.list_agencies(limit=1) +ag_code = "" +if ag_pager.results: + ag_code = str(ag_pager.results[0].code or "") +run( + f"list_agency_awarding_contracts({ag_code!r}, limit=2)", + lambda: client.list_agency_awarding_contracts(ag_code, limit=2), + skip_if_blank=ag_code, +) +# funding-contracts can 504 locally if the agency has a wide net of awards +# (heavy aggregation). Catch and SKIP server timeouts. +_fund_label = f"list_agency_funding_contracts({ag_code!r}, limit=2)" +if ag_code: + try: + client.list_agency_funding_contracts(ag_code, limit=2) + results.append((_fund_label, True, "ok")) + print(f" PASS {_fund_label}") + except Exception as exc: # noqa: BLE001 + msg = f"{type(exc).__name__}: {exc}" + if "504" in msg or "timeout" in msg.lower(): + results.append((_fund_label, True, f"skipped: server {msg}")) + print(f" SKIP {_fund_label} — {msg}") + else: + results.append((_fund_label, False, msg)) + print(f" FAIL {_fund_label} — {msg}") +else: + results.append((_fund_label, True, "skipped: dependency unavailable")) + print(f" SKIP {_fund_label}") + + +# ============================================================================ +# ordering= round-trips +# ============================================================================ + +print("\n=== ordering= round-trips ===") +run( + "list_forecasts(ordering='fiscal_year', limit=2)", + lambda: client.list_forecasts(ordering="fiscal_year", limit=2), +) +run( + "list_grants(ordering='-posted_date', limit=2)", + lambda: client.list_grants(ordering="-posted_date", limit=2), +) +run( + "list_opportunities(ordering='-last_notice_date', limit=2)", + lambda: client.list_opportunities(ordering="-last_notice_date", limit=2), +) +run( + "list_notices(limit=2) (no ordering — endpoint rejects ordering server-side)", + lambda: client.list_notices(limit=2), +) +run( + "list_protests(limit=2) (no ordering — endpoint rejects ordering server-side)", + lambda: client.list_protests(limit=2), +) +run( + "list_subawards(ordering='-last_modified_date', limit=2)", + lambda: client.list_subawards(ordering="-last_modified_date", limit=2), +) +run( + "list_gsa_elibrary_contracts(ordering='piid', limit=2)", + lambda: client.list_gsa_elibrary_contracts(ordering="piid", limit=2), +) + + +# ============================================================================ +# resolve / validate +# ============================================================================ + +print("\n=== resolve / validate ===") +run( + "resolve('Microsoft', target_type='entity')", + lambda: client.resolve("Microsoft", target_type="entity"), +) +run( + "validate('uei', 'TESTUEI12345')", + lambda: client.validate("uei", "TESTUEI12345"), +) +run( + "validate('piid', '47QSMA22D08PT')", + lambda: client.validate("piid", "47QSMA22D08PT"), +) + + +# ============================================================================ +# Misc +# ============================================================================ + +print("\n=== Misc ===") +run("get_version()", client.get_version) +run("list_api_keys()", client.list_api_keys) +# attachment-search may 404 locally if the feature flag / RAG index isn't set +# up. Treat 404 as SKIP, anything else as a real result. +_attach_label = "search_opportunity_attachments(q='cyber', top_k=3)" +try: + client.search_opportunity_attachments(q="cyber", top_k=3) + results.append((_attach_label, True, "ok")) + print(f" PASS {_attach_label}") +except Exception as exc: # noqa: BLE001 + if type(exc).__name__ == "TangoNotFoundError": + results.append( + (_attach_label, True, "skipped: 404 (feature flag / index not enabled locally)") + ) + print(f" SKIP {_attach_label} — 404 locally") + else: + results.append((_attach_label, False, f"{type(exc).__name__}: {exc}")) + print(f" FAIL {_attach_label} — {type(exc).__name__}: {exc}") + + +# ============================================================================ +# _post json= kwarg backcompat +# ============================================================================ + +print("\n=== _post json= kwarg backcompat ===") +run( + "_post(json=) backcompat (resolve via internal helper)", + lambda: client._post( + "/api/resolve/", + json={"name": "Microsoft", "target_type": "entity"}, + ), +) + + +# ============================================================================ +# Webhook write methods (mutations: create + verify + delete) +# ============================================================================ + +print("\n=== Webhook endpoint create/update/delete ===") +endpoint_name = f"smoke-python-{int(time.time())}" +created_endpoint: WebhookEndpoint | None = None + + +def _create_endpoint() -> WebhookEndpoint: + return client.create_webhook_endpoint( + callback_url=CALLBACK_URL, is_active=True, name=endpoint_name + ) + + +created_endpoint = run("create_webhook_endpoint(name=...)", _create_endpoint) + +if created_endpoint: + ep_id = created_endpoint.id + + run( + f"get_webhook_endpoint({ep_id!r})", + lambda: client.get_webhook_endpoint(ep_id), + ) + run( + "update_webhook_endpoint(is_active=False)", + lambda: client.update_webhook_endpoint(ep_id, is_active=False), + ) + + # Subscription tied to this endpoint + sub_id: str | None = None + + def _create_sub() -> Any: + sub = client.create_webhook_subscription( + subscription_name="smoke-sub", + payload={ + "records": [ + {"event_type": "awards.new_award", "subject_ids": []}, + ] + }, + endpoint=ep_id, + ) + return sub + + sub = run("create_webhook_subscription(endpoint=...)", _create_sub) + if sub: + sub_id = sub.id + run( + "update_webhook_subscription(subscription_name='updated')", + lambda: client.update_webhook_subscription(sub_id, subscription_name="updated"), + ) + run( + "delete_webhook_subscription(...)", + lambda: client.delete_webhook_subscription(sub_id), + ) + + # Alert (filter subscription) + alert: WebhookAlert | None = None + alert_label = "create_webhook_alert(...)" + try: + alert = client.create_webhook_alert( + name=f"smoke-alert-{int(time.time())}", + query_type="opportunity", + filters={"naics": "541511"}, + frequency="daily", + ) + results.append((alert_label, True, "ok")) + print(f" PASS {alert_label}") + except Exception as exc: # noqa: BLE001 + msg = f"{type(exc).__name__}: {exc}" + # The alerts route refuses to auto-resolve the endpoint for users + # with multiple endpoints. That's expected after we just created one + # above — record as SKIP, not FAIL. + response_data = getattr(exc, "response_data", None) or {} + non_field = [] + if isinstance(response_data, dict): + non_field = response_data.get("non_field_errors") or [] + haystack = str(non_field) + " " + str(exc) + if "multiple webhook endpoints" in haystack.lower() or "multiple" in haystack.lower(): + results.append((alert_label, True, f"skipped: {msg}")) + print(f" SKIP {alert_label} — {msg}") + else: + results.append((alert_label, False, msg)) + print(f" FAIL {alert_label} — {msg}") + + if alert: + aid = alert.alert_id + run(f"get_webhook_alert({aid!r})", lambda: client.get_webhook_alert(aid)) + run( + "update_webhook_alert(name='renamed')", + lambda: client.update_webhook_alert(aid, name="renamed"), + ) + run("list_webhook_alerts()", client.list_webhook_alerts) + run( + "delete_webhook_alert(...)", + lambda: client.delete_webhook_alert(aid), + ) + else: + run("list_webhook_alerts()", client.list_webhook_alerts) + + # Cleanup endpoint + run( + f"delete_webhook_endpoint({ep_id!r})", + lambda: client.delete_webhook_endpoint(ep_id), + ) + + +# ============================================================================ +# Summary +# ============================================================================ + +passed = sum(1 for _, ok, _ in results if ok) +failed = sum(1 for _, ok, _ in results if not ok) +total = len(results) +print("\n" + "=" * 70) +print(f"Smoke summary: {passed} passed, {failed} failed, {total} total") +print("=" * 70) +if failed: + for label, ok, msg in results: + if not ok: + print(f" FAIL {label}: {msg}") + sys.exit(1) +sys.exit(0) diff --git a/tango/__init__.py b/tango/__init__.py index 814d393..c1638f8 100644 --- a/tango/__init__.py +++ b/tango/__init__.py @@ -13,10 +13,14 @@ ITDashboardInvestment, PaginatedResponse, RateLimitInfo, + ResolveCandidate, + ResolveResult, SearchFilters, ShapeConfig, + ValidateResult, Vehicle, VehicleMetrics, + WebhookAlert, WebhookEndpoint, WebhookEventType, WebhookEventTypesResponse, @@ -46,13 +50,17 @@ "TangoValidationError", "TangoRateLimitError", "RateLimitInfo", + "ResolveCandidate", + "ResolveResult", "GsaElibraryContract", "ITDashboardInvestment", "PaginatedResponse", "SearchFilters", "ShapeConfig", + "ValidateResult", "Vehicle", "VehicleMetrics", + "WebhookAlert", "WebhookEndpoint", "WebhookEventType", "WebhookEventTypesResponse", diff --git a/tango/client.py b/tango/client.py index 108aa83..69c5331 100644 --- a/tango/client.py +++ b/tango/client.py @@ -35,10 +35,14 @@ PaginatedResponse, Protest, RateLimitInfo, + ResolveCandidate, + ResolveResult, SearchFilters, ShapeConfig, Subaward, + ValidateResult, Vehicle, + WebhookAlert, WebhookEndpoint, WebhookEventType, WebhookEventTypesResponse, @@ -202,13 +206,39 @@ def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, """Make a GET request""" return self._request("GET", endpoint, params=params) - def _post(self, endpoint: str, json_data: dict[str, Any]) -> dict[str, Any]: - """Make a POST request""" - return self._request("POST", endpoint, json_data=json_data) + def _post( + self, + endpoint: str, + json_data: dict[str, Any] | None = None, + *, + json: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Make a POST request. + + Accepts either ``json_data`` (positional) or ``json=`` (keyword) for + backward compatibility with internal callers and docs examples. + """ + body = json_data if json_data is not None else json + if body is None: + body = {} + return self._request("POST", endpoint, json_data=body) - def _patch(self, endpoint: str, json_data: dict[str, Any]) -> dict[str, Any]: - """Make a PATCH request""" - return self._request("PATCH", endpoint, json_data=json_data) + def _patch( + self, + endpoint: str, + json_data: dict[str, Any] | None = None, + *, + json: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Make a PATCH request. + + Accepts either ``json_data`` (positional) or ``json=`` (keyword) for + backward compatibility with internal callers and docs examples. + """ + body = json_data if json_data is not None else json + if body is None: + body = {} + return self._request("PATCH", endpoint, json_data=body) def _delete(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]: """Make a DELETE request""" @@ -1218,6 +1248,7 @@ def list_subawards( fiscal_year_gte: int | None = None, fiscal_year_lte: int | None = None, funding_agency: str | None = None, + ordering: str | None = None, prime_uei: str | None = None, recipient: str | None = None, sub_uei: str | None = None, @@ -1239,6 +1270,7 @@ def list_subawards( ("fiscal_year_gte", fiscal_year_gte), ("fiscal_year_lte", fiscal_year_lte), ("funding_agency", funding_agency), + ("ordering", ordering), ("prime_uei", prime_uei), ("recipient", recipient), ("sub_uei", sub_uei), @@ -1271,6 +1303,7 @@ def list_gsa_elibrary_contracts( joiner: str = ".", contract_number: str | None = None, key: str | None = None, + ordering: str | None = None, piid: str | None = None, schedule: str | None = None, search: str | None = None, @@ -1292,6 +1325,7 @@ def list_gsa_elibrary_contracts( for k, val in ( ("contract_number", contract_number), ("key", key), + ("ordering", ordering), ("piid", piid), ("schedule", schedule), ("search", search), @@ -1895,6 +1929,7 @@ def list_forecasts( modified_before: str | None = None, naics_code: str | None = None, naics_starts_with: str | None = None, + ordering: str | None = None, search: str | None = None, source_system: str | None = None, status: str | None = None, @@ -1918,6 +1953,7 @@ def list_forecasts( modified_before: Modified before date naics_code: NAICS code filter naics_starts_with: NAICS code prefix filter + ordering: Sort field (prefix with '-' for descending) search: Search query source_system: Source system filter status: Status filter @@ -1944,6 +1980,7 @@ def list_forecasts( ("modified_before", modified_before), ("naics_code", naics_code), ("naics_starts_with", naics_starts_with), + ("ordering", ordering), ("search", search), ("source_system", source_system), ("status", status), @@ -1982,6 +2019,7 @@ def list_opportunities( last_notice_date_before: str | None = None, naics: str | None = None, notice_type: str | None = None, + ordering: str | None = None, place_of_performance: str | None = None, psc: str | None = None, response_deadline_after: str | None = None, @@ -2007,6 +2045,7 @@ def list_opportunities( last_notice_date_before: Last notice date before naics: NAICS code filter notice_type: Notice type filter + ordering: Sort field (prefix with '-' for descending) place_of_performance: Place of performance filter psc: PSC code filter response_deadline_after: Response deadline after @@ -2035,6 +2074,7 @@ def list_opportunities( ("last_notice_date_before", last_notice_date_before), ("naics", naics), ("notice_type", notice_type), + ("ordering", ordering), ("place_of_performance", place_of_performance), ("psc", psc), ("response_deadline_after", response_deadline_after), @@ -2073,6 +2113,7 @@ def list_notices( agency: str | None = None, naics: str | None = None, notice_type: str | None = None, + ordering: str | None = None, posted_date_after: str | None = None, posted_date_before: str | None = None, psc: str | None = None, @@ -2095,6 +2136,7 @@ def list_notices( agency: Agency filter naics: NAICS code filter notice_type: Notice type filter + ordering: Sort field (prefix with '-' for descending) posted_date_after: Posted date after posted_date_before: Posted date before psc: PSC code filter @@ -2120,6 +2162,7 @@ def list_notices( ("agency", agency), ("naics", naics), ("notice_type", notice_type), + ("ordering", ordering), ("posted_date_after", posted_date_after), ("posted_date_before", posted_date_before), ("psc", psc), @@ -2169,6 +2212,7 @@ def list_protests( filed_date_before: str | None = None, decision_date_after: str | None = None, decision_date_before: str | None = None, + ordering: str | None = None, search: str | None = None, ) -> PaginatedResponse: """ @@ -2194,6 +2238,7 @@ def list_protests( filed_date_before: Filed date on or before decision_date_after: Decision date on or after decision_date_before: Decision date on or before + ordering: Sort field (prefix with '-' for descending) search: Full-text search over protest searchable fields """ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} @@ -2219,6 +2264,7 @@ def list_protests( ("filed_date_before", filed_date_before), ("decision_date_after", decision_date_after), ("decision_date_before", decision_date_before), + ("ordering", ordering), ("search", search), ): if val is not None: @@ -2284,6 +2330,7 @@ def list_grants( funding_categories: str | None = None, funding_instruments: str | None = None, opportunity_number: str | None = None, + ordering: str | None = None, posted_date_after: str | None = None, posted_date_before: str | None = None, response_date_after: str | None = None, @@ -2306,6 +2353,7 @@ def list_grants( funding_categories: Funding categories filter funding_instruments: Funding instruments filter opportunity_number: Opportunity number filter + ordering: Sort field (prefix with '-' for descending) posted_date_after: Posted date after posted_date_before: Posted date before response_date_after: Response date after @@ -2331,6 +2379,7 @@ def list_grants( ("funding_categories", funding_categories), ("funding_instruments", funding_instruments), ("opportunity_number", opportunity_number), + ("ordering", ordering), ("posted_date_after", posted_date_after), ("posted_date_before", posted_date_before), ("response_date_after", response_date_after), @@ -2442,16 +2491,72 @@ def get_webhook_subscription(self, subscription_id: str) -> WebhookSubscription: ) def create_webhook_subscription( - self, subscription_name: str, payload: dict[str, Any] + self, + subscription_name: str, + payload: dict[str, Any], + *, + endpoint: str | None = None, + subscription_type: str | None = None, + query_type: str | None = None, + filter_definition: dict[str, Any] | None = None, + frequency: str | None = None, + cron_expression: str | None = None, + is_active: bool | None = None, ) -> WebhookSubscription: - """Create a webhook subscription.""" + """Create a webhook subscription. + + Args: + subscription_name: Human-readable name for the subscription. + payload: Subscription payload (records list with event_type + + subject_type + subject_ids). Pass ``{"records": []}`` for + filter-based subscriptions; the server reads filter fields + directly. + endpoint: UUID of the WebhookEndpoint this subscription delivers + to. The server now requires this for users who own more than + one endpoint; for single-endpoint users it auto-resolves. + Pass it explicitly to be safe. + subscription_type: ``"subject"`` (default) or ``"filter"``. Use + ``"filter"`` together with ``query_type`` + ``filter_definition`` + to create a saved-search alert subscription. + query_type: Resource type for filter subscriptions (one of + ``opportunity``, ``contract``, ``idv``, ``ota``, ``otidv``, + ``entity``, ``grant``, ``forecast``). + filter_definition: Dict of query params to match for filter + subscriptions. + frequency: ``realtime`` | ``daily`` | ``weekly`` | ``custom``. + cron_expression: 5-field cron expression, required when + ``frequency="custom"`` (Pro+ tiers only). + is_active: Whether the subscription is enabled. + + Notes: + The ``/api/webhooks/alerts/`` convenience API + (:meth:`create_webhook_alert`) is generally easier for filter + subscriptions — this lower-level method mirrors the raw + ``/api/webhooks/subscriptions/`` shape. + """ if not subscription_name: raise TangoValidationError("Webhook subscription_name is required") - data = self._post( - "/api/webhooks/subscriptions/", - {"subscription_name": subscription_name, "payload": payload}, - ) + body: dict[str, Any] = { + "subscription_name": subscription_name, + "payload": payload, + } + if endpoint is not None: + body["endpoint"] = endpoint + if subscription_type is not None: + body["subscription_type"] = subscription_type + if query_type is not None: + body["query_type"] = query_type + if filter_definition is not None: + body["filter_definition"] = filter_definition + if frequency is not None: + body["frequency"] = frequency + if cron_expression is not None: + body["cron_expression"] = cron_expression + if is_active is not None: + body["is_active"] = is_active + + data = self._post("/api/webhooks/subscriptions/", body) return WebhookSubscription( id=str(data.get("id", "")), @@ -2467,8 +2572,16 @@ def update_webhook_subscription( *, subscription_name: str | None = None, payload: dict[str, Any] | None = None, + frequency: str | None = None, + cron_expression: str | None = None, + is_active: bool | None = None, ) -> WebhookSubscription: - """Patch a webhook subscription.""" + """Patch a webhook subscription. + + For filter subscriptions, ``frequency``, ``cron_expression``, and + ``is_active`` are also writable; subject-based subscriptions + typically only patch ``subscription_name`` and ``payload``. + """ if not subscription_id: raise TangoValidationError("Webhook subscription_id is required") @@ -2477,6 +2590,12 @@ def update_webhook_subscription( body["subscription_name"] = subscription_name if payload is not None: body["payload"] = payload + if frequency is not None: + body["frequency"] = frequency + if cron_expression is not None: + body["cron_expression"] = cron_expression + if is_active is not None: + body["is_active"] = is_active data = self._patch(f"/api/webhooks/subscriptions/{subscription_id}/", body) return WebhookSubscription( @@ -2542,21 +2661,48 @@ def list_webhook_endpoints( results=results, ) - def create_webhook_endpoint(self, callback_url: str, is_active: bool = True) -> WebhookEndpoint: + def create_webhook_endpoint( + self, + callback_url: str, + is_active: bool = True, + *, + name: str | None = None, + ) -> WebhookEndpoint: """ Create a webhook endpoint for the authenticated user. + Args: + callback_url: HTTPS URL to receive POSTed webhook events. + is_active: Whether deliveries are enabled. + name: Human-readable name for this endpoint. Required by the + Tango API (the server enforces ``unique(user, name)``). + Currently keyword-optional in the SDK for backward + compatibility — passing it explicitly is strongly + recommended; a future major version will make it required. + When omitted, callers will see a server-side 400 + ``"name: This field is required"``. + Note: - - The server generates `secret` and manages `name`. - - Only one endpoint per user is allowed; if one already exists, this will fail. + The server generates ``secret``. A user may have multiple + endpoints (unique on ``(user, name)``). """ if not callback_url: raise TangoValidationError("Webhook callback_url is required") + if name is None: + warnings.warn( + "create_webhook_endpoint() called without name=; the Tango " + "API requires `name` and this call will fail server-side. " + "Pass name='your-endpoint-name'. This will become a required " + "argument in a future major version.", + DeprecationWarning, + stacklevel=2, + ) - data = self._post( - "/api/webhooks/endpoints/", - {"callback_url": callback_url, "is_active": is_active}, - ) + body: dict[str, Any] = {"callback_url": callback_url, "is_active": is_active} + if name is not None: + body["name"] = name + + data = self._post("/api/webhooks/endpoints/", body) return WebhookEndpoint( id=str(data.get("id", "")), name=str(data.get("name", "")), @@ -2571,6 +2717,7 @@ def update_webhook_endpoint( self, endpoint_id: str, *, + name: str | None = None, callback_url: str | None = None, is_active: bool | None = None, ) -> WebhookEndpoint: @@ -2579,6 +2726,8 @@ def update_webhook_endpoint( raise TangoValidationError("Webhook endpoint_id is required") body: dict[str, Any] = {} + if name is not None: + body["name"] = name if callback_url is not None: body["callback_url"] = callback_url if is_active is not None: @@ -2639,3 +2788,797 @@ def get_webhook_sample_payload(self, event_type: str | None = None) -> dict[str, if event_type: params["event_type"] = event_type return self._get("/api/webhooks/endpoints/sample-payload/", params) + + # ============================================================================ + # Webhook Alerts (filter-based subscriptions) + # ============================================================================ + + @staticmethod + def _parse_webhook_alert(data: dict[str, Any]) -> WebhookAlert: + """Hydrate an Alert dict from /api/webhooks/alerts/ into a WebhookAlert.""" + return WebhookAlert( + alert_id=str(data.get("alert_id") or data.get("id") or ""), + name=str(data.get("name") or data.get("subscription_name") or ""), + query_type=(str(data["query_type"]) if data.get("query_type") is not None else None), + filters=data.get("filters") or data.get("filter_definition"), + frequency=str(data.get("frequency", "realtime")), + cron_expression=( + str(data["cron_expression"]) if data.get("cron_expression") is not None else None + ), + status=str(data.get("status", "active")), + created_at=str(data.get("created_at", "")), + last_checked_at=( + str(data["last_checked_at"]) if data.get("last_checked_at") is not None else None + ), + match_count=int(data.get("match_count", 0) or 0), + ) + + def list_webhook_alerts( + self, page: int = 1, page_size: int | None = None + ) -> PaginatedResponse[WebhookAlert]: + """List filter-based webhook subscriptions (alerts). + + Backed by ``GET /api/webhooks/alerts/`` — a convenience over + ``WebhookSubscription(subscription_type="filter")`` with a cleaner + shape (``alert_id``, ``name``, ``filters``, etc.). + """ + params: dict[str, Any] = {"page": page} + if page_size is not None: + params["page_size"] = page_size + data = self._get("/api/webhooks/alerts/", params) + results = [ + self._parse_webhook_alert(item) + for item in (data.get("results") or []) + if isinstance(item, dict) + ] + return PaginatedResponse( + count=int(data.get("count", len(results))), + next=data.get("next"), + previous=data.get("previous"), + results=results, + ) + + def get_webhook_alert(self, alert_id: str) -> WebhookAlert: + """Get a single filter-based webhook subscription by alert_id (UUID).""" + if not alert_id: + raise TangoValidationError("Webhook alert_id is required") + data = self._get(f"/api/webhooks/alerts/{alert_id}/") + return self._parse_webhook_alert(data) + + def create_webhook_alert( + self, + name: str, + query_type: str, + filters: dict[str, Any], + *, + frequency: str = "realtime", + cron_expression: str | None = None, + ) -> WebhookAlert: + """Create a filter-based webhook subscription (alert). + + Args: + name: Human-readable name for this alert. + query_type: One of ``opportunity``, ``contract``, ``idv``, ``ota``, + ``otidv``, ``entity``, ``grant``, ``forecast``. + filters: Dict of query parameters that the alert matches against + (e.g. ``{"naics": "541330", "set_aside": "SBA"}``). + frequency: ``realtime`` | ``daily`` | ``weekly`` | ``custom``. + ``custom`` requires ``cron_expression`` and Pro+ tier. + cron_expression: 5-field cron expression, only valid when + ``frequency="custom"``. + + Returns: + The created (or, if a dedup-matched alert already exists, the + existing) :class:`WebhookAlert`. Both 201-Created and 200-OK + (dedup) responses are normalized to the same shape. + """ + if not name: + raise TangoValidationError("Webhook alert name is required") + if not query_type: + raise TangoValidationError("Webhook alert query_type is required") + if not filters or not isinstance(filters, dict): + raise TangoValidationError( + "Webhook alert filters must be a non-empty dict of query params" + ) + + body: dict[str, Any] = { + "name": name, + "query_type": query_type, + "filters": filters, + "frequency": frequency, + } + if cron_expression is not None: + body["cron_expression"] = cron_expression + + data = self._post("/api/webhooks/alerts/", body) + return self._parse_webhook_alert(data) + + def update_webhook_alert( + self, + alert_id: str, + *, + name: str | None = None, + frequency: str | None = None, + cron_expression: str | None = None, + is_active: bool | None = None, + ) -> WebhookAlert: + """Patch a webhook alert (filter subscription). + + Only ``name``, ``frequency``, ``cron_expression``, and ``is_active`` + are writable — ``query_type`` and ``filters`` are read-only after + creation (the server treats them as part of the alert's identity + via ``filter_hash``). + """ + if not alert_id: + raise TangoValidationError("Webhook alert_id is required") + + body: dict[str, Any] = {} + if name is not None: + body["name"] = name + if frequency is not None: + body["frequency"] = frequency + if cron_expression is not None: + body["cron_expression"] = cron_expression + if is_active is not None: + body["is_active"] = is_active + + data = self._patch(f"/api/webhooks/alerts/{alert_id}/", body) + return self._parse_webhook_alert(data) + + def delete_webhook_alert(self, alert_id: str) -> None: + """Delete a webhook alert (filter subscription).""" + if not alert_id: + raise TangoValidationError("Webhook alert_id is required") + self._delete(f"/api/webhooks/alerts/{alert_id}/") + + # ============================================================================ + # Resolve & Validate (POST endpoints) + # ============================================================================ + + def resolve( + self, + name: str, + target_type: str, + *, + state: str | None = None, + city: str | None = None, + context: str | None = None, + ) -> ResolveResult: + """Resolve a free-text name to ranked entity or organization candidates. + + ``POST /api/resolve/`` + + Args: + name: Name to resolve. + target_type: ``"entity"`` or ``"organization"``. + state: Optional 2-letter US state code to bias matching. + city: Optional city name to bias matching. + context: Optional freeform additional context for better matching. + + Returns: + :class:`ResolveResult` with ranked candidates. Free-tier callers + get up to 3 candidates with ``identifier`` and ``display_name``; + Pro+ callers get up to 5 with an additional ``match_tier`` field. + Other server-returned fields are preserved on each candidate's + ``extra`` dict. + """ + if not name: + raise TangoValidationError("resolve(): name is required") + if target_type not in ("entity", "organization"): + raise TangoValidationError("resolve(): target_type must be 'entity' or 'organization'") + + body: dict[str, Any] = {"name": name, "target_type": target_type} + if state is not None: + body["state"] = state + if city is not None: + body["city"] = city + if context is not None: + body["context"] = context + + data = self._post("/api/resolve/", body) + candidates: list[ResolveCandidate] = [] + for raw in data.get("candidates") or []: + if not isinstance(raw, dict): + continue + extras = { + k: v + for k, v in raw.items() + if k not in {"identifier", "display_name", "match_tier"} + } + candidates.append( + ResolveCandidate( + identifier=raw.get("identifier"), + display_name=raw.get("display_name"), + match_tier=raw.get("match_tier"), + extra=extras or None, + ) + ) + return ResolveResult( + candidates=candidates, + count=int(data.get("count", len(candidates))), + ) + + def validate(self, identifier_type: str, value: str) -> ValidateResult: + """Validate the format of a PIID, solicitation number, or UEI. + + ``POST /api/validate/`` + + Args: + identifier_type: One of ``"piid"``, ``"solicitation"``, ``"uei"``. + (Maps to the API's ``type`` field — ``identifier_type`` here + avoids shadowing the Python builtin.) + value: The identifier value to validate. + + Returns: + :class:`ValidateResult` with ``result`` in ``{"valid", + "not_valid", "low_confidence"}``. ``low_confidence`` applies only + to solicitation numbers that pass basic checks but don't match a + named pattern. + """ + if identifier_type not in ("piid", "solicitation", "uei"): + raise TangoValidationError( + "validate(): identifier_type must be 'piid', 'solicitation', or 'uei'" + ) + if not value: + raise TangoValidationError("validate(): value is required") + + data = self._post("/api/validate/", {"type": identifier_type, "value": value}) + return ValidateResult( + result=str(data.get("result", "")), + type=str(data.get("type", identifier_type)), + value=str(data.get("value", value)), + errors=list(data.get("errors") or []) or None, + ) + + # ============================================================================ + # Reference data + # ============================================================================ + + def list_departments(self, page: int = 1, limit: int = 25) -> PaginatedResponse[dict[str, Any]]: + """List departments (`/api/departments/`).""" + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + data = self._get("/api/departments/", params) + return PaginatedResponse( + count=int(data.get("count", 0)), + next=data.get("next"), + previous=data.get("previous"), + results=list(data.get("results") or []), + ) + + def get_department(self, code: str) -> dict[str, Any]: + """Get a department by code (`/api/departments/{code}/`).""" + if not code: + raise TangoValidationError("Department code is required") + return self._get(f"/api/departments/{code}/") + + def list_psc(self, page: int = 1, limit: int = 25) -> PaginatedResponse[dict[str, Any]]: + """List Product Service Codes (`/api/psc/`).""" + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + data = self._get("/api/psc/", params) + return PaginatedResponse( + count=int(data.get("count", 0)), + next=data.get("next"), + previous=data.get("previous"), + results=list(data.get("results") or []), + ) + + def get_psc(self, code: str) -> dict[str, Any]: + """Get a Product Service Code by code (`/api/psc/{code}/`).""" + if not code: + raise TangoValidationError("PSC code is required") + return self._get(f"/api/psc/{code}/") + + def get_psc_metrics(self, code: str, months: int, period_grouping: str) -> dict[str, Any]: + """Get rolling PSC metrics (`/api/psc/{code}/metrics/{months}/{period_grouping}/`). + + Args: + code: PSC code. + months: Window size in months (e.g. 6, 12, 24, 36). + period_grouping: ``monthly``, ``quarterly``, etc. + """ + if not code: + raise TangoValidationError("PSC code is required") + return self._get(f"/api/psc/{code}/metrics/{months}/{period_grouping}/") + + def get_naics(self, code: str) -> dict[str, Any]: + """Get a NAICS code by code (`/api/naics/{code}/`).""" + if not code: + raise TangoValidationError("NAICS code is required") + return self._get(f"/api/naics/{code}/") + + def get_naics_metrics(self, code: str, months: int, period_grouping: str) -> dict[str, Any]: + """Get rolling NAICS metrics (`/api/naics/{code}/metrics/{months}/{period_grouping}/`).""" + if not code: + raise TangoValidationError("NAICS code is required") + return self._get(f"/api/naics/{code}/metrics/{months}/{period_grouping}/") + + def get_business_type(self, code: str) -> dict[str, Any]: + """Get a business type by code (`/api/business_types/{code}/`).""" + if not code: + raise TangoValidationError("Business type code is required") + return self._get(f"/api/business_types/{code}/") + + def list_assistance_listings( + self, page: int = 1, limit: int = 25 + ) -> PaginatedResponse[dict[str, Any]]: + """List Assistance Listings (CFDA programs) (`/api/assistance_listings/`).""" + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + data = self._get("/api/assistance_listings/", params) + return PaginatedResponse( + count=int(data.get("count", 0)), + next=data.get("next"), + previous=data.get("previous"), + results=list(data.get("results") or []), + ) + + def get_assistance_listing(self, number: str) -> dict[str, Any]: + """Get an Assistance Listing by CFDA number (`/api/assistance_listings/{number}/`).""" + if not number: + raise TangoValidationError("Assistance listing number is required") + return self._get(f"/api/assistance_listings/{number}/") + + def list_mas_sins( + self, + page: int = 1, + limit: int = 25, + search: str | None = None, + ) -> PaginatedResponse[dict[str, Any]]: + """List GSA MAS SINs (`/api/mas_sins/`).""" + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + if search is not None: + params["search"] = search + data = self._get("/api/mas_sins/", params) + return PaginatedResponse( + count=int(data.get("count", 0)), + next=data.get("next"), + previous=data.get("previous"), + results=list(data.get("results") or []), + ) + + def get_mas_sin(self, sin: str) -> dict[str, Any]: + """Get a MAS SIN by code (`/api/mas_sins/{sin}/`).""" + if not sin: + raise TangoValidationError("MAS SIN is required") + return self._get(f"/api/mas_sins/{sin}/") + + # ============================================================================ + # Entity sub-resources + # ============================================================================ + + def _entity_subresource_contracts( + self, + uei: str, + endpoint_segment: str, + model: type, + *, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + **filters: Any, + ) -> PaginatedResponse: + """Shared helper for /api/entities/{uei}/{contracts,idvs,otas,otidvs}/.""" + params: dict[str, Any] = {"limit": min(limit, 100)} + if cursor: + params["cursor"] = cursor + if shape is None: + # Conservative minimal default — caller can override per-call. + shape = "key,piid,award_date,recipient(display_name)" + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if joiner: + params["joiner"] = joiner + if flat_lists: + params["flat_lists"] = "true" + for k, v in filters.items(): + if v is not None: + params[k] = v + data = self._get(f"/api/entities/{uei}/{endpoint_segment}/", params) + results = [ + self._parse_response_with_shape(obj, shape, model, flat, flat_lists, joiner=joiner) + for obj in (data.get("results") or []) + ] + return PaginatedResponse( + count=int(data.get("count") or len(results)), + next=data.get("next"), + previous=data.get("previous"), + results=results, + cursor=data.get("cursor"), + page_metadata=data.get("page_metadata"), + ) + + def list_entity_contracts( + self, + uei: str, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + ordering: str | None = None, + search: str | None = None, + **filters: Any, + ) -> PaginatedResponse: + """List contracts awarded to an entity (`/api/entities/{uei}/contracts/`). + + Supports the same filter set as /api/contracts/ scoped to the recipient. + """ + if not uei: + raise TangoValidationError("UEI is required") + if shape is None: + shape = ShapeConfig.CONTRACTS_MINIMAL + return self._entity_subresource_contracts( + uei, + "contracts", + Contract, + limit=limit, + cursor=cursor, + shape=shape, + flat=flat, + flat_lists=flat_lists, + joiner=joiner, + ordering=ordering, + search=search, + **filters, + ) + + def list_entity_idvs( + self, + uei: str, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + ordering: str | None = None, + search: str | None = None, + **filters: Any, + ) -> PaginatedResponse: + """List IDVs held by an entity (`/api/entities/{uei}/idvs/`).""" + if not uei: + raise TangoValidationError("UEI is required") + if shape is None: + shape = ShapeConfig.IDVS_MINIMAL + return self._entity_subresource_contracts( + uei, + "idvs", + IDV, + limit=limit, + cursor=cursor, + shape=shape, + flat=flat, + flat_lists=flat_lists, + joiner=joiner, + ordering=ordering, + search=search, + **filters, + ) + + def list_entity_otas( + self, + uei: str, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + ordering: str | None = None, + search: str | None = None, + **filters: Any, + ) -> PaginatedResponse: + """List OTAs held by an entity (`/api/entities/{uei}/otas/`).""" + if not uei: + raise TangoValidationError("UEI is required") + if shape is None: + shape = ShapeConfig.OTAS_MINIMAL + return self._entity_subresource_contracts( + uei, + "otas", + OTA, + limit=limit, + cursor=cursor, + shape=shape, + flat=flat, + flat_lists=flat_lists, + joiner=joiner, + ordering=ordering, + search=search, + **filters, + ) + + def list_entity_otidvs( + self, + uei: str, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + ordering: str | None = None, + search: str | None = None, + **filters: Any, + ) -> PaginatedResponse: + """List OTIDVs held by an entity (`/api/entities/{uei}/otidvs/`).""" + if not uei: + raise TangoValidationError("UEI is required") + if shape is None: + shape = ShapeConfig.OTIDVS_MINIMAL + return self._entity_subresource_contracts( + uei, + "otidvs", + OTIDV, + limit=limit, + cursor=cursor, + shape=shape, + flat=flat, + flat_lists=flat_lists, + joiner=joiner, + ordering=ordering, + search=search, + **filters, + ) + + def list_entity_subawards( + self, + uei: str, + page: int = 1, + limit: int = 25, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + ordering: str | None = None, + **filters: Any, + ) -> PaginatedResponse: + """List subawards for an entity (`/api/entities/{uei}/subawards/`).""" + if not uei: + raise TangoValidationError("UEI is required") + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + if shape is None: + shape = ShapeConfig.SUBAWARDS_MINIMAL + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if flat_lists: + params["flat_lists"] = "true" + if ordering: + params["ordering"] = ordering + for k, v in filters.items(): + if v is not None: + params[k] = v + data = self._get(f"/api/entities/{uei}/subawards/", params) + results = [ + self._parse_response_with_shape(obj, shape, Subaward, flat, flat_lists) + for obj in (data.get("results") or []) + ] + return PaginatedResponse( + count=int(data.get("count", 0)), + next=data.get("next"), + previous=data.get("previous"), + results=results, + ) + + def list_entity_lcats( + self, + uei: str, + page: int = 1, + limit: int = 25, + ordering: str | None = None, + search: str | None = None, + **filters: Any, + ) -> PaginatedResponse[dict[str, Any]]: + """List GSA-eLibrary Labor Categories (LCATs) for an entity + (`/api/entities/{uei}/lcats/`). + """ + if not uei: + raise TangoValidationError("UEI is required") + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + if ordering: + params["ordering"] = ordering + if search is not None: + params["search"] = search + for k, v in filters.items(): + if v is not None: + params[k] = v + data = self._get(f"/api/entities/{uei}/lcats/", params) + return PaginatedResponse( + count=int(data.get("count", 0)), + next=data.get("next"), + previous=data.get("previous"), + results=list(data.get("results") or []), + ) + + def get_entity_metrics(self, uei: str, months: int, period_grouping: str) -> dict[str, Any]: + """Get rolling metrics for an entity + (`/api/entities/{uei}/metrics/{months}/{period_grouping}/`). + """ + if not uei: + raise TangoValidationError("UEI is required") + return self._get(f"/api/entities/{uei}/metrics/{months}/{period_grouping}/") + + # ============================================================================ + # IDV sub-resources + # ============================================================================ + + def list_idv_lcats( + self, + key: str, + page: int = 1, + limit: int = 25, + ordering: str | None = None, + search: str | None = None, + **filters: Any, + ) -> PaginatedResponse[dict[str, Any]]: + """List Labor Categories under an IDV (`/api/idvs/{key}/lcats/`).""" + if not key: + raise TangoValidationError("IDV key is required") + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + if ordering: + params["ordering"] = ordering + if search is not None: + params["search"] = search + for k, v in filters.items(): + if v is not None: + params[k] = v + data = self._get(f"/api/idvs/{key}/lcats/", params) + return PaginatedResponse( + count=int(data.get("count", 0)), + next=data.get("next"), + previous=data.get("previous"), + results=list(data.get("results") or []), + ) + + # ============================================================================ + # Agency sub-resources + # ============================================================================ + + def _agency_contracts( + self, + code: str, + which: str, + *, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + ordering: str | None = None, + search: str | None = None, + **filters: Any, + ) -> PaginatedResponse: + if not code: + raise TangoValidationError("Agency code is required") + if which not in ("awarding", "funding"): + raise TangoValidationError("Agency contracts which= must be 'awarding' or 'funding'") + params: dict[str, Any] = {"limit": min(limit, 100)} + if cursor: + params["cursor"] = cursor + if shape is None: + shape = ShapeConfig.CONTRACTS_MINIMAL + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if joiner: + params["joiner"] = joiner + if flat_lists: + params["flat_lists"] = "true" + if ordering: + params["ordering"] = ordering + if search is not None: + params["search"] = search + for k, v in filters.items(): + if v is not None: + params[k] = v + data = self._get(f"/api/agencies/{code}/contracts/{which}/", params) + results = [ + self._parse_response_with_shape(obj, shape, Contract, flat, flat_lists, joiner=joiner) + for obj in (data.get("results") or []) + ] + return PaginatedResponse( + count=int(data.get("count") or len(results)), + next=data.get("next"), + previous=data.get("previous"), + results=results, + cursor=data.get("cursor"), + page_metadata=data.get("page_metadata"), + ) + + def list_agency_awarding_contracts( + self, + code: str, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + ordering: str | None = None, + search: str | None = None, + **filters: Any, + ) -> PaginatedResponse: + """List contracts where this agency is the awarding agency + (`/api/agencies/{code}/contracts/awarding/`). + """ + return self._agency_contracts( + code, + "awarding", + limit=limit, + cursor=cursor, + shape=shape, + flat=flat, + flat_lists=flat_lists, + joiner=joiner, + ordering=ordering, + search=search, + **filters, + ) + + def list_agency_funding_contracts( + self, + code: str, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + ordering: str | None = None, + search: str | None = None, + **filters: Any, + ) -> PaginatedResponse: + """List contracts where this agency is the funding agency + (`/api/agencies/{code}/contracts/funding/`). + """ + return self._agency_contracts( + code, + "funding", + limit=limit, + cursor=cursor, + shape=shape, + flat=flat, + flat_lists=flat_lists, + joiner=joiner, + ordering=ordering, + search=search, + **filters, + ) + + # ============================================================================ + # Opportunity attachment search & misc + # ============================================================================ + + def search_opportunity_attachments( + self, + q: str, + top_k: int | None = None, + include_extracted_text: bool | None = None, + ) -> dict[str, Any]: + """Semantic search over opportunity attachments + (`/api/opportunities/attachment-search/`). + """ + if not q: + raise TangoValidationError("search_opportunity_attachments(): q is required") + params: dict[str, Any] = {"q": q} + if top_k is not None: + params["top_k"] = top_k + if include_extracted_text is not None: + params["include_extracted_text"] = "true" if include_extracted_text else "false" + return self._get("/api/opportunities/attachment-search/", params) + + def get_version(self) -> dict[str, Any]: + """Get the Tango API version info (`/api/version/`).""" + return self._get("/api/version/") + + def list_api_keys(self) -> dict[str, Any]: + """List the authenticated user's API keys (`/api/api-keys/`).""" + return self._get("/api/api-keys/") diff --git a/tango/models.py b/tango/models.py index 95ca73e..440bfe9 100644 --- a/tango/models.py +++ b/tango/models.py @@ -623,6 +623,54 @@ class WebhookTestDeliveryResult: test_payload: dict[str, Any] | None = None +@dataclass +class WebhookAlert: + """Filter-based webhook subscription (alert), backed by + ``/api/webhooks/alerts/``. + + Convenience shape over ``WebhookSubscription(subscription_type="filter")``. + """ + + alert_id: str + name: str + query_type: str | None + filters: dict[str, Any] | None + frequency: str + cron_expression: str | None + status: str + created_at: str + last_checked_at: str | None = None + match_count: int = 0 + + +@dataclass +class ResolveCandidate: + """A single ranked candidate from /api/resolve/.""" + + identifier: str | None = None + display_name: str | None = None + match_tier: str | None = None + extra: dict[str, Any] | None = None + + +@dataclass +class ResolveResult: + """Result of POST /api/resolve/ — ranked entity/organization candidates.""" + + candidates: list[ResolveCandidate] + count: int + + +@dataclass +class ValidateResult: + """Result of POST /api/validate/ — identifier format validation.""" + + result: str # "valid" | "not_valid" | "low_confidence" + type: str + value: str + errors: list[str] | None = None + + @dataclass class PaginatedResponse[T]: """Paginated API response @@ -738,8 +786,7 @@ class ShapeConfig: # Default for list_vehicle_orders() VEHICLE_ORDERS_MINIMAL: Final = ( - "key,piid,award_date,obligated,total_contract_value,description," - "recipient(display_name,uei)" + "key,piid,award_date,obligated,total_contract_value,description,recipient(display_name,uei)" ) # Default for list_organizations() diff --git a/tests/test_api_parity.py b/tests/test_api_parity.py new file mode 100644 index 0000000..432eb66 --- /dev/null +++ b/tests/test_api_parity.py @@ -0,0 +1,501 @@ +"""Unit tests for API-parity additions (feat/api-parity branch). + +Mock-driven tests — no network. Verifies that the new methods build the +right HTTP request and parse the response into the expected shapes. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from tango import ( + ResolveResult, + TangoClient, + ValidateResult, + WebhookAlert, +) +from tango.exceptions import TangoValidationError + + +def _mock_response(payload: dict[str, Any], status: int = 200) -> Mock: + resp = Mock() + resp.is_success = 200 <= status < 400 + resp.status_code = status + resp.json.return_value = payload + resp.content = b'{"x": 1}' + resp.headers = {} + return resp + + +@patch("tango.client.httpx.Client.request") +class TestPostJsonKwargAlias: + """`_post` should accept either `json_data` (positional) or `json` (keyword).""" + + def test_positional_json_data(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"ok": True}) + client = TangoClient(api_key="x", base_url="https://t.example") + client._post("/api/foo/", {"a": 1}) + assert mock_request.call_args[1]["json"] == {"a": 1} + + def test_keyword_json_alias(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"ok": True}) + client = TangoClient(api_key="x", base_url="https://t.example") + client._post("/api/foo/", json={"a": 2}) + assert mock_request.call_args[1]["json"] == {"a": 2} + + def test_keyword_json_data_alias(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"ok": True}) + client = TangoClient(api_key="x", base_url="https://t.example") + client._post("/api/foo/", json_data={"a": 3}) + assert mock_request.call_args[1]["json"] == {"a": 3} + + def test_patch_json_kwarg(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"ok": True}) + client = TangoClient(api_key="x", base_url="https://t.example") + client._patch("/api/foo/1/", json={"b": 4}) + assert mock_request.call_args[1]["json"] == {"b": 4} + + +@patch("tango.client.httpx.Client.request") +class TestResolveValidate: + def test_resolve_builds_request(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + { + "candidates": [ + { + "identifier": "ABC123", + "display_name": "Acme Corp", + "match_tier": "high", + "score": 0.95, + }, + {"identifier": "DEF456", "display_name": "Acme LLC"}, + ], + "count": 2, + } + ) + client = TangoClient(api_key="x", base_url="https://t.example") + out = client.resolve("Acme", target_type="entity", state="CA", city="LA", context="cyber") + + # Request shape + call = mock_request.call_args + assert call[1]["method"] == "POST" + assert call[1]["url"].endswith("/api/resolve/") + body = call[1]["json"] + assert body["name"] == "Acme" + assert body["target_type"] == "entity" + assert body["state"] == "CA" + assert body["city"] == "LA" + assert body["context"] == "cyber" + + # Response parsing + assert isinstance(out, ResolveResult) + assert out.count == 2 + assert out.candidates[0].identifier == "ABC123" + assert out.candidates[0].match_tier == "high" + # extra fields preserved + assert out.candidates[0].extra == {"score": 0.95} + assert out.candidates[1].match_tier is None + + def test_resolve_validates_target_type(self, mock_request: Mock) -> None: + client = TangoClient(api_key="x", base_url="https://t.example") + with pytest.raises(TangoValidationError): + client.resolve("Acme", target_type="bogus") + + def test_resolve_requires_name(self, mock_request: Mock) -> None: + client = TangoClient(api_key="x", base_url="https://t.example") + with pytest.raises(TangoValidationError): + client.resolve("", target_type="entity") + + def test_validate_builds_request(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + {"result": "valid", "type": "uei", "value": "ABC123"} + ) + client = TangoClient(api_key="x", base_url="https://t.example") + out = client.validate("uei", "ABC123") + + call = mock_request.call_args + assert call[1]["method"] == "POST" + assert call[1]["url"].endswith("/api/validate/") + # Maps `identifier_type` -> `type` in the body + assert call[1]["json"] == {"type": "uei", "value": "ABC123"} + + assert isinstance(out, ValidateResult) + assert out.result == "valid" + assert out.type == "uei" + assert out.value == "ABC123" + assert out.errors is None + + def test_validate_rejects_unknown_type(self, mock_request: Mock) -> None: + client = TangoClient(api_key="x", base_url="https://t.example") + with pytest.raises(TangoValidationError): + client.validate("naics", "541511") + + +@patch("tango.client.httpx.Client.request") +class TestWebhookAlerts: + def test_create_webhook_alert(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + { + "alert_id": "alert-1", + "name": "my-alert", + "query_type": "opportunity", + "filters": {"naics": "541511"}, + "frequency": "daily", + "cron_expression": None, + "status": "active", + "created_at": "2026-05-11T00:00:00Z", + "last_checked_at": None, + "match_count": 0, + } + ) + client = TangoClient(api_key="x", base_url="https://t.example") + out = client.create_webhook_alert( + name="my-alert", + query_type="opportunity", + filters={"naics": "541511"}, + frequency="daily", + ) + + call = mock_request.call_args + assert call[1]["method"] == "POST" + assert call[1]["url"].endswith("/api/webhooks/alerts/") + assert call[1]["json"] == { + "name": "my-alert", + "query_type": "opportunity", + "filters": {"naics": "541511"}, + "frequency": "daily", + } + + assert isinstance(out, WebhookAlert) + assert out.alert_id == "alert-1" + assert out.filters == {"naics": "541511"} + + def test_create_webhook_alert_validates_inputs(self, mock_request: Mock) -> None: + client = TangoClient(api_key="x", base_url="https://t.example") + with pytest.raises(TangoValidationError): + client.create_webhook_alert(name="", query_type="entity", filters={"x": 1}) + with pytest.raises(TangoValidationError): + client.create_webhook_alert(name="n", query_type="", filters={"x": 1}) + with pytest.raises(TangoValidationError): + client.create_webhook_alert(name="n", query_type="entity", filters={}) + + def test_update_webhook_alert(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + { + "alert_id": "alert-1", + "name": "renamed", + "query_type": "opportunity", + "filters": {"naics": "541511"}, + "frequency": "weekly", + "cron_expression": None, + "status": "active", + "created_at": "2026-05-11T00:00:00Z", + } + ) + client = TangoClient(api_key="x", base_url="https://t.example") + out = client.update_webhook_alert("alert-1", name="renamed", frequency="weekly") + + call = mock_request.call_args + assert call[1]["method"] == "PATCH" + assert call[1]["json"] == {"name": "renamed", "frequency": "weekly"} + assert out.name == "renamed" + assert out.frequency == "weekly" + + def test_list_webhook_alerts(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "alert_id": "a1", + "name": "x", + "query_type": "entity", + "filters": {"uei": "U"}, + "frequency": "realtime", + "cron_expression": None, + "status": "active", + "created_at": "2026-01-01T00:00:00Z", + "match_count": 5, + } + ], + } + ) + client = TangoClient(api_key="x", base_url="https://t.example") + out = client.list_webhook_alerts() + assert out.count == 1 + assert out.results[0].alert_id == "a1" + assert out.results[0].match_count == 5 + + +@patch("tango.client.httpx.Client.request") +class TestWebhookEndpointWriteFixes: + def test_create_endpoint_passes_name(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + { + "id": "ep-1", + "name": "primary", + "callback_url": "https://x/", + "secret": "s", + "is_active": True, + "created_at": "2026-01-01", + "updated_at": "2026-01-01", + } + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.create_webhook_endpoint("https://x/", name="primary") + + body = mock_request.call_args[1]["json"] + assert body["name"] == "primary" + assert body["callback_url"] == "https://x/" + + def test_create_endpoint_without_name_warns(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + { + "id": "ep-1", + "name": "", + "callback_url": "https://x/", + "is_active": True, + "created_at": "2026-01-01", + "updated_at": "2026-01-01", + } + ) + client = TangoClient(api_key="x", base_url="https://t.example") + with pytest.warns(DeprecationWarning, match="name"): + client.create_webhook_endpoint("https://x/") + + def test_update_endpoint_passes_name(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + { + "id": "ep-1", + "name": "renamed", + "callback_url": "https://x/", + "is_active": True, + "created_at": "2026-01-01", + "updated_at": "2026-01-01", + } + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.update_webhook_endpoint("ep-1", name="renamed") + body = mock_request.call_args[1]["json"] + assert body == {"name": "renamed"} + + +@patch("tango.client.httpx.Client.request") +class TestWebhookSubscriptionWriteFixes: + def test_create_subscription_passes_endpoint(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + { + "id": "sub-1", + "endpoint": "ep-1", + "subscription_name": "my-sub", + "payload": {"records": []}, + "created_at": "2026-01-01", + } + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.create_webhook_subscription( + "my-sub", + {"records": []}, + endpoint="ep-1", + subscription_type="subject", + ) + body = mock_request.call_args[1]["json"] + assert body["endpoint"] == "ep-1" + assert body["subscription_type"] == "subject" + assert body["subscription_name"] == "my-sub" + + def test_create_filter_subscription_fields(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + { + "id": "sub-1", + "endpoint": "ep-1", + "subscription_name": "filter-sub", + "payload": {"records": []}, + "created_at": "2026-01-01", + } + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.create_webhook_subscription( + "filter-sub", + {"records": []}, + endpoint="ep-1", + subscription_type="filter", + query_type="opportunity", + filter_definition={"naics": "541511"}, + frequency="custom", + cron_expression="0 9 * * 1", + ) + body = mock_request.call_args[1]["json"] + assert body["subscription_type"] == "filter" + assert body["query_type"] == "opportunity" + assert body["filter_definition"] == {"naics": "541511"} + assert body["frequency"] == "custom" + assert body["cron_expression"] == "0 9 * * 1" + + +@patch("tango.client.httpx.Client.request") +class TestOrderingParam: + """Verify ordering kwarg lands in query params on the seven list_* methods.""" + + @pytest.mark.parametrize( + "method,path,extra_kwargs", + [ + ("list_forecasts", "/api/forecasts/", {}), + ("list_grants", "/api/grants/", {}), + ("list_subawards", "/api/subawards/", {}), + ("list_gsa_elibrary_contracts", "/api/gsa_elibrary_contracts/", {}), + ("list_opportunities", "/api/opportunities/", {}), + ("list_notices", "/api/notices/", {}), + ("list_protests", "/api/protests/", {}), + ], + ) + def test_ordering_threads_through( + self, + mock_request: Mock, + method: str, + path: str, + extra_kwargs: dict[str, Any], + ) -> None: + mock_request.return_value = _mock_response( + {"count": 0, "next": None, "previous": None, "results": []} + ) + client = TangoClient(api_key="x", base_url="https://t.example") + fn = getattr(client, method) + fn(ordering="-foo", **extra_kwargs) + + call = mock_request.call_args + params = call[1]["params"] + assert params["ordering"] == "-foo", ( + f"{method}: expected ordering='-foo' in query, got {params}" + ) + assert call[1]["url"].endswith(path) + + +@patch("tango.client.httpx.Client.request") +class TestReferenceData: + def test_list_departments(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + {"count": 1, "next": None, "previous": None, "results": [{"code": "97"}]} + ) + client = TangoClient(api_key="x", base_url="https://t.example") + out = client.list_departments(limit=2) + assert out.results == [{"code": "97"}] + assert mock_request.call_args[1]["url"].endswith("/api/departments/") + + def test_get_department(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"code": "97", "name": "DoD"}) + client = TangoClient(api_key="x", base_url="https://t.example") + out = client.get_department("97") + assert out["code"] == "97" + assert mock_request.call_args[1]["url"].endswith("/api/departments/97/") + + def test_get_psc_metrics(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"metrics": []}) + client = TangoClient(api_key="x", base_url="https://t.example") + client.get_psc_metrics("R425", 12, "month") + assert mock_request.call_args[1]["url"].endswith("/api/psc/R425/metrics/12/month/") + + def test_get_naics(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"code": "541511"}) + client = TangoClient(api_key="x", base_url="https://t.example") + out = client.get_naics("541511") + assert out["code"] == "541511" + + def test_list_mas_sins_with_search(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + {"count": 0, "next": None, "previous": None, "results": []} + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.list_mas_sins(search="cyber") + assert mock_request.call_args[1]["params"]["search"] == "cyber" + + +@patch("tango.client.httpx.Client.request") +class TestEntitySubResources: + def test_list_entity_contracts(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + {"count": 0, "next": None, "previous": None, "results": []} + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.list_entity_contracts("UEI123", limit=10, ordering="-award_date", naics="541511") + call = mock_request.call_args + assert call[1]["url"].endswith("/api/entities/UEI123/contracts/") + params = call[1]["params"] + assert params["ordering"] == "-award_date" + assert params["naics"] == "541511" + assert params["limit"] == 10 + + def test_get_entity_metrics(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"obligations": []}) + client = TangoClient(api_key="x", base_url="https://t.example") + client.get_entity_metrics("UEI1", 24, "quarter") + assert mock_request.call_args[1]["url"].endswith("/api/entities/UEI1/metrics/24/quarter/") + + def test_list_entity_lcats(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + {"count": 0, "next": None, "previous": None, "results": []} + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.list_entity_lcats("UEI1", search="engineer") + assert mock_request.call_args[1]["params"]["search"] == "engineer" + + def test_uei_required(self, mock_request: Mock) -> None: + client = TangoClient(api_key="x", base_url="https://t.example") + with pytest.raises(TangoValidationError): + client.list_entity_contracts("") + + +@patch("tango.client.httpx.Client.request") +class TestAgencyContracts: + def test_list_agency_awarding_contracts(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + {"count": 0, "next": None, "previous": None, "results": []} + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.list_agency_awarding_contracts("4732", limit=5) + assert mock_request.call_args[1]["url"].endswith("/api/agencies/4732/contracts/awarding/") + + def test_list_agency_funding_contracts(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + {"count": 0, "next": None, "previous": None, "results": []} + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.list_agency_funding_contracts("4732", limit=5) + assert mock_request.call_args[1]["url"].endswith("/api/agencies/4732/contracts/funding/") + + +@patch("tango.client.httpx.Client.request") +class TestMiscMethods: + def test_get_version(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"version": "4.5.0"}) + client = TangoClient(api_key="x", base_url="https://t.example") + out = client.get_version() + assert out["version"] == "4.5.0" + + def test_list_api_keys(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"keys": []}) + client = TangoClient(api_key="x", base_url="https://t.example") + client.list_api_keys() + assert mock_request.call_args[1]["url"].endswith("/api/api-keys/") + + def test_search_opportunity_attachments(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response({"matches": []}) + client = TangoClient(api_key="x", base_url="https://t.example") + client.search_opportunity_attachments(q="cyber", top_k=5, include_extracted_text=True) + params = mock_request.call_args[1]["params"] + assert params["q"] == "cyber" + assert params["top_k"] == 5 + assert params["include_extracted_text"] == "true" + + def test_list_idv_lcats(self, mock_request: Mock) -> None: + mock_request.return_value = _mock_response( + {"count": 0, "next": None, "previous": None, "results": []} + ) + client = TangoClient(api_key="x", base_url="https://t.example") + client.list_idv_lcats("IDV-1") + assert mock_request.call_args[1]["url"].endswith("/api/idvs/IDV-1/lcats/") diff --git a/tests/test_client.py b/tests/test_client.py index df948cf..fba6f25 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -768,7 +768,7 @@ def test_webhook_endpoints_crud(self, mock_request): assert endpoints.results[0].name == "yoni" created = client.create_webhook_endpoint( - "https://example.com/tango/webhooks", is_active=True + "https://example.com/tango/webhooks", is_active=True, name="primary" ) assert created.secret == "secret" @@ -785,6 +785,7 @@ def test_webhook_endpoints_crud(self, mock_request): assert calls[1][1]["method"] == "POST" assert calls[1][1]["json"]["callback_url"] == "https://example.com/tango/webhooks" assert calls[1][1]["json"]["is_active"] is True + assert calls[1][1]["json"]["name"] == "primary" assert calls[2][1]["method"] == "PATCH" assert calls[2][1]["json"]["is_active"] is False