Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 160 additions & 15 deletions lib/python-sdk/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,51 +7,196 @@ This document provides instructions for common development tasks in the CommonGr
- Python 3.11 or higher
- Poetry for dependency management

## Development Commands

### Setup

The commands described in this guide should be run from the `python-sdk` directory:
```bash
cd simpler-grants-protocol/lib/python-sdk
```
## Dependencies

Install dependencies:
```bash
cd simpler-grants-protocol/lib/python-sdk
poetry install
```

### Testing
## Code Quality & Testing

### Tests

Run all tests:
```bash
poetry run pytest
cd simpler-grants-protocol/lib/python-sdk
poetry run pytest tests/ -v
```

Run tests with coverage report:
```bash
cd simpler-grants-protocol/lib/python-sdk
poetry run pytest --cov=common_grants --cov-report=term-missing
```

### Code Quality

#### Formatting
### Formatting

Format code with Black:
```bash
cd simpler-grants-protocol/lib/python-sdk
poetry run black .
```

#### Linting
### Linting

Check code with Ruff:
```bash
cd simpler-grants-protocol/lib/python-sdk
poetry run ruff check .
```

#### Type Checking
### Type Checking

Verify types with MyPy:
```bash
cd simpler-grants-protocol/lib/python-sdk
poetry run mypy .
```

## Usage Examples

### Initialize Opportunity Model Instance

```python
from datetime import datetime, date, UTC
from uuid import uuid4

from common_grants.schemas.fields import Money, Event
from common_grants.schemas.models.opp_base import OpportunityBase
from common_grants.schemas.models.opp_funding import OppFunding
from common_grants.schemas.models.opp_status import OppStatus, OppStatusOptions
from common_grants.schemas.models.opp_timeline import OppTimeline

# Create a new opportunity
opportunity = OpportunityBase(
id=uuid4(),
title="Research Grant 2024",
description="Funding for innovative research projects",
status=OppStatus(
value=OppStatusOptions.OPEN,
description="This opportunity is currently accepting applications"
),
created_at=datetime.now(UTC),
last_modified_at=datetime.now(UTC),
funding=OppFunding(
total_amount_available=Money(amount="100000.00", currency="USD"),
min_award_amount=Money(amount="10000.00", currency="USD"),
max_award_amount=Money(amount="50000.00", currency="USD"),
estimated_award_count=5
),
key_dates=OppTimeline(
app_opens=Event(
name="Application Opens",
date=date(2024, 1, 1),
description="Applications open"
),
app_deadline=Event(
name="Application Deadline",
date=date(2024, 3, 31),
description="Applications close"
)
)
)
```

### Serialize / Deserialize

```python
# Create an opportunity instance
opportunity = OpportunityBase(
...
)

# Serialize to JSON
json_data = opportunity.dump_json()

# Deserialize from JSON
loaded_opportunity = OpportunityBase.from_json(json_data)
```

### Transform Data Sources and Models

When extending an existing system to adopt the CommonGrants protocol, a developer might need to transform existing data model implementations or data sources into canonical model instances. Such custom transformations can be easily implemented by leveraging the abstract base class `OpportunityTransformer`.

The `OpportunityTransformer` base class defines a standard interface for transforming raw input data (from third-party feeds, legacy formats, custom JSON, etc.) into structured `OpportunityBase` instances. As an abstract base class, `OpportunityTransformer` itself *does not contain any transformation logic*; custom transformation logic must be implemented by subclassing the abstract base class (see following example).
Comment on lines +119 to +123
Copy link
Collaborator

@widal001 widal001 May 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't align with the pattern that we're trying to establish here. Adopters shouldn't have to define a custom transformer in Python code.

The goal is to define simple transformations using a serializable mapping that is portable across languages and provide language-specific utilities to automate transformations using that mapping.

Copy link
Collaborator Author

@DavidDudas-Intuitial DavidDudas-Intuitial May 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's discuss. IMO this is compatible. A transformer pattern and portable serializable mapping formats are not mutually exclusive and developers can benefit from one, the other, or both combined.

"Adopters shouldn't have to define a custom transformer in Python code"

Nor should they have to learn a complicated deeply-nested mapping format if they simply want to instantiate an object from a known data source! :) IMO we should give them options.

The spirit of this PR is to provide a very simple scaffold for enabling the ADR stated goal ("flexibly map existing data structures to the canonical models") within the accepted tradeoffs ("requires a custom runtime to apply transformations in each new language or SDK") which can be extended to support any number of formats and transformers (such as the draft PR you opened today) OR used as-is without use of any mapping format.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While I agree these aren't incompatible approaches, maybe we can talk tomorrow about the benefit that this transformer ABC is providing if we want to keep it as an option?

Maybe you could outline the why someone might implement this ABC versus creating their own custom transformer function / class? Typically, the main use case for ABCs in Python is to support polymorphism, providing a consistent interface for downstream consumers of objects that implement this interface. Did you anticipate someone implementing multiple versions of this transformer? Or was there some specific downstream consumer you had in mind that would create the need for a consistent interface for the transformer class?

I could see the value of providing a standard Python interface for transformations that would be consistent across CommonGrants models, like Opportunity (current) and Organization, Person, etc. (future), but it would probably make more sense to define an ABC that is generic (e.g. ModelTransformer) rather than specific to a given model.

I'll also take some time to provide feedback on some specific design choices in this transformer ABC, because even if we decide to proceed with this as an option, there are a few things I'd recommend tweaking.


#### Custom Transformer Example

```python
from uuid import UUID
from transformers.base import OpportunityTransformer
from common_grants_sdk.schemas.models.opp_funding import OppFunding
from common_grants_sdk.schemas.models.opp_status import OppStatus, OppStatusOptions
from common_grants_sdk.schemas.models.opp_timeline import OppTimeline
from common_grants_sdk.schemas.fields import Event, Money
from datetime import date

class LegacyGrantDataTransformer(OpportunityTransformer):

def transform_opportunity_description(self) -> str:
return self.source_data.get("grant_description", "Description")

def transform_opportunity_funding(self) -> OppFunding:
total_award = self.source_data.get("total_award", 0)
min_award = self.source_data.get("min_award", 0)
max_award = self.source_data.get("max_award", 0)
currency = self.source_data.get("award_currency", "USD")
return OppFunding(
total_amount_available=Money(amount=total_award, currency=currency),
min_award_amount=Money(amount=min_award, currency=currency),
max_award_amount=Money(amount=max_award, currency=currency),
estimated_award_count=10
)

def transform_opportunity_status(self) -> OppStatus:
return OppStatus(
value=OppStatusOptions.FORECASTED,
description="Grant opportunity status"
)

def transform_opportunity_timeline(self) -> OppTimeline:
start_date = self.source_data.get("start_date")
end_date = self.source_data.get("end_date")
return OppTimeline(
app_opens=Event(
name="Application Open",
date=date.fromisoformat(start_date),
description="Start date for application submission"
),
app_deadline=Event(
name="Application Deadline",
date=date.fromisoformat(end_date),
description="Deadline for submission"
)
)

def transform_opportunity_title(self) -> str:
return self.source_data.get("grant_name", "Title")
```

Usage of the custom transformer:

```python
# Define source data
source_data = {
"grant_name": "Small Business Research Grant",
"grant_description": "Funding for early-stage R&D.",
"start_date": "2026-06-01",
"end_date": "2026-08-31",
"total_award": 2000000,
"min_award": 10000,
"max_award": 50000,
"award_count" 10
}

# Instantiate transformer
transformer = LegacyGrantFeedTransformer(source_data=source_data)

# Execute transformation to instantiate an opportunity model instance
opportunity = transformer.transform_opportunity()

# Output the opportunity model instance as json
print(opportunity.model_dump_json(indent=2))
```
6 changes: 5 additions & 1 deletion lib/python-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ pip install common-grants-python-sdk
poetry add common-grants-python-sdk
```

## Quick Start
## Example Usage

```python
from datetime import datetime, date, UTC
Expand Down Expand Up @@ -90,3 +90,7 @@ loaded_opportunity = OpportunityBase.from_json(json_data)
- `OppStatus`: Opportunity status tracking
- `OppTimeline`: Key dates and milestones

## Development Guide

For technical details, see the [Developer Guide](./DEVELOPMENT.md).

2 changes: 2 additions & 0 deletions lib/python-sdk/common_grants_sdk/schemas/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
from .opp_funding import OppFunding
from .opp_status import OppStatus, OppStatusOptions
from .opp_timeline import OppTimeline
from .opp_transformer import OpportunityTransformer

__all__ = [
"OpportunityBase",
"OppFunding",
"OppStatus",
"OppStatusOptions",
"OppTimeline",
"OpportunityTransformer",
]
98 changes: 98 additions & 0 deletions lib/python-sdk/common_grants_sdk/schemas/models/opp_transformer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from abc import ABC, abstractmethod
from datetime import datetime, UTC
from typing import Annotated, Any, final
from uuid import UUID, uuid4

from pydantic import Field

from common_grants_sdk.schemas.base import CommonGrantsBaseModel
from common_grants_sdk.schemas.models.opp_base import OpportunityBase
from common_grants_sdk.schemas.models.opp_funding import OppFunding
from common_grants_sdk.schemas.models.opp_status import OppStatus
from common_grants_sdk.schemas.models.opp_timeline import OppTimeline


class OpportunityTransformer(CommonGrantsBaseModel, ABC):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the motivation for inheriting from the CommonGrantsBaseModel and thus making this transformer class a pydantic schema?

Is there a reason someone implementing this function would call transform_opportunity() multiple times after instantiating a given transformer with a single input record?

Some alternative patterns that I think would make more sense:

  • Treating a given transformer as a singleton class that accepts arbitrary input data to the transform_opportunity() method, so it can be used in a loop to transform a list of records without having to instantiate the class, then invoke transform_opportunity()
  • Removing the inheritance from CommonGrantsBaseModel so it's not a pydantic schema, accepting input data and invoking transform_opportunity() as part of the __init__() method and storing the resulting model in a model so that folks could simply run LegacyTransformer(data={...}).model to get the resulting opportunity instance.

"""
Base class for transforming arbitrary data structures
into canonical models.
"""

source_data: Annotated[
dict[str, Any],
Field(
description="Arbitrary source data",
),
]

def transform_opportunity(self, id: UUID | None = None) -> OpportunityBase:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to my comments below on the self._generate_id() method, I think it would make more sense to have id be a required value that is either a uuid or a string that is used to generate a UUID so that successive runs of this function with the same input data will yield identical results.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or if we're sticking with the pattern of having one instance of this transformer per record, expecting an ID value on initialization or generating and saving the UUID when the transformer is instantiated with input data.

"""
Extract and transform opportunity data from source_data
into a canonical OpportunityBase model instance.
"""

return OpportunityBase(
Copy link
Collaborator

@widal001 widal001 May 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works if folks only want to use the default OpportunityBase class, but if they want to extend the OpportunityBase class with custom fields (like outlined in this tutorial) this approach won't work.

An alternative would be to make this a generic class to which developers can pass a sub-class of OpportunityBase when they are implementing this ABC.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the generic classes docs for more info

id=id or self._generate_id(),
title=self.transform_opportunity_title(),
description=self.transform_opportunity_description(),
status=self.transform_opportunity_status(),
funding=self.transform_opportunity_funding(),
key_dates=self.transform_opportunity_timeline(),
created_at=self._current_timestamp(),
last_modified_at=self._current_timestamp(),
Comment on lines +41 to +42
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll likely want to allow consumers of this function to optionally provide existing created_at and last_modified_at attributes, for example if these values are already coming from an existing database like we might expect for the Simpler.Grants.gov API.

)

@abstractmethod
def transform_opportunity_description(self) -> str:
"""
Extract and transform description data from source_data
into a canonical description string.
"""

pass

@abstractmethod
def transform_opportunity_funding(self) -> OppFunding:
"""
Extract and transform funding data from source_data
into a canonical OppFunding model instance.
"""

pass

@abstractmethod
def transform_opportunity_status(self) -> OppStatus:
"""
Extract and transform status data from source_data
into a canonical OppStatus model instance.
"""

pass

@abstractmethod
def transform_opportunity_timeline(self) -> OppTimeline:
"""
Extract and transform timeline data from source_data
into a canonical OppTimeline model instance.
"""

pass

@abstractmethod
def transform_opportunity_title(self) -> str:
"""
Extract and transform title data from source_data
into a canonical title string.
"""

pass

@final
def _generate_id(self) -> UUID:
"""Generate unique ID for the opportunity."""
return uuid4()
Comment on lines +90 to +93
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might either want to replace this with a deterministic UUID method (e.g. v5) or at least provide an alternative internal function that supports deterministic UUID generation.

The current approach would generate two different UUIDs for the same input data.


@final
def _current_timestamp(self) -> datetime:
"""Get current timestamp."""
return datetime.now(UTC)
Loading