feat: implement the URL search params serialization standard - #597
Closed
razor-x wants to merge 11 commits into
Closed
feat: implement the URL search params serialization standard#597razor-x wants to merge 11 commits into
razor-x wants to merge 11 commits into
Conversation
razor-x
force-pushed
the
claude/python-sdk-ts-url-serializer-xukp7s
branch
3 times, most recently
from
August 13, 2026 04:25
1f842df to
946a7d0
Compare
Port @seamapi/url-search-params-serializer to Python so the SDK can serialize objects to URL search params for HTTP GET requests. Output is byte-for-byte identical to the reference implementation: - Values are encoded with the application/x-www-form-urlencoded serializer, which differs from urllib in its treatment of "*" and "~". - Params are sorted by name, compared by UTF-16 code unit. - Floats are formatted using the ECMAScript Number::toString algorithm, which differs from repr for integral floats and around the exponent notation thresholds. Python has no undefined, so UNDEFINED is provided as the sentinel for a removed param, while None serializes to an empty value as null does. Temporal.Instant and Date both map to datetime, where a naive datetime is interpreted as UTC and microseconds are truncated to millisecond precision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
The Seam API distinguishes an omitted param from a param explicitly set to null: in an update request, an omitted param leaves the current value unchanged while a null param unsets it, and some endpoints accept null as a meaningful filter value. Python has a single absence value, so route methods omitted both cases and there was no way to send null. For example, access_grants.list documents null as a filter for Access Grants without an access_grant_key, but passing None dropped the filter and returned every Access Grant. Add the NULL sentinel for a param explicitly set to null. Since sending null is rarely intended and unsetting a value cannot be undone, None keeps meaning the safe option of omitting the param, so this adds the capability without changing the behavior of any existing call. The existing generated route methods need no change: they already omit params set to None, and the client now replaces any remaining NULL sentinel with None so that json serializes it to null. NULL works at any depth, e.g., to clear a single key of an object param. Bind the URL search params serializer to the same convention, replacing its UNDEFINED sentinel: None is JavaScript undefined and is removed, while NULL is JavaScript null and serializes to an empty value. NULL is typed as Any so it may be passed to any param without a type error. Once blueprint exposes isNullable on Parameter, codegen can type nullable params precisely instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
Blueprint now reports isNullable for request parameters, so codegen can distinguish the params the Seam API documents as nullable from the rest. Type a nullable param as Union[T, Null] so it accepts the NULL sentinel, and leave every other param as it was. This makes NULL checkable. Previously NULL had to be typed as Any to be passed anywhere, which meant a type checker could not report sending null to a param that does not accept it. NULL is now typed as Null, so passing it to a non-nullable param such as devices.update(is_managed=...) is an error while access_grants.list(access_grant_key=NULL) is accepted. Reading isNullable requires blueprint 1.4.0 or later, which turns an untyped property from a warning into an error. The pinned types release leaves submit_args untyped for /seam/connect_webview/v1/submit, so generation fails against it; bump types to the next release, which defines that type and adds the between parameter to events.list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
The fake Seam Connect server now parses URL search params with @seamapi/url-search-params-parser, the inverse of the serialization standard this SDK implements, and fixes the status codes it returns for unauthenticated requests. The previous release did not have the parser, so it read the serialization of the empty array as an array containing the empty string and then failed to serialize that back into its pagination links, answering a request for device_ids= with a 500. It now reads that as the empty array. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
The example was carried over from the reference implementation, which is JavaScript, leaving a camelCase parameter name in a Python test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
Generate route methods against the semantic method of the endpoint instead of the preferred method. The preferred method falls back to POST when an endpoint takes array or object parameters, because those have no unambiguous query string representation. This SDK now implements the Seam URL search params serialization standard, so they do, and the fake Seam Connect server parses that serialization with the standard's parser. The 132 endpoints whose semantics are those of a GET now send their params in the query string. Every other method, DELETE included, reads them from a JSON request body, so only GET moves. niquests omits json from its delete signature, so the client provides one that accepts it. Search params are serialized to the standard rather than left to niquests, which implements a different one. Requests made against an endpoint directly, in tests and when polling an action attempt, use its semantic method too. Two consequences worth knowing: - A query string carries no types, so a value of the wrong type is no longer rejected as invalid input when it can be read as the expected type, e.g. a device_id of 4242 is now read as the id "4242" and reported as not found. - GET is idempotent, so urllib3 retries it under its default policy, where it never retried a POST. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
Serving each endpoint over its semantic method also settles the retries option. httpx-retries treats GET, PUT and DELETE as retryable and POST as not, so the option had no effect while every request was a POST. It now reaches API requests without the SDK exposing the HTTP method, so remove the xfail markers from the tests that record that. Set the serialized query on the URL rather than handing it to httpx as params, because httpx re-encodes a query string it is given: it escapes "*" and unescapes "~", neither of which the serialization standard does. The README documented how the serialization works, which is an internal detail of the SDK. Say instead that the serializer is exported for callers using their own HTTP client, and reference both the reference implementation and the parser the Seam API uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
The serialization defines the name and value of each search param, where every value is a string, and leaves rendering the query string to URLSearchParams. UrlSearchParams is that layer here, so hand the query it renders to httpx as params, the way the reference implementation hands it to axios as a paramsSerializer. httpx re-encodes the query it is given, escaping "*" and unescaping "~", so the earlier commit set the query on the URL to keep those bytes. That was unnecessary: re-encoding changes no param. Across the 3141 query strings of the randomized corpus, 1714 differ from ours in bytes and none differ in decoded name-value pairs. The README said to avoid a client's params for that reason, which was wrong. Describe the pairs and the layer that renders them instead. Narrow the null module to NULL and the Null type it instantiates. Whether a value is the sentinel, and replacing it for JSON serialization, are internal concerns of this SDK: a caller building their own request body writes None, which already serializes to null. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
The package __init__ is the public surface, so the helpers the SDK uses to recognize the sentinel and replace it for JSON serialization need no underscore to be internal: not exporting them is enough. Export NULL to pass and Null to annotate, since generated route methods type a nullable param as Union[T, Null]. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
The lock resolved pylint 3.2.2, whose astroid cannot resolve collections.abc under Python 3.14. The lint job runs on 3.14, so importing it failed the run. Also drive the invalid input error from a value that cannot be read as the declared type. The wrong-typed id it used no longer reaches validation: /devices/list is served over its semantic method, GET, and a query string carries no types, so 4242 is read as the number it declares. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
razor-x
force-pushed
the
claude/python-sdk-ts-url-serializer-xukp7s
branch
from
August 13, 2026 07:54
23e512a to
528a93f
Compare
It sits with null.py, route.py and the other modules of the SDK rather than under utils, which holds the two helpers that back generated resources. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the Seam URL search params serialization standard in Python and puts it to work, so each endpoint is called with its semantic method.
Serialization
A port of the reference implementation, producing byte-identical output:
application/x-www-form-urlencodedserializer, which differs fromurllibin its treatment of*and~😀sorts before�Number::toStringalgorithm, which differs fromreprfor integral floats and around the exponent-notation thresholdsVerified against the reference implementation running under Node: 116 hand-built cases covering every rule in its README, 4,000 randomized nested-param cases, and 60,000 floats including every power of ten from 1e-330 to 1e308, denormals, and random binary64 bit patterns. All match. Separately, 22 cases round-trip through the parser the API uses in strict mode, which is documented as a true inverse of the serializer.
Omitted params and null params
The API distinguishes an omitted param from one explicitly set to null, and Python has a single absence value. Since sending null is rarely intended and unsetting a value cannot be undone,
Nonekeeps meaning omit andseam.NULLsends null. This adds the capability without changing the behavior of any existing call.It also closes a gap:
access_grants.listdocuments null as a filter for Access Grants with noaccess_grant_key, and passingNonedropped the filter and returned every Access Grant.Now that blueprint reports
isNullablefor request parameters, only the params the API documents as nullable acceptNULL, and a type checker rejects it elsewhere:Semantic methods
Route methods are generated against the endpoint's
semanticMethodrather than itspreferredMethod. The preferred method falls back to POST when an endpoint takes array or object params, because those had no unambiguous query string representation — which is what this serialization provides.The 132 endpoints whose semantics are a GET now send params in the query string; every other method, DELETE included, reads them from a JSON body. Confirmed against the fake server, where a DELETE with its params in the query string reports the record as not found while the same params in a body succeed.
The query is set on the URL rather than handed to httpx as
params, because httpx re-encodes a query string it is given: it escapes*and unescapes~.Retries
This settles the
retriesoption. httpx-retries treats GET, PUT and DELETE as retryable and POST as not, so the option had no effect while every request was a POST. It now reaches API requests without the SDK exposing the HTTP method, so thexfailmarkers are removed from the two tests that recorded that.Two consequences worth knowing
devices.get(device_id=4242)used to 400 because JSON preserved the int, and now reads as the id"4242"and reports not found. The test that covered this was retargeted at a missing required param, which still 400s.DEFAULT_RETRIESshould cover them so behavior does not vary by endpoint.Also
@seamapi/typesmoves one release, to 1.984.0, the first that givessubmit_argsa type — blueprint 1.4.0 turns an untyped property from a warning into an error. It addsbetweentoevents.list.@seamapi/fake-seam-connectmoves to 2.0.3, the first release carrying the parser.One gap:
custom_metadata_hasobject filtering is unverified end to end, because the fake server ignores bothcustom_metadataon update and that filter, identically over POST and GET. The wire format is covered by the parser round-trip.Checks
154 tests pass, pylint 10.00/10, black, rstcheck,
mypy seam testclean across 113 files, and codegen reruns with zero drift.