From fcf96d3c4f3bdbec2cd1b88745cdbbc48e864be2 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 04:41:54 +0000 Subject: [PATCH 01/49] chore(internal): add request options to SSE classes --- src/beeper_desktop_api/_response.py | 3 +++ src/beeper_desktop_api/_streaming.py | 11 ++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/beeper_desktop_api/_response.py b/src/beeper_desktop_api/_response.py index 5d155b7..a7f1bf9 100644 --- a/src/beeper_desktop_api/_response.py +++ b/src/beeper_desktop_api/_response.py @@ -152,6 +152,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: ), response=self.http_response, client=cast(Any, self._client), + options=self._options, ), ) @@ -162,6 +163,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: cast_to=extract_stream_chunk_type(self._stream_cls), response=self.http_response, client=cast(Any, self._client), + options=self._options, ), ) @@ -175,6 +177,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: cast_to=cast_to, response=self.http_response, client=cast(Any, self._client), + options=self._options, ), ) diff --git a/src/beeper_desktop_api/_streaming.py b/src/beeper_desktop_api/_streaming.py index 55409b8..be797cc 100644 --- a/src/beeper_desktop_api/_streaming.py +++ b/src/beeper_desktop_api/_streaming.py @@ -4,7 +4,7 @@ import json import inspect from types import TracebackType -from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, AsyncIterator, cast +from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, Optional, AsyncIterator, cast from typing_extensions import Self, Protocol, TypeGuard, override, get_origin, runtime_checkable import httpx @@ -13,6 +13,7 @@ if TYPE_CHECKING: from ._client import BeeperDesktop, AsyncBeeperDesktop + from ._models import FinalRequestOptions _T = TypeVar("_T") @@ -22,7 +23,7 @@ class Stream(Generic[_T]): """Provides the core interface to iterate over a synchronous stream response.""" response: httpx.Response - + _options: Optional[FinalRequestOptions] = None _decoder: SSEBytesDecoder def __init__( @@ -31,10 +32,12 @@ def __init__( cast_to: type[_T], response: httpx.Response, client: BeeperDesktop, + options: Optional[FinalRequestOptions] = None, ) -> None: self.response = response self._cast_to = cast_to self._client = client + self._options = options self._decoder = client._make_sse_decoder() self._iterator = self.__stream__() @@ -85,7 +88,7 @@ class AsyncStream(Generic[_T]): """Provides the core interface to iterate over an asynchronous stream response.""" response: httpx.Response - + _options: Optional[FinalRequestOptions] = None _decoder: SSEDecoder | SSEBytesDecoder def __init__( @@ -94,10 +97,12 @@ def __init__( cast_to: type[_T], response: httpx.Response, client: AsyncBeeperDesktop, + options: Optional[FinalRequestOptions] = None, ) -> None: self.response = response self._cast_to = cast_to self._client = client + self._options = options self._decoder = client._make_sse_decoder() self._iterator = self.__stream__() From 2420dd3d3de95350f142acaf7fb923fd292af59e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 04:53:34 +0000 Subject: [PATCH 02/49] chore(internal): make `test_proxy_environment_variables` more resilient --- tests/test_client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index 5cbca10..aa68ad7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -984,6 +984,8 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + # Delete in case our environment has this set + monkeypatch.delenv("HTTP_PROXY", raising=False) client = DefaultHttpxClient() @@ -1916,6 +1918,8 @@ async def test_get_platform(self) -> None: async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + # Delete in case our environment has this set + monkeypatch.delenv("HTTP_PROXY", raising=False) client = DefaultAsyncHttpxClient() From a54d51a23c31d38e124dc263f748f6ada2f2409c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:38:28 +0000 Subject: [PATCH 03/49] chore: configure new SDK language --- .stats.yml | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.stats.yml b/.stats.yml index 56c368e..eea9217 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-4acef56b00be513f305543096fdd407e6947f0a5ad268ab2e627ff30b37a75db.yml openapi_spec_hash: e876d796b6c25f18577f6be3944bf7d9 -config_hash: 659111d4e28efa599b5f800619ed79c2 +config_hash: 66617ffb2c7b6ef016e9704e766e7f65 diff --git a/README.md b/README.md index b42ad75..cb71054 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ and offers both synchronous and asynchronous clients powered by [httpx](https:// Use the Beeper Desktop MCP Server to enable AI assistants to interact with this API, allowing them to explore endpoints, make test requests, and use documentation to help integrate this SDK into your application. -[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=%40beeper%2Fdesktop-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1tY3AiXSwiZW52Ijp7IkJFRVBFUl9BQ0NFU1NfVE9LRU4iOiJNeSBBY2Nlc3MgVG9rZW4ifX0) -[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22%40beeper%2Fdesktop-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40beeper%2Fdesktop-mcp%22%5D%2C%22env%22%3A%7B%22BEEPER_ACCESS_TOKEN%22%3A%22My%20Access%20Token%22%7D%7D) +[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=%40beeper%2Fdesktop-api-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1hcGktbWNwIl0sImVudiI6eyJCRUVQRVJfQUNDRVNTX1RPS0VOIjoiTXkgQWNjZXNzIFRva2VuIn19) +[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22%40beeper%2Fdesktop-api-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40beeper%2Fdesktop-api-mcp%22%5D%2C%22env%22%3A%7B%22BEEPER_ACCESS_TOKEN%22%3A%22My%20Access%20Token%22%7D%7D) > Note: You may need to set environment variables in your MCP client. From 770a8e2a6fc4d96dae58b3b787d55072faf63e34 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:39:39 +0000 Subject: [PATCH 04/49] feat(api): api update --- .stats.yml | 6 +++--- README.md | 18 ++++-------------- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/.stats.yml b/.stats.yml index eea9217..6e96390 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-4acef56b00be513f305543096fdd407e6947f0a5ad268ab2e627ff30b37a75db.yml -openapi_spec_hash: e876d796b6c25f18577f6be3944bf7d9 -config_hash: 66617ffb2c7b6ef016e9704e766e7f65 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml +openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a +config_hash: 659111d4e28efa599b5f800619ed79c2 diff --git a/README.md b/README.md index cb71054..15ac23a 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ and offers both synchronous and asynchronous clients powered by [httpx](https:// Use the Beeper Desktop MCP Server to enable AI assistants to interact with this API, allowing them to explore endpoints, make test requests, and use documentation to help integrate this SDK into your application. -[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=%40beeper%2Fdesktop-api-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1hcGktbWNwIl0sImVudiI6eyJCRUVQRVJfQUNDRVNTX1RPS0VOIjoiTXkgQWNjZXNzIFRva2VuIn19) -[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22%40beeper%2Fdesktop-api-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40beeper%2Fdesktop-api-mcp%22%5D%2C%22env%22%3A%7B%22BEEPER_ACCESS_TOKEN%22%3A%22My%20Access%20Token%22%7D%7D) +[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=%40beeper%2Fdesktop-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1tY3AiXSwiZW52Ijp7IkJFRVBFUl9BQ0NFU1NfVE9LRU4iOiJNeSBBY2Nlc3MgVG9rZW4ifX0) +[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22%40beeper%2Fdesktop-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40beeper%2Fdesktop-mcp%22%5D%2C%22env%22%3A%7B%22BEEPER_ACCESS_TOKEN%22%3A%22My%20Access%20Token%22%7D%7D) > Note: You may need to set environment variables in your MCP client. @@ -35,12 +35,9 @@ pip install git+ssh://git@github.com/beeper/desktop-api-python.git The full API of this library can be found in [api.md](api.md). ```python -import os from beeper_desktop_api import BeeperDesktop -client = BeeperDesktop( - access_token=os.environ.get("BEEPER_ACCESS_TOKEN"), # This is the default and can be omitted -) +client = BeeperDesktop() page = client.chats.search( include_muted=True, @@ -60,13 +57,10 @@ so that your Access Token is not stored in source control. Simply import `AsyncBeeperDesktop` instead of `BeeperDesktop` and use `await` with each API call: ```python -import os import asyncio from beeper_desktop_api import AsyncBeeperDesktop -client = AsyncBeeperDesktop( - access_token=os.environ.get("BEEPER_ACCESS_TOKEN"), # This is the default and can be omitted -) +client = AsyncBeeperDesktop() async def main() -> None: @@ -97,7 +91,6 @@ pip install 'beeper_desktop_api[aiohttp] @ git+ssh://git@github.com/beeper/deskt Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: ```python -import os import asyncio from beeper_desktop_api import DefaultAioHttpClient from beeper_desktop_api import AsyncBeeperDesktop @@ -105,9 +98,6 @@ from beeper_desktop_api import AsyncBeeperDesktop async def main() -> None: async with AsyncBeeperDesktop( - access_token=os.environ.get( - "BEEPER_ACCESS_TOKEN" - ), # This is the default and can be omitted http_client=DefaultAioHttpClient(), ) as client: page = await client.chats.search( From 8b9d76c76fe4e3ae99d85429f20d9b782bea2520 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:41:55 +0000 Subject: [PATCH 05/49] chore: configure new SDK language --- .stats.yml | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.stats.yml b/.stats.yml index 6e96390..ea5e4be 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: 659111d4e28efa599b5f800619ed79c2 +config_hash: 66617ffb2c7b6ef016e9704e766e7f65 diff --git a/README.md b/README.md index 15ac23a..40c528f 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ and offers both synchronous and asynchronous clients powered by [httpx](https:// Use the Beeper Desktop MCP Server to enable AI assistants to interact with this API, allowing them to explore endpoints, make test requests, and use documentation to help integrate this SDK into your application. -[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=%40beeper%2Fdesktop-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1tY3AiXSwiZW52Ijp7IkJFRVBFUl9BQ0NFU1NfVE9LRU4iOiJNeSBBY2Nlc3MgVG9rZW4ifX0) -[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22%40beeper%2Fdesktop-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40beeper%2Fdesktop-mcp%22%5D%2C%22env%22%3A%7B%22BEEPER_ACCESS_TOKEN%22%3A%22My%20Access%20Token%22%7D%7D) +[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=%40beeper%2Fdesktop-api-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1hcGktbWNwIl0sImVudiI6eyJCRUVQRVJfQUNDRVNTX1RPS0VOIjoiTXkgQWNjZXNzIFRva2VuIn19) +[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22%40beeper%2Fdesktop-api-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40beeper%2Fdesktop-api-mcp%22%5D%2C%22env%22%3A%7B%22BEEPER_ACCESS_TOKEN%22%3A%22My%20Access%20Token%22%7D%7D) > Note: You may need to set environment variables in your MCP client. From 266e1afc38d192d9412a4d295727fdc16d74235d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:58:36 +0000 Subject: [PATCH 06/49] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index ea5e4be..7c03a30 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: 66617ffb2c7b6ef016e9704e766e7f65 +config_hash: 2f5c2448fc8eec47bb412de39beb09dc From 5df69bf22554340ee0fd0c694fb755c80907ee22 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:59:54 +0000 Subject: [PATCH 07/49] chore: update SDK settings --- .stats.yml | 2 +- README.md | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.stats.yml b/.stats.yml index 7c03a30..004aab8 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: 2f5c2448fc8eec47bb412de39beb09dc +config_hash: aa49273410d42fb96c5515dbce1f182f diff --git a/README.md b/README.md index 40c528f..b82d06c 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,10 @@ The REST API documentation can be found on [developers.beeper.com](https://devel ## Installation ```sh -# install from the production repo -pip install git+ssh://git@github.com/beeper/desktop-api-python.git +# install from PyPI +pip install beeper_desktop_api ``` -> [!NOTE] -> Once this package is [published to PyPI](https://www.stainless.com/docs/guides/publish), this will become: `pip install beeper_desktop_api` - ## Usage The full API of this library can be found in [api.md](api.md). @@ -84,8 +81,8 @@ By default, the async client uses `httpx` for HTTP requests. However, for improv You can enable this by installing `aiohttp`: ```sh -# install from the production repo -pip install 'beeper_desktop_api[aiohttp] @ git+ssh://git@github.com/beeper/desktop-api-python.git' +# install from PyPI +pip install beeper_desktop_api[aiohttp] ``` Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: From 1ad2ddfe678d2a495d69e45d7a1a8f0856af4211 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 04:25:14 +0000 Subject: [PATCH 08/49] chore(internal): make `test_proxy_environment_variables` more resilient to env --- tests/test_client.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index aa68ad7..a687487 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -984,8 +984,14 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly monkeypatch.setenv("HTTPS_PROXY", "https://example.org") - # Delete in case our environment has this set + # Delete in case our environment has any proxy env vars set monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) client = DefaultHttpxClient() @@ -1918,8 +1924,14 @@ async def test_get_platform(self) -> None: async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly monkeypatch.setenv("HTTPS_PROXY", "https://example.org") - # Delete in case our environment has this set + # Delete in case our environment has any proxy env vars set monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) client = DefaultAsyncHttpxClient() From a6b8aac8430c698cd1a73bab2cd257c9cf553df6 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 05:47:58 +0000 Subject: [PATCH 09/49] chore(internal): codegen related update --- src/beeper_desktop_api/_client.py | 6 ++++++ src/beeper_desktop_api/resources/info.py | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py index 2dc0bf9..2f1306e 100644 --- a/src/beeper_desktop_api/_client.py +++ b/src/beeper_desktop_api/_client.py @@ -154,6 +154,7 @@ def assets(self) -> AssetsResource: @cached_property def info(self) -> InfoResource: + """Control the Beeper Desktop application""" from .resources.info import InfoResource return InfoResource(self) @@ -448,6 +449,7 @@ def assets(self) -> AsyncAssetsResource: @cached_property def info(self) -> AsyncInfoResource: + """Control the Beeper Desktop application""" from .resources.info import AsyncInfoResource return AsyncInfoResource(self) @@ -700,6 +702,7 @@ def assets(self) -> assets.AssetsResourceWithRawResponse: @cached_property def info(self) -> info.InfoResourceWithRawResponse: + """Control the Beeper Desktop application""" from .resources.info import InfoResourceWithRawResponse return InfoResourceWithRawResponse(self._client.info) @@ -748,6 +751,7 @@ def assets(self) -> assets.AsyncAssetsResourceWithRawResponse: @cached_property def info(self) -> info.AsyncInfoResourceWithRawResponse: + """Control the Beeper Desktop application""" from .resources.info import AsyncInfoResourceWithRawResponse return AsyncInfoResourceWithRawResponse(self._client.info) @@ -796,6 +800,7 @@ def assets(self) -> assets.AssetsResourceWithStreamingResponse: @cached_property def info(self) -> info.InfoResourceWithStreamingResponse: + """Control the Beeper Desktop application""" from .resources.info import InfoResourceWithStreamingResponse return InfoResourceWithStreamingResponse(self._client.info) @@ -844,6 +849,7 @@ def assets(self) -> assets.AsyncAssetsResourceWithStreamingResponse: @cached_property def info(self) -> info.AsyncInfoResourceWithStreamingResponse: + """Control the Beeper Desktop application""" from .resources.info import AsyncInfoResourceWithStreamingResponse return AsyncInfoResourceWithStreamingResponse(self._client.info) diff --git a/src/beeper_desktop_api/resources/info.py b/src/beeper_desktop_api/resources/info.py index 43a98bf..9b6bc94 100644 --- a/src/beeper_desktop_api/resources/info.py +++ b/src/beeper_desktop_api/resources/info.py @@ -20,6 +20,8 @@ class InfoResource(SyncAPIResource): + """Control the Beeper Desktop application""" + @cached_property def with_raw_response(self) -> InfoResourceWithRawResponse: """ @@ -63,6 +65,8 @@ def retrieve( class AsyncInfoResource(AsyncAPIResource): + """Control the Beeper Desktop application""" + @cached_property def with_raw_response(self) -> AsyncInfoResourceWithRawResponse: """ From 352dc26df496dac34b88c8d410a3d5761fad7cde Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:02:00 +0000 Subject: [PATCH 10/49] chore(test): do not count install time for mock server timeout --- scripts/mock | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/mock b/scripts/mock index 0b28f6e..bcf3b39 100755 --- a/scripts/mock +++ b/scripts/mock @@ -21,11 +21,22 @@ echo "==> Starting mock server with URL ${URL}" # Run prism mock on the given spec if [ "$1" == "--daemon" ]; then + # Pre-install the package so the download doesn't eat into the startup timeout + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism --version + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & - # Wait for server to come online + # Wait for server to come online (max 30s) echo -n "Waiting for server" + attempts=0 while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 300 ]; then + echo + echo "Timed out waiting for Prism server to start" + cat .prism.log + exit 1 + fi echo -n "." sleep 0.1 done From 3f5692eb199bd02db1359e8131c8774eee7fabcf Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:08:09 +0000 Subject: [PATCH 11/49] chore(ci): skip uploading artifacts on stainless-internal branches --- .github/workflows/ci.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e7de04..99e51ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,14 +61,18 @@ jobs: run: rye build - name: Get GitHub OIDC Token - if: github.repository == 'stainless-sdks/beeper-desktop-api-python' + if: |- + github.repository == 'stainless-sdks/beeper-desktop-api-python' && + !startsWith(github.ref, 'refs/heads/stl/') id: github-oidc uses: actions/github-script@v8 with: script: core.setOutput('github_token', await core.getIDToken()); - name: Upload tarball - if: github.repository == 'stainless-sdks/beeper-desktop-api-python' + if: |- + github.repository == 'stainless-sdks/beeper-desktop-api-python' && + !startsWith(github.ref, 'refs/heads/stl/') env: URL: https://pkg.stainless.com/s AUTH: ${{ steps.github-oidc.outputs.github_token }} From f9883db325ccb453c60affa4218f2b257c4d41d8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:08:50 +0000 Subject: [PATCH 12/49] chore: update placeholder string --- tests/api_resources/test_assets.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/api_resources/test_assets.py b/tests/api_resources/test_assets.py index 64927ac..16d9ffa 100644 --- a/tests/api_resources/test_assets.py +++ b/tests/api_resources/test_assets.py @@ -86,14 +86,14 @@ def test_streaming_response_serve(self, client: BeeperDesktop) -> None: @parametrize def test_method_upload(self, client: BeeperDesktop) -> None: asset = client.assets.upload( - file=b"raw file contents", + file=b"Example data", ) assert_matches_type(AssetUploadResponse, asset, path=["response"]) @parametrize def test_method_upload_with_all_params(self, client: BeeperDesktop) -> None: asset = client.assets.upload( - file=b"raw file contents", + file=b"Example data", file_name="fileName", mime_type="mimeType", ) @@ -102,7 +102,7 @@ def test_method_upload_with_all_params(self, client: BeeperDesktop) -> None: @parametrize def test_raw_response_upload(self, client: BeeperDesktop) -> None: response = client.assets.with_raw_response.upload( - file=b"raw file contents", + file=b"Example data", ) assert response.is_closed is True @@ -113,7 +113,7 @@ def test_raw_response_upload(self, client: BeeperDesktop) -> None: @parametrize def test_streaming_response_upload(self, client: BeeperDesktop) -> None: with client.assets.with_streaming_response.upload( - file=b"raw file contents", + file=b"Example data", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -234,14 +234,14 @@ async def test_streaming_response_serve(self, async_client: AsyncBeeperDesktop) @parametrize async def test_method_upload(self, async_client: AsyncBeeperDesktop) -> None: asset = await async_client.assets.upload( - file=b"raw file contents", + file=b"Example data", ) assert_matches_type(AssetUploadResponse, asset, path=["response"]) @parametrize async def test_method_upload_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: asset = await async_client.assets.upload( - file=b"raw file contents", + file=b"Example data", file_name="fileName", mime_type="mimeType", ) @@ -250,7 +250,7 @@ async def test_method_upload_with_all_params(self, async_client: AsyncBeeperDesk @parametrize async def test_raw_response_upload(self, async_client: AsyncBeeperDesktop) -> None: response = await async_client.assets.with_raw_response.upload( - file=b"raw file contents", + file=b"Example data", ) assert response.is_closed is True @@ -261,7 +261,7 @@ async def test_raw_response_upload(self, async_client: AsyncBeeperDesktop) -> No @parametrize async def test_streaming_response_upload(self, async_client: AsyncBeeperDesktop) -> None: async with async_client.assets.with_streaming_response.upload( - file=b"raw file contents", + file=b"Example data", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" From 0d6781765c31c396a4cf0b0e89b61dd816645941 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 01:45:30 +0000 Subject: [PATCH 13/49] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 004aab8..06ba3c3 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: aa49273410d42fb96c5515dbce1f182f +config_hash: bfb432c69dc0a8d273043a3cdd87ffe1 From c84dca576d56b83e314ab798749607e70aea7223 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:22:00 +0000 Subject: [PATCH 14/49] feat(api): manual updates --- .stats.yml | 2 +- README.md | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.stats.yml b/.stats.yml index 06ba3c3..5dbc3d6 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: bfb432c69dc0a8d273043a3cdd87ffe1 +config_hash: 6fc4359a793fc3fc9ac01712b5ef8c0d diff --git a/README.md b/README.md index b82d06c..7972750 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,14 @@ The Beeper Desktop Python library provides convenient access to the Beeper Deskt application. The library includes type definitions for all request params and response fields, and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). +It is generated with [Stainless](https://www.stainless.com/). + ## MCP Server Use the Beeper Desktop MCP Server to enable AI assistants to interact with this API, allowing them to explore endpoints, make test requests, and use documentation to help integrate this SDK into your application. -[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=%40beeper%2Fdesktop-api-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1hcGktbWNwIl0sImVudiI6eyJCRUVQRVJfQUNDRVNTX1RPS0VOIjoiTXkgQWNjZXNzIFRva2VuIn19) -[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22%40beeper%2Fdesktop-api-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40beeper%2Fdesktop-api-mcp%22%5D%2C%22env%22%3A%7B%22BEEPER_ACCESS_TOKEN%22%3A%22My%20Access%20Token%22%7D%7D) +[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=%40beeper%2Fdesktop-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1tY3AiXSwiZW52Ijp7IkJFRVBFUl9BQ0NFU1NfVE9LRU4iOiJNeSBBY2Nlc3MgVG9rZW4ifX0) +[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22%40beeper%2Fdesktop-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40beeper%2Fdesktop-mcp%22%5D%2C%22env%22%3A%7B%22BEEPER_ACCESS_TOKEN%22%3A%22My%20Access%20Token%22%7D%7D) > Note: You may need to set environment variables in your MCP client. @@ -23,10 +25,13 @@ The REST API documentation can be found on [developers.beeper.com](https://devel ## Installation ```sh -# install from PyPI -pip install beeper_desktop_api +# install from the production repo +pip install git+ssh://git@github.com/beeper/desktop-api-python.git ``` +> [!NOTE] +> Once this package is [published to PyPI](https://www.stainless.com/docs/guides/publish), this will become: `pip install beeper_desktop_api` + ## Usage The full API of this library can be found in [api.md](api.md). @@ -81,8 +86,8 @@ By default, the async client uses `httpx` for HTTP requests. However, for improv You can enable this by installing `aiohttp`: ```sh -# install from PyPI -pip install beeper_desktop_api[aiohttp] +# install from the production repo +pip install 'beeper_desktop_api[aiohttp] @ git+ssh://git@github.com/beeper/desktop-api-python.git' ``` Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: From bbfc0badc6259db8d6d448ef828cf9ac484d1667 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:36:12 +0000 Subject: [PATCH 15/49] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 5dbc3d6..2b39be6 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: 6fc4359a793fc3fc9ac01712b5ef8c0d +config_hash: ca148af6be59ec54295b2c5f852a38d1 From 8b9fe85df1911bc10a65b5c965e5465c4041e065 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 03:15:09 +0000 Subject: [PATCH 16/49] fix(pydantic): do not pass `by_alias` unless set --- src/beeper_desktop_api/_compat.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/beeper_desktop_api/_compat.py b/src/beeper_desktop_api/_compat.py index 786ff42..e6690a4 100644 --- a/src/beeper_desktop_api/_compat.py +++ b/src/beeper_desktop_api/_compat.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast, overload from datetime import date, datetime -from typing_extensions import Self, Literal +from typing_extensions import Self, Literal, TypedDict import pydantic from pydantic.fields import FieldInfo @@ -131,6 +131,10 @@ def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str: return model.model_dump_json(indent=indent) +class _ModelDumpKwargs(TypedDict, total=False): + by_alias: bool + + def model_dump( model: pydantic.BaseModel, *, @@ -142,6 +146,9 @@ def model_dump( by_alias: bool | None = None, ) -> dict[str, Any]: if (not PYDANTIC_V1) or hasattr(model, "model_dump"): + kwargs: _ModelDumpKwargs = {} + if by_alias is not None: + kwargs["by_alias"] = by_alias return model.model_dump( mode=mode, exclude=exclude, @@ -149,7 +156,7 @@ def model_dump( exclude_defaults=exclude_defaults, # warnings are not supported in Pydantic v1 warnings=True if PYDANTIC_V1 else warnings, - by_alias=by_alias, + **kwargs, ) return cast( "dict[str, Any]", From 922d90aeb6d75306490a359d26ebbf71a6e340b8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 03:33:27 +0000 Subject: [PATCH 17/49] fix(deps): bump minimum typing-extensions version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 089b317..3f8161a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = [ dependencies = [ "httpx>=0.23.0, <1", "pydantic>=1.9.0, <3", - "typing-extensions>=4.10, <5", + "typing-extensions>=4.14, <5", "anyio>=3.5.0, <5", "distro>=1.7.0, <2", "sniffio", From 311a998617de99c1defaa4c54f1b8a308d1bfaf3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 03:38:25 +0000 Subject: [PATCH 18/49] chore(internal): tweak CI branches --- .github/workflows/ci.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99e51ac..afb122d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,14 @@ name: CI on: push: - branches-ignore: - - 'generated' - - 'codegen/**' - - 'integrated/**' - - 'stl-preview-head/**' - - 'stl-preview-base/**' + branches: + - '**' + - '!integrated/**' + - '!stl-preview-head/**' + - '!stl-preview-base/**' + - '!generated' + - '!codegen/**' + - 'codegen/stl/**' pull_request: branches-ignore: - 'stl-preview-head/**' From 900c955edf1d5f8cf7aa9c7d8a7859e5b61ae379 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 02:08:03 +0000 Subject: [PATCH 19/49] fix: sanitize endpoint path params --- src/beeper_desktop_api/_utils/__init__.py | 1 + src/beeper_desktop_api/_utils/_path.py | 127 ++++++++++++++++++ .../resources/accounts/contacts.py | 10 +- .../resources/chats/chats.py | 10 +- .../resources/chats/messages/reactions.py | 18 ++- .../resources/chats/reminders.py | 10 +- src/beeper_desktop_api/resources/messages.py | 14 +- tests/test_utils/test_path.py | 89 ++++++++++++ 8 files changed, 252 insertions(+), 27 deletions(-) create mode 100644 src/beeper_desktop_api/_utils/_path.py create mode 100644 tests/test_utils/test_path.py diff --git a/src/beeper_desktop_api/_utils/__init__.py b/src/beeper_desktop_api/_utils/__init__.py index dc64e29..10cb66d 100644 --- a/src/beeper_desktop_api/_utils/__init__.py +++ b/src/beeper_desktop_api/_utils/__init__.py @@ -1,3 +1,4 @@ +from ._path import path_template as path_template from ._sync import asyncify as asyncify from ._proxy import LazyProxy as LazyProxy from ._utils import ( diff --git a/src/beeper_desktop_api/_utils/_path.py b/src/beeper_desktop_api/_utils/_path.py new file mode 100644 index 0000000..4d6e1e4 --- /dev/null +++ b/src/beeper_desktop_api/_utils/_path.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import re +from typing import ( + Any, + Mapping, + Callable, +) +from urllib.parse import quote + +# Matches '.' or '..' where each dot is either literal or percent-encoded (%2e / %2E). +_DOT_SEGMENT_RE = re.compile(r"^(?:\.|%2[eE]){1,2}$") + +_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}") + + +def _quote_path_segment_part(value: str) -> str: + """Percent-encode `value` for use in a URI path segment. + + Considers characters not in `pchar` set from RFC 3986 §3.3 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.3 + """ + # quote() already treats unreserved characters (letters, digits, and -._~) + # as safe, so we only need to add sub-delims, ':', and '@'. + # Notably, unlike the default `safe` for quote(), / is unsafe and must be quoted. + return quote(value, safe="!$&'()*+,;=:@") + + +def _quote_query_part(value: str) -> str: + """Percent-encode `value` for use in a URI query string. + + Considers &, = and characters not in `query` set from RFC 3986 §3.4 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.4 + """ + return quote(value, safe="!$'()*+,;:@/?") + + +def _quote_fragment_part(value: str) -> str: + """Percent-encode `value` for use in a URI fragment. + + Considers characters not in `fragment` set from RFC 3986 §3.5 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.5 + """ + return quote(value, safe="!$&'()*+,;=:@/?") + + +def _interpolate( + template: str, + values: Mapping[str, Any], + quoter: Callable[[str], str], +) -> str: + """Replace {name} placeholders in `template`, quoting each value with `quoter`. + + Placeholder names are looked up in `values`. + + Raises: + KeyError: If a placeholder is not found in `values`. + """ + # re.split with a capturing group returns alternating + # [text, name, text, name, ..., text] elements. + parts = _PLACEHOLDER_RE.split(template) + + for i in range(1, len(parts), 2): + name = parts[i] + if name not in values: + raise KeyError(f"a value for placeholder {{{name}}} was not provided") + val = values[name] + if val is None: + parts[i] = "null" + elif isinstance(val, bool): + parts[i] = "true" if val else "false" + else: + parts[i] = quoter(str(values[name])) + + return "".join(parts) + + +def path_template(template: str, /, **kwargs: Any) -> str: + """Interpolate {name} placeholders in `template` from keyword arguments. + + Args: + template: The template string containing {name} placeholders. + **kwargs: Keyword arguments to interpolate into the template. + + Returns: + The template with placeholders interpolated and percent-encoded. + + Safe characters for percent-encoding are dependent on the URI component. + Placeholders in path and fragment portions are percent-encoded where the `segment` + and `fragment` sets from RFC 3986 respectively are considered safe. + Placeholders in the query portion are percent-encoded where the `query` set from + RFC 3986 §3.3 is considered safe except for = and & characters. + + Raises: + KeyError: If a placeholder is not found in `kwargs`. + ValueError: If resulting path contains /./ or /../ segments (including percent-encoded dot-segments). + """ + # Split the template into path, query, and fragment portions. + fragment_template: str | None = None + query_template: str | None = None + + rest = template + if "#" in rest: + rest, fragment_template = rest.split("#", 1) + if "?" in rest: + rest, query_template = rest.split("?", 1) + path_template = rest + + # Interpolate each portion with the appropriate quoting rules. + path_result = _interpolate(path_template, kwargs, _quote_path_segment_part) + + # Reject dot-segments (. and ..) in the final assembled path. The check + # runs after interpolation so that adjacent placeholders or a mix of static + # text and placeholders that together form a dot-segment are caught. + # Also reject percent-encoded dot-segments to protect against incorrectly + # implemented normalization in servers/proxies. + for segment in path_result.split("/"): + if _DOT_SEGMENT_RE.match(segment): + raise ValueError(f"Constructed path {path_result!r} contains dot-segment {segment!r} which is not allowed") + + result = path_result + if query_template is not None: + result += "?" + _interpolate(query_template, kwargs, _quote_query_part) + if fragment_template is not None: + result += "#" + _interpolate(fragment_template, kwargs, _quote_fragment_part) + + return result diff --git a/src/beeper_desktop_api/resources/accounts/contacts.py b/src/beeper_desktop_api/resources/accounts/contacts.py index 02749f1..ba704bb 100644 --- a/src/beeper_desktop_api/resources/accounts/contacts.py +++ b/src/beeper_desktop_api/resources/accounts/contacts.py @@ -7,7 +7,7 @@ import httpx from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from ..._utils import maybe_transform, async_maybe_transform +from ..._utils import path_template, maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -88,7 +88,7 @@ def list( if not account_id: raise ValueError(f"Expected a non-empty value for `account_id` but received {account_id!r}") return self._get_api_list( - f"/v1/accounts/{account_id}/contacts/list", + path_template("/v1/accounts/{account_id}/contacts/list", account_id=account_id), page=SyncCursorSearch[User], options=make_request_options( extra_headers=extra_headers, @@ -140,7 +140,7 @@ def search( if not account_id: raise ValueError(f"Expected a non-empty value for `account_id` but received {account_id!r}") return self._get( - f"/v1/accounts/{account_id}/contacts", + path_template("/v1/accounts/{account_id}/contacts", account_id=account_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -215,7 +215,7 @@ def list( if not account_id: raise ValueError(f"Expected a non-empty value for `account_id` but received {account_id!r}") return self._get_api_list( - f"/v1/accounts/{account_id}/contacts/list", + path_template("/v1/accounts/{account_id}/contacts/list", account_id=account_id), page=AsyncCursorSearch[User], options=make_request_options( extra_headers=extra_headers, @@ -267,7 +267,7 @@ async def search( if not account_id: raise ValueError(f"Expected a non-empty value for `account_id` but received {account_id!r}") return await self._get( - f"/v1/accounts/{account_id}/contacts", + path_template("/v1/accounts/{account_id}/contacts", account_id=account_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py index 6a3cdb0..b72d252 100644 --- a/src/beeper_desktop_api/resources/chats/chats.py +++ b/src/beeper_desktop_api/resources/chats/chats.py @@ -10,7 +10,7 @@ from ...types import chat_list_params, chat_create_params, chat_search_params, chat_archive_params, chat_retrieve_params from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, SequenceNotStr, omit, not_given -from ..._utils import maybe_transform, async_maybe_transform +from ..._utils import path_template, maybe_transform, async_maybe_transform from ..._compat import cached_property from .reminders import ( RemindersResource, @@ -180,7 +180,7 @@ def retrieve( if not chat_id: raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") return self._get( - f"/v1/chats/{chat_id}", + path_template("/v1/chats/{chat_id}", chat_id=chat_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -281,7 +281,7 @@ def archive( raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") extra_headers = {"Accept": "*/*", **(extra_headers or {})} return self._post( - f"/v1/chats/{chat_id}/archive", + path_template("/v1/chats/{chat_id}/archive", chat_id=chat_id), body=maybe_transform({"archived": archived}, chat_archive_params.ChatArchiveParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -523,7 +523,7 @@ async def retrieve( if not chat_id: raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") return await self._get( - f"/v1/chats/{chat_id}", + path_template("/v1/chats/{chat_id}", chat_id=chat_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -624,7 +624,7 @@ async def archive( raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") extra_headers = {"Accept": "*/*", **(extra_headers or {})} return await self._post( - f"/v1/chats/{chat_id}/archive", + path_template("/v1/chats/{chat_id}/archive", chat_id=chat_id), body=await async_maybe_transform({"archived": archived}, chat_archive_params.ChatArchiveParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout diff --git a/src/beeper_desktop_api/resources/chats/messages/reactions.py b/src/beeper_desktop_api/resources/chats/messages/reactions.py index d9e610d..13a855d 100644 --- a/src/beeper_desktop_api/resources/chats/messages/reactions.py +++ b/src/beeper_desktop_api/resources/chats/messages/reactions.py @@ -5,7 +5,7 @@ import httpx from ...._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from ...._utils import maybe_transform, async_maybe_transform +from ...._utils import path_template, maybe_transform, async_maybe_transform from ...._compat import cached_property from ...._resource import SyncAPIResource, AsyncAPIResource from ...._response import ( @@ -78,7 +78,9 @@ def delete( if not message_id: raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") return self._delete( - f"/v1/chats/{chat_id}/messages/{message_id}/reactions", + path_template( + "/v1/chats/{chat_id}/messages/{message_id}/reactions", chat_id=chat_id, message_id=message_id + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -126,7 +128,9 @@ def add( if not message_id: raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") return self._post( - f"/v1/chats/{chat_id}/messages/{message_id}/reactions", + path_template( + "/v1/chats/{chat_id}/messages/{message_id}/reactions", chat_id=chat_id, message_id=message_id + ), body=maybe_transform( { "reaction_key": reaction_key, @@ -197,7 +201,9 @@ async def delete( if not message_id: raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") return await self._delete( - f"/v1/chats/{chat_id}/messages/{message_id}/reactions", + path_template( + "/v1/chats/{chat_id}/messages/{message_id}/reactions", chat_id=chat_id, message_id=message_id + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -247,7 +253,9 @@ async def add( if not message_id: raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") return await self._post( - f"/v1/chats/{chat_id}/messages/{message_id}/reactions", + path_template( + "/v1/chats/{chat_id}/messages/{message_id}/reactions", chat_id=chat_id, message_id=message_id + ), body=await async_maybe_transform( { "reaction_key": reaction_key, diff --git a/src/beeper_desktop_api/resources/chats/reminders.py b/src/beeper_desktop_api/resources/chats/reminders.py index 2096903..32a169b 100644 --- a/src/beeper_desktop_api/resources/chats/reminders.py +++ b/src/beeper_desktop_api/resources/chats/reminders.py @@ -5,7 +5,7 @@ import httpx from ..._types import Body, Query, Headers, NoneType, NotGiven, not_given -from ..._utils import maybe_transform, async_maybe_transform +from ..._utils import path_template, maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -74,7 +74,7 @@ def create( raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") extra_headers = {"Accept": "*/*", **(extra_headers or {})} return self._post( - f"/v1/chats/{chat_id}/reminders", + path_template("/v1/chats/{chat_id}/reminders", chat_id=chat_id), body=maybe_transform({"reminder": reminder}, reminder_create_params.ReminderCreateParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -111,7 +111,7 @@ def delete( raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") extra_headers = {"Accept": "*/*", **(extra_headers or {})} return self._delete( - f"/v1/chats/{chat_id}/reminders", + path_template("/v1/chats/{chat_id}/reminders", chat_id=chat_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -173,7 +173,7 @@ async def create( raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") extra_headers = {"Accept": "*/*", **(extra_headers or {})} return await self._post( - f"/v1/chats/{chat_id}/reminders", + path_template("/v1/chats/{chat_id}/reminders", chat_id=chat_id), body=await async_maybe_transform({"reminder": reminder}, reminder_create_params.ReminderCreateParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -210,7 +210,7 @@ async def delete( raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") extra_headers = {"Accept": "*/*", **(extra_headers or {})} return await self._delete( - f"/v1/chats/{chat_id}/reminders", + path_template("/v1/chats/{chat_id}/reminders", chat_id=chat_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py index b97c7a0..af2178e 100644 --- a/src/beeper_desktop_api/resources/messages.py +++ b/src/beeper_desktop_api/resources/messages.py @@ -10,7 +10,7 @@ from ..types import message_list_params, message_send_params, message_search_params, message_update_params from .._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given -from .._utils import maybe_transform, async_maybe_transform +from .._utils import path_template, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -86,7 +86,7 @@ def update( if not message_id: raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") return self._put( - f"/v1/chats/{chat_id}/messages/{message_id}", + path_template("/v1/chats/{chat_id}/messages/{message_id}", chat_id=chat_id, message_id=message_id), body=maybe_transform({"text": text}, message_update_params.MessageUpdateParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -130,7 +130,7 @@ def list( if not chat_id: raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") return self._get_api_list( - f"/v1/chats/{chat_id}/messages", + path_template("/v1/chats/{chat_id}/messages", chat_id=chat_id), page=SyncCursorSortKey[Message], options=make_request_options( extra_headers=extra_headers, @@ -288,7 +288,7 @@ def send( if not chat_id: raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") return self._post( - f"/v1/chats/{chat_id}/messages", + path_template("/v1/chats/{chat_id}/messages", chat_id=chat_id), body=maybe_transform( { "attachment": attachment, @@ -362,7 +362,7 @@ async def update( if not message_id: raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") return await self._put( - f"/v1/chats/{chat_id}/messages/{message_id}", + path_template("/v1/chats/{chat_id}/messages/{message_id}", chat_id=chat_id, message_id=message_id), body=await async_maybe_transform({"text": text}, message_update_params.MessageUpdateParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout @@ -406,7 +406,7 @@ def list( if not chat_id: raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") return self._get_api_list( - f"/v1/chats/{chat_id}/messages", + path_template("/v1/chats/{chat_id}/messages", chat_id=chat_id), page=AsyncCursorSortKey[Message], options=make_request_options( extra_headers=extra_headers, @@ -564,7 +564,7 @@ async def send( if not chat_id: raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") return await self._post( - f"/v1/chats/{chat_id}/messages", + path_template("/v1/chats/{chat_id}/messages", chat_id=chat_id), body=await async_maybe_transform( { "attachment": attachment, diff --git a/tests/test_utils/test_path.py b/tests/test_utils/test_path.py new file mode 100644 index 0000000..d429db8 --- /dev/null +++ b/tests/test_utils/test_path.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from beeper_desktop_api._utils._path import path_template + + +@pytest.mark.parametrize( + "template, kwargs, expected", + [ + ("/v1/{id}", dict(id="abc"), "/v1/abc"), + ("/v1/{a}/{b}", dict(a="x", b="y"), "/v1/x/y"), + ("/v1/{a}{b}/path/{c}?val={d}#{e}", dict(a="x", b="y", c="z", d="u", e="v"), "/v1/xy/path/z?val=u#v"), + ("/{w}/{w}", dict(w="echo"), "/echo/echo"), + ("/v1/static", {}, "/v1/static"), + ("", {}, ""), + ("/v1/?q={n}&count=10", dict(n=42), "/v1/?q=42&count=10"), + ("/v1/{v}", dict(v=None), "/v1/null"), + ("/v1/{v}", dict(v=True), "/v1/true"), + ("/v1/{v}", dict(v=False), "/v1/false"), + ("/v1/{v}", dict(v=".hidden"), "/v1/.hidden"), # dot prefix ok + ("/v1/{v}", dict(v="file.txt"), "/v1/file.txt"), # dot in middle ok + ("/v1/{v}", dict(v="..."), "/v1/..."), # triple dot ok + ("/v1/{a}{b}", dict(a=".", b="txt"), "/v1/.txt"), # dot var combining with adjacent to be ok + ("/items?q={v}#{f}", dict(v=".", f=".."), "/items?q=.#.."), # dots in query/fragment are fine + ( + "/v1/{a}?query={b}", + dict(a="../../other/endpoint", b="a&bad=true"), + "/v1/..%2F..%2Fother%2Fendpoint?query=a%26bad%3Dtrue", + ), + ("/v1/{val}", dict(val="a/b/c"), "/v1/a%2Fb%2Fc"), + ("/v1/{val}", dict(val="a/b/c?query=value"), "/v1/a%2Fb%2Fc%3Fquery=value"), + ("/v1/{val}", dict(val="a/b/c?query=value&bad=true"), "/v1/a%2Fb%2Fc%3Fquery=value&bad=true"), + ("/v1/{val}", dict(val="%20"), "/v1/%2520"), # escapes escape sequences in input + # Query: slash and ? are safe, # is not + ("/items?q={v}", dict(v="a/b"), "/items?q=a/b"), + ("/items?q={v}", dict(v="a?b"), "/items?q=a?b"), + ("/items?q={v}", dict(v="a#b"), "/items?q=a%23b"), + ("/items?q={v}", dict(v="a b"), "/items?q=a%20b"), + # Fragment: slash and ? are safe + ("/docs#{v}", dict(v="a/b"), "/docs#a/b"), + ("/docs#{v}", dict(v="a?b"), "/docs#a?b"), + # Path: slash, ? and # are all encoded + ("/v1/{v}", dict(v="a/b"), "/v1/a%2Fb"), + ("/v1/{v}", dict(v="a?b"), "/v1/a%3Fb"), + ("/v1/{v}", dict(v="a#b"), "/v1/a%23b"), + # same var encoded differently by component + ( + "/v1/{v}?q={v}#{v}", + dict(v="a/b?c#d"), + "/v1/a%2Fb%3Fc%23d?q=a/b?c%23d#a/b?c%23d", + ), + ("/v1/{val}", dict(val="x?admin=true"), "/v1/x%3Fadmin=true"), # query injection + ("/v1/{val}", dict(val="x#admin"), "/v1/x%23admin"), # fragment injection + ], +) +def test_interpolation(template: str, kwargs: dict[str, Any], expected: str) -> None: + assert path_template(template, **kwargs) == expected + + +def test_missing_kwarg_raises_key_error() -> None: + with pytest.raises(KeyError, match="org_id"): + path_template("/v1/{org_id}") + + +@pytest.mark.parametrize( + "template, kwargs", + [ + ("{a}/path", dict(a=".")), + ("{a}/path", dict(a="..")), + ("/v1/{a}", dict(a=".")), + ("/v1/{a}", dict(a="..")), + ("/v1/{a}/path", dict(a=".")), + ("/v1/{a}/path", dict(a="..")), + ("/v1/{a}{b}", dict(a=".", b=".")), # adjacent vars → ".." + ("/v1/{a}.", dict(a=".")), # var + static → ".." + ("/v1/{a}{b}", dict(a="", b=".")), # empty + dot → "." + ("/v1/%2e/{x}", dict(x="ok")), # encoded dot in static text + ("/v1/%2e./{x}", dict(x="ok")), # mixed encoded ".." in static + ("/v1/.%2E/{x}", dict(x="ok")), # mixed encoded ".." in static + ("/v1/{v}?q=1", dict(v="..")), + ("/v1/{v}#frag", dict(v="..")), + ], +) +def test_dot_segment_rejected(template: str, kwargs: dict[str, Any]) -> None: + with pytest.raises(ValueError, match="dot-segment"): + path_template(template, **kwargs) From ef99778f642f49a26aad1d65c59df9f9cfa766e9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 02:09:41 +0000 Subject: [PATCH 20/49] refactor(tests): switch from prism to steady --- CONTRIBUTING.md | 2 +- scripts/mock | 26 +++++++++++++------------- scripts/test | 16 ++++++++-------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08c3ec2..f303ab9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,7 +85,7 @@ $ pip install ./path-to-wheel-file.whl ## Running tests -Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests. +Most tests require you to [set up a mock server](https://github.com/dgellow/steady) against the OpenAPI spec to run the tests. ```sh $ ./scripts/mock diff --git a/scripts/mock b/scripts/mock index bcf3b39..00b490b 100755 --- a/scripts/mock +++ b/scripts/mock @@ -19,34 +19,34 @@ fi echo "==> Starting mock server with URL ${URL}" -# Run prism mock on the given spec +# Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism --version + npm exec --package=@stdy/cli@0.19.3 -- steady --version - npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & + npm exec --package=@stdy/cli@0.19.3 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-query-object-format=brackets "$URL" &> .stdy.log & - # Wait for server to come online (max 30s) + # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" attempts=0 - while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + while ! curl --silent --fail "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1; do + if ! kill -0 $! 2>/dev/null; then + echo + cat .stdy.log + exit 1 + fi attempts=$((attempts + 1)) if [ "$attempts" -ge 300 ]; then echo - echo "Timed out waiting for Prism server to start" - cat .prism.log + echo "Timed out waiting for Steady server to start" + cat .stdy.log exit 1 fi echo -n "." sleep 0.1 done - if grep -q "✖ fatal" ".prism.log"; then - cat .prism.log - exit 1 - fi - echo else - npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" + npm exec --package=@stdy/cli@0.19.3 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-query-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index dbeda2d..d0fe9be 100755 --- a/scripts/test +++ b/scripts/test @@ -9,8 +9,8 @@ GREEN='\033[0;32m' YELLOW='\033[0;33m' NC='\033[0m' # No Color -function prism_is_running() { - curl --silent "http://localhost:4010" >/dev/null 2>&1 +function steady_is_running() { + curl --silent "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1 } kill_server_on_port() { @@ -25,7 +25,7 @@ function is_overriding_api_base_url() { [ -n "$TEST_API_BASE_URL" ] } -if ! is_overriding_api_base_url && ! prism_is_running ; then +if ! is_overriding_api_base_url && ! steady_is_running ; then # When we exit this script, make sure to kill the background mock server process trap 'kill_server_on_port 4010' EXIT @@ -36,19 +36,19 @@ fi if is_overriding_api_base_url ; then echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}" echo -elif ! prism_is_running ; then - echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Prism server" +elif ! steady_is_running ; then + echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Steady server" echo -e "running against your OpenAPI spec." echo echo -e "To run the server, pass in the path or url of your OpenAPI" - echo -e "spec to the prism command:" + echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.3 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-query-object-format=brackets${NC}" echo exit 1 else - echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}" + echo -e "${GREEN}✔ Mock steady server is running with your OpenAPI spec${NC}" echo fi From 68f14afb85f168eb61a91398165b1d8222fbeea4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 02:11:00 +0000 Subject: [PATCH 21/49] chore(tests): bump steady to v0.19.4 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 00b490b..f310477 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.19.3 -- steady --version + npm exec --package=@stdy/cli@0.19.4 -- steady --version - npm exec --package=@stdy/cli@0.19.3 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-query-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.19.4 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.19.3 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-query-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.19.4 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index d0fe9be..0c2bfad 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.3 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-query-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.4 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" echo exit 1 From 9229d32b59f8494f49677dbf508d2fedd21cd8b4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 02:18:23 +0000 Subject: [PATCH 22/49] chore(tests): bump steady to v0.19.5 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index f310477..54fc791 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.19.4 -- steady --version + npm exec --package=@stdy/cli@0.19.5 -- steady --version - npm exec --package=@stdy/cli@0.19.4 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.19.5 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.19.4 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.19.5 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 0c2bfad..4153738 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.4 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.5 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" echo exit 1 From 3dfb3799f6ebbde6400db092864361e3b6a6e07f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 02:09:33 +0000 Subject: [PATCH 23/49] chore(internal): update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 95ceb18..3824f4c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .prism.log +.stdy.log _dev __pycache__ From 166b069fbd3034b27d16baf50ef96c61a46996d9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 02:16:24 +0000 Subject: [PATCH 24/49] chore(tests): bump steady to v0.19.6 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 54fc791..0f82c95 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.19.5 -- steady --version + npm exec --package=@stdy/cli@0.19.6 -- steady --version - npm exec --package=@stdy/cli@0.19.5 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.19.6 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.19.5 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.19.6 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 4153738..4f9eef9 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.5 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.6 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" echo exit 1 From 1fe013eacfb814508b88eefa3b2ee3bf51618edc Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:05:07 +0000 Subject: [PATCH 25/49] chore(ci): skip lint on metadata-only changes Note that we still want to run tests, as these depend on the metadata. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afb122d..1ca0ca2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: timeout-minutes: 10 name: lint runs-on: ${{ github.repository == 'stainless-sdks/beeper-desktop-api-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') steps: - uses: actions/checkout@v6 @@ -38,7 +38,7 @@ jobs: run: ./scripts/lint build: - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') timeout-minutes: 10 name: build permissions: From 31a8e58a09bd798b9930c195da2be239d50a2e77 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:05:53 +0000 Subject: [PATCH 26/49] chore(tests): bump steady to v0.19.7 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 0f82c95..3732f8e 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.19.6 -- steady --version + npm exec --package=@stdy/cli@0.19.7 -- steady --version - npm exec --package=@stdy/cli@0.19.6 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.19.6 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 4f9eef9..e642cea 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.6 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.7 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" echo exit 1 From b954521c09743ea77dc1fe3f49e62cadb9cb6b1f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:25:03 +0000 Subject: [PATCH 27/49] chore: update SDK settings --- .stats.yml | 2 +- README.md | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.stats.yml b/.stats.yml index 2b39be6..60bb453 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: ca148af6be59ec54295b2c5f852a38d1 +config_hash: e342a96262eaf44c54e8bbb93cc8d7a7 diff --git a/README.md b/README.md index 7972750..b0c6f12 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,10 @@ The REST API documentation can be found on [developers.beeper.com](https://devel ## Installation ```sh -# install from the production repo -pip install git+ssh://git@github.com/beeper/desktop-api-python.git +# install from PyPI +pip install beeper_desktop_api ``` -> [!NOTE] -> Once this package is [published to PyPI](https://www.stainless.com/docs/guides/publish), this will become: `pip install beeper_desktop_api` - ## Usage The full API of this library can be found in [api.md](api.md). @@ -86,8 +83,8 @@ By default, the async client uses `httpx` for HTTP requests. However, for improv You can enable this by installing `aiohttp`: ```sh -# install from the production repo -pip install 'beeper_desktop_api[aiohttp] @ git+ssh://git@github.com/beeper/desktop-api-python.git' +# install from PyPI +pip install beeper_desktop_api[aiohttp] ``` Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: From fc973f48f6dce9712ce7da0c588ed9fee2e6348e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:25:37 +0000 Subject: [PATCH 28/49] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 60bb453..16d5bba 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: e342a96262eaf44c54e8bbb93cc8d7a7 +config_hash: f99f904573839260bdb6d428bad17613 From daf5390c2b456877ee06b154d9567af708bb0057 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:26:20 +0000 Subject: [PATCH 29/49] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 16d5bba..2c47924 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: f99f904573839260bdb6d428bad17613 +config_hash: 7d85c0b454fc78a59db6474c5c4d73c6 From de85c3aef481f44350bab667ed81e155573ace81 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 02:09:32 +0000 Subject: [PATCH 30/49] feat(internal): implement indices array format for query and form serialization --- scripts/mock | 4 ++-- scripts/test | 2 +- src/beeper_desktop_api/_qs.py | 5 ++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 3732f8e..58e4628 100755 --- a/scripts/mock +++ b/scripts/mock @@ -24,7 +24,7 @@ if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout npm exec --package=@stdy/cli@0.19.7 -- steady --version - npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index e642cea..3970464 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.7 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.7 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 diff --git a/src/beeper_desktop_api/_qs.py b/src/beeper_desktop_api/_qs.py index ada6fd3..de8c99b 100644 --- a/src/beeper_desktop_api/_qs.py +++ b/src/beeper_desktop_api/_qs.py @@ -101,7 +101,10 @@ def _stringify_item( items.extend(self._stringify_item(key, item, opts)) return items elif array_format == "indices": - raise NotImplementedError("The array indices format is not supported yet") + items = [] + for i, item in enumerate(value): + items.extend(self._stringify_item(f"{key}[{i}]", item, opts)) + return items elif array_format == "brackets": items = [] key = key + "[]" From d2cf119042313a4b82f4a395a43c175874ceca1e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 02:17:51 +0000 Subject: [PATCH 31/49] chore(tests): bump steady to v0.20.1 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 58e4628..5ea72a2 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.19.7 -- steady --version + npm exec --package=@stdy/cli@0.20.1 -- steady --version - npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.20.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.20.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 3970464..3fdac80 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.7 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 From 0def55c17787849b27d1e8c21cb6cd129069e220 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 02:22:29 +0000 Subject: [PATCH 32/49] chore(tests): bump steady to v0.20.2 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 5ea72a2..7c58865 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.20.1 -- steady --version + npm exec --package=@stdy/cli@0.20.2 -- steady --version - npm exec --package=@stdy/cli@0.20.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.20.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 3fdac80..87cdeac 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.2 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 From 9e86464960e28472bc3a4f137c5d2c025f2acc16 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 02:10:53 +0000 Subject: [PATCH 33/49] fix(client): preserve hardcoded query params when merging with user params --- src/beeper_desktop_api/_base_client.py | 4 +++ tests/test_client.py | 48 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/beeper_desktop_api/_base_client.py b/src/beeper_desktop_api/_base_client.py index 25424b1..4e62b4b 100644 --- a/src/beeper_desktop_api/_base_client.py +++ b/src/beeper_desktop_api/_base_client.py @@ -540,6 +540,10 @@ def _build_request( files = cast(HttpxRequestFiles, ForceMultipartDict()) prepared_url = self._prepare_url(options.url) + # preserve hard-coded query params from the url + if params and prepared_url.query: + params = {**dict(prepared_url.params.items()), **params} + prepared_url = prepared_url.copy_with(raw_path=prepared_url.raw_path.split(b"?", 1)[0]) if "_" in prepared_url.host: # work around https://github.com/encode/httpx/discussions/2880 kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} diff --git a/tests/test_client.py b/tests/test_client.py index a687487..0e4b49b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -443,6 +443,30 @@ def test_default_query_option(self) -> None: client.close() + def test_hardcoded_query_params_in_url(self, client: BeeperDesktop) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo?beta=true", + params={"limit": "10", "page": "abc"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/files/a%2Fb?beta=true", + params={"limit": "10"}, + ) + ) + assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10" + def test_request_extra_json(self, client: BeeperDesktop) -> None: request = client._build_request( FinalRequestOptions( @@ -1366,6 +1390,30 @@ async def test_default_query_option(self) -> None: await client.close() + async def test_hardcoded_query_params_in_url(self, async_client: AsyncBeeperDesktop) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true"} + + request = async_client._build_request( + FinalRequestOptions( + method="get", + url="/foo?beta=true", + params={"limit": "10", "page": "abc"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} + + request = async_client._build_request( + FinalRequestOptions( + method="get", + url="/files/a%2Fb?beta=true", + params={"limit": "10"}, + ) + ) + assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10" + def test_request_extra_json(self, client: BeeperDesktop) -> None: request = client._build_request( FinalRequestOptions( From 69f6d11ecb0959d1a5eb90c41c76542a1ea5826f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 02:30:02 +0000 Subject: [PATCH 34/49] fix: ensure file data are only sent as 1 parameter --- src/beeper_desktop_api/_utils/_utils.py | 5 +++-- tests/test_extract_files.py | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/beeper_desktop_api/_utils/_utils.py b/src/beeper_desktop_api/_utils/_utils.py index eec7f4a..63b8cd6 100644 --- a/src/beeper_desktop_api/_utils/_utils.py +++ b/src/beeper_desktop_api/_utils/_utils.py @@ -86,8 +86,9 @@ def _extract_items( index += 1 if is_dict(obj): try: - # We are at the last entry in the path so we must remove the field - if (len(path)) == index: + # Remove the field if there are no more dict keys in the path, + # only "" traversal markers or end. + if all(p == "" for p in path[index:]): item = obj.pop(key) else: item = obj[key] diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py index 497fb79..7c7c5dd 100644 --- a/tests/test_extract_files.py +++ b/tests/test_extract_files.py @@ -35,6 +35,15 @@ def test_multiple_files() -> None: assert query == {"documents": [{}, {}]} +def test_top_level_file_array() -> None: + query = {"files": [b"file one", b"file two"], "title": "hello"} + assert extract_files(query, paths=[["files", ""]]) == [ + ("files[]", b"file one"), + ("files[]", b"file two"), + ] + assert query == {"title": "hello"} + + @pytest.mark.parametrize( "query,paths,expected", [ From af70fc9fab45036721b4be634bb4444964c70d1e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:31:50 +0000 Subject: [PATCH 35/49] feat(api): add network, bridge fields to accounts --- .stats.yml | 6 +- README.md | 34 +++++--- .../resources/chats/chats.py | 86 +------------------ src/beeper_desktop_api/types/account.py | 23 ++++- .../types/chat_create_params.py | 83 ++++++++++-------- tests/api_resources/test_chats.py | 74 +++++++--------- 6 files changed, 127 insertions(+), 179 deletions(-) diff --git a/.stats.yml b/.stats.yml index 2c47924..229f6b5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml -openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: 7d85c0b454fc78a59db6474c5c4d73c6 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-611aa7641fbca8cf31d626bf86f9efd3c2b92778e897ebbb25c6ea44185ed1ed.yml +openapi_spec_hash: d6c0a1776048dab04f6c5625c9893c9c +config_hash: 39ed0717b5f415499aaace2468346e1a diff --git a/README.md b/README.md index b0c6f12..c0c9be9 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,6 @@ The Beeper Desktop Python library provides convenient access to the Beeper Deskt application. The library includes type definitions for all request params and response fields, and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). -It is generated with [Stainless](https://www.stainless.com/). - ## MCP Server Use the Beeper Desktop MCP Server to enable AI assistants to interact with this API, allowing them to explore endpoints, make test requests, and use documentation to help integrate this SDK into your application. @@ -25,18 +23,24 @@ The REST API documentation can be found on [developers.beeper.com](https://devel ## Installation ```sh -# install from PyPI -pip install beeper_desktop_api +# install from the production repo +pip install git+ssh://git@github.com/beeper/desktop-api-python.git ``` +> [!NOTE] +> Once this package is [published to PyPI](https://www.stainless.com/docs/guides/publish), this will become: `pip install beeper_desktop_api` + ## Usage The full API of this library can be found in [api.md](api.md). ```python +import os from beeper_desktop_api import BeeperDesktop -client = BeeperDesktop() +client = BeeperDesktop( + access_token=os.environ.get("BEEPER_ACCESS_TOKEN"), # This is the default and can be omitted +) page = client.chats.search( include_muted=True, @@ -56,10 +60,13 @@ so that your Access Token is not stored in source control. Simply import `AsyncBeeperDesktop` instead of `BeeperDesktop` and use `await` with each API call: ```python +import os import asyncio from beeper_desktop_api import AsyncBeeperDesktop -client = AsyncBeeperDesktop() +client = AsyncBeeperDesktop( + access_token=os.environ.get("BEEPER_ACCESS_TOKEN"), # This is the default and can be omitted +) async def main() -> None: @@ -83,13 +90,14 @@ By default, the async client uses `httpx` for HTTP requests. However, for improv You can enable this by installing `aiohttp`: ```sh -# install from PyPI -pip install beeper_desktop_api[aiohttp] +# install from the production repo +pip install 'beeper_desktop_api[aiohttp] @ git+ssh://git@github.com/beeper/desktop-api-python.git' ``` Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: ```python +import os import asyncio from beeper_desktop_api import DefaultAioHttpClient from beeper_desktop_api import AsyncBeeperDesktop @@ -97,6 +105,9 @@ from beeper_desktop_api import AsyncBeeperDesktop async def main() -> None: async with AsyncBeeperDesktop( + access_token=os.environ.get( + "BEEPER_ACCESS_TOKEN" + ), # This is the default and can be omitted http_client=DefaultAioHttpClient(), ) as client: page = await client.chats.search( @@ -207,11 +218,10 @@ from beeper_desktop_api import BeeperDesktop client = BeeperDesktop() -chat = client.chats.create( - account_id="accountID", - user={}, +client.chats.reminders.create( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + reminder={"remind_at_ms": 0}, ) -print(chat.user) ``` ## File uploads diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py index b72d252..2a6a92c 100644 --- a/src/beeper_desktop_api/resources/chats/chats.py +++ b/src/beeper_desktop_api/resources/chats/chats.py @@ -79,14 +79,7 @@ def with_streaming_response(self) -> ChatsResourceWithStreamingResponse: def create( self, *, - account_id: str, - allow_invite: bool | Omit = omit, - message_text: str | Omit = omit, - mode: Literal["create", "start"] | Omit = omit, - participant_ids: SequenceNotStr[str] | Omit = omit, - title: str | Omit = omit, - type: Literal["single", "group"] | Omit = omit, - user: chat_create_params.User | Omit = omit, + params: chat_create_params.Params | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -99,26 +92,6 @@ def create( user data (mode='start'). Args: - account_id: Account to create or start the chat on. - - allow_invite: Whether invite-based DM creation is allowed when required by the platform. Used - for mode='start'. - - message_text: Optional first message content if the platform requires it to create the chat. - - mode: Operation mode. Defaults to 'create' when omitted. - - participant_ids: Required when mode='create'. User IDs to include in the new chat. - - title: Optional title for group chats when mode='create'; ignored for single chats on - most platforms. - - type: Required when mode='create'. 'single' requires exactly one participantID; - 'group' supports multiple participants and optional title. - - user: Required when mode='start'. Merged user-like contact payload used to resolve the - best identifier. - extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -129,19 +102,7 @@ def create( """ return self._post( "/v1/chats", - body=maybe_transform( - { - "account_id": account_id, - "allow_invite": allow_invite, - "message_text": message_text, - "mode": mode, - "participant_ids": participant_ids, - "title": title, - "type": type, - "user": user, - }, - chat_create_params.ChatCreateParams, - ), + body=maybe_transform(params, chat_create_params.ChatCreateParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -422,14 +383,7 @@ def with_streaming_response(self) -> AsyncChatsResourceWithStreamingResponse: async def create( self, *, - account_id: str, - allow_invite: bool | Omit = omit, - message_text: str | Omit = omit, - mode: Literal["create", "start"] | Omit = omit, - participant_ids: SequenceNotStr[str] | Omit = omit, - title: str | Omit = omit, - type: Literal["single", "group"] | Omit = omit, - user: chat_create_params.User | Omit = omit, + params: chat_create_params.Params | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -442,26 +396,6 @@ async def create( user data (mode='start'). Args: - account_id: Account to create or start the chat on. - - allow_invite: Whether invite-based DM creation is allowed when required by the platform. Used - for mode='start'. - - message_text: Optional first message content if the platform requires it to create the chat. - - mode: Operation mode. Defaults to 'create' when omitted. - - participant_ids: Required when mode='create'. User IDs to include in the new chat. - - title: Optional title for group chats when mode='create'; ignored for single chats on - most platforms. - - type: Required when mode='create'. 'single' requires exactly one participantID; - 'group' supports multiple participants and optional title. - - user: Required when mode='start'. Merged user-like contact payload used to resolve the - best identifier. - extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -472,19 +406,7 @@ async def create( """ return await self._post( "/v1/chats", - body=await async_maybe_transform( - { - "account_id": account_id, - "allow_invite": allow_invite, - "message_text": message_text, - "mode": mode, - "participant_ids": participant_ids, - "title": title, - "type": type, - "user": user, - }, - chat_create_params.ChatCreateParams, - ), + body=await async_maybe_transform(params, chat_create_params.ChatCreateParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/beeper_desktop_api/types/account.py b/src/beeper_desktop_api/types/account.py index ff00c78..c024419 100644 --- a/src/beeper_desktop_api/types/account.py +++ b/src/beeper_desktop_api/types/account.py @@ -1,11 +1,26 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +from typing_extensions import Literal + from pydantic import Field as FieldInfo from .._models import BaseModel from .shared.user import User -__all__ = ["Account"] +__all__ = ["Account", "Bridge"] + + +class Bridge(BaseModel): + """Bridge metadata for the account. Available from Beeper Desktop v.4.2.719+.""" + + id: str + """Bridge instance identifier.""" + + provider: Literal["cloud", "self-hosted", "local", "platform-sdk"] + """Bridge provider for the account.""" + + type: str + """Bridge type.""" class Account(BaseModel): @@ -14,5 +29,11 @@ class Account(BaseModel): account_id: str = FieldInfo(alias="accountID") """Chat account added to Beeper. Use this to route account-scoped actions.""" + bridge: Bridge + """Bridge metadata for the account. Available from Beeper Desktop v.4.2.719+.""" + + network: str + """Human-friendly network name for the account.""" + user: User """User the account belongs to.""" diff --git a/src/beeper_desktop_api/types/chat_create_params.py b/src/beeper_desktop_api/types/chat_create_params.py index 93229c1..f755b70 100644 --- a/src/beeper_desktop_api/types/chat_create_params.py +++ b/src/beeper_desktop_api/types/chat_create_params.py @@ -2,18 +2,48 @@ from __future__ import annotations -from typing_extensions import Literal, Required, Annotated, TypedDict +from typing import Union +from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict from .._types import SequenceNotStr from .._utils import PropertyInfo -__all__ = ["ChatCreateParams", "User"] +__all__ = ["ChatCreateParams", "Params", "ParamsUnionMember0", "ParamsUnionMember0User", "ParamsUnionMember1"] class ChatCreateParams(TypedDict, total=False): + params: Params + + +class ParamsUnionMember0User(TypedDict, total=False): + """Merged user-like contact payload used to resolve the best identifier.""" + + id: str + """Known user ID when available.""" + + email: str + """Email candidate.""" + + full_name: Annotated[str, PropertyInfo(alias="fullName")] + """Display name hint used for ranking only.""" + + phone_number: Annotated[str, PropertyInfo(alias="phoneNumber")] + """Phone number candidate (E.164 preferred).""" + + username: str + """Username/handle candidate.""" + + +class ParamsUnionMember0(TypedDict, total=False): account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] """Account to create or start the chat on.""" + mode: Required[Literal["start"]] + """Operation mode. Use 'start' to resolve a user/contact and start a direct chat.""" + + user: Required[ParamsUnionMember0User] + """Merged user-like contact payload used to resolve the best identifier.""" + allow_invite: Annotated[bool, PropertyInfo(alias="allowInvite")] """Whether invite-based DM creation is allowed when required by the platform. @@ -23,49 +53,28 @@ class ChatCreateParams(TypedDict, total=False): message_text: Annotated[str, PropertyInfo(alias="messageText")] """Optional first message content if the platform requires it to create the chat.""" - mode: Literal["create", "start"] - """Operation mode. Defaults to 'create' when omitted.""" - - participant_ids: Annotated[SequenceNotStr[str], PropertyInfo(alias="participantIDs")] - """Required when mode='create'. User IDs to include in the new chat.""" - title: str - """ - Optional title for group chats when mode='create'; ignored for single chats on - most platforms. - """ +class ParamsUnionMember1(TypedDict, total=False): + account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] + """Account to create or start the chat on.""" - type: Literal["single", "group"] - """Required when mode='create'. + participant_ids: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="participantIDs")]] + """User IDs to include in the new chat.""" + type: Required[Literal["single", "group"]] + """ 'single' requires exactly one participantID; 'group' supports multiple participants and optional title. """ - user: User - """Required when mode='start'. - - Merged user-like contact payload used to resolve the best identifier. - """ - - -class User(TypedDict, total=False): - """Required when mode='start'. - - Merged user-like contact payload used to resolve the best identifier. - """ - - id: str - """Known user ID when available.""" + message_text: Annotated[str, PropertyInfo(alias="messageText")] + """Optional first message content if the platform requires it to create the chat.""" - email: str - """Email candidate.""" + mode: Literal["create"] + """Operation mode. Defaults to 'create' when omitted.""" - full_name: Annotated[str, PropertyInfo(alias="fullName")] - """Display name hint used for ranking only.""" + title: str + """Optional title for group chats; ignored for single chats on most platforms.""" - phone_number: Annotated[str, PropertyInfo(alias="phoneNumber")] - """Phone number candidate (E.164 preferred).""" - username: str - """Username/handle candidate.""" +Params: TypeAlias = Union[ParamsUnionMember0, ParamsUnionMember1] diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py index b899add..10de8b2 100644 --- a/tests/api_resources/test_chats.py +++ b/tests/api_resources/test_chats.py @@ -25,36 +25,31 @@ class TestChats: @parametrize def test_method_create(self, client: BeeperDesktop) -> None: - chat = client.chats.create( - account_id="accountID", - ) + chat = client.chats.create() assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: chat = client.chats.create( - account_id="accountID", - allow_invite=True, - message_text="messageText", - mode="create", - participant_ids=["string"], - title="title", - type="single", - user={ - "id": "id", - "email": "email", - "full_name": "fullName", - "phone_number": "phoneNumber", - "username": "username", + params={ + "account_id": "accountID", + "mode": "start", + "user": { + "id": "id", + "email": "email", + "full_name": "fullName", + "phone_number": "phoneNumber", + "username": "username", + }, + "allow_invite": True, + "message_text": "messageText", }, ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize def test_raw_response_create(self, client: BeeperDesktop) -> None: - response = client.chats.with_raw_response.create( - account_id="accountID", - ) + response = client.chats.with_raw_response.create() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -63,9 +58,7 @@ def test_raw_response_create(self, client: BeeperDesktop) -> None: @parametrize def test_streaming_response_create(self, client: BeeperDesktop) -> None: - with client.chats.with_streaming_response.create( - account_id="accountID", - ) as response: + with client.chats.with_streaming_response.create() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -257,36 +250,31 @@ class TestAsyncChats: @parametrize async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: - chat = await async_client.chats.create( - account_id="accountID", - ) + chat = await async_client.chats.create() assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.create( - account_id="accountID", - allow_invite=True, - message_text="messageText", - mode="create", - participant_ids=["string"], - title="title", - type="single", - user={ - "id": "id", - "email": "email", - "full_name": "fullName", - "phone_number": "phoneNumber", - "username": "username", + params={ + "account_id": "accountID", + "mode": "start", + "user": { + "id": "id", + "email": "email", + "full_name": "fullName", + "phone_number": "phoneNumber", + "username": "username", + }, + "allow_invite": True, + "message_text": "messageText", }, ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.chats.with_raw_response.create( - account_id="accountID", - ) + response = await async_client.chats.with_raw_response.create() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -295,9 +283,7 @@ async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> No @parametrize async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.chats.with_streaming_response.create( - account_id="accountID", - ) as response: + async with async_client.chats.with_streaming_response.create() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" From 7addb88adf0574857ce646e8a9f15e8eb035a48a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:32:36 +0000 Subject: [PATCH 36/49] perf(client): optimize file structure copying in multipart requests --- src/beeper_desktop_api/_files.py | 56 +++++++++++- src/beeper_desktop_api/_utils/__init__.py | 1 - src/beeper_desktop_api/_utils/_utils.py | 15 ---- src/beeper_desktop_api/resources/assets.py | 13 +-- tests/test_deepcopy.py | 58 ------------- tests/test_files.py | 99 +++++++++++++++++++++- 6 files changed, 159 insertions(+), 83 deletions(-) delete mode 100644 tests/test_deepcopy.py diff --git a/src/beeper_desktop_api/_files.py b/src/beeper_desktop_api/_files.py index e0ef7aa..8a371d3 100644 --- a/src/beeper_desktop_api/_files.py +++ b/src/beeper_desktop_api/_files.py @@ -3,8 +3,8 @@ import io import os import pathlib -from typing import overload -from typing_extensions import TypeGuard +from typing import Sequence, cast, overload +from typing_extensions import TypeVar, TypeGuard import anyio @@ -17,7 +17,9 @@ HttpxFileContent, HttpxRequestFiles, ) -from ._utils import is_tuple_t, is_mapping_t, is_sequence_t +from ._utils import is_list, is_mapping, is_tuple_t, is_mapping_t, is_sequence_t + +_T = TypeVar("_T") def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: @@ -121,3 +123,51 @@ async def async_read_file_content(file: FileContent) -> HttpxFileContent: return await anyio.Path(file).read_bytes() return file + + +def deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]]) -> _T: + """Copy only the containers along the given paths. + + Used to guard against mutation by extract_files without copying the entire structure. + Only dicts and lists that lie on a path are copied; everything else + is returned by reference. + + For example, given paths=[["foo", "files", "file"]] and the structure: + { + "foo": { + "bar": {"baz": {}}, + "files": {"file": } + } + } + The root dict, "foo", and "files" are copied (they lie on the path). + "bar" and "baz" are returned by reference (off the path). + """ + return _deepcopy_with_paths(item, paths, 0) + + +def _deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]], index: int) -> _T: + if not paths: + return item + if is_mapping(item): + key_to_paths: dict[str, list[Sequence[str]]] = {} + for path in paths: + if index < len(path): + key_to_paths.setdefault(path[index], []).append(path) + + # if no path continues through this mapping, it won't be mutated and copying it is redundant + if not key_to_paths: + return item + + result = dict(item) + for key, subpaths in key_to_paths.items(): + if key in result: + result[key] = _deepcopy_with_paths(result[key], subpaths, index + 1) + return cast(_T, result) + if is_list(item): + array_paths = [path for path in paths if index < len(path) and path[index] == ""] + + # if no path expects a list here, nothing will be mutated inside it - return by reference + if not array_paths: + return cast(_T, item) + return cast(_T, [_deepcopy_with_paths(entry, array_paths, index + 1) for entry in item]) + return item diff --git a/src/beeper_desktop_api/_utils/__init__.py b/src/beeper_desktop_api/_utils/__init__.py index 10cb66d..1c090e5 100644 --- a/src/beeper_desktop_api/_utils/__init__.py +++ b/src/beeper_desktop_api/_utils/__init__.py @@ -24,7 +24,6 @@ coerce_integer as coerce_integer, file_from_path as file_from_path, strip_not_given as strip_not_given, - deepcopy_minimal as deepcopy_minimal, get_async_library as get_async_library, maybe_coerce_float as maybe_coerce_float, get_required_header as get_required_header, diff --git a/src/beeper_desktop_api/_utils/_utils.py b/src/beeper_desktop_api/_utils/_utils.py index 63b8cd6..771859f 100644 --- a/src/beeper_desktop_api/_utils/_utils.py +++ b/src/beeper_desktop_api/_utils/_utils.py @@ -177,21 +177,6 @@ def is_iterable(obj: object) -> TypeGuard[Iterable[object]]: return isinstance(obj, Iterable) -def deepcopy_minimal(item: _T) -> _T: - """Minimal reimplementation of copy.deepcopy() that will only copy certain object types: - - - mappings, e.g. `dict` - - list - - This is done for performance reasons. - """ - if is_mapping(item): - return cast(_T, {k: deepcopy_minimal(v) for k, v in item.items()}) - if is_list(item): - return cast(_T, [deepcopy_minimal(entry) for entry in item]) - return item - - # copied from https://github.com/Rapptz/RoboDanny def human_join(seq: Sequence[str], *, delim: str = ", ", final: str = "or") -> str: size = len(seq) diff --git a/src/beeper_desktop_api/resources/assets.py b/src/beeper_desktop_api/resources/assets.py index db5dce4..652dcd5 100644 --- a/src/beeper_desktop_api/resources/assets.py +++ b/src/beeper_desktop_api/resources/assets.py @@ -7,8 +7,9 @@ import httpx from ..types import asset_serve_params, asset_upload_params, asset_download_params, asset_upload_base64_params +from .._files import deepcopy_with_paths from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, FileTypes, omit, not_given -from .._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform +from .._utils import extract_files, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -155,12 +156,13 @@ def upload( timeout: Override the client-level default timeout for this request, in seconds """ - body = deepcopy_minimal( + body = deepcopy_with_paths( { "file": file, "file_name": file_name, "mime_type": mime_type, - } + }, + [["file"]], ) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) # It should be noted that the actual Content-Type header that will be @@ -358,12 +360,13 @@ async def upload( timeout: Override the client-level default timeout for this request, in seconds """ - body = deepcopy_minimal( + body = deepcopy_with_paths( { "file": file, "file_name": file_name, "mime_type": mime_type, - } + }, + [["file"]], ) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) # It should be noted that the actual Content-Type header that will be diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py deleted file mode 100644 index 6288bda..0000000 --- a/tests/test_deepcopy.py +++ /dev/null @@ -1,58 +0,0 @@ -from beeper_desktop_api._utils import deepcopy_minimal - - -def assert_different_identities(obj1: object, obj2: object) -> None: - assert obj1 == obj2 - assert id(obj1) != id(obj2) - - -def test_simple_dict() -> None: - obj1 = {"foo": "bar"} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_dict() -> None: - obj1 = {"foo": {"bar": True}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - - -def test_complex_nested_dict() -> None: - obj1 = {"foo": {"bar": [{"hello": "world"}]}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - assert_different_identities(obj1["foo"]["bar"], obj2["foo"]["bar"]) - assert_different_identities(obj1["foo"]["bar"][0], obj2["foo"]["bar"][0]) - - -def test_simple_list() -> None: - obj1 = ["a", "b", "c"] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_list() -> None: - obj1 = ["a", [1, 2, 3]] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1[1], obj2[1]) - - -class MyObject: ... - - -def test_ignores_other_types() -> None: - # custom classes - my_obj = MyObject() - obj1 = {"foo": my_obj} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert obj1["foo"] is my_obj - - # tuples - obj3 = ("a", "b") - obj4 = deepcopy_minimal(obj3) - assert obj3 is obj4 diff --git a/tests/test_files.py b/tests/test_files.py index 76be5ce..310e532 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -4,7 +4,8 @@ import pytest from dirty_equals import IsDict, IsList, IsBytes, IsTuple -from beeper_desktop_api._files import to_httpx_files, async_to_httpx_files +from beeper_desktop_api._files import to_httpx_files, deepcopy_with_paths, async_to_httpx_files +from beeper_desktop_api._utils import extract_files readme_path = Path(__file__).parent.parent.joinpath("README.md") @@ -49,3 +50,99 @@ def test_string_not_allowed() -> None: "file": "foo", # type: ignore } ) + + +def assert_different_identities(obj1: object, obj2: object) -> None: + assert obj1 == obj2 + assert obj1 is not obj2 + + +class TestDeepcopyWithPaths: + def test_copies_top_level_dict(self) -> None: + original = {"file": b"data", "other": "value"} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + + def test_file_value_is_same_reference(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + assert result["file"] is file_bytes + + def test_list_popped_wholesale(self) -> None: + files = [b"f1", b"f2"] + original = {"files": files, "title": "t"} + result = deepcopy_with_paths(original, [["files", ""]]) + assert_different_identities(result, original) + result_files = result["files"] + assert isinstance(result_files, list) + assert_different_identities(result_files, files) + + def test_nested_array_path_copies_list_and_elements(self) -> None: + elem1 = {"file": b"f1", "extra": 1} + elem2 = {"file": b"f2", "extra": 2} + original = {"items": [elem1, elem2]} + result = deepcopy_with_paths(original, [["items", "", "file"]]) + assert_different_identities(result, original) + result_items = result["items"] + assert isinstance(result_items, list) + assert_different_identities(result_items, original["items"]) + assert_different_identities(result_items[0], elem1) + assert_different_identities(result_items[1], elem2) + + def test_empty_paths_returns_same_object(self) -> None: + original = {"foo": "bar"} + result = deepcopy_with_paths(original, []) + assert result is original + + def test_multiple_paths(self) -> None: + f1 = b"file1" + f2 = b"file2" + original = {"a": f1, "b": f2, "c": "unchanged"} + result = deepcopy_with_paths(original, [["a"], ["b"]]) + assert_different_identities(result, original) + assert result["a"] is f1 + assert result["b"] is f2 + assert result["c"] is original["c"] + + def test_extract_files_does_not_mutate_original_top_level(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes, "other": "value"} + + copied = deepcopy_with_paths(original, [["file"]]) + extracted = extract_files(copied, paths=[["file"]]) + + assert extracted == [("file", file_bytes)] + assert original == {"file": file_bytes, "other": "value"} + assert copied == {"other": "value"} + + def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None: + file1 = b"f1" + file2 = b"f2" + original = { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + + copied = deepcopy_with_paths(original, [["items", "", "file"]]) + extracted = extract_files(copied, paths=[["items", "", "file"]]) + + assert extracted == [("items[][file]", file1), ("items[][file]", file2)] + assert original == { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + assert copied == { + "items": [ + {"extra": 1}, + {"extra": 2}, + ], + "title": "example", + } From 29127f7697a10191b913f86e8664321963b55003 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:34:27 +0000 Subject: [PATCH 37/49] chore(tests): bump steady to v0.22.1 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 7c58865..9c7c439 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.20.2 -- steady --version + npm exec --package=@stdy/cli@0.22.1 -- steady --version - npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 87cdeac..0159035 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.2 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.22.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 From ed8c2c499c99ac2f1a4e83054ae0000cb9e12c47 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:11:43 +0000 Subject: [PATCH 38/49] chore(internal): more robust bootstrap script --- scripts/bootstrap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/bootstrap b/scripts/bootstrap index b430fee..fe8451e 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -4,7 +4,7 @@ set -e cd "$(dirname "$0")/.." -if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then brew bundle check >/dev/null 2>&1 || { echo -n "==> Install Homebrew dependencies? (y/N): " read -r response From d086e7f0ff86653ace4d1f21c5daf1d6605b3369 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 02:06:13 +0000 Subject: [PATCH 39/49] fix: use correct field name format for multipart file arrays --- src/beeper_desktop_api/_qs.py | 8 ++--- src/beeper_desktop_api/_types.py | 3 ++ src/beeper_desktop_api/_utils/_utils.py | 42 ++++++++++++++++++++----- tests/test_extract_files.py | 28 ++++++++++++++--- tests/test_files.py | 2 +- 5 files changed, 63 insertions(+), 20 deletions(-) diff --git a/src/beeper_desktop_api/_qs.py b/src/beeper_desktop_api/_qs.py index de8c99b..4127c19 100644 --- a/src/beeper_desktop_api/_qs.py +++ b/src/beeper_desktop_api/_qs.py @@ -2,17 +2,13 @@ from typing import Any, List, Tuple, Union, Mapping, TypeVar from urllib.parse import parse_qs, urlencode -from typing_extensions import Literal, get_args +from typing_extensions import get_args -from ._types import NotGiven, not_given +from ._types import NotGiven, ArrayFormat, NestedFormat, not_given from ._utils import flatten _T = TypeVar("_T") - -ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] -NestedFormat = Literal["dots", "brackets"] - PrimitiveData = Union[str, int, float, bool, None] # this should be Data = Union[PrimitiveData, "List[Data]", "Tuple[Data]", "Mapping[str, Data]"] # https://github.com/microsoft/pyright/issues/3555 diff --git a/src/beeper_desktop_api/_types.py b/src/beeper_desktop_api/_types.py index 2880d78..3c1fd72 100644 --- a/src/beeper_desktop_api/_types.py +++ b/src/beeper_desktop_api/_types.py @@ -47,6 +47,9 @@ ModelT = TypeVar("ModelT", bound=pydantic.BaseModel) _T = TypeVar("_T") +ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] +NestedFormat = Literal["dots", "brackets"] + # Approximates httpx internal ProxiesTypes and RequestFiles types # while adding support for `PathLike` instances diff --git a/src/beeper_desktop_api/_utils/_utils.py b/src/beeper_desktop_api/_utils/_utils.py index 771859f..199cd23 100644 --- a/src/beeper_desktop_api/_utils/_utils.py +++ b/src/beeper_desktop_api/_utils/_utils.py @@ -17,11 +17,11 @@ ) from pathlib import Path from datetime import date, datetime -from typing_extensions import TypeGuard +from typing_extensions import TypeGuard, get_args import sniffio -from .._types import Omit, NotGiven, FileTypes, HeadersLike +from .._types import Omit, NotGiven, FileTypes, ArrayFormat, HeadersLike _T = TypeVar("_T") _TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) @@ -40,25 +40,45 @@ def extract_files( query: Mapping[str, object], *, paths: Sequence[Sequence[str]], + array_format: ArrayFormat = "brackets", ) -> list[tuple[str, FileTypes]]: """Recursively extract files from the given dictionary based on specified paths. A path may look like this ['foo', 'files', '', 'data']. + ``array_format`` controls how ```` segments contribute to the emitted + field name. Supported values: ``"brackets"`` (``foo[]``), ``"repeat"`` and + ``"comma"`` (``foo``), ``"indices"`` (``foo[0]``, ``foo[1]``). + Note: this mutates the given dictionary. """ files: list[tuple[str, FileTypes]] = [] for path in paths: - files.extend(_extract_items(query, path, index=0, flattened_key=None)) + files.extend(_extract_items(query, path, index=0, flattened_key=None, array_format=array_format)) return files +def _array_suffix(array_format: ArrayFormat, array_index: int) -> str: + if array_format == "brackets": + return "[]" + if array_format == "indices": + return f"[{array_index}]" + if array_format == "repeat" or array_format == "comma": + # Both repeat the bare field name for each file part; there is no + # meaningful way to comma-join binary parts. + return "" + raise NotImplementedError( + f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}" + ) + + def _extract_items( obj: object, path: Sequence[str], *, index: int, flattened_key: str | None, + array_format: ArrayFormat, ) -> list[tuple[str, FileTypes]]: try: key = path[index] @@ -75,9 +95,11 @@ def _extract_items( if is_list(obj): files: list[tuple[str, FileTypes]] = [] - for entry in obj: - assert_is_file_content(entry, key=flattened_key + "[]" if flattened_key else "") - files.append((flattened_key + "[]", cast(FileTypes, entry))) + for array_index, entry in enumerate(obj): + suffix = _array_suffix(array_format, array_index) + emitted_key = (flattened_key + suffix) if flattened_key else suffix + assert_is_file_content(entry, key=emitted_key) + files.append((emitted_key, cast(FileTypes, entry))) return files assert_is_file_content(obj, key=flattened_key) @@ -106,6 +128,7 @@ def _extract_items( path, index=index, flattened_key=flattened_key, + array_format=array_format, ) elif is_list(obj): if key != "": @@ -117,9 +140,12 @@ def _extract_items( item, path, index=index, - flattened_key=flattened_key + "[]" if flattened_key is not None else "[]", + flattened_key=( + (flattened_key if flattened_key is not None else "") + _array_suffix(array_format, array_index) + ), + array_format=array_format, ) - for item in obj + for array_index, item in enumerate(obj) ] ) diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py index 7c7c5dd..889c22e 100644 --- a/tests/test_extract_files.py +++ b/tests/test_extract_files.py @@ -4,7 +4,7 @@ import pytest -from beeper_desktop_api._types import FileTypes +from beeper_desktop_api._types import FileTypes, ArrayFormat from beeper_desktop_api._utils import extract_files @@ -37,10 +37,7 @@ def test_multiple_files() -> None: def test_top_level_file_array() -> None: query = {"files": [b"file one", b"file two"], "title": "hello"} - assert extract_files(query, paths=[["files", ""]]) == [ - ("files[]", b"file one"), - ("files[]", b"file two"), - ] + assert extract_files(query, paths=[["files", ""]]) == [("files[]", b"file one"), ("files[]", b"file two")] assert query == {"title": "hello"} @@ -71,3 +68,24 @@ def test_ignores_incorrect_paths( expected: list[tuple[str, FileTypes]], ) -> None: assert extract_files(query, paths=paths) == expected + + +@pytest.mark.parametrize( + "array_format,expected_top_level,expected_nested", + [ + ("brackets", [("files[]", b"a"), ("files[]", b"b")], [("items[][file]", b"a"), ("items[][file]", b"b")]), + ("repeat", [("files", b"a"), ("files", b"b")], [("items[file]", b"a"), ("items[file]", b"b")]), + ("comma", [("files", b"a"), ("files", b"b")], [("items[file]", b"a"), ("items[file]", b"b")]), + ("indices", [("files[0]", b"a"), ("files[1]", b"b")], [("items[0][file]", b"a"), ("items[1][file]", b"b")]), + ], +) +def test_array_format_controls_file_field_names( + array_format: ArrayFormat, + expected_top_level: list[tuple[str, FileTypes]], + expected_nested: list[tuple[str, FileTypes]], +) -> None: + top_level = {"files": [b"a", b"b"]} + assert extract_files(top_level, paths=[["files", ""]], array_format=array_format) == expected_top_level + + nested = {"items": [{"file": b"a"}, {"file": b"b"}]} + assert extract_files(nested, paths=[["items", "", "file"]], array_format=array_format) == expected_nested diff --git a/tests/test_files.py b/tests/test_files.py index 310e532..c7492da 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -131,7 +131,7 @@ def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None: copied = deepcopy_with_paths(original, [["items", "", "file"]]) extracted = extract_files(copied, paths=[["items", "", "file"]]) - assert extracted == [("items[][file]", file1), ("items[][file]", file2)] + assert [entry for _, entry in extracted] == [file1, file2] assert original == { "items": [ {"file": file1, "extra": 1}, From 6841539c8619a507ec5e717d08a81837e83d76c2 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 02:07:23 +0000 Subject: [PATCH 40/49] feat: support setting headers via env --- src/beeper_desktop_api/_client.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py index 2f1306e..1f7d397 100644 --- a/src/beeper_desktop_api/_client.py +++ b/src/beeper_desktop_api/_client.py @@ -26,6 +26,7 @@ ) from ._utils import ( is_given, + is_mapping_t, maybe_transform, get_async_library, async_maybe_transform, @@ -113,6 +114,15 @@ def __init__( if base_url is None: base_url = f"http://localhost:23373" + custom_headers_env = os.environ.get("BEEPER_DESKTOP_CUSTOM_HEADERS") + if custom_headers_env is not None: + parsed: dict[str, str] = {} + for line in custom_headers_env.split("\n"): + colon = line.find(":") + if colon >= 0: + parsed[line[:colon].strip()] = line[colon + 1 :].strip() + default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})} + super().__init__( version=__version__, base_url=base_url, @@ -408,6 +418,15 @@ def __init__( if base_url is None: base_url = f"http://localhost:23373" + custom_headers_env = os.environ.get("BEEPER_DESKTOP_CUSTOM_HEADERS") + if custom_headers_env is not None: + parsed: dict[str, str] = {} + for line in custom_headers_env.split("\n"): + colon = line.find(":") + if colon >= 0: + parsed[line[:colon].strip()] = line[colon + 1 :].strip() + default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})} + super().__init__( version=__version__, base_url=base_url, From 4ff5262cbac0cd214a861f04c3159cd7236843af Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:50:37 +0000 Subject: [PATCH 41/49] Update Desktop API Stainless config and OpenAPI spec --- .github/workflows/detect-breaking-changes.yml | 42 ++++++++ .github/workflows/publish-pypi.yml | 2 +- .github/workflows/release-doctor.yml | 2 +- .stats.yml | 4 +- README.md | 13 +-- api.md | 4 +- pyproject.toml | 1 + requirements-dev.lock | 3 + scripts/detect-breaking-changes | 19 ++++ scripts/detect-breaking-changes.py | 79 ++++++++++++++ src/beeper_desktop_api/_base_client.py | 34 ++++-- src/beeper_desktop_api/_client.py | 27 +++-- src/beeper_desktop_api/_models.py | 6 ++ src/beeper_desktop_api/_types.py | 3 +- src/beeper_desktop_api/_utils/_logs.py | 2 +- src/beeper_desktop_api/pagination.py | 84 +-------------- src/beeper_desktop_api/resources/assets.py | 34 ++++-- .../resources/chats/chats.py | 102 +++++++++++++++--- .../resources/chats/messages/reactions.py | 4 +- src/beeper_desktop_api/resources/info.py | 12 ++- src/beeper_desktop_api/resources/messages.py | 14 +-- src/beeper_desktop_api/types/account.py | 22 ++-- .../types/chat_create_params.py | 89 ++++++++------- tests/api_resources/test_assets.py | 90 ++++++++++------ tests/api_resources/test_chats.py | 74 +++++++------ tests/api_resources/test_messages.py | 18 ++-- tests/test_client.py | 4 +- 27 files changed, 518 insertions(+), 270 deletions(-) create mode 100644 .github/workflows/detect-breaking-changes.yml create mode 100755 scripts/detect-breaking-changes create mode 100644 scripts/detect-breaking-changes.py diff --git a/.github/workflows/detect-breaking-changes.yml b/.github/workflows/detect-breaking-changes.yml new file mode 100644 index 0000000..8514409 --- /dev/null +++ b/.github/workflows/detect-breaking-changes.yml @@ -0,0 +1,42 @@ +name: CI +on: + pull_request: + branches: + - main + - next + +jobs: + detect_breaking_changes: + runs-on: 'ubuntu-latest' + name: detect-breaking-changes + if: github.repository == 'beeper/desktop-api-python' + steps: + - name: Calculate fetch-depth + run: | + echo "FETCH_DEPTH=$(expr ${{ github.event.pull_request.commits }} + 1)" >> $GITHUB_ENV + + - uses: actions/checkout@v6 + with: + # Ensure we can check out the pull request base in the script below. + fetch-depth: ${{ env.FETCH_DEPTH }} + + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + - name: Install dependencies + run: | + rye sync --all-features + - name: Detect removed symbols + run: | + rye run python scripts/detect-breaking-changes.py "${{ github.event.pull_request.base.sha }}" + + - name: Detect breaking changes + run: | + # Try to check out previous versions of the breaking change detection script. This ensures that + # we still detect breaking changes when entire files and their tests are removed. + git checkout "${{ github.event.pull_request.base.sha }}" -- ./scripts/detect-breaking-changes 2>/dev/null || true + ./scripts/detect-breaking-changes ${{ github.event.pull_request.base.sha }} \ No newline at end of file diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 08d08f6..54361b5 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -28,4 +28,4 @@ jobs: run: | bash ./bin/publish-pypi env: - PYPI_TOKEN: ${{ secrets.BEEPER_DESKTOP_PYPI_TOKEN || secrets.PYPI_TOKEN }} + PYPI_TOKEN: ${{ secrets.BEEPER_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index 4bccf2f..2d24407 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -18,4 +18,4 @@ jobs: run: | bash ./bin/check-release-environment env: - PYPI_TOKEN: ${{ secrets.BEEPER_DESKTOP_PYPI_TOKEN || secrets.PYPI_TOKEN }} + PYPI_TOKEN: ${{ secrets.BEEPER_PYPI_TOKEN || secrets.PYPI_TOKEN }} diff --git a/.stats.yml b/.stats.yml index 229f6b5..e925f68 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-611aa7641fbca8cf31d626bf86f9efd3c2b92778e897ebbb25c6ea44185ed1ed.yml -openapi_spec_hash: d6c0a1776048dab04f6c5625c9893c9c -config_hash: 39ed0717b5f415499aaace2468346e1a +openapi_spec_hash: 4840f003552e8b48eb8e689b59a819ef +config_hash: 05ebdec072113f63395372504da98192 diff --git a/README.md b/README.md index c0c9be9..bdee98a 100644 --- a/README.md +++ b/README.md @@ -218,10 +218,11 @@ from beeper_desktop_api import BeeperDesktop client = BeeperDesktop() -client.chats.reminders.create( - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reminder={"remind_at_ms": 0}, +chat = client.chats.create( + account_id="accountID", + user={}, ) +print(chat.user) ``` ## File uploads @@ -336,10 +337,10 @@ Note that requests that time out are [retried twice by default](#retries). We use the standard library [`logging`](https://docs.python.org/3/library/logging.html) module. -You can enable logging by setting the environment variable `BEEPER_DESKTOP_LOG` to `info`. +You can enable logging by setting the environment variable `BEEPER_LOG` to `info`. ```shell -$ export BEEPER_DESKTOP_LOG=info +$ export BEEPER_LOG=info ``` Or to `debug` for more verbose logging. @@ -438,7 +439,7 @@ import httpx from beeper_desktop_api import BeeperDesktop, DefaultHttpxClient client = BeeperDesktop( - # Or use the `BEEPER_DESKTOP_BASE_URL` env var + # Or use the `BEEPER_BASE_URL` env var base_url="http://my.test.server.example.com:8083", http_client=DefaultHttpxClient( proxy="http://my.test.proxy.example.com", diff --git a/api.md b/api.md index 5efec0a..068f976 100644 --- a/api.md +++ b/api.md @@ -91,7 +91,7 @@ from beeper_desktop_api.types import MessageUpdateResponse, MessageSendResponse Methods: - client.messages.update(message_id, \*, chat_id, \*\*params) -> MessageUpdateResponse -- client.messages.list(chat_id, \*\*params) -> SyncCursorSortKey[Message] +- client.messages.list(chat_id, \*\*params) -> SyncCursorNoLimit[Message] - client.messages.search(\*\*params) -> SyncCursorSearch[Message] - client.messages.send(chat_id, \*\*params) -> MessageSendResponse @@ -110,7 +110,7 @@ from beeper_desktop_api.types import ( Methods: - client.assets.download(\*\*params) -> AssetDownloadResponse -- client.assets.serve(\*\*params) -> None +- client.assets.serve(\*\*params) -> BinaryAPIResponse - client.assets.upload(\*\*params) -> AssetUploadResponse - client.assets.upload_base64(\*\*params) -> AssetUploadBase64Response diff --git a/pyproject.toml b/pyproject.toml index 3f8161a..3a1513d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ dev-dependencies = [ "importlib-metadata>=6.7.0", "rich>=13.7.1", "pytest-xdist>=3.6.1", + "griffe>=1", ] [tool.rye.scripts] diff --git a/requirements-dev.lock b/requirements-dev.lock index 2fdb945..c74b1ba 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -34,6 +34,8 @@ backports-asyncio-runner==1.2.0 certifi==2026.1.4 # via httpcore # via httpx +colorama==0.4.6 + # via griffe colorlog==6.10.1 # via nox dependency-groups==1.3.1 @@ -53,6 +55,7 @@ filelock==3.19.1 frozenlist==1.8.0 # via aiohttp # via aiosignal +griffe==1.14.0 h11==0.16.0 # via httpcore httpcore==1.0.9 diff --git a/scripts/detect-breaking-changes b/scripts/detect-breaking-changes new file mode 100755 index 0000000..fb28f3a --- /dev/null +++ b/scripts/detect-breaking-changes @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Detecting breaking changes" + +TEST_PATHS=( tests/api_resources tests/test_client.py tests/test_response.py ) + +for PATHSPEC in "${TEST_PATHS[@]}"; do + # Try to check out previous versions of the test files + # with the current SDK. + git checkout "$1" -- "${PATHSPEC}" 2>/dev/null || true +done + +# Instead of running the tests, use the linter to check if an +# older test is no longer compatible with the latest SDK. +./scripts/lint diff --git a/scripts/detect-breaking-changes.py b/scripts/detect-breaking-changes.py new file mode 100644 index 0000000..c61e8ba --- /dev/null +++ b/scripts/detect-breaking-changes.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import sys +from typing import Iterator +from pathlib import Path + +import rich +import griffe +from rich.text import Text +from rich.style import Style + + +def public_members(obj: griffe.Object | griffe.Alias) -> dict[str, griffe.Object | griffe.Alias]: + if isinstance(obj, griffe.Alias): + # ignore imports for now, they're technically part of the public API + # but we don't have good preventative measures in place to prevent + # changing them + return {} + + return {name: value for name, value in obj.all_members.items() if not name.startswith("_")} + + +def find_breaking_changes( + new_obj: griffe.Object | griffe.Alias, + old_obj: griffe.Object | griffe.Alias, + *, + path: list[str], +) -> Iterator[Text | str]: + new_members = public_members(new_obj) + old_members = public_members(old_obj) + + for name, old_member in old_members.items(): + if isinstance(old_member, griffe.Alias) and len(path) > 2: + # ignore imports in `/types/` for now, they're technically part of the public API + # but we don't have good preventative measures in place to prevent changing them + continue + + new_member = new_members.get(name) + if new_member is None: + cls_name = old_member.__class__.__name__ + yield Text(f"({cls_name})", style=Style(color="rgb(119, 119, 119)")) + yield from [" " for _ in range(10 - len(cls_name))] + yield f" {'.'.join(path)}.{name}" + yield "\n" + continue + + yield from find_breaking_changes(new_member, old_member, path=[*path, name]) + + +def main() -> None: + try: + against_ref = sys.argv[1] + except IndexError as err: + raise RuntimeError("You must specify a base ref to run breaking change detection against") from err + + package = griffe.load( + "beeper_desktop_api", + search_paths=[Path(__file__).parent.parent.joinpath("src")], + ) + old_package = griffe.load_git( + "beeper_desktop_api", + ref=against_ref, + search_paths=["src"], + ) + assert isinstance(package, griffe.Module) + assert isinstance(old_package, griffe.Module) + + output = list(find_breaking_changes(package, old_package, path=["beeper_desktop_api"])) + if output: + rich.print(Text("Breaking changes detected!", style=Style(color="rgb(165, 79, 87)"))) + rich.print() + + for text in output: + rich.print(text, end="") + + sys.exit(1) + + +main() diff --git a/src/beeper_desktop_api/_base_client.py b/src/beeper_desktop_api/_base_client.py index 4e62b4b..5bce507 100644 --- a/src/beeper_desktop_api/_base_client.py +++ b/src/beeper_desktop_api/_base_client.py @@ -63,7 +63,7 @@ ) from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping from ._compat import PYDANTIC_V1, model_copy, model_dump -from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type +from ._models import GenericModel, SecurityOptions, FinalRequestOptions, validate_type, construct_type from ._response import ( APIResponse, BaseAPIResponse, @@ -432,9 +432,27 @@ def _make_status_error( ) -> _exceptions.APIStatusError: raise NotImplementedError() + def _auth_headers( + self, + security: SecurityOptions, # noqa: ARG002 + ) -> dict[str, str]: + return {} + + def _auth_query( + self, + security: SecurityOptions, # noqa: ARG002 + ) -> dict[str, str]: + return {} + + def _custom_auth( + self, + security: SecurityOptions, # noqa: ARG002 + ) -> httpx.Auth | None: + return None + def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0) -> httpx.Headers: custom_headers = options.headers or {} - headers_dict = _merge_mappings(self.default_headers, custom_headers) + headers_dict = _merge_mappings({**self._auth_headers(options.security), **self.default_headers}, custom_headers) self._validate_headers(headers_dict, custom_headers) # headers are case-insensitive while dictionaries are not. @@ -506,7 +524,7 @@ def _build_request( raise RuntimeError(f"Unexpected JSON data type, {type(json_data)}, cannot merge with `extra_body`") headers = self._build_headers(options, retries_taken=retries_taken) - params = _merge_mappings(self.default_query, options.params) + params = _merge_mappings({**self._auth_query(options.security), **self.default_query}, options.params) content_type = headers.get("Content-Type") files = options.files @@ -675,7 +693,6 @@ def default_headers(self) -> dict[str, str | Omit]: "Content-Type": "application/json", "User-Agent": self.user_agent, **self.platform_headers(), - **self.auth_headers, **self._custom_headers, } @@ -994,8 +1011,9 @@ def request( self._prepare_request(request) kwargs: HttpxSendArgs = {} - if self.custom_auth is not None: - kwargs["auth"] = self.custom_auth + custom_auth = self._custom_auth(options.security) + if custom_auth is not None: + kwargs["auth"] = custom_auth if options.follow_redirects is not None: kwargs["follow_redirects"] = options.follow_redirects @@ -1956,6 +1974,7 @@ def make_request_options( idempotency_key: str | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, post_parser: PostParser | NotGiven = not_given, + security: SecurityOptions | None = None, ) -> RequestOptions: """Create a dict of type RequestOptions without keys of NotGiven values.""" options: RequestOptions = {} @@ -1981,6 +2000,9 @@ def make_request_options( # internal options["post_parser"] = post_parser # type: ignore + if security is not None: + options["security"] = security + return options diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py index 1f7d397..bf45e4d 100644 --- a/src/beeper_desktop_api/_client.py +++ b/src/beeper_desktop_api/_client.py @@ -32,6 +32,7 @@ async_maybe_transform, ) from ._compat import cached_property +from ._models import SecurityOptions from ._version import __version__ from ._response import ( to_raw_response_wrapper, @@ -110,11 +111,11 @@ def __init__( self.access_token = access_token if base_url is None: - base_url = os.environ.get("BEEPER_DESKTOP_BASE_URL") + base_url = os.environ.get("BEEPER_BASE_URL") if base_url is None: base_url = f"http://localhost:23373" - custom_headers_env = os.environ.get("BEEPER_DESKTOP_CUSTOM_HEADERS") + custom_headers_env = os.environ.get("BEEPER_CUSTOM_HEADERS") if custom_headers_env is not None: parsed: dict[str, str] = {} for line in custom_headers_env.split("\n"): @@ -182,9 +183,14 @@ def with_streaming_response(self) -> BeeperDesktopWithStreamedResponse: def qs(self) -> Querystring: return Querystring(array_format="repeat") - @property @override - def auth_headers(self) -> dict[str, str]: + def _auth_headers(self, security: SecurityOptions) -> dict[str, str]: + return { + **(self._bearer_auth if security.get("bearer_auth", False) else {}), + } + + @property + def _bearer_auth(self) -> dict[str, str]: access_token = self.access_token return {"Authorization": f"Bearer {access_token}"} @@ -414,11 +420,11 @@ def __init__( self.access_token = access_token if base_url is None: - base_url = os.environ.get("BEEPER_DESKTOP_BASE_URL") + base_url = os.environ.get("BEEPER_BASE_URL") if base_url is None: base_url = f"http://localhost:23373" - custom_headers_env = os.environ.get("BEEPER_DESKTOP_CUSTOM_HEADERS") + custom_headers_env = os.environ.get("BEEPER_CUSTOM_HEADERS") if custom_headers_env is not None: parsed: dict[str, str] = {} for line in custom_headers_env.split("\n"): @@ -486,9 +492,14 @@ def with_streaming_response(self) -> AsyncBeeperDesktopWithStreamedResponse: def qs(self) -> Querystring: return Querystring(array_format="repeat") - @property @override - def auth_headers(self) -> dict[str, str]: + def _auth_headers(self, security: SecurityOptions) -> dict[str, str]: + return { + **(self._bearer_auth if security.get("bearer_auth", False) else {}), + } + + @property + def _bearer_auth(self) -> dict[str, str]: access_token = self.access_token return {"Authorization": f"Bearer {access_token}"} diff --git a/src/beeper_desktop_api/_models.py b/src/beeper_desktop_api/_models.py index 29070e0..e22dd2a 100644 --- a/src/beeper_desktop_api/_models.py +++ b/src/beeper_desktop_api/_models.py @@ -791,6 +791,10 @@ def _create_pydantic_model(type_: _T) -> Type[RootModel[_T]]: return RootModel[type_] # type: ignore +class SecurityOptions(TypedDict, total=False): + bearer_auth: bool + + class FinalRequestOptionsInput(TypedDict, total=False): method: Required[str] url: Required[str] @@ -804,6 +808,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): json_data: Body extra_json: AnyMapping follow_redirects: bool + security: SecurityOptions @final @@ -818,6 +823,7 @@ class FinalRequestOptions(pydantic.BaseModel): idempotency_key: Union[str, None] = None post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() follow_redirects: Union[bool, None] = None + security: SecurityOptions = {"bearer_auth": True} content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None # It should be noted that we cannot use `json` here as that would override diff --git a/src/beeper_desktop_api/_types.py b/src/beeper_desktop_api/_types.py index 3c1fd72..a131d99 100644 --- a/src/beeper_desktop_api/_types.py +++ b/src/beeper_desktop_api/_types.py @@ -36,7 +36,7 @@ from httpx import URL, Proxy, Timeout, Response, BaseTransport, AsyncBaseTransport if TYPE_CHECKING: - from ._models import BaseModel + from ._models import BaseModel, SecurityOptions from ._response import APIResponse, AsyncAPIResponse Transport = BaseTransport @@ -124,6 +124,7 @@ class RequestOptions(TypedDict, total=False): extra_json: AnyMapping idempotency_key: str follow_redirects: bool + security: SecurityOptions # Sentinel class used until PEP 0661 is accepted diff --git a/src/beeper_desktop_api/_utils/_logs.py b/src/beeper_desktop_api/_utils/_logs.py index da351d5..96d73d5 100644 --- a/src/beeper_desktop_api/_utils/_logs.py +++ b/src/beeper_desktop_api/_utils/_logs.py @@ -14,7 +14,7 @@ def _basic_config() -> None: def setup_logging() -> None: - env = os.environ.get("BEEPER_DESKTOP_LOG") + env = os.environ.get("BEEPER_LOG") if env == "debug": _basic_config() logger.setLevel(logging.DEBUG) diff --git a/src/beeper_desktop_api/pagination.py b/src/beeper_desktop_api/pagination.py index 03ecb2a..b3dc44c 100644 --- a/src/beeper_desktop_api/pagination.py +++ b/src/beeper_desktop_api/pagination.py @@ -1,29 +1,17 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Any, List, Generic, TypeVar, Optional, cast -from typing_extensions import Protocol, override, runtime_checkable +from typing import List, Generic, TypeVar, Optional +from typing_extensions import override from pydantic import Field as FieldInfo from ._base_client import BasePage, PageInfo, BaseSyncPage, BaseAsyncPage -__all__ = [ - "SyncCursorSearch", - "AsyncCursorSearch", - "SyncCursorNoLimit", - "AsyncCursorNoLimit", - "SyncCursorSortKey", - "AsyncCursorSortKey", -] +__all__ = ["SyncCursorSearch", "AsyncCursorSearch", "SyncCursorNoLimit", "AsyncCursorNoLimit"] _T = TypeVar("_T") -@runtime_checkable -class CursorSortKeyItem(Protocol): - sort_key: Optional[str] - - class SyncCursorSearch(BaseSyncPage[_T], BasePage[_T], Generic[_T]): items: List[_T] has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) @@ -142,69 +130,3 @@ def next_page_info(self) -> Optional[PageInfo]: return None return PageInfo(params={"cursor": oldest_cursor}) - - -class SyncCursorSortKey(BaseSyncPage[_T], BasePage[_T], Generic[_T]): - items: List[_T] - has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) - - @override - def _get_page_items(self) -> List[_T]: - items = self.items - if not items: - return [] - return items - - @override - def has_next_page(self) -> bool: - has_more = self.has_more - if has_more is not None and has_more is False: - return False - - return super().has_next_page() - - @override - def next_page_info(self) -> Optional[PageInfo]: - items = self.items - if not items: - return None - - item = cast(Any, items[-1]) - if not isinstance(item, CursorSortKeyItem) or item.sort_key is None: - # TODO emit warning log - return None - - return PageInfo(params={"cursor": item.sort_key}) - - -class AsyncCursorSortKey(BaseAsyncPage[_T], BasePage[_T], Generic[_T]): - items: List[_T] - has_more: Optional[bool] = FieldInfo(alias="hasMore", default=None) - - @override - def _get_page_items(self) -> List[_T]: - items = self.items - if not items: - return [] - return items - - @override - def has_next_page(self) -> bool: - has_more = self.has_more - if has_more is not None and has_more is False: - return False - - return super().has_next_page() - - @override - def next_page_info(self) -> Optional[PageInfo]: - items = self.items - if not items: - return None - - item = cast(Any, items[-1]) - if not isinstance(item, CursorSortKeyItem) or item.sort_key is None: - # TODO emit warning log - return None - - return PageInfo(params={"cursor": item.sort_key}) diff --git a/src/beeper_desktop_api/resources/assets.py b/src/beeper_desktop_api/resources/assets.py index 652dcd5..dc85070 100644 --- a/src/beeper_desktop_api/resources/assets.py +++ b/src/beeper_desktop_api/resources/assets.py @@ -8,15 +8,23 @@ from ..types import asset_serve_params, asset_upload_params, asset_download_params, asset_upload_base64_params from .._files import deepcopy_with_paths -from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, FileTypes, omit, not_given +from .._types import Body, Omit, Query, Headers, NotGiven, FileTypes, omit, not_given from .._utils import extract_files, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, to_raw_response_wrapper, to_streamed_response_wrapper, async_to_raw_response_wrapper, + to_custom_raw_response_wrapper, async_to_streamed_response_wrapper, + to_custom_streamed_response_wrapper, + async_to_custom_raw_response_wrapper, + async_to_custom_streamed_response_wrapper, ) from .._base_client import make_request_options from ..types.asset_upload_response import AssetUploadResponse @@ -93,7 +101,7 @@ def serve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: + ) -> BinaryAPIResponse: """Stream a file given an mxc://, localmxc://, or file:// URL. Downloads first if @@ -110,7 +118,7 @@ def serve( timeout: Override the client-level default timeout for this request, in seconds """ - extra_headers = {"Accept": "*/*", **(extra_headers or {})} + extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})} return self._get( "/v1/assets/serve", options=make_request_options( @@ -120,7 +128,7 @@ def serve( timeout=timeout, query=maybe_transform({"url": url}, asset_serve_params.AssetServeParams), ), - cast_to=NoneType, + cast_to=BinaryAPIResponse, ) def upload( @@ -297,7 +305,7 @@ async def serve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: + ) -> AsyncBinaryAPIResponse: """Stream a file given an mxc://, localmxc://, or file:// URL. Downloads first if @@ -314,7 +322,7 @@ async def serve( timeout: Override the client-level default timeout for this request, in seconds """ - extra_headers = {"Accept": "*/*", **(extra_headers or {})} + extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})} return await self._get( "/v1/assets/serve", options=make_request_options( @@ -324,7 +332,7 @@ async def serve( timeout=timeout, query=await async_maybe_transform({"url": url}, asset_serve_params.AssetServeParams), ), - cast_to=NoneType, + cast_to=AsyncBinaryAPIResponse, ) async def upload( @@ -441,8 +449,9 @@ def __init__(self, assets: AssetsResource) -> None: self.download = to_raw_response_wrapper( assets.download, ) - self.serve = to_raw_response_wrapper( + self.serve = to_custom_raw_response_wrapper( assets.serve, + BinaryAPIResponse, ) self.upload = to_raw_response_wrapper( assets.upload, @@ -459,8 +468,9 @@ def __init__(self, assets: AsyncAssetsResource) -> None: self.download = async_to_raw_response_wrapper( assets.download, ) - self.serve = async_to_raw_response_wrapper( + self.serve = async_to_custom_raw_response_wrapper( assets.serve, + AsyncBinaryAPIResponse, ) self.upload = async_to_raw_response_wrapper( assets.upload, @@ -477,8 +487,9 @@ def __init__(self, assets: AssetsResource) -> None: self.download = to_streamed_response_wrapper( assets.download, ) - self.serve = to_streamed_response_wrapper( + self.serve = to_custom_streamed_response_wrapper( assets.serve, + StreamedBinaryAPIResponse, ) self.upload = to_streamed_response_wrapper( assets.upload, @@ -495,8 +506,9 @@ def __init__(self, assets: AsyncAssetsResource) -> None: self.download = async_to_streamed_response_wrapper( assets.download, ) - self.serve = async_to_streamed_response_wrapper( + self.serve = async_to_custom_streamed_response_wrapper( assets.serve, + AsyncStreamedBinaryAPIResponse, ) self.upload = async_to_streamed_response_wrapper( assets.upload, diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py index 2a6a92c..318ebf3 100644 --- a/src/beeper_desktop_api/resources/chats/chats.py +++ b/src/beeper_desktop_api/resources/chats/chats.py @@ -79,7 +79,14 @@ def with_streaming_response(self) -> ChatsResourceWithStreamingResponse: def create( self, *, - params: chat_create_params.Params | Omit = omit, + account_id: str, + allow_invite: bool | Omit = omit, + message_text: str | Omit = omit, + mode: Literal["start", "create"] | Omit = omit, + participant_ids: SequenceNotStr[str] | Omit = omit, + title: str | Omit = omit, + type: Literal["single", "group"] | Omit = omit, + user: chat_create_params.User | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -88,10 +95,31 @@ def create( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ChatCreateResponse: """ - Create a single/group chat (mode='create') or start a direct chat from merged - user data (mode='start'). + Create a direct or group chat with mode="create", or use mode="start" to resolve + a contact and open a direct chat. Args: + account_id: Account to create or start the chat on. + + allow_invite: Only used for mode='start'. Whether invite-based DM creation is allowed when + required by the platform. + + message_text: Optional first message content if the platform requires it to create the chat. + + mode: Operation mode. Use 'start' to resolve a user/contact and start a direct chat. + Omit or set 'create' to create a chat directly. + + participant_ids: Required for create mode. Provide exactly one user ID for 'single' chats and one + or more for 'group' chats. + + title: Optional title for group chats; ignored for single chats on most networks. + + type: Required for create mode. 'single' creates a direct message chat; 'group' + creates a group chat. + + user: Required for mode='start'. Merged user-like contact payload used to resolve the + best identifier. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -102,7 +130,19 @@ def create( """ return self._post( "/v1/chats", - body=maybe_transform(params, chat_create_params.ChatCreateParams), + body=maybe_transform( + { + "account_id": account_id, + "allow_invite": allow_invite, + "message_text": message_text, + "mode": mode, + "participant_ids": participant_ids, + "title": title, + "type": type, + "user": user, + }, + chat_create_params.ChatCreateParams, + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -273,8 +313,7 @@ def search( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SyncCursorSearch[Chat]: """ - Search chats by title/network or participants using Beeper Desktop's renderer - algorithm. + Search chats by title, network, or participant names. Args: account_ids: Provide an array of account IDs to filter chats from specific messaging accounts @@ -383,7 +422,14 @@ def with_streaming_response(self) -> AsyncChatsResourceWithStreamingResponse: async def create( self, *, - params: chat_create_params.Params | Omit = omit, + account_id: str, + allow_invite: bool | Omit = omit, + message_text: str | Omit = omit, + mode: Literal["start", "create"] | Omit = omit, + participant_ids: SequenceNotStr[str] | Omit = omit, + title: str | Omit = omit, + type: Literal["single", "group"] | Omit = omit, + user: chat_create_params.User | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -392,10 +438,31 @@ async def create( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ChatCreateResponse: """ - Create a single/group chat (mode='create') or start a direct chat from merged - user data (mode='start'). + Create a direct or group chat with mode="create", or use mode="start" to resolve + a contact and open a direct chat. Args: + account_id: Account to create or start the chat on. + + allow_invite: Only used for mode='start'. Whether invite-based DM creation is allowed when + required by the platform. + + message_text: Optional first message content if the platform requires it to create the chat. + + mode: Operation mode. Use 'start' to resolve a user/contact and start a direct chat. + Omit or set 'create' to create a chat directly. + + participant_ids: Required for create mode. Provide exactly one user ID for 'single' chats and one + or more for 'group' chats. + + title: Optional title for group chats; ignored for single chats on most networks. + + type: Required for create mode. 'single' creates a direct message chat; 'group' + creates a group chat. + + user: Required for mode='start'. Merged user-like contact payload used to resolve the + best identifier. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -406,7 +473,19 @@ async def create( """ return await self._post( "/v1/chats", - body=await async_maybe_transform(params, chat_create_params.ChatCreateParams), + body=await async_maybe_transform( + { + "account_id": account_id, + "allow_invite": allow_invite, + "message_text": message_text, + "mode": mode, + "participant_ids": participant_ids, + "title": title, + "type": type, + "user": user, + }, + chat_create_params.ChatCreateParams, + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -577,8 +656,7 @@ def search( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AsyncPaginator[Chat, AsyncCursorSearch[Chat]]: """ - Search chats by title/network or participants using Beeper Desktop's renderer - algorithm. + Search chats by title, network, or participant names. Args: account_ids: Provide an array of account IDs to filter chats from specific messaging accounts diff --git a/src/beeper_desktop_api/resources/chats/messages/reactions.py b/src/beeper_desktop_api/resources/chats/messages/reactions.py index 13a855d..bdf93a5 100644 --- a/src/beeper_desktop_api/resources/chats/messages/reactions.py +++ b/src/beeper_desktop_api/resources/chats/messages/reactions.py @@ -58,7 +58,7 @@ def delete( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ReactionDeleteResponse: """ - Remove the authenticated user's reaction from an existing message. + Remove the reaction added by the authenticated user from an existing message. Args: chat_id: Unique identifier of the chat. @@ -181,7 +181,7 @@ async def delete( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ReactionDeleteResponse: """ - Remove the authenticated user's reaction from an existing message. + Remove the reaction added by the authenticated user from an existing message. Args: chat_id: Unique identifier of the chat. diff --git a/src/beeper_desktop_api/resources/info.py b/src/beeper_desktop_api/resources/info.py index 9b6bc94..2b33a3d 100644 --- a/src/beeper_desktop_api/resources/info.py +++ b/src/beeper_desktop_api/resources/info.py @@ -58,7 +58,11 @@ def retrieve( return self._get( "/v1/info", options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + security={}, ), cast_to=InfoRetrieveResponse, ) @@ -103,7 +107,11 @@ async def retrieve( return await self._get( "/v1/info", options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + security={}, ), cast_to=InfoRetrieveResponse, ) diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py index af2178e..e0be745 100644 --- a/src/beeper_desktop_api/resources/messages.py +++ b/src/beeper_desktop_api/resources/messages.py @@ -19,7 +19,7 @@ async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) -from ..pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorSortKey, AsyncCursorSortKey +from ..pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorNoLimit, AsyncCursorNoLimit from .._base_client import AsyncPaginator, make_request_options from ..types.shared.message import Message from ..types.message_send_response import MessageSendResponse @@ -106,7 +106,7 @@ def list( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SyncCursorSortKey[Message]: + ) -> SyncCursorNoLimit[Message]: """List all messages in a chat with cursor-based pagination. Sorted by timestamp. @@ -131,7 +131,7 @@ def list( raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") return self._get_api_list( path_template("/v1/chats/{chat_id}/messages", chat_id=chat_id), - page=SyncCursorSortKey[Message], + page=SyncCursorNoLimit[Message], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -172,7 +172,7 @@ def search( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SyncCursorSearch[Message]: """ - Search messages across chats using Beeper's message index + Search messages across chats. Args: account_ids: Limit search to specific account IDs. @@ -382,7 +382,7 @@ def list( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncPaginator[Message, AsyncCursorSortKey[Message]]: + ) -> AsyncPaginator[Message, AsyncCursorNoLimit[Message]]: """List all messages in a chat with cursor-based pagination. Sorted by timestamp. @@ -407,7 +407,7 @@ def list( raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") return self._get_api_list( path_template("/v1/chats/{chat_id}/messages", chat_id=chat_id), - page=AsyncCursorSortKey[Message], + page=AsyncCursorNoLimit[Message], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -448,7 +448,7 @@ def search( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AsyncPaginator[Message, AsyncCursorSearch[Message]]: """ - Search messages across chats using Beeper's message index + Search messages across chats. Args: account_ids: Limit search to specific account IDs. diff --git a/src/beeper_desktop_api/types/account.py b/src/beeper_desktop_api/types/account.py index c024419..bb569a7 100644 --- a/src/beeper_desktop_api/types/account.py +++ b/src/beeper_desktop_api/types/account.py @@ -1,5 +1,6 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +from typing import Optional from typing_extensions import Literal from pydantic import Field as FieldInfo @@ -11,29 +12,32 @@ class Bridge(BaseModel): - """Bridge metadata for the account. Available from Beeper Desktop v.4.2.719+.""" + """Bridge metadata for the account. Available in Beeper Desktop v4.2.789+.""" id: str - """Bridge instance identifier.""" + """Bridge instance identifier. Available in Beeper Desktop v4.2.789+.""" provider: Literal["cloud", "self-hosted", "local", "platform-sdk"] - """Bridge provider for the account.""" + """Bridge provider for the account. Available in Beeper Desktop v4.2.789+.""" type: str - """Bridge type.""" + """Bridge type. Available in Beeper Desktop v4.2.789+.""" class Account(BaseModel): - """A chat account added to Beeper""" + """A chat account added to Beeper.""" account_id: str = FieldInfo(alias="accountID") """Chat account added to Beeper. Use this to route account-scoped actions.""" bridge: Bridge - """Bridge metadata for the account. Available from Beeper Desktop v.4.2.719+.""" - - network: str - """Human-friendly network name for the account.""" + """Bridge metadata for the account. Available in Beeper Desktop v4.2.789+.""" user: User """User the account belongs to.""" + + network: Optional[str] = None + """Human-friendly network name for the account. + + Omitted when the network is unknown. + """ diff --git a/src/beeper_desktop_api/types/chat_create_params.py b/src/beeper_desktop_api/types/chat_create_params.py index f755b70..b63be4f 100644 --- a/src/beeper_desktop_api/types/chat_create_params.py +++ b/src/beeper_desktop_api/types/chat_create_params.py @@ -2,79 +2,74 @@ from __future__ import annotations -from typing import Union -from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict +from typing_extensions import Literal, Required, Annotated, TypedDict from .._types import SequenceNotStr from .._utils import PropertyInfo -__all__ = ["ChatCreateParams", "Params", "ParamsUnionMember0", "ParamsUnionMember0User", "ParamsUnionMember1"] +__all__ = ["ChatCreateParams", "User"] class ChatCreateParams(TypedDict, total=False): - params: Params - - -class ParamsUnionMember0User(TypedDict, total=False): - """Merged user-like contact payload used to resolve the best identifier.""" - - id: str - """Known user ID when available.""" + account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] + """Account to create or start the chat on.""" - email: str - """Email candidate.""" + allow_invite: Annotated[bool, PropertyInfo(alias="allowInvite")] + """Only used for mode='start'. - full_name: Annotated[str, PropertyInfo(alias="fullName")] - """Display name hint used for ranking only.""" + Whether invite-based DM creation is allowed when required by the platform. + """ - phone_number: Annotated[str, PropertyInfo(alias="phoneNumber")] - """Phone number candidate (E.164 preferred).""" + message_text: Annotated[str, PropertyInfo(alias="messageText")] + """Optional first message content if the platform requires it to create the chat.""" - username: str - """Username/handle candidate.""" + mode: Literal["start", "create"] + """Operation mode. + Use 'start' to resolve a user/contact and start a direct chat. Omit or set + 'create' to create a chat directly. + """ -class ParamsUnionMember0(TypedDict, total=False): - account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] - """Account to create or start the chat on.""" + participant_ids: Annotated[SequenceNotStr[str], PropertyInfo(alias="participantIDs")] + """Required for create mode. - mode: Required[Literal["start"]] - """Operation mode. Use 'start' to resolve a user/contact and start a direct chat.""" + Provide exactly one user ID for 'single' chats and one or more for 'group' + chats. + """ - user: Required[ParamsUnionMember0User] - """Merged user-like contact payload used to resolve the best identifier.""" + title: str + """Optional title for group chats; ignored for single chats on most networks.""" - allow_invite: Annotated[bool, PropertyInfo(alias="allowInvite")] - """Whether invite-based DM creation is allowed when required by the platform. + type: Literal["single", "group"] + """Required for create mode. - Used for mode='start'. + 'single' creates a direct message chat; 'group' creates a group chat. """ - message_text: Annotated[str, PropertyInfo(alias="messageText")] - """Optional first message content if the platform requires it to create the chat.""" + user: User + """Required for mode='start'. + Merged user-like contact payload used to resolve the best identifier. + """ -class ParamsUnionMember1(TypedDict, total=False): - account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] - """Account to create or start the chat on.""" - participant_ids: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="participantIDs")]] - """User IDs to include in the new chat.""" +class User(TypedDict, total=False): + """Required for mode='start'. - type: Required[Literal["single", "group"]] - """ - 'single' requires exactly one participantID; 'group' supports multiple - participants and optional title. + Merged user-like contact payload used to resolve the best identifier. """ - message_text: Annotated[str, PropertyInfo(alias="messageText")] - """Optional first message content if the platform requires it to create the chat.""" + id: str + """Known user ID when available.""" - mode: Literal["create"] - """Operation mode. Defaults to 'create' when omitted.""" + email: str + """Email candidate.""" - title: str - """Optional title for group chats; ignored for single chats on most platforms.""" + full_name: Annotated[str, PropertyInfo(alias="fullName")] + """Display name hint used for ranking only.""" + phone_number: Annotated[str, PropertyInfo(alias="phoneNumber")] + """Phone number candidate (E.164 preferred).""" -Params: TypeAlias = Union[ParamsUnionMember0, ParamsUnionMember1] + username: str + """Username/handle candidate.""" diff --git a/tests/api_resources/test_assets.py b/tests/api_resources/test_assets.py index 16d9ffa..f63f7bb 100644 --- a/tests/api_resources/test_assets.py +++ b/tests/api_resources/test_assets.py @@ -5,7 +5,9 @@ import os from typing import Any, cast +import httpx import pytest +from respx import MockRouter from tests.utils import assert_matches_type from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop @@ -14,6 +16,12 @@ AssetDownloadResponse, AssetUploadBase64Response, ) +from beeper_desktop_api._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, +) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -53,35 +61,46 @@ def test_streaming_response_download(self, client: BeeperDesktop) -> None: assert cast(Any, response.is_closed) is True @parametrize - def test_method_serve(self, client: BeeperDesktop) -> None: + @pytest.mark.respx(base_url=base_url) + def test_method_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) asset = client.assets.serve( url="x", ) - assert asset is None + assert asset.is_closed + assert asset.json() == {"foo": "bar"} + assert cast(Any, asset.is_closed) is True + assert isinstance(asset, BinaryAPIResponse) @parametrize - def test_raw_response_serve(self, client: BeeperDesktop) -> None: - response = client.assets.with_raw_response.serve( + @pytest.mark.respx(base_url=base_url) + def test_raw_response_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + asset = client.assets.with_raw_response.serve( url="x", ) - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - asset = response.parse() - assert asset is None + assert asset.is_closed is True + assert asset.http_request.headers.get("X-Stainless-Lang") == "python" + assert asset.json() == {"foo": "bar"} + assert isinstance(asset, BinaryAPIResponse) @parametrize - def test_streaming_response_serve(self, client: BeeperDesktop) -> None: + @pytest.mark.respx(base_url=base_url) + def test_streaming_response_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) with client.assets.with_streaming_response.serve( url="x", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" + ) as asset: + assert not asset.is_closed + assert asset.http_request.headers.get("X-Stainless-Lang") == "python" - asset = response.parse() - assert asset is None + assert asset.json() == {"foo": "bar"} + assert cast(Any, asset.is_closed) is True + assert isinstance(asset, StreamedBinaryAPIResponse) - assert cast(Any, response.is_closed) is True + assert cast(Any, asset.is_closed) is True @parametrize def test_method_upload(self, client: BeeperDesktop) -> None: @@ -201,35 +220,46 @@ async def test_streaming_response_download(self, async_client: AsyncBeeperDeskto assert cast(Any, response.is_closed) is True @parametrize - async def test_method_serve(self, async_client: AsyncBeeperDesktop) -> None: + @pytest.mark.respx(base_url=base_url) + async def test_method_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) asset = await async_client.assets.serve( url="x", ) - assert asset is None + assert asset.is_closed + assert await asset.json() == {"foo": "bar"} + assert cast(Any, asset.is_closed) is True + assert isinstance(asset, AsyncBinaryAPIResponse) @parametrize - async def test_raw_response_serve(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.assets.with_raw_response.serve( + @pytest.mark.respx(base_url=base_url) + async def test_raw_response_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + asset = await async_client.assets.with_raw_response.serve( url="x", ) - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - asset = await response.parse() - assert asset is None + assert asset.is_closed is True + assert asset.http_request.headers.get("X-Stainless-Lang") == "python" + assert await asset.json() == {"foo": "bar"} + assert isinstance(asset, AsyncBinaryAPIResponse) @parametrize - async def test_streaming_response_serve(self, async_client: AsyncBeeperDesktop) -> None: + @pytest.mark.respx(base_url=base_url) + async def test_streaming_response_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) async with async_client.assets.with_streaming_response.serve( url="x", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" + ) as asset: + assert not asset.is_closed + assert asset.http_request.headers.get("X-Stainless-Lang") == "python" - asset = await response.parse() - assert asset is None + assert await asset.json() == {"foo": "bar"} + assert cast(Any, asset.is_closed) is True + assert isinstance(asset, AsyncStreamedBinaryAPIResponse) - assert cast(Any, response.is_closed) is True + assert cast(Any, asset.is_closed) is True @parametrize async def test_method_upload(self, async_client: AsyncBeeperDesktop) -> None: diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py index 10de8b2..f03276e 100644 --- a/tests/api_resources/test_chats.py +++ b/tests/api_resources/test_chats.py @@ -25,31 +25,36 @@ class TestChats: @parametrize def test_method_create(self, client: BeeperDesktop) -> None: - chat = client.chats.create() + chat = client.chats.create( + account_id="accountID", + ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: chat = client.chats.create( - params={ - "account_id": "accountID", - "mode": "start", - "user": { - "id": "id", - "email": "email", - "full_name": "fullName", - "phone_number": "phoneNumber", - "username": "username", - }, - "allow_invite": True, - "message_text": "messageText", + account_id="accountID", + allow_invite=True, + message_text="messageText", + mode="start", + participant_ids=["string"], + title="title", + type="single", + user={ + "id": "id", + "email": "email", + "full_name": "fullName", + "phone_number": "phoneNumber", + "username": "username", }, ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize def test_raw_response_create(self, client: BeeperDesktop) -> None: - response = client.chats.with_raw_response.create() + response = client.chats.with_raw_response.create( + account_id="accountID", + ) assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -58,7 +63,9 @@ def test_raw_response_create(self, client: BeeperDesktop) -> None: @parametrize def test_streaming_response_create(self, client: BeeperDesktop) -> None: - with client.chats.with_streaming_response.create() as response: + with client.chats.with_streaming_response.create( + account_id="accountID", + ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -250,31 +257,36 @@ class TestAsyncChats: @parametrize async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: - chat = await async_client.chats.create() + chat = await async_client.chats.create( + account_id="accountID", + ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.create( - params={ - "account_id": "accountID", - "mode": "start", - "user": { - "id": "id", - "email": "email", - "full_name": "fullName", - "phone_number": "phoneNumber", - "username": "username", - }, - "allow_invite": True, - "message_text": "messageText", + account_id="accountID", + allow_invite=True, + message_text="messageText", + mode="start", + participant_ids=["string"], + title="title", + type="single", + user={ + "id": "id", + "email": "email", + "full_name": "fullName", + "phone_number": "phoneNumber", + "username": "username", }, ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @parametrize async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.chats.with_raw_response.create() + response = await async_client.chats.with_raw_response.create( + account_id="accountID", + ) assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -283,7 +295,9 @@ async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> No @parametrize async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) -> None: - async with async_client.chats.with_streaming_response.create() as response: + async with async_client.chats.with_streaming_response.create( + account_id="accountID", + ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py index a167221..fec66ee 100644 --- a/tests/api_resources/test_messages.py +++ b/tests/api_resources/test_messages.py @@ -14,7 +14,7 @@ MessageUpdateResponse, ) from beeper_desktop_api._utils import parse_datetime -from beeper_desktop_api.pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorSortKey, AsyncCursorSortKey +from beeper_desktop_api.pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorNoLimit, AsyncCursorNoLimit from beeper_desktop_api.types.shared import Message base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -81,7 +81,7 @@ def test_method_list(self, client: BeeperDesktop) -> None: message = client.messages.list( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert_matches_type(SyncCursorSortKey[Message], message, path=["response"]) + assert_matches_type(SyncCursorNoLimit[Message], message, path=["response"]) @parametrize def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: @@ -90,7 +90,7 @@ def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: cursor="1725489123456|c29tZUltc2dQYWdl", direction="before", ) - assert_matches_type(SyncCursorSortKey[Message], message, path=["response"]) + assert_matches_type(SyncCursorNoLimit[Message], message, path=["response"]) @parametrize def test_raw_response_list(self, client: BeeperDesktop) -> None: @@ -101,7 +101,7 @@ def test_raw_response_list(self, client: BeeperDesktop) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" message = response.parse() - assert_matches_type(SyncCursorSortKey[Message], message, path=["response"]) + assert_matches_type(SyncCursorNoLimit[Message], message, path=["response"]) @parametrize def test_streaming_response_list(self, client: BeeperDesktop) -> None: @@ -112,7 +112,7 @@ def test_streaming_response_list(self, client: BeeperDesktop) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" message = response.parse() - assert_matches_type(SyncCursorSortKey[Message], message, path=["response"]) + assert_matches_type(SyncCursorNoLimit[Message], message, path=["response"]) assert cast(Any, response.is_closed) is True @@ -292,7 +292,7 @@ async def test_method_list(self, async_client: AsyncBeeperDesktop) -> None: message = await async_client.messages.list( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", ) - assert_matches_type(AsyncCursorSortKey[Message], message, path=["response"]) + assert_matches_type(AsyncCursorNoLimit[Message], message, path=["response"]) @parametrize async def test_method_list_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: @@ -301,7 +301,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncBeeperDeskto cursor="1725489123456|c29tZUltc2dQYWdl", direction="before", ) - assert_matches_type(AsyncCursorSortKey[Message], message, path=["response"]) + assert_matches_type(AsyncCursorNoLimit[Message], message, path=["response"]) @parametrize async def test_raw_response_list(self, async_client: AsyncBeeperDesktop) -> None: @@ -312,7 +312,7 @@ async def test_raw_response_list(self, async_client: AsyncBeeperDesktop) -> None assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" message = await response.parse() - assert_matches_type(AsyncCursorSortKey[Message], message, path=["response"]) + assert_matches_type(AsyncCursorNoLimit[Message], message, path=["response"]) @parametrize async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) -> None: @@ -323,7 +323,7 @@ async def test_streaming_response_list(self, async_client: AsyncBeeperDesktop) - assert response.http_request.headers.get("X-Stainless-Lang") == "python" message = await response.parse() - assert_matches_type(AsyncCursorSortKey[Message], message, path=["response"]) + assert_matches_type(AsyncCursorNoLimit[Message], message, path=["response"]) assert cast(Any, response.is_closed) is True diff --git a/tests/test_client.py b/tests/test_client.py index 0e4b49b..d9ab686 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -729,7 +729,7 @@ def test_base_url_setter(self) -> None: client.close() def test_base_url_env(self) -> None: - with update_env(BEEPER_DESKTOP_BASE_URL="http://localhost:5000/from/env"): + with update_env(BEEPER_BASE_URL="http://localhost:5000/from/env"): client = BeeperDesktop(access_token=access_token, _strict_response_validation=True) assert client.base_url == "http://localhost:5000/from/env/" @@ -1680,7 +1680,7 @@ async def test_base_url_setter(self) -> None: await client.close() async def test_base_url_env(self) -> None: - with update_env(BEEPER_DESKTOP_BASE_URL="http://localhost:5000/from/env"): + with update_env(BEEPER_BASE_URL="http://localhost:5000/from/env"): client = AsyncBeeperDesktop(access_token=access_token, _strict_response_validation=True) assert client.base_url == "http://localhost:5000/from/env/" From 8699254fe6877748254383480d1aa9341d79c030 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:53:18 +0000 Subject: [PATCH 42/49] Preserve asset serve SDK compatibility --- .stats.yml | 2 +- api.md | 2 +- src/beeper_desktop_api/resources/assets.py | 34 +++----- tests/api_resources/test_assets.py | 90 ++++++++-------------- 4 files changed, 43 insertions(+), 85 deletions(-) diff --git a/.stats.yml b/.stats.yml index e925f68..1d3cc36 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-611aa7641fbca8cf31d626bf86f9efd3c2b92778e897ebbb25c6ea44185ed1ed.yml -openapi_spec_hash: 4840f003552e8b48eb8e689b59a819ef +openapi_spec_hash: 8dff13848934c5b20f3804236e8286d3 config_hash: 05ebdec072113f63395372504da98192 diff --git a/api.md b/api.md index 068f976..c0ddbc2 100644 --- a/api.md +++ b/api.md @@ -110,7 +110,7 @@ from beeper_desktop_api.types import ( Methods: - client.assets.download(\*\*params) -> AssetDownloadResponse -- client.assets.serve(\*\*params) -> BinaryAPIResponse +- client.assets.serve(\*\*params) -> None - client.assets.upload(\*\*params) -> AssetUploadResponse - client.assets.upload_base64(\*\*params) -> AssetUploadBase64Response diff --git a/src/beeper_desktop_api/resources/assets.py b/src/beeper_desktop_api/resources/assets.py index dc85070..652dcd5 100644 --- a/src/beeper_desktop_api/resources/assets.py +++ b/src/beeper_desktop_api/resources/assets.py @@ -8,23 +8,15 @@ from ..types import asset_serve_params, asset_upload_params, asset_download_params, asset_upload_base64_params from .._files import deepcopy_with_paths -from .._types import Body, Omit, Query, Headers, NotGiven, FileTypes, omit, not_given +from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, FileTypes, omit, not_given from .._utils import extract_files, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( - BinaryAPIResponse, - AsyncBinaryAPIResponse, - StreamedBinaryAPIResponse, - AsyncStreamedBinaryAPIResponse, to_raw_response_wrapper, to_streamed_response_wrapper, async_to_raw_response_wrapper, - to_custom_raw_response_wrapper, async_to_streamed_response_wrapper, - to_custom_streamed_response_wrapper, - async_to_custom_raw_response_wrapper, - async_to_custom_streamed_response_wrapper, ) from .._base_client import make_request_options from ..types.asset_upload_response import AssetUploadResponse @@ -101,7 +93,7 @@ def serve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BinaryAPIResponse: + ) -> None: """Stream a file given an mxc://, localmxc://, or file:// URL. Downloads first if @@ -118,7 +110,7 @@ def serve( timeout: Override the client-level default timeout for this request, in seconds """ - extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})} + extra_headers = {"Accept": "*/*", **(extra_headers or {})} return self._get( "/v1/assets/serve", options=make_request_options( @@ -128,7 +120,7 @@ def serve( timeout=timeout, query=maybe_transform({"url": url}, asset_serve_params.AssetServeParams), ), - cast_to=BinaryAPIResponse, + cast_to=NoneType, ) def upload( @@ -305,7 +297,7 @@ async def serve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncBinaryAPIResponse: + ) -> None: """Stream a file given an mxc://, localmxc://, or file:// URL. Downloads first if @@ -322,7 +314,7 @@ async def serve( timeout: Override the client-level default timeout for this request, in seconds """ - extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})} + extra_headers = {"Accept": "*/*", **(extra_headers or {})} return await self._get( "/v1/assets/serve", options=make_request_options( @@ -332,7 +324,7 @@ async def serve( timeout=timeout, query=await async_maybe_transform({"url": url}, asset_serve_params.AssetServeParams), ), - cast_to=AsyncBinaryAPIResponse, + cast_to=NoneType, ) async def upload( @@ -449,9 +441,8 @@ def __init__(self, assets: AssetsResource) -> None: self.download = to_raw_response_wrapper( assets.download, ) - self.serve = to_custom_raw_response_wrapper( + self.serve = to_raw_response_wrapper( assets.serve, - BinaryAPIResponse, ) self.upload = to_raw_response_wrapper( assets.upload, @@ -468,9 +459,8 @@ def __init__(self, assets: AsyncAssetsResource) -> None: self.download = async_to_raw_response_wrapper( assets.download, ) - self.serve = async_to_custom_raw_response_wrapper( + self.serve = async_to_raw_response_wrapper( assets.serve, - AsyncBinaryAPIResponse, ) self.upload = async_to_raw_response_wrapper( assets.upload, @@ -487,9 +477,8 @@ def __init__(self, assets: AssetsResource) -> None: self.download = to_streamed_response_wrapper( assets.download, ) - self.serve = to_custom_streamed_response_wrapper( + self.serve = to_streamed_response_wrapper( assets.serve, - StreamedBinaryAPIResponse, ) self.upload = to_streamed_response_wrapper( assets.upload, @@ -506,9 +495,8 @@ def __init__(self, assets: AsyncAssetsResource) -> None: self.download = async_to_streamed_response_wrapper( assets.download, ) - self.serve = async_to_custom_streamed_response_wrapper( + self.serve = async_to_streamed_response_wrapper( assets.serve, - AsyncStreamedBinaryAPIResponse, ) self.upload = async_to_streamed_response_wrapper( assets.upload, diff --git a/tests/api_resources/test_assets.py b/tests/api_resources/test_assets.py index f63f7bb..16d9ffa 100644 --- a/tests/api_resources/test_assets.py +++ b/tests/api_resources/test_assets.py @@ -5,9 +5,7 @@ import os from typing import Any, cast -import httpx import pytest -from respx import MockRouter from tests.utils import assert_matches_type from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop @@ -16,12 +14,6 @@ AssetDownloadResponse, AssetUploadBase64Response, ) -from beeper_desktop_api._response import ( - BinaryAPIResponse, - AsyncBinaryAPIResponse, - StreamedBinaryAPIResponse, - AsyncStreamedBinaryAPIResponse, -) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -61,46 +53,35 @@ def test_streaming_response_download(self, client: BeeperDesktop) -> None: assert cast(Any, response.is_closed) is True @parametrize - @pytest.mark.respx(base_url=base_url) - def test_method_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None: - respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + def test_method_serve(self, client: BeeperDesktop) -> None: asset = client.assets.serve( url="x", ) - assert asset.is_closed - assert asset.json() == {"foo": "bar"} - assert cast(Any, asset.is_closed) is True - assert isinstance(asset, BinaryAPIResponse) + assert asset is None @parametrize - @pytest.mark.respx(base_url=base_url) - def test_raw_response_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None: - respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - - asset = client.assets.with_raw_response.serve( + def test_raw_response_serve(self, client: BeeperDesktop) -> None: + response = client.assets.with_raw_response.serve( url="x", ) - assert asset.is_closed is True - assert asset.http_request.headers.get("X-Stainless-Lang") == "python" - assert asset.json() == {"foo": "bar"} - assert isinstance(asset, BinaryAPIResponse) + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + asset = response.parse() + assert asset is None @parametrize - @pytest.mark.respx(base_url=base_url) - def test_streaming_response_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None: - respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + def test_streaming_response_serve(self, client: BeeperDesktop) -> None: with client.assets.with_streaming_response.serve( url="x", - ) as asset: - assert not asset.is_closed - assert asset.http_request.headers.get("X-Stainless-Lang") == "python" + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" - assert asset.json() == {"foo": "bar"} - assert cast(Any, asset.is_closed) is True - assert isinstance(asset, StreamedBinaryAPIResponse) + asset = response.parse() + assert asset is None - assert cast(Any, asset.is_closed) is True + assert cast(Any, response.is_closed) is True @parametrize def test_method_upload(self, client: BeeperDesktop) -> None: @@ -220,46 +201,35 @@ async def test_streaming_response_download(self, async_client: AsyncBeeperDeskto assert cast(Any, response.is_closed) is True @parametrize - @pytest.mark.respx(base_url=base_url) - async def test_method_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None: - respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + async def test_method_serve(self, async_client: AsyncBeeperDesktop) -> None: asset = await async_client.assets.serve( url="x", ) - assert asset.is_closed - assert await asset.json() == {"foo": "bar"} - assert cast(Any, asset.is_closed) is True - assert isinstance(asset, AsyncBinaryAPIResponse) + assert asset is None @parametrize - @pytest.mark.respx(base_url=base_url) - async def test_raw_response_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None: - respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - - asset = await async_client.assets.with_raw_response.serve( + async def test_raw_response_serve(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.assets.with_raw_response.serve( url="x", ) - assert asset.is_closed is True - assert asset.http_request.headers.get("X-Stainless-Lang") == "python" - assert await asset.json() == {"foo": "bar"} - assert isinstance(asset, AsyncBinaryAPIResponse) + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + asset = await response.parse() + assert asset is None @parametrize - @pytest.mark.respx(base_url=base_url) - async def test_streaming_response_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None: - respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + async def test_streaming_response_serve(self, async_client: AsyncBeeperDesktop) -> None: async with async_client.assets.with_streaming_response.serve( url="x", - ) as asset: - assert not asset.is_closed - assert asset.http_request.headers.get("X-Stainless-Lang") == "python" + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" - assert await asset.json() == {"foo": "bar"} - assert cast(Any, asset.is_closed) is True - assert isinstance(asset, AsyncStreamedBinaryAPIResponse) + asset = await response.parse() + assert asset is None - assert cast(Any, asset.is_closed) is True + assert cast(Any, response.is_closed) is True @parametrize async def test_method_upload(self, async_client: AsyncBeeperDesktop) -> None: From ae59b66aa21c0eabcd08c4f411b3be45fb3e652f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:54:22 +0000 Subject: [PATCH 43/49] Document asset serve stream response --- .stats.yml | 2 +- api.md | 2 +- src/beeper_desktop_api/resources/assets.py | 34 +++++--- tests/api_resources/test_assets.py | 90 ++++++++++++++-------- 4 files changed, 85 insertions(+), 43 deletions(-) diff --git a/.stats.yml b/.stats.yml index 1d3cc36..e925f68 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-611aa7641fbca8cf31d626bf86f9efd3c2b92778e897ebbb25c6ea44185ed1ed.yml -openapi_spec_hash: 8dff13848934c5b20f3804236e8286d3 +openapi_spec_hash: 4840f003552e8b48eb8e689b59a819ef config_hash: 05ebdec072113f63395372504da98192 diff --git a/api.md b/api.md index c0ddbc2..068f976 100644 --- a/api.md +++ b/api.md @@ -110,7 +110,7 @@ from beeper_desktop_api.types import ( Methods: - client.assets.download(\*\*params) -> AssetDownloadResponse -- client.assets.serve(\*\*params) -> None +- client.assets.serve(\*\*params) -> BinaryAPIResponse - client.assets.upload(\*\*params) -> AssetUploadResponse - client.assets.upload_base64(\*\*params) -> AssetUploadBase64Response diff --git a/src/beeper_desktop_api/resources/assets.py b/src/beeper_desktop_api/resources/assets.py index 652dcd5..dc85070 100644 --- a/src/beeper_desktop_api/resources/assets.py +++ b/src/beeper_desktop_api/resources/assets.py @@ -8,15 +8,23 @@ from ..types import asset_serve_params, asset_upload_params, asset_download_params, asset_upload_base64_params from .._files import deepcopy_with_paths -from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, FileTypes, omit, not_given +from .._types import Body, Omit, Query, Headers, NotGiven, FileTypes, omit, not_given from .._utils import extract_files, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, to_raw_response_wrapper, to_streamed_response_wrapper, async_to_raw_response_wrapper, + to_custom_raw_response_wrapper, async_to_streamed_response_wrapper, + to_custom_streamed_response_wrapper, + async_to_custom_raw_response_wrapper, + async_to_custom_streamed_response_wrapper, ) from .._base_client import make_request_options from ..types.asset_upload_response import AssetUploadResponse @@ -93,7 +101,7 @@ def serve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: + ) -> BinaryAPIResponse: """Stream a file given an mxc://, localmxc://, or file:// URL. Downloads first if @@ -110,7 +118,7 @@ def serve( timeout: Override the client-level default timeout for this request, in seconds """ - extra_headers = {"Accept": "*/*", **(extra_headers or {})} + extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})} return self._get( "/v1/assets/serve", options=make_request_options( @@ -120,7 +128,7 @@ def serve( timeout=timeout, query=maybe_transform({"url": url}, asset_serve_params.AssetServeParams), ), - cast_to=NoneType, + cast_to=BinaryAPIResponse, ) def upload( @@ -297,7 +305,7 @@ async def serve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: + ) -> AsyncBinaryAPIResponse: """Stream a file given an mxc://, localmxc://, or file:// URL. Downloads first if @@ -314,7 +322,7 @@ async def serve( timeout: Override the client-level default timeout for this request, in seconds """ - extra_headers = {"Accept": "*/*", **(extra_headers or {})} + extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})} return await self._get( "/v1/assets/serve", options=make_request_options( @@ -324,7 +332,7 @@ async def serve( timeout=timeout, query=await async_maybe_transform({"url": url}, asset_serve_params.AssetServeParams), ), - cast_to=NoneType, + cast_to=AsyncBinaryAPIResponse, ) async def upload( @@ -441,8 +449,9 @@ def __init__(self, assets: AssetsResource) -> None: self.download = to_raw_response_wrapper( assets.download, ) - self.serve = to_raw_response_wrapper( + self.serve = to_custom_raw_response_wrapper( assets.serve, + BinaryAPIResponse, ) self.upload = to_raw_response_wrapper( assets.upload, @@ -459,8 +468,9 @@ def __init__(self, assets: AsyncAssetsResource) -> None: self.download = async_to_raw_response_wrapper( assets.download, ) - self.serve = async_to_raw_response_wrapper( + self.serve = async_to_custom_raw_response_wrapper( assets.serve, + AsyncBinaryAPIResponse, ) self.upload = async_to_raw_response_wrapper( assets.upload, @@ -477,8 +487,9 @@ def __init__(self, assets: AssetsResource) -> None: self.download = to_streamed_response_wrapper( assets.download, ) - self.serve = to_streamed_response_wrapper( + self.serve = to_custom_streamed_response_wrapper( assets.serve, + StreamedBinaryAPIResponse, ) self.upload = to_streamed_response_wrapper( assets.upload, @@ -495,8 +506,9 @@ def __init__(self, assets: AsyncAssetsResource) -> None: self.download = async_to_streamed_response_wrapper( assets.download, ) - self.serve = async_to_streamed_response_wrapper( + self.serve = async_to_custom_streamed_response_wrapper( assets.serve, + AsyncStreamedBinaryAPIResponse, ) self.upload = async_to_streamed_response_wrapper( assets.upload, diff --git a/tests/api_resources/test_assets.py b/tests/api_resources/test_assets.py index 16d9ffa..f63f7bb 100644 --- a/tests/api_resources/test_assets.py +++ b/tests/api_resources/test_assets.py @@ -5,7 +5,9 @@ import os from typing import Any, cast +import httpx import pytest +from respx import MockRouter from tests.utils import assert_matches_type from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop @@ -14,6 +16,12 @@ AssetDownloadResponse, AssetUploadBase64Response, ) +from beeper_desktop_api._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, +) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -53,35 +61,46 @@ def test_streaming_response_download(self, client: BeeperDesktop) -> None: assert cast(Any, response.is_closed) is True @parametrize - def test_method_serve(self, client: BeeperDesktop) -> None: + @pytest.mark.respx(base_url=base_url) + def test_method_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) asset = client.assets.serve( url="x", ) - assert asset is None + assert asset.is_closed + assert asset.json() == {"foo": "bar"} + assert cast(Any, asset.is_closed) is True + assert isinstance(asset, BinaryAPIResponse) @parametrize - def test_raw_response_serve(self, client: BeeperDesktop) -> None: - response = client.assets.with_raw_response.serve( + @pytest.mark.respx(base_url=base_url) + def test_raw_response_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + asset = client.assets.with_raw_response.serve( url="x", ) - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - asset = response.parse() - assert asset is None + assert asset.is_closed is True + assert asset.http_request.headers.get("X-Stainless-Lang") == "python" + assert asset.json() == {"foo": "bar"} + assert isinstance(asset, BinaryAPIResponse) @parametrize - def test_streaming_response_serve(self, client: BeeperDesktop) -> None: + @pytest.mark.respx(base_url=base_url) + def test_streaming_response_serve(self, client: BeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) with client.assets.with_streaming_response.serve( url="x", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" + ) as asset: + assert not asset.is_closed + assert asset.http_request.headers.get("X-Stainless-Lang") == "python" - asset = response.parse() - assert asset is None + assert asset.json() == {"foo": "bar"} + assert cast(Any, asset.is_closed) is True + assert isinstance(asset, StreamedBinaryAPIResponse) - assert cast(Any, response.is_closed) is True + assert cast(Any, asset.is_closed) is True @parametrize def test_method_upload(self, client: BeeperDesktop) -> None: @@ -201,35 +220,46 @@ async def test_streaming_response_download(self, async_client: AsyncBeeperDeskto assert cast(Any, response.is_closed) is True @parametrize - async def test_method_serve(self, async_client: AsyncBeeperDesktop) -> None: + @pytest.mark.respx(base_url=base_url) + async def test_method_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) asset = await async_client.assets.serve( url="x", ) - assert asset is None + assert asset.is_closed + assert await asset.json() == {"foo": "bar"} + assert cast(Any, asset.is_closed) is True + assert isinstance(asset, AsyncBinaryAPIResponse) @parametrize - async def test_raw_response_serve(self, async_client: AsyncBeeperDesktop) -> None: - response = await async_client.assets.with_raw_response.serve( + @pytest.mark.respx(base_url=base_url) + async def test_raw_response_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) + + asset = await async_client.assets.with_raw_response.serve( url="x", ) - assert response.is_closed is True - assert response.http_request.headers.get("X-Stainless-Lang") == "python" - asset = await response.parse() - assert asset is None + assert asset.is_closed is True + assert asset.http_request.headers.get("X-Stainless-Lang") == "python" + assert await asset.json() == {"foo": "bar"} + assert isinstance(asset, AsyncBinaryAPIResponse) @parametrize - async def test_streaming_response_serve(self, async_client: AsyncBeeperDesktop) -> None: + @pytest.mark.respx(base_url=base_url) + async def test_streaming_response_serve(self, async_client: AsyncBeeperDesktop, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/assets/serve").mock(return_value=httpx.Response(200, json={"foo": "bar"})) async with async_client.assets.with_streaming_response.serve( url="x", - ) as response: - assert not response.is_closed - assert response.http_request.headers.get("X-Stainless-Lang") == "python" + ) as asset: + assert not asset.is_closed + assert asset.http_request.headers.get("X-Stainless-Lang") == "python" - asset = await response.parse() - assert asset is None + assert await asset.json() == {"foo": "bar"} + assert cast(Any, asset.is_closed) is True + assert isinstance(asset, AsyncStreamedBinaryAPIResponse) - assert cast(Any, response.is_closed) is True + assert cast(Any, asset.is_closed) is True @parametrize async def test_method_upload(self, async_client: AsyncBeeperDesktop) -> None: From 8cdbacbf46b9986fc1d0fdca8b542a746b0133e9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:52:44 +0000 Subject: [PATCH 44/49] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index e925f68..a2edbe5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-611aa7641fbca8cf31d626bf86f9efd3c2b92778e897ebbb25c6ea44185ed1ed.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-fdefa92da44ff91a34438591d022c182a83d9daf9a28b452556e325c6ef7b3f0.yml openapi_spec_hash: 4840f003552e8b48eb8e689b59a819ef config_hash: 05ebdec072113f63395372504da98192 From 407623a59e11cf3576153511a20293cc9ca91ba7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 03:38:38 +0000 Subject: [PATCH 45/49] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index a2edbe5..ec75571 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-fdefa92da44ff91a34438591d022c182a83d9daf9a28b452556e325c6ef7b3f0.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-356444646dafe352d3ef7c2e01aedf030197a5519b41cf2c3fd8be2571456b43.yml openapi_spec_hash: 4840f003552e8b48eb8e689b59a819ef config_hash: 05ebdec072113f63395372504da98192 From f11e2a6c2e25d78bd95e0a81ce52e814dc2a9e79 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 03:42:40 +0000 Subject: [PATCH 46/49] chore(internal): reformat pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3a1513d..7cbdc0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -169,7 +169,7 @@ show_error_codes = true # # We also exclude our `tests` as mypy doesn't always infer # types correctly and Pyright will still catch any type errors. -exclude = ['src/beeper_desktop_api/_files.py', '_dev/.*.py', 'tests/.*'] +exclude = ["src/beeper_desktop_api/_files.py", "_dev/.*.py", "tests/.*"] strict_equality = true implicit_reexport = true From a1af475c4f4c51e2aed27ea8c793364444ed8055 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 18:06:57 +0000 Subject: [PATCH 47/49] feat(api): api update --- .github/workflows/detect-breaking-changes.yml | 42 ---- .stats.yml | 8 +- README.md | 4 +- api.md | 3 +- pyproject.toml | 1 - requirements-dev.lock | 3 - scripts/detect-breaking-changes | 19 -- scripts/detect-breaking-changes.py | 79 ------- .../resources/chats/chats.py | 198 +++++++++++++----- src/beeper_desktop_api/types/__init__.py | 2 + src/beeper_desktop_api/types/account.py | 10 +- .../types/chat_create_params.py | 59 +----- .../types/chat_start_params.py | 42 ++++ .../types/chat_start_response.py | 22 ++ tests/api_resources/test_chats.py | 163 ++++++++++++-- 15 files changed, 372 insertions(+), 283 deletions(-) delete mode 100644 .github/workflows/detect-breaking-changes.yml delete mode 100755 scripts/detect-breaking-changes delete mode 100644 scripts/detect-breaking-changes.py create mode 100644 src/beeper_desktop_api/types/chat_start_params.py create mode 100644 src/beeper_desktop_api/types/chat_start_response.py diff --git a/.github/workflows/detect-breaking-changes.yml b/.github/workflows/detect-breaking-changes.yml deleted file mode 100644 index 8514409..0000000 --- a/.github/workflows/detect-breaking-changes.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: CI -on: - pull_request: - branches: - - main - - next - -jobs: - detect_breaking_changes: - runs-on: 'ubuntu-latest' - name: detect-breaking-changes - if: github.repository == 'beeper/desktop-api-python' - steps: - - name: Calculate fetch-depth - run: | - echo "FETCH_DEPTH=$(expr ${{ github.event.pull_request.commits }} + 1)" >> $GITHUB_ENV - - - uses: actions/checkout@v6 - with: - # Ensure we can check out the pull request base in the script below. - fetch-depth: ${{ env.FETCH_DEPTH }} - - - name: Install Rye - run: | - curl -sSf https://rye.astral.sh/get | bash - echo "$HOME/.rye/shims" >> $GITHUB_PATH - env: - RYE_VERSION: '0.44.0' - RYE_INSTALL_OPTION: '--yes' - - name: Install dependencies - run: | - rye sync --all-features - - name: Detect removed symbols - run: | - rye run python scripts/detect-breaking-changes.py "${{ github.event.pull_request.base.sha }}" - - - name: Detect breaking changes - run: | - # Try to check out previous versions of the breaking change detection script. This ensures that - # we still detect breaking changes when entire files and their tests are removed. - git checkout "${{ github.event.pull_request.base.sha }}" -- ./scripts/detect-breaking-changes 2>/dev/null || true - ./scripts/detect-breaking-changes ${{ github.event.pull_request.base.sha }} \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index ec75571..75ad795 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-356444646dafe352d3ef7c2e01aedf030197a5519b41cf2c3fd8be2571456b43.yml -openapi_spec_hash: 4840f003552e8b48eb8e689b59a819ef -config_hash: 05ebdec072113f63395372504da98192 +configured_endpoints: 24 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-8b89ffbfeb39b4186328fefd81f7dab1d28c786012201feb8035c7f920c4fbae.yml +openapi_spec_hash: de40e013fcc83fa44d5c51ddecb543c0 +config_hash: 08b781db5f1857ed601d1b2f4b6b45d9 diff --git a/README.md b/README.md index bdee98a..13e8668 100644 --- a/README.md +++ b/README.md @@ -218,11 +218,11 @@ from beeper_desktop_api import BeeperDesktop client = BeeperDesktop() -chat = client.chats.create( +response = client.chats.start( account_id="accountID", user={}, ) -print(chat.user) +print(response.user) ``` ## File uploads diff --git a/api.md b/api.md index 068f976..9838884 100644 --- a/api.md +++ b/api.md @@ -47,7 +47,7 @@ Methods: Types: ```python -from beeper_desktop_api.types import Chat, ChatCreateResponse, ChatListResponse +from beeper_desktop_api.types import Chat, ChatCreateResponse, ChatListResponse, ChatStartResponse ``` Methods: @@ -57,6 +57,7 @@ Methods: - client.chats.list(\*\*params) -> SyncCursorNoLimit[ChatListResponse] - client.chats.archive(chat_id, \*\*params) -> None - client.chats.search(\*\*params) -> SyncCursorSearch[Chat] +- client.chats.start(\*\*params) -> ChatStartResponse ## Reminders diff --git a/pyproject.toml b/pyproject.toml index 7cbdc0a..7416fc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,6 @@ dev-dependencies = [ "importlib-metadata>=6.7.0", "rich>=13.7.1", "pytest-xdist>=3.6.1", - "griffe>=1", ] [tool.rye.scripts] diff --git a/requirements-dev.lock b/requirements-dev.lock index c74b1ba..2fdb945 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -34,8 +34,6 @@ backports-asyncio-runner==1.2.0 certifi==2026.1.4 # via httpcore # via httpx -colorama==0.4.6 - # via griffe colorlog==6.10.1 # via nox dependency-groups==1.3.1 @@ -55,7 +53,6 @@ filelock==3.19.1 frozenlist==1.8.0 # via aiohttp # via aiosignal -griffe==1.14.0 h11==0.16.0 # via httpcore httpcore==1.0.9 diff --git a/scripts/detect-breaking-changes b/scripts/detect-breaking-changes deleted file mode 100755 index fb28f3a..0000000 --- a/scripts/detect-breaking-changes +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -echo "==> Detecting breaking changes" - -TEST_PATHS=( tests/api_resources tests/test_client.py tests/test_response.py ) - -for PATHSPEC in "${TEST_PATHS[@]}"; do - # Try to check out previous versions of the test files - # with the current SDK. - git checkout "$1" -- "${PATHSPEC}" 2>/dev/null || true -done - -# Instead of running the tests, use the linter to check if an -# older test is no longer compatible with the latest SDK. -./scripts/lint diff --git a/scripts/detect-breaking-changes.py b/scripts/detect-breaking-changes.py deleted file mode 100644 index c61e8ba..0000000 --- a/scripts/detect-breaking-changes.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -import sys -from typing import Iterator -from pathlib import Path - -import rich -import griffe -from rich.text import Text -from rich.style import Style - - -def public_members(obj: griffe.Object | griffe.Alias) -> dict[str, griffe.Object | griffe.Alias]: - if isinstance(obj, griffe.Alias): - # ignore imports for now, they're technically part of the public API - # but we don't have good preventative measures in place to prevent - # changing them - return {} - - return {name: value for name, value in obj.all_members.items() if not name.startswith("_")} - - -def find_breaking_changes( - new_obj: griffe.Object | griffe.Alias, - old_obj: griffe.Object | griffe.Alias, - *, - path: list[str], -) -> Iterator[Text | str]: - new_members = public_members(new_obj) - old_members = public_members(old_obj) - - for name, old_member in old_members.items(): - if isinstance(old_member, griffe.Alias) and len(path) > 2: - # ignore imports in `/types/` for now, they're technically part of the public API - # but we don't have good preventative measures in place to prevent changing them - continue - - new_member = new_members.get(name) - if new_member is None: - cls_name = old_member.__class__.__name__ - yield Text(f"({cls_name})", style=Style(color="rgb(119, 119, 119)")) - yield from [" " for _ in range(10 - len(cls_name))] - yield f" {'.'.join(path)}.{name}" - yield "\n" - continue - - yield from find_breaking_changes(new_member, old_member, path=[*path, name]) - - -def main() -> None: - try: - against_ref = sys.argv[1] - except IndexError as err: - raise RuntimeError("You must specify a base ref to run breaking change detection against") from err - - package = griffe.load( - "beeper_desktop_api", - search_paths=[Path(__file__).parent.parent.joinpath("src")], - ) - old_package = griffe.load_git( - "beeper_desktop_api", - ref=against_ref, - search_paths=["src"], - ) - assert isinstance(package, griffe.Module) - assert isinstance(old_package, griffe.Module) - - output = list(find_breaking_changes(package, old_package, path=["beeper_desktop_api"])) - if output: - rich.print(Text("Breaking changes detected!", style=Style(color="rgb(165, 79, 87)"))) - rich.print() - - for text in output: - rich.print(text, end="") - - sys.exit(1) - - -main() diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py index 318ebf3..434d252 100644 --- a/src/beeper_desktop_api/resources/chats/chats.py +++ b/src/beeper_desktop_api/resources/chats/chats.py @@ -8,7 +8,14 @@ import httpx -from ...types import chat_list_params, chat_create_params, chat_search_params, chat_archive_params, chat_retrieve_params +from ...types import ( + chat_list_params, + chat_start_params, + chat_create_params, + chat_search_params, + chat_archive_params, + chat_retrieve_params, +) from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, SequenceNotStr, omit, not_given from ..._utils import path_template, maybe_transform, async_maybe_transform from ..._compat import cached_property @@ -39,6 +46,7 @@ AsyncMessagesResourceWithStreamingResponse, ) from ...types.chat_list_response import ChatListResponse +from ...types.chat_start_response import ChatStartResponse from ...types.chat_create_response import ChatCreateResponse __all__ = ["ChatsResource", "AsyncChatsResource"] @@ -80,13 +88,10 @@ def create( self, *, account_id: str, - allow_invite: bool | Omit = omit, + participant_ids: SequenceNotStr[str], + type: Literal["single", "group"], message_text: str | Omit = omit, - mode: Literal["start", "create"] | Omit = omit, - participant_ids: SequenceNotStr[str] | Omit = omit, title: str | Omit = omit, - type: Literal["single", "group"] | Omit = omit, - user: chat_create_params.User | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -95,31 +100,20 @@ def create( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ChatCreateResponse: """ - Create a direct or group chat with mode="create", or use mode="start" to resolve - a contact and open a direct chat. + Create a direct or group chat from participant IDs. Args: account_id: Account to create or start the chat on. - allow_invite: Only used for mode='start'. Whether invite-based DM creation is allowed when - required by the platform. - - message_text: Optional first message content if the platform requires it to create the chat. + participant_ids: User IDs to include in the new chat. - mode: Operation mode. Use 'start' to resolve a user/contact and start a direct chat. - Omit or set 'create' to create a chat directly. + type: 'single' requires exactly one participantID; 'group' supports multiple + participants and optional title. - participant_ids: Required for create mode. Provide exactly one user ID for 'single' chats and one - or more for 'group' chats. + message_text: Optional first message content if the platform requires it to create the chat. title: Optional title for group chats; ignored for single chats on most networks. - type: Required for create mode. 'single' creates a direct message chat; 'group' - creates a group chat. - - user: Required for mode='start'. Merged user-like contact payload used to resolve the - best identifier. - extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -133,13 +127,10 @@ def create( body=maybe_transform( { "account_id": account_id, - "allow_invite": allow_invite, - "message_text": message_text, - "mode": mode, "participant_ids": participant_ids, - "title": title, "type": type, - "user": user, + "message_text": message_text, + "title": title, }, chat_create_params.ChatCreateParams, ), @@ -386,6 +377,59 @@ def search( model=Chat, ) + def start( + self, + *, + account_id: str, + user: chat_start_params.User, + allow_invite: bool | Omit = omit, + message_text: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ChatStartResponse: + """Resolve a user/contact and open a direct chat. + + Reuses an existing direct chat + when one is found. Available in Beeper Desktop v4.2.799+. + + Args: + account_id: Account to create or start the chat on. + + user: Merged user-like contact payload used to resolve the best identifier. + + allow_invite: Whether invite-based DM creation is allowed when required by the platform. + + message_text: Optional first message content if the platform requires it to create the chat. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/chats.start", + body=maybe_transform( + { + "account_id": account_id, + "user": user, + "allow_invite": allow_invite, + "message_text": message_text, + }, + chat_start_params.ChatStartParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ChatStartResponse, + ) + class AsyncChatsResource(AsyncAPIResource): """Manage chats""" @@ -423,13 +467,10 @@ async def create( self, *, account_id: str, - allow_invite: bool | Omit = omit, + participant_ids: SequenceNotStr[str], + type: Literal["single", "group"], message_text: str | Omit = omit, - mode: Literal["start", "create"] | Omit = omit, - participant_ids: SequenceNotStr[str] | Omit = omit, title: str | Omit = omit, - type: Literal["single", "group"] | Omit = omit, - user: chat_create_params.User | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -438,31 +479,20 @@ async def create( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ChatCreateResponse: """ - Create a direct or group chat with mode="create", or use mode="start" to resolve - a contact and open a direct chat. + Create a direct or group chat from participant IDs. Args: account_id: Account to create or start the chat on. - allow_invite: Only used for mode='start'. Whether invite-based DM creation is allowed when - required by the platform. + participant_ids: User IDs to include in the new chat. - message_text: Optional first message content if the platform requires it to create the chat. + type: 'single' requires exactly one participantID; 'group' supports multiple + participants and optional title. - mode: Operation mode. Use 'start' to resolve a user/contact and start a direct chat. - Omit or set 'create' to create a chat directly. - - participant_ids: Required for create mode. Provide exactly one user ID for 'single' chats and one - or more for 'group' chats. + message_text: Optional first message content if the platform requires it to create the chat. title: Optional title for group chats; ignored for single chats on most networks. - type: Required for create mode. 'single' creates a direct message chat; 'group' - creates a group chat. - - user: Required for mode='start'. Merged user-like contact payload used to resolve the - best identifier. - extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -476,13 +506,10 @@ async def create( body=await async_maybe_transform( { "account_id": account_id, - "allow_invite": allow_invite, - "message_text": message_text, - "mode": mode, "participant_ids": participant_ids, - "title": title, "type": type, - "user": user, + "message_text": message_text, + "title": title, }, chat_create_params.ChatCreateParams, ), @@ -729,6 +756,59 @@ def search( model=Chat, ) + async def start( + self, + *, + account_id: str, + user: chat_start_params.User, + allow_invite: bool | Omit = omit, + message_text: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ChatStartResponse: + """Resolve a user/contact and open a direct chat. + + Reuses an existing direct chat + when one is found. Available in Beeper Desktop v4.2.799+. + + Args: + account_id: Account to create or start the chat on. + + user: Merged user-like contact payload used to resolve the best identifier. + + allow_invite: Whether invite-based DM creation is allowed when required by the platform. + + message_text: Optional first message content if the platform requires it to create the chat. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/chats.start", + body=await async_maybe_transform( + { + "account_id": account_id, + "user": user, + "allow_invite": allow_invite, + "message_text": message_text, + }, + chat_start_params.ChatStartParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ChatStartResponse, + ) + class ChatsResourceWithRawResponse: def __init__(self, chats: ChatsResource) -> None: @@ -749,6 +829,9 @@ def __init__(self, chats: ChatsResource) -> None: self.search = to_raw_response_wrapper( chats.search, ) + self.start = to_raw_response_wrapper( + chats.start, + ) @cached_property def reminders(self) -> RemindersResourceWithRawResponse: @@ -780,6 +863,9 @@ def __init__(self, chats: AsyncChatsResource) -> None: self.search = async_to_raw_response_wrapper( chats.search, ) + self.start = async_to_raw_response_wrapper( + chats.start, + ) @cached_property def reminders(self) -> AsyncRemindersResourceWithRawResponse: @@ -811,6 +897,9 @@ def __init__(self, chats: ChatsResource) -> None: self.search = to_streamed_response_wrapper( chats.search, ) + self.start = to_streamed_response_wrapper( + chats.start, + ) @cached_property def reminders(self) -> RemindersResourceWithStreamingResponse: @@ -842,6 +931,9 @@ def __init__(self, chats: AsyncChatsResource) -> None: self.search = async_to_streamed_response_wrapper( chats.search, ) + self.start = async_to_streamed_response_wrapper( + chats.start, + ) @cached_property def reminders(self) -> AsyncRemindersResourceWithStreamingResponse: diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py index 9e7445a..e072da8 100644 --- a/src/beeper_desktop_api/types/__init__.py +++ b/src/beeper_desktop_api/types/__init__.py @@ -8,12 +8,14 @@ from .focus_response import FocusResponse as FocusResponse from .search_response import SearchResponse as SearchResponse from .chat_list_params import ChatListParams as ChatListParams +from .chat_start_params import ChatStartParams as ChatStartParams from .asset_serve_params import AssetServeParams as AssetServeParams from .chat_create_params import ChatCreateParams as ChatCreateParams from .chat_list_response import ChatListResponse as ChatListResponse from .chat_search_params import ChatSearchParams as ChatSearchParams from .asset_upload_params import AssetUploadParams as AssetUploadParams from .chat_archive_params import ChatArchiveParams as ChatArchiveParams +from .chat_start_response import ChatStartResponse as ChatStartResponse from .client_focus_params import ClientFocusParams as ClientFocusParams from .message_list_params import MessageListParams as MessageListParams from .message_send_params import MessageSendParams as MessageSendParams diff --git a/src/beeper_desktop_api/types/account.py b/src/beeper_desktop_api/types/account.py index bb569a7..8dec8d5 100644 --- a/src/beeper_desktop_api/types/account.py +++ b/src/beeper_desktop_api/types/account.py @@ -12,16 +12,16 @@ class Bridge(BaseModel): - """Bridge metadata for the account. Available in Beeper Desktop v4.2.789+.""" + """Bridge metadata for the account. Available in Beeper Desktop v4.2.799+.""" id: str - """Bridge instance identifier. Available in Beeper Desktop v4.2.789+.""" + """Bridge instance identifier. Available in Beeper Desktop v4.2.799+.""" provider: Literal["cloud", "self-hosted", "local", "platform-sdk"] - """Bridge provider for the account. Available in Beeper Desktop v4.2.789+.""" + """Bridge provider for the account. Available in Beeper Desktop v4.2.799+.""" type: str - """Bridge type. Available in Beeper Desktop v4.2.789+.""" + """Bridge type. Available in Beeper Desktop v4.2.799+.""" class Account(BaseModel): @@ -31,7 +31,7 @@ class Account(BaseModel): """Chat account added to Beeper. Use this to route account-scoped actions.""" bridge: Bridge - """Bridge metadata for the account. Available in Beeper Desktop v4.2.789+.""" + """Bridge metadata for the account. Available in Beeper Desktop v4.2.799+.""" user: User """User the account belongs to.""" diff --git a/src/beeper_desktop_api/types/chat_create_params.py b/src/beeper_desktop_api/types/chat_create_params.py index b63be4f..8a4041f 100644 --- a/src/beeper_desktop_api/types/chat_create_params.py +++ b/src/beeper_desktop_api/types/chat_create_params.py @@ -7,69 +7,24 @@ from .._types import SequenceNotStr from .._utils import PropertyInfo -__all__ = ["ChatCreateParams", "User"] +__all__ = ["ChatCreateParams"] class ChatCreateParams(TypedDict, total=False): account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] """Account to create or start the chat on.""" - allow_invite: Annotated[bool, PropertyInfo(alias="allowInvite")] - """Only used for mode='start'. + participant_ids: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="participantIDs")]] + """User IDs to include in the new chat.""" - Whether invite-based DM creation is allowed when required by the platform. + type: Required[Literal["single", "group"]] + """ + 'single' requires exactly one participantID; 'group' supports multiple + participants and optional title. """ message_text: Annotated[str, PropertyInfo(alias="messageText")] """Optional first message content if the platform requires it to create the chat.""" - mode: Literal["start", "create"] - """Operation mode. - - Use 'start' to resolve a user/contact and start a direct chat. Omit or set - 'create' to create a chat directly. - """ - - participant_ids: Annotated[SequenceNotStr[str], PropertyInfo(alias="participantIDs")] - """Required for create mode. - - Provide exactly one user ID for 'single' chats and one or more for 'group' - chats. - """ - title: str """Optional title for group chats; ignored for single chats on most networks.""" - - type: Literal["single", "group"] - """Required for create mode. - - 'single' creates a direct message chat; 'group' creates a group chat. - """ - - user: User - """Required for mode='start'. - - Merged user-like contact payload used to resolve the best identifier. - """ - - -class User(TypedDict, total=False): - """Required for mode='start'. - - Merged user-like contact payload used to resolve the best identifier. - """ - - id: str - """Known user ID when available.""" - - email: str - """Email candidate.""" - - full_name: Annotated[str, PropertyInfo(alias="fullName")] - """Display name hint used for ranking only.""" - - phone_number: Annotated[str, PropertyInfo(alias="phoneNumber")] - """Phone number candidate (E.164 preferred).""" - - username: str - """Username/handle candidate.""" diff --git a/src/beeper_desktop_api/types/chat_start_params.py b/src/beeper_desktop_api/types/chat_start_params.py new file mode 100644 index 0000000..e70216d --- /dev/null +++ b/src/beeper_desktop_api/types/chat_start_params.py @@ -0,0 +1,42 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["ChatStartParams", "User"] + + +class ChatStartParams(TypedDict, total=False): + account_id: Required[Annotated[str, PropertyInfo(alias="accountID")]] + """Account to create or start the chat on.""" + + user: Required[User] + """Merged user-like contact payload used to resolve the best identifier.""" + + allow_invite: Annotated[bool, PropertyInfo(alias="allowInvite")] + """Whether invite-based DM creation is allowed when required by the platform.""" + + message_text: Annotated[str, PropertyInfo(alias="messageText")] + """Optional first message content if the platform requires it to create the chat.""" + + +class User(TypedDict, total=False): + """Merged user-like contact payload used to resolve the best identifier.""" + + id: str + """Known user ID when available.""" + + email: str + """Email candidate.""" + + full_name: Annotated[str, PropertyInfo(alias="fullName")] + """Display name hint used for ranking only.""" + + phone_number: Annotated[str, PropertyInfo(alias="phoneNumber")] + """Phone number candidate (E.164 preferred).""" + + username: str + """Username/handle candidate.""" diff --git a/src/beeper_desktop_api/types/chat_start_response.py b/src/beeper_desktop_api/types/chat_start_response.py new file mode 100644 index 0000000..89722c5 --- /dev/null +++ b/src/beeper_desktop_api/types/chat_start_response.py @@ -0,0 +1,22 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["ChatStartResponse"] + + +class ChatStartResponse(BaseModel): + chat_id: str = FieldInfo(alias="chatID") + """Newly created chat ID.""" + + status: Optional[Literal["existing", "created"]] = None + """Only returned in start mode. + + 'existing' means an existing chat was reused; 'created' means a new chat was + created. + """ diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py index f03276e..969d99c 100644 --- a/tests/api_resources/test_chats.py +++ b/tests/api_resources/test_chats.py @@ -12,6 +12,7 @@ from beeper_desktop_api.types import ( Chat, ChatListResponse, + ChatStartResponse, ChatCreateResponse, ) from beeper_desktop_api._utils import parse_datetime @@ -27,6 +28,8 @@ class TestChats: def test_method_create(self, client: BeeperDesktop) -> None: chat = client.chats.create( account_id="accountID", + participant_ids=["string"], + type="single", ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @@ -34,19 +37,10 @@ def test_method_create(self, client: BeeperDesktop) -> None: def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: chat = client.chats.create( account_id="accountID", - allow_invite=True, - message_text="messageText", - mode="start", participant_ids=["string"], - title="title", type="single", - user={ - "id": "id", - "email": "email", - "full_name": "fullName", - "phone_number": "phoneNumber", - "username": "username", - }, + message_text="messageText", + title="title", ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @@ -54,6 +48,8 @@ def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: def test_raw_response_create(self, client: BeeperDesktop) -> None: response = client.chats.with_raw_response.create( account_id="accountID", + participant_ids=["string"], + type="single", ) assert response.is_closed is True @@ -65,6 +61,8 @@ def test_raw_response_create(self, client: BeeperDesktop) -> None: def test_streaming_response_create(self, client: BeeperDesktop) -> None: with client.chats.with_streaming_response.create( account_id="accountID", + participant_ids=["string"], + type="single", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -249,6 +247,68 @@ def test_streaming_response_search(self, client: BeeperDesktop) -> None: assert cast(Any, response.is_closed) is True + @pytest.mark.skip( + reason="Stainless mock tests currently load the project-published OpenAPI spec URL, which may not include newly-added local-only endpoints during build checks." + ) + @parametrize + def test_method_start(self, client: BeeperDesktop) -> None: + chat = client.chats.start( + account_id="accountID", + user={}, + ) + assert_matches_type(ChatStartResponse, chat, path=["response"]) + + @pytest.mark.skip( + reason="Stainless mock tests currently load the project-published OpenAPI spec URL, which may not include newly-added local-only endpoints during build checks." + ) + @parametrize + def test_method_start_with_all_params(self, client: BeeperDesktop) -> None: + chat = client.chats.start( + account_id="accountID", + user={ + "id": "id", + "email": "email", + "full_name": "fullName", + "phone_number": "phoneNumber", + "username": "username", + }, + allow_invite=True, + message_text="messageText", + ) + assert_matches_type(ChatStartResponse, chat, path=["response"]) + + @pytest.mark.skip( + reason="Stainless mock tests currently load the project-published OpenAPI spec URL, which may not include newly-added local-only endpoints during build checks." + ) + @parametrize + def test_raw_response_start(self, client: BeeperDesktop) -> None: + response = client.chats.with_raw_response.start( + account_id="accountID", + user={}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = response.parse() + assert_matches_type(ChatStartResponse, chat, path=["response"]) + + @pytest.mark.skip( + reason="Stainless mock tests currently load the project-published OpenAPI spec URL, which may not include newly-added local-only endpoints during build checks." + ) + @parametrize + def test_streaming_response_start(self, client: BeeperDesktop) -> None: + with client.chats.with_streaming_response.start( + account_id="accountID", + user={}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = response.parse() + assert_matches_type(ChatStartResponse, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + class TestAsyncChats: parametrize = pytest.mark.parametrize( @@ -259,6 +319,8 @@ class TestAsyncChats: async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.create( account_id="accountID", + participant_ids=["string"], + type="single", ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @@ -266,19 +328,10 @@ async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.create( account_id="accountID", - allow_invite=True, - message_text="messageText", - mode="start", participant_ids=["string"], - title="title", type="single", - user={ - "id": "id", - "email": "email", - "full_name": "fullName", - "phone_number": "phoneNumber", - "username": "username", - }, + message_text="messageText", + title="title", ) assert_matches_type(ChatCreateResponse, chat, path=["response"]) @@ -286,6 +339,8 @@ async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesk async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> None: response = await async_client.chats.with_raw_response.create( account_id="accountID", + participant_ids=["string"], + type="single", ) assert response.is_closed is True @@ -297,6 +352,8 @@ async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> No async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) -> None: async with async_client.chats.with_streaming_response.create( account_id="accountID", + participant_ids=["string"], + type="single", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -480,3 +537,65 @@ async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) assert_matches_type(AsyncCursorSearch[Chat], chat, path=["response"]) assert cast(Any, response.is_closed) is True + + @pytest.mark.skip( + reason="Stainless mock tests currently load the project-published OpenAPI spec URL, which may not include newly-added local-only endpoints during build checks." + ) + @parametrize + async def test_method_start(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.start( + account_id="accountID", + user={}, + ) + assert_matches_type(ChatStartResponse, chat, path=["response"]) + + @pytest.mark.skip( + reason="Stainless mock tests currently load the project-published OpenAPI spec URL, which may not include newly-added local-only endpoints during build checks." + ) + @parametrize + async def test_method_start_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.start( + account_id="accountID", + user={ + "id": "id", + "email": "email", + "full_name": "fullName", + "phone_number": "phoneNumber", + "username": "username", + }, + allow_invite=True, + message_text="messageText", + ) + assert_matches_type(ChatStartResponse, chat, path=["response"]) + + @pytest.mark.skip( + reason="Stainless mock tests currently load the project-published OpenAPI spec URL, which may not include newly-added local-only endpoints during build checks." + ) + @parametrize + async def test_raw_response_start(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.with_raw_response.start( + account_id="accountID", + user={}, + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = await response.parse() + assert_matches_type(ChatStartResponse, chat, path=["response"]) + + @pytest.mark.skip( + reason="Stainless mock tests currently load the project-published OpenAPI spec URL, which may not include newly-added local-only endpoints during build checks." + ) + @parametrize + async def test_streaming_response_start(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.with_streaming_response.start( + account_id="accountID", + user={}, + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = await response.parse() + assert_matches_type(ChatStartResponse, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True From a5afb8f6a0037bc3727eca0a7ca0c46ca371c6f0 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 21:17:37 +0000 Subject: [PATCH 48/49] feat(api): api update --- .stats.yml | 8 +- README.md | 27 +- api.md | 10 +- src/beeper_desktop_api/_client.py | 42 +- .../resources/accounts/accounts.py | 8 +- src/beeper_desktop_api/resources/assets.py | 30 +- .../resources/chats/chats.py | 497 +++++++++++++++++- .../resources/chats/messages/reactions.py | 76 +-- .../resources/chats/reminders.py | 32 +- src/beeper_desktop_api/resources/info.py | 18 +- src/beeper_desktop_api/resources/messages.py | 252 ++++++++- src/beeper_desktop_api/types/__init__.py | 4 + src/beeper_desktop_api/types/account.py | 25 +- .../types/asset_download_params.py | 2 +- .../types/asset_download_response.py | 2 +- .../types/asset_serve_params.py | 2 +- .../types/asset_upload_base64_response.py | 4 +- .../types/asset_upload_response.py | 4 +- src/beeper_desktop_api/types/chat.py | 413 ++++++++++++++- .../types/chat_create_response.py | 11 +- .../types/chat_list_response.py | 2 + .../types/chat_mark_read_params.py | 14 + .../types/chat_mark_unread_params.py | 14 + .../types/chat_retrieve_params.py | 3 +- .../types/chat_start_response.py | 11 +- .../types/chat_update_params.py | 106 ++++ .../types/chats/messages/__init__.py | 1 - .../chats/messages/reaction_add_params.py | 8 +- .../chats/messages/reaction_add_response.py | 15 +- .../chats/messages/reaction_delete_params.py | 17 - .../messages/reaction_delete_response.py | 13 +- .../types/chats/reminder_create_params.py | 6 +- .../types/client_focus_params.py | 4 +- .../types/info_retrieve_response.py | 2 +- .../types/message_delete_params.py | 25 + .../types/message_send_params.py | 10 +- .../types/message_send_response.py | 12 +- .../types/message_update_params.py | 6 +- .../types/message_update_response.py | 18 +- .../types/shared/attachment.py | 22 +- .../types/shared/message.py | 107 +++- .../types/shared/reaction.py | 7 +- src/beeper_desktop_api/types/shared/user.py | 5 +- .../chats/messages/test_reactions.py | 75 +-- tests/api_resources/chats/test_reminders.py | 21 +- tests/api_resources/test_chats.py | 448 ++++++++++++++-- tests/api_resources/test_messages.py | 240 ++++++++- 47 files changed, 2361 insertions(+), 318 deletions(-) create mode 100644 src/beeper_desktop_api/types/chat_mark_read_params.py create mode 100644 src/beeper_desktop_api/types/chat_mark_unread_params.py create mode 100644 src/beeper_desktop_api/types/chat_update_params.py delete mode 100644 src/beeper_desktop_api/types/chats/messages/reaction_delete_params.py create mode 100644 src/beeper_desktop_api/types/message_delete_params.py diff --git a/.stats.yml b/.stats.yml index 75ad795..2dd3fee 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 24 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-8b89ffbfeb39b4186328fefd81f7dab1d28c786012201feb8035c7f920c4fbae.yml -openapi_spec_hash: de40e013fcc83fa44d5c51ddecb543c0 -config_hash: 08b781db5f1857ed601d1b2f4b6b45d9 +configured_endpoints: 30 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-c08c14bb754b4cb0e02b21fabb680469368286be339dec0aaa8c69d04a1f021a.yml +openapi_spec_hash: a10246aaf7cdc33b682fc245bd5f893b +config_hash: 72f9d43b9b51a5da912e9f3730e53ae2 diff --git a/README.md b/README.md index 13e8668..645fd65 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ client = BeeperDesktop( ) page = client.chats.search( + account_ids=["matrix", "discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"], include_muted=True, limit=3, type="single", @@ -71,6 +72,7 @@ client = AsyncBeeperDesktop( async def main() -> None: page = await client.chats.search( + account_ids=["matrix", "discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"], include_muted=True, limit=3, type="single", @@ -111,6 +113,7 @@ async def main() -> None: http_client=DefaultAioHttpClient(), ) as client: page = await client.chats.search( + account_ids=["matrix", "discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"], include_muted=True, limit=3, type="single", @@ -144,9 +147,9 @@ client = BeeperDesktop() all_messages = [] # Automatically fetches more pages as needed. for message in client.messages.search( - account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + account_ids=["discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"], limit=10, - query="deployment", + query="oauth", ): # Do something with message here all_messages.append(message) @@ -166,9 +169,9 @@ async def main() -> None: all_messages = [] # Iterate through items across all pages, issuing requests as needed. async for message in client.messages.search( - account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + account_ids=["discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"], limit=10, - query="deployment", + query="oauth", ): all_messages.append(message) print(all_messages) @@ -181,9 +184,9 @@ Alternatively, you can use the `.has_next_page()`, `.next_page_info()`, or `.get ```python first_page = await client.messages.search( - account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + account_ids=["discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"], limit=10, - query="deployment", + query="oauth", ) if first_page.has_next_page(): print(f"will fetch next page using these details: {first_page.next_page_info()}") @@ -197,9 +200,9 @@ Or just work directly with the returned data: ```python first_page = await client.messages.search( - account_ids=["local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"], + account_ids=["discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"], limit=10, - query="deployment", + query="oauth", ) print(f"next page cursor: {first_page.oldest_cursor}") # => "next page cursor: ..." @@ -218,11 +221,11 @@ from beeper_desktop_api import BeeperDesktop client = BeeperDesktop() -response = client.chats.start( - account_id="accountID", - user={}, +chat = client.chats.update( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + draft={"text": "text"}, ) -print(response.user) +print(chat.draft) ``` ## File uploads diff --git a/api.md b/api.md index 9838884..4818056 100644 --- a/api.md +++ b/api.md @@ -54,10 +54,14 @@ Methods: - client.chats.create(\*\*params) -> ChatCreateResponse - client.chats.retrieve(chat_id, \*\*params) -> Chat +- client.chats.update(chat_id, \*\*params) -> Chat - client.chats.list(\*\*params) -> SyncCursorNoLimit[ChatListResponse] - client.chats.archive(chat_id, \*\*params) -> None +- client.chats.mark_read(chat_id, \*\*params) -> Chat +- client.chats.mark_unread(chat_id, \*\*params) -> Chat +- client.chats.notify_anyway(chat_id) -> Chat - client.chats.search(\*\*params) -> SyncCursorSearch[Chat] -- client.chats.start(\*\*params) -> ChatStartResponse +- client.chats.start(\*\*params) -> ChatStartResponse ## Reminders @@ -78,7 +82,7 @@ from beeper_desktop_api.types.chats.messages import ReactionDeleteResponse, Reac Methods: -- client.chats.messages.reactions.delete(message_id, \*, chat_id, \*\*params) -> ReactionDeleteResponse +- client.chats.messages.reactions.delete(reaction_key, \*, chat_id, message_id) -> ReactionDeleteResponse - client.chats.messages.reactions.add(message_id, \*, chat_id, \*\*params) -> ReactionAddResponse # Messages @@ -91,8 +95,10 @@ from beeper_desktop_api.types import MessageUpdateResponse, MessageSendResponse Methods: +- client.messages.retrieve(message_id, \*, chat_id) -> Message - client.messages.update(message_id, \*, chat_id, \*\*params) -> MessageUpdateResponse - client.messages.list(chat_id, \*\*params) -> SyncCursorNoLimit[Message] +- client.messages.delete(message_id, \*, chat_id, \*\*params) -> None - client.messages.search(\*\*params) -> SyncCursorSearch[Message] - client.messages.send(chat_id, \*\*params) -> MessageSendResponse diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py index bf45e4d..6c43fcb 100644 --- a/src/beeper_desktop_api/_client.py +++ b/src/beeper_desktop_api/_client.py @@ -165,7 +165,10 @@ def assets(self) -> AssetsResource: @cached_property def info(self) -> InfoResource: - """Control the Beeper Desktop application""" + """Server discovery and capability metadata. + + Use /v1/info before authentication setup. + """ from .resources.info import InfoResource return InfoResource(self) @@ -270,15 +273,15 @@ def focus( ) -> FocusResponse: """ Focus Beeper Desktop and optionally navigate to a specific chat, message, or - pre-fill draft text and attachment. + pre-fill plain text and an image path. Args: chat_id: Optional Beeper chat ID (or local chat ID) to focus after opening the app. If omitted, only opens/focuses the app. - draft_attachment_path: Optional draft attachment path to populate in the message input field. + draft_attachment_path: Optional image path to populate in the message input field. - draft_text: Optional draft text to populate in the message input field. + draft_text: Optional plain text to populate in the message input field. message_id: Optional message ID. Jumps to that message in the chat when opening. @@ -474,7 +477,10 @@ def assets(self) -> AsyncAssetsResource: @cached_property def info(self) -> AsyncInfoResource: - """Control the Beeper Desktop application""" + """Server discovery and capability metadata. + + Use /v1/info before authentication setup. + """ from .resources.info import AsyncInfoResource return AsyncInfoResource(self) @@ -579,15 +585,15 @@ async def focus( ) -> FocusResponse: """ Focus Beeper Desktop and optionally navigate to a specific chat, message, or - pre-fill draft text and attachment. + pre-fill plain text and an image path. Args: chat_id: Optional Beeper chat ID (or local chat ID) to focus after opening the app. If omitted, only opens/focuses the app. - draft_attachment_path: Optional draft attachment path to populate in the message input field. + draft_attachment_path: Optional image path to populate in the message input field. - draft_text: Optional draft text to populate in the message input field. + draft_text: Optional plain text to populate in the message input field. message_id: Optional message ID. Jumps to that message in the chat when opening. @@ -732,7 +738,10 @@ def assets(self) -> assets.AssetsResourceWithRawResponse: @cached_property def info(self) -> info.InfoResourceWithRawResponse: - """Control the Beeper Desktop application""" + """Server discovery and capability metadata. + + Use /v1/info before authentication setup. + """ from .resources.info import InfoResourceWithRawResponse return InfoResourceWithRawResponse(self._client.info) @@ -781,7 +790,10 @@ def assets(self) -> assets.AsyncAssetsResourceWithRawResponse: @cached_property def info(self) -> info.AsyncInfoResourceWithRawResponse: - """Control the Beeper Desktop application""" + """Server discovery and capability metadata. + + Use /v1/info before authentication setup. + """ from .resources.info import AsyncInfoResourceWithRawResponse return AsyncInfoResourceWithRawResponse(self._client.info) @@ -830,7 +842,10 @@ def assets(self) -> assets.AssetsResourceWithStreamingResponse: @cached_property def info(self) -> info.InfoResourceWithStreamingResponse: - """Control the Beeper Desktop application""" + """Server discovery and capability metadata. + + Use /v1/info before authentication setup. + """ from .resources.info import InfoResourceWithStreamingResponse return InfoResourceWithStreamingResponse(self._client.info) @@ -879,7 +894,10 @@ def assets(self) -> assets.AsyncAssetsResourceWithStreamingResponse: @cached_property def info(self) -> info.AsyncInfoResourceWithStreamingResponse: - """Control the Beeper Desktop application""" + """Server discovery and capability metadata. + + Use /v1/info before authentication setup. + """ from .resources.info import AsyncInfoResourceWithStreamingResponse return AsyncInfoResourceWithStreamingResponse(self._client.info) diff --git a/src/beeper_desktop_api/resources/accounts/accounts.py b/src/beeper_desktop_api/resources/accounts/accounts.py index a86fa76..15cfd33 100644 --- a/src/beeper_desktop_api/resources/accounts/accounts.py +++ b/src/beeper_desktop_api/resources/accounts/accounts.py @@ -65,8 +65,8 @@ def list( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AccountListResponse: """ - Lists chat accounts across networks (WhatsApp, Telegram, Twitter/X, etc.) - actively connected to this Beeper Desktop instance + List Chat Accounts connected to this Beeper Desktop instance, including bridge + metadata and network identity. """ return self._get( "/v1/accounts", @@ -115,8 +115,8 @@ async def list( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AccountListResponse: """ - Lists chat accounts across networks (WhatsApp, Telegram, Twitter/X, etc.) - actively connected to this Beeper Desktop instance + List Chat Accounts connected to this Beeper Desktop instance, including bridge + metadata and network identity. """ return await self._get( "/v1/accounts", diff --git a/src/beeper_desktop_api/resources/assets.py b/src/beeper_desktop_api/resources/assets.py index dc85070..ccfeab4 100644 --- a/src/beeper_desktop_api/resources/assets.py +++ b/src/beeper_desktop_api/resources/assets.py @@ -68,11 +68,11 @@ def download( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AssetDownloadResponse: """ - Download a Matrix asset using its mxc:// or localmxc:// URL to the device - running Beeper Desktop and return the local file URL. + Download a Matrix file using its mxc:// or localmxc:// URL to the device running + Beeper Desktop and return the local file URL. Args: - url: Matrix content URL (mxc:// or localmxc://) for the asset to download. + url: Matrix content URL (mxc:// or localmxc://) for the file to download. extra_headers: Send extra headers @@ -108,7 +108,7 @@ def serve( not cached. Supports Range requests for seeking in large files. Args: - url: Asset URL to serve. Accepts mxc://, localmxc://, or file:// URLs. + url: File URL to serve. Accepts mxc://, localmxc://, or file:// URLs. extra_headers: Send extra headers @@ -147,7 +147,8 @@ def upload( """Upload a file to a temporary location using multipart/form-data. Returns an - uploadID that can be referenced when sending messages with attachments. + uploadID that can be referenced when sending a message or materializing a draft + attachment. Args: file: The file to upload (max 500 MB). @@ -203,8 +204,8 @@ def upload_base64( """Upload a file using a JSON body with base64-encoded content. Returns an uploadID - that can be referenced when sending messages with attachments. Alternative to - the multipart upload endpoint. + that can be referenced when sending a message or materializing a draft + attachment. Alternative to the multipart upload endpoint. Args: content: Base64-encoded file content (max ~500MB decoded) @@ -272,11 +273,11 @@ async def download( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AssetDownloadResponse: """ - Download a Matrix asset using its mxc:// or localmxc:// URL to the device - running Beeper Desktop and return the local file URL. + Download a Matrix file using its mxc:// or localmxc:// URL to the device running + Beeper Desktop and return the local file URL. Args: - url: Matrix content URL (mxc:// or localmxc://) for the asset to download. + url: Matrix content URL (mxc:// or localmxc://) for the file to download. extra_headers: Send extra headers @@ -312,7 +313,7 @@ async def serve( not cached. Supports Range requests for seeking in large files. Args: - url: Asset URL to serve. Accepts mxc://, localmxc://, or file:// URLs. + url: File URL to serve. Accepts mxc://, localmxc://, or file:// URLs. extra_headers: Send extra headers @@ -351,7 +352,8 @@ async def upload( """Upload a file to a temporary location using multipart/form-data. Returns an - uploadID that can be referenced when sending messages with attachments. + uploadID that can be referenced when sending a message or materializing a draft + attachment. Args: file: The file to upload (max 500 MB). @@ -407,8 +409,8 @@ async def upload_base64( """Upload a file using a JSON body with base64-encoded content. Returns an uploadID - that can be referenced when sending messages with attachments. Alternative to - the multipart upload endpoint. + that can be referenced when sending a message or materializing a draft + attachment. Alternative to the multipart upload endpoint. Args: content: Base64-encoded file content (max ~500MB decoded) diff --git a/src/beeper_desktop_api/resources/chats/chats.py b/src/beeper_desktop_api/resources/chats/chats.py index 434d252..38775e0 100644 --- a/src/beeper_desktop_api/resources/chats/chats.py +++ b/src/beeper_desktop_api/resources/chats/chats.py @@ -13,8 +13,11 @@ chat_start_params, chat_create_params, chat_search_params, + chat_update_params, chat_archive_params, chat_retrieve_params, + chat_mark_read_params, + chat_mark_unread_params, ) from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, SequenceNotStr, omit, not_given from ..._utils import path_template, maybe_transform, async_maybe_transform @@ -99,8 +102,9 @@ def create( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ChatCreateResponse: - """ - Create a direct or group chat from participant IDs. + """Create a direct or group chat from participant IDs. + + Returns the created chat. Args: account_id: Account to create or start the chat on. @@ -156,10 +160,12 @@ def retrieve( Retrieve chat details including metadata, participants, and latest message Args: - chat_id: Unique identifier of the chat. + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. - max_participant_count: Maximum number of participants to return. Use -1 for all; otherwise 0–500. - Defaults to all (-1). + max_participant_count: Maximum number of participants to return. Use -1 for all; otherwise 0-500. + Defaults to 100. List and search endpoints return up to 20 participants per + chat. extra_headers: Send extra headers @@ -185,6 +191,90 @@ def retrieve( cast_to=Chat, ) + def update( + self, + chat_id: str, + *, + description: Optional[str] | Omit = omit, + draft: Optional[chat_update_params.Draft] | Omit = omit, + img_url: Optional[str] | Omit = omit, + is_archived: bool | Omit = omit, + is_low_priority: bool | Omit = omit, + is_muted: bool | Omit = omit, + is_pinned: bool | Omit = omit, + message_expiry_seconds: Optional[int] | Omit = omit, + title: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Chat: + """Update supported chat fields. + + Non-empty draft objects are accepted only when the + current draft is empty. Send draft=null to clear the draft before setting new + draft text or attachments. + + Args: + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. + + description: Group chat description/topic. Support depends on the chat account and chat + permissions. + + draft: Draft object to set or clear. Non-empty drafts are only accepted when the + current draft is empty. Send draft=null to clear text and attachments together + before setting a new draft. + + img_url: Local filesystem path to a group chat avatar image. Support depends on the chat + account and chat permissions. + + is_archived: Archive or unarchive the chat. + + is_low_priority: Mark or unmark the chat as low priority when supported by the account. + + is_muted: Mute or unmute the chat. + + is_pinned: Pin or unpin the chat when supported by the account. + + message_expiry_seconds: Disappearing-message timer in seconds, or null to clear when supported. + + title: Custom chat title. Support depends on the chat account and chat permissions. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + return self._patch( + path_template("/v1/chats/{chat_id}", chat_id=chat_id), + body=maybe_transform( + { + "description": description, + "draft": draft, + "img_url": img_url, + "is_archived": is_archived, + "is_low_priority": is_low_priority, + "is_muted": is_muted, + "is_pinned": is_pinned, + "message_expiry_seconds": message_expiry_seconds, + "title": title, + }, + chat_update_params.ChatUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Chat, + ) + def list( self, *, @@ -257,7 +347,8 @@ def archive( archived=false to move back to inbox Args: - chat_id: Unique identifier of the chat. + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. archived: True to archive, false to unarchive @@ -281,6 +372,123 @@ def archive( cast_to=NoneType, ) + def mark_read( + self, + chat_id: str, + *, + message_id: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Chat: + """ + Mark a chat as read, optionally through a specific message ID. + + Args: + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. + + message_id: Optional message ID to mark read through. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + return self._post( + path_template("/v1/chats/{chat_id}/read", chat_id=chat_id), + body=maybe_transform({"message_id": message_id}, chat_mark_read_params.ChatMarkReadParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Chat, + ) + + def mark_unread( + self, + chat_id: str, + *, + message_id: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Chat: + """ + Mark a chat as unread, optionally from a specific message ID. + + Args: + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. + + message_id: Optional message ID to mark unread from. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + return self._post( + path_template("/v1/chats/{chat_id}/unread", chat_id=chat_id), + body=maybe_transform({"message_id": message_id}, chat_mark_unread_params.ChatMarkUnreadParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Chat, + ) + + def notify_anyway( + self, + chat_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Chat: + """ + Force a delivery notification when supported by the underlying network. + Currently intended for iMessage on macOS; unsupported networks return an error. + + Args: + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + return self._post( + path_template("/v1/chats/{chat_id}/notify-anyway", chat_id=chat_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Chat, + ) + def search( self, *, @@ -393,8 +601,8 @@ def start( ) -> ChatStartResponse: """Resolve a user/contact and open a direct chat. - Reuses an existing direct chat - when one is found. Available in Beeper Desktop v4.2.799+. + Reuses and returns an existing + direct chat when one is found. Available in Beeper Desktop v4.2.808+. Args: account_id: Account to create or start the chat on. @@ -414,7 +622,7 @@ def start( timeout: Override the client-level default timeout for this request, in seconds """ return self._post( - "/v1/chats.start", + "/v1/chats/start", body=maybe_transform( { "account_id": account_id, @@ -478,8 +686,9 @@ async def create( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ChatCreateResponse: - """ - Create a direct or group chat from participant IDs. + """Create a direct or group chat from participant IDs. + + Returns the created chat. Args: account_id: Account to create or start the chat on. @@ -535,10 +744,12 @@ async def retrieve( Retrieve chat details including metadata, participants, and latest message Args: - chat_id: Unique identifier of the chat. + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. - max_participant_count: Maximum number of participants to return. Use -1 for all; otherwise 0–500. - Defaults to all (-1). + max_participant_count: Maximum number of participants to return. Use -1 for all; otherwise 0-500. + Defaults to 100. List and search endpoints return up to 20 participants per + chat. extra_headers: Send extra headers @@ -564,6 +775,90 @@ async def retrieve( cast_to=Chat, ) + async def update( + self, + chat_id: str, + *, + description: Optional[str] | Omit = omit, + draft: Optional[chat_update_params.Draft] | Omit = omit, + img_url: Optional[str] | Omit = omit, + is_archived: bool | Omit = omit, + is_low_priority: bool | Omit = omit, + is_muted: bool | Omit = omit, + is_pinned: bool | Omit = omit, + message_expiry_seconds: Optional[int] | Omit = omit, + title: Optional[str] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Chat: + """Update supported chat fields. + + Non-empty draft objects are accepted only when the + current draft is empty. Send draft=null to clear the draft before setting new + draft text or attachments. + + Args: + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. + + description: Group chat description/topic. Support depends on the chat account and chat + permissions. + + draft: Draft object to set or clear. Non-empty drafts are only accepted when the + current draft is empty. Send draft=null to clear text and attachments together + before setting a new draft. + + img_url: Local filesystem path to a group chat avatar image. Support depends on the chat + account and chat permissions. + + is_archived: Archive or unarchive the chat. + + is_low_priority: Mark or unmark the chat as low priority when supported by the account. + + is_muted: Mute or unmute the chat. + + is_pinned: Pin or unpin the chat when supported by the account. + + message_expiry_seconds: Disappearing-message timer in seconds, or null to clear when supported. + + title: Custom chat title. Support depends on the chat account and chat permissions. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + return await self._patch( + path_template("/v1/chats/{chat_id}", chat_id=chat_id), + body=await async_maybe_transform( + { + "description": description, + "draft": draft, + "img_url": img_url, + "is_archived": is_archived, + "is_low_priority": is_low_priority, + "is_muted": is_muted, + "is_pinned": is_pinned, + "message_expiry_seconds": message_expiry_seconds, + "title": title, + }, + chat_update_params.ChatUpdateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Chat, + ) + def list( self, *, @@ -636,7 +931,8 @@ async def archive( archived=false to move back to inbox Args: - chat_id: Unique identifier of the chat. + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. archived: True to archive, false to unarchive @@ -660,6 +956,123 @@ async def archive( cast_to=NoneType, ) + async def mark_read( + self, + chat_id: str, + *, + message_id: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Chat: + """ + Mark a chat as read, optionally through a specific message ID. + + Args: + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. + + message_id: Optional message ID to mark read through. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + return await self._post( + path_template("/v1/chats/{chat_id}/read", chat_id=chat_id), + body=await async_maybe_transform({"message_id": message_id}, chat_mark_read_params.ChatMarkReadParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Chat, + ) + + async def mark_unread( + self, + chat_id: str, + *, + message_id: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Chat: + """ + Mark a chat as unread, optionally from a specific message ID. + + Args: + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. + + message_id: Optional message ID to mark unread from. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + return await self._post( + path_template("/v1/chats/{chat_id}/unread", chat_id=chat_id), + body=await async_maybe_transform({"message_id": message_id}, chat_mark_unread_params.ChatMarkUnreadParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Chat, + ) + + async def notify_anyway( + self, + chat_id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Chat: + """ + Force a delivery notification when supported by the underlying network. + Currently intended for iMessage on macOS; unsupported networks return an error. + + Args: + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + return await self._post( + path_template("/v1/chats/{chat_id}/notify-anyway", chat_id=chat_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Chat, + ) + def search( self, *, @@ -772,8 +1185,8 @@ async def start( ) -> ChatStartResponse: """Resolve a user/contact and open a direct chat. - Reuses an existing direct chat - when one is found. Available in Beeper Desktop v4.2.799+. + Reuses and returns an existing + direct chat when one is found. Available in Beeper Desktop v4.2.808+. Args: account_id: Account to create or start the chat on. @@ -793,7 +1206,7 @@ async def start( timeout: Override the client-level default timeout for this request, in seconds """ return await self._post( - "/v1/chats.start", + "/v1/chats/start", body=await async_maybe_transform( { "account_id": account_id, @@ -820,12 +1233,24 @@ def __init__(self, chats: ChatsResource) -> None: self.retrieve = to_raw_response_wrapper( chats.retrieve, ) + self.update = to_raw_response_wrapper( + chats.update, + ) self.list = to_raw_response_wrapper( chats.list, ) self.archive = to_raw_response_wrapper( chats.archive, ) + self.mark_read = to_raw_response_wrapper( + chats.mark_read, + ) + self.mark_unread = to_raw_response_wrapper( + chats.mark_unread, + ) + self.notify_anyway = to_raw_response_wrapper( + chats.notify_anyway, + ) self.search = to_raw_response_wrapper( chats.search, ) @@ -854,12 +1279,24 @@ def __init__(self, chats: AsyncChatsResource) -> None: self.retrieve = async_to_raw_response_wrapper( chats.retrieve, ) + self.update = async_to_raw_response_wrapper( + chats.update, + ) self.list = async_to_raw_response_wrapper( chats.list, ) self.archive = async_to_raw_response_wrapper( chats.archive, ) + self.mark_read = async_to_raw_response_wrapper( + chats.mark_read, + ) + self.mark_unread = async_to_raw_response_wrapper( + chats.mark_unread, + ) + self.notify_anyway = async_to_raw_response_wrapper( + chats.notify_anyway, + ) self.search = async_to_raw_response_wrapper( chats.search, ) @@ -888,12 +1325,24 @@ def __init__(self, chats: ChatsResource) -> None: self.retrieve = to_streamed_response_wrapper( chats.retrieve, ) + self.update = to_streamed_response_wrapper( + chats.update, + ) self.list = to_streamed_response_wrapper( chats.list, ) self.archive = to_streamed_response_wrapper( chats.archive, ) + self.mark_read = to_streamed_response_wrapper( + chats.mark_read, + ) + self.mark_unread = to_streamed_response_wrapper( + chats.mark_unread, + ) + self.notify_anyway = to_streamed_response_wrapper( + chats.notify_anyway, + ) self.search = to_streamed_response_wrapper( chats.search, ) @@ -922,12 +1371,24 @@ def __init__(self, chats: AsyncChatsResource) -> None: self.retrieve = async_to_streamed_response_wrapper( chats.retrieve, ) + self.update = async_to_streamed_response_wrapper( + chats.update, + ) self.list = async_to_streamed_response_wrapper( chats.list, ) self.archive = async_to_streamed_response_wrapper( chats.archive, ) + self.mark_read = async_to_streamed_response_wrapper( + chats.mark_read, + ) + self.mark_unread = async_to_streamed_response_wrapper( + chats.mark_unread, + ) + self.notify_anyway = async_to_streamed_response_wrapper( + chats.notify_anyway, + ) self.search = async_to_streamed_response_wrapper( chats.search, ) diff --git a/src/beeper_desktop_api/resources/chats/messages/reactions.py b/src/beeper_desktop_api/resources/chats/messages/reactions.py index bdf93a5..974d9f6 100644 --- a/src/beeper_desktop_api/resources/chats/messages/reactions.py +++ b/src/beeper_desktop_api/resources/chats/messages/reactions.py @@ -15,7 +15,7 @@ async_to_streamed_response_wrapper, ) from ...._base_client import make_request_options -from ....types.chats.messages import reaction_add_params, reaction_delete_params +from ....types.chats.messages import reaction_add_params from ....types.chats.messages.reaction_add_response import ReactionAddResponse from ....types.chats.messages.reaction_delete_response import ReactionDeleteResponse @@ -46,10 +46,10 @@ def with_streaming_response(self) -> ReactionsResourceWithStreamingResponse: def delete( self, - message_id: str, + reaction_key: str, *, chat_id: str, - reaction_key: str, + message_id: str, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -61,9 +61,12 @@ def delete( Remove the reaction added by the authenticated user from an existing message. Args: - chat_id: Unique identifier of the chat. + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. + + message_id: Message ID. - reaction_key: Reaction key to remove + reaction_key: Reaction key to remove (emoji, shortcode, or custom emoji key) extra_headers: Send extra headers @@ -77,16 +80,17 @@ def delete( raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") if not message_id: raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") + if not reaction_key: + raise ValueError(f"Expected a non-empty value for `reaction_key` but received {reaction_key!r}") return self._delete( path_template( - "/v1/chats/{chat_id}/messages/{message_id}/reactions", chat_id=chat_id, message_id=message_id + "/v1/chats/{chat_id}/messages/{message_id}/reactions/{reaction_key}", + chat_id=chat_id, + message_id=message_id, + reaction_key=reaction_key, ), options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=maybe_transform({"reaction_key": reaction_key}, reaction_delete_params.ReactionDeleteParams), + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), cast_to=ReactionDeleteResponse, ) @@ -105,15 +109,19 @@ def add( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ReactionAddResponse: - """ - Add a reaction to an existing message. + """Add a reaction to an existing message. Args: - chat_id: Unique identifier of the chat. + chat_id: Chat ID. + + Input routes also accept the local chat ID from this Beeper Desktop + installation when available. + + message_id: Message ID. reaction_key: Reaction key to add (emoji, shortcode, or custom emoji key) - transaction_id: Optional transaction ID for deduplication and local echo tracking + transaction_id: Optional transaction ID for deduplication and send tracking extra_headers: Send extra headers @@ -169,10 +177,10 @@ def with_streaming_response(self) -> AsyncReactionsResourceWithStreamingResponse async def delete( self, - message_id: str, + reaction_key: str, *, chat_id: str, - reaction_key: str, + message_id: str, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -184,9 +192,12 @@ async def delete( Remove the reaction added by the authenticated user from an existing message. Args: - chat_id: Unique identifier of the chat. + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. + + message_id: Message ID. - reaction_key: Reaction key to remove + reaction_key: Reaction key to remove (emoji, shortcode, or custom emoji key) extra_headers: Send extra headers @@ -200,18 +211,17 @@ async def delete( raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") if not message_id: raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") + if not reaction_key: + raise ValueError(f"Expected a non-empty value for `reaction_key` but received {reaction_key!r}") return await self._delete( path_template( - "/v1/chats/{chat_id}/messages/{message_id}/reactions", chat_id=chat_id, message_id=message_id + "/v1/chats/{chat_id}/messages/{message_id}/reactions/{reaction_key}", + chat_id=chat_id, + message_id=message_id, + reaction_key=reaction_key, ), options=make_request_options( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - query=await async_maybe_transform( - {"reaction_key": reaction_key}, reaction_delete_params.ReactionDeleteParams - ), + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), cast_to=ReactionDeleteResponse, ) @@ -230,15 +240,19 @@ async def add( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ReactionAddResponse: - """ - Add a reaction to an existing message. + """Add a reaction to an existing message. Args: - chat_id: Unique identifier of the chat. + chat_id: Chat ID. + + Input routes also accept the local chat ID from this Beeper Desktop + installation when available. + + message_id: Message ID. reaction_key: Reaction key to add (emoji, shortcode, or custom emoji key) - transaction_id: Optional transaction ID for deduplication and local echo tracking + transaction_id: Optional transaction ID for deduplication and send tracking extra_headers: Send extra headers diff --git a/src/beeper_desktop_api/resources/chats/reminders.py b/src/beeper_desktop_api/resources/chats/reminders.py index 32a169b..ef502a6 100644 --- a/src/beeper_desktop_api/resources/chats/reminders.py +++ b/src/beeper_desktop_api/resources/chats/reminders.py @@ -54,11 +54,13 @@ def create( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> None: - """ - Set a reminder for a chat at a specific time + """Set a reminder for a chat at a specific time Args: - chat_id: Unique identifier of the chat. + chat_id: Chat ID. + + Input routes also accept the local chat ID from this Beeper Desktop + installation when available. reminder: Reminder configuration @@ -93,11 +95,13 @@ def delete( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> None: - """ - Clear an existing reminder from a chat + """Clear an existing reminder from a chat Args: - chat_id: Unique identifier of the chat. + chat_id: Chat ID. + + Input routes also accept the local chat ID from this Beeper Desktop + installation when available. extra_headers: Send extra headers @@ -153,11 +157,13 @@ async def create( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> None: - """ - Set a reminder for a chat at a specific time + """Set a reminder for a chat at a specific time Args: - chat_id: Unique identifier of the chat. + chat_id: Chat ID. + + Input routes also accept the local chat ID from this Beeper Desktop + installation when available. reminder: Reminder configuration @@ -192,11 +198,13 @@ async def delete( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> None: - """ - Clear an existing reminder from a chat + """Clear an existing reminder from a chat Args: - chat_id: Unique identifier of the chat. + chat_id: Chat ID. + + Input routes also accept the local chat ID from this Beeper Desktop + installation when available. extra_headers: Send extra headers diff --git a/src/beeper_desktop_api/resources/info.py b/src/beeper_desktop_api/resources/info.py index 2b33a3d..d7eaf8d 100644 --- a/src/beeper_desktop_api/resources/info.py +++ b/src/beeper_desktop_api/resources/info.py @@ -20,7 +20,10 @@ class InfoResource(SyncAPIResource): - """Control the Beeper Desktop application""" + """Server discovery and capability metadata. + + Use /v1/info before authentication setup. + """ @cached_property def with_raw_response(self) -> InfoResourceWithRawResponse: @@ -52,8 +55,8 @@ def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> InfoRetrieveResponse: """ - Returns app, platform, server, and endpoint discovery metadata for this Beeper - Desktop instance. + Returns app, platform, server, endpoint discovery, OAuth, and WebSocket metadata + for this Beeper Desktop instance. """ return self._get( "/v1/info", @@ -69,7 +72,10 @@ def retrieve( class AsyncInfoResource(AsyncAPIResource): - """Control the Beeper Desktop application""" + """Server discovery and capability metadata. + + Use /v1/info before authentication setup. + """ @cached_property def with_raw_response(self) -> AsyncInfoResourceWithRawResponse: @@ -101,8 +107,8 @@ async def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> InfoRetrieveResponse: """ - Returns app, platform, server, and endpoint discovery metadata for this Beeper - Desktop instance. + Returns app, platform, server, endpoint discovery, OAuth, and WebSocket metadata + for this Beeper Desktop instance. """ return await self._get( "/v1/info", diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py index e0be745..290c923 100644 --- a/src/beeper_desktop_api/resources/messages.py +++ b/src/beeper_desktop_api/resources/messages.py @@ -8,8 +8,14 @@ import httpx -from ..types import message_list_params, message_send_params, message_search_params, message_update_params -from .._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given +from ..types import ( + message_list_params, + message_send_params, + message_delete_params, + message_search_params, + message_update_params, +) +from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, SequenceNotStr, omit, not_given from .._utils import path_template, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -50,6 +56,48 @@ def with_streaming_response(self) -> MessagesResourceWithStreamingResponse: """ return MessagesResourceWithStreamingResponse(self) + def retrieve( + self, + message_id: str, + *, + chat_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Message: + """ + Retrieve a message by final message ID, pendingMessageID, or Matrix event ID. + Chat ID may be a Beeper chat ID or local chat ID. + + Args: + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. + + message_id: Message ID. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + if not message_id: + raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") + return self._get( + path_template("/v1/chats/{chat_id}/messages/{message_id}", chat_id=chat_id, message_id=message_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Message, + ) + def update( self, message_id: str, @@ -69,7 +117,10 @@ def update( be edited. Args: - chat_id: Unique identifier of the chat. + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. + + message_id: Message ID. text: New text content for the message @@ -112,7 +163,8 @@ def list( Sorted by timestamp. Args: - chat_id: Unique identifier of the chat. + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. @@ -148,6 +200,58 @@ def list( model=Message, ) + def delete( + self, + message_id: str, + *, + chat_id: str, + for_everyone: Optional[bool] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """Delete a message by final message ID. + + Pending message IDs are not accepted + because messages cannot be deleted while sending. + + Args: + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. + + message_id: Message ID. + + for_everyone: True to request deletion for everyone when the network supports it; false to + delete only for the authenticated user when supported. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + if not message_id: + raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/v1/chats/{chat_id}/messages/{message_id}", chat_id=chat_id, message_id=message_id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"for_everyone": for_everyone}, message_delete_params.MessageDeleteParams), + ), + cast_to=NoneType, + ) + def search( self, *, @@ -269,13 +373,15 @@ def send( Returns a pending message ID. Args: - chat_id: Unique identifier of the chat. + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. attachment: Single attachment to send with the message reply_to_message_id: Provide a message ID to send this as a reply to an existing message - text: Text content of the message you want to send. You may use markdown. + text: Draft text. Plain text and Markdown are converted to Matrix HTML with the same + rules used by send and edit. extra_headers: Send extra headers @@ -326,6 +432,48 @@ def with_streaming_response(self) -> AsyncMessagesResourceWithStreamingResponse: """ return AsyncMessagesResourceWithStreamingResponse(self) + async def retrieve( + self, + message_id: str, + *, + chat_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Message: + """ + Retrieve a message by final message ID, pendingMessageID, or Matrix event ID. + Chat ID may be a Beeper chat ID or local chat ID. + + Args: + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. + + message_id: Message ID. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + if not message_id: + raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") + return await self._get( + path_template("/v1/chats/{chat_id}/messages/{message_id}", chat_id=chat_id, message_id=message_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Message, + ) + async def update( self, message_id: str, @@ -345,7 +493,10 @@ async def update( be edited. Args: - chat_id: Unique identifier of the chat. + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. + + message_id: Message ID. text: New text content for the message @@ -388,7 +539,8 @@ def list( Sorted by timestamp. Args: - chat_id: Unique identifier of the chat. + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. cursor: Opaque pagination cursor; do not inspect. Use together with 'direction'. @@ -424,6 +576,60 @@ def list( model=Message, ) + async def delete( + self, + message_id: str, + *, + chat_id: str, + for_everyone: Optional[bool] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """Delete a message by final message ID. + + Pending message IDs are not accepted + because messages cannot be deleted while sending. + + Args: + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. + + message_id: Message ID. + + for_everyone: True to request deletion for everyone when the network supports it; false to + delete only for the authenticated user when supported. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not chat_id: + raise ValueError(f"Expected a non-empty value for `chat_id` but received {chat_id!r}") + if not message_id: + raise ValueError(f"Expected a non-empty value for `message_id` but received {message_id!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/v1/chats/{chat_id}/messages/{message_id}", chat_id=chat_id, message_id=message_id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"for_everyone": for_everyone}, message_delete_params.MessageDeleteParams + ), + ), + cast_to=NoneType, + ) + def search( self, *, @@ -545,13 +751,15 @@ async def send( Returns a pending message ID. Args: - chat_id: Unique identifier of the chat. + chat_id: Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + installation when available. attachment: Single attachment to send with the message reply_to_message_id: Provide a message ID to send this as a reply to an existing message - text: Text content of the message you want to send. You may use markdown. + text: Draft text. Plain text and Markdown are converted to Matrix HTML with the same + rules used by send and edit. extra_headers: Send extra headers @@ -584,12 +792,18 @@ class MessagesResourceWithRawResponse: def __init__(self, messages: MessagesResource) -> None: self._messages = messages + self.retrieve = to_raw_response_wrapper( + messages.retrieve, + ) self.update = to_raw_response_wrapper( messages.update, ) self.list = to_raw_response_wrapper( messages.list, ) + self.delete = to_raw_response_wrapper( + messages.delete, + ) self.search = to_raw_response_wrapper( messages.search, ) @@ -602,12 +816,18 @@ class AsyncMessagesResourceWithRawResponse: def __init__(self, messages: AsyncMessagesResource) -> None: self._messages = messages + self.retrieve = async_to_raw_response_wrapper( + messages.retrieve, + ) self.update = async_to_raw_response_wrapper( messages.update, ) self.list = async_to_raw_response_wrapper( messages.list, ) + self.delete = async_to_raw_response_wrapper( + messages.delete, + ) self.search = async_to_raw_response_wrapper( messages.search, ) @@ -620,12 +840,18 @@ class MessagesResourceWithStreamingResponse: def __init__(self, messages: MessagesResource) -> None: self._messages = messages + self.retrieve = to_streamed_response_wrapper( + messages.retrieve, + ) self.update = to_streamed_response_wrapper( messages.update, ) self.list = to_streamed_response_wrapper( messages.list, ) + self.delete = to_streamed_response_wrapper( + messages.delete, + ) self.search = to_streamed_response_wrapper( messages.search, ) @@ -638,12 +864,18 @@ class AsyncMessagesResourceWithStreamingResponse: def __init__(self, messages: AsyncMessagesResource) -> None: self._messages = messages + self.retrieve = async_to_streamed_response_wrapper( + messages.retrieve, + ) self.update = async_to_streamed_response_wrapper( messages.update, ) self.list = async_to_streamed_response_wrapper( messages.list, ) + self.delete = async_to_streamed_response_wrapper( + messages.delete, + ) self.search = async_to_streamed_response_wrapper( messages.search, ) diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py index e072da8..78ec780 100644 --- a/src/beeper_desktop_api/types/__init__.py +++ b/src/beeper_desktop_api/types/__init__.py @@ -13,6 +13,7 @@ from .chat_create_params import ChatCreateParams as ChatCreateParams from .chat_list_response import ChatListResponse as ChatListResponse from .chat_search_params import ChatSearchParams as ChatSearchParams +from .chat_update_params import ChatUpdateParams as ChatUpdateParams from .asset_upload_params import AssetUploadParams as AssetUploadParams from .chat_archive_params import ChatArchiveParams as ChatArchiveParams from .chat_start_response import ChatStartResponse as ChatStartResponse @@ -25,11 +26,14 @@ from .account_list_response import AccountListResponse as AccountListResponse from .asset_download_params import AssetDownloadParams as AssetDownloadParams from .asset_upload_response import AssetUploadResponse as AssetUploadResponse +from .chat_mark_read_params import ChatMarkReadParams as ChatMarkReadParams +from .message_delete_params import MessageDeleteParams as MessageDeleteParams from .message_search_params import MessageSearchParams as MessageSearchParams from .message_send_response import MessageSendResponse as MessageSendResponse from .message_update_params import MessageUpdateParams as MessageUpdateParams from .info_retrieve_response import InfoRetrieveResponse as InfoRetrieveResponse from .asset_download_response import AssetDownloadResponse as AssetDownloadResponse +from .chat_mark_unread_params import ChatMarkUnreadParams as ChatMarkUnreadParams from .message_update_response import MessageUpdateResponse as MessageUpdateResponse from .asset_upload_base64_params import AssetUploadBase64Params as AssetUploadBase64Params from .asset_upload_base64_response import AssetUploadBase64Response as AssetUploadBase64Response diff --git a/src/beeper_desktop_api/types/account.py b/src/beeper_desktop_api/types/account.py index 8dec8d5..fdc1a4b 100644 --- a/src/beeper_desktop_api/types/account.py +++ b/src/beeper_desktop_api/types/account.py @@ -12,26 +12,39 @@ class Bridge(BaseModel): - """Bridge metadata for the account. Available in Beeper Desktop v4.2.799+.""" + """Bridge metadata for the account. Available in Beeper Desktop v4.2.785+.""" id: str - """Bridge instance identifier. Available in Beeper Desktop v4.2.799+.""" + """Bridge instance identifier. + + Matrix and cloud bridges often use the bridge type (for example matrix or + discordgo); local bridges use a local bridge ID (for example local-whatsapp). + Available in Beeper Desktop v4.2.785+. + """ provider: Literal["cloud", "self-hosted", "local", "platform-sdk"] - """Bridge provider for the account. Available in Beeper Desktop v4.2.799+.""" + """Bridge provider for the account. Available in Beeper Desktop v4.2.785+.""" type: str - """Bridge type. Available in Beeper Desktop v4.2.799+.""" + """Bridge type, such as matrix, discordgo, slackgo, whatsapp, telegram, or twitter. + + Available in Beeper Desktop v4.2.785+. + """ class Account(BaseModel): """A chat account added to Beeper.""" account_id: str = FieldInfo(alias="accountID") - """Chat account added to Beeper. Use this to route account-scoped actions.""" + """Chat account added to Beeper. + + Use this to route account-scoped actions. Examples include matrix for + Beeper/Matrix, discordgo for a cloud bridge, slackgo.TEAM-USER for + workspace-scoped cloud bridges, and local-whatsapp*ba*... for local bridges. + """ bridge: Bridge - """Bridge metadata for the account. Available in Beeper Desktop v4.2.799+.""" + """Bridge metadata for the account. Available in Beeper Desktop v4.2.785+.""" user: User """User the account belongs to.""" diff --git a/src/beeper_desktop_api/types/asset_download_params.py b/src/beeper_desktop_api/types/asset_download_params.py index 1b3d584..62e7c02 100644 --- a/src/beeper_desktop_api/types/asset_download_params.py +++ b/src/beeper_desktop_api/types/asset_download_params.py @@ -9,4 +9,4 @@ class AssetDownloadParams(TypedDict, total=False): url: Required[str] - """Matrix content URL (mxc:// or localmxc://) for the asset to download.""" + """Matrix content URL (mxc:// or localmxc://) for the file to download.""" diff --git a/src/beeper_desktop_api/types/asset_download_response.py b/src/beeper_desktop_api/types/asset_download_response.py index 3cf7729..a2bd7ec 100644 --- a/src/beeper_desktop_api/types/asset_download_response.py +++ b/src/beeper_desktop_api/types/asset_download_response.py @@ -14,4 +14,4 @@ class AssetDownloadResponse(BaseModel): """Error message if the download failed.""" src_url: Optional[str] = FieldInfo(alias="srcURL", default=None) - """Local file URL to the downloaded asset.""" + """Local file URL to the downloaded file.""" diff --git a/src/beeper_desktop_api/types/asset_serve_params.py b/src/beeper_desktop_api/types/asset_serve_params.py index 395e8b1..80b77c7 100644 --- a/src/beeper_desktop_api/types/asset_serve_params.py +++ b/src/beeper_desktop_api/types/asset_serve_params.py @@ -9,4 +9,4 @@ class AssetServeParams(TypedDict, total=False): url: Required[str] - """Asset URL to serve. Accepts mxc://, localmxc://, or file:// URLs.""" + """File URL to serve. Accepts mxc://, localmxc://, or file:// URLs.""" diff --git a/src/beeper_desktop_api/types/asset_upload_base64_response.py b/src/beeper_desktop_api/types/asset_upload_base64_response.py index cfa8351..41d8ddd 100644 --- a/src/beeper_desktop_api/types/asset_upload_base64_response.py +++ b/src/beeper_desktop_api/types/asset_upload_base64_response.py @@ -29,10 +29,10 @@ class AssetUploadBase64Response(BaseModel): """Detected or provided MIME type""" src_url: Optional[str] = FieldInfo(alias="srcURL", default=None) - """Local file URL (file://) for the uploaded asset""" + """Local file URL (file://) for the uploaded file""" upload_id: Optional[str] = FieldInfo(alias="uploadID", default=None) - """Unique upload ID for this asset""" + """Unique upload ID for this temporary file""" width: Optional[float] = None """Width in pixels (images/videos)""" diff --git a/src/beeper_desktop_api/types/asset_upload_response.py b/src/beeper_desktop_api/types/asset_upload_response.py index 571d81e..82dadd5 100644 --- a/src/beeper_desktop_api/types/asset_upload_response.py +++ b/src/beeper_desktop_api/types/asset_upload_response.py @@ -29,10 +29,10 @@ class AssetUploadResponse(BaseModel): """Detected or provided MIME type""" src_url: Optional[str] = FieldInfo(alias="srcURL", default=None) - """Local file URL (file://) for the uploaded asset""" + """Local file URL (file://) for the uploaded file""" upload_id: Optional[str] = FieldInfo(alias="uploadID", default=None) - """Unique upload ID for this asset""" + """Unique upload ID for this temporary file""" width: Optional[float] = None """Width in pixels (images/videos)""" diff --git a/src/beeper_desktop_api/types/chat.py b/src/beeper_desktop_api/types/chat.py index fc67be8..4cd922c 100644 --- a/src/beeper_desktop_api/types/chat.py +++ b/src/beeper_desktop_api/types/chat.py @@ -1,6 +1,6 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import List, Optional +from typing import Dict, List, Optional from datetime import datetime from typing_extensions import Literal @@ -9,7 +9,39 @@ from .._models import BaseModel from .shared.user import User -__all__ = ["Chat", "Participants"] +__all__ = [ + "Chat", + "Participants", + "ParticipantsItem", + "Capabilities", + "CapabilitiesAttachments", + "CapabilitiesDisappearingTimer", + "CapabilitiesMessageRequest", + "CapabilitiesParticipantActions", + "CapabilitiesState", + "CapabilitiesStateAvatar", + "CapabilitiesStateDescription", + "CapabilitiesStateDisappearingTimer", + "CapabilitiesStateTitle", + "Draft", + "DraftAttachments", + "DraftAttachmentsSize", + "Reminder", + "Snooze", +] + + +class ParticipantsItem(User): + """A chat participant. Extends User with chat membership metadata.""" + + is_admin: Optional[bool] = FieldInfo(alias="isAdmin", default=None) + """True if this participant has admin privileges in the chat.""" + + is_network_bot: Optional[bool] = FieldInfo(alias="isNetworkBot", default=None) + """True if this participant represents a network or bridge bot.""" + + is_pending: Optional[bool] = FieldInfo(alias="isPending", default=None) + """True if this participant has been invited but has not joined yet.""" class Participants(BaseModel): @@ -18,13 +50,352 @@ class Participants(BaseModel): has_more: bool = FieldInfo(alias="hasMore") """True if there are more participants than included in items.""" - items: List[User] + items: List[ParticipantsItem] """Participants returned for this chat (limited by the request; may be a subset).""" total: int """Total number of participants in the chat.""" +class CapabilitiesAttachments(BaseModel): + """Capabilities for one attachment message type.""" + + mime_types: Dict[str, Literal[-2, -1, 0, 1, 2]] = FieldInfo(alias="mimeTypes") + """Supported MIME types or MIME patterns for this file message type. + + Missing MIME types should be treated as rejected. + """ + + caption: Optional[Literal[-2, -1, 0, 1, 2]] = None + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + max_caption_length: Optional[int] = FieldInfo(alias="maxCaptionLength", default=None) + """Maximum caption length when captions are supported.""" + + max_duration: Optional[int] = FieldInfo(alias="maxDuration", default=None) + """Maximum audio or video duration in seconds.""" + + max_height: Optional[int] = FieldInfo(alias="maxHeight", default=None) + """Maximum image or video height in pixels.""" + + max_size: Optional[int] = FieldInfo(alias="maxSize", default=None) + """Maximum file size in bytes.""" + + max_width: Optional[int] = FieldInfo(alias="maxWidth", default=None) + """Maximum image or video width in pixels.""" + + view_once: Optional[bool] = FieldInfo(alias="viewOnce", default=None) + """True if this file type can be sent as view-once media.""" + + +class CapabilitiesDisappearingTimer(BaseModel): + """Disappearing-message timer capabilities.""" + + omit_empty_timer: Optional[bool] = FieldInfo(alias="omitEmptyTimer", default=None) + """True if empty timer objects should be omitted from message content.""" + + timers: Optional[List[int]] = None + """Allowed disappearing timer values in milliseconds. + + Omitted means any timer is allowed. + """ + + types: Optional[List[Literal["afterRead", "afterSend"]]] = None + """Supported disappearing timer types.""" + + +class CapabilitiesMessageRequest(BaseModel): + """Message request capabilities.""" + + accept_with_button: Optional[Literal[-2, -1, 0, 1, 2]] = FieldInfo(alias="acceptWithButton", default=None) + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + accept_with_message: Optional[Literal[-2, -1, 0, 1, 2]] = FieldInfo(alias="acceptWithMessage", default=None) + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + +class CapabilitiesParticipantActions(BaseModel): + """Participant management capabilities.""" + + ban: Optional[Literal[-2, -1, 0, 1, 2]] = None + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + invite: Optional[Literal[-2, -1, 0, 1, 2]] = None + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + kick: Optional[Literal[-2, -1, 0, 1, 2]] = None + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + leave: Optional[Literal[-2, -1, 0, 1, 2]] = None + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + revoke_invite: Optional[Literal[-2, -1, 0, 1, 2]] = FieldInfo(alias="revokeInvite", default=None) + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + +class CapabilitiesStateAvatar(BaseModel): + """Chat avatar state capability.""" + + level: Literal[-2, -1, 0, 1, 2] + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + +class CapabilitiesStateDescription(BaseModel): + """Chat description/topic state capability.""" + + level: Literal[-2, -1, 0, 1, 2] + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + +class CapabilitiesStateDisappearingTimer(BaseModel): + """Disappearing-message timer state capability.""" + + level: Literal[-2, -1, 0, 1, 2] + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + +class CapabilitiesStateTitle(BaseModel): + """Chat title state capability.""" + + level: Literal[-2, -1, 0, 1, 2] + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + +class CapabilitiesState(BaseModel): + """Chat state update capabilities.""" + + avatar: Optional[CapabilitiesStateAvatar] = None + """Chat avatar state capability.""" + + description: Optional[CapabilitiesStateDescription] = None + """Chat description/topic state capability.""" + + disappearing_timer: Optional[CapabilitiesStateDisappearingTimer] = FieldInfo( + alias="disappearingTimer", default=None + ) + """Disappearing-message timer state capability.""" + + title: Optional[CapabilitiesStateTitle] = None + """Chat title state capability.""" + + +class Capabilities(BaseModel): + """Chat capabilities reported by the platform.""" + + allowed_reactions: Optional[List[str]] = FieldInfo(alias="allowedReactions", default=None) + """Allowed Unicode reactions. Omitted means all emoji reactions are allowed.""" + + archive: Optional[bool] = None + """True if archive/unarchive is supported.""" + + attachments: Optional[Dict[str, CapabilitiesAttachments]] = None + """ + Supported attachment message types and their per-type constraints, keyed by + Matrix msgtype or pseudo-msgtype (for example m.image, m.video, + org.matrix.msc3245.voice). Missing message types should be treated as rejected. + """ + + custom_emoji_reactions: Optional[bool] = FieldInfo(alias="customEmojiReactions", default=None) + """True if custom emoji reactions are supported.""" + + delete: Optional[Literal[-2, -1, 0, 1, 2]] = None + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + delete_chat: Optional[bool] = FieldInfo(alias="deleteChat", default=None) + """True if deleting chats for the authenticated user is supported.""" + + delete_chat_for_everyone: Optional[bool] = FieldInfo(alias="deleteChatForEveryone", default=None) + """True if deleting chats for everyone is supported.""" + + delete_for_me: Optional[bool] = FieldInfo(alias="deleteForMe", default=None) + """True if deleting messages only for the authenticated user is supported.""" + + delete_max_age: Optional[int] = FieldInfo(alias="deleteMaxAge", default=None) + """Maximum message age for delete-for-everyone, in seconds.""" + + disappearing_timer: Optional[CapabilitiesDisappearingTimer] = FieldInfo(alias="disappearingTimer", default=None) + """Disappearing-message timer capabilities.""" + + edit: Optional[Literal[-2, -1, 0, 1, 2]] = None + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + edit_max_age: Optional[int] = FieldInfo(alias="editMaxAge", default=None) + """Maximum message age for edits, in seconds.""" + + edit_max_count: Optional[int] = FieldInfo(alias="editMaxCount", default=None) + """Maximum number of edits allowed for one message.""" + + formatting: Optional[Dict[str, Literal[-2, -1, 0, 1, 2]]] = None + """ + Supported rich-text formatting features keyed by feature name (for example bold, + inline_code, code_block.syntax_highlighting). Omitted means no formatting + support is advertised. + """ + + location_message: Optional[Literal[-2, -1, 0, 1, 2]] = FieldInfo(alias="locationMessage", default=None) + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + mark_as_unread: Optional[bool] = FieldInfo(alias="markAsUnread", default=None) + """True if marking chats unread is supported.""" + + max_text_length: Optional[int] = FieldInfo(alias="maxTextLength", default=None) + """Maximum length of normal text messages.""" + + message_request: Optional[CapabilitiesMessageRequest] = FieldInfo(alias="messageRequest", default=None) + """Message request capabilities.""" + + participant_actions: Optional[CapabilitiesParticipantActions] = FieldInfo(alias="participantActions", default=None) + """Participant management capabilities.""" + + poll: Optional[Literal[-2, -1, 0, 1, 2]] = None + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + reaction: Optional[Literal[-2, -1, 0, 1, 2]] = None + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + reaction_count: Optional[int] = FieldInfo(alias="reactionCount", default=None) + """Maximum number of reactions allowed on a single message.""" + + read_receipts: Optional[bool] = FieldInfo(alias="readReceipts", default=None) + """True if read receipts are supported.""" + + reply: Optional[Literal[-2, -1, 0, 1, 2]] = None + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + state: Optional[CapabilitiesState] = None + """Chat state update capabilities.""" + + thread: Optional[Literal[-2, -1, 0, 1, 2]] = None + """ + -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + supported. + """ + + typing_notifications: Optional[bool] = FieldInfo(alias="typingNotifications", default=None) + """True if typing notifications are supported.""" + + +class DraftAttachmentsSize(BaseModel): + """Pixel dimensions of the attachment.""" + + height: Optional[float] = None + + width: Optional[float] = None + + +class DraftAttachments(BaseModel): + id: str + """Draft attachment identifier.""" + + type: Literal["file", "gif", "recorded_audio"] + """Draft attachment type. GIF and recorded audio are mutually exclusive types.""" + + audio_duration_seconds: Optional[float] = FieldInfo(alias="audioDurationSeconds", default=None) + """Audio duration in seconds if known.""" + + file_name: Optional[str] = FieldInfo(alias="fileName", default=None) + """Original filename if available.""" + + file_path: Optional[str] = FieldInfo(alias="filePath", default=None) + """Local filesystem path for the draft attachment.""" + + file_size: Optional[float] = FieldInfo(alias="fileSize", default=None) + """File size in bytes if known.""" + + mime_type: Optional[str] = FieldInfo(alias="mimeType", default=None) + """MIME type if known.""" + + size: Optional[DraftAttachmentsSize] = None + """Pixel dimensions of the attachment.""" + + sticker_id: Optional[str] = FieldInfo(alias="stickerID", default=None) + """Sticker identifier if the draft attachment is a sticker.""" + + +class Draft(BaseModel): + """Current draft object for this chat, or null when no draft is set.""" + + text: str + """Matrix HTML draft body.""" + + attachments: Optional[Dict[str, DraftAttachments]] = None + """Draft attachments keyed by attachment ID.""" + + +class Reminder(BaseModel): + """Current reminder for this chat, or null when no reminder is set.""" + + dismiss_on_incoming_message: Optional[bool] = FieldInfo(alias="dismissOnIncomingMessage", default=None) + """Cancel reminder if someone messages in the chat.""" + + remind_at: Optional[datetime] = FieldInfo(alias="remindAt", default=None) + """Timestamp when the reminder should trigger.""" + + +class Snooze(BaseModel): + """Current snooze state for this chat, or null when no snooze is set.""" + + snooze_until: Optional[datetime] = FieldInfo(alias="snoozeUntil", default=None) + """Timestamp when the snooze expires.""" + + user_snoozed_at: Optional[datetime] = FieldInfo(alias="userSnoozedAt", default=None) + """Timestamp when the user set the snooze.""" + + class Chat(BaseModel): id: str """Unique identifier of the chat across Beeper.""" @@ -32,6 +403,9 @@ class Chat(BaseModel): account_id: str = FieldInfo(alias="accountID") """Account ID this chat belongs to.""" + network: str + """Display-only human-readable account/network name.""" + participants: Participants """Chat participants information.""" @@ -44,15 +418,36 @@ class Chat(BaseModel): unread_count: int = FieldInfo(alias="unreadCount") """Number of unread messages.""" + capabilities: Optional[Capabilities] = None + """Chat capabilities reported by the platform.""" + + description: Optional[str] = None + """Group chat description/topic when available.""" + + draft: Optional[Draft] = None + """Current draft object for this chat, or null when no draft is set.""" + + img_url: Optional[str] = FieldInfo(alias="imgURL", default=None) + """Local filesystem path to the chat avatar image when available.""" + is_archived: Optional[bool] = FieldInfo(alias="isArchived", default=None) """True if chat is archived.""" + is_low_priority: Optional[bool] = FieldInfo(alias="isLowPriority", default=None) + """True if chat is marked low priority.""" + + is_marked_unread: Optional[bool] = FieldInfo(alias="isMarkedUnread", default=None) + """True if the chat was explicitly marked unread by the authenticated user.""" + is_muted: Optional[bool] = FieldInfo(alias="isMuted", default=None) """True if chat notifications are muted.""" is_pinned: Optional[bool] = FieldInfo(alias="isPinned", default=None) """True if chat is pinned.""" + is_read_only: Optional[bool] = FieldInfo(alias="isReadOnly", default=None) + """True if messages cannot be sent in this chat.""" + last_activity: Optional[datetime] = FieldInfo(alias="lastActivity", default=None) """Timestamp of last activity.""" @@ -61,3 +456,15 @@ class Chat(BaseModel): local_chat_id: Optional[str] = FieldInfo(alias="localChatID", default=None) """Local chat ID specific to this Beeper Desktop installation.""" + + message_expiry_seconds: Optional[int] = FieldInfo(alias="messageExpirySeconds", default=None) + """Disappearing-message timer in seconds when available.""" + + reminder: Optional[Reminder] = None + """Current reminder for this chat, or null when no reminder is set.""" + + snooze: Optional[Snooze] = None + """Current snooze state for this chat, or null when no snooze is set.""" + + unread_mentions_count: Optional[int] = FieldInfo(alias="unreadMentionsCount", default=None) + """Number of unread messages that mention the authenticated user or @room.""" diff --git a/src/beeper_desktop_api/types/chat_create_response.py b/src/beeper_desktop_api/types/chat_create_response.py index 3f6b36f..f114484 100644 --- a/src/beeper_desktop_api/types/chat_create_response.py +++ b/src/beeper_desktop_api/types/chat_create_response.py @@ -5,18 +5,17 @@ from pydantic import Field as FieldInfo -from .._models import BaseModel +from .chat import Chat __all__ = ["ChatCreateResponse"] -class ChatCreateResponse(BaseModel): +class ChatCreateResponse(Chat): chat_id: str = FieldInfo(alias="chatID") - """Newly created chat ID.""" + """DEPRECATED - use id instead. Compatibility alias for older clients.""" status: Optional[Literal["existing", "created"]] = None - """Only returned in start mode. + """DEPRECATED - legacy start-chat status for older clients. - 'existing' means an existing chat was reused; 'created' means a new chat was - created. + New clients should inspect the returned Chat instead. """ diff --git a/src/beeper_desktop_api/types/chat_list_response.py b/src/beeper_desktop_api/types/chat_list_response.py index 80e3885..07ceab1 100644 --- a/src/beeper_desktop_api/types/chat_list_response.py +++ b/src/beeper_desktop_api/types/chat_list_response.py @@ -9,5 +9,7 @@ class ChatListResponse(Chat): + """Chat with optional last message preview.""" + preview: Optional[Message] = None """Last message preview for this chat, if available.""" diff --git a/src/beeper_desktop_api/types/chat_mark_read_params.py b/src/beeper_desktop_api/types/chat_mark_read_params.py new file mode 100644 index 0000000..a8d4ab6 --- /dev/null +++ b/src/beeper_desktop_api/types/chat_mark_read_params.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["ChatMarkReadParams"] + + +class ChatMarkReadParams(TypedDict, total=False): + message_id: Annotated[str, PropertyInfo(alias="messageID")] + """Optional message ID to mark read through.""" diff --git a/src/beeper_desktop_api/types/chat_mark_unread_params.py b/src/beeper_desktop_api/types/chat_mark_unread_params.py new file mode 100644 index 0000000..142c15b --- /dev/null +++ b/src/beeper_desktop_api/types/chat_mark_unread_params.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["ChatMarkUnreadParams"] + + +class ChatMarkUnreadParams(TypedDict, total=False): + message_id: Annotated[str, PropertyInfo(alias="messageID")] + """Optional message ID to mark unread from.""" diff --git a/src/beeper_desktop_api/types/chat_retrieve_params.py b/src/beeper_desktop_api/types/chat_retrieve_params.py index 00d4e68..4d31ec4 100644 --- a/src/beeper_desktop_api/types/chat_retrieve_params.py +++ b/src/beeper_desktop_api/types/chat_retrieve_params.py @@ -14,5 +14,6 @@ class ChatRetrieveParams(TypedDict, total=False): max_participant_count: Annotated[Optional[int], PropertyInfo(alias="maxParticipantCount")] """Maximum number of participants to return. - Use -1 for all; otherwise 0–500. Defaults to all (-1). + Use -1 for all; otherwise 0-500. Defaults to 100. List and search endpoints + return up to 20 participants per chat. """ diff --git a/src/beeper_desktop_api/types/chat_start_response.py b/src/beeper_desktop_api/types/chat_start_response.py index 89722c5..5b3880f 100644 --- a/src/beeper_desktop_api/types/chat_start_response.py +++ b/src/beeper_desktop_api/types/chat_start_response.py @@ -5,18 +5,17 @@ from pydantic import Field as FieldInfo -from .._models import BaseModel +from .chat import Chat __all__ = ["ChatStartResponse"] -class ChatStartResponse(BaseModel): +class ChatStartResponse(Chat): chat_id: str = FieldInfo(alias="chatID") - """Newly created chat ID.""" + """DEPRECATED - use id instead. Compatibility alias for older clients.""" status: Optional[Literal["existing", "created"]] = None - """Only returned in start mode. + """DEPRECATED - legacy start-chat status for older clients. - 'existing' means an existing chat was reused; 'created' means a new chat was - created. + New clients should inspect the returned Chat instead. """ diff --git a/src/beeper_desktop_api/types/chat_update_params.py b/src/beeper_desktop_api/types/chat_update_params.py new file mode 100644 index 0000000..d485a1b --- /dev/null +++ b/src/beeper_desktop_api/types/chat_update_params.py @@ -0,0 +1,106 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Optional +from typing_extensions import Literal, Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["ChatUpdateParams", "Draft", "DraftAttachments", "DraftAttachmentsSize"] + + +class ChatUpdateParams(TypedDict, total=False): + description: Optional[str] + """Group chat description/topic. + + Support depends on the chat account and chat permissions. + """ + + draft: Optional[Draft] + """Draft object to set or clear. + + Non-empty drafts are only accepted when the current draft is empty. Send + draft=null to clear text and attachments together before setting a new draft. + """ + + img_url: Annotated[Optional[str], PropertyInfo(alias="imgURL")] + """Local filesystem path to a group chat avatar image. + + Support depends on the chat account and chat permissions. + """ + + is_archived: Annotated[bool, PropertyInfo(alias="isArchived")] + """Archive or unarchive the chat.""" + + is_low_priority: Annotated[bool, PropertyInfo(alias="isLowPriority")] + """Mark or unmark the chat as low priority when supported by the account.""" + + is_muted: Annotated[bool, PropertyInfo(alias="isMuted")] + """Mute or unmute the chat.""" + + is_pinned: Annotated[bool, PropertyInfo(alias="isPinned")] + """Pin or unpin the chat when supported by the account.""" + + message_expiry_seconds: Annotated[Optional[int], PropertyInfo(alias="messageExpirySeconds")] + """Disappearing-message timer in seconds, or null to clear when supported.""" + + title: Optional[str] + """Custom chat title. Support depends on the chat account and chat permissions.""" + + +class DraftAttachmentsSize(TypedDict, total=False): + """Dimensions (optional override of cached value)""" + + height: Required[float] + + width: Required[float] + + +class DraftAttachments(TypedDict, total=False): + upload_id: Required[Annotated[str, PropertyInfo(alias="uploadID")]] + """Upload ID from uploadAsset endpoint. Required to reference uploaded files.""" + + id: str + """Optional draft attachment identifier. + + If omitted, a new identifier is generated. + """ + + duration: float + """Duration in seconds (optional override of cached value)""" + + file_name: Annotated[str, PropertyInfo(alias="fileName")] + """Filename (optional override of cached value)""" + + mime_type: Annotated[str, PropertyInfo(alias="mimeType")] + """MIME type (optional override of cached value)""" + + size: DraftAttachmentsSize + """Dimensions (optional override of cached value)""" + + type: Literal["image", "video", "audio", "file", "gif", "voice-note", "sticker"] + """Attachment type hint (image, video, audio, file, gif, voice-note, sticker). + + If omitted, auto-detected from mimeType + """ + + +class Draft(TypedDict, total=False): + """Draft object to set or clear. + + Non-empty drafts are only accepted when the current draft is empty. Send draft=null to clear text and attachments together before setting a new draft. + """ + + text: Required[str] + """Draft text. + + Plain text and Markdown are converted to Matrix HTML with the same rules used by + send and edit. + """ + + attachments: Dict[str, DraftAttachments] + """Draft attachments keyed by attachment ID. + + Each attachment must reference an uploadID returned by the upload file endpoint. + """ diff --git a/src/beeper_desktop_api/types/chats/messages/__init__.py b/src/beeper_desktop_api/types/chats/messages/__init__.py index 5731683..cd93e4b 100644 --- a/src/beeper_desktop_api/types/chats/messages/__init__.py +++ b/src/beeper_desktop_api/types/chats/messages/__init__.py @@ -4,5 +4,4 @@ from .reaction_add_params import ReactionAddParams as ReactionAddParams from .reaction_add_response import ReactionAddResponse as ReactionAddResponse -from .reaction_delete_params import ReactionDeleteParams as ReactionDeleteParams from .reaction_delete_response import ReactionDeleteResponse as ReactionDeleteResponse diff --git a/src/beeper_desktop_api/types/chats/messages/reaction_add_params.py b/src/beeper_desktop_api/types/chats/messages/reaction_add_params.py index da7ae6c..976e0a0 100644 --- a/src/beeper_desktop_api/types/chats/messages/reaction_add_params.py +++ b/src/beeper_desktop_api/types/chats/messages/reaction_add_params.py @@ -11,10 +11,14 @@ class ReactionAddParams(TypedDict, total=False): chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] - """Unique identifier of the chat.""" + """Chat ID. + + Input routes also accept the local chat ID from this Beeper Desktop installation + when available. + """ reaction_key: Required[Annotated[str, PropertyInfo(alias="reactionKey")]] """Reaction key to add (emoji, shortcode, or custom emoji key)""" transaction_id: Annotated[str, PropertyInfo(alias="transactionID")] - """Optional transaction ID for deduplication and local echo tracking""" + """Optional transaction ID for deduplication and send tracking""" diff --git a/src/beeper_desktop_api/types/chats/messages/reaction_add_response.py b/src/beeper_desktop_api/types/chats/messages/reaction_add_response.py index d7bb679..3e0a638 100644 --- a/src/beeper_desktop_api/types/chats/messages/reaction_add_response.py +++ b/src/beeper_desktop_api/types/chats/messages/reaction_add_response.py @@ -11,16 +11,23 @@ class ReactionAddResponse(BaseModel): chat_id: str = FieldInfo(alias="chatID") - """Unique identifier of the chat.""" + """Chat ID. + + Input routes also accept the local chat ID from this Beeper Desktop installation + when available. + """ message_id: str = FieldInfo(alias="messageID") """Message ID.""" reaction_key: str = FieldInfo(alias="reactionKey") - """Reaction key that was added""" + """Reaction key that was added.""" success: Literal[True] - """Whether the reaction was successfully added""" + """Always true. + + Indicates the reaction was queued; failures return an error response. + """ transaction_id: str = FieldInfo(alias="transactionID") - """Transaction ID used for the reaction event""" + """Transaction ID used for send tracking.""" diff --git a/src/beeper_desktop_api/types/chats/messages/reaction_delete_params.py b/src/beeper_desktop_api/types/chats/messages/reaction_delete_params.py deleted file mode 100644 index c6bdfc3..0000000 --- a/src/beeper_desktop_api/types/chats/messages/reaction_delete_params.py +++ /dev/null @@ -1,17 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from __future__ import annotations - -from typing_extensions import Required, Annotated, TypedDict - -from ...._utils import PropertyInfo - -__all__ = ["ReactionDeleteParams"] - - -class ReactionDeleteParams(TypedDict, total=False): - chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] - """Unique identifier of the chat.""" - - reaction_key: Required[Annotated[str, PropertyInfo(alias="reactionKey")]] - """Reaction key to remove""" diff --git a/src/beeper_desktop_api/types/chats/messages/reaction_delete_response.py b/src/beeper_desktop_api/types/chats/messages/reaction_delete_response.py index 05ced92..196a739 100644 --- a/src/beeper_desktop_api/types/chats/messages/reaction_delete_response.py +++ b/src/beeper_desktop_api/types/chats/messages/reaction_delete_response.py @@ -11,13 +11,20 @@ class ReactionDeleteResponse(BaseModel): chat_id: str = FieldInfo(alias="chatID") - """Unique identifier of the chat.""" + """Chat ID. + + Input routes also accept the local chat ID from this Beeper Desktop installation + when available. + """ message_id: str = FieldInfo(alias="messageID") """Message ID.""" reaction_key: str = FieldInfo(alias="reactionKey") - """Reaction key that was removed""" + """Reaction key that was removed.""" success: Literal[True] - """Whether the reaction was successfully removed""" + """Always true. + + Indicates the reaction removal was queued; failures return an error response. + """ diff --git a/src/beeper_desktop_api/types/chats/reminder_create_params.py b/src/beeper_desktop_api/types/chats/reminder_create_params.py index 3a92906..e9e29b6 100644 --- a/src/beeper_desktop_api/types/chats/reminder_create_params.py +++ b/src/beeper_desktop_api/types/chats/reminder_create_params.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Union +from datetime import datetime from typing_extensions import Required, Annotated, TypedDict from ..._utils import PropertyInfo @@ -17,8 +19,8 @@ class ReminderCreateParams(TypedDict, total=False): class Reminder(TypedDict, total=False): """Reminder configuration""" - remind_at_ms: Required[Annotated[float, PropertyInfo(alias="remindAtMs")]] - """Unix timestamp in milliseconds when reminder should trigger""" + remind_at: Required[Annotated[Union[str, datetime], PropertyInfo(alias="remindAt", format="iso8601")]] + """Timestamp when the reminder should trigger.""" dismiss_on_incoming_message: Annotated[bool, PropertyInfo(alias="dismissOnIncomingMessage")] """Cancel reminder if someone messages in the chat""" diff --git a/src/beeper_desktop_api/types/client_focus_params.py b/src/beeper_desktop_api/types/client_focus_params.py index 6359eb2..df3106f 100644 --- a/src/beeper_desktop_api/types/client_focus_params.py +++ b/src/beeper_desktop_api/types/client_focus_params.py @@ -17,10 +17,10 @@ class ClientFocusParams(TypedDict, total=False): """ draft_attachment_path: Annotated[str, PropertyInfo(alias="draftAttachmentPath")] - """Optional draft attachment path to populate in the message input field.""" + """Optional image path to populate in the message input field.""" draft_text: Annotated[str, PropertyInfo(alias="draftText")] - """Optional draft text to populate in the message input field.""" + """Optional plain text to populate in the message input field.""" message_id: Annotated[str, PropertyInfo(alias="messageID")] """Optional message ID. Jumps to that message in the chat when opening.""" diff --git a/src/beeper_desktop_api/types/info_retrieve_response.py b/src/beeper_desktop_api/types/info_retrieve_response.py index b7230a7..9a643aa 100644 --- a/src/beeper_desktop_api/types/info_retrieve_response.py +++ b/src/beeper_desktop_api/types/info_retrieve_response.py @@ -64,7 +64,7 @@ class Platform(BaseModel): class Server(BaseModel): base_url: str - """Base URL of the Connect server""" + """Base URL of the Beeper Desktop API server""" hostname: str """Listening host""" diff --git a/src/beeper_desktop_api/types/message_delete_params.py b/src/beeper_desktop_api/types/message_delete_params.py new file mode 100644 index 0000000..f8c7fff --- /dev/null +++ b/src/beeper_desktop_api/types/message_delete_params.py @@ -0,0 +1,25 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["MessageDeleteParams"] + + +class MessageDeleteParams(TypedDict, total=False): + chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] + """Chat ID. + + Input routes also accept the local chat ID from this Beeper Desktop installation + when available. + """ + + for_everyone: Annotated[Optional[bool], PropertyInfo(alias="forEveryone")] + """ + True to request deletion for everyone when the network supports it; false to + delete only for the authenticated user when supported. + """ diff --git a/src/beeper_desktop_api/types/message_send_params.py b/src/beeper_desktop_api/types/message_send_params.py index b3f390a..78de9d1 100644 --- a/src/beeper_desktop_api/types/message_send_params.py +++ b/src/beeper_desktop_api/types/message_send_params.py @@ -17,7 +17,11 @@ class MessageSendParams(TypedDict, total=False): """Provide a message ID to send this as a reply to an existing message""" text: str - """Text content of the message you want to send. You may use markdown.""" + """Draft text. + + Plain text and Markdown are converted to Matrix HTML with the same rules used by + send and edit. + """ class AttachmentSize(TypedDict, total=False): @@ -46,8 +50,8 @@ class Attachment(TypedDict, total=False): size: AttachmentSize """Dimensions (optional override of cached value)""" - type: Literal["gif", "voiceNote", "sticker"] - """Special attachment type (gif, voiceNote, sticker). + type: Literal["image", "video", "audio", "file", "gif", "voice-note", "sticker"] + """Attachment type hint (image, video, audio, file, gif, voice-note, sticker). If omitted, auto-detected from mimeType """ diff --git a/src/beeper_desktop_api/types/message_send_response.py b/src/beeper_desktop_api/types/message_send_response.py index 93ddfc3..37b739c 100644 --- a/src/beeper_desktop_api/types/message_send_response.py +++ b/src/beeper_desktop_api/types/message_send_response.py @@ -9,7 +9,15 @@ class MessageSendResponse(BaseModel): chat_id: str = FieldInfo(alias="chatID") - """Unique identifier of the chat.""" + """Chat ID. + + Input routes also accept the local chat ID from this Beeper Desktop installation + when available. + """ pending_message_id: str = FieldInfo(alias="pendingMessageID") - """Pending message ID""" + """Pending ID assigned to the message before the network confirms the send. + + Pass it to GET /v1/chats/{chatID}/messages/{messageID} to resolve, or wait for + the matching message.upserted over the WebSocket. + """ diff --git a/src/beeper_desktop_api/types/message_update_params.py b/src/beeper_desktop_api/types/message_update_params.py index 663d6e8..9f62d93 100644 --- a/src/beeper_desktop_api/types/message_update_params.py +++ b/src/beeper_desktop_api/types/message_update_params.py @@ -11,7 +11,11 @@ class MessageUpdateParams(TypedDict, total=False): chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]] - """Unique identifier of the chat.""" + """Chat ID. + + Input routes also accept the local chat ID from this Beeper Desktop installation + when available. + """ text: Required[str] """New text content for the message""" diff --git a/src/beeper_desktop_api/types/message_update_response.py b/src/beeper_desktop_api/types/message_update_response.py index 41e0383..095f542 100644 --- a/src/beeper_desktop_api/types/message_update_response.py +++ b/src/beeper_desktop_api/types/message_update_response.py @@ -1,18 +1,20 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +from typing_extensions import Literal + from pydantic import Field as FieldInfo -from .._models import BaseModel +from .shared.message import Message __all__ = ["MessageUpdateResponse"] -class MessageUpdateResponse(BaseModel): - chat_id: str = FieldInfo(alias="chatID") - """Unique identifier of the chat.""" - +class MessageUpdateResponse(Message): message_id: str = FieldInfo(alias="messageID") - """Message ID.""" + """DEPRECATED - use id instead. Compatibility alias for older clients.""" + + success: Literal[True] + """DEPRECATED - compatibility field. - success: bool - """Whether the message was successfully edited""" + Successful responses are already represented by the 200 status code. + """ diff --git a/src/beeper_desktop_api/types/shared/attachment.py b/src/beeper_desktop_api/types/shared/attachment.py index e1b7b7b..1ec39f6 100644 --- a/src/beeper_desktop_api/types/shared/attachment.py +++ b/src/beeper_desktop_api/types/shared/attachment.py @@ -7,7 +7,7 @@ from ..._models import BaseModel -__all__ = ["Attachment", "Size"] +__all__ = ["Attachment", "Size", "Transcription"] class Size(BaseModel): @@ -18,6 +18,19 @@ class Size(BaseModel): width: Optional[float] = None +class Transcription(BaseModel): + """Attachment transcription if available.""" + + engine: str + """Transcription engine.""" + + transcription: str + """Transcribed text.""" + + language: Optional[str] = None + """Detected or selected language.""" + + class Attachment(BaseModel): type: Literal["unknown", "img", "video", "audio"] """Attachment type.""" @@ -25,7 +38,7 @@ class Attachment(BaseModel): id: Optional[str] = None """Attachment identifier (typically an mxc:// URL). - Use with /v1/assets/download to get a local file path. + Use the download file endpoint to get a local file path. """ duration: Optional[float] = None @@ -60,8 +73,11 @@ class Attachment(BaseModel): """Pixel dimensions of the attachment: width/height in px.""" src_url: Optional[str] = FieldInfo(alias="srcURL", default=None) - """Public URL or local file path to fetch the asset. + """Public URL or local file path to fetch the file. May be temporary or local-only to this device; download promptly if durable access is needed. """ + + transcription: Optional[Transcription] = None + """Attachment transcription if available.""" diff --git a/src/beeper_desktop_api/types/shared/message.py b/src/beeper_desktop_api/types/shared/message.py index dc3ad4f..40206de 100644 --- a/src/beeper_desktop_api/types/shared/message.py +++ b/src/beeper_desktop_api/types/shared/message.py @@ -1,6 +1,6 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import List, Optional +from typing import Dict, List, Union, Optional from datetime import datetime from typing_extensions import Literal @@ -10,7 +10,70 @@ from ..._models import BaseModel from .attachment import Attachment -__all__ = ["Message"] +__all__ = ["Message", "Link", "LinkImgSize", "SendStatus"] + + +class LinkImgSize(BaseModel): + """Preview image dimensions.""" + + height: Optional[float] = None + + width: Optional[float] = None + + +class Link(BaseModel): + """Link preview included with a message.""" + + title: str + """Link preview title.""" + + url: str + """Resolved link URL.""" + + favicon: Optional[str] = None + """Favicon URL if available. + + May be temporary or local-only to this device; download promptly if durable + access is needed. + """ + + img: Optional[str] = None + """Preview image URL if available. + + May be temporary or local-only to this device; download promptly if durable + access is needed. + """ + + img_size: Optional[LinkImgSize] = FieldInfo(alias="imgSize", default=None) + """Preview image dimensions.""" + + original_url: Optional[str] = FieldInfo(alias="originalURL", default=None) + """Original URL when the displayed URL is shortened or redirected.""" + + summary: Optional[str] = None + """Link preview summary.""" + + +class SendStatus(BaseModel): + """Message send status for this message, when reported by the bridge.""" + + status: Literal["SUCCESS", "PENDING", "FAIL_RETRIABLE", "FAIL_PERMANENT"] + """Current status of the message send attempt.""" + + timestamp: datetime + """Timestamp for the send status event.""" + + delivered_to_users: Optional[List[str]] = FieldInfo(alias="deliveredToUsers", default=None) + """User IDs the message was delivered to, when reported by the network.""" + + internal_error: Optional[str] = FieldInfo(alias="internalError", default=None) + """Internal bridge error detail. Intended for diagnostics, not end-user display.""" + + message: Optional[str] = None + """Human-readable send status or failure message.""" + + reason: Optional[str] = None + """Machine-readable failure reason. Present when the send status is a failure.""" class Message(BaseModel): @@ -21,10 +84,17 @@ class Message(BaseModel): """Beeper account ID the message belongs to.""" chat_id: str = FieldInfo(alias="chatID") - """Unique identifier of the chat.""" + """Chat ID. + + Input routes also accept the local chat ID from this Beeper Desktop installation + when available. + """ sender_id: str = FieldInfo(alias="senderID") - """Sender user ID.""" + """ + Matrix-style fully-qualified sender user ID, usually including a bridge prefix + and homeserver. + """ sort_key: str = FieldInfo(alias="sortKey") """A unique, sortable key used to sort messages.""" @@ -35,6 +105,15 @@ class Message(BaseModel): attachments: Optional[List[Attachment]] = None """Attachments included with this message, if any.""" + edited_timestamp: Optional[datetime] = FieldInfo(alias="editedTimestamp", default=None) + """Timestamp when the message was edited, if known.""" + + is_deleted: Optional[bool] = FieldInfo(alias="isDeleted", default=None) + """True if the message has been deleted.""" + + is_hidden: Optional[bool] = FieldInfo(alias="isHidden", default=None) + """True if the message is hidden from normal display.""" + is_sender: Optional[bool] = FieldInfo(alias="isSender", default=None) """True if the authenticated user sent the message.""" @@ -44,19 +123,31 @@ class Message(BaseModel): linked_message_id: Optional[str] = FieldInfo(alias="linkedMessageID", default=None) """ID of the message this is a reply to, if any.""" + links: Optional[List[Link]] = None + """Link previews included with this message, if any.""" + + mentions: Optional[List[str]] = None + """ + Mentioned user IDs, @room, or null for legacy messages that require text + scanning. + """ + reactions: Optional[List[Reaction]] = None """Reactions to the message, if any.""" + seen: Union[bool, datetime, Dict[str, Union[bool, datetime]], None] = None + """Read receipt state for this message, when available.""" + sender_name: Optional[str] = FieldInfo(alias="senderName", default=None) """ Resolved sender display name (impersonator/full name/username/participant name). """ - text: Optional[str] = None - """Plain-text body if present. + send_status: Optional[SendStatus] = FieldInfo(alias="sendStatus", default=None) + """Message send status for this message, when reported by the bridge.""" - May include a JSON fallback with text entities for rich messages. - """ + text: Optional[str] = None + """Matrix HTML body if present.""" type: Optional[ Literal["TEXT", "NOTICE", "IMAGE", "VIDEO", "VOICE", "AUDIO", "FILE", "STICKER", "LOCATION", "REACTION"] diff --git a/src/beeper_desktop_api/types/shared/reaction.py b/src/beeper_desktop_api/types/shared/reaction.py index 6a64ebe..e28fb69 100644 --- a/src/beeper_desktop_api/types/shared/reaction.py +++ b/src/beeper_desktop_api/types/shared/reaction.py @@ -11,9 +11,10 @@ class Reaction(BaseModel): id: str - """ - Reaction ID, typically ${participantID}${reactionKey} if multiple reactions - allowed, or just participantID otherwise. + """Reaction ID. + + When a participant can react more than once, the ID is the participant ID + concatenated with the reaction key; otherwise it equals the participant ID. """ participant_id: str = FieldInfo(alias="participantID") diff --git a/src/beeper_desktop_api/types/shared/user.py b/src/beeper_desktop_api/types/shared/user.py index d990c5f..f77a31f 100644 --- a/src/beeper_desktop_api/types/shared/user.py +++ b/src/beeper_desktop_api/types/shared/user.py @@ -30,8 +30,9 @@ class User(BaseModel): img_url: Optional[str] = FieldInfo(alias="imgURL", default=None) """Avatar image URL if available. - May be temporary or local-only to this device; download promptly if durable - access is needed. + This may be a remote URL, Matrix media URL, data URL, or local filesystem URL + depending on source and endpoint. May be temporary or local-only to this device; + download promptly if durable access is needed. """ is_self: Optional[bool] = FieldInfo(alias="isSelf", default=None) diff --git a/tests/api_resources/chats/messages/test_reactions.py b/tests/api_resources/chats/messages/test_reactions.py index c472ebf..5b8a782 100644 --- a/tests/api_resources/chats/messages/test_reactions.py +++ b/tests/api_resources/chats/messages/test_reactions.py @@ -9,10 +9,7 @@ from tests.utils import assert_matches_type from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop -from beeper_desktop_api.types.chats.messages import ( - ReactionAddResponse, - ReactionDeleteResponse, -) +from beeper_desktop_api.types.chats.messages import ReactionAddResponse, ReactionDeleteResponse base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -23,18 +20,18 @@ class TestReactions: @parametrize def test_method_delete(self, client: BeeperDesktop) -> None: reaction = client.chats.messages.reactions.delete( - message_id="messageID", - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reaction_key="x", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + message_id="1343993", ) assert_matches_type(ReactionDeleteResponse, reaction, path=["response"]) @parametrize def test_raw_response_delete(self, client: BeeperDesktop) -> None: response = client.chats.messages.reactions.with_raw_response.delete( - message_id="messageID", - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reaction_key="x", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + message_id="1343993", ) assert response.is_closed is True @@ -45,9 +42,9 @@ def test_raw_response_delete(self, client: BeeperDesktop) -> None: @parametrize def test_streaming_response_delete(self, client: BeeperDesktop) -> None: with client.chats.messages.reactions.with_streaming_response.delete( - message_id="messageID", - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reaction_key="x", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + message_id="1343993", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -61,22 +58,29 @@ def test_streaming_response_delete(self, client: BeeperDesktop) -> None: def test_path_params_delete(self, client: BeeperDesktop) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): client.chats.messages.reactions.with_raw_response.delete( - message_id="messageID", - chat_id="", reaction_key="x", + chat_id="", + message_id="1343993", ) with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"): client.chats.messages.reactions.with_raw_response.delete( + reaction_key="x", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", message_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `reaction_key` but received ''"): + client.chats.messages.reactions.with_raw_response.delete( + reaction_key="", chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reaction_key="x", + message_id="1343993", ) @parametrize def test_method_add(self, client: BeeperDesktop) -> None: reaction = client.chats.messages.reactions.add( - message_id="messageID", + message_id="1343993", chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reaction_key="x", ) @@ -85,7 +89,7 @@ def test_method_add(self, client: BeeperDesktop) -> None: @parametrize def test_method_add_with_all_params(self, client: BeeperDesktop) -> None: reaction = client.chats.messages.reactions.add( - message_id="messageID", + message_id="1343993", chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reaction_key="x", transaction_id="transactionID", @@ -95,7 +99,7 @@ def test_method_add_with_all_params(self, client: BeeperDesktop) -> None: @parametrize def test_raw_response_add(self, client: BeeperDesktop) -> None: response = client.chats.messages.reactions.with_raw_response.add( - message_id="messageID", + message_id="1343993", chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reaction_key="x", ) @@ -108,7 +112,7 @@ def test_raw_response_add(self, client: BeeperDesktop) -> None: @parametrize def test_streaming_response_add(self, client: BeeperDesktop) -> None: with client.chats.messages.reactions.with_streaming_response.add( - message_id="messageID", + message_id="1343993", chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reaction_key="x", ) as response: @@ -124,7 +128,7 @@ def test_streaming_response_add(self, client: BeeperDesktop) -> None: def test_path_params_add(self, client: BeeperDesktop) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): client.chats.messages.reactions.with_raw_response.add( - message_id="messageID", + message_id="1343993", chat_id="", reaction_key="x", ) @@ -145,18 +149,18 @@ class TestAsyncReactions: @parametrize async def test_method_delete(self, async_client: AsyncBeeperDesktop) -> None: reaction = await async_client.chats.messages.reactions.delete( - message_id="messageID", - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reaction_key="x", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + message_id="1343993", ) assert_matches_type(ReactionDeleteResponse, reaction, path=["response"]) @parametrize async def test_raw_response_delete(self, async_client: AsyncBeeperDesktop) -> None: response = await async_client.chats.messages.reactions.with_raw_response.delete( - message_id="messageID", - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reaction_key="x", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + message_id="1343993", ) assert response.is_closed is True @@ -167,9 +171,9 @@ async def test_raw_response_delete(self, async_client: AsyncBeeperDesktop) -> No @parametrize async def test_streaming_response_delete(self, async_client: AsyncBeeperDesktop) -> None: async with async_client.chats.messages.reactions.with_streaming_response.delete( - message_id="messageID", - chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reaction_key="x", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + message_id="1343993", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -183,22 +187,29 @@ async def test_streaming_response_delete(self, async_client: AsyncBeeperDesktop) async def test_path_params_delete(self, async_client: AsyncBeeperDesktop) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): await async_client.chats.messages.reactions.with_raw_response.delete( - message_id="messageID", - chat_id="", reaction_key="x", + chat_id="", + message_id="1343993", ) with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"): await async_client.chats.messages.reactions.with_raw_response.delete( + reaction_key="x", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", message_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `reaction_key` but received ''"): + await async_client.chats.messages.reactions.with_raw_response.delete( + reaction_key="", chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reaction_key="x", + message_id="1343993", ) @parametrize async def test_method_add(self, async_client: AsyncBeeperDesktop) -> None: reaction = await async_client.chats.messages.reactions.add( - message_id="messageID", + message_id="1343993", chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reaction_key="x", ) @@ -207,7 +218,7 @@ async def test_method_add(self, async_client: AsyncBeeperDesktop) -> None: @parametrize async def test_method_add_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: reaction = await async_client.chats.messages.reactions.add( - message_id="messageID", + message_id="1343993", chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reaction_key="x", transaction_id="transactionID", @@ -217,7 +228,7 @@ async def test_method_add_with_all_params(self, async_client: AsyncBeeperDesktop @parametrize async def test_raw_response_add(self, async_client: AsyncBeeperDesktop) -> None: response = await async_client.chats.messages.reactions.with_raw_response.add( - message_id="messageID", + message_id="1343993", chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reaction_key="x", ) @@ -230,7 +241,7 @@ async def test_raw_response_add(self, async_client: AsyncBeeperDesktop) -> None: @parametrize async def test_streaming_response_add(self, async_client: AsyncBeeperDesktop) -> None: async with async_client.chats.messages.reactions.with_streaming_response.add( - message_id="messageID", + message_id="1343993", chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reaction_key="x", ) as response: @@ -246,7 +257,7 @@ async def test_streaming_response_add(self, async_client: AsyncBeeperDesktop) -> async def test_path_params_add(self, async_client: AsyncBeeperDesktop) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): await async_client.chats.messages.reactions.with_raw_response.add( - message_id="messageID", + message_id="1343993", chat_id="", reaction_key="x", ) diff --git a/tests/api_resources/chats/test_reminders.py b/tests/api_resources/chats/test_reminders.py index ea4febb..f21dc2d 100644 --- a/tests/api_resources/chats/test_reminders.py +++ b/tests/api_resources/chats/test_reminders.py @@ -8,6 +8,7 @@ import pytest from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop +from beeper_desktop_api._utils import parse_datetime base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -19,7 +20,7 @@ class TestReminders: def test_method_create(self, client: BeeperDesktop) -> None: reminder = client.chats.reminders.create( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reminder={"remind_at_ms": 0}, + reminder={"remind_at": parse_datetime("2025-08-31T23:30:12.520Z")}, ) assert reminder is None @@ -28,7 +29,7 @@ def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: reminder = client.chats.reminders.create( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reminder={ - "remind_at_ms": 0, + "remind_at": parse_datetime("2025-08-31T23:30:12.520Z"), "dismiss_on_incoming_message": True, }, ) @@ -38,7 +39,7 @@ def test_method_create_with_all_params(self, client: BeeperDesktop) -> None: def test_raw_response_create(self, client: BeeperDesktop) -> None: response = client.chats.reminders.with_raw_response.create( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reminder={"remind_at_ms": 0}, + reminder={"remind_at": parse_datetime("2025-08-31T23:30:12.520Z")}, ) assert response.is_closed is True @@ -50,7 +51,7 @@ def test_raw_response_create(self, client: BeeperDesktop) -> None: def test_streaming_response_create(self, client: BeeperDesktop) -> None: with client.chats.reminders.with_streaming_response.create( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reminder={"remind_at_ms": 0}, + reminder={"remind_at": parse_datetime("2025-08-31T23:30:12.520Z")}, ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -65,7 +66,7 @@ def test_path_params_create(self, client: BeeperDesktop) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): client.chats.reminders.with_raw_response.create( chat_id="", - reminder={"remind_at_ms": 0}, + reminder={"remind_at": parse_datetime("2025-08-31T23:30:12.520Z")}, ) @parametrize @@ -116,7 +117,7 @@ class TestAsyncReminders: async def test_method_create(self, async_client: AsyncBeeperDesktop) -> None: reminder = await async_client.chats.reminders.create( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reminder={"remind_at_ms": 0}, + reminder={"remind_at": parse_datetime("2025-08-31T23:30:12.520Z")}, ) assert reminder is None @@ -125,7 +126,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesk reminder = await async_client.chats.reminders.create( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", reminder={ - "remind_at_ms": 0, + "remind_at": parse_datetime("2025-08-31T23:30:12.520Z"), "dismiss_on_incoming_message": True, }, ) @@ -135,7 +136,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncBeeperDesk async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> None: response = await async_client.chats.reminders.with_raw_response.create( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reminder={"remind_at_ms": 0}, + reminder={"remind_at": parse_datetime("2025-08-31T23:30:12.520Z")}, ) assert response.is_closed is True @@ -147,7 +148,7 @@ async def test_raw_response_create(self, async_client: AsyncBeeperDesktop) -> No async def test_streaming_response_create(self, async_client: AsyncBeeperDesktop) -> None: async with async_client.chats.reminders.with_streaming_response.create( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - reminder={"remind_at_ms": 0}, + reminder={"remind_at": parse_datetime("2025-08-31T23:30:12.520Z")}, ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -162,7 +163,7 @@ async def test_path_params_create(self, async_client: AsyncBeeperDesktop) -> Non with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): await async_client.chats.reminders.with_raw_response.create( chat_id="", - reminder={"remind_at_ms": 0}, + reminder={"remind_at": parse_datetime("2025-08-31T23:30:12.520Z")}, ) @parametrize diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py index 969d99c..f161ea6 100644 --- a/tests/api_resources/test_chats.py +++ b/tests/api_resources/test_chats.py @@ -83,7 +83,7 @@ def test_method_retrieve(self, client: BeeperDesktop) -> None: def test_method_retrieve_with_all_params(self, client: BeeperDesktop) -> None: chat = client.chats.retrieve( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - max_participant_count=50, + max_participant_count=100, ) assert_matches_type(Chat, chat, path=["response"]) @@ -118,6 +118,76 @@ def test_path_params_retrieve(self, client: BeeperDesktop) -> None: chat_id="", ) + @parametrize + def test_method_update(self, client: BeeperDesktop) -> None: + chat = client.chats.update( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + def test_method_update_with_all_params(self, client: BeeperDesktop) -> None: + chat = client.chats.update( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + description="description", + draft={ + "text": "text", + "attachments": { + "foo": { + "upload_id": "uploadID", + "id": "id", + "duration": 0, + "file_name": "fileName", + "mime_type": "mimeType", + "size": { + "height": 0, + "width": 0, + }, + "type": "image", + } + }, + }, + img_url="imgURL", + is_archived=True, + is_low_priority=True, + is_muted=True, + is_pinned=True, + message_expiry_seconds=0, + title="title", + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + def test_raw_response_update(self, client: BeeperDesktop) -> None: + response = client.chats.with_raw_response.update( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + def test_streaming_response_update(self, client: BeeperDesktop) -> None: + with client.chats.with_streaming_response.update( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_update(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + client.chats.with_raw_response.update( + chat_id="", + ) + @parametrize def test_method_list(self, client: BeeperDesktop) -> None: chat = client.chats.list() @@ -126,10 +196,7 @@ def test_method_list(self, client: BeeperDesktop) -> None: @parametrize def test_method_list_with_all_params(self, client: BeeperDesktop) -> None: chat = client.chats.list( - account_ids=[ - "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", - ], + account_ids=["matrix", "discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"], cursor="1725489123456|c29tZUltc2dQYWdl", direction="before", ) @@ -201,6 +268,136 @@ def test_path_params_archive(self, client: BeeperDesktop) -> None: chat_id="", ) + @parametrize + def test_method_mark_read(self, client: BeeperDesktop) -> None: + chat = client.chats.mark_read( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + def test_method_mark_read_with_all_params(self, client: BeeperDesktop) -> None: + chat = client.chats.mark_read( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + message_id="1343993", + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + def test_raw_response_mark_read(self, client: BeeperDesktop) -> None: + response = client.chats.with_raw_response.mark_read( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + def test_streaming_response_mark_read(self, client: BeeperDesktop) -> None: + with client.chats.with_streaming_response.mark_read( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_mark_read(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + client.chats.with_raw_response.mark_read( + chat_id="", + ) + + @parametrize + def test_method_mark_unread(self, client: BeeperDesktop) -> None: + chat = client.chats.mark_unread( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + def test_method_mark_unread_with_all_params(self, client: BeeperDesktop) -> None: + chat = client.chats.mark_unread( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + message_id="1343993", + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + def test_raw_response_mark_unread(self, client: BeeperDesktop) -> None: + response = client.chats.with_raw_response.mark_unread( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + def test_streaming_response_mark_unread(self, client: BeeperDesktop) -> None: + with client.chats.with_streaming_response.mark_unread( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_mark_unread(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + client.chats.with_raw_response.mark_unread( + chat_id="", + ) + + @parametrize + def test_method_notify_anyway(self, client: BeeperDesktop) -> None: + chat = client.chats.notify_anyway( + "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + def test_raw_response_notify_anyway(self, client: BeeperDesktop) -> None: + response = client.chats.with_raw_response.notify_anyway( + "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + def test_streaming_response_notify_anyway(self, client: BeeperDesktop) -> None: + with client.chats.with_streaming_response.notify_anyway( + "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_notify_anyway(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + client.chats.with_raw_response.notify_anyway( + "", + ) + @parametrize def test_method_search(self, client: BeeperDesktop) -> None: chat = client.chats.search() @@ -209,10 +406,7 @@ def test_method_search(self, client: BeeperDesktop) -> None: @parametrize def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: chat = client.chats.search( - account_ids=[ - "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", - ], + account_ids=["matrix", "discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"], cursor="1725489123456|c29tZUltc2dQYWdl", direction="before", inbox="primary", @@ -247,9 +441,6 @@ def test_streaming_response_search(self, client: BeeperDesktop) -> None: assert cast(Any, response.is_closed) is True - @pytest.mark.skip( - reason="Stainless mock tests currently load the project-published OpenAPI spec URL, which may not include newly-added local-only endpoints during build checks." - ) @parametrize def test_method_start(self, client: BeeperDesktop) -> None: chat = client.chats.start( @@ -258,9 +449,6 @@ def test_method_start(self, client: BeeperDesktop) -> None: ) assert_matches_type(ChatStartResponse, chat, path=["response"]) - @pytest.mark.skip( - reason="Stainless mock tests currently load the project-published OpenAPI spec URL, which may not include newly-added local-only endpoints during build checks." - ) @parametrize def test_method_start_with_all_params(self, client: BeeperDesktop) -> None: chat = client.chats.start( @@ -277,9 +465,6 @@ def test_method_start_with_all_params(self, client: BeeperDesktop) -> None: ) assert_matches_type(ChatStartResponse, chat, path=["response"]) - @pytest.mark.skip( - reason="Stainless mock tests currently load the project-published OpenAPI spec URL, which may not include newly-added local-only endpoints during build checks." - ) @parametrize def test_raw_response_start(self, client: BeeperDesktop) -> None: response = client.chats.with_raw_response.start( @@ -292,9 +477,6 @@ def test_raw_response_start(self, client: BeeperDesktop) -> None: chat = response.parse() assert_matches_type(ChatStartResponse, chat, path=["response"]) - @pytest.mark.skip( - reason="Stainless mock tests currently load the project-published OpenAPI spec URL, which may not include newly-added local-only endpoints during build checks." - ) @parametrize def test_streaming_response_start(self, client: BeeperDesktop) -> None: with client.chats.with_streaming_response.start( @@ -374,7 +556,7 @@ async def test_method_retrieve(self, async_client: AsyncBeeperDesktop) -> None: async def test_method_retrieve_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.retrieve( chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", - max_participant_count=50, + max_participant_count=100, ) assert_matches_type(Chat, chat, path=["response"]) @@ -409,6 +591,76 @@ async def test_path_params_retrieve(self, async_client: AsyncBeeperDesktop) -> N chat_id="", ) + @parametrize + async def test_method_update(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.update( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.update( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + description="description", + draft={ + "text": "text", + "attachments": { + "foo": { + "upload_id": "uploadID", + "id": "id", + "duration": 0, + "file_name": "fileName", + "mime_type": "mimeType", + "size": { + "height": 0, + "width": 0, + }, + "type": "image", + } + }, + }, + img_url="imgURL", + is_archived=True, + is_low_priority=True, + is_muted=True, + is_pinned=True, + message_expiry_seconds=0, + title="title", + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + async def test_raw_response_update(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.with_raw_response.update( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = await response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + async def test_streaming_response_update(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.with_streaming_response.update( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = await response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_update(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + await async_client.chats.with_raw_response.update( + chat_id="", + ) + @parametrize async def test_method_list(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.list() @@ -417,10 +669,7 @@ async def test_method_list(self, async_client: AsyncBeeperDesktop) -> None: @parametrize async def test_method_list_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.list( - account_ids=[ - "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", - ], + account_ids=["matrix", "discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"], cursor="1725489123456|c29tZUltc2dQYWdl", direction="before", ) @@ -492,6 +741,136 @@ async def test_path_params_archive(self, async_client: AsyncBeeperDesktop) -> No chat_id="", ) + @parametrize + async def test_method_mark_read(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.mark_read( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + async def test_method_mark_read_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.mark_read( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + message_id="1343993", + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + async def test_raw_response_mark_read(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.with_raw_response.mark_read( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = await response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + async def test_streaming_response_mark_read(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.with_streaming_response.mark_read( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = await response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_mark_read(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + await async_client.chats.with_raw_response.mark_read( + chat_id="", + ) + + @parametrize + async def test_method_mark_unread(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.mark_unread( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + async def test_method_mark_unread_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.mark_unread( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + message_id="1343993", + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + async def test_raw_response_mark_unread(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.with_raw_response.mark_unread( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = await response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + async def test_streaming_response_mark_unread(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.with_streaming_response.mark_unread( + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = await response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_mark_unread(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + await async_client.chats.with_raw_response.mark_unread( + chat_id="", + ) + + @parametrize + async def test_method_notify_anyway(self, async_client: AsyncBeeperDesktop) -> None: + chat = await async_client.chats.notify_anyway( + "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + async def test_raw_response_notify_anyway(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.chats.with_raw_response.notify_anyway( + "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + chat = await response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + @parametrize + async def test_streaming_response_notify_anyway(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.chats.with_streaming_response.notify_anyway( + "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + chat = await response.parse() + assert_matches_type(Chat, chat, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_notify_anyway(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + await async_client.chats.with_raw_response.notify_anyway( + "", + ) + @parametrize async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.search() @@ -500,10 +879,7 @@ async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: @parametrize async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.search( - account_ids=[ - "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", - ], + account_ids=["matrix", "discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"], cursor="1725489123456|c29tZUltc2dQYWdl", direction="before", inbox="primary", @@ -538,9 +914,6 @@ async def test_streaming_response_search(self, async_client: AsyncBeeperDesktop) assert cast(Any, response.is_closed) is True - @pytest.mark.skip( - reason="Stainless mock tests currently load the project-published OpenAPI spec URL, which may not include newly-added local-only endpoints during build checks." - ) @parametrize async def test_method_start(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.start( @@ -549,9 +922,6 @@ async def test_method_start(self, async_client: AsyncBeeperDesktop) -> None: ) assert_matches_type(ChatStartResponse, chat, path=["response"]) - @pytest.mark.skip( - reason="Stainless mock tests currently load the project-published OpenAPI spec URL, which may not include newly-added local-only endpoints during build checks." - ) @parametrize async def test_method_start_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: chat = await async_client.chats.start( @@ -568,9 +938,6 @@ async def test_method_start_with_all_params(self, async_client: AsyncBeeperDeskt ) assert_matches_type(ChatStartResponse, chat, path=["response"]) - @pytest.mark.skip( - reason="Stainless mock tests currently load the project-published OpenAPI spec URL, which may not include newly-added local-only endpoints during build checks." - ) @parametrize async def test_raw_response_start(self, async_client: AsyncBeeperDesktop) -> None: response = await async_client.chats.with_raw_response.start( @@ -583,9 +950,6 @@ async def test_raw_response_start(self, async_client: AsyncBeeperDesktop) -> Non chat = await response.parse() assert_matches_type(ChatStartResponse, chat, path=["response"]) - @pytest.mark.skip( - reason="Stainless mock tests currently load the project-published OpenAPI spec URL, which may not include newly-added local-only endpoints during build checks." - ) @parametrize async def test_streaming_response_start(self, async_client: AsyncBeeperDesktop) -> None: async with async_client.chats.with_streaming_response.start( diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py index fec66ee..1d301b2 100644 --- a/tests/api_resources/test_messages.py +++ b/tests/api_resources/test_messages.py @@ -23,10 +23,58 @@ class TestMessages: parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + @parametrize + def test_method_retrieve(self, client: BeeperDesktop) -> None: + message = client.messages.retrieve( + message_id="1343993", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(Message, message, path=["response"]) + + @parametrize + def test_raw_response_retrieve(self, client: BeeperDesktop) -> None: + response = client.messages.with_raw_response.retrieve( + message_id="1343993", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = response.parse() + assert_matches_type(Message, message, path=["response"]) + + @parametrize + def test_streaming_response_retrieve(self, client: BeeperDesktop) -> None: + with client.messages.with_streaming_response.retrieve( + message_id="1343993", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = response.parse() + assert_matches_type(Message, message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_retrieve(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + client.messages.with_raw_response.retrieve( + message_id="1343993", + chat_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"): + client.messages.with_raw_response.retrieve( + message_id="", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + @parametrize def test_method_update(self, client: BeeperDesktop) -> None: message = client.messages.update( - message_id="messageID", + message_id="1343993", chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", text="x", ) @@ -35,7 +83,7 @@ def test_method_update(self, client: BeeperDesktop) -> None: @parametrize def test_raw_response_update(self, client: BeeperDesktop) -> None: response = client.messages.with_raw_response.update( - message_id="messageID", + message_id="1343993", chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", text="x", ) @@ -48,7 +96,7 @@ def test_raw_response_update(self, client: BeeperDesktop) -> None: @parametrize def test_streaming_response_update(self, client: BeeperDesktop) -> None: with client.messages.with_streaming_response.update( - message_id="messageID", + message_id="1343993", chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", text="x", ) as response: @@ -64,7 +112,7 @@ def test_streaming_response_update(self, client: BeeperDesktop) -> None: def test_path_params_update(self, client: BeeperDesktop) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): client.messages.with_raw_response.update( - message_id="messageID", + message_id="1343993", chat_id="", text="x", ) @@ -123,6 +171,63 @@ def test_path_params_list(self, client: BeeperDesktop) -> None: chat_id="", ) + @parametrize + def test_method_delete(self, client: BeeperDesktop) -> None: + message = client.messages.delete( + message_id="1343993", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert message is None + + @parametrize + def test_method_delete_with_all_params(self, client: BeeperDesktop) -> None: + message = client.messages.delete( + message_id="1343993", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + for_everyone=True, + ) + assert message is None + + @parametrize + def test_raw_response_delete(self, client: BeeperDesktop) -> None: + response = client.messages.with_raw_response.delete( + message_id="1343993", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = response.parse() + assert message is None + + @parametrize + def test_streaming_response_delete(self, client: BeeperDesktop) -> None: + with client.messages.with_streaming_response.delete( + message_id="1343993", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = response.parse() + assert message is None + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_delete(self, client: BeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + client.messages.with_raw_response.delete( + message_id="1343993", + chat_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"): + client.messages.with_raw_response.delete( + message_id="", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + @parametrize def test_method_search(self, client: BeeperDesktop) -> None: message = client.messages.search() @@ -131,10 +236,7 @@ def test_method_search(self, client: BeeperDesktop) -> None: @parametrize def test_method_search_with_all_params(self, client: BeeperDesktop) -> None: message = client.messages.search( - account_ids=[ - "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", - ], + account_ids=["matrix", "discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"], chat_ids=["!NCdzlIaMjZUmvmvyHU:beeper.com", "1231073"], chat_type="group", cursor="1725489123456|c29tZUltc2dQYWdl", @@ -190,7 +292,7 @@ def test_method_send_with_all_params(self, client: BeeperDesktop) -> None: "height": 0, "width": 0, }, - "type": "gif", + "type": "image", }, reply_to_message_id="replyToMessageID", text="text", @@ -234,10 +336,58 @@ class TestAsyncMessages: "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] ) + @parametrize + async def test_method_retrieve(self, async_client: AsyncBeeperDesktop) -> None: + message = await async_client.messages.retrieve( + message_id="1343993", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert_matches_type(Message, message, path=["response"]) + + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.messages.with_raw_response.retrieve( + message_id="1343993", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = await response.parse() + assert_matches_type(Message, message, path=["response"]) + + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.messages.with_streaming_response.retrieve( + message_id="1343993", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = await response.parse() + assert_matches_type(Message, message, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + await async_client.messages.with_raw_response.retrieve( + message_id="1343993", + chat_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"): + await async_client.messages.with_raw_response.retrieve( + message_id="", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + @parametrize async def test_method_update(self, async_client: AsyncBeeperDesktop) -> None: message = await async_client.messages.update( - message_id="messageID", + message_id="1343993", chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", text="x", ) @@ -246,7 +396,7 @@ async def test_method_update(self, async_client: AsyncBeeperDesktop) -> None: @parametrize async def test_raw_response_update(self, async_client: AsyncBeeperDesktop) -> None: response = await async_client.messages.with_raw_response.update( - message_id="messageID", + message_id="1343993", chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", text="x", ) @@ -259,7 +409,7 @@ async def test_raw_response_update(self, async_client: AsyncBeeperDesktop) -> No @parametrize async def test_streaming_response_update(self, async_client: AsyncBeeperDesktop) -> None: async with async_client.messages.with_streaming_response.update( - message_id="messageID", + message_id="1343993", chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", text="x", ) as response: @@ -275,7 +425,7 @@ async def test_streaming_response_update(self, async_client: AsyncBeeperDesktop) async def test_path_params_update(self, async_client: AsyncBeeperDesktop) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): await async_client.messages.with_raw_response.update( - message_id="messageID", + message_id="1343993", chat_id="", text="x", ) @@ -334,6 +484,63 @@ async def test_path_params_list(self, async_client: AsyncBeeperDesktop) -> None: chat_id="", ) + @parametrize + async def test_method_delete(self, async_client: AsyncBeeperDesktop) -> None: + message = await async_client.messages.delete( + message_id="1343993", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + assert message is None + + @parametrize + async def test_method_delete_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: + message = await async_client.messages.delete( + message_id="1343993", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + for_everyone=True, + ) + assert message is None + + @parametrize + async def test_raw_response_delete(self, async_client: AsyncBeeperDesktop) -> None: + response = await async_client.messages.with_raw_response.delete( + message_id="1343993", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + message = await response.parse() + assert message is None + + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncBeeperDesktop) -> None: + async with async_client.messages.with_streaming_response.delete( + message_id="1343993", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + message = await response.parse() + assert message is None + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_delete(self, async_client: AsyncBeeperDesktop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `chat_id` but received ''"): + await async_client.messages.with_raw_response.delete( + message_id="1343993", + chat_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"): + await async_client.messages.with_raw_response.delete( + message_id="", + chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + @parametrize async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: message = await async_client.messages.search() @@ -342,10 +549,7 @@ async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None: @parametrize async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesktop) -> None: message = await async_client.messages.search( - account_ids=[ - "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", - ], + account_ids=["matrix", "discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"], chat_ids=["!NCdzlIaMjZUmvmvyHU:beeper.com", "1231073"], chat_type="group", cursor="1725489123456|c29tZUltc2dQYWdl", @@ -401,7 +605,7 @@ async def test_method_send_with_all_params(self, async_client: AsyncBeeperDeskto "height": 0, "width": 0, }, - "type": "gif", + "type": "image", }, reply_to_message_id="replyToMessageID", text="text", From e0b0a39935028d9fd5274c32dc3804d87c447439 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 21:18:11 +0000 Subject: [PATCH 49/49] release: 5.0.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 61 ++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/beeper_desktop_api/_version.py | 2 +- 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 29102ae..8e76abb 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "4.3.0" + ".": "5.0.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d5ee4c9..03fd832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,66 @@ # Changelog +## 5.0.0 (2026-05-06) + +Full Changelog: [v4.3.0...v5.0.0](https://github.com/beeper/desktop-api-python/compare/v4.3.0...v5.0.0) + +### Features + +* **api:** add network, bridge fields to accounts ([af70fc9](https://github.com/beeper/desktop-api-python/commit/af70fc9fab45036721b4be634bb4444964c70d1e)) +* **api:** api update ([a5afb8f](https://github.com/beeper/desktop-api-python/commit/a5afb8f6a0037bc3727eca0a7ca0c46ca371c6f0)) +* **api:** api update ([a1af475](https://github.com/beeper/desktop-api-python/commit/a1af475c4f4c51e2aed27ea8c793364444ed8055)) +* **api:** api update ([770a8e2](https://github.com/beeper/desktop-api-python/commit/770a8e2a6fc4d96dae58b3b787d55072faf63e34)) +* **api:** manual updates ([c84dca5](https://github.com/beeper/desktop-api-python/commit/c84dca576d56b83e314ab798749607e70aea7223)) +* **internal:** implement indices array format for query and form serialization ([de85c3a](https://github.com/beeper/desktop-api-python/commit/de85c3aef481f44350bab667ed81e155573ace81)) +* support setting headers via env ([6841539](https://github.com/beeper/desktop-api-python/commit/6841539c8619a507ec5e717d08a81837e83d76c2)) + + +### Bug Fixes + +* **client:** preserve hardcoded query params when merging with user params ([9e86464](https://github.com/beeper/desktop-api-python/commit/9e86464960e28472bc3a4f137c5d2c025f2acc16)) +* **deps:** bump minimum typing-extensions version ([922d90a](https://github.com/beeper/desktop-api-python/commit/922d90aeb6d75306490a359d26ebbf71a6e340b8)) +* ensure file data are only sent as 1 parameter ([69f6d11](https://github.com/beeper/desktop-api-python/commit/69f6d11ecb0959d1a5eb90c41c76542a1ea5826f)) +* **pydantic:** do not pass `by_alias` unless set ([8b9fe85](https://github.com/beeper/desktop-api-python/commit/8b9fe85df1911bc10a65b5c965e5465c4041e065)) +* sanitize endpoint path params ([900c955](https://github.com/beeper/desktop-api-python/commit/900c955edf1d5f8cf7aa9c7d8a7859e5b61ae379)) +* use correct field name format for multipart file arrays ([d086e7f](https://github.com/beeper/desktop-api-python/commit/d086e7f0ff86653ace4d1f21c5daf1d6605b3369)) + + +### Performance Improvements + +* **client:** optimize file structure copying in multipart requests ([7addb88](https://github.com/beeper/desktop-api-python/commit/7addb88adf0574857ce646e8a9f15e8eb035a48a)) + + +### Chores + +* **ci:** skip lint on metadata-only changes ([1fe013e](https://github.com/beeper/desktop-api-python/commit/1fe013eacfb814508b88eefa3b2ee3bf51618edc)) +* **ci:** skip uploading artifacts on stainless-internal branches ([3f5692e](https://github.com/beeper/desktop-api-python/commit/3f5692eb199bd02db1359e8131c8774eee7fabcf)) +* configure new SDK language ([8b9d76c](https://github.com/beeper/desktop-api-python/commit/8b9d76c76fe4e3ae99d85429f20d9b782bea2520)) +* configure new SDK language ([a54d51a](https://github.com/beeper/desktop-api-python/commit/a54d51a23c31d38e124dc263f748f6ada2f2409c)) +* **internal:** add request options to SSE classes ([fcf96d3](https://github.com/beeper/desktop-api-python/commit/fcf96d3c4f3bdbec2cd1b88745cdbbc48e864be2)) +* **internal:** codegen related update ([a6b8aac](https://github.com/beeper/desktop-api-python/commit/a6b8aac8430c698cd1a73bab2cd257c9cf553df6)) +* **internal:** make `test_proxy_environment_variables` more resilient ([2420dd3](https://github.com/beeper/desktop-api-python/commit/2420dd3d3de95350f142acaf7fb923fd292af59e)) +* **internal:** make `test_proxy_environment_variables` more resilient to env ([1ad2ddf](https://github.com/beeper/desktop-api-python/commit/1ad2ddfe678d2a495d69e45d7a1a8f0856af4211)) +* **internal:** more robust bootstrap script ([ed8c2c4](https://github.com/beeper/desktop-api-python/commit/ed8c2c499c99ac2f1a4e83054ae0000cb9e12c47)) +* **internal:** reformat pyproject.toml ([f11e2a6](https://github.com/beeper/desktop-api-python/commit/f11e2a6c2e25d78bd95e0a81ce52e814dc2a9e79)) +* **internal:** tweak CI branches ([311a998](https://github.com/beeper/desktop-api-python/commit/311a998617de99c1defaa4c54f1b8a308d1bfaf3)) +* **internal:** update gitignore ([3dfb379](https://github.com/beeper/desktop-api-python/commit/3dfb3799f6ebbde6400db092864361e3b6a6e07f)) +* **test:** do not count install time for mock server timeout ([352dc26](https://github.com/beeper/desktop-api-python/commit/352dc26df496dac34b88c8d410a3d5761fad7cde)) +* **tests:** bump steady to v0.19.4 ([68f14af](https://github.com/beeper/desktop-api-python/commit/68f14afb85f168eb61a91398165b1d8222fbeea4)) +* **tests:** bump steady to v0.19.5 ([9229d32](https://github.com/beeper/desktop-api-python/commit/9229d32b59f8494f49677dbf508d2fedd21cd8b4)) +* **tests:** bump steady to v0.19.6 ([166b069](https://github.com/beeper/desktop-api-python/commit/166b069fbd3034b27d16baf50ef96c61a46996d9)) +* **tests:** bump steady to v0.19.7 ([31a8e58](https://github.com/beeper/desktop-api-python/commit/31a8e58a09bd798b9930c195da2be239d50a2e77)) +* **tests:** bump steady to v0.20.1 ([d2cf119](https://github.com/beeper/desktop-api-python/commit/d2cf119042313a4b82f4a395a43c175874ceca1e)) +* **tests:** bump steady to v0.20.2 ([0def55c](https://github.com/beeper/desktop-api-python/commit/0def55c17787849b27d1e8c21cb6cd129069e220)) +* **tests:** bump steady to v0.22.1 ([29127f7](https://github.com/beeper/desktop-api-python/commit/29127f7697a10191b913f86e8664321963b55003)) +* update placeholder string ([f9883db](https://github.com/beeper/desktop-api-python/commit/f9883db325ccb453c60affa4218f2b257c4d41d8)) +* update SDK settings ([b954521](https://github.com/beeper/desktop-api-python/commit/b954521c09743ea77dc1fe3f49e62cadb9cb6b1f)) +* update SDK settings ([5df69bf](https://github.com/beeper/desktop-api-python/commit/5df69bf22554340ee0fd0c694fb755c80907ee22)) + + +### Refactors + +* **tests:** switch from prism to steady ([ef99778](https://github.com/beeper/desktop-api-python/commit/ef99778f642f49a26aad1d65c59df9f9cfa766e9)) + ## 4.3.0 (2026-02-20) Full Changelog: [v4.2.0...v4.3.0](https://github.com/beeper/desktop-api-python/compare/v4.2.0...v4.3.0) diff --git a/pyproject.toml b/pyproject.toml index 7416fc8..83d327e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "beeper_desktop_api" -version = "4.3.0" +version = "5.0.0" description = "The official Python library for the beeperdesktop API" dynamic = ["readme"] license = "MIT" diff --git a/src/beeper_desktop_api/_version.py b/src/beeper_desktop_api/_version.py index 1bc95e4..60fb169 100644 --- a/src/beeper_desktop_api/_version.py +++ b/src/beeper_desktop_api/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "beeper_desktop_api" -__version__ = "4.3.0" # x-release-please-version +__version__ = "5.0.0" # x-release-please-version