Skip to content

fix(python): base64-encode raw bytes passed to base64 file input fields - #200

Merged
2ynn merged 3 commits into
mainfrom
fix/python/base64-file-input-bytes
Nov 10, 2026
Merged

2ynn merged 3 commits into
mainfrom
fix/python/base64-file-input-bytes

Conversation

@2ynn

@2ynn 2ynn commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Why

Fields opted into x-speakeasy-base64-input-mode: file accept a pre-encoded base64 str, a PathLike, or a binary stream. Raw bytes, bytearray and memoryview values were passed through encode_base64_file_input unchanged. Pydantic's lax str coercion then decoded them as UTF-8:

  • non-UTF-8 payloads (most binary content) failed validation with string_unicode
  • ASCII payloads were accepted and sent on the wire unencoded
  • memoryview failed with string_type

Binary streams whose read() returns a memoryview raised TypeError.

What changed

  • encode_base64_file_input base64-encodes bytes-like values. Paths and binary streams are read into bytes and go through the same encoding step.
  • memoryview values must have a byte-sized item format (B, b, c, with or without a byte-order prefix). Other formats raise ValueError, which pydantic reports as a validation error, so the wire bytes never depend on the host's int size or byte order.
  • bytearray and contiguous memoryview values are encoded without an intermediate copy; only non-contiguous views are copied.
  • In a union that also has a bytes member (e.g. Union[Base64EncodedString, bytes, int], generated from a oneOf of a file-mode format: byte string, a format: binary string and another type), bytes input resolves to the bytes member regardless of member order, as on main. Values encoded from bytes-like input are returned as a str subclass that pydantic scores as a strict but non-exact match, so an exact bytes member wins.
  • Base64FileInput includes bytes, bytearray and memoryview, so type checkers accept them in the input TypedDicts.
  • Review Python SDK regenerated; only types/base64fileinput.py and its gen.lock checksum change.
  • New feature test request-bodies-base64-file-input-raw-bytes (pythonv2 only; skipped on the other targets, which do not implement the extension).

Encoding contract

The input type decides the encoding: str is sent as already-encoded base64, bytes-like values are treated as raw content. Content alone cannot tell the two apart, since short alphanumeric payloads are also valid base64.

Warning

⚠️ Migration: already-encoded base64 held as bytes (e.g. the result of base64.b64encode(...)) must now be passed as str (value.decode("ascii")). Passing it as bytes encodes it a second time, silently. That input was never part of the declared Union[str, Base64FileInput] type, and the changeset states the migration.

Known limitations: mixed unions

Rare shapes where a base64 file-input member shares a union with a member that also accepts bytes-like input. Pydantic's smart union has three match levels, so the helper cannot rank the base64 member above every other member and below bytes at the same time.

  • ⚠️ bytearray with a bytes member: for Union[Base64EncodedString, bytes], bytearray(b"abc") is sent through the base64 member as "YWJj" (main sent "abc"). The bytes member only accepts bytearray in lax mode, so the base64 member matches first. Exact bytes input is unaffected.
  • ⚠️ bytes with an Any member: for a union generated from oneOf: [{}, <file-mode string>], bytes input follows member order: Union[Any, Base64EncodedString] keeps the raw bytes ("abc", as on main), Union[Base64EncodedString, Any] sends "YWJj". str, PathLike and stream input resolve the same in both orders.

Why not a new extension value

file already means "pass content, the SDK encodes it", and file content is bytes elsewhere in the Python SDK (File.content: Union[bytes, IO[bytes], io.IOBase]). Raw bytes had no working behaviour under file, so keeping it and adding a separate value would leave existing specs on the broken path. A new value would also not avoid the bytes-as-pre-encoded break: SDK users would hit it as soon as a spec author switched values.

Testing

  • pytest tests/test_request_bodies_additional.py -k base64 in the generated primary pythonv2 SDK: 5 passed.
    • SDK round-trips cover non-UTF-8 bytes, ASCII bytearray (sent encoded), base64-looking b"YWJj" (sent as "WVdKag==", not passed through), and a strided memoryview.
    • Helper assertions cover contiguous, strided and ctypes ('<B') memoryview, a stream returning memoryview, a non-byte memoryview raising ValueError, and "YWJj" passing through unchanged.
    • Union assertions cover bytes, str and int input against Union[Base64EncodedString, bytes, int] in both member orders, PathLike and stream input against Union[Any, Base64EncodedString], and a non-byte memoryview falling through to List[int] in Union[List[int], Base64EncodedString].
  • Union resolution checked on pydantic 2.11.2 (the SDK's minimum) through 2.14.3.
  • mypy, pyright, pylint on the generated base64fileinput.py: clean.
  • ./scripts/build-review-sdk.sh pythonv2: integrity validation passed.
  • prettier --check on touched features.ts files; go build ./internal/features/.
  • Full make test-pythonv2 not run; the change is confined to the base64 input helper.

Public-safety check

  • This change contains no credentials, customer documents, private repository URLs, private filesystem paths, or unredacted private logs.
  • Title, body, comments, and commit messages name no customers or customer-derived identifiers, private paths or trackers, or workflow provenance, and are understandable without private context (.claude/skills/public-repo-communication/SKILL.md).
  • Generated fixtures and review SDK changes are public-safe.
  • I reviewed git diff --check.

@github-actions github-actions Bot added csharp Trigger (24) snapshot tests for csharp go Trigger (90) snapshot tests for go java Trigger (40) snapshot tests for java mcp-typescript Trigger (16) snapshot tests for mcp-typescript php Trigger (22) snapshot tests for php python Trigger (106) snapshot tests for python ruby Trigger (19) snapshot tests for ruby typescript Trigger (168) snapshot tests for typescript unity labels Nov 6, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 19 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Fixes base64 encoding for raw byte inputs in the Python SDK, with updated generated code, tests, and migration documentation.

View guided diff | Turn on auto-fix | Re-trigger cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 1 file (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Fixes Python SDK base64 file input handling so raw bytes/bytearray/memoryview are base64-encoded instead of being passed through or str-coerced; updates template, generated SDK, feature tests and docs, with tests pinning the corrected behavior.

View guided diff | Turn on auto-fix | Re-trigger cubic

@2ynn 2ynn removed csharp Trigger (24) snapshot tests for csharp go Trigger (90) snapshot tests for go java Trigger (40) snapshot tests for java mcp-typescript Trigger (16) snapshot tests for mcp-typescript php Trigger (22) snapshot tests for php ruby Trigger (19) snapshot tests for ruby typescript Trigger (168) snapshot tests for typescript labels Nov 6, 2026
@2ynn
2ynn marked this pull request as ready for review November 6, 2026 16:04
@2ynn
2ynn requested a review from a team as a code owner November 6, 2026 16:04
@2ynn
2ynn requested a review from mfbx9da4 November 6, 2026 16:04
@speakeasy-generator-bot

speakeasy-generator-bot Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Generator Snapshot Testing

Selector(s) Result Summary
snapshot-python ✅ Snapshot run completed

Snapshot results: https://github.com/speakeasy-api/openapi-generation-snapshots/issues/157#issuecomment-6768889383

Snapshot results are summarized above. Additional run context is linked for maintainers with access.

@AshGodfrey AshGodfrey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on four behaviours this PR introduces; the helper on main does none of them. For each one: is it an intentional trade-off? If so, a sentence in the PR body or the docstring is enough and I will re-review. Otherwise there is a suggestion inline.

All four were reproduced by running the main and branch versions of encode_base64_file_input side by side under pydantic 2.14.2 on a model with a Base64EncodedString field.

)
return base64.b64encode(binary).decode("ascii")
value = binary
if isinstance(value, (bytes, bytearray, memoryview)):

@AshGodfrey AshGodfrey Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-encoded bytes are now double-encoded, silently. base64.b64encode(raw) returns bytes, and passing that result straight in worked on main through pydantic's lax str coercion:

M(f=base64.b64encode(b"hello world payload")).f
main:   'aGVsbG8gd29ybGQgcGF5bG9hZA=='   # decodes back to the payload
branch: 'YUdWc2JHOGdkMjl5YkdRZ2NHRjViRzloWkE9PQ=='

Nothing raises, so the server stores base64 text as the file content. Is this intended?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes this was intentional decision on my part:

PR description called out:

Already-encoded base64 held as bytes must now be passed as str (value.decode("ascii")); passing it as bytes encodes it a second time. That input was never part of the declared Union[str, Base64FileInput] type, and the changeset states the migration

In this PR, a bytes-like input is interpreted as raw content and always gets base64-encoded. Note that the SDK cannot tell already-encoded bytes from raw bytes( e.g. b"YWJj" could be base64 for abc or the raw content YWJj). This means that if a caller passes an already base64-encoded byte (e.g. b"aGVsbG8=") it will get encoded a second time ("YUdWc2JHOD0="). To send an already-encoded base64 it should be passed as a str instead (value.decode("ascii")).

The reason why I don't consider this a breaking change is that passing bytes was never part of the contract (it's a type error and only got through because pydantic's lax mode converts bytes to str), the caeveat being that if anyone relied on the old behavior they would get silently wrong data after upgrading, with no error.

)
return base64.b64encode(binary).decode("ascii")
value = binary
if isinstance(value, (bytes, bytearray, memoryview)):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Union member order now decides what a bytes value becomes. For a field typed Union[Base64EncodedString, bytes] (a oneOf of a file-mode format: byte string and a format: binary string), b"..." resolved to the bytes member on main regardless of order. On this branch it becomes the base64 str when the base64 member is listed first and stays bytes when it is second: the before-validator makes the str member a strict-mode match, so pydantic's smart union takes it.

Rare shape, so if it is accepted as-is a note is enough; flagging it because the result flips on spec ordering rather than on the input.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 56b60f2. Values encoded from bytes-like input are now returned as a private str subclass. Pydantic scores a str subclass as a strict but non-exact match, so for bytes input an exact bytes member wins regardless of member order: Union[Base64EncodedString, bytes, int] and Union[bytes, Base64EncodedString, int] both keep b"abc" as bytes ({"value":"abc"}), as on main. The stored field value is still a plain str. PathLike and stream input still return a plain str, so they keep their exact match against members such as Any.

Regression test: test_request_bodies_base64_file_input_union_members covers both orders. Checked on pydantic 2.11.2 (the SDK's minimum) through 2.14.3.

Two rarer mixed-union cases stay order- or type-dependent, because smart union has only three match levels. They are listed under "Known limitations: mixed unions" in the PR body.

return base64.b64encode(binary).decode("ascii")
value = binary
if isinstance(value, (bytes, bytearray, memoryview)):
return base64.b64encode(bytes(value)).decode("ascii")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any memoryview is accepted, whatever its item format. memoryview(array.array('i', [1, 2])) was a validation error on main; here it sends AQAAAAIAAAA=, the native-endian buffer, so the wire bytes depend on the host's int size and byte order.

Suggest restricting to byte-sized formats and raising otherwise, e.g. if isinstance(value, memoryview) and value.format not in ("B", "b", "c"): raise TypeError(...), or casting explicitly with value.cast("B") if accepting them is the intent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 56b60f2. A memoryview must have a byte-sized item format (B, b or c). Byte-order prefixes are ignored, so ctypes buffers ('<B') and cast("@B") still work. Any other format is rejected.

It raises ValueError rather than TypeError. A TypeError from a before-validator escapes pydantic unwrapped, which aborts union resolution: for Union[List[int], Base64EncodedString], memoryview(array('i', [1, 2])) would raise instead of falling through to List[int]. With ValueError it resolves to [1, 2] there, and a plain field raises ValidationError, as on main. The check also applies to a memoryview returned by a stream's read().

return base64.b64encode(binary).decode("ascii")
value = binary
if isinstance(value, (bytes, bytearray, memoryview)):
return base64.b64encode(bytes(value)).decode("ascii")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bytes(value) copies every bytearray and contiguous memoryview; b64encode accepts both directly. Only a non-contiguous view needs the copy:

if isinstance(value, memoryview) and not value.c_contiguous:
    value = bytes(value)
return base64.b64encode(value).decode("ascii")

For a large upload the current form holds the original, a full copy, and the base64 output at once.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 56b60f2. bytes, bytearray and contiguous memoryview values go to b64encode directly; only a non-contiguous memoryview is copied first.

@TristanSpeakEasy TristanSpeakEasy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good overall, the raw-bytes behaviour and the migration for pre-encoded bytes are clear. I agree with the mixed-union concern and left a concrete example below, but I don't think that edge case needs to block this fix.

)
return base64.b64encode(binary).decode("ascii")
value = binary
if isinstance(value, (bytes, bytearray, memoryview)):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can reproduce the existing union concern, and it changes the JSON too: Union[Base64EncodedString, bytes] now turns b"abc" into "YWJj", while reversing the members sends "abc". Both orders sent "abc" before. For a field that allows either binary or base64 content, an existing binary input can therefore silently switch representation. Worth preserving the explicit bytes branch's precedence when both are available and adding a regression test for both orders. Not blocking, but this is separate from the documented migration for pre-encoded bytes on base64-only fields.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 56b60f2. bytes input now resolves to the bytes member in either order, so b"abc" sends "abc" for both Union[Base64EncodedString, bytes] and Union[bytes, Base64EncodedString], as before. test_request_bodies_base64_file_input_union_members covers both orders.

Two edge cases remain, listed under "Known limitations: mixed unions" in the PR body:

  • bytearray in the same union is sent through the base64 member as "YWJj" in both orders (main sent "abc"). The bytes member only accepts bytearray in lax mode, so the base64 member matches first.
  • With an Any member (oneOf: [{}, <file-mode string>]), bytes input follows member order: Any first keeps the raw bytes ("abc"), base64 first sends "YWJj". str, path and stream input resolve the same in both orders.

Smart union has three match levels, so the base64 member cannot rank below bytes and above Any at the same time.

2ynn added 3 commits November 10, 2026 08:34
Fields opted into `x-speakeasy-base64-input-mode: file` accept a
pre-encoded base64 `str`, a `PathLike`, or a binary stream. Raw
`bytes`, `bytearray` and `memoryview` values fell through
`encode_base64_file_input` unchanged and reached pydantic's lax `str`
coercion, which decodes them as UTF-8:

- non-UTF-8 payloads (most binary content) failed validation with
  `string_unicode`
- ASCII payloads were accepted and sent on the wire unencoded
- `memoryview` failed with `string_type`

`encode_base64_file_input` now base64-encodes bytes-like values,
including non-contiguous `memoryview` slices, and `Base64FileInput`
includes `bytes`, `bytearray` and `memoryview` so type checkers accept
them in the input TypedDicts. Paths and binary streams are read into
bytes and go through the same encoding step, so streams whose `read()`
returns a `memoryview` are also accepted instead of raising `TypeError`.

The input type decides the encoding: `str` is sent as already-encoded
base64, bytes-like values are treated as raw content. Content alone
cannot tell the two apart, since short alphanumeric payloads are also
valid base64. Already-encoded base64 held as `bytes` must now be passed
as `str` (`value.decode("ascii")`); passing it as `bytes` encodes it a
second time. That input was never part of the declared
`Union[str, Base64FileInput]` type.
…e input request

Send `b"YWJj"` and a strided `memoryview` through the SDK request so the
wire value pins that the input type, not base64-looking content, decides
encoding, and that the generated input surface accepts `memoryview`.
…ews in base64 file input

- Return values encoded from bytes-like input as a str subclass so pydantic
  scores them as a strict, non-exact match. In a union that also has a
  `bytes` member, `bytes` input resolves to that member regardless of
  member order. PathLike and IO input still returns a plain str, so the
  base64 member stays an exact match and still outranks members such as
  `Any`.
- Reject memoryview values whose item format is not byte-sized (ignoring
  byte-order prefixes) with ValueError, so pydantic reports a validation
  error and other union members are still tried.
- Pass bytearray and contiguous memoryview values to b64encode directly;
  only non-contiguous views are copied.
- Document the encoding contract in the helper docstring.
@2ynn
2ynn force-pushed the fix/python/base64-file-input-bytes branch from 1ebcc9a to 56b60f2 Compare November 10, 2026 13:35

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 4 reviewed files. 1 file intentionally excluded from review.

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Fixes Python SDK base64 file input handling so raw bytes/bytearray/memoryview are base64-encoded instead of passed through or str-coerced; updates template, generated SDK, feature test and docs, with tests pinning the corrected behavior.

View guided diff | Turn on auto-fix | Re-trigger cubic

@2ynn
2ynn dismissed AshGodfrey’s stale review November 10, 2026 14:44

All five threads are substantively addressed at 56b60f2

@2ynn
2ynn added this pull request to the merge queue Nov 10, 2026
Merged via the queue into main with commit 1a9861e Nov 10, 2026
248 checks passed
@2ynn
2ynn deleted the fix/python/base64-file-input-bytes branch November 10, 2026 14:49
@github-actions github-actions Bot locked and limited conversation to collaborators Nov 10, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

python Trigger (106) snapshot tests for python unity

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants