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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ jobs:
with:
python-version: "3.10"
- run: >-
uv run --isolated --resolution lowest-direct --with-editable '.[pty]'
uv run --isolated --resolution lowest-direct --with-editable '.[pty,aiohttp]'
--with pytest --with respx --with trio
pytest -m "not live" -p no:cacheprovider

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ Releases are published automatically on every merge to `main`. Release notes for
- `client.rpc(Definition)`: typed plugin RPC calls and event subscriptions declared with `RpcDefinition`,
`rpc_method` and `rpc_event`; RPC failures raise `RpcCallError` / `RpcCallInternalError`.
- `Stream.on_activity` and `Stream.interrupt()`.
- `client.copy(...)`, an alias of `client.with_options(...)`; documented that copies share the connection pool
and do not close it.
- `OPENCODE_LOG=debug|info` sends the `opencode` logger to stderr at that level.
- `DefaultAioHttpClient`: an optional aiohttp transport for `AsyncOpencode`, installed with the `aiohttp` extra
(`pip install 'opencode-python-sdk[aiohttp]'`) and passed as `http_client=`.
- `.with_raw_response` and `.with_streaming_response` on every resource (sync and async): return an
`APIResponse` / `AsyncAPIResponse` with `headers`, `status_code`, `http_response`, `request`, `text`, `json()`,
`iter_bytes()` / `iter_lines()` and `parse()` for the usual typed value.
- `client.get` / `post` / `put` / `patch` / `delete` for endpoints the client has no method for.

## 0.1.0

Expand Down
88 changes: 87 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,27 @@ async def main() -> None:
asyncio.run(main())
```

### aiohttp transport

For high-concurrency asyncio code you can swap httpx's networking for [aiohttp](https://docs.aiohttp.org). Install
the extra and pass `DefaultAioHttpClient` as `http_client`:

```bash
pip install 'opencode-python-sdk[aiohttp]'
```

```python
from opencode import AsyncOpencode, DefaultAioHttpClient


async def main() -> None:
async with DefaultAioHttpClient() as http, AsyncOpencode("http://127.0.0.1:4096", http_client=http) as client:
info = await client.server.info()
```

Retries, timeouts and event streams work the same. aiohttp runs on asyncio only, not trio. The client does not close
an `http_client` you pass in, so close it yourself as above.

## Calling the API

Methods mirror the official TypeScript client: `client.<group>.<operation>()`, with names in `snake_case`. Path
Expand Down Expand Up @@ -313,6 +334,15 @@ client.session.get("ses_...", timeout=5.0) # per call

The default timeout is 10 minutes with a 10 second connect timeout.

`with_options` (alias `copy`) returns a client with some options changed: `timeout`, `max_retries`, `base_url`,
`password`, `directory`, `default_headers` and `default_query` (headers and query are merged over the current
ones). The copy shares the original's connection pool, and closing it leaves the pool open.

```python
client.with_options(max_retries=0).session.create() # no retries for this call
scoped = client.with_options(directory="/other/repo", default_headers={"X-Trace": "1"})
```

## Running an agent headless

`client.session.run` does the usual "prompt an agent and wait" round trip in one call: it creates a session (or
Expand Down Expand Up @@ -420,7 +450,63 @@ client.session.create(title="t", extra_headers={"X-Trace": "1"}, extra_body={"fu
```

Logs go to the `opencode` logger. `logging.getLogger("opencode").setLevel(logging.DEBUG)` shows every request and
retry; credentials are never logged.
retry; credentials are never logged. Set `OPENCODE_LOG=debug` (or `info`) to have the SDK attach a stderr handler at
that level on import, without touching your own logging config.

### Accessing raw response data

Prefix any method with `.with_raw_response` to get the HTTP response (headers, status, request) instead of the
parsed value. `.parse()` returns what the plain call would have returned:

```python
response = client.session.with_raw_response.get("ses_...")
print(response.status_code, response.headers.get("content-type"))
session = response.parse() # SessionInfo
```

`.with_raw_response` reads the body before returning. `.with_streaming_response` does not: it is a context manager
that sends the request on entry and closes the connection on exit, so you can read large bodies incrementally with
`iter_bytes()`, `iter_text()` or `iter_lines()`, or still call `parse()`:

```python
with client.file.with_streaming_response.read("big.log") as response:
for line in response.iter_lines():
...
```

Both exist on every resource and sub-resource of both clients (`async with` and `await response.parse()` on
`AsyncOpencode`). `APIResponse` also has `.http_response`, `.request`, `.text`, `.json()` and `.read()`.

- **List methods** give the response of the first page; `parse()` returns a `SyncCursorPage` / `AsyncCursorPage`
that fetches the following pages as usual.
- **Event streams** are never read eagerly: `parse()` returns the `Stream` / `AsyncStream`, which reads events as
you iterate. Close the stream (or the response) when done.
- Error statuses still raise `APIStatusError` subclasses; the response is on the exception as `error.response`.
- Hand-written helpers such as `client.session.run()` and `client.pty.attach()` are not wrapped.

### Making custom/undocumented requests

Undocumented parameters go through `extra_headers`, `extra_query` and `extra_body` on every method (see above). For
an endpoint the client has no method for, `client.get`, `post`, `put`, `patch` and `delete` send a request relative
to `base_url` with the client's authentication, retries and typed errors, and return the decoded JSON:

```python
data = client.post("/api/new-endpoint", body={"key": "value"}, query={"directory": "/repo"})

# Validate into a type, e.g. a response wrapped in {"data": ...}:
from opencode.types import SessionInfo

payload = client.get("/api/session/ses_...", cast_to=dict[str, SessionInfo])
```

### Determining the installed version

```python
import opencode

print(opencode.__version__) # this package
print(opencode.API_VERSION) # the OpenCode server version it was generated from
```

## Versioning

Expand Down
68 changes: 67 additions & 1 deletion codegen/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,13 @@ def _render_method(operation: Operation, errors: dict[str, dict[str, Any]], *, i
" )",
]
elif operation.kind == "void":
lines += [f" {awaiting}self._client.request(", *request_options, ' kind="void",', " )"]
# Returned so ``with_raw_response`` can hand back the response; a plain call returns ``None``.
lines += [
f' return cast("None", {awaiting}self._client.request(',
*request_options,
' kind="void",',
" ))",
]
elif operation.kind == "binary":
lines += [
f' return cast("bytes", {awaiting}self._client.request(',
Expand Down Expand Up @@ -412,13 +418,67 @@ def _resource_classes(resource: Resource, errors: dict[str, dict[str, Any]], *,
f' """Operations under ``client.{_attr_path(child)}``."""',
f" return {prefix}{child.class_name}Resource(self._client)\n",
]
for kind, summary in _WRAPPERS:
lines += [
" @cached_property",
f" def {kind}(self) -> {prefix}{resource.class_name}Resource{_WRAPPER_SUFFIX[kind]}:",
f' """{summary}"""',
f" return {prefix}{resource.class_name}Resource{_WRAPPER_SUFFIX[kind]}(self)\n",
]
for operation in resource.operations:
lines += _render_method(operation, errors, is_async=is_async)
lines.append("")
blocks.append("\n".join(lines).rstrip() + "\n")
for kind, _ in _WRAPPERS:
blocks.append(_wrapper_class(resource, kind, is_async=is_async))
return blocks


_WRAPPERS = (
(
"with_raw_response",
"Same methods, returning the raw HTTP response; call ``.parse()`` on it for the usual value.",
),
(
"with_streaming_response",
"Same methods, returning a context manager over the HTTP response without reading the body.",
),
)
_WRAPPER_SUFFIX = {"with_raw_response": "WithRawResponse", "with_streaming_response": "WithStreamingResponse"}
_WRAPPER_FUNCTION = {
("with_raw_response", False): "to_raw_response_wrapper",
("with_streaming_response", False): "to_streamed_response_wrapper",
("with_raw_response", True): "async_to_raw_response_wrapper",
("with_streaming_response", True): "async_to_streamed_response_wrapper",
}


def _wrapper_class(resource: Resource, kind: str, *, is_async: bool) -> str:
prefix = "Async" if is_async else ""
suffix = _WRAPPER_SUFFIX[kind]
name = f"{prefix}{resource.class_name}Resource"
wrap = _WRAPPER_FUNCTION[(kind, is_async)]
lines = [
f"class {name}{suffix}:",
f' """``client.{_attr_path(resource)}.{kind}``."""\n',
f" def __init__(self, resource: {name}) -> None:",
" self._resource = resource",
*[
f" self.{operation.method_name} = {wrap}(resource.{operation.method_name})"
for operation in resource.operations
],
"",
]
for child_name, child in resource.children.items():
lines += [
" @cached_property",
f" def {child_name}(self) -> {prefix}{child.class_name}Resource{suffix}:",
f' """``client.{_attr_path(child)}.{kind}``."""',
f" return {prefix}{child.class_name}Resource{suffix}(self._resource.{child_name})\n",
]
return "\n".join(lines).rstrip() + "\n"


def _attr_path(resource: Resource) -> str:
return ".".join(safe_identifier(snake(part)) for part in resource.path)

Expand Down Expand Up @@ -478,6 +538,12 @@ def render_resource_module(
*[f"from ..{module} import {', '.join(sorted({sync, async_}))}" for module, sync, async_ in _mixins(resource)],
*([f"from .._qs import {', '.join(runtime)}"] if runtime else []),
"from .._resource import AsyncAPIResource, SyncAPIResource",
"from .._response import (",
" async_to_raw_response_wrapper,",
" async_to_streamed_response_wrapper,",
" to_raw_response_wrapper,",
" to_streamed_response_wrapper,",
")",
*(["from .._streaming import AsyncStream, Stream"] if any(op.kind == "stream" for op in operations) else []),
"from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout"
+ (", OpenEnum" if "OpenEnum" in uses else ""),
Expand Down
21 changes: 21 additions & 0 deletions docs/guides/async.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,24 @@ async with await client.event.subscribe() as events: # (3)!
1. `await` the paginator for the first page.
2. `async for` over it for every item across pages.
3. `subscribe()` returns an `AsyncStream` once awaited.

## aiohttp transport

For high-concurrency asyncio code you can swap httpx's networking for [aiohttp](https://docs.aiohttp.org). Install
the extra and pass `DefaultAioHttpClient` as `http_client`:

```bash
pip install 'opencode-python-sdk[aiohttp]'
```

```python
from opencode import AsyncOpencode, DefaultAioHttpClient


async def main() -> None:
async with DefaultAioHttpClient() as http, AsyncOpencode("http://127.0.0.1:4096", http_client=http) as client:
info = await client.server.info()
```

Retries, timeouts and event streams work the same. aiohttp runs on asyncio only, not trio. The client does not close
an `http_client` you pass in, so close it yourself as above.
6 changes: 5 additions & 1 deletion docs/hooks/types_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,11 @@ def resources_page() -> str:
if path.name.startswith("_"):
continue
tree = ast.parse(path.read_text())
classes = [n.name for n in tree.body if isinstance(n, ast.ClassDef) and not n.name.startswith("Async")]
classes = [
n.name
for n in tree.body
if isinstance(n, ast.ClassDef) and not n.name.startswith("Async") and n.name.endswith("Resource")
]
for cls in classes:
lines += [f"::: opencode.resources.{path.stem}.{cls}", " options:", " show_bases: false", ""]
return "\n".join(lines)
Expand Down
9 changes: 7 additions & 2 deletions docs/reference/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@

::: opencode.Opencode
options:
members: [with_options, close, wait_until_ready, fake]
members: [with_options, copy, close, wait_until_ready, fake, get, post, put, patch, delete]
show_bases: false

::: opencode.AsyncOpencode
options:
members: [with_options, close, wait_until_ready, fake]
members: [with_options, copy, close, wait_until_ready, fake, get, post, put, patch, delete]
show_bases: false

::: opencode.DefaultAioHttpClient
options:
members: false
show_bases: false

## Module constants
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Generated from the docstrings in `src/opencode`.
| [Server launcher](server.md) | `OpencodeServer`, `AsyncOpencodeServer` |
| [Background service](service.md) | `opencode.service`, `Endpoint`, `ServiceError` |
| [Resources](resources.md) | Every `client.<group>` and its methods |
| [Pagination and streams](pagination.md) | `SyncCursorPage`, `AsyncPaginator`, `Stream`, `AsyncStream` |
| [Pagination and streams](pagination.md) | `SyncCursorPage`, `AsyncPaginator`, `Stream`, `AsyncStream`, `APIResponse` |
| [Shared events and RPC](events.md) | `EventHub`, `Subscription`, `RpcDefinition`, `RpcClient` |
| [Exceptions](exceptions.md) | Status errors and every declared server error |
| [Types](types/index.md) | The models in `opencode.types`, grouped by domain |
Expand Down
9 changes: 9 additions & 0 deletions docs/reference/pagination.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,12 @@
::: opencode.Stream

::: opencode.AsyncStream

## Raw responses

Returned by `with_raw_response` and `with_streaming_response` on every resource. See
[Accessing raw response data](https://github.com/shrijayan/opencode-python-sdk#accessing-raw-response-data).

::: opencode.APIResponse

::: opencode.AsyncAPIResponse
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,16 @@ Changelog = "https://github.com/shrijayan/opencode-python-sdk/blob/main/CHANGELO
pty = [
"websockets>=14,<17",
]
aiohttp = [
"httpx-aiohttp>=0.1.9,<0.3",
]

[dependency-groups]
dev = [
"mypy>=1.18",
"pyright>=1.1.408",
"pytest>=8.4",
"httpx-aiohttp>=0.1.9",
"pytest-cov>=7",
"respx>=0.22",
"ruff>=0.14",
Expand Down
9 changes: 9 additions & 0 deletions src/opencode/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from typing import TYPE_CHECKING, Any

from . import service
from ._aiohttp import DefaultAioHttpClient
from ._client import AsyncOpencode, Opencode

# <generated:error-imports>
Expand Down Expand Up @@ -75,9 +76,11 @@
ServiceUnavailableError,
UnprocessableEntityError,
)
from ._logs import setup_logging as _setup_logging
from ._models import BaseModel
from ._pagination import AsyncCursorPage, AsyncPaginator, SyncCursorPage
from ._pty import AsyncPtyConnection, PtyConnection, PtyConnectionError
from ._response import APIResponse, AsyncAPIResponse
from ._server import AsyncOpencodeServer, OpencodeServer, ServerStartError
from ._service import Endpoint, ServiceError
from ._streaming import AsyncStream, Stream
Expand Down Expand Up @@ -123,10 +126,12 @@
"API_VERSION",
"NOT_GIVEN",
"APIConnectionError",
"APIResponse",
"APIError",
"APIResponseValidationError",
"APIStatusError",
"APITimeoutError",
"AsyncAPIResponse",
"AsyncCursorPage",
"AsyncOpencode",
"AsyncOpencodeServer",
Expand All @@ -138,6 +143,7 @@
"BaseModel",
"ConflictError",
"ContentTooLargeError",
"DefaultAioHttpClient",
"Endpoint",
"InternalServerError",
"NotFoundError",
Expand Down Expand Up @@ -224,3 +230,6 @@ def __getattr__(name: str) -> Any:

return importlib.import_module(".types", __name__)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


_setup_logging()
Loading
Loading