757 extend define plugin#838
Conversation
|
🚀 Website Preview Deployed! Preview your changes at: https://cg-pr-838.billy-daly.workers.dev This preview will be automatically deleted when the PR is closed. |
SnowboardTechie
left a comment
There was a problem hiding this comment.
Looked through this — really nice work on the layering between the new types and the generator. A few thoughts:
The "bring your own callable" path works cleanly. Anything matching Callable[..., TransformResult] drops into ObjectSchemasInput and build_transforms() is just one way to produce one. Might be worth a README mention that hand-written ones still need to return TransformResult — the type catches it but it's easy to miss when porting existing code.
The bigger thing I'd push on is get_client and filters (inline below) — they're not really stubs, they're plumbed through enough layers that we're committing to their shape today, and waiting to ship them as optional kwargs once the interfaces firm up would be a no-op for adopters.
One thing that didn't fit inline: lib/python-sdk/.coverage (53KB pytest-cov artifact) got committed. Probably just needs a gitignore entry and a git rm.
While I was reading the transform surface this builds on (these are in transformation.py and extensions/transforms.py, which already landed in HOLD-transforms via #810 — so not this PR's job to fix, but flagging in case it's useful before that branch merges):
transform_from_mappingdoesif hasattr(data, "model_dump"): data = data.model_dump(mode="json")(around line 208). That call doesn't setby_alias=True, butCommonGrantsBaseModeldoesn't setby_aliasby default either, so dumping a Pydantic CG instance gives you snake_case keys (min_award_amount) while the from_common mappings use the camelCase aliases (funding.minAwardAmount.amount, seeexamples/plugins/grants_gov/cg_config.py:94). The path lookups silently miss and you get an empty result with no errors raised. The example happens to work because the chain isto_common(dict) → dict → from_common(dict)— both sides are dicts so it never matters. Butfrom_common(opportunity_model)directly will break. Fix isdata.model_dump(mode="json", by_alias=True)plus anisinstance(data, BaseModel)check instead ofhasattr(the duck-type would match unrelated classes).build_transforms'scommon_modelparameter only applies toto_commonoutput —from_commonignores it. Symmetric naming threw me a bit; might read better asto_common_model=.- The
to_commonvalidation block only catchesValidationError. Anything else frommodel_validate(e.g.TypeErrorfrom a misconfigured root validator) bubbles up and breaks the errors-as-values contract the rest of the function maintains. DEFAULT_HANDLERShas"switch"aliased to"match"for backward compat, but the tests intest_transforms.pystill useswitchwhile the newgrants_gov/cg_config.pyexample usesmatch. Two names for the same thing across canonical example vs. canonical tests is confusing for someone reading the code to learn the shape.
|
HOLD-transforms synced from |
SnowboardTechie
left a comment
There was a problem hiding this comment.
Nice work, this is feeling solid. The by_alias + isinstance(BaseModel) fix, catch-all Exception on both transform directions (with the new test), and dropping get_client / filters all read clean. match as canonical with switch kept as the tested alias is a nice landing.
Two small follow-ups dropped inline — neither needs to land here. Approving.
| f" self.{name} = ObjectSchemas(native=dict, common={name}, to_common=None, from_common=None)" | ||
| for name in model_names | ||
| ] | ||
| for obj in sorted(mappings_only): |
There was a problem hiding this comment.
Heads up: this loop covers the mappings-only case, but there's a third bucket that's currently invisible — objects declared in config.schemas (explicit transforms) with no custom_fields and no mappings. They never get added to _Schemas, so _render_plugin_init_py's injected schemas.Opportunity.native = ... blows up at import with AttributeError.
Repro:
config = define_plugin(
schemas={"Opportunity": ObjectSchemasInput(to_common=_to, from_common=_from)},
)
# generate runs fine; import → AttributeErrorNothing today hits this since the canonical example declares both slots, but it'll bite the first "just transforms, no custom fields" plugin. Probably folds into this loop by also iterating set(config.schemas.keys()) - explicit_cf_objs - mappings_only_objs with common defaulting to the base SDK class, same shape as mappings_only_objs. Fine to defer.
| plugin.extensions // serializable; used by mergeExtensions() | ||
| plugin.schemas.<ObjectName> = { native, common, toCommon, fromCommon } | ||
| plugin.meta // name, version, sourceSystem, capabilities | ||
| plugin.get_client // (config: ClientConfig) => Client; memoized by the code generator |
There was a problem hiding this comment.
The rest of the doc still references getClient / filters throughout, but the merged code has dropped them for this slot. Anyone reading the ADR alongside the code will wonder why the Plugin types don't match. Probably wants a one-line "Implementation status" note near the Decision summary explaining that get_client / filters are deferred to sibling tickets.
| extensions=PluginExtensions( | ||
| schemas={ | ||
| "Opportunity": PluginExtensionsSchema( | ||
| customFields={ | ||
| "legacyId": CustomFieldSpec( | ||
| field_type=CustomFieldType.INTEGER, | ||
| name="Legacy ID", | ||
| description="Unique identifier in legacy database", | ||
| ), | ||
| "legacyIdStr": CustomFieldSpec( | ||
| field_type=CustomFieldType.STRING, | ||
| name="Legacy ID (string)", | ||
| description="Legacy ID coerced to a string via numberToString", | ||
| ), | ||
| "agencyName": CustomFieldSpec( | ||
| field_type=CustomFieldType.STRING, | ||
| name="Agency", | ||
| description="Agency hosting the opportunity", | ||
| ), | ||
| "applicantTypes": CustomFieldSpec( | ||
| field_type=CustomFieldType.ARRAY, | ||
| name="Applicant types", | ||
| description="Types of applicants eligible to apply", | ||
| ), | ||
| "priorityScore": CustomFieldSpec( | ||
| field_type=CustomFieldType.NUMBER, | ||
| name="Priority score", | ||
| description="Numeric priority score coerced from a string via stringToNumber", | ||
| ), | ||
| }, | ||
| ) | ||
| } | ||
| ), | ||
| schemas={ |
There was a problem hiding this comment.
@jcrichlake @SnowboardTechie I know I've been a little out of the loop here, but I think something might have gotten lost in translation between our initial discussions around ADR-0022 and this implementation:
Based on this code it looks like we have the following attributes in DefinePluginOptions:
schemas-- Keyed by object, holds the transformation code logicextensions.schemas-- ALSO keyed by object, but only contains the custom fields
I thought the whole point of consolidating transformation logic and custom fields under a single dictionary keyed by object was so that users only had to add one new entry when introducing a new object (e.g. Applications). But this still requires them to add two new entries.
I'd suggest we either consolidate these into a single root-level schemas.<object>.{customFields, native_schema, to_common, from_common} or we keep them separate and rename them:
transformationswith this shape<object>.{native_schema, to_common, from_common}customFieldswith this shape<object>.CustomFieldSpec
There was a problem hiding this comment.
@SnowboardTechie does the TypeScript SDK transformation changes also have this same issue?
There was a problem hiding this comment.
Thanks for noting this! I went ahead and moved everything under the schemas object. I think this was a hold over from the PoC which was a partial implementation.
There was a problem hiding this comment.
Good news on the TS side. #825 is still in draft, and current shape has customFields on ObjectSchemasInput, so the per-object entry under transformSchemas carries native + customFields + toCommon + fromCommon together. No second key per object to maintain.
One thing the consolidation surfaces: pulling customFields out of the JSON-safe extensions surface means mergeExtensions over customFields drops out of the consolidated path, and the ADR-0022 text still describes the old dual-keyed shape. Better tracked as ADR cleanup than a separate amendment.
widal001
left a comment
There was a problem hiding this comment.
@jcrichlake See my comment above.
I'll continue working through this PR when I have time, but I think we'd need to combine or clarify the naming around schemas and extensions.schemas which will probably trickle down into how the rest of this PR is implemented.
There was a problem hiding this comment.
This file looks like it needs to be update to change the Any to a set of generics that actually leverage native and common schemas so that the input and output of to_common() and from_common() have proper type hinting.
There was a problem hiding this comment.
I've attempted to address in subsequent commits.
Mirror the Python PoC PR #838 commit a156d31 so plugin authors add a single per-object entry under transformSchemas[Object] when introducing a new object, rather than splitting customFields across a separate top-level extensions dict. - types.ts: add customFields?: Record<string, CustomFieldSpec> to ObjectSchemasInput; drop customFields from PluginExtensionsObjectConfig (now mappings-only, matching Python's PluginExtensionsSchema). - define-plugin.ts: make extensions optional in DefinePluginOptions and Plugin; definePlugin() sources customFields from transformSchemas[name].customFields first, falling back to the legacy extensions[name] surface so existing customFields-only plugins keep working. - examples/transforms.ts: consolidate to a single transformSchemas entry carrying customFields + toCommon + fromCommon together. - tests: cover the consolidated path (customFields on transformSchemas[obj]), no-extensions-arg case, and transformSchemas-wins-over-extensions priority. Open question — ADR-0022 as written places customFields inside PluginExtensions.schemas[obj] specifically so declarations can be combined across packages via mergeExtensions() (Decision driver line 23, Decision #4). This commit follows Jeff's Python move out of that serializable surface, which drops customFields from the cross-package merge contract. Pending an ADR-0022 amendment formalizing the trade-off; flagged inline on ObjectSchemasInput and PluginExtensionsObjectConfig. Type inference for plugin.schemas[obj] still flows through the legacy extensions parameter — the runtime applies customFields from either source, but the compiled-schema type only narrows when customFields are declared via extensions. Wiring the generic through transformSchemas is a follow-up (parallel to the transformSchemas → schemas rename deferred to #756).
Summary
Changes proposed
Core framework (
common_grants_sdk/extensions/)types.py— new plugin framework types per ADR-0022:PluginExtensions,PluginExtensionsMeta,PluginExtensionsSchema,ObjectSchemasInput,ObjectSchemas,TransformResult,PluginError,Handler,ObjectMappings,PluginCapabilitytransforms.py—build_transforms()generatesto_common/from_commoncallables from declarative mapping dicts, with structural validation at calltime, custom handler registration, and optional Pydantic model validation via
common_modelspecs.py—merge_extensions()now acceptsPluginExtensions(structured objects) instead of the flatSchemaExtensionsTypedDict; added_merge_meta()for conflict-aware metadata mergingplugin.py—define_plugin()now acceptsmeta,extensions,schemas,get_client, andfilters(all optional);PluginandPluginConfigupdated to match
__init__.py— exportsbuild_transformsand all new ADR-0022 typesgenerate.py— code generator now handles the newPluginConfigshape, emitsObjectSchemasinstances in generated__init__.py, and injectstransform callables at generation time
Transformation utilities (
utils/transformation.py)numberToStringandstringToNumberbuilt-in handlersHandlerErrorfor attributing transform failures to specific handlersDEFAULT_HANDLERSregistry dict andhandlersparameter totransform_from_mappingExamples
examples/plugins/grants_gov/cg_config.py— grants.gov sample plugin demonstrating all 6 mapping types (field,const,match,numberToString,stringToNumber, and custom handlers)examples/transforms.py— end-to-end demo of bidirectional transforms with roundtrip verification, custom handlers (join/split), and typed modelvalidation
examples/plugins/opportunity_extensions/cg_config.py— migrated fromSchemaExtensionsdict format toPluginExtensionsobjects; added moduledocstring
examples/plugin_custom_fields.py— updated call sites to useschemas.Opportunity.common.model_validate()andextensions.schemas["Opportunity"].custom_fieldsTests and docs
tests/extensions/test_plugin.py,test_transforms.py,test_types.pytest_merge_extensions.py,test_plugin_generator.py,test_transformation.py,test_custom_fields.py,test_plugin_registry.pyextensions/README.md, ADR-0017, and ADR-0022Context for reviewers
This PR extends
define_plugin()to support the full plugin framework shape described in ADR-0022. The key design decisions:build_transforms()validates mapping structure at call time (not generation time) and surfaces failures asPluginErrorentries inTransformResult.errorsrather than raising — consumers choose strict vs. lenient handling
build_transforms()intentionally does not invert one into the other because many-to-one handlers likematch/switchare not reversiblebuild_transforms()call only and must not shadowDEFAULT_HANDLERS(raisesValueErrorat call time)to_common/from_commoncallables into generated__init__.pysoplugin.schemas.Opportunity.to_commonis populated without pluginauthors needing to do it manually
Testing instructions:
From
lib/python-sdk/: