This is a Forward Networks Field Integration, maintained by the field team. Issues and pull requests are welcome and handled on a best-effort basis; it is not routed through Forward Support.
uv sync --all-groups
uv run pre-commit installuv run ruff check . && uv run ruff format --check .
uv run mypy
uv run pytest
uv run python scripts/check_generated.py
uv run mkdocs build --strictMuch of this repository is generated, and CI fails if the checked-in output is stale. Do not edit these by hand:
| Path | Generated by |
|---|---|
spec/forward-openapi.yaml |
scripts/sync_spec.py, from the Forward monorepo |
spec/forward-openapi-3.1.json |
scripts/downconvert_spec.py |
src/forward_sdk/_generated/models.py |
scripts/gen_models.py |
src/forward_sdk/_generated/operations.py |
scripts/gen_operations.py |
src/forward_sdk/_async/services/_generated/ |
scripts/gen_services.py |
src/forward_sdk/_sync/, tests/_sync/ |
scripts/unasync.py |
uv run python scripts/sync_spec.py --fwd-root ~/src/fwdThat vendors the description, regenerates everything, and prints which operations were added, removed or deprecated. That list is the starting point for a changelog entry.
Then run the checks. tests/spec/test_coverage.py names any operation that
gained or lost an implementation, so nothing slips through silently.
Nothing tells you. CI cannot: the Forward monorepo is not available to it, so
scripts/check_generated.py only proves the committed spec and the committed
code agree, never that the spec is current. A description that is a year stale
passes every check in this repository.
So check by hand when you touch this, and before a release:
jq -r .fwd_commit spec/SPEC_SOURCE.json
git -C ~/src/fwd rev-parse HEADDifferent hashes mean the vendored description may be behind. Re-running the sync is cheap and idempotent against an unchanged checkout, so run it rather than reason about whether anything relevant changed.
spec/unpublished.yaml has no upstream to sync from at all. Those shapes are
observed rather than generated, so a Forward release can change one without
anything here noticing. The scheduled live workflow exists for that; see
running the live tests.
Anything that performs I/O is written once, as async, under
src/forward_sdk/_async/ and tests/_async/. The synchronous client is
generated from it:
uv run python scripts/unasync.pyNever edit _sync directly. If a construct is awkward to translate
mechanically, it probably belongs in the pure layers (_ops, nqe) that both
clients already share.
Most operations need no code: they are generated from the description. Write a builder by hand only when the shape is unusual, such as multipart upload or a body assembled from several arguments.
- Add a builder in
src/forward_sdk/_ops/, decorated@op("theOperationId"). The decorator binds it to the operation table, so its method, path and dispatch cannot disagree with the spec. - Add a service method under
src/forward_sdk/_async/services/, and list the group inHAND_WRITTEN_TAGSinscripts/gen_services.pyso it is not also generated. - Run
scripts/unasync.py, then the tests.
Endpoints outside Forward's published description belong in
spec/unpublished.yaml and are marked stability="unpublished". Document them
in docs/unpublished.md. They carry no compatibility promise, so keep them
isolated and say so in the docstring.
Most of these tests assert that the SDK produces the strings and requests it produces. That catches regressions, and it cannot catch a shared wrong assumption: when a parser and its fixtures are written together, they agree with each other whether or not they agree with Forward.
Two shipped defects came from exactly that. where.py emitted field contains "value" for months of development, and NQE has no contains operator, so every
tag-scoped predicate would have been rejected. A query's commit id was read from
a flat key Forward never sends, so every query resolved by path silently ran
against head. Both had tests. Both tests passed.
What found them was a second implementation. Running this client and an existing one against the same fake Forward and diffing the request sequences surfaces disagreements that neither suite can see alone, because the disagreement does not exist inside either one.
So when you change how a request is built or a response is read, prefer evidence
from outside this repository: a recorded payload, a live call, or another client
that talks to the same endpoint. The wire shapes in spec/unpublished.yaml exist
for this reason, and tests/spec/test_unpublished_shapes.py runs the parsers
against them rather than against fixtures written alongside the parsers.
The first run against a real instance found seven defects in code that had a green suite, including a predicate helper that could never have worked, a repository method that failed on every call, and a Forward AI response that failed to parse at all. None was subtle. All were assumptions.
The advice above has a hole, and one defect went through it. Both plugins this
SDK replaces stripped a fields wrapper from NQE result rows, so the SDK did
too. Forward never sends that wrapper. Its serializer holds a row in a field
named fields internally and writes the row's own entries at the top level, so
the envelope exists only in the server's own representation, and the API
description documents bare rows on every result endpoint.
Three codebases held the belief at once, and one of them had encoded it in seventeen fixtures. Every one of those fixtures was written by the same people who wrote the parser it fed. Consensus between consumers is not independent confirmation, because consumers copy each other, and this SDK copied both.
The stripping was not merely useless. select {fields: {...}} yields a row whose
single key is fields, indistinguishable on the wire from the envelope it was
mistaken for, so those rows were silently rewritten and could not be recovered
downstream.
Rank your evidence. The server's source and its generated description outrank a live call, which outranks a recorded payload, which outranks another client's behaviour. Another client tells you what someone else believed. Only the first three tell you what Forward does. When a shape is not in the description, say in the docstring which of these you checked, so the next person knows whether they are reading an observation or an inheritance.
export FORWARD_URL=https://fwd.app
export FORWARD_USERNAME=... FORWARD_PASSWORD=... FORWARD_NETWORK_ID=...
uv run pytest -m liveThey are skipped without credentials, so they never block an ordinary run. If
you change a predicate builder, a response parser, or anything under
spec/unpublished.yaml, run them before you believe the unit tests.
.github/workflows/live.yml runs them weekly against a configured instance and
opens an issue when they fail, so a Forward release changing an unpublished
shape is reported here rather than discovered in someone's sync. It needs four
repository secrets, FORWARD_URL, FORWARD_USERNAME, FORWARD_PASSWORD and
FORWARD_NETWORK_ID, and skips cleanly without them.
The class TestShapesWithNoCiBackstop is the point of that schedule. Each test
in it pins a shape that has been wrong at least once and that no other check in
this repository can catch. Add to it whenever a defect turns out to have been a
wrong assumption about what Forward sends, and say in the docstring what the
wrong assumption cost.
Be careful what you write on a shared instance. Verification should be
read-only where it can be, anything created should be removed in a finally,
and the account should be left as it was found.
tests/unit/-- pure functions, no I/O.tests/_async/-- behaviour, throughhttpx.MockTransport. Generated intotests/_sync/, so both clients are covered by one set of assertions.tests/spec/-- coverage and conformance against the vendored description.tests/live/-- against a real instance, skipped without credentials.
- Update
__version__insrc/forward_sdk/_version.pyandCHANGELOG.md. - Tag
vX.Y.Z. The release workflow refuses to publish if the tag and the version disagree, then publishes via PyPI Trusted Publishing.
Trusted Publishing is configured, so tagging is all a release needs. No long-lived credential lives in this repository. The publisher registered at https://pypi.org/manage/project/forward-sdk/settings/publishing/ is:
| Field | Value |
|---|---|
| Owner | forwardnetworks |
| Repository | forward-python-sdk |
| Workflow name | release.yml |
| Environment name | pypi |
The pypi environment exists under Settings → Environments and can take
required reviewers if publishing should need a human approval.
Releases 0.1.0 through 0.1.4 predate this and were uploaded manually with an
API token, so their files carry no attestations; PyPI does not add them to files
already published, and re-running a release job over an existing version does
not backfill them. Attestations start with the first release published through
the workflow.
If a future release fails with invalid-publisher, the publisher record no
longer matches the claims the workflow presents. The failure text prints those
claims, so compare them against the table above rather than against the
workflow file: workflow_ref must end in release.yml, and environment must
read pypi. The build and test job runs first either way, so a tag is verified
even when the upload is refused.