Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.2.1 (2026-10-01)


### Bug Fixes

* **python:** type client methods as their decoded results
* **python:** type client methods as their decoded results

## 0.2.0 (2026-10-01)


Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "photonhq-api"
version = "0.2.0"
version = "0.2.1"
description = "Generated Photon API RPC client"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
6 changes: 3 additions & 3 deletions packages/python/src/photon_api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import httpx

from .config_generated import DEFAULT_BASE_URL
from .rpc_generated import AsyncRoot, SyncRoot
from .rpc_generated import AsyncRawRoot, AsyncRoot, SyncRawRoot, SyncRoot
from .transport import (
DEFAULT_MAX_RETRY_AFTER,
AsyncHeaderProvider,
Expand Down Expand Up @@ -35,7 +35,7 @@ def __init__(
max_retry_after=max_retry_after,
)
super().__init__(self._transport)
self.raw = SyncRoot(self._transport, raw=True)
self.raw = SyncRawRoot(self._transport)

def close(self) -> None:
self._transport.close()
Expand Down Expand Up @@ -67,7 +67,7 @@ def __init__(
max_retry_after=max_retry_after,
)
super().__init__(self._transport)
self.raw = AsyncRoot(self._transport, raw=True)
self.raw = AsyncRawRoot(self._transport)

async def close(self) -> None:
await self._transport.close()
Expand Down
3,846 changes: 2,393 additions & 1,453 deletions packages/python/src/photon_api/rpc_generated.py

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions packages/python/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,10 @@ def check(photon: Photon | AsyncPhoton) -> None:
if name.startswith("_"):
continue
resource = getattr(photon, name)
assert type(resource) is type(raw_resource)
assert resource._raw is False
assert raw_resource._raw is True
# The client's resource returns decoded results through its raw
# counterpart, which returns whole responses (photon.raw).
assert type(resource._raw_resource) is type(raw_resource)
assert type(resource) is not type(raw_resource)

if asynchronous:

Expand Down
24 changes: 24 additions & 0 deletions packages/python/tests/typing_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Static typing check, run by pyright in CI (not a pytest module).

The client's methods return decoded results; photon.raw returns whole responses.
"""

from typing import assert_type

from photon_api import AsyncPhoton, Photon
from photon_api.generated import models
from photon_api.rpc_generated import ListProjectsInput
from photon_api.transport import RawResponse


def sync_usage(photon: Photon) -> None:
assert_type(photon.account.get(), models.Account)
assert_type(photon.raw.account.get(), RawResponse[models.Account])
request = ListProjectsInput.model_validate({"path": {"organizationId": "o"}})
assert_type(photon.organizations.projects.list(request), models.ProjectPage)
assert_type(photon.auth.list_oauth_scopes().scopes, list[str])


async def async_usage(photon: AsyncPhoton) -> None:
assert_type(await photon.account.get(), models.Account)
assert_type(await photon.raw.account.get(), RawResponse[models.Account])
2 changes: 1 addition & 1 deletion packages/rust/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "photonhq-api"
version = "0.2.0"
version = "0.2.1"
description = "Spargen-generated Photon OpenAPI 3.1 client"
edition.workspace = true
license.workspace = true
Expand Down
2 changes: 1 addition & 1 deletion packages/typescript/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@photon-ai/api",
"version": "0.2.0",
"version": "0.2.1",
"description": "Generated Photon API RPC client for Node.js and browsers",
"license": "MIT",
"type": "module",
Expand Down
32 changes: 18 additions & 14 deletions tools/openapi/src/generate-facades.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,11 @@ test("all convenience methods retain summaries and descriptions safely", () => {
const docstrings = [...python.matchAll(
/^ (?:async )?def get_example\([^\n]+\n([^\n]+)/gm,
)];
assert.deepEqual(docstrings.map((match) => JSON.parse(match[1]!.trim())), [
`${documented.summary}\n\n${documented.description}`,
`${documented.summary}\n\n${documented.description}`,
]);
// Sync and async, each in the client and its raw counterpart (photon.raw).
assert.deepEqual(
docstrings.map((match) => JSON.parse(match[1]!.trim())),
Array(4).fill(`${documented.summary}\n\n${documented.description}`),
);
});

test("legacy manifests without documentation still generate valid method bodies", () => {
Expand Down Expand Up @@ -137,7 +138,8 @@ test("Python binary methods expose bytes while retaining empty response handling
})] });
assert.match(source, /"200": bytes/);
assert.match(source, /"204": None/);
assert.match(source, /bytes \| None \| RawResponse\[bytes \| None\]/);
assert.match(source, /def download\(self[^\n]*\) -> bytes \| None:/);
assert.match(source, /def download\(self[^\n]*\) -> RawResponse\[bytes \| None\]:/);
assert.doesNotMatch(source, /TypeAdapter\(models.DownloadBody\)/);
});

Expand All @@ -154,10 +156,10 @@ test("Python facade validates and returns every successful response model", () =
source.match(/TypeAdapter\(models\.SharedResponse\)/g)?.length,
2,
);
assert.match(
source,
/models\.CompletedResponse \| models\.PendingResponse \| RawResponse\[models\.CompletedResponse \| models\.PendingResponse\]/,
);
// The client returns the decoded result; photon.raw returns the whole response.
assert.match(source, /\) -> models\.CompletedResponse \| models\.PendingResponse:/);
assert.match(source, /\) -> RawResponse\[models\.CompletedResponse \| models\.PendingResponse\]:/);
assert.doesNotMatch(source, /models\.PendingResponse \| RawResponse/);
});

test("TypeScript facade validates each success status against its own component", () => {
Expand Down Expand Up @@ -248,9 +250,10 @@ test("Python facade escapes reserved identifiers while preserving wire names", (
source,
/field_2fa_code: str \| MISSING = Field\(default=MISSING, alias="2fa-code"\)/,
);
assert.equal(source.match(/def async_\(self,/g)?.length, 2);
assert.match(source, /self\.from_ = SyncFromResource\(transport, raw\)/);
assert.match(source, /self\.from_ = AsyncFromResource\(transport, raw\)/);
assert.equal(source.match(/def async_\(self,/g)?.length, 4);
for (const prefix of ["Sync", "SyncRaw", "Async", "AsyncRaw"]) {
assert.match(source, new RegExp(`self\\.from_ = ${prefix}FromResource\\(transport\\)`));
}
});


Expand Down Expand Up @@ -416,8 +419,9 @@ test("Python resources are snake_case while TypeScript keeps camelCase", () => {
nested.namespace = ["projects", "agentProfile"];
const fixture = { operations: [nested] };
const python = renderPython(fixture);
assert.match(python, /self\.agent_profile = SyncProjectsAgentProfileResource\(transport, raw\)/);
assert.match(python, /self\.agent_profile = AsyncProjectsAgentProfileResource\(transport, raw\)/);
for (const prefix of ["Sync", "SyncRaw", "Async", "AsyncRaw"]) {
assert.match(python, new RegExp(`self\\.agent_profile = ${prefix}ProjectsAgentProfileResource\\(transport\\)`));
}
assert.doesNotMatch(python, /self\.agentProfile/);
assert.match(renderTypeScript(fixture), /agentProfile: \{/);
});
Expand Down
67 changes: 41 additions & 26 deletions tools/openapi/src/generate-facades.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,7 @@ function pythonMethod(
operation: ManifestOperation,
asynchronous: boolean,
methodName: string,
raw: boolean,
): string {
const typeName = pascalCase(operation.operationId);
const required =
Expand All @@ -561,19 +562,25 @@ function pythonMethod(
const input = required
? `input: ${typeName}Input`
: `input: ${typeName}Input | None = None`;
const parsedInput = required ? "input" : `(input or ${typeName}Input())`;
const outputType = pythonSuccessType(operation);
const awaitPrefix = asynchronous ? "await " : "";
const asyncPrefix = asynchronous ? "async " : "";
if (!raw) {
// The client's method returns the decoded result; its raw counterpart
// (photon.raw) holds the request and returns the whole response.
return ` ${asyncPrefix}def ${methodName}(self, ${input}) -> ${outputType}:\n` +
pythonDocumentation(operation) +
` return (${awaitPrefix}self._raw_resource.${methodName}(input)).data\n`;
}
const parsedInput = required ? "input" : `(input or ${typeName}Input())`;
const rawRequest = operationMedia(operation).rawRequest;
return ` ${asyncPrefix}def ${methodName}(self, ${input}) -> ${outputType} | RawResponse[${outputType}]:\n` +
return ` ${asyncPrefix}def ${methodName}(self, ${input}) -> RawResponse[${outputType}]:\n` +
pythonDocumentation(operation) +
` payload = ${parsedInput}.model_dump(mode="json", by_alias=True, exclude_unset=True${rawRequest ? ', exclude={"body"}' : ""})\n` +
(rawRequest ? ` payload["body"] = ${parsedInput}.body.root if ${parsedInput}.body is not None else None\n` : "") +
` response = ${awaitPrefix}self._transport.request(\n` +
` return ${awaitPrefix}self._transport.request(\n` +
` _OP_${snakeCase(operation.operationId).toUpperCase()}, payload\n` +
` )\n` +
` return response if self._raw else response.data\n`;
` )\n`;
}

function allNodes(
Expand All @@ -590,27 +597,32 @@ function allNodes(
return result;
}

/** Python class name of a resource; the raw variant returns RawResponse[T]. */
function pythonResourceName(path: string[], asynchronous: boolean, raw: boolean): string {
return `${asynchronous ? "Async" : "Sync"}${raw ? "Raw" : ""}${path.map(pascalCase).join("")}Resource`;
}

function pythonResourceClass(
node: TreeNode,
path: string[],
asynchronous: boolean,
raw: boolean,
): string {
const prefix = asynchronous ? "Async" : "Sync";
const className = `${prefix}${path.map(pascalCase).join("")}Resource`;
const transportType = asynchronous ? "AsyncTransport" : "SyncTransport";
const lines = [
`class ${className}:`,
` def __init__(self, transport: ${transportType}, raw: bool = False) -> None:`,
" self._transport = transport",
" self._raw = raw",
`class ${pythonResourceName(path, asynchronous, raw)}:`,
` def __init__(self, transport: ${transportType}) -> None:`,
raw
? " self._transport = transport"
: ` self._raw_resource = ${pythonResourceName(path, asynchronous, true)}(transport)`,
];
const memberNames = new Set(["__init__", "_raw", "_transport"]);
// Both variants reserve the same names, so members are named identically.
const memberNames = new Set(["__init__", "_raw_resource", "_transport"]);
for (const [name] of [...node.children].sort(([a], [b]) =>
a.localeCompare(b),
)) {
const childClass = `${prefix}${[...path, name].map(pascalCase).join("")}Resource`;
const memberName = allocatePythonIdentifier(name, memberNames, "resource");
lines.push(` self.${memberName} = ${childClass}(transport, raw)`);
lines.push(` self.${memberName} = ${pythonResourceName([...path, name], asynchronous, raw)}(transport)`);
}
for (const operation of node.operations.sort((a, b) =>
a.rpcMethod.localeCompare(b.rpcMethod),
Expand All @@ -622,26 +634,26 @@ function pythonResourceClass(
);
lines.push(
"",
pythonMethod(operation, asynchronous, methodName).trimEnd(),
pythonMethod(operation, asynchronous, methodName, raw).trimEnd(),
);
}
return `${lines.join("\n")}\n`;
}

function pythonRoot(root: TreeNode, asynchronous: boolean): string {
function pythonRoot(root: TreeNode, asynchronous: boolean, raw: boolean): string {
const prefix = asynchronous ? "Async" : "Sync";
const transportType = asynchronous ? "AsyncTransport" : "SyncTransport";
const lines = [
`class ${prefix}Root:`,
` def __init__(self, transport: ${transportType}, raw: bool = False) -> None:`,
`class ${prefix}${raw ? "Raw" : ""}Root:`,
` def __init__(self, transport: ${transportType}) -> None:`,
];
const memberNames = new Set(["__init__"]);
for (const [name] of [...root.children].sort(([a], [b]) =>
a.localeCompare(b),
)) {
const memberName = allocatePythonIdentifier(name, memberNames, "resource");
lines.push(
` self.${memberName} = ${prefix}${pascalCase(name)}Resource(transport, raw)`,
` self.${memberName} = ${pythonResourceName([name], asynchronous, raw)}(transport)`,
);
}
return `${lines.join("\n")}\n`;
Expand All @@ -666,12 +678,11 @@ from .transport import AsyncTransport, OperationSpec, RawResponse, SyncTransport
`;
const inputClasses = operations.map(pythonInputClasses).join("\n");
const constants = operations.map(pythonOperationConstant).join("\n\n");
const syncClasses = nodes
.map(({ node, path }) => pythonResourceClass(node, path, false))
.join("\n");
const asyncClasses = nodes
.map(({ node, path }) => pythonResourceClass(node, path, true))
const classes = (asynchronous: boolean) => [true, false]
.flatMap((raw) => nodes.map(({ node, path }) => pythonResourceClass(node, path, asynchronous, raw)))
.join("\n");
const syncClasses = classes(false);
const asyncClasses = classes(true);
return (
PYTHON_GENERATED_HEADER +
imports +
Expand All @@ -684,9 +695,13 @@ from .transport import AsyncTransport, OperationSpec, RawResponse, SyncTransport
"\n" +
asyncClasses +
"\n" +
pythonRoot(tree, false) +
pythonRoot(tree, false, true) +
"\n" +
pythonRoot(tree, false, false) +
"\n" +
pythonRoot(tree, true, true) +
"\n" +
pythonRoot(tree, true)
pythonRoot(tree, true, false)
);
}

Expand Down
2 changes: 2 additions & 0 deletions tools/python-codegen/requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ referencing==0.37.0
# Gate B reference (tools/conformance/reference.py): ECMA-262 patterns, as
# JSON Schema 2020-12 specifies, so generated values are contract-valid.
regress==2026.9.1
# Static type check of the Python client's public typing (packages/python/tests/typing_check.py).
pyright==1.1.414
Loading