Skip to content

Commit 00aaba8

Browse files
committed
Added in /grants endpoint and method
1 parent f614616 commit 00aaba8

16 files changed

+1290
-13
lines changed

README.md

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ A modern Python SDK for the [Tango API](https://tango.makegov.com) by MakeGov, f
66

77
- **Dynamic Response Shaping** - Request only the fields you need, reducing payload sizes by 60-80%
88
- **Full Type Safety** - Runtime-generated TypedDict types with accurate type hints for IDE autocomplete
9-
- **Comprehensive API Coverage** - All major Tango API endpoints (contracts, entities, forecasts, opportunities, notices) [Note: the current version does NOT implement all endpoints, we will be adding them incrementally]
9+
- **Comprehensive API Coverage** - All major Tango API endpoints (contracts, entities, forecasts, opportunities, notices, grants) [Note: the current version does NOT implement all endpoints, we will be adding them incrementally]
1010
- **Flexible Data Access** - Dictionary-based response objects with validation
1111
- **Modern Python** - Built for Python 3.12+ using modern async-ready patterns
1212
- **Production-Ready** - Comprehensive test suite with VCR.py-based integration tests
@@ -175,6 +175,16 @@ notices = client.list_notices(
175175
)
176176
```
177177

178+
### Grants
179+
180+
```python
181+
# List grant opportunities
182+
grants = client.list_grants(
183+
agency_code="HHS",
184+
limit=25
185+
)
186+
```
187+
178188
### Business Types
179189

180190
```python
@@ -360,39 +370,63 @@ uv run ruff format tango/ && uv run ruff check tango/ && uv run mypy tango/
360370

361371
```
362372
tango-python/
363-
├── tango/
373+
├── tango/ # Main SDK package
364374
│ ├── __init__.py # Public API exports
365375
│ ├── client.py # TangoClient implementation
366376
│ ├── models.py # Data models and shape configs
367377
│ ├── exceptions.py # Exception classes
368378
│ └── shapes/ # Dynamic model system
379+
│ ├── __init__.py # Shapes package exports
369380
│ ├── parser.py # Shape string parser
370381
│ ├── generator.py # TypedDict generator
371382
│ ├── factory.py # Instance factory
372383
│ ├── schema.py # Schema registry
373-
│ ├── explicit_schemas.py # Predefined schemas
384+
│ ├── explicit_schemas.py # Predefined schemas (Contract, Entity, Grant, etc.)
385+
│ ├── models.py # Shape specification models
374386
│ └── types.py # TypedDict exports
375-
├── tests/
376-
│ ├── test_client.py # Unit tests
387+
├── tests/ # Test suite
388+
│ ├── __init__.py
389+
│ ├── conftest.py # Pytest configuration
390+
│ ├── test_client.py # Unit tests for client
377391
│ ├── test_models.py # Model tests
378392
│ ├── test_shapes.py # Shape system tests
393+
│ ├── cassettes/ # VCR.py HTTP cassettes
379394
│ └── integration/ # Integration tests
395+
│ ├── __init__.py
380396
│ ├── README.md # Integration test docs
381-
│ ├── conftest.py # Test fixtures
397+
│ ├── conftest.py # Integration test fixtures
382398
│ ├── validation.py # Validation utilities
383-
│ └── test_*.py # Integration test files
384-
├── docs/
399+
│ ├── test_agencies_integration.py
400+
│ ├── test_contracts_integration.py
401+
│ ├── test_entities_integration.py
402+
│ ├── test_forecasts_integration.py
403+
│ ├── test_grants_integration.py
404+
│ ├── test_notices_integration.py
405+
│ ├── test_opportunities_integration.py
406+
│ ├── test_reference_data_integration.py
407+
│ └── test_edge_cases_integration.py
408+
├── docs/ # Documentation
409+
│ ├── API_REFERENCE.md # Complete API reference
410+
│ ├── DEVELOPERS.md # Developer guide
385411
│ ├── SHAPES.md # Shape system guide
386-
│ ├── API_REFERENCE.md # API reference
387-
│ └── DYNAMIC_MODELS.md # Dynamic models docs
388-
└── pyproject.toml # Project configuration
412+
│ └── quick_start.ipynb # Interactive quick start
413+
├── scripts/ # Utility scripts
414+
│ ├── README.md
415+
│ ├── fetch_api_schema.py
416+
│ └── generate_schemas_from_api.py
417+
├── pyproject.toml # Project configuration
418+
├── uv.lock # Dependency lock file
419+
├── LICENSE # MIT License
420+
├── CHANGELOG.md # Version history
421+
└── README.md # This file
389422
```
390423

391424
## Documentation
392425

393426
- [Shape System Guide](docs/SHAPES.md) - Comprehensive guide to response shaping
394427
- [API Reference](docs/API_REFERENCE.md) - Detailed API documentation
395-
- [Dynamic Models](docs/DYNAMIC_MODELS.md) - Technical documentation on the dynamic model system
428+
- [Developer Guide](docs/DEVELOPERS.md) - Technical documentation for developers
429+
- [Quick Start Notebook](docs/quick_start.ipynb) - Interactive Jupyter notebook with examples
396430

397431
## Requirements
398432

docs/API_REFERENCE.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Complete reference for all Tango Python SDK methods and functionality.
1111
- [Forecasts](#forecasts)
1212
- [Opportunities](#opportunities)
1313
- [Notices](#notices)
14+
- [Grants](#grants)
1415
- [Business Types](#business-types)
1516
- [Response Objects](#response-objects)
1617
- [Error Handling](#error-handling)
@@ -461,6 +462,83 @@ for notice in notices.results:
461462

462463
---
463464

465+
## Grants
466+
467+
Federal grant opportunities and assistance listings.
468+
469+
### list_grants()
470+
471+
List grant opportunities.
472+
473+
```python
474+
grants = client.list_grants(
475+
page=1,
476+
limit=25,
477+
shape=None,
478+
flat=False,
479+
flat_lists=False,
480+
# Additional filters
481+
agency_code=None,
482+
)
483+
```
484+
485+
**Parameters:**
486+
- `page` (int): Page number
487+
- `limit` (int): Results per page (max 100)
488+
- `shape` (str): Response shape string (defaults to minimal shape).
489+
Use None to disable shaping, ShapeConfig.GRANTS_MINIMAL for minimal,
490+
or provide custom shape string
491+
- `flat` (bool): Flatten nested objects in shaped response
492+
- `flat_lists` (bool): Flatten arrays using indexed keys
493+
- `agency_code` (str): Filter by agency code
494+
495+
**Returns:** [PaginatedResponse](#paginatedresponse) with grant dictionaries
496+
497+
**Example:**
498+
```python
499+
grants = client.list_grants(agency_code="HHS", limit=20)
500+
501+
for grant in grants.results:
502+
print(f"{grant['title']}")
503+
print(f"Opportunity: {grant.get('opportunity_number', 'N/A')}")
504+
print(f"Status: {grant.get('status', {}).get('description', 'N/A')}")
505+
```
506+
507+
**Common Grant Fields:**
508+
- `grant_id` - Grant identifier
509+
- `opportunity_number` - Opportunity number
510+
- `title` - Grant title
511+
- `status` - Status information (nested object with code and description)
512+
- `agency_code` - Agency code
513+
- `description` - Description
514+
- `last_updated` - Last updated timestamp
515+
- `cfda_numbers` - CFDA numbers (list of objects with number and title)
516+
- `applicant_types` - Applicant types (list of objects with code and description)
517+
- `funding_categories` - Funding categories (list of objects with code and description)
518+
- `funding_instruments` - Funding instruments (list of objects with code and description)
519+
- `category` - Category (object with code and description)
520+
- `important_dates` - Important dates (list)
521+
- `attachments` - Attachments (list of objects)
522+
523+
**Example with Expanded Fields:**
524+
```python
525+
# Get grants with expanded status and CFDA numbers
526+
grants = client.list_grants(
527+
shape="grant_id,title,opportunity_number,status(*),cfda_numbers(number,title)",
528+
limit=10
529+
)
530+
531+
for grant in grants.results:
532+
print(f"Grant: {grant['title']}")
533+
if grant.get('status'):
534+
print(f"Status: {grant['status'].get('description')}")
535+
if grant.get('cfda_numbers'):
536+
for cfda in grant['cfda_numbers']:
537+
print(f"CFDA: {cfda.get('number')} - {cfda.get('title')}")
538+
```
539+
540+
---
541+
464542
## Business Types
465543

466544
Business type classifications.

tango/client.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
Contract,
2222
Entity,
2323
Forecast,
24+
Grant,
2425
Location,
2526
Notice,
2627
Opportunity,
@@ -749,3 +750,55 @@ def list_notices(
749750
previous=data.get("previous"),
750751
results=results,
751752
)
753+
754+
# Grant endpoints
755+
def list_grants(
756+
self,
757+
page: int = 1,
758+
limit: int = 25,
759+
shape: str | None = None,
760+
flat: bool = False,
761+
flat_lists: bool = False,
762+
**filters,
763+
) -> PaginatedResponse:
764+
"""
765+
List grants
766+
767+
Args:
768+
page: Page number
769+
limit: Results per page (max 100)
770+
shape: Response shape string (defaults to minimal shape).
771+
Use None to disable shaping, ShapeConfig.GRANTS_MINIMAL for minimal,
772+
or provide custom shape string
773+
flat: If True, flatten nested objects in shaped response
774+
flat_lists: If True, flatten arrays using indexed keys
775+
**filters: Additional filter parameters
776+
"""
777+
params = {"page": page, "limit": min(limit, 100)}
778+
779+
# Add shape parameter with default minimal shape
780+
if shape is None:
781+
shape = ShapeConfig.GRANTS_MINIMAL
782+
if shape:
783+
params["shape"] = shape
784+
if flat:
785+
params["flat"] = "true"
786+
if flat_lists:
787+
params["flat_lists"] = "true"
788+
789+
params.update(filters)
790+
791+
data = self._get("/api/grants/", params)
792+
793+
# Always use dynamic parsing
794+
results = [
795+
self._parse_response_with_shape(grant, shape, Grant, flat, flat_lists)
796+
for grant in data["results"]
797+
]
798+
799+
return PaginatedResponse(
800+
count=data["count"],
801+
next=data.get("next"),
802+
previous=data.get("previous"),
803+
results=results,
804+
)

tango/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,3 +415,6 @@ class ShapeConfig:
415415

416416
# Default for list_notices()
417417
NOTICES_MINIMAL: Final = "notice_id,title,solicitation_number,posted_date"
418+
419+
# Default for list_grants()
420+
GRANTS_MINIMAL: Final = "grant_id,opportunity_number,title,status(*),agency_code"

tango/shapes/explicit_schemas.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,98 @@
588588
}
589589

590590

591+
# Nested schemas for Grant fields
592+
CFDA_NUMBER_SCHEMA: dict[str, FieldSchema] = {
593+
"number": FieldSchema(name="number", type=str, is_optional=True, is_list=False),
594+
"title": FieldSchema(name="title", type=str, is_optional=True, is_list=False),
595+
}
596+
597+
CODE_DESCRIPTION_SCHEMA: dict[str, FieldSchema] = {
598+
"code": FieldSchema(name="code", type=str, is_optional=True, is_list=False),
599+
"description": FieldSchema(name="description", type=str, is_optional=True, is_list=False),
600+
}
601+
602+
GRANT_ATTACHMENT_SCHEMA: dict[str, FieldSchema] = {
603+
"attachment_id": FieldSchema(name="attachment_id", type=str, is_optional=True, is_list=False),
604+
"mime_type": FieldSchema(name="mime_type", type=str, is_optional=True, is_list=False),
605+
"name": FieldSchema(name="name", type=str, is_optional=True, is_list=False),
606+
"posted_date": FieldSchema(name="posted_date", type=datetime, is_optional=True, is_list=False),
607+
"resource_id": FieldSchema(name="resource_id", type=str, is_optional=True, is_list=False),
608+
"type": FieldSchema(name="type", type=str, is_optional=True, is_list=False),
609+
"url": FieldSchema(name="url", type=str, is_optional=True, is_list=False),
610+
}
611+
612+
GRANT_SCHEMA: dict[str, FieldSchema] = {
613+
"agency_code": FieldSchema(name="agency_code", type=str, is_optional=True, is_list=False),
614+
"applicant_eligibility_description": FieldSchema(
615+
name="applicant_eligibility_description", type=str, is_optional=True, is_list=False
616+
),
617+
"description": FieldSchema(name="description", type=str, is_optional=True, is_list=False),
618+
"funding_activity_category_description": FieldSchema(
619+
name="funding_activity_category_description", type=str, is_optional=True, is_list=False
620+
),
621+
"grant_id": FieldSchema(name="grant_id", type=int, is_optional=False, is_list=False),
622+
"grantor_contact": FieldSchema(
623+
name="grantor_contact", type=dict, is_optional=True, is_list=True
624+
),
625+
"last_updated": FieldSchema(
626+
name="last_updated", type=datetime, is_optional=True, is_list=False
627+
),
628+
"opportunity_number": FieldSchema(
629+
name="opportunity_number", type=str, is_optional=False, is_list=False
630+
),
631+
"status": FieldSchema(
632+
name="status", type=dict, is_optional=True, is_list=False, nested_model="CodeDescription"
633+
),
634+
"title": FieldSchema(name="title", type=str, is_optional=False, is_list=False),
635+
# Expanded fields
636+
"cfda_numbers": FieldSchema(
637+
name="cfda_numbers",
638+
type=dict,
639+
is_optional=True,
640+
is_list=True,
641+
nested_model="CFDANumber",
642+
),
643+
"applicant_types": FieldSchema(
644+
name="applicant_types",
645+
type=dict,
646+
is_optional=True,
647+
is_list=True,
648+
nested_model="CodeDescription",
649+
),
650+
"category": FieldSchema(
651+
name="category", type=dict, is_optional=True, is_list=False, nested_model="CodeDescription"
652+
),
653+
"funding_categories": FieldSchema(
654+
name="funding_categories",
655+
type=dict,
656+
is_optional=True,
657+
is_list=True,
658+
nested_model="CodeDescription",
659+
),
660+
"funding_details": FieldSchema(
661+
name="funding_details", type=dict, is_optional=True, is_list=True
662+
),
663+
"funding_instruments": FieldSchema(
664+
name="funding_instruments",
665+
type=dict,
666+
is_optional=True,
667+
is_list=True,
668+
nested_model="CodeDescription",
669+
),
670+
"important_dates": FieldSchema(
671+
name="important_dates", type=dict, is_optional=True, is_list=True
672+
),
673+
"attachments": FieldSchema(
674+
name="attachments",
675+
type=dict,
676+
is_optional=True,
677+
is_list=True,
678+
nested_model="GrantAttachment",
679+
),
680+
}
681+
682+
591683
# ============================================================================
592684
# SCHEMA REGISTRY MAPPING
593685
# ============================================================================
@@ -610,6 +702,11 @@
610702
"Opportunity": OPPORTUNITY_SCHEMA,
611703
"Notice": NOTICE_SCHEMA,
612704
"Agency": AGENCY_SCHEMA,
705+
"Grant": GRANT_SCHEMA,
706+
# Nested schemas for Grant fields
707+
"CFDANumber": CFDA_NUMBER_SCHEMA,
708+
"CodeDescription": CODE_DESCRIPTION_SCHEMA,
709+
"GrantAttachment": GRANT_ATTACHMENT_SCHEMA,
613710
}
614711

615712

tango/shapes/factory.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,18 @@ def _resolve_nested_model(self, nested_model: type | str) -> type:
640640
ModelInstantiationError: If model cannot be resolved
641641
"""
642642
if isinstance(nested_model, str):
643+
# First check if it's in the schema registry (for schema-only models)
644+
schema = self.schema_registry.get_schema(nested_model)
645+
if schema:
646+
# Create a simple type to represent this schema-only model
647+
# We'll use a dynamically created type that the schema registry can work with
648+
class_name = nested_model
649+
model_type = type(class_name, (object,), {"__name__": class_name})
650+
# Register it with the schema registry if not already registered
651+
if not self.schema_registry.is_registered(model_type):
652+
self.schema_registry._schemas[model_type] = schema
653+
return model_type
654+
643655
# Try to import the model from tango.models
644656
try:
645657
from tango import models

0 commit comments

Comments
 (0)