Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
21c0c8e
[Issue #799] Transform PoC (#810)
jcrichlake May 13, 2026
b3cc578
Merge remote-tracking branch 'origin/main' into HOLD-transforms
SnowboardTechie May 20, 2026
211ecec
Merge main into HOLD-transforms (2026-05-21)
SnowboardTechie May 21, 2026
a1ccced
757 extend define plugin (#838)
jcrichlake May 29, 2026
3a78941
[Issue #736] ADR: Nullable Optional Fields (#855)
jcrichlake May 26, 2026
5cab45f
chore(deps): bump tsx in the tooling group across 1 directory (#830)
dependabot[bot] May 26, 2026
55db061
chore(deps-dev): bump wrangler in the runtime group (#854)
dependabot[bot] May 26, 2026
b2b8bc0
ci: gate audit-deps step to PRs into main for lib/* workflows (#847)
SnowboardTechie May 26, 2026
b74269d
chore(deps): bump astro (#853)
dependabot[bot] May 26, 2026
fcdb6ba
chore(deps-dev): bump the runtime group with 2 updates (#856)
dependabot[bot] May 26, 2026
d47ee90
chore(deps): bump openapi-sampler (#863)
dependabot[bot] May 28, 2026
702eac4
chore(deps): bump the website-framework group across 1 directory with…
dependabot[bot] Jun 1, 2026
fd14d4a
Update code.json metadata (#874)
github-actions[bot] Jun 1, 2026
ab1499b
chore(deps-dev): bump the all-deps group across 1 directory with 2 up…
dependabot[bot] Jun 1, 2026
60c31a3
Merge branch 'main' into HOLD-transforms
jcrichlake Jun 2, 2026
e76dd45
[Issue #798] Transforms PoC: TypeScript (#825)
SnowboardTechie Jun 4, 2026
c654e3e
Expanding transform logic (#880)
jcrichlake Jun 8, 2026
49d38a0
Merging main in
jcrichlake Jun 10, 2026
debd55b
Update examples file
jcrichlake Jun 11, 2026
3f255c4
Custom filters TS PoC (#868) (#884)
SnowboardTechie Jun 11, 2026
0f85b79
Addressing PR comments
jcrichlake Jun 12, 2026
a60ff42
Fixing pnpm merge conflict
jcrichlake Jun 15, 2026
e90b4bc
refactor(ts-sdk): update transform helper types (#916)
widal001 Jun 16, 2026
8aed146
Custom filters Python PoC (#869) (#885)
SnowboardTechie Jun 16, 2026
fa1ea82
Merge HOLD-transforms into HOLD-filters (2026-06-16)
SnowboardTechie Jun 16, 2026
d5b322b
feat(py-sdk): add codegen-free schema extension framework
widal001 Jun 16, 2026
e82858b
refactor(py-sdk): rewire the public surface onto the new framework
widal001 Jun 16, 2026
a8fdb7e
refactor(py-sdk): port examples and tests to the new framework
widal001 Jun 16, 2026
b56c0dc
fix(sdk): add custom-filters FilterError class
SnowboardTechie Jun 16, 2026
41144f4
chore(py-sdk): remove the codegen build path
widal001 Jun 16, 2026
5db313f
docs(py-sdk): rewrite extensions and examples READMEs
widal001 Jun 16, 2026
ca373ce
docs(sdk): fix stale PluginError references in transforms comments (#…
SnowboardTechie Jun 16, 2026
d2b8f79
Merge branch 'HOLD-transforms' into 888-python-sdk-refactor
widal001 Jun 16, 2026
67ae27c
refactor(py-sdk): rename schema() params to common_schema / source_sc…
widal001 Jun 16, 2026
15c32b7
fix(ts-sdk): guard date schemas against invalid Date input (#920)
SnowboardTechie Jun 17, 2026
6db8a6e
test(py-sdk): cover the schema_name mismatch path in define_plugin
widal001 Jun 17, 2026
e2fff2c
fix(py-sdk): transform_from_mapping returns {} for an empty mapping node
widal001 Jun 17, 2026
855f876
fix(py-sdk): runtime guards for transform shape in schema()
widal001 Jun 17, 2026
dd05c54
refactor(py-sdk): make CustomField generic, drop the duplicate
widal001 Jun 17, 2026
250d830
refactor(py-sdk): collapse Opportunity into a generic OpportunityBase
widal001 Jun 17, 2026
1418644
fix(ts-sdk): annotate enum constants with local types for portable .d.ts
widal001 Jun 17, 2026
4276dc2
Merge HOLD-transforms into HOLD-filters (2026-06-18)
SnowboardTechie Jun 18, 2026
dbaa264
Merge 888-python-sdk-refactor (#924) into HOLD-filters (2026-06-18)
SnowboardTechie Jun 18, 2026
492fb00
refactor(py-sdk): adopt new plugin framework without codegen (#924)
widal001 Jun 18, 2026
dad8869
Resolve merge conflicts
jcrichlake Jun 18, 2026
762f850
Merge HOLD-transforms into HOLD-filters (2026-06-18)
SnowboardTechie Jun 18, 2026
a180ea7
Merge main into HOLD-filters (2026-06-22)
SnowboardTechie Jun 22, 2026
54bff1c
feat(py-sdk): codegen-free typed custom-filter authoring DX (#934)
SnowboardTechie Jun 25, 2026
89e2b00
docs(adr): amend ADR-0012 + ADR-0022 to match the implemented custom-…
SnowboardTechie Jun 25, 2026
4b9b00a
spike(sdk): classify custom filters behind opportunities.search() in …
SnowboardTechie Jun 26, 2026
c861b40
feat(core): add boolean and integer comparison filter models (#953)
SnowboardTechie Jun 26, 2026
3569e14
feat(py-sdk): typed custom-filters authoring surface and client (#966…
SnowboardTechie Jul 6, 2026
6674fcd
refactor(py-sdk): remove `routes` and `schemas` args from `Client` (#…
widal001 Jul 7, 2026
f909175
feat(ts-sdk): typed custom-filters authoring surface and client (#967…
SnowboardTechie Jul 7, 2026
48ef6a5
feat(ts-sdk): strengthen ad hoc filter validation (#982)
widal001 Jul 7, 2026
1f3f98b
Merge main into HOLD-filters (2026-07-07)
SnowboardTechie Jul 7, 2026
037fb61
fix(py-sdk): import GrantsGovOpportunitySchema from cg_grants_gov.models
SnowboardTechie Jul 7, 2026
19fdd8f
fix(py-sdk): align python and typescript (#988)
widal001 Jul 9, 2026
20795f0
fix(py-sdk): ship PEP 561 py.typed marker (#1002)
SnowboardTechie Jul 13, 2026
9689815
Merge main into HOLD-filters (2026-07-13)
SnowboardTechie Jul 13, 2026
af1f759
Revert "feat(core): add boolean and integer comparison filter models …
widal001 Jul 15, 2026
7f56f44
fix(py-sdk): let snake_case construction of aliased models type-check
SnowboardTechie Jul 15, 2026
abc366e
fix(py-sdk): derive camelCase wire naming from a shared alias generator
SnowboardTechie Jul 15, 2026
82c98b4
chore: trim mechanism comments to intent
SnowboardTechie Jul 15, 2026
cfe907c
docs: correct stale alias claims
SnowboardTechie Jul 15, 2026
00c468f
Merge remote-tracking branch 'origin/main' into fix/py-sdk-populate-b…
SnowboardTechie Jul 21, 2026
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
5 changes: 5 additions & 0 deletions .changeset/python-sdk-populate-by-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"common-grants-sdk": patch
---

camelCase wire naming now comes from a shared alias generator on the model config instead of per-field `alias=` declarations. Validation and serialization use each field's `to_camel` alias, `populate_by_name` keeps snake_case construction working, and static type checkers now accept snake_case constructor calls like `SingleDateEvent(event_type=...)`, which bare `alias=` declarations made them reject. The config applies to `CommonGrantsBaseModel` and to the wire models that don't extend it (sorting, pagination, filters). Wire I/O is unchanged: every removed declaration matched its field's `to_camel` form exactly, and irregular wire names (`CustomField.schema_url` -> `"schema"`) keep explicit field-level aliases.
8 changes: 4 additions & 4 deletions lib/python-sdk/common_grants_sdk/client/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,9 @@ def wrapper(

aggregated_pagination_info = PaginatedResultsInfo(
page=1,
pageSize=len(items) or page_size,
totalItems=len(items),
totalPages=1,
page_size=len(items) or page_size,
total_items=len(items),
total_pages=1,
)

# Build aggregated response. Copy the first page's response so any extra
Expand All @@ -131,7 +131,7 @@ def wrapper(
status=latest_response.status if latest_response else 200,
message=latest_response.message if latest_response else "Success",
items=cast(list[ItemsT], items),
paginationInfo=aggregated_pagination_info,
pagination_info=aggregated_pagination_info,
)

return wrapper
52 changes: 14 additions & 38 deletions lib/python-sdk/common_grants_sdk/extensions/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,32 +299,19 @@ def _range(self, operator: RangeOperator, min: Any, max: Any) -> BaseModel:
#: field (an alias-only set would silently drop snake_case keys into ``customFilters``).
DEFAULT_FILTER_NAMES: frozenset[str] = frozenset(
list(OppDefaultFilters.model_fields.keys())
+ [v.alias for v in OppDefaultFilters.model_fields.values() if v.alias]
+ [
name
for v in OppDefaultFilters.model_fields.values()
for name in (v.alias, v.validation_alias, v.serialization_alias)
if isinstance(name, str)
]
)

# ---------------------------------------------------------------------------
# Alias-normalization maps for classify_filters
#
# OppDefaultFilters uses snake_case field names with camelCase aliases but does NOT
# set populate_by_name=True. Pydantic v2 therefore requires the alias form when
# constructing OppFilters via **kwargs — passing the snake_case field name silently
# results in None (the alias is the required construction key).
#
# classify_filters normalizes consumer keys to the alias (or field-name for fields
# without an alias) before passing them to OppFilters(**...):
# - snake_case keys with a camelCase alias → converted to the alias (closeDateRange)
# - camelCase alias keys → kept as-is (already the alias)
# - keys with no alias (e.g. "status") → kept as-is (snake == request key)
# ---------------------------------------------------------------------------

# Map from snake_case field name → camelCase alias (used for OppFilters construction).
# Only fields that declare an alias are included; alias-form keys and fields without
# aliases fall through ``_SNAKE_TO_ALIAS.get(key, key)`` unchanged — one lookup
# normalizes all three key classes.
# snake_case field name → camelCase wire alias, for OppFilters construction.
_SNAKE_TO_ALIAS: dict[str, str] = {
field_name: field_info.alias
field_name: wire_name
for field_name, field_info in OppDefaultFilters.model_fields.items()
if field_info.alias
if (wire_name := field_info.serialization_alias or field_info.alias)
}

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -598,15 +585,10 @@ def classify_filters(
then validated against the known-model union, exactly like ad-hoc input. Call
sites must pass the same resource/method strings the plugin declared.

Construction normalizes all default consumer keys to the form that
``OppFilters(**kwargs)`` accepts. Because ``OppDefaultFilters`` does NOT set
``populate_by_name=True``, Pydantic v2 requires the alias form (e.g.
``closeDateRange``) for aliased fields. Snake_case keys (e.g.
``close_date_range``) are therefore mapped to their alias via ``_SNAKE_TO_ALIAS``
before construction. Fields without aliases (e.g. ``status``) pass through
unchanged. The alternative — enabling ``populate_by_name=True`` on
``OppFilters.model_config`` — is deliberately avoided: the classifier must not
modify core schema model config.
Default consumer keys are normalized to the wire alias via ``_SNAKE_TO_ALIAS``
before ``OppFilters(**kwargs)`` construction. ``populate_by_name`` would accept
either form directly, but normalizing first collapses a snake_case key and its
camelCase alias onto one kwarg instead of passing pydantic both.

Args:
routes: Plugin route declarations (used to identify registered custom filters).
Expand Down Expand Up @@ -636,10 +618,6 @@ def classify_filters(
if key in DEFAULT_FILTER_NAMES:
# Bucket 1: a standard filter. Validate it against the type declared for
# that field (for example, "status" is validated as a StringArrayFilter).
# OppFilters is constructed with keyword arguments and does not set
# populate_by_name, so a snake_case key is first converted to its alias.
# Keys that are already in alias form, and keys that have no alias, pass
# through unchanged.
alias_key = _SNAKE_TO_ALIAS.get(key, key)
if alias_key in default_fields:
# Snake and camel forms of the same field normalize to one key.
Expand Down Expand Up @@ -670,9 +648,7 @@ def classify_filters(
raise error
custom_buckets[key] = validated # type: ignore[assignment]

# OppFilters requires the alias form for construction (populate_by_name is not set).
# Use "customFilters" (the alias) rather than "custom_filters" (the field name).
return OppFilters(
**default_fields,
customFilters=custom_buckets if custom_buckets else None,
custom_filters=custom_buckets if custom_buckets else None,
)
5 changes: 3 additions & 2 deletions lib/python-sdk/common_grants_sdk/extensions/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,9 @@ def _output_field_names(model: type[BaseModel]) -> set[str]:
"""Valid top-level output keys for a model: field names plus their aliases."""
names: set[str] = set(model.model_fields.keys())
for info in model.model_fields.values():
if info.alias:
names.add(info.alias)
for alias in (info.alias, info.validation_alias, info.serialization_alias):
if isinstance(alias, str):
names.add(alias)
return names


Expand Down
9 changes: 7 additions & 2 deletions lib/python-sdk/common_grants_sdk/extensions/transforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,13 @@ def _validate_output_paths(

valid_names: set[str] = set(model.model_fields.keys())
for field_info in model.model_fields.values():
if field_info.alias:
valid_names.add(field_info.alias)
for alias in (
field_info.alias,
field_info.validation_alias,
field_info.serialization_alias,
):
if isinstance(alias, str):
valid_names.add(alias)

# Top-level handler invocations (rare but structurally valid) are not output keys
output_keys = {k for k in mapping if k not in known_handlers}
Expand Down
17 changes: 16 additions & 1 deletion lib/python-sdk/common_grants_sdk/schemas/pydantic/base.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
import json
from typing import Self

from pydantic import BaseModel, ConfigDict
from pydantic import AliasGenerator, BaseModel, ConfigDict
from pydantic.alias_generators import to_camel

from common_grants_sdk.utils.transformation import transform_from_mapping

# Generated (not field-level) aliases keep snake_case construction type-checking:
# static checkers fall back to field names when the alias comes from a generator.
# Field-level alias settings still override for irregular wire names.
CAMEL_ALIASES = AliasGenerator(
validation_alias=to_camel,
serialization_alias=to_camel,
)
CAMEL_WIRE_CONFIG = ConfigDict(
populate_by_name=True,
alias_generator=CAMEL_ALIASES,
)


class CommonGrantsBaseModel(BaseModel):
"""Base model with common configuration and methods for CommonGrants models."""

model_config = ConfigDict(
from_attributes=True,
strict=False, # Coerces strings to enums, datetimes, etc.
populate_by_name=True,
alias_generator=CAMEL_ALIASES,
)

def dump(self) -> dict:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ class CustomField(CommonGrantsBaseModel, Generic[V]):
Generic over its value type ``V`` (default ``Any``): the bare ``CustomField``
keeps the protocol's untyped-value behavior, while ``CustomField[int]`` (or a
Pydantic model) gives plugin authors and consumers a concrete, inspectable
``value`` type. ``populate_by_name`` plus ``validation_alias``/
``serialization_alias`` keep JSON I/O camelCase (``fieldType``) while
snake_case field-name construction (``CustomField(field_type=...)``) type-checks.
``value`` type. Wire naming comes from the base alias generator (camelCase
``fieldType`` on the wire, ``CustomField(field_type=...)`` in code); the one
irregular wire name, ``schema_url`` -> ``"schema"``, keeps an explicit
field-level alias.
"""

model_config = ConfigDict(populate_by_name=True)
Expand All @@ -44,8 +45,6 @@ class CustomField(CommonGrantsBaseModel, Generic[V]):
)
field_type: CustomFieldType = Field(
...,
validation_alias="fieldType",
serialization_alias="fieldType",
description="The JSON schema type to use when de-serializing the `value` field",
)
schema_url: Optional[HttpUrl] = Field(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ class EventBase(CommonGrantsBaseModel):
)
event_type: EventType = Field(
...,
alias="eventType",
description="Type of event",
)
description: Optional[str] = Field(
Expand All @@ -44,7 +43,6 @@ class SingleDateEvent(EventBase):

event_type: Literal[EventType.SINGLE_DATE] = Field(
EventType.SINGLE_DATE,
alias="eventType",
)
date: ISODate = Field(
...,
Expand All @@ -62,26 +60,21 @@ class DateRangeEvent(EventBase):

event_type: Literal[EventType.DATE_RANGE] = Field(
EventType.DATE_RANGE,
alias="eventType",
)
start_date: ISODate = Field(
...,
alias="startDate",
description="Start date of the event in ISO 8601 format: YYYY-MM-DD",
)
start_time: Optional[ISOTime] = Field(
default=None,
alias="startTime",
description="Start time of the event in ISO 8601 format: HH:MM:SS",
)
end_date: ISODate = Field(
...,
alias="endDate",
description="End date of the event in ISO 8601 format: YYYY-MM-DD",
)
end_time: Optional[ISOTime] = Field(
default=None,
alias="endTime",
description="End time of the event in ISO 8601 format: HH:MM:SS",
)

Expand All @@ -92,7 +85,6 @@ class OtherEvent(EventBase):

event_type: Literal[EventType.OTHER] = Field(
EventType.OTHER,
alias="eventType",
)
details: Optional[str] = Field(
default=None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,10 @@ class SystemMetadata(CommonGrantsBaseModel):

created_at: UTCDateTime = Field(
...,
alias="createdAt",
description="The timestamp (in UTC) at which the record was created.",
)
last_modified_at: UTCDateTime = Field(
...,
alias="lastModifiedAt",
description="The timestamp (in UTC) at which the record was last modified.",
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from typing_extensions import TypedDict

from ..base import CommonGrantsBaseModel
from ..base import CAMEL_WIRE_CONFIG, CommonGrantsBaseModel
from .base import DefaultFilter
from .boolean import BooleanComparisonFilter
from .date import DateComparisonFilter, DateRangeFilter
Expand Down Expand Up @@ -32,28 +32,26 @@
class OppDefaultFilters(BaseModel):
"""Standard filters available for searching opportunities."""

model_config = CAMEL_WIRE_CONFIG

status: Optional[StringArrayFilter] = Field(
default=None,
description="`status.value` matches one of the following values",
)
close_date_range: Optional[DateRangeFilter] = Field(
default=None,
alias="closeDateRange",
description="`keyDates.closeDate` is between the given range",
)
total_funding_available_range: Optional[MoneyRangeFilter] = Field(
default=None,
alias="totalFundingAvailableRange",
description="`funding.totalAmountAvailable` is between the given range",
)
min_award_amount_range: Optional[MoneyRangeFilter] = Field(
default=None,
alias="minAwardAmountRange",
description="`funding.minAwardAmount` is between the given range",
)
max_award_amount_range: Optional[MoneyRangeFilter] = Field(
default=None,
alias="maxAwardAmountRange",
description="`funding.maxAwardAmount` is between the given range",
)

Expand All @@ -64,7 +62,6 @@ class OppFilters(OppDefaultFilters):
custom_filters: Optional[dict[str, DefaultFilter]] = Field(
default=None,
description="Additional custom filters to apply to the search",
alias="customFilters",
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ class ApplicantType(CommonGrantsBaseModel):
)
custom_value: Optional[str] = Field(
default=None,
alias="customValue",
description="A custom applicant type value, only meaningful when value='custom'",
)
description: Optional[str] = Field(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ class OpportunityBase(SystemMetadata, CommonGrantsBaseModel, Generic[CF]):
)
key_dates: Optional[OppTimeline] = Field(
default=None,
alias="keyDates",
description="Key dates for the opportunity, such as when the application opens and closes",
)
source: Optional[HttpUrl] = Field(
Expand All @@ -58,12 +57,10 @@ class OpportunityBase(SystemMetadata, CommonGrantsBaseModel, Generic[CF]):
)
custom_fields: Optional[CF] = Field(
default=None,
alias="customFields",
description="Additional custom fields specific to this opportunity",
)
accepted_applicant_types: Optional[list[ApplicantType]] = Field(
default=None,
alias="acceptedApplicantTypes",
description="The type of applicant for the opportunity",
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,31 +17,25 @@ class OppFunding(CommonGrantsBaseModel):
)
total_amount_available: Optional[Money] = Field(
default=None,
alias="totalAmountAvailable",
description="Total amount of funding available for this opportunity",
)
min_award_amount: Optional[Money] = Field(
default=None,
alias="minAwardAmount",
description="Minimum amount of funding granted per award",
)
max_award_amount: Optional[Money] = Field(
default=None,
alias="maxAwardAmount",
description="Maximum amount of funding granted per award",
)
min_award_count: Optional[int] = Field(
default=None,
alias="minAwardCount",
description="Minimum number of awards granted",
)
max_award_count: Optional[int] = Field(
default=None,
alias="maxAwardCount",
description="Maximum number of awards granted",
)
estimated_award_count: Optional[int] = Field(
default=None,
alias="estimatedAwardCount",
description="Estimated number of awards that will be granted",
)
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ class OppStatus(CommonGrantsBaseModel):
)
custom_value: Optional[str] = Field(
default=None,
alias="customValue",
description="A custom status value",
)
description: Optional[str] = Field(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,13 @@ class OppTimeline(CommonGrantsBaseModel):

post_date: Optional[Event] = Field(
default=None,
alias="postDate",
description="The date (and time) at which the opportunity is posted",
)
close_date: Optional[Event] = Field(
default=None,
alias="closeDate",
description="The date (and time) at which the opportunity closes",
)
other_dates: Optional[dict[str, Event]] = Field(
default=None,
alias="otherDates",
description="An optional map of other key dates or events in the opportunity timeline",
)
Loading
Loading