Skip to content

The Python lane: opendpp-sdk on PyPI, and one shared spec normalizer for every generated client - #20

Merged
gsavastano merged 3 commits into
mainfrom
feat/python-sdk
Aug 18, 2026
Merged

The Python lane: opendpp-sdk on PyPI, and one shared spec normalizer for every generated client#20
gsavastano merged 3 commits into
mainfrom
feat/python-sdk

Conversation

@gsavastano

Copy link
Copy Markdown
Contributor

What

The third SDK lane — opendpp-sdk on PyPI (pydantic v2 + urllib3, sync, Python ≥ 3.9) — generated with the Java lane's exact toolchain (openapi-generator 7.12.0), plus the refactor that makes a third lane sane: the Java build's private Groovy prepareSpec becomes ONE shared, upstream-tested normalizer (scripts/normalize-spec.mjs, authored in the node repo and synced here like CHANGELOG.md).

Why the normalizer moved

Python hits the same OpenAPI-3.1 constructs Java did and fails differently — additionalProperties: false becomes pydantic extra=\"forbid\", i.e. runtime rejection of real payloads the moment the server adds a field. A second private rewriter would have made three lanes with two-and-counting normalizations. Now both openapi-generator lanes read one tested rewrite; the retrofit is proven inert (wipe + regen at contract 1.13.0 → zero diff over java/src/main/java).

What the live payload-truth suite caught

The new python/tests/test_live.py (opt-in, OPENDPP_LIVE_TEST=1) parses real hosted-node responses into the generated models — and found two generator defects on its first run:

  1. Boolean single-value validators are stringified (if value not in set(['true'])) — every real ok: true / success: false payload failed to parse. Fixed as normalizer transform 6 (strip boolean const/enum from the generation input); the Java models simplify too (the one-value SuccessEnum/OkEnum wrappers with the Boolean.valueOf(\"11184809\") sentinel become plain Boolean — same wire format).
  2. @id/id attribute shadowing — the generator strips the @, emitting two class attributes with one name; the second silently shadows the first. Fixed with --name-mappings @id=at_id,@type=at_type,@context=at_context.

With both fixes: 14/14 tests pass including all live tests against the production node, and the generated models capture unknown response fields losslessly in additional_properties instead of raising.

Lane furniture

Same shape as the siblings: committed generated sources + scripts/regenerate.sh drift guard, scripts/check_version_lock.py (major.minor locked to the vendored contract; the generated __version__ must match pyproject.toml), hand-written opendpp_sdk/ergonomics.py at TypeScript-lane parity (create_opendpp_client + the four typed resolve_*_as helpers), publish-sdk-py.yml (tag sdk-py-*, environment: release, keyless PyPI trusted publishing — the pending-publisher flow needs no bootstrap token), the ci.yml python job, drift-check COPIES and release.yml ARTIFACTS rows.

Wheel + sdist build clean (twine check passes); the wheel ships the package, ergonomics, and py.typed — no generated docs/tests.

Sequencing

This PR vendors contract 1.13.0 (byte-identical across all three lanes). The upstream contract-hygiene release (1.14.0 — named unions, const discriminants, tolerant responses) lands in the node repo; its sdk-mirror-sync run regenerates all three lanes here post-merge. Publishing stays a deliberate tag act (sdk-py-<semver>), gated on the PyPI trusted publisher being configured for publish-sdk-py.yml.

… spec normalizer

The five generation-input transforms (webhooks collision, @context polymorphism,
additionalProperties -> extends HashMap, 3.1 multi-type containers, Accept-union
content negotiation) move to scripts/normalize-spec.mjs — authored and unit-tested
upstream in opendpp-node (scripts/lib/spec-codegen-normalize.mjs) and synced here
like CHANGELOG.md, so every openapi-generator lane (Java today, Python next) reads
ONE tested rewrite instead of a private per-lane one. prepareSpec becomes an Exec
task invoking it; the generated tree is byte-identical (proven by wipe + regen +
zero git diff against the same 1.13.0 contract).
…h the shared normalizer

The third SDK lane, at parity with the existing two and generated with the Java
lane's exact toolchain (openapi-generator 7.12.0, pydantic v2 + urllib3, sync):

- python/opendpp_sdk/** committed + drift-checked (scripts/regenerate.sh is the
  one regeneration entrypoint: shared normalizer -> wipe -> generate). Two
  config choices are LOAD-BEARING, each documented where it lives:
  disallowAdditionalPropertiesIfNotPresent=false (the generator default turns
  ABSENT additionalProperties into pydantic extra="forbid") and
  --name-mappings @id/@type/@context=at_* (the JSON-LD documents carry both
  @id and id; the generator strips the @ and one attribute silently shadows
  the other).
- opendpp_sdk/ergonomics.py — the only hand-written module: TypeScript-lane
  parity (create_opendpp_client + the four typed resolve_*_as helpers for the
  content-negotiated public resolvers).
- scripts/check_version_lock.py — the TS lane's version lock, plus one
  python-specific check (the generated __version__ must match pyproject.toml).
- tests: offline smoke + negotiation (mocked at the ApiClient.call_api
  boundary) and the opt-in OPENDPP_LIVE_TEST=1 payload-truth suite, which
  parses real hosted-node responses into the generated models. That live
  suite is what caught the two generator defects fixed in this change.
- workflows: ci.yml python job (regen-drift, version lock, build, test),
  publish-sdk-py.yml (sdk-py-* tag, environment: release, keyless PyPI
  trusted publishing), drift-check COPIES + release.yml ARTIFACTS rows.

The normalizer gains transform 6 (authored upstream, synced here): boolean
single-value constraints are stripped from the generation input — the
generator stringifies the allowed value into its validators, so python
rejected every real ok:true / success:false payload and Java wrapped plain
booleans in one-value SuccessEnum/OkEnum classes with a magic sentinel. The
Java models regenerate accordingly (same wire format, simpler surface);
handwritten code referenced none of the removed enum types and the full
gradle build + tests pass.
…eleted inline step

The section still described the Groovy pre-generation step this branch removed;
it now names ../scripts/normalize-spec.mjs, the node-on-PATH requirement, and
the boolean single-value transform the live tests forced.
@gsavastano
gsavastano merged commit 6f981ca into main Aug 18, 2026
3 checks passed
@gsavastano
gsavastano deleted the feat/python-sdk branch August 18, 2026 18:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants