diff --git a/.changeset/python-sdk-populate-by-name.md b/.changeset/python-sdk-populate-by-name.md new file mode 100644 index 000000000..5256540c5 --- /dev/null +++ b/.changeset/python-sdk-populate-by-name.md @@ -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. diff --git a/lib/python-sdk/common_grants_sdk/client/pagination.py b/lib/python-sdk/common_grants_sdk/client/pagination.py index 7ebdc2567..f88862512 100644 --- a/lib/python-sdk/common_grants_sdk/client/pagination.py +++ b/lib/python-sdk/common_grants_sdk/client/pagination.py @@ -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 @@ -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 diff --git a/lib/python-sdk/common_grants_sdk/extensions/filters.py b/lib/python-sdk/common_grants_sdk/extensions/filters.py index 8892095bc..6dda7a0bb 100644 --- a/lib/python-sdk/common_grants_sdk/extensions/filters.py +++ b/lib/python-sdk/common_grants_sdk/extensions/filters.py @@ -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) } # --------------------------------------------------------------------------- @@ -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). @@ -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. @@ -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, ) diff --git a/lib/python-sdk/common_grants_sdk/extensions/schema.py b/lib/python-sdk/common_grants_sdk/extensions/schema.py index 6e848e2c9..22746d926 100644 --- a/lib/python-sdk/common_grants_sdk/extensions/schema.py +++ b/lib/python-sdk/common_grants_sdk/extensions/schema.py @@ -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 diff --git a/lib/python-sdk/common_grants_sdk/extensions/transforms.py b/lib/python-sdk/common_grants_sdk/extensions/transforms.py index 4f3c0dbe9..61fc0cbab 100644 --- a/lib/python-sdk/common_grants_sdk/extensions/transforms.py +++ b/lib/python-sdk/common_grants_sdk/extensions/transforms.py @@ -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} diff --git a/lib/python-sdk/common_grants_sdk/schemas/pydantic/base.py b/lib/python-sdk/common_grants_sdk/schemas/pydantic/base.py index f3a5edc1c..5b7a876bc 100644 --- a/lib/python-sdk/common_grants_sdk/schemas/pydantic/base.py +++ b/lib/python-sdk/common_grants_sdk/schemas/pydantic/base.py @@ -1,10 +1,23 @@ 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.""" @@ -12,6 +25,8 @@ class CommonGrantsBaseModel(BaseModel): 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: diff --git a/lib/python-sdk/common_grants_sdk/schemas/pydantic/fields/custom.py b/lib/python-sdk/common_grants_sdk/schemas/pydantic/fields/custom.py index 907a20edc..335936d81 100644 --- a/lib/python-sdk/common_grants_sdk/schemas/pydantic/fields/custom.py +++ b/lib/python-sdk/common_grants_sdk/schemas/pydantic/fields/custom.py @@ -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) @@ -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( diff --git a/lib/python-sdk/common_grants_sdk/schemas/pydantic/fields/event.py b/lib/python-sdk/common_grants_sdk/schemas/pydantic/fields/event.py index c231db931..2c7fd53fc 100644 --- a/lib/python-sdk/common_grants_sdk/schemas/pydantic/fields/event.py +++ b/lib/python-sdk/common_grants_sdk/schemas/pydantic/fields/event.py @@ -29,7 +29,6 @@ class EventBase(CommonGrantsBaseModel): ) event_type: EventType = Field( ..., - alias="eventType", description="Type of event", ) description: Optional[str] = Field( @@ -44,7 +43,6 @@ class SingleDateEvent(EventBase): event_type: Literal[EventType.SINGLE_DATE] = Field( EventType.SINGLE_DATE, - alias="eventType", ) date: ISODate = Field( ..., @@ -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", ) @@ -92,7 +85,6 @@ class OtherEvent(EventBase): event_type: Literal[EventType.OTHER] = Field( EventType.OTHER, - alias="eventType", ) details: Optional[str] = Field( default=None, diff --git a/lib/python-sdk/common_grants_sdk/schemas/pydantic/fields/metadata.py b/lib/python-sdk/common_grants_sdk/schemas/pydantic/fields/metadata.py index 39230e400..6c67f29ba 100644 --- a/lib/python-sdk/common_grants_sdk/schemas/pydantic/fields/metadata.py +++ b/lib/python-sdk/common_grants_sdk/schemas/pydantic/fields/metadata.py @@ -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.", ) diff --git a/lib/python-sdk/common_grants_sdk/schemas/pydantic/filters/opportunity.py b/lib/python-sdk/common_grants_sdk/schemas/pydantic/filters/opportunity.py index dc2a04050..76b511b12 100644 --- a/lib/python-sdk/common_grants_sdk/schemas/pydantic/filters/opportunity.py +++ b/lib/python-sdk/common_grants_sdk/schemas/pydantic/filters/opportunity.py @@ -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 @@ -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", ) @@ -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", ) diff --git a/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_applicant_type.py b/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_applicant_type.py index f2941ac20..5ed782287 100644 --- a/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_applicant_type.py +++ b/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_applicant_type.py @@ -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( diff --git a/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_base.py b/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_base.py index 407adc47d..caea500b3 100644 --- a/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_base.py +++ b/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_base.py @@ -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( @@ -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", ) diff --git a/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_funding.py b/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_funding.py index 5e237e64d..cdf5272a0 100644 --- a/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_funding.py +++ b/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_funding.py @@ -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", ) diff --git a/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_status.py b/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_status.py index 6abb482b8..fe4de9357 100644 --- a/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_status.py +++ b/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_status.py @@ -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( diff --git a/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_timeline.py b/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_timeline.py index 682dfb082..17d5ff2fc 100644 --- a/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_timeline.py +++ b/lib/python-sdk/common_grants_sdk/schemas/pydantic/models/opp_timeline.py @@ -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", ) diff --git a/lib/python-sdk/common_grants_sdk/schemas/pydantic/pagination.py b/lib/python-sdk/common_grants_sdk/schemas/pydantic/pagination.py index 372c8b66b..c48c4fd74 100644 --- a/lib/python-sdk/common_grants_sdk/schemas/pydantic/pagination.py +++ b/lib/python-sdk/common_grants_sdk/schemas/pydantic/pagination.py @@ -2,6 +2,8 @@ from pydantic import BaseModel, Field +from .base import CAMEL_WIRE_CONFIG + class PaginatedBase(BaseModel): """Parameters for pagination.""" @@ -13,12 +15,11 @@ class PaginatedBase(BaseModel): ) page_size: int = Field( default=10, - alias="pageSize", description="The number of items per page", ge=1, ) - model_config = {"populate_by_name": True} + model_config = CAMEL_WIRE_CONFIG class PaginatedBodyParams(PaginatedBase): @@ -34,13 +35,11 @@ class PaginatedResultsInfo(PaginatedBase): total_items: int = Field( ..., - alias="totalItems", description="The total number of items", ) total_pages: int = Field( ..., - alias="totalPages", description="The total number of pages", ) - model_config = {"populate_by_name": True} + model_config = CAMEL_WIRE_CONFIG diff --git a/lib/python-sdk/common_grants_sdk/schemas/pydantic/requests/opportunity.py b/lib/python-sdk/common_grants_sdk/schemas/pydantic/requests/opportunity.py index 36705a168..e71e3ea03 100644 --- a/lib/python-sdk/common_grants_sdk/schemas/pydantic/requests/opportunity.py +++ b/lib/python-sdk/common_grants_sdk/schemas/pydantic/requests/opportunity.py @@ -21,5 +21,5 @@ class OpportunitySearchRequest(BaseModel): default_factory=OppFilters, description="Filters to apply to the opportunity search", ) - sorting: OppSorting = OppSorting(sortBy=OppSortBy.LAST_MODIFIED_AT) + sorting: OppSorting = OppSorting(sort_by=OppSortBy.LAST_MODIFIED_AT) pagination: PaginatedBodyParams = PaginatedBodyParams() diff --git a/lib/python-sdk/common_grants_sdk/schemas/pydantic/responses/opportunity.py b/lib/python-sdk/common_grants_sdk/schemas/pydantic/responses/opportunity.py index 4e7a391a6..886d4ac7f 100644 --- a/lib/python-sdk/common_grants_sdk/schemas/pydantic/responses/opportunity.py +++ b/lib/python-sdk/common_grants_sdk/schemas/pydantic/responses/opportunity.py @@ -16,7 +16,6 @@ class OpportunitiesListResponse(DefaultResponse): pagination_info: PaginatedResultsInfo = Field( ..., description="The pagination details", - alias="paginationInfo", ) model_config = {"populate_by_name": True} @@ -29,17 +28,14 @@ class OpportunitiesSearchResponse(DefaultResponse): pagination_info: PaginatedResultsInfo = Field( ..., description="The pagination details", - alias="paginationInfo", ) sort_info: SortedResultsInfo = Field( ..., description="The sorting details", - alias="sortInfo", ) filter_info: FilterInfo[dict] = Field( ..., description="The filter details", - alias="filterInfo", ) model_config = {"populate_by_name": True} diff --git a/lib/python-sdk/common_grants_sdk/schemas/pydantic/responses/success.py b/lib/python-sdk/common_grants_sdk/schemas/pydantic/responses/success.py index 45bf2a02c..d8b9b8259 100644 --- a/lib/python-sdk/common_grants_sdk/schemas/pydantic/responses/success.py +++ b/lib/python-sdk/common_grants_sdk/schemas/pydantic/responses/success.py @@ -36,7 +36,6 @@ class Paginated(Success, Generic[ItemsT]): pagination_info: PaginatedResultsInfo = Field( ..., description="Details about the paginated results", - alias="paginationInfo", ) model_config = {"populate_by_name": True} @@ -48,7 +47,6 @@ class Sorted(Paginated[ItemsT], Generic[ItemsT]): sort_info: SortedResultsInfo = Field( ..., description="The sort order of the items", - alias="sortInfo", ) model_config = {"populate_by_name": True} @@ -72,7 +70,6 @@ class Filtered(Sorted[ItemsT], Generic[ItemsT, FilterT]): filter_info: FilterInfo[FilterT] = Field( ..., description="The filters applied to the response items", - alias="filterInfo", ) model_config = {"populate_by_name": True} diff --git a/lib/python-sdk/common_grants_sdk/schemas/pydantic/sorting.py b/lib/python-sdk/common_grants_sdk/schemas/pydantic/sorting.py index 14a61f40c..a639bf928 100644 --- a/lib/python-sdk/common_grants_sdk/schemas/pydantic/sorting.py +++ b/lib/python-sdk/common_grants_sdk/schemas/pydantic/sorting.py @@ -5,6 +5,8 @@ from pydantic import BaseModel, Field, model_validator +from .base import CAMEL_WIRE_CONFIG + class SortOrder(str, Enum): """Sort order enumeration.""" @@ -18,18 +20,16 @@ class SortBase(BaseModel): sort_by: Union[str, None] = Field( ..., - alias="sortBy", description="The field to sort by", examples=["lastModifiedAt"], ) custom_sort_by: Optional[str] = Field( default=None, - alias="customSortBy", description="Implementation-defined sort key", examples=["customField"], ) - model_config = {"populate_by_name": True} + model_config = CAMEL_WIRE_CONFIG class SortQueryParams(SortBase): @@ -37,7 +37,6 @@ class SortQueryParams(SortBase): sort_order: Optional[SortOrder] = Field( default=None, - alias="sortOrder", description="The order to sort by", examples=[SortOrder.ASC], ) @@ -48,7 +47,6 @@ class SortBodyParams(SortBase): sort_order: Optional[SortOrder] = Field( default=None, - alias="sortOrder", description="The order to sort by", examples=[SortOrder.ASC], ) @@ -59,7 +57,6 @@ class SortedResultsInfo(SortBase): sort_order: str = Field( ..., - alias="sortOrder", description="The order in which the results are sorted", ) errors: Optional[list[str]] = Field( @@ -90,17 +87,14 @@ class OppSorting(BaseModel): sort_by: OppSortBy = Field( ..., description="The field to sort by", - alias="sortBy", ) sort_order: str = Field( default="desc", description="The sort order (asc or desc)", - alias="sortOrder", ) custom_sort_by: Optional[str] = Field( default=None, description="The custom field to sort by when sortBy is 'custom'", - alias="customSortBy", ) @model_validator(mode="after") @@ -111,4 +105,4 @@ def validate_custom_sort_by(self) -> "OppSorting": raise ValueError(e) return self - model_config = {"populate_by_name": True} + model_config = CAMEL_WIRE_CONFIG diff --git a/lib/python-sdk/common_grants_sdk/utils/custom_fields.py b/lib/python-sdk/common_grants_sdk/utils/custom_fields.py index c98db17ef..8378572b5 100644 --- a/lib/python-sdk/common_grants_sdk/utils/custom_fields.py +++ b/lib/python-sdk/common_grants_sdk/utils/custom_fields.py @@ -55,9 +55,13 @@ def _parse_extra_as_custom_fields(cls, values: Any) -> Any: if hasattr(cls, "model_fields"): for attr, field_info in cls.model_fields.items(): known.add(attr) - alias = getattr(field_info, "alias", None) - if alias: - known.add(alias) + for alias in ( + getattr(field_info, "alias", None), + getattr(field_info, "validation_alias", None), + getattr(field_info, "serialization_alias", None), + ): + if isinstance(alias, str): + known.add(alias) for key, val in list(values.items()): if key not in known and isinstance(val, dict) and "fieldType" in val: # Inject the key as the name if not already present @@ -80,7 +84,7 @@ def _parse_extra_as_custom_fields(cls, values: Any) -> Any: __base__=cls, custom_fields=( Optional[CustomFieldsContainer], - Field(default=None, alias="customFields"), + Field(default=None), ), ) @@ -160,10 +164,10 @@ class OpportunityLegacyIdField(CustomField): CustomFieldForAttr = create_model( _create_model_name(name=name, key=key), __base__=CustomField, - # pin expected type (still accepts wire key "fieldType" via alias) + # pin expected type field_type=( CustomFieldType, - Field(default=field.field_type, alias="fieldType"), + Field(default=field.field_type), ), # pin name and description from spec name=(str, Field(default=field.name or key)), diff --git a/lib/python-sdk/tests/extensions/test_filters.py b/lib/python-sdk/tests/extensions/test_filters.py index 8053c3db2..c22e6b4bf 100644 --- a/lib/python-sdk/tests/extensions/test_filters.py +++ b/lib/python-sdk/tests/extensions/test_filters.py @@ -247,12 +247,7 @@ def test_classify_empty_filters_dict_yields_empty_body(): def test_classify_default_snake_form_of_aliased_key_normalizes_to_alias(): - """A snake_case key for an aliased field lands in the named field, not customFilters. - - Without the alias normalization, OppFilters(close_date_range=...) would be - silently dropped by pydantic (populate_by_name is not set) and the field would - stay None. - """ + """A snake_case key for an aliased field lands in the named field, not customFilters.""" result = classify_filters( SAMPLE_ROUTES, "opportunities",