diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index eb758a6..825e98a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,7 +19,7 @@ jobs:
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
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Install Rye
run: |
@@ -44,7 +44,7 @@ jobs:
id-token: write
runs-on: ${{ github.repository == 'stainless-sdks/beeper-desktop-api-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Install Rye
run: |
@@ -81,7 +81,7 @@ jobs:
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
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Install Rye
run: |
diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml
index c22364e..08d08f6 100644
--- a/.github/workflows/publish-pypi.yml
+++ b/.github/workflows/publish-pypi.yml
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Install Rye
run: |
diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml
index c6b3e44..4bccf2f 100644
--- a/.github/workflows/release-doctor.yml
+++ b/.github/workflows/release-doctor.yml
@@ -12,7 +12,7 @@ jobs:
if: github.repository == 'beeper/desktop-api-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next')
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Check release environment
run: |
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index d6a8b5d..bd7f384 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "4.1.296"
+ ".": "4.2.0"
}
\ No newline at end of file
diff --git a/.stats.yml b/.stats.yml
index 325712c..cf52119 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
-configured_endpoints: 15
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-0763b61997721da6f4514241bf0f7bb5f7a88c7298baf0f1b2d58036aaf7e2f1.yml
-openapi_spec_hash: 5158475919c04bb52fb03c6a4582188d
-config_hash: 5fa7ded4bfdffe4cc1944a819da87f9f
+configured_endpoints: 18
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5fb80d7f97f2428d1826b9c381476f0d46117fc694140175dbc15920b1884f1f.yml
+openapi_spec_hash: 06f8538bc0a27163d33a80c00fb16e86
+config_hash: 85c42610c7ef58aa5e2a51a068ebb831
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 78075b2..0f27b0e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,50 @@
# Changelog
+## 4.2.0 (2026-01-23)
+
+Full Changelog: [v4.1.296...v4.2.0](https://github.com/beeper/desktop-api-python/compare/v4.1.296...v4.2.0)
+
+### Features
+
+* **api:** add `description` field to chats, make `title` optional ([4ea0387](https://github.com/beeper/desktop-api-python/commit/4ea0387eaec221fd3bbfc38dd0d78bec923a8d81))
+* **api:** add upload asset and edit message endpoints ([b73273f](https://github.com/beeper/desktop-api-python/commit/b73273f6831278207d89927097e1cfcfaba7a22a))
+* **api:** manual updates ([9e0265f](https://github.com/beeper/desktop-api-python/commit/9e0265f5155064f9c253ed17614e701a13ce39cf))
+* **api:** remove mcp for now ([108db8e](https://github.com/beeper/desktop-api-python/commit/108db8e71d02f8457e88f6299597ecad23f756ea))
+* **client:** add support for binary request streaming ([ea509da](https://github.com/beeper/desktop-api-python/commit/ea509daa15ef0e9dcf0b08d379c97dff1b1fd4eb))
+
+
+### Bug Fixes
+
+* **client:** close streams without requiring full consumption ([5449667](https://github.com/beeper/desktop-api-python/commit/544966767cb709bb67daf01b3e01fc0a1f5b78c8))
+* compat with Python 3.14 ([ed03f21](https://github.com/beeper/desktop-api-python/commit/ed03f2168fe6f88ecc7068c8914065784d11561c))
+* **compat:** update signatures of `model_dump` and `model_dump_json` for Pydantic v1 ([e185aed](https://github.com/beeper/desktop-api-python/commit/e185aede9d368ba424a73e42e19eafb7ba581222))
+* ensure streams are always closed ([3a660be](https://github.com/beeper/desktop-api-python/commit/3a660be67436ef4e2227ed905682c0162fa4ee01))
+* **types:** allow pyright to infer TypedDict types within SequenceNotStr ([9a11f98](https://github.com/beeper/desktop-api-python/commit/9a11f98105b307afcdf27d44a17749908596642c))
+* use async_to_httpx_files in patch method ([f37e9db](https://github.com/beeper/desktop-api-python/commit/f37e9db5b52f983f0931cd23182bc4083d135497))
+
+
+### Chores
+
+* add missing docstrings ([ec4dacb](https://github.com/beeper/desktop-api-python/commit/ec4dacbba523fe8d8c7aa55441f2950de1748f3d))
+* add Python 3.14 classifier and testing ([e735dc0](https://github.com/beeper/desktop-api-python/commit/e735dc09e604866003b0dc0acf76c10a1d580f51))
+* configure new SDK language ([df55111](https://github.com/beeper/desktop-api-python/commit/df551116f6eab14028e30d0974b9157a4ed9543d))
+* **deps:** mypy 1.18.1 has a regression, pin to 1.17 ([1963ec3](https://github.com/beeper/desktop-api-python/commit/1963ec35e4a9b5aba1dae3533898bad4e8979fb0))
+* **docs:** use environment variables for authentication in code snippets ([b8c7ffb](https://github.com/beeper/desktop-api-python/commit/b8c7ffb4b13386fd98afe0ad77ca210320cf3c4b))
+* **internal/tests:** avoid race condition with implicit client cleanup ([3b3c246](https://github.com/beeper/desktop-api-python/commit/3b3c24628854e4fea29e0594ef5ecc31f9444c02))
+* **internal:** add `--fix` argument to lint script ([d958469](https://github.com/beeper/desktop-api-python/commit/d95846930fdee434c6aa1f694c84d22e9ec4ea41))
+* **internal:** add missing files argument to base client ([85e06b8](https://github.com/beeper/desktop-api-python/commit/85e06b8d715968ffbfaf158ef0e56d468f55bbaa))
+* **internal:** codegen related update ([be5fb2d](https://github.com/beeper/desktop-api-python/commit/be5fb2d9cad77879a2216b770e7bf25ddbe3b778))
+* **internal:** grammar fix (it's -> its) ([9dd17e2](https://github.com/beeper/desktop-api-python/commit/9dd17e2b322aedd17dc0cd2e5cc78a1ac38ae53a))
+* **internal:** update `actions/checkout` version ([d82497d](https://github.com/beeper/desktop-api-python/commit/d82497d40140d08bc2659ef70b1e76e237fb1fa6))
+* **package:** drop Python 3.8 support ([3926021](https://github.com/beeper/desktop-api-python/commit/3926021bbdb4c56732364e1b4dc065ec47cf85c0))
+* speedup initial import ([78578a1](https://github.com/beeper/desktop-api-python/commit/78578a1910a2cb82650410d82fc9ab6e5099b5e3))
+* update lockfile ([366d69a](https://github.com/beeper/desktop-api-python/commit/366d69acd5e42fb975fa2d72002285a6ab76d990))
+
+
+### Documentation
+
+* prominently feature MCP server setup in root SDK readmes ([cc7035b](https://github.com/beeper/desktop-api-python/commit/cc7035ba857eca0b63dc3c169a43172fe5e2e437))
+
## 4.1.296 (2025-10-18)
Full Changelog: [v4.1.295...v4.1.296](https://github.com/beeper/desktop-api-python/compare/v4.1.295...v4.1.296)
diff --git a/LICENSE b/LICENSE
index 59424c8..76a908d 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright 2025 beeperdesktop
+Copyright 2026 beeperdesktop
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
diff --git a/README.md b/README.md
index 33488be..857bb9b 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
[)](https://pypi.org/project/beeper_desktop_api/)
-The Beeper Desktop Python library provides convenient access to the Beeper Desktop REST API from any Python 3.8+
+The Beeper Desktop Python library provides convenient access to the Beeper Desktop REST API from any Python 3.9+
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).
@@ -14,10 +14,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).
@@ -78,13 +81,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
@@ -92,7 +96,9 @@ from beeper_desktop_api import AsyncBeeperDesktop
async def main() -> None:
async with AsyncBeeperDesktop(
- access_token="My Access Token",
+ 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(
@@ -209,6 +215,23 @@ client.chats.reminders.create(
)
```
+## File uploads
+
+Request parameters that correspond to file uploads can be passed as `bytes`, or a [`PathLike`](https://docs.python.org/3/library/os.html#os.PathLike) instance or a tuple of `(filename, contents, media type)`.
+
+```python
+from pathlib import Path
+from beeper_desktop_api import BeeperDesktop
+
+client = BeeperDesktop()
+
+client.assets.upload(
+ file=Path("/path/to/file"),
+)
+```
+
+The async client uses the exact same interface. If you pass a [`PathLike`](https://docs.python.org/3/library/os.html#os.PathLike) instance, the file contents will be read asynchronously automatically.
+
## Handling errors
When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `beeper_desktop_api.APIConnectionError` is raised.
@@ -460,7 +483,7 @@ print(beeper_desktop_api.__version__)
## Requirements
-Python 3.8 or higher.
+Python 3.9 or higher.
## Contributing
diff --git a/api.md b/api.md
index 48bc9b9..c309128 100644
--- a/api.md
+++ b/api.md
@@ -69,11 +69,12 @@ Methods:
Types:
```python
-from beeper_desktop_api.types import MessageSendResponse
+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.search(\*\*params) -> SyncCursorSearch[Message]
- client.messages.send(chat_id, \*\*params) -> MessageSendResponse
@@ -83,9 +84,15 @@ Methods:
Types:
```python
-from beeper_desktop_api.types import AssetDownloadResponse
+from beeper_desktop_api.types import (
+ AssetDownloadResponse,
+ AssetUploadResponse,
+ AssetUploadBase64Response,
+)
```
Methods:
- client.assets.download(\*\*params) -> AssetDownloadResponse
+- client.assets.upload(\*\*params) -> AssetUploadResponse
+- client.assets.upload_base64(\*\*params) -> AssetUploadBase64Response
diff --git a/pyproject.toml b/pyproject.toml
index 8855e0f..813a399 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,30 +1,32 @@
[project]
name = "beeper_desktop_api"
-version = "4.1.296"
+version = "4.2.0"
description = "The official Python library for the beeperdesktop API"
dynamic = ["readme"]
license = "MIT"
authors = [
{ name = "Beeper Desktop", email = "help@beeper.com" },
]
+
dependencies = [
- "httpx>=0.23.0, <1",
- "pydantic>=1.9.0, <3",
- "typing-extensions>=4.10, <5",
- "anyio>=3.5.0, <5",
- "distro>=1.7.0, <2",
- "sniffio",
+ "httpx>=0.23.0, <1",
+ "pydantic>=1.9.0, <3",
+ "typing-extensions>=4.10, <5",
+ "anyio>=3.5.0, <5",
+ "distro>=1.7.0, <2",
+ "sniffio",
]
-requires-python = ">= 3.8"
+
+requires-python = ">= 3.9"
classifiers = [
"Typing :: Typed",
"Intended Audience :: Developers",
- "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
"Operating System :: OS Independent",
"Operating System :: POSIX",
"Operating System :: MacOS",
@@ -46,7 +48,7 @@ managed = true
# version pins are in requirements-dev.lock
dev-dependencies = [
"pyright==1.1.399",
- "mypy",
+ "mypy==1.17",
"respx",
"pytest",
"pytest-asyncio",
@@ -141,7 +143,7 @@ filterwarnings = [
# there are a couple of flags that are still disabled by
# default in strict mode as they are experimental and niche.
typeCheckingMode = "strict"
-pythonVersion = "3.8"
+pythonVersion = "3.9"
exclude = [
"_dev",
diff --git a/requirements-dev.lock b/requirements-dev.lock
index 22f7e57..2e7f1c5 100644
--- a/requirements-dev.lock
+++ b/requirements-dev.lock
@@ -12,40 +12,45 @@
-e file:.
aiohappyeyeballs==2.6.1
# via aiohttp
-aiohttp==3.12.8
+aiohttp==3.13.2
# via beeper-desktop-api
# via httpx-aiohttp
-aiosignal==1.3.2
+aiosignal==1.4.0
# via aiohttp
-annotated-types==0.6.0
+annotated-types==0.7.0
# via pydantic
-anyio==4.4.0
+anyio==4.12.0
# via beeper-desktop-api
# via httpx
-argcomplete==3.1.2
+argcomplete==3.6.3
# via nox
async-timeout==5.0.1
# via aiohttp
-attrs==25.3.0
+attrs==25.4.0
# via aiohttp
-certifi==2023.7.22
+ # via nox
+backports-asyncio-runner==1.2.0
+ # via pytest-asyncio
+certifi==2025.11.12
# via httpcore
# via httpx
-colorlog==6.7.0
+colorlog==6.10.1
+ # via nox
+dependency-groups==1.3.1
# via nox
-dirty-equals==0.6.0
-distlib==0.3.7
+dirty-equals==0.11
+distlib==0.4.0
# via virtualenv
-distro==1.8.0
+distro==1.9.0
# via beeper-desktop-api
-exceptiongroup==1.2.2
+exceptiongroup==1.3.1
# via anyio
# via pytest
-execnet==2.1.1
+execnet==2.1.2
# via pytest-xdist
-filelock==3.12.4
+filelock==3.19.1
# via virtualenv
-frozenlist==1.6.2
+frozenlist==1.8.0
# via aiohttp
# via aiosignal
h11==0.16.0
@@ -58,80 +63,87 @@ httpx==0.28.1
# via respx
httpx-aiohttp==0.1.9
# via beeper-desktop-api
-idna==3.4
+humanize==4.13.0
+ # via nox
+idna==3.11
# via anyio
# via httpx
# via yarl
-importlib-metadata==7.0.0
-iniconfig==2.0.0
+importlib-metadata==8.7.0
+iniconfig==2.1.0
# via pytest
markdown-it-py==3.0.0
# via rich
mdurl==0.1.2
# via markdown-it-py
-multidict==6.4.4
+multidict==6.7.0
# via aiohttp
# via yarl
-mypy==1.14.1
-mypy-extensions==1.0.0
+mypy==1.17.0
+mypy-extensions==1.1.0
# via mypy
-nodeenv==1.8.0
+nodeenv==1.9.1
# via pyright
-nox==2023.4.22
-packaging==23.2
+nox==2025.11.12
+packaging==25.0
+ # via dependency-groups
# via nox
# via pytest
-platformdirs==3.11.0
+pathspec==0.12.1
+ # via mypy
+platformdirs==4.4.0
# via virtualenv
-pluggy==1.5.0
+pluggy==1.6.0
# via pytest
-propcache==0.3.1
+propcache==0.4.1
# via aiohttp
# via yarl
-pydantic==2.11.9
+pydantic==2.12.5
# via beeper-desktop-api
-pydantic-core==2.33.2
+pydantic-core==2.41.5
# via pydantic
-pygments==2.18.0
+pygments==2.19.2
+ # via pytest
# via rich
pyright==1.1.399
-pytest==8.3.3
+pytest==8.4.2
# via pytest-asyncio
# via pytest-xdist
-pytest-asyncio==0.24.0
-pytest-xdist==3.7.0
-python-dateutil==2.8.2
+pytest-asyncio==1.2.0
+pytest-xdist==3.8.0
+python-dateutil==2.9.0.post0
# via time-machine
-pytz==2023.3.post1
- # via dirty-equals
respx==0.22.0
-rich==13.7.1
-ruff==0.9.4
-setuptools==68.2.2
- # via nodeenv
-six==1.16.0
+rich==14.2.0
+ruff==0.14.7
+six==1.17.0
# via python-dateutil
-sniffio==1.3.0
- # via anyio
+sniffio==1.3.1
# via beeper-desktop-api
-time-machine==2.9.0
-tomli==2.0.2
+time-machine==2.19.0
+tomli==2.3.0
+ # via dependency-groups
# via mypy
+ # via nox
# via pytest
-typing-extensions==4.12.2
+typing-extensions==4.15.0
+ # via aiosignal
# via anyio
# via beeper-desktop-api
+ # via exceptiongroup
# via multidict
# via mypy
# via pydantic
# via pydantic-core
# via pyright
+ # via pytest-asyncio
# via typing-inspection
-typing-inspection==0.4.1
+ # via virtualenv
+typing-inspection==0.4.2
# via pydantic
-virtualenv==20.24.5
+virtualenv==20.35.4
# via nox
-yarl==1.20.0
+yarl==1.22.0
# via aiohttp
-zipp==3.17.0
+zipp==3.23.0
# via importlib-metadata
diff --git a/requirements.lock b/requirements.lock
index afb8f46..9b061dd 100644
--- a/requirements.lock
+++ b/requirements.lock
@@ -12,28 +12,28 @@
-e file:.
aiohappyeyeballs==2.6.1
# via aiohttp
-aiohttp==3.12.8
+aiohttp==3.13.2
# via beeper-desktop-api
# via httpx-aiohttp
-aiosignal==1.3.2
+aiosignal==1.4.0
# via aiohttp
-annotated-types==0.6.0
+annotated-types==0.7.0
# via pydantic
-anyio==4.4.0
+anyio==4.12.0
# via beeper-desktop-api
# via httpx
async-timeout==5.0.1
# via aiohttp
-attrs==25.3.0
+attrs==25.4.0
# via aiohttp
-certifi==2023.7.22
+certifi==2025.11.12
# via httpcore
# via httpx
-distro==1.8.0
+distro==1.9.0
# via beeper-desktop-api
-exceptiongroup==1.2.2
+exceptiongroup==1.3.1
# via anyio
-frozenlist==1.6.2
+frozenlist==1.8.0
# via aiohttp
# via aiosignal
h11==0.16.0
@@ -45,31 +45,32 @@ httpx==0.28.1
# via httpx-aiohttp
httpx-aiohttp==0.1.9
# via beeper-desktop-api
-idna==3.4
+idna==3.11
# via anyio
# via httpx
# via yarl
-multidict==6.4.4
+multidict==6.7.0
# via aiohttp
# via yarl
-propcache==0.3.1
+propcache==0.4.1
# via aiohttp
# via yarl
-pydantic==2.11.9
+pydantic==2.12.5
# via beeper-desktop-api
-pydantic-core==2.33.2
+pydantic-core==2.41.5
# via pydantic
-sniffio==1.3.0
- # via anyio
+sniffio==1.3.1
# via beeper-desktop-api
-typing-extensions==4.12.2
+typing-extensions==4.15.0
+ # via aiosignal
# via anyio
# via beeper-desktop-api
+ # via exceptiongroup
# via multidict
# via pydantic
# via pydantic-core
# via typing-inspection
-typing-inspection==0.4.1
+typing-inspection==0.4.2
# via pydantic
-yarl==1.20.0
+yarl==1.22.0
# via aiohttp
diff --git a/scripts/lint b/scripts/lint
index 8d20626..ca0f8cf 100755
--- a/scripts/lint
+++ b/scripts/lint
@@ -4,8 +4,13 @@ set -e
cd "$(dirname "$0")/.."
-echo "==> Running lints"
-rye run lint
+if [ "$1" = "--fix" ]; then
+ echo "==> Running lints with --fix"
+ rye run fix:ruff
+else
+ echo "==> Running lints"
+ rye run lint
+fi
echo "==> Making sure it imports"
rye run python -c 'import beeper_desktop_api'
diff --git a/src/beeper_desktop_api/_base_client.py b/src/beeper_desktop_api/_base_client.py
index 16539a1..19b20e8 100644
--- a/src/beeper_desktop_api/_base_client.py
+++ b/src/beeper_desktop_api/_base_client.py
@@ -9,6 +9,7 @@
import inspect
import logging
import platform
+import warnings
import email.utils
from types import TracebackType
from random import random
@@ -51,9 +52,11 @@
ResponseT,
AnyMapping,
PostParser,
+ BinaryTypes,
RequestFiles,
HttpxSendArgs,
RequestOptions,
+ AsyncBinaryTypes,
HttpxRequestFiles,
ModelBuilderProtocol,
not_given,
@@ -477,8 +480,19 @@ def _build_request(
retries_taken: int = 0,
) -> httpx.Request:
if log.isEnabledFor(logging.DEBUG):
- log.debug("Request options: %s", model_dump(options, exclude_unset=True))
-
+ log.debug(
+ "Request options: %s",
+ model_dump(
+ options,
+ exclude_unset=True,
+ # Pydantic v1 can't dump every type we support in content, so we exclude it for now.
+ exclude={
+ "content",
+ }
+ if PYDANTIC_V1
+ else {},
+ ),
+ )
kwargs: dict[str, Any] = {}
json_data = options.json_data
@@ -532,7 +546,13 @@ def _build_request(
is_body_allowed = options.method.lower() != "get"
if is_body_allowed:
- if isinstance(json_data, bytes):
+ if options.content is not None and json_data is not None:
+ raise TypeError("Passing both `content` and `json_data` is not supported")
+ if options.content is not None and files is not None:
+ raise TypeError("Passing both `content` and `files` is not supported")
+ if options.content is not None:
+ kwargs["content"] = options.content
+ elif isinstance(json_data, bytes):
kwargs["content"] = json_data
else:
kwargs["json"] = json_data if is_given(json_data) else None
@@ -1194,6 +1214,7 @@ def post(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: BinaryTypes | None = None,
options: RequestOptions = {},
files: RequestFiles | None = None,
stream: Literal[False] = False,
@@ -1206,6 +1227,7 @@ def post(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: BinaryTypes | None = None,
options: RequestOptions = {},
files: RequestFiles | None = None,
stream: Literal[True],
@@ -1219,6 +1241,7 @@ def post(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: BinaryTypes | None = None,
options: RequestOptions = {},
files: RequestFiles | None = None,
stream: bool,
@@ -1231,13 +1254,25 @@ def post(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: BinaryTypes | None = None,
options: RequestOptions = {},
files: RequestFiles | None = None,
stream: bool = False,
stream_cls: type[_StreamT] | None = None,
) -> ResponseT | _StreamT:
+ if body is not None and content is not None:
+ raise TypeError("Passing both `body` and `content` is not supported")
+ if files is not None and content is not None:
+ raise TypeError("Passing both `files` and `content` is not supported")
+ if isinstance(body, bytes):
+ warnings.warn(
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
+ "Please pass raw bytes via the `content` parameter instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
opts = FinalRequestOptions.construct(
- method="post", url=path, json_data=body, files=to_httpx_files(files), **options
+ method="post", url=path, json_data=body, content=content, files=to_httpx_files(files), **options
)
return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls))
@@ -1247,9 +1282,24 @@ def patch(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: BinaryTypes | None = None,
+ files: RequestFiles | None = None,
options: RequestOptions = {},
) -> ResponseT:
- opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options)
+ if body is not None and content is not None:
+ raise TypeError("Passing both `body` and `content` is not supported")
+ if files is not None and content is not None:
+ raise TypeError("Passing both `files` and `content` is not supported")
+ if isinstance(body, bytes):
+ warnings.warn(
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
+ "Please pass raw bytes via the `content` parameter instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ opts = FinalRequestOptions.construct(
+ method="patch", url=path, json_data=body, content=content, files=to_httpx_files(files), **options
+ )
return self.request(cast_to, opts)
def put(
@@ -1258,11 +1308,23 @@ def put(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: BinaryTypes | None = None,
files: RequestFiles | None = None,
options: RequestOptions = {},
) -> ResponseT:
+ if body is not None and content is not None:
+ raise TypeError("Passing both `body` and `content` is not supported")
+ if files is not None and content is not None:
+ raise TypeError("Passing both `files` and `content` is not supported")
+ if isinstance(body, bytes):
+ warnings.warn(
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
+ "Please pass raw bytes via the `content` parameter instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
opts = FinalRequestOptions.construct(
- method="put", url=path, json_data=body, files=to_httpx_files(files), **options
+ method="put", url=path, json_data=body, content=content, files=to_httpx_files(files), **options
)
return self.request(cast_to, opts)
@@ -1272,9 +1334,19 @@ def delete(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: BinaryTypes | None = None,
options: RequestOptions = {},
) -> ResponseT:
- opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options)
+ if body is not None and content is not None:
+ raise TypeError("Passing both `body` and `content` is not supported")
+ if isinstance(body, bytes):
+ warnings.warn(
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
+ "Please pass raw bytes via the `content` parameter instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options)
return self.request(cast_to, opts)
def get_api_list(
@@ -1714,6 +1786,7 @@ async def post(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: AsyncBinaryTypes | None = None,
files: RequestFiles | None = None,
options: RequestOptions = {},
stream: Literal[False] = False,
@@ -1726,6 +1799,7 @@ async def post(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: AsyncBinaryTypes | None = None,
files: RequestFiles | None = None,
options: RequestOptions = {},
stream: Literal[True],
@@ -1739,6 +1813,7 @@ async def post(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: AsyncBinaryTypes | None = None,
files: RequestFiles | None = None,
options: RequestOptions = {},
stream: bool,
@@ -1751,13 +1826,25 @@ async def post(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: AsyncBinaryTypes | None = None,
files: RequestFiles | None = None,
options: RequestOptions = {},
stream: bool = False,
stream_cls: type[_AsyncStreamT] | None = None,
) -> ResponseT | _AsyncStreamT:
+ if body is not None and content is not None:
+ raise TypeError("Passing both `body` and `content` is not supported")
+ if files is not None and content is not None:
+ raise TypeError("Passing both `files` and `content` is not supported")
+ if isinstance(body, bytes):
+ warnings.warn(
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
+ "Please pass raw bytes via the `content` parameter instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
opts = FinalRequestOptions.construct(
- method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options
+ method="post", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options
)
return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)
@@ -1767,9 +1854,29 @@ async def patch(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: AsyncBinaryTypes | None = None,
+ files: RequestFiles | None = None,
options: RequestOptions = {},
) -> ResponseT:
- opts = FinalRequestOptions.construct(method="patch", url=path, json_data=body, **options)
+ if body is not None and content is not None:
+ raise TypeError("Passing both `body` and `content` is not supported")
+ if files is not None and content is not None:
+ raise TypeError("Passing both `files` and `content` is not supported")
+ if isinstance(body, bytes):
+ warnings.warn(
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
+ "Please pass raw bytes via the `content` parameter instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ opts = FinalRequestOptions.construct(
+ method="patch",
+ url=path,
+ json_data=body,
+ content=content,
+ files=await async_to_httpx_files(files),
+ **options,
+ )
return await self.request(cast_to, opts)
async def put(
@@ -1778,11 +1885,23 @@ async def put(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: AsyncBinaryTypes | None = None,
files: RequestFiles | None = None,
options: RequestOptions = {},
) -> ResponseT:
+ if body is not None and content is not None:
+ raise TypeError("Passing both `body` and `content` is not supported")
+ if files is not None and content is not None:
+ raise TypeError("Passing both `files` and `content` is not supported")
+ if isinstance(body, bytes):
+ warnings.warn(
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
+ "Please pass raw bytes via the `content` parameter instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
opts = FinalRequestOptions.construct(
- method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options
+ method="put", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options
)
return await self.request(cast_to, opts)
@@ -1792,9 +1911,19 @@ async def delete(
*,
cast_to: Type[ResponseT],
body: Body | None = None,
+ content: AsyncBinaryTypes | None = None,
options: RequestOptions = {},
) -> ResponseT:
- opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options)
+ if body is not None and content is not None:
+ raise TypeError("Passing both `body` and `content` is not supported")
+ if isinstance(body, bytes):
+ warnings.warn(
+ "Passing raw bytes as `body` is deprecated and will be removed in a future version. "
+ "Please pass raw bytes via the `content` parameter instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options)
return await self.request(cast_to, opts)
def get_api_list(
diff --git a/src/beeper_desktop_api/_client.py b/src/beeper_desktop_api/_client.py
index b5d0200..7d0cb94 100644
--- a/src/beeper_desktop_api/_client.py
+++ b/src/beeper_desktop_api/_client.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import os
-from typing import Any, Mapping
+from typing import TYPE_CHECKING, Any, Mapping
from typing_extensions import Self, override
import httpx
@@ -30,6 +30,7 @@
get_async_library,
async_maybe_transform,
)
+from ._compat import cached_property
from ._version import __version__
from ._response import (
to_raw_response_wrapper,
@@ -37,7 +38,6 @@
async_to_raw_response_wrapper,
async_to_streamed_response_wrapper,
)
-from .resources import assets, messages
from ._streaming import Stream as Stream, AsyncStream as AsyncStream
from ._exceptions import APIStatusError, BeeperDesktopError
from ._base_client import (
@@ -46,11 +46,16 @@
AsyncAPIClient,
make_request_options,
)
-from .resources.chats import chats
-from .resources.accounts import accounts
from .types.focus_response import FocusResponse
from .types.search_response import SearchResponse
+if TYPE_CHECKING:
+ from .resources import chats, assets, accounts, messages
+ from .resources.assets import AssetsResource, AsyncAssetsResource
+ from .resources.messages import MessagesResource, AsyncMessagesResource
+ from .resources.chats.chats import ChatsResource, AsyncChatsResource
+ from .resources.accounts.accounts import AccountsResource, AsyncAccountsResource
+
__all__ = [
"Timeout",
"Transport",
@@ -64,13 +69,6 @@
class BeeperDesktop(SyncAPIClient):
- accounts: accounts.AccountsResource
- chats: chats.ChatsResource
- messages: messages.MessagesResource
- assets: assets.AssetsResource
- with_raw_response: BeeperDesktopWithRawResponse
- with_streaming_response: BeeperDesktopWithStreamedResponse
-
# client options
access_token: str
@@ -125,12 +123,41 @@ def __init__(
_strict_response_validation=_strict_response_validation,
)
- self.accounts = accounts.AccountsResource(self)
- self.chats = chats.ChatsResource(self)
- self.messages = messages.MessagesResource(self)
- self.assets = assets.AssetsResource(self)
- self.with_raw_response = BeeperDesktopWithRawResponse(self)
- self.with_streaming_response = BeeperDesktopWithStreamedResponse(self)
+ @cached_property
+ def accounts(self) -> AccountsResource:
+ """Manage connected chat accounts"""
+ from .resources.accounts import AccountsResource
+
+ return AccountsResource(self)
+
+ @cached_property
+ def chats(self) -> ChatsResource:
+ """Manage chats"""
+ from .resources.chats import ChatsResource
+
+ return ChatsResource(self)
+
+ @cached_property
+ def messages(self) -> MessagesResource:
+ """Manage messages in chats"""
+ from .resources.messages import MessagesResource
+
+ return MessagesResource(self)
+
+ @cached_property
+ def assets(self) -> AssetsResource:
+ """Manage assets in Beeper Desktop, like message attachments"""
+ from .resources.assets import AssetsResource
+
+ return AssetsResource(self)
+
+ @cached_property
+ def with_raw_response(self) -> BeeperDesktopWithRawResponse:
+ return BeeperDesktopWithRawResponse(self)
+
+ @cached_property
+ def with_streaming_response(self) -> BeeperDesktopWithStreamedResponse:
+ return BeeperDesktopWithStreamedResponse(self)
@property
@override
@@ -273,7 +300,7 @@ def search(
via search-chats. Uses the same sorting as the chat search in the app.
Args:
- query: User-typed search text. Literal word matching (NOT semantic).
+ query: User-typed search text. Literal word matching (non-semantic).
extra_headers: Send extra headers
@@ -330,13 +357,6 @@ def _make_status_error(
class AsyncBeeperDesktop(AsyncAPIClient):
- accounts: accounts.AsyncAccountsResource
- chats: chats.AsyncChatsResource
- messages: messages.AsyncMessagesResource
- assets: assets.AsyncAssetsResource
- with_raw_response: AsyncBeeperDesktopWithRawResponse
- with_streaming_response: AsyncBeeperDesktopWithStreamedResponse
-
# client options
access_token: str
@@ -391,12 +411,41 @@ def __init__(
_strict_response_validation=_strict_response_validation,
)
- self.accounts = accounts.AsyncAccountsResource(self)
- self.chats = chats.AsyncChatsResource(self)
- self.messages = messages.AsyncMessagesResource(self)
- self.assets = assets.AsyncAssetsResource(self)
- self.with_raw_response = AsyncBeeperDesktopWithRawResponse(self)
- self.with_streaming_response = AsyncBeeperDesktopWithStreamedResponse(self)
+ @cached_property
+ def accounts(self) -> AsyncAccountsResource:
+ """Manage connected chat accounts"""
+ from .resources.accounts import AsyncAccountsResource
+
+ return AsyncAccountsResource(self)
+
+ @cached_property
+ def chats(self) -> AsyncChatsResource:
+ """Manage chats"""
+ from .resources.chats import AsyncChatsResource
+
+ return AsyncChatsResource(self)
+
+ @cached_property
+ def messages(self) -> AsyncMessagesResource:
+ """Manage messages in chats"""
+ from .resources.messages import AsyncMessagesResource
+
+ return AsyncMessagesResource(self)
+
+ @cached_property
+ def assets(self) -> AsyncAssetsResource:
+ """Manage assets in Beeper Desktop, like message attachments"""
+ from .resources.assets import AsyncAssetsResource
+
+ return AsyncAssetsResource(self)
+
+ @cached_property
+ def with_raw_response(self) -> AsyncBeeperDesktopWithRawResponse:
+ return AsyncBeeperDesktopWithRawResponse(self)
+
+ @cached_property
+ def with_streaming_response(self) -> AsyncBeeperDesktopWithStreamedResponse:
+ return AsyncBeeperDesktopWithStreamedResponse(self)
@property
@override
@@ -539,7 +588,7 @@ async def search(
via search-chats. Uses the same sorting as the chat search in the app.
Args:
- query: User-typed search text. Literal word matching (NOT semantic).
+ query: User-typed search text. Literal word matching (non-semantic).
extra_headers: Send extra headers
@@ -596,11 +645,10 @@ def _make_status_error(
class BeeperDesktopWithRawResponse:
+ _client: BeeperDesktop
+
def __init__(self, client: BeeperDesktop) -> None:
- self.accounts = accounts.AccountsResourceWithRawResponse(client.accounts)
- self.chats = chats.ChatsResourceWithRawResponse(client.chats)
- self.messages = messages.MessagesResourceWithRawResponse(client.messages)
- self.assets = assets.AssetsResourceWithRawResponse(client.assets)
+ self._client = client
self.focus = to_raw_response_wrapper(
client.focus,
@@ -609,13 +657,40 @@ def __init__(self, client: BeeperDesktop) -> None:
client.search,
)
+ @cached_property
+ def accounts(self) -> accounts.AccountsResourceWithRawResponse:
+ """Manage connected chat accounts"""
+ from .resources.accounts import AccountsResourceWithRawResponse
+
+ return AccountsResourceWithRawResponse(self._client.accounts)
+
+ @cached_property
+ def chats(self) -> chats.ChatsResourceWithRawResponse:
+ """Manage chats"""
+ from .resources.chats import ChatsResourceWithRawResponse
+
+ return ChatsResourceWithRawResponse(self._client.chats)
+
+ @cached_property
+ def messages(self) -> messages.MessagesResourceWithRawResponse:
+ """Manage messages in chats"""
+ from .resources.messages import MessagesResourceWithRawResponse
+
+ return MessagesResourceWithRawResponse(self._client.messages)
+
+ @cached_property
+ def assets(self) -> assets.AssetsResourceWithRawResponse:
+ """Manage assets in Beeper Desktop, like message attachments"""
+ from .resources.assets import AssetsResourceWithRawResponse
+
+ return AssetsResourceWithRawResponse(self._client.assets)
+
class AsyncBeeperDesktopWithRawResponse:
+ _client: AsyncBeeperDesktop
+
def __init__(self, client: AsyncBeeperDesktop) -> None:
- self.accounts = accounts.AsyncAccountsResourceWithRawResponse(client.accounts)
- self.chats = chats.AsyncChatsResourceWithRawResponse(client.chats)
- self.messages = messages.AsyncMessagesResourceWithRawResponse(client.messages)
- self.assets = assets.AsyncAssetsResourceWithRawResponse(client.assets)
+ self._client = client
self.focus = async_to_raw_response_wrapper(
client.focus,
@@ -624,13 +699,40 @@ def __init__(self, client: AsyncBeeperDesktop) -> None:
client.search,
)
+ @cached_property
+ def accounts(self) -> accounts.AsyncAccountsResourceWithRawResponse:
+ """Manage connected chat accounts"""
+ from .resources.accounts import AsyncAccountsResourceWithRawResponse
+
+ return AsyncAccountsResourceWithRawResponse(self._client.accounts)
+
+ @cached_property
+ def chats(self) -> chats.AsyncChatsResourceWithRawResponse:
+ """Manage chats"""
+ from .resources.chats import AsyncChatsResourceWithRawResponse
+
+ return AsyncChatsResourceWithRawResponse(self._client.chats)
+
+ @cached_property
+ def messages(self) -> messages.AsyncMessagesResourceWithRawResponse:
+ """Manage messages in chats"""
+ from .resources.messages import AsyncMessagesResourceWithRawResponse
+
+ return AsyncMessagesResourceWithRawResponse(self._client.messages)
+
+ @cached_property
+ def assets(self) -> assets.AsyncAssetsResourceWithRawResponse:
+ """Manage assets in Beeper Desktop, like message attachments"""
+ from .resources.assets import AsyncAssetsResourceWithRawResponse
+
+ return AsyncAssetsResourceWithRawResponse(self._client.assets)
+
class BeeperDesktopWithStreamedResponse:
+ _client: BeeperDesktop
+
def __init__(self, client: BeeperDesktop) -> None:
- self.accounts = accounts.AccountsResourceWithStreamingResponse(client.accounts)
- self.chats = chats.ChatsResourceWithStreamingResponse(client.chats)
- self.messages = messages.MessagesResourceWithStreamingResponse(client.messages)
- self.assets = assets.AssetsResourceWithStreamingResponse(client.assets)
+ self._client = client
self.focus = to_streamed_response_wrapper(
client.focus,
@@ -639,13 +741,40 @@ def __init__(self, client: BeeperDesktop) -> None:
client.search,
)
+ @cached_property
+ def accounts(self) -> accounts.AccountsResourceWithStreamingResponse:
+ """Manage connected chat accounts"""
+ from .resources.accounts import AccountsResourceWithStreamingResponse
+
+ return AccountsResourceWithStreamingResponse(self._client.accounts)
+
+ @cached_property
+ def chats(self) -> chats.ChatsResourceWithStreamingResponse:
+ """Manage chats"""
+ from .resources.chats import ChatsResourceWithStreamingResponse
+
+ return ChatsResourceWithStreamingResponse(self._client.chats)
+
+ @cached_property
+ def messages(self) -> messages.MessagesResourceWithStreamingResponse:
+ """Manage messages in chats"""
+ from .resources.messages import MessagesResourceWithStreamingResponse
+
+ return MessagesResourceWithStreamingResponse(self._client.messages)
+
+ @cached_property
+ def assets(self) -> assets.AssetsResourceWithStreamingResponse:
+ """Manage assets in Beeper Desktop, like message attachments"""
+ from .resources.assets import AssetsResourceWithStreamingResponse
+
+ return AssetsResourceWithStreamingResponse(self._client.assets)
+
class AsyncBeeperDesktopWithStreamedResponse:
+ _client: AsyncBeeperDesktop
+
def __init__(self, client: AsyncBeeperDesktop) -> None:
- self.accounts = accounts.AsyncAccountsResourceWithStreamingResponse(client.accounts)
- self.chats = chats.AsyncChatsResourceWithStreamingResponse(client.chats)
- self.messages = messages.AsyncMessagesResourceWithStreamingResponse(client.messages)
- self.assets = assets.AsyncAssetsResourceWithStreamingResponse(client.assets)
+ self._client = client
self.focus = async_to_streamed_response_wrapper(
client.focus,
@@ -654,6 +783,34 @@ def __init__(self, client: AsyncBeeperDesktop) -> None:
client.search,
)
+ @cached_property
+ def accounts(self) -> accounts.AsyncAccountsResourceWithStreamingResponse:
+ """Manage connected chat accounts"""
+ from .resources.accounts import AsyncAccountsResourceWithStreamingResponse
+
+ return AsyncAccountsResourceWithStreamingResponse(self._client.accounts)
+
+ @cached_property
+ def chats(self) -> chats.AsyncChatsResourceWithStreamingResponse:
+ """Manage chats"""
+ from .resources.chats import AsyncChatsResourceWithStreamingResponse
+
+ return AsyncChatsResourceWithStreamingResponse(self._client.chats)
+
+ @cached_property
+ def messages(self) -> messages.AsyncMessagesResourceWithStreamingResponse:
+ """Manage messages in chats"""
+ from .resources.messages import AsyncMessagesResourceWithStreamingResponse
+
+ return AsyncMessagesResourceWithStreamingResponse(self._client.messages)
+
+ @cached_property
+ def assets(self) -> assets.AsyncAssetsResourceWithStreamingResponse:
+ """Manage assets in Beeper Desktop, like message attachments"""
+ from .resources.assets import AsyncAssetsResourceWithStreamingResponse
+
+ return AsyncAssetsResourceWithStreamingResponse(self._client.assets)
+
Client = BeeperDesktop
diff --git a/src/beeper_desktop_api/_files.py b/src/beeper_desktop_api/_files.py
index cc14c14..e0ef7aa 100644
--- a/src/beeper_desktop_api/_files.py
+++ b/src/beeper_desktop_api/_files.py
@@ -34,7 +34,7 @@ def assert_is_file_content(obj: object, *, key: str | None = None) -> None:
if not is_file_content(obj):
prefix = f"Expected entry at `{key}`" if key is not None else f"Expected file input `{obj!r}`"
raise RuntimeError(
- f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead."
+ f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead. See https://github.com/beeper/desktop-api-python/tree/main#file-uploads"
) from None
diff --git a/src/beeper_desktop_api/_models.py b/src/beeper_desktop_api/_models.py
index 6a3cd1d..29070e0 100644
--- a/src/beeper_desktop_api/_models.py
+++ b/src/beeper_desktop_api/_models.py
@@ -2,7 +2,21 @@
import os
import inspect
-from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast
+import weakref
+from typing import (
+ IO,
+ TYPE_CHECKING,
+ Any,
+ Type,
+ Union,
+ Generic,
+ TypeVar,
+ Callable,
+ Iterable,
+ Optional,
+ AsyncIterable,
+ cast,
+)
from datetime import date, datetime
from typing_extensions import (
List,
@@ -256,15 +270,16 @@ def model_dump(
mode: Literal["json", "python"] | str = "python",
include: IncEx | None = None,
exclude: IncEx | None = None,
+ context: Any | None = None,
by_alias: bool | None = None,
exclude_unset: bool = False,
exclude_defaults: bool = False,
exclude_none: bool = False,
+ exclude_computed_fields: bool = False,
round_trip: bool = False,
warnings: bool | Literal["none", "warn", "error"] = True,
- context: dict[str, Any] | None = None,
- serialize_as_any: bool = False,
fallback: Callable[[Any], Any] | None = None,
+ serialize_as_any: bool = False,
) -> dict[str, Any]:
"""Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump
@@ -272,16 +287,24 @@ def model_dump(
Args:
mode: The mode in which `to_python` should run.
- If mode is 'json', the dictionary will only contain JSON serializable types.
- If mode is 'python', the dictionary may contain any Python objects.
- include: A list of fields to include in the output.
- exclude: A list of fields to exclude from the output.
+ If mode is 'json', the output will only contain JSON serializable types.
+ If mode is 'python', the output may contain non-JSON-serializable Python objects.
+ include: A set of fields to include in the output.
+ exclude: A set of fields to exclude from the output.
+ context: Additional context to pass to the serializer.
by_alias: Whether to use the field's alias in the dictionary key if defined.
- exclude_unset: Whether to exclude fields that are unset or None from the output.
- exclude_defaults: Whether to exclude fields that are set to their default value from the output.
- exclude_none: Whether to exclude fields that have a value of `None` from the output.
- round_trip: Whether to enable serialization and deserialization round-trip support.
- warnings: Whether to log warnings when invalid fields are encountered.
+ exclude_unset: Whether to exclude fields that have not been explicitly set.
+ exclude_defaults: Whether to exclude fields that are set to their default value.
+ exclude_none: Whether to exclude fields that have a value of `None`.
+ exclude_computed_fields: Whether to exclude computed fields.
+ While this can be useful for round-tripping, it is usually recommended to use the dedicated
+ `round_trip` parameter instead.
+ round_trip: If True, dumped values should be valid as input for non-idempotent types such as Json[T].
+ warnings: How to handle serialization errors. False/"none" ignores them, True/"warn" logs errors,
+ "error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError].
+ fallback: A function to call when an unknown value is encountered. If not provided,
+ a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError] error is raised.
+ serialize_as_any: Whether to serialize fields with duck-typing serialization behavior.
Returns:
A dictionary representation of the model.
@@ -298,6 +321,8 @@ def model_dump(
raise ValueError("serialize_as_any is only supported in Pydantic v2")
if fallback is not None:
raise ValueError("fallback is only supported in Pydantic v2")
+ if exclude_computed_fields != False:
+ raise ValueError("exclude_computed_fields is only supported in Pydantic v2")
dumped = super().dict( # pyright: ignore[reportDeprecated]
include=include,
exclude=exclude,
@@ -314,15 +339,17 @@ def model_dump_json(
self,
*,
indent: int | None = None,
+ ensure_ascii: bool = False,
include: IncEx | None = None,
exclude: IncEx | None = None,
+ context: Any | None = None,
by_alias: bool | None = None,
exclude_unset: bool = False,
exclude_defaults: bool = False,
exclude_none: bool = False,
+ exclude_computed_fields: bool = False,
round_trip: bool = False,
warnings: bool | Literal["none", "warn", "error"] = True,
- context: dict[str, Any] | None = None,
fallback: Callable[[Any], Any] | None = None,
serialize_as_any: bool = False,
) -> str:
@@ -354,6 +381,10 @@ def model_dump_json(
raise ValueError("serialize_as_any is only supported in Pydantic v2")
if fallback is not None:
raise ValueError("fallback is only supported in Pydantic v2")
+ if ensure_ascii != False:
+ raise ValueError("ensure_ascii is only supported in Pydantic v2")
+ if exclude_computed_fields != False:
+ raise ValueError("exclude_computed_fields is only supported in Pydantic v2")
return super().json( # type: ignore[reportDeprecated]
indent=indent,
include=include,
@@ -573,6 +604,9 @@ class CachedDiscriminatorType(Protocol):
__discriminator__: DiscriminatorDetails
+DISCRIMINATOR_CACHE: weakref.WeakKeyDictionary[type, DiscriminatorDetails] = weakref.WeakKeyDictionary()
+
+
class DiscriminatorDetails:
field_name: str
"""The name of the discriminator field in the variant class, e.g.
@@ -615,8 +649,9 @@ def __init__(
def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, ...]) -> DiscriminatorDetails | None:
- if isinstance(union, CachedDiscriminatorType):
- return union.__discriminator__
+ cached = DISCRIMINATOR_CACHE.get(union)
+ if cached is not None:
+ return cached
discriminator_field_name: str | None = None
@@ -669,7 +704,7 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any,
discriminator_field=discriminator_field_name,
discriminator_alias=discriminator_alias,
)
- cast(CachedDiscriminatorType, union).__discriminator__ = details
+ DISCRIMINATOR_CACHE.setdefault(union, details)
return details
@@ -765,6 +800,7 @@ class FinalRequestOptionsInput(TypedDict, total=False):
timeout: float | Timeout | None
files: HttpxRequestFiles | None
idempotency_key: str
+ content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None]
json_data: Body
extra_json: AnyMapping
follow_redirects: bool
@@ -783,6 +819,7 @@ class FinalRequestOptions(pydantic.BaseModel):
post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven()
follow_redirects: Union[bool, None] = None
+ 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
# a BaseModel method in an incompatible fashion.
json_data: Union[Body, None] = None
diff --git a/src/beeper_desktop_api/_streaming.py b/src/beeper_desktop_api/_streaming.py
index 3462cf4..55409b8 100644
--- a/src/beeper_desktop_api/_streaming.py
+++ b/src/beeper_desktop_api/_streaming.py
@@ -54,12 +54,12 @@ def __stream__(self) -> Iterator[_T]:
process_data = self._client._process_response_data
iterator = self._iter_events()
- for sse in iterator:
- yield process_data(data=sse.json(), cast_to=cast_to, response=response)
-
- # Ensure the entire stream is consumed
- for _sse in iterator:
- ...
+ try:
+ for sse in iterator:
+ yield process_data(data=sse.json(), cast_to=cast_to, response=response)
+ finally:
+ # Ensure the response is closed even if the consumer doesn't read all data
+ response.close()
def __enter__(self) -> Self:
return self
@@ -118,12 +118,12 @@ async def __stream__(self) -> AsyncIterator[_T]:
process_data = self._client._process_response_data
iterator = self._iter_events()
- async for sse in iterator:
- yield process_data(data=sse.json(), cast_to=cast_to, response=response)
-
- # Ensure the entire stream is consumed
- async for _sse in iterator:
- ...
+ try:
+ async for sse in iterator:
+ yield process_data(data=sse.json(), cast_to=cast_to, response=response)
+ finally:
+ # Ensure the response is closed even if the consumer doesn't read all data
+ await response.aclose()
async def __aenter__(self) -> Self:
return self
diff --git a/src/beeper_desktop_api/_types.py b/src/beeper_desktop_api/_types.py
index d3c2e24..2880d78 100644
--- a/src/beeper_desktop_api/_types.py
+++ b/src/beeper_desktop_api/_types.py
@@ -13,9 +13,11 @@
Mapping,
TypeVar,
Callable,
+ Iterable,
Iterator,
Optional,
Sequence,
+ AsyncIterable,
)
from typing_extensions import (
Set,
@@ -56,6 +58,13 @@
else:
Base64FileInput = Union[IO[bytes], PathLike]
FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8.
+
+
+# Used for sending raw binary data / streaming data in request bodies
+# e.g. for file uploads without multipart encoding
+BinaryTypes = Union[bytes, bytearray, IO[bytes], Iterable[bytes]]
+AsyncBinaryTypes = Union[bytes, bytearray, IO[bytes], AsyncIterable[bytes]]
+
FileTypes = Union[
# file (or bytes)
FileContent,
@@ -243,6 +252,9 @@ class HttpxSendArgs(TypedDict, total=False):
if TYPE_CHECKING:
# This works because str.__contains__ does not accept object (either in typeshed or at runtime)
# https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285
+ #
+ # Note: index() and count() methods are intentionally omitted to allow pyright to properly
+ # infer TypedDict types when dict literals are used in lists assigned to SequenceNotStr.
class SequenceNotStr(Protocol[_T_co]):
@overload
def __getitem__(self, index: SupportsIndex, /) -> _T_co: ...
@@ -251,8 +263,6 @@ def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ...
def __contains__(self, value: object, /) -> bool: ...
def __len__(self) -> int: ...
def __iter__(self) -> Iterator[_T_co]: ...
- def index(self, value: Any, start: int = 0, stop: int = ..., /) -> int: ...
- def count(self, value: Any, /) -> int: ...
def __reversed__(self) -> Iterator[_T_co]: ...
else:
# just point this to a normal `Sequence` at runtime to avoid having to special case
diff --git a/src/beeper_desktop_api/_utils/_sync.py b/src/beeper_desktop_api/_utils/_sync.py
index ad7ec71..f6027c1 100644
--- a/src/beeper_desktop_api/_utils/_sync.py
+++ b/src/beeper_desktop_api/_utils/_sync.py
@@ -1,10 +1,8 @@
from __future__ import annotations
-import sys
import asyncio
import functools
-import contextvars
-from typing import Any, TypeVar, Callable, Awaitable
+from typing import TypeVar, Callable, Awaitable
from typing_extensions import ParamSpec
import anyio
@@ -15,34 +13,11 @@
T_ParamSpec = ParamSpec("T_ParamSpec")
-if sys.version_info >= (3, 9):
- _asyncio_to_thread = asyncio.to_thread
-else:
- # backport of https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread
- # for Python 3.8 support
- async def _asyncio_to_thread(
- func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs
- ) -> Any:
- """Asynchronously run function *func* in a separate thread.
-
- Any *args and **kwargs supplied for this function are directly passed
- to *func*. Also, the current :class:`contextvars.Context` is propagated,
- allowing context variables from the main thread to be accessed in the
- separate thread.
-
- Returns a coroutine that can be awaited to get the eventual result of *func*.
- """
- loop = asyncio.events.get_running_loop()
- ctx = contextvars.copy_context()
- func_call = functools.partial(ctx.run, func, *args, **kwargs)
- return await loop.run_in_executor(None, func_call)
-
-
async def to_thread(
func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs
) -> T_Retval:
if sniffio.current_async_library() == "asyncio":
- return await _asyncio_to_thread(func, *args, **kwargs)
+ return await asyncio.to_thread(func, *args, **kwargs)
return await anyio.to_thread.run_sync(
functools.partial(func, *args, **kwargs),
@@ -53,10 +28,7 @@ async def to_thread(
def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]:
"""
Take a blocking function and create an async one that receives the same
- positional and keyword arguments. For python version 3.9 and above, it uses
- asyncio.to_thread to run the function in a separate thread. For python version
- 3.8, it uses locally defined copy of the asyncio.to_thread function which was
- introduced in python 3.9.
+ positional and keyword arguments.
Usage:
diff --git a/src/beeper_desktop_api/_utils/_utils.py b/src/beeper_desktop_api/_utils/_utils.py
index 50d5926..eec7f4a 100644
--- a/src/beeper_desktop_api/_utils/_utils.py
+++ b/src/beeper_desktop_api/_utils/_utils.py
@@ -133,7 +133,7 @@ def is_given(obj: _T | NotGiven | Omit) -> TypeGuard[_T]:
# Type safe methods for narrowing types with TypeVars.
# The default narrowing for isinstance(obj, dict) is dict[unknown, unknown],
# however this cause Pyright to rightfully report errors. As we know we don't
-# care about the contained types we can safely use `object` in it's place.
+# care about the contained types we can safely use `object` in its place.
#
# There are two separate functions defined, `is_*` and `is_*_t` for different use cases.
# `is_*` is for when you're dealing with an unknown input
diff --git a/src/beeper_desktop_api/_version.py b/src/beeper_desktop_api/_version.py
index f18d0c4..9e1f20a 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.1.296" # x-release-please-version
+__version__ = "4.2.0" # x-release-please-version
diff --git a/src/beeper_desktop_api/resources/accounts/contacts.py b/src/beeper_desktop_api/resources/accounts/contacts.py
index b2a6491..5719d18 100644
--- a/src/beeper_desktop_api/resources/accounts/contacts.py
+++ b/src/beeper_desktop_api/resources/accounts/contacts.py
@@ -55,9 +55,10 @@ def search(
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> ContactSearchResponse:
- """
- Search contacts across on a specific account using the network's search API.
- Only use for creating new chats.
+ """Search contacts on a specific account using the network's search API.
+
+ Only use
+ for creating new chats.
Args:
account_id: Account ID this resource belongs to.
@@ -121,9 +122,10 @@ async def search(
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> ContactSearchResponse:
- """
- Search contacts across on a specific account using the network's search API.
- Only use for creating new chats.
+ """Search contacts on a specific account using the network's search API.
+
+ Only use
+ for creating new chats.
Args:
account_id: Account ID this resource belongs to.
diff --git a/src/beeper_desktop_api/resources/assets.py b/src/beeper_desktop_api/resources/assets.py
index 0d3a790..a4d8ce7 100644
--- a/src/beeper_desktop_api/resources/assets.py
+++ b/src/beeper_desktop_api/resources/assets.py
@@ -2,11 +2,13 @@
from __future__ import annotations
+from typing import Mapping, cast
+
import httpx
-from ..types import asset_download_params
-from .._types import Body, Query, Headers, NotGiven, not_given
-from .._utils import maybe_transform, async_maybe_transform
+from ..types import asset_upload_params, asset_download_params, asset_upload_base64_params
+from .._types import Body, Omit, Query, Headers, NotGiven, FileTypes, omit, not_given
+from .._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform
from .._compat import cached_property
from .._resource import SyncAPIResource, AsyncAPIResource
from .._response import (
@@ -16,7 +18,9 @@
async_to_streamed_response_wrapper,
)
from .._base_client import make_request_options
+from ..types.asset_upload_response import AssetUploadResponse
from ..types.asset_download_response import AssetDownloadResponse
+from ..types.asset_upload_base64_response import AssetUploadBase64Response
__all__ = ["AssetsResource", "AsyncAssetsResource"]
@@ -78,6 +82,111 @@ def download(
cast_to=AssetDownloadResponse,
)
+ def upload(
+ self,
+ *,
+ file: FileTypes,
+ file_name: str | Omit = omit,
+ mime_type: 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,
+ ) -> AssetUploadResponse:
+ """Upload a file to a temporary location using multipart/form-data.
+
+ Returns an
+ uploadID that can be referenced when sending messages with attachments.
+
+ Args:
+ file: The file to upload (max 500 MB).
+
+ file_name: Original filename. Defaults to the uploaded file name if omitted
+
+ mime_type: MIME type. Auto-detected from magic bytes if omitted
+
+ 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
+ """
+ body = deepcopy_minimal(
+ {
+ "file": file,
+ "file_name": file_name,
+ "mime_type": mime_type,
+ }
+ )
+ files = extract_files(cast(Mapping[str, object], body), paths=[["file"]])
+ # It should be noted that the actual Content-Type header that will be
+ # sent to the server will contain a `boundary` parameter, e.g.
+ # multipart/form-data; boundary=---abc--
+ extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})}
+ return self._post(
+ "/v1/assets/upload",
+ body=maybe_transform(body, asset_upload_params.AssetUploadParams),
+ files=files,
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=AssetUploadResponse,
+ )
+
+ def upload_base64(
+ self,
+ *,
+ content: str,
+ file_name: str | Omit = omit,
+ mime_type: 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,
+ ) -> AssetUploadBase64Response:
+ """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.
+
+ Args:
+ content: Base64-encoded file content (max ~500MB decoded)
+
+ file_name: Original filename. Generated if omitted
+
+ mime_type: MIME type. Auto-detected from magic bytes if omitted
+
+ 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/assets/upload/base64",
+ body=maybe_transform(
+ {
+ "content": content,
+ "file_name": file_name,
+ "mime_type": mime_type,
+ },
+ asset_upload_base64_params.AssetUploadBase64Params,
+ ),
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=AssetUploadBase64Response,
+ )
+
class AsyncAssetsResource(AsyncAPIResource):
"""Manage assets in Beeper Desktop, like message attachments"""
@@ -136,6 +245,111 @@ async def download(
cast_to=AssetDownloadResponse,
)
+ async def upload(
+ self,
+ *,
+ file: FileTypes,
+ file_name: str | Omit = omit,
+ mime_type: 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,
+ ) -> AssetUploadResponse:
+ """Upload a file to a temporary location using multipart/form-data.
+
+ Returns an
+ uploadID that can be referenced when sending messages with attachments.
+
+ Args:
+ file: The file to upload (max 500 MB).
+
+ file_name: Original filename. Defaults to the uploaded file name if omitted
+
+ mime_type: MIME type. Auto-detected from magic bytes if omitted
+
+ 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
+ """
+ body = deepcopy_minimal(
+ {
+ "file": file,
+ "file_name": file_name,
+ "mime_type": mime_type,
+ }
+ )
+ files = extract_files(cast(Mapping[str, object], body), paths=[["file"]])
+ # It should be noted that the actual Content-Type header that will be
+ # sent to the server will contain a `boundary` parameter, e.g.
+ # multipart/form-data; boundary=---abc--
+ extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})}
+ return await self._post(
+ "/v1/assets/upload",
+ body=await async_maybe_transform(body, asset_upload_params.AssetUploadParams),
+ files=files,
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=AssetUploadResponse,
+ )
+
+ async def upload_base64(
+ self,
+ *,
+ content: str,
+ file_name: str | Omit = omit,
+ mime_type: 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,
+ ) -> AssetUploadBase64Response:
+ """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.
+
+ Args:
+ content: Base64-encoded file content (max ~500MB decoded)
+
+ file_name: Original filename. Generated if omitted
+
+ mime_type: MIME type. Auto-detected from magic bytes if omitted
+
+ 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/assets/upload/base64",
+ body=await async_maybe_transform(
+ {
+ "content": content,
+ "file_name": file_name,
+ "mime_type": mime_type,
+ },
+ asset_upload_base64_params.AssetUploadBase64Params,
+ ),
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=AssetUploadBase64Response,
+ )
+
class AssetsResourceWithRawResponse:
def __init__(self, assets: AssetsResource) -> None:
@@ -144,6 +358,12 @@ def __init__(self, assets: AssetsResource) -> None:
self.download = to_raw_response_wrapper(
assets.download,
)
+ self.upload = to_raw_response_wrapper(
+ assets.upload,
+ )
+ self.upload_base64 = to_raw_response_wrapper(
+ assets.upload_base64,
+ )
class AsyncAssetsResourceWithRawResponse:
@@ -153,6 +373,12 @@ def __init__(self, assets: AsyncAssetsResource) -> None:
self.download = async_to_raw_response_wrapper(
assets.download,
)
+ self.upload = async_to_raw_response_wrapper(
+ assets.upload,
+ )
+ self.upload_base64 = async_to_raw_response_wrapper(
+ assets.upload_base64,
+ )
class AssetsResourceWithStreamingResponse:
@@ -162,6 +388,12 @@ def __init__(self, assets: AssetsResource) -> None:
self.download = to_streamed_response_wrapper(
assets.download,
)
+ self.upload = to_streamed_response_wrapper(
+ assets.upload,
+ )
+ self.upload_base64 = to_streamed_response_wrapper(
+ assets.upload_base64,
+ )
class AsyncAssetsResourceWithStreamingResponse:
@@ -171,3 +403,9 @@ def __init__(self, assets: AsyncAssetsResource) -> None:
self.download = async_to_streamed_response_wrapper(
assets.download,
)
+ self.upload = async_to_streamed_response_wrapper(
+ assets.upload,
+ )
+ self.upload_base64 = async_to_streamed_response_wrapper(
+ assets.upload_base64,
+ )
diff --git a/src/beeper_desktop_api/resources/messages.py b/src/beeper_desktop_api/resources/messages.py
index 4f384f6..63ad8e1 100644
--- a/src/beeper_desktop_api/resources/messages.py
+++ b/src/beeper_desktop_api/resources/messages.py
@@ -8,7 +8,7 @@
import httpx
-from ..types import message_list_params, message_send_params, message_search_params
+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 .._compat import cached_property
@@ -23,6 +23,7 @@
from .._base_client import AsyncPaginator, make_request_options
from ..types.shared.message import Message
from ..types.message_send_response import MessageSendResponse
+from ..types.message_update_response import MessageUpdateResponse
__all__ = ["MessagesResource", "AsyncMessagesResource"]
@@ -49,6 +50,50 @@ def with_streaming_response(self) -> MessagesResourceWithStreamingResponse:
"""
return MessagesResourceWithStreamingResponse(self)
+ def update(
+ self,
+ message_id: str,
+ *,
+ chat_id: str,
+ text: 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,
+ ) -> MessageUpdateResponse:
+ """Edit the text content of an existing message.
+
+ Messages with attachments cannot
+ be edited.
+
+ Args:
+ chat_id: Unique identifier of the chat.
+
+ text: New text content for the message
+
+ 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._put(
+ f"/v1/chats/{chat_id}/messages/{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
+ ),
+ cast_to=MessageUpdateResponse,
+ )
+
def list(
self,
chat_id: str,
@@ -158,7 +203,7 @@ def search(
media_types: Filter messages by media types. Use ['any'] for any media type, or specify exact
types like ['video', 'image']. Omit for no media filtering.
- query: Literal word search (NOT semantic). Finds messages containing these EXACT words
+ query: Literal word search (non-semantic). Finds messages containing these EXACT words
in any order. Use single words users actually type, not concepts or phrases.
Example: use "dinner" not "dinner plans", use "sick" not "health issues". If
omitted, returns results filtered only by other parameters.
@@ -208,6 +253,7 @@ def send(
self,
chat_id: str,
*,
+ attachment: message_send_params.Attachment | Omit = omit,
reply_to_message_id: str | Omit = omit,
text: str | Omit = omit,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
@@ -220,11 +266,13 @@ def send(
"""Send a text message to a specific chat.
Supports replying to existing messages.
- Returns the sent message ID.
+ Returns a pending message ID.
Args:
chat_id: Unique identifier of the chat.
+ 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.
@@ -243,6 +291,7 @@ def send(
f"/v1/chats/{chat_id}/messages",
body=maybe_transform(
{
+ "attachment": attachment,
"reply_to_message_id": reply_to_message_id,
"text": text,
},
@@ -277,6 +326,50 @@ def with_streaming_response(self) -> AsyncMessagesResourceWithStreamingResponse:
"""
return AsyncMessagesResourceWithStreamingResponse(self)
+ async def update(
+ self,
+ message_id: str,
+ *,
+ chat_id: str,
+ text: 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,
+ ) -> MessageUpdateResponse:
+ """Edit the text content of an existing message.
+
+ Messages with attachments cannot
+ be edited.
+
+ Args:
+ chat_id: Unique identifier of the chat.
+
+ text: New text content for the message
+
+ 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._put(
+ f"/v1/chats/{chat_id}/messages/{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
+ ),
+ cast_to=MessageUpdateResponse,
+ )
+
def list(
self,
chat_id: str,
@@ -386,7 +479,7 @@ def search(
media_types: Filter messages by media types. Use ['any'] for any media type, or specify exact
types like ['video', 'image']. Omit for no media filtering.
- query: Literal word search (NOT semantic). Finds messages containing these EXACT words
+ query: Literal word search (non-semantic). Finds messages containing these EXACT words
in any order. Use single words users actually type, not concepts or phrases.
Example: use "dinner" not "dinner plans", use "sick" not "health issues". If
omitted, returns results filtered only by other parameters.
@@ -436,6 +529,7 @@ async def send(
self,
chat_id: str,
*,
+ attachment: message_send_params.Attachment | Omit = omit,
reply_to_message_id: str | Omit = omit,
text: str | Omit = omit,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
@@ -448,11 +542,13 @@ async def send(
"""Send a text message to a specific chat.
Supports replying to existing messages.
- Returns the sent message ID.
+ Returns a pending message ID.
Args:
chat_id: Unique identifier of the chat.
+ 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.
@@ -471,6 +567,7 @@ async def send(
f"/v1/chats/{chat_id}/messages",
body=await async_maybe_transform(
{
+ "attachment": attachment,
"reply_to_message_id": reply_to_message_id,
"text": text,
},
@@ -487,6 +584,9 @@ class MessagesResourceWithRawResponse:
def __init__(self, messages: MessagesResource) -> None:
self._messages = messages
+ self.update = to_raw_response_wrapper(
+ messages.update,
+ )
self.list = to_raw_response_wrapper(
messages.list,
)
@@ -502,6 +602,9 @@ class AsyncMessagesResourceWithRawResponse:
def __init__(self, messages: AsyncMessagesResource) -> None:
self._messages = messages
+ self.update = async_to_raw_response_wrapper(
+ messages.update,
+ )
self.list = async_to_raw_response_wrapper(
messages.list,
)
@@ -517,6 +620,9 @@ class MessagesResourceWithStreamingResponse:
def __init__(self, messages: MessagesResource) -> None:
self._messages = messages
+ self.update = to_streamed_response_wrapper(
+ messages.update,
+ )
self.list = to_streamed_response_wrapper(
messages.list,
)
@@ -532,6 +638,9 @@ class AsyncMessagesResourceWithStreamingResponse:
def __init__(self, messages: AsyncMessagesResource) -> None:
self._messages = messages
+ self.update = async_to_streamed_response_wrapper(
+ messages.update,
+ )
self.list = async_to_streamed_response_wrapper(
messages.list,
)
diff --git a/src/beeper_desktop_api/types/__init__.py b/src/beeper_desktop_api/types/__init__.py
index 1d77bdb..9d2773d 100644
--- a/src/beeper_desktop_api/types/__init__.py
+++ b/src/beeper_desktop_api/types/__init__.py
@@ -11,6 +11,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 .asset_upload_params import AssetUploadParams as AssetUploadParams
from .chat_archive_params import ChatArchiveParams as ChatArchiveParams
from .client_focus_params import ClientFocusParams as ClientFocusParams
from .message_list_params import MessageListParams as MessageListParams
@@ -20,6 +21,11 @@
from .client_search_params import ClientSearchParams as ClientSearchParams
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 .message_search_params import MessageSearchParams as MessageSearchParams
from .message_send_response import MessageSendResponse as MessageSendResponse
+from .message_update_params import MessageUpdateParams as MessageUpdateParams
from .asset_download_response import AssetDownloadResponse as AssetDownloadResponse
+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 97336b7..011c64e 100644
--- a/src/beeper_desktop_api/types/account.py
+++ b/src/beeper_desktop_api/types/account.py
@@ -9,6 +9,8 @@
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."""
diff --git a/src/beeper_desktop_api/types/asset_upload_base64_params.py b/src/beeper_desktop_api/types/asset_upload_base64_params.py
new file mode 100644
index 0000000..9600201
--- /dev/null
+++ b/src/beeper_desktop_api/types/asset_upload_base64_params.py
@@ -0,0 +1,20 @@
+# 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__ = ["AssetUploadBase64Params"]
+
+
+class AssetUploadBase64Params(TypedDict, total=False):
+ content: Required[str]
+ """Base64-encoded file content (max ~500MB decoded)"""
+
+ file_name: Annotated[str, PropertyInfo(alias="fileName")]
+ """Original filename. Generated if omitted"""
+
+ mime_type: Annotated[str, PropertyInfo(alias="mimeType")]
+ """MIME type. Auto-detected from magic bytes if omitted"""
diff --git a/src/beeper_desktop_api/types/asset_upload_base64_response.py b/src/beeper_desktop_api/types/asset_upload_base64_response.py
new file mode 100644
index 0000000..cfa8351
--- /dev/null
+++ b/src/beeper_desktop_api/types/asset_upload_base64_response.py
@@ -0,0 +1,38 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from typing import Optional
+
+from pydantic import Field as FieldInfo
+
+from .._models import BaseModel
+
+__all__ = ["AssetUploadBase64Response"]
+
+
+class AssetUploadBase64Response(BaseModel):
+ duration: Optional[float] = None
+ """Duration in seconds (audio/videos)"""
+
+ error: Optional[str] = None
+ """Error message if upload failed"""
+
+ file_name: Optional[str] = FieldInfo(alias="fileName", default=None)
+ """Resolved filename"""
+
+ file_size: Optional[float] = FieldInfo(alias="fileSize", default=None)
+ """File size in bytes"""
+
+ height: Optional[float] = None
+ """Height in pixels (images/videos)"""
+
+ mime_type: Optional[str] = FieldInfo(alias="mimeType", default=None)
+ """Detected or provided MIME type"""
+
+ src_url: Optional[str] = FieldInfo(alias="srcURL", default=None)
+ """Local file URL (file://) for the uploaded asset"""
+
+ upload_id: Optional[str] = FieldInfo(alias="uploadID", default=None)
+ """Unique upload ID for this asset"""
+
+ width: Optional[float] = None
+ """Width in pixels (images/videos)"""
diff --git a/src/beeper_desktop_api/types/asset_upload_params.py b/src/beeper_desktop_api/types/asset_upload_params.py
new file mode 100644
index 0000000..3249b44
--- /dev/null
+++ b/src/beeper_desktop_api/types/asset_upload_params.py
@@ -0,0 +1,21 @@
+# 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 .._types import FileTypes
+from .._utils import PropertyInfo
+
+__all__ = ["AssetUploadParams"]
+
+
+class AssetUploadParams(TypedDict, total=False):
+ file: Required[FileTypes]
+ """The file to upload (max 500 MB)."""
+
+ file_name: Annotated[str, PropertyInfo(alias="fileName")]
+ """Original filename. Defaults to the uploaded file name if omitted"""
+
+ mime_type: Annotated[str, PropertyInfo(alias="mimeType")]
+ """MIME type. Auto-detected from magic bytes if omitted"""
diff --git a/src/beeper_desktop_api/types/asset_upload_response.py b/src/beeper_desktop_api/types/asset_upload_response.py
new file mode 100644
index 0000000..571d81e
--- /dev/null
+++ b/src/beeper_desktop_api/types/asset_upload_response.py
@@ -0,0 +1,38 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from typing import Optional
+
+from pydantic import Field as FieldInfo
+
+from .._models import BaseModel
+
+__all__ = ["AssetUploadResponse"]
+
+
+class AssetUploadResponse(BaseModel):
+ duration: Optional[float] = None
+ """Duration in seconds (audio/videos)"""
+
+ error: Optional[str] = None
+ """Error message if upload failed"""
+
+ file_name: Optional[str] = FieldInfo(alias="fileName", default=None)
+ """Resolved filename"""
+
+ file_size: Optional[float] = FieldInfo(alias="fileSize", default=None)
+ """File size in bytes"""
+
+ height: Optional[float] = None
+ """Height in pixels (images/videos)"""
+
+ mime_type: Optional[str] = FieldInfo(alias="mimeType", default=None)
+ """Detected or provided MIME type"""
+
+ src_url: Optional[str] = FieldInfo(alias="srcURL", default=None)
+ """Local file URL (file://) for the uploaded asset"""
+
+ upload_id: Optional[str] = FieldInfo(alias="uploadID", default=None)
+ """Unique upload ID for this asset"""
+
+ 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 b42cf4b..837da86 100644
--- a/src/beeper_desktop_api/types/chat.py
+++ b/src/beeper_desktop_api/types/chat.py
@@ -13,6 +13,8 @@
class Participants(BaseModel):
+ """Chat participants information."""
+
has_more: bool = FieldInfo(alias="hasMore")
"""True if there are more participants than included in items."""
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 810263e..3a92906 100644
--- a/src/beeper_desktop_api/types/chats/reminder_create_params.py
+++ b/src/beeper_desktop_api/types/chats/reminder_create_params.py
@@ -15,6 +15,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"""
diff --git a/src/beeper_desktop_api/types/client_search_params.py b/src/beeper_desktop_api/types/client_search_params.py
index 06d58e4..6135164 100644
--- a/src/beeper_desktop_api/types/client_search_params.py
+++ b/src/beeper_desktop_api/types/client_search_params.py
@@ -9,4 +9,4 @@
class ClientSearchParams(TypedDict, total=False):
query: Required[str]
- """User-typed search text. Literal word matching (NOT semantic)."""
+ """User-typed search text. Literal word matching (non-semantic)."""
diff --git a/src/beeper_desktop_api/types/focus_response.py b/src/beeper_desktop_api/types/focus_response.py
index 28875b1..c6a0262 100644
--- a/src/beeper_desktop_api/types/focus_response.py
+++ b/src/beeper_desktop_api/types/focus_response.py
@@ -6,5 +6,7 @@
class FocusResponse(BaseModel):
+ """Response indicating successful app focus action."""
+
success: bool
"""Whether the app was successfully opened/focused."""
diff --git a/src/beeper_desktop_api/types/message_search_params.py b/src/beeper_desktop_api/types/message_search_params.py
index 93fbd63..3ba609d 100644
--- a/src/beeper_desktop_api/types/message_search_params.py
+++ b/src/beeper_desktop_api/types/message_search_params.py
@@ -66,7 +66,7 @@ class MessageSearchParams(TypedDict, total=False):
"""
query: str
- """Literal word search (NOT semantic).
+ """Literal word search (non-semantic).
Finds messages containing these EXACT words in any order. Use single words users
actually type, not concepts or phrases. Example: use "dinner" not "dinner
diff --git a/src/beeper_desktop_api/types/message_send_params.py b/src/beeper_desktop_api/types/message_send_params.py
index 840e745..b3f390a 100644
--- a/src/beeper_desktop_api/types/message_send_params.py
+++ b/src/beeper_desktop_api/types/message_send_params.py
@@ -2,16 +2,52 @@
from __future__ import annotations
-from typing_extensions import Annotated, TypedDict
+from typing_extensions import Literal, Required, Annotated, TypedDict
from .._utils import PropertyInfo
-__all__ = ["MessageSendParams"]
+__all__ = ["MessageSendParams", "Attachment", "AttachmentSize"]
class MessageSendParams(TypedDict, total=False):
+ attachment: Attachment
+ """Single attachment to send with the message"""
+
reply_to_message_id: Annotated[str, PropertyInfo(alias="replyToMessageID")]
"""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."""
+
+
+class AttachmentSize(TypedDict, total=False):
+ """Dimensions (optional override of cached value)"""
+
+ height: Required[float]
+
+ width: Required[float]
+
+
+class Attachment(TypedDict, total=False):
+ """Single attachment to send with the message"""
+
+ upload_id: Required[Annotated[str, PropertyInfo(alias="uploadID")]]
+ """Upload ID from uploadAsset endpoint. Required to reference uploaded files."""
+
+ 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: AttachmentSize
+ """Dimensions (optional override of cached value)"""
+
+ type: Literal["gif", "voiceNote", "sticker"]
+ """Special attachment type (gif, voiceNote, sticker).
+
+ If omitted, auto-detected from mimeType
+ """
diff --git a/src/beeper_desktop_api/types/message_update_params.py b/src/beeper_desktop_api/types/message_update_params.py
new file mode 100644
index 0000000..663d6e8
--- /dev/null
+++ b/src/beeper_desktop_api/types/message_update_params.py
@@ -0,0 +1,17 @@
+# 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__ = ["MessageUpdateParams"]
+
+
+class MessageUpdateParams(TypedDict, total=False):
+ chat_id: Required[Annotated[str, PropertyInfo(alias="chatID")]]
+ """Unique identifier of the chat."""
+
+ 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
new file mode 100644
index 0000000..41e0383
--- /dev/null
+++ b/src/beeper_desktop_api/types/message_update_response.py
@@ -0,0 +1,18 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from pydantic import Field as FieldInfo
+
+from .._models import BaseModel
+
+__all__ = ["MessageUpdateResponse"]
+
+
+class MessageUpdateResponse(BaseModel):
+ chat_id: str = FieldInfo(alias="chatID")
+ """Unique identifier of the chat."""
+
+ message_id: str = FieldInfo(alias="messageID")
+ """Message ID."""
+
+ success: bool
+ """Whether the message was successfully edited"""
diff --git a/src/beeper_desktop_api/types/shared/attachment.py b/src/beeper_desktop_api/types/shared/attachment.py
index 4964307..e1b7b7b 100644
--- a/src/beeper_desktop_api/types/shared/attachment.py
+++ b/src/beeper_desktop_api/types/shared/attachment.py
@@ -11,6 +11,8 @@
class Size(BaseModel):
+ """Pixel dimensions of the attachment: width/height in px."""
+
height: Optional[float] = None
width: Optional[float] = None
@@ -20,6 +22,12 @@ class Attachment(BaseModel):
type: Literal["unknown", "img", "video", "audio"]
"""Attachment type."""
+ id: Optional[str] = None
+ """Attachment identifier (typically an mxc:// URL).
+
+ Use with /v1/assets/download to get a local file path.
+ """
+
duration: Optional[float] = None
"""Duration in seconds (audio/video)."""
diff --git a/src/beeper_desktop_api/types/shared/error.py b/src/beeper_desktop_api/types/shared/error.py
index 10b5f9c..df9fdbb 100644
--- a/src/beeper_desktop_api/types/shared/error.py
+++ b/src/beeper_desktop_api/types/shared/error.py
@@ -20,6 +20,8 @@ class DetailsIssuesIssue(BaseModel):
class DetailsIssues(BaseModel):
+ """Validation error details"""
+
issues: List[DetailsIssuesIssue]
"""List of validation issues"""
diff --git a/src/beeper_desktop_api/types/shared/message.py b/src/beeper_desktop_api/types/shared/message.py
index f87febe..dc3ad4f 100644
--- a/src/beeper_desktop_api/types/shared/message.py
+++ b/src/beeper_desktop_api/types/shared/message.py
@@ -2,6 +2,7 @@
from typing import List, Optional
from datetime import datetime
+from typing_extensions import Literal
from pydantic import Field as FieldInfo
@@ -40,6 +41,9 @@ class Message(BaseModel):
is_unread: Optional[bool] = FieldInfo(alias="isUnread", default=None)
"""True if the message is unread for the authenticated user. May be omitted."""
+ linked_message_id: Optional[str] = FieldInfo(alias="linkedMessageID", default=None)
+ """ID of the message this is a reply to, if any."""
+
reactions: Optional[List[Reaction]] = None
"""Reactions to the message, if any."""
@@ -53,3 +57,12 @@ class Message(BaseModel):
May include a JSON fallback with text entities for rich messages.
"""
+
+ type: Optional[
+ Literal["TEXT", "NOTICE", "IMAGE", "VIDEO", "VOICE", "AUDIO", "FILE", "STICKER", "LOCATION", "REACTION"]
+ ] = None
+ """Message content type.
+
+ Useful for distinguishing reactions, media messages, and state events from
+ regular text messages.
+ """
diff --git a/src/beeper_desktop_api/types/shared/user.py b/src/beeper_desktop_api/types/shared/user.py
index c05827b..d990c5f 100644
--- a/src/beeper_desktop_api/types/shared/user.py
+++ b/src/beeper_desktop_api/types/shared/user.py
@@ -10,6 +10,8 @@
class User(BaseModel):
+ """User the account belongs to."""
+
id: str
"""Stable Beeper user ID. Use as the primary key when referencing a person."""
diff --git a/tests/api_resources/test_assets.py b/tests/api_resources/test_assets.py
index f878292..1aa8c0d 100644
--- a/tests/api_resources/test_assets.py
+++ b/tests/api_resources/test_assets.py
@@ -9,7 +9,11 @@
from tests.utils import assert_matches_type
from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop
-from beeper_desktop_api.types import AssetDownloadResponse
+from beeper_desktop_api.types import (
+ AssetUploadResponse,
+ AssetDownloadResponse,
+ AssetUploadBase64Response,
+)
base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
@@ -48,6 +52,86 @@ def test_streaming_response_download(self, client: BeeperDesktop) -> None:
assert cast(Any, response.is_closed) is True
+ @parametrize
+ def test_method_upload(self, client: BeeperDesktop) -> None:
+ asset = client.assets.upload(
+ file=b"raw file contents",
+ )
+ 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_name="fileName",
+ mime_type="mimeType",
+ )
+ assert_matches_type(AssetUploadResponse, asset, path=["response"])
+
+ @parametrize
+ def test_raw_response_upload(self, client: BeeperDesktop) -> None:
+ response = client.assets.with_raw_response.upload(
+ file=b"raw file contents",
+ )
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ asset = response.parse()
+ assert_matches_type(AssetUploadResponse, asset, path=["response"])
+
+ @parametrize
+ def test_streaming_response_upload(self, client: BeeperDesktop) -> None:
+ with client.assets.with_streaming_response.upload(
+ file=b"raw file contents",
+ ) as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ asset = response.parse()
+ assert_matches_type(AssetUploadResponse, asset, path=["response"])
+
+ assert cast(Any, response.is_closed) is True
+
+ @parametrize
+ def test_method_upload_base64(self, client: BeeperDesktop) -> None:
+ asset = client.assets.upload_base64(
+ content="x",
+ )
+ assert_matches_type(AssetUploadBase64Response, asset, path=["response"])
+
+ @parametrize
+ def test_method_upload_base64_with_all_params(self, client: BeeperDesktop) -> None:
+ asset = client.assets.upload_base64(
+ content="x",
+ file_name="fileName",
+ mime_type="mimeType",
+ )
+ assert_matches_type(AssetUploadBase64Response, asset, path=["response"])
+
+ @parametrize
+ def test_raw_response_upload_base64(self, client: BeeperDesktop) -> None:
+ response = client.assets.with_raw_response.upload_base64(
+ content="x",
+ )
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ asset = response.parse()
+ assert_matches_type(AssetUploadBase64Response, asset, path=["response"])
+
+ @parametrize
+ def test_streaming_response_upload_base64(self, client: BeeperDesktop) -> None:
+ with client.assets.with_streaming_response.upload_base64(
+ content="x",
+ ) as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ asset = response.parse()
+ assert_matches_type(AssetUploadBase64Response, asset, path=["response"])
+
+ assert cast(Any, response.is_closed) is True
+
class TestAsyncAssets:
parametrize = pytest.mark.parametrize(
@@ -84,3 +168,83 @@ async def test_streaming_response_download(self, async_client: AsyncBeeperDeskto
assert_matches_type(AssetDownloadResponse, asset, path=["response"])
assert cast(Any, response.is_closed) is True
+
+ @parametrize
+ async def test_method_upload(self, async_client: AsyncBeeperDesktop) -> None:
+ asset = await async_client.assets.upload(
+ file=b"raw file contents",
+ )
+ 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_name="fileName",
+ mime_type="mimeType",
+ )
+ assert_matches_type(AssetUploadResponse, asset, path=["response"])
+
+ @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",
+ )
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ asset = await response.parse()
+ assert_matches_type(AssetUploadResponse, asset, path=["response"])
+
+ @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",
+ ) as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ asset = await response.parse()
+ assert_matches_type(AssetUploadResponse, asset, path=["response"])
+
+ assert cast(Any, response.is_closed) is True
+
+ @parametrize
+ async def test_method_upload_base64(self, async_client: AsyncBeeperDesktop) -> None:
+ asset = await async_client.assets.upload_base64(
+ content="x",
+ )
+ assert_matches_type(AssetUploadBase64Response, asset, path=["response"])
+
+ @parametrize
+ async def test_method_upload_base64_with_all_params(self, async_client: AsyncBeeperDesktop) -> None:
+ asset = await async_client.assets.upload_base64(
+ content="x",
+ file_name="fileName",
+ mime_type="mimeType",
+ )
+ assert_matches_type(AssetUploadBase64Response, asset, path=["response"])
+
+ @parametrize
+ async def test_raw_response_upload_base64(self, async_client: AsyncBeeperDesktop) -> None:
+ response = await async_client.assets.with_raw_response.upload_base64(
+ content="x",
+ )
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ asset = await response.parse()
+ assert_matches_type(AssetUploadBase64Response, asset, path=["response"])
+
+ @parametrize
+ async def test_streaming_response_upload_base64(self, async_client: AsyncBeeperDesktop) -> None:
+ async with async_client.assets.with_streaming_response.upload_base64(
+ content="x",
+ ) as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ asset = await response.parse()
+ assert_matches_type(AssetUploadBase64Response, asset, path=["response"])
+
+ assert cast(Any, response.is_closed) is True
diff --git a/tests/api_resources/test_chats.py b/tests/api_resources/test_chats.py
index 6a59733..786ae9b 100644
--- a/tests/api_resources/test_chats.py
+++ b/tests/api_resources/test_chats.py
@@ -126,7 +126,6 @@ def test_method_list(self, client: BeeperDesktop) -> None:
def test_method_list_with_all_params(self, client: BeeperDesktop) -> None:
chat = client.chats.list(
account_ids=[
- "whatsapp",
"local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc",
"local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU",
],
@@ -356,7 +355,6 @@ async def test_method_list(self, async_client: AsyncBeeperDesktop) -> None:
async def test_method_list_with_all_params(self, async_client: AsyncBeeperDesktop) -> None:
chat = await async_client.chats.list(
account_ids=[
- "whatsapp",
"local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc",
"local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU",
],
diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py
index 0a6d9f3..b6c3900 100644
--- a/tests/api_resources/test_messages.py
+++ b/tests/api_resources/test_messages.py
@@ -11,6 +11,7 @@
from beeper_desktop_api import BeeperDesktop, AsyncBeeperDesktop
from beeper_desktop_api.types import (
MessageSendResponse,
+ MessageUpdateResponse,
)
from beeper_desktop_api._utils import parse_datetime
from beeper_desktop_api.pagination import SyncCursorSearch, AsyncCursorSearch, SyncCursorSortKey, AsyncCursorSortKey
@@ -22,6 +23,59 @@
class TestMessages:
parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"])
+ @parametrize
+ def test_method_update(self, client: BeeperDesktop) -> None:
+ message = client.messages.update(
+ message_id="messageID",
+ chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com",
+ text="x",
+ )
+ assert_matches_type(MessageUpdateResponse, message, path=["response"])
+
+ @parametrize
+ def test_raw_response_update(self, client: BeeperDesktop) -> None:
+ response = client.messages.with_raw_response.update(
+ message_id="messageID",
+ chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com",
+ text="x",
+ )
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ message = response.parse()
+ assert_matches_type(MessageUpdateResponse, message, path=["response"])
+
+ @parametrize
+ def test_streaming_response_update(self, client: BeeperDesktop) -> None:
+ with client.messages.with_streaming_response.update(
+ message_id="messageID",
+ chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com",
+ text="x",
+ ) as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ message = response.parse()
+ assert_matches_type(MessageUpdateResponse, message, 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.messages.with_raw_response.update(
+ message_id="messageID",
+ chat_id="",
+ text="x",
+ )
+
+ with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"):
+ client.messages.with_raw_response.update(
+ message_id="",
+ chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com",
+ text="x",
+ )
+
@parametrize
def test_method_list(self, client: BeeperDesktop) -> None:
message = client.messages.list(
@@ -78,7 +132,6 @@ def test_method_search(self, client: BeeperDesktop) -> None:
def test_method_search_with_all_params(self, client: BeeperDesktop) -> None:
message = client.messages.search(
account_ids=[
- "whatsapp",
"local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc",
"local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU",
],
@@ -128,6 +181,17 @@ def test_method_send(self, client: BeeperDesktop) -> None:
def test_method_send_with_all_params(self, client: BeeperDesktop) -> None:
message = client.messages.send(
chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com",
+ attachment={
+ "upload_id": "uploadID",
+ "duration": 0,
+ "file_name": "fileName",
+ "mime_type": "mimeType",
+ "size": {
+ "height": 0,
+ "width": 0,
+ },
+ "type": "gif",
+ },
reply_to_message_id="replyToMessageID",
text="text",
)
@@ -170,6 +234,59 @@ class TestAsyncMessages:
"async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"]
)
+ @parametrize
+ async def test_method_update(self, async_client: AsyncBeeperDesktop) -> None:
+ message = await async_client.messages.update(
+ message_id="messageID",
+ chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com",
+ text="x",
+ )
+ assert_matches_type(MessageUpdateResponse, message, path=["response"])
+
+ @parametrize
+ async def test_raw_response_update(self, async_client: AsyncBeeperDesktop) -> None:
+ response = await async_client.messages.with_raw_response.update(
+ message_id="messageID",
+ chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com",
+ text="x",
+ )
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ message = await response.parse()
+ assert_matches_type(MessageUpdateResponse, message, path=["response"])
+
+ @parametrize
+ async def test_streaming_response_update(self, async_client: AsyncBeeperDesktop) -> None:
+ async with async_client.messages.with_streaming_response.update(
+ message_id="messageID",
+ chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com",
+ text="x",
+ ) as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ message = await response.parse()
+ assert_matches_type(MessageUpdateResponse, message, 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.messages.with_raw_response.update(
+ message_id="messageID",
+ chat_id="",
+ text="x",
+ )
+
+ with pytest.raises(ValueError, match=r"Expected a non-empty value for `message_id` but received ''"):
+ await async_client.messages.with_raw_response.update(
+ message_id="",
+ chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com",
+ text="x",
+ )
+
@parametrize
async def test_method_list(self, async_client: AsyncBeeperDesktop) -> None:
message = await async_client.messages.list(
@@ -226,7 +343,6 @@ async def test_method_search(self, async_client: AsyncBeeperDesktop) -> None:
async def test_method_search_with_all_params(self, async_client: AsyncBeeperDesktop) -> None:
message = await async_client.messages.search(
account_ids=[
- "whatsapp",
"local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc",
"local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU",
],
@@ -276,6 +392,17 @@ async def test_method_send(self, async_client: AsyncBeeperDesktop) -> None:
async def test_method_send_with_all_params(self, async_client: AsyncBeeperDesktop) -> None:
message = await async_client.messages.send(
chat_id="!NCdzlIaMjZUmvmvyHU:beeper.com",
+ attachment={
+ "upload_id": "uploadID",
+ "duration": 0,
+ "file_name": "fileName",
+ "mime_type": "mimeType",
+ "size": {
+ "height": 0,
+ "width": 0,
+ },
+ "type": "gif",
+ },
reply_to_message_id="replyToMessageID",
text="text",
)
diff --git a/tests/test_client.py b/tests/test_client.py
index c9e3e9e..5cbca10 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -8,10 +8,11 @@
import json
import asyncio
import inspect
+import dataclasses
import tracemalloc
-from typing import Any, Union, cast
+from typing import Any, Union, TypeVar, Callable, Iterable, Iterator, Optional, Coroutine, cast
from unittest import mock
-from typing_extensions import Literal
+from typing_extensions import Literal, AsyncIterator, override
import httpx
import pytest
@@ -41,6 +42,7 @@
from .utils import update_env
+T = TypeVar("T")
base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
access_token = "My Access Token"
@@ -55,6 +57,57 @@ def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float:
return 0.1
+def mirror_request_content(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, content=request.content)
+
+
+# note: we can't use the httpx.MockTransport class as it consumes the request
+# body itself, which means we can't test that the body is read lazily
+class MockTransport(httpx.BaseTransport, httpx.AsyncBaseTransport):
+ def __init__(
+ self,
+ handler: Callable[[httpx.Request], httpx.Response]
+ | Callable[[httpx.Request], Coroutine[Any, Any, httpx.Response]],
+ ) -> None:
+ self.handler = handler
+
+ @override
+ def handle_request(
+ self,
+ request: httpx.Request,
+ ) -> httpx.Response:
+ assert not inspect.iscoroutinefunction(self.handler), "handler must not be a coroutine function"
+ assert inspect.isfunction(self.handler), "handler must be a function"
+ return self.handler(request)
+
+ @override
+ async def handle_async_request(
+ self,
+ request: httpx.Request,
+ ) -> httpx.Response:
+ assert inspect.iscoroutinefunction(self.handler), "handler must be a coroutine function"
+ return await self.handler(request)
+
+
+@dataclasses.dataclass
+class Counter:
+ value: int = 0
+
+
+def _make_sync_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> Iterator[T]:
+ for item in iterable:
+ if counter:
+ counter.value += 1
+ yield item
+
+
+async def _make_async_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> AsyncIterator[T]:
+ for item in iterable:
+ if counter:
+ counter.value += 1
+ yield item
+
+
def _get_open_connections(client: BeeperDesktop | AsyncBeeperDesktop) -> int:
transport = client._client._transport
assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport)
@@ -64,51 +117,49 @@ def _get_open_connections(client: BeeperDesktop | AsyncBeeperDesktop) -> int:
class TestBeeperDesktop:
- client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True)
-
@pytest.mark.respx(base_url=base_url)
- def test_raw_response(self, respx_mock: MockRouter) -> None:
+ def test_raw_response(self, respx_mock: MockRouter, client: BeeperDesktop) -> None:
respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
- response = self.client.post("/foo", cast_to=httpx.Response)
+ response = client.post("/foo", cast_to=httpx.Response)
assert response.status_code == 200
assert isinstance(response, httpx.Response)
assert response.json() == {"foo": "bar"}
@pytest.mark.respx(base_url=base_url)
- def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None:
+ def test_raw_response_for_binary(self, respx_mock: MockRouter, client: BeeperDesktop) -> None:
respx_mock.post("/foo").mock(
return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}')
)
- response = self.client.post("/foo", cast_to=httpx.Response)
+ response = client.post("/foo", cast_to=httpx.Response)
assert response.status_code == 200
assert isinstance(response, httpx.Response)
assert response.json() == {"foo": "bar"}
- def test_copy(self) -> None:
- copied = self.client.copy()
- assert id(copied) != id(self.client)
+ def test_copy(self, client: BeeperDesktop) -> None:
+ copied = client.copy()
+ assert id(copied) != id(client)
- copied = self.client.copy(access_token="another My Access Token")
+ copied = client.copy(access_token="another My Access Token")
assert copied.access_token == "another My Access Token"
- assert self.client.access_token == "My Access Token"
+ assert client.access_token == "My Access Token"
- def test_copy_default_options(self) -> None:
+ def test_copy_default_options(self, client: BeeperDesktop) -> None:
# options that have a default are overridden correctly
- copied = self.client.copy(max_retries=7)
+ copied = client.copy(max_retries=7)
assert copied.max_retries == 7
- assert self.client.max_retries == 2
+ assert client.max_retries == 2
copied2 = copied.copy(max_retries=6)
assert copied2.max_retries == 6
assert copied.max_retries == 7
# timeout
- assert isinstance(self.client.timeout, httpx.Timeout)
- copied = self.client.copy(timeout=None)
+ assert isinstance(client.timeout, httpx.Timeout)
+ copied = client.copy(timeout=None)
assert copied.timeout is None
- assert isinstance(self.client.timeout, httpx.Timeout)
+ assert isinstance(client.timeout, httpx.Timeout)
def test_copy_default_headers(self) -> None:
client = BeeperDesktop(
@@ -146,6 +197,7 @@ def test_copy_default_headers(self) -> None:
match="`default_headers` and `set_default_headers` arguments are mutually exclusive",
):
client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"})
+ client.close()
def test_copy_default_query(self) -> None:
client = BeeperDesktop(
@@ -183,13 +235,15 @@ def test_copy_default_query(self) -> None:
):
client.copy(set_default_query={}, default_query={"foo": "Bar"})
- def test_copy_signature(self) -> None:
+ client.close()
+
+ def test_copy_signature(self, client: BeeperDesktop) -> None:
# ensure the same parameters that can be passed to the client are defined in the `.copy()` method
init_signature = inspect.signature(
# mypy doesn't like that we access the `__init__` property.
- self.client.__init__, # type: ignore[misc]
+ client.__init__, # type: ignore[misc]
)
- copy_signature = inspect.signature(self.client.copy)
+ copy_signature = inspect.signature(client.copy)
exclude_params = {"transport", "proxies", "_strict_response_validation"}
for name in init_signature.parameters.keys():
@@ -200,12 +254,12 @@ def test_copy_signature(self) -> None:
assert copy_param is not None, f"copy() signature is missing the {name} param"
@pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12")
- def test_copy_build_request(self) -> None:
+ def test_copy_build_request(self, client: BeeperDesktop) -> None:
options = FinalRequestOptions(method="get", url="/foo")
def build_request(options: FinalRequestOptions) -> None:
- client = self.client.copy()
- client._build_request(options)
+ client_copy = client.copy()
+ client_copy._build_request(options)
# ensure that the machinery is warmed up before tracing starts.
build_request(options)
@@ -262,14 +316,12 @@ def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.Statistic
print(frame)
raise AssertionError()
- def test_request_timeout(self) -> None:
- request = self.client._build_request(FinalRequestOptions(method="get", url="/foo"))
+ def test_request_timeout(self, client: BeeperDesktop) -> None:
+ request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
assert timeout == DEFAULT_TIMEOUT
- request = self.client._build_request(
- FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))
- )
+ request = client._build_request(FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)))
timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
assert timeout == httpx.Timeout(100.0)
@@ -282,6 +334,8 @@ def test_client_timeout_option(self) -> None:
timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
assert timeout == httpx.Timeout(0)
+ client.close()
+
def test_http_client_timeout_option(self) -> None:
# custom timeout given to the httpx client should be used
with httpx.Client(timeout=None) as http_client:
@@ -293,6 +347,8 @@ def test_http_client_timeout_option(self) -> None:
timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
assert timeout == httpx.Timeout(None)
+ client.close()
+
# no timeout given to the httpx client should not use the httpx default
with httpx.Client() as http_client:
client = BeeperDesktop(
@@ -303,6 +359,8 @@ def test_http_client_timeout_option(self) -> None:
timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
assert timeout == DEFAULT_TIMEOUT
+ client.close()
+
# explicitly passing the default timeout currently results in it being ignored
with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
client = BeeperDesktop(
@@ -313,6 +371,8 @@ def test_http_client_timeout_option(self) -> None:
timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
assert timeout == DEFAULT_TIMEOUT # our default
+ client.close()
+
async def test_invalid_http_client(self) -> None:
with pytest.raises(TypeError, match="Invalid `http_client` arg"):
async with httpx.AsyncClient() as http_client:
@@ -324,17 +384,17 @@ async def test_invalid_http_client(self) -> None:
)
def test_default_headers_option(self) -> None:
- client = BeeperDesktop(
+ test_client = BeeperDesktop(
base_url=base_url,
access_token=access_token,
_strict_response_validation=True,
default_headers={"X-Foo": "bar"},
)
- request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
+ request = test_client._build_request(FinalRequestOptions(method="get", url="/foo"))
assert request.headers.get("x-foo") == "bar"
assert request.headers.get("x-stainless-lang") == "python"
- client2 = BeeperDesktop(
+ test_client2 = BeeperDesktop(
base_url=base_url,
access_token=access_token,
_strict_response_validation=True,
@@ -343,10 +403,13 @@ def test_default_headers_option(self) -> None:
"X-Stainless-Lang": "my-overriding-header",
},
)
- request = client2._build_request(FinalRequestOptions(method="get", url="/foo"))
+ request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo"))
assert request.headers.get("x-foo") == "stainless"
assert request.headers.get("x-stainless-lang") == "my-overriding-header"
+ test_client.close()
+ test_client2.close()
+
def test_validate_headers(self) -> None:
client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True)
request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
@@ -378,8 +441,10 @@ def test_default_query_option(self) -> None:
url = httpx.URL(request.url)
assert dict(url.params) == {"foo": "baz", "query_param": "overridden"}
- def test_request_extra_json(self) -> None:
- request = self.client._build_request(
+ client.close()
+
+ def test_request_extra_json(self, client: BeeperDesktop) -> None:
+ request = client._build_request(
FinalRequestOptions(
method="post",
url="/foo",
@@ -390,7 +455,7 @@ def test_request_extra_json(self) -> None:
data = json.loads(request.content.decode("utf-8"))
assert data == {"foo": "bar", "baz": False}
- request = self.client._build_request(
+ request = client._build_request(
FinalRequestOptions(
method="post",
url="/foo",
@@ -401,7 +466,7 @@ def test_request_extra_json(self) -> None:
assert data == {"baz": False}
# `extra_json` takes priority over `json_data` when keys clash
- request = self.client._build_request(
+ request = client._build_request(
FinalRequestOptions(
method="post",
url="/foo",
@@ -412,8 +477,8 @@ def test_request_extra_json(self) -> None:
data = json.loads(request.content.decode("utf-8"))
assert data == {"foo": "bar", "baz": None}
- def test_request_extra_headers(self) -> None:
- request = self.client._build_request(
+ def test_request_extra_headers(self, client: BeeperDesktop) -> None:
+ request = client._build_request(
FinalRequestOptions(
method="post",
url="/foo",
@@ -423,7 +488,7 @@ def test_request_extra_headers(self) -> None:
assert request.headers.get("X-Foo") == "Foo"
# `extra_headers` takes priority over `default_headers` when keys clash
- request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request(
+ request = client.with_options(default_headers={"X-Bar": "true"})._build_request(
FinalRequestOptions(
method="post",
url="/foo",
@@ -434,8 +499,8 @@ def test_request_extra_headers(self) -> None:
)
assert request.headers.get("X-Bar") == "false"
- def test_request_extra_query(self) -> None:
- request = self.client._build_request(
+ def test_request_extra_query(self, client: BeeperDesktop) -> None:
+ request = client._build_request(
FinalRequestOptions(
method="post",
url="/foo",
@@ -448,7 +513,7 @@ def test_request_extra_query(self) -> None:
assert params == {"my_query_param": "Foo"}
# if both `query` and `extra_query` are given, they are merged
- request = self.client._build_request(
+ request = client._build_request(
FinalRequestOptions(
method="post",
url="/foo",
@@ -462,7 +527,7 @@ def test_request_extra_query(self) -> None:
assert params == {"bar": "1", "foo": "2"}
# `extra_query` takes priority over `query` when keys clash
- request = self.client._build_request(
+ request = client._build_request(
FinalRequestOptions(
method="post",
url="/foo",
@@ -505,7 +570,71 @@ def test_multipart_repeating_array(self, client: BeeperDesktop) -> None:
]
@pytest.mark.respx(base_url=base_url)
- def test_basic_union_response(self, respx_mock: MockRouter) -> None:
+ def test_binary_content_upload(self, respx_mock: MockRouter, client: BeeperDesktop) -> None:
+ respx_mock.post("/upload").mock(side_effect=mirror_request_content)
+
+ file_content = b"Hello, this is a test file."
+
+ response = client.post(
+ "/upload",
+ content=file_content,
+ cast_to=httpx.Response,
+ options={"headers": {"Content-Type": "application/octet-stream"}},
+ )
+
+ assert response.status_code == 200
+ assert response.request.headers["Content-Type"] == "application/octet-stream"
+ assert response.content == file_content
+
+ def test_binary_content_upload_with_iterator(self) -> None:
+ file_content = b"Hello, this is a test file."
+ counter = Counter()
+ iterator = _make_sync_iterator([file_content], counter=counter)
+
+ def mock_handler(request: httpx.Request) -> httpx.Response:
+ assert counter.value == 0, "the request body should not have been read"
+ return httpx.Response(200, content=request.read())
+
+ with BeeperDesktop(
+ base_url=base_url,
+ access_token=access_token,
+ _strict_response_validation=True,
+ http_client=httpx.Client(transport=MockTransport(handler=mock_handler)),
+ ) as client:
+ response = client.post(
+ "/upload",
+ content=iterator,
+ cast_to=httpx.Response,
+ options={"headers": {"Content-Type": "application/octet-stream"}},
+ )
+
+ assert response.status_code == 200
+ assert response.request.headers["Content-Type"] == "application/octet-stream"
+ assert response.content == file_content
+ assert counter.value == 1
+
+ @pytest.mark.respx(base_url=base_url)
+ def test_binary_content_upload_with_body_is_deprecated(self, respx_mock: MockRouter, client: BeeperDesktop) -> None:
+ respx_mock.post("/upload").mock(side_effect=mirror_request_content)
+
+ file_content = b"Hello, this is a test file."
+
+ with pytest.deprecated_call(
+ match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead."
+ ):
+ response = client.post(
+ "/upload",
+ body=file_content,
+ cast_to=httpx.Response,
+ options={"headers": {"Content-Type": "application/octet-stream"}},
+ )
+
+ assert response.status_code == 200
+ assert response.request.headers["Content-Type"] == "application/octet-stream"
+ assert response.content == file_content
+
+ @pytest.mark.respx(base_url=base_url)
+ def test_basic_union_response(self, respx_mock: MockRouter, client: BeeperDesktop) -> None:
class Model1(BaseModel):
name: str
@@ -514,12 +643,12 @@ class Model2(BaseModel):
respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
- response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
+ response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
assert isinstance(response, Model2)
assert response.foo == "bar"
@pytest.mark.respx(base_url=base_url)
- def test_union_response_different_types(self, respx_mock: MockRouter) -> None:
+ def test_union_response_different_types(self, respx_mock: MockRouter, client: BeeperDesktop) -> None:
"""Union of objects with the same field name using a different type"""
class Model1(BaseModel):
@@ -530,18 +659,20 @@ class Model2(BaseModel):
respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
- response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
+ response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
assert isinstance(response, Model2)
assert response.foo == "bar"
respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
- response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
+ response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
assert isinstance(response, Model1)
assert response.foo == 1
@pytest.mark.respx(base_url=base_url)
- def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
+ def test_non_application_json_content_type_for_json_data(
+ self, respx_mock: MockRouter, client: BeeperDesktop
+ ) -> None:
"""
Response that sets Content-Type to something other than application/json but returns json data
"""
@@ -557,7 +688,7 @@ class Model(BaseModel):
)
)
- response = self.client.get("/foo", cast_to=Model)
+ response = client.get("/foo", cast_to=Model)
assert isinstance(response, Model)
assert response.foo == 2
@@ -571,6 +702,8 @@ def test_base_url_setter(self) -> None:
assert client.base_url == "https://example.com/from_setter/"
+ client.close()
+
def test_base_url_env(self) -> None:
with update_env(BEEPER_DESKTOP_BASE_URL="http://localhost:5000/from/env"):
client = BeeperDesktop(access_token=access_token, _strict_response_validation=True)
@@ -602,6 +735,7 @@ def test_base_url_trailing_slash(self, client: BeeperDesktop) -> None:
),
)
assert request.url == "http://localhost:5000/custom/path/foo"
+ client.close()
@pytest.mark.parametrize(
"client",
@@ -629,6 +763,7 @@ def test_base_url_no_trailing_slash(self, client: BeeperDesktop) -> None:
),
)
assert request.url == "http://localhost:5000/custom/path/foo"
+ client.close()
@pytest.mark.parametrize(
"client",
@@ -656,35 +791,36 @@ def test_absolute_request_url(self, client: BeeperDesktop) -> None:
),
)
assert request.url == "https://myapi.com/foo"
+ client.close()
def test_copied_client_does_not_close_http(self) -> None:
- client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True)
- assert not client.is_closed()
+ test_client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True)
+ assert not test_client.is_closed()
- copied = client.copy()
- assert copied is not client
+ copied = test_client.copy()
+ assert copied is not test_client
del copied
- assert not client.is_closed()
+ assert not test_client.is_closed()
def test_client_context_manager(self) -> None:
- client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True)
- with client as c2:
- assert c2 is client
+ test_client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True)
+ with test_client as c2:
+ assert c2 is test_client
assert not c2.is_closed()
- assert not client.is_closed()
- assert client.is_closed()
+ assert not test_client.is_closed()
+ assert test_client.is_closed()
@pytest.mark.respx(base_url=base_url)
- def test_client_response_validation_error(self, respx_mock: MockRouter) -> None:
+ def test_client_response_validation_error(self, respx_mock: MockRouter, client: BeeperDesktop) -> None:
class Model(BaseModel):
foo: str
respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}}))
with pytest.raises(APIResponseValidationError) as exc:
- self.client.get("/foo", cast_to=Model)
+ client.get("/foo", cast_to=Model)
assert isinstance(exc.value.__cause__, ValidationError)
@@ -709,11 +845,16 @@ class Model(BaseModel):
with pytest.raises(APIResponseValidationError):
strict_client.get("/foo", cast_to=Model)
- client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=False)
+ non_strict_client = BeeperDesktop(
+ base_url=base_url, access_token=access_token, _strict_response_validation=False
+ )
- response = client.get("/foo", cast_to=Model)
+ response = non_strict_client.get("/foo", cast_to=Model)
assert isinstance(response, str) # type: ignore[unreachable]
+ strict_client.close()
+ non_strict_client.close()
+
@pytest.mark.parametrize(
"remaining_retries,retry_after,timeout",
[
@@ -736,9 +877,9 @@ class Model(BaseModel):
],
)
@mock.patch("time.time", mock.MagicMock(return_value=1696004797))
- def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None:
- client = BeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True)
-
+ def test_parse_retry_after_header(
+ self, remaining_retries: int, retry_after: str, timeout: float, client: BeeperDesktop
+ ) -> None:
headers = httpx.Headers({"retry-after": retry_after})
options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
calculated = client._calculate_retry_timeout(remaining_retries, options, headers)
@@ -752,7 +893,7 @@ def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, clien
with pytest.raises(APITimeoutError):
client.accounts.with_streaming_response.list().__enter__()
- assert _get_open_connections(self.client) == 0
+ assert _get_open_connections(client) == 0
@mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
@pytest.mark.respx(base_url=base_url)
@@ -761,7 +902,7 @@ def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client
with pytest.raises(APIStatusError):
client.accounts.with_streaming_response.list().__enter__()
- assert _get_open_connections(self.client) == 0
+ assert _get_open_connections(client) == 0
@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
@mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
@@ -863,83 +1004,77 @@ def test_default_client_creation(self) -> None:
)
@pytest.mark.respx(base_url=base_url)
- def test_follow_redirects(self, respx_mock: MockRouter) -> None:
+ def test_follow_redirects(self, respx_mock: MockRouter, client: BeeperDesktop) -> None:
# Test that the default follow_redirects=True allows following redirects
respx_mock.post("/redirect").mock(
return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
)
respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"}))
- response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response)
+ response = client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response)
assert response.status_code == 200
assert response.json() == {"status": "ok"}
@pytest.mark.respx(base_url=base_url)
- def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None:
+ def test_follow_redirects_disabled(self, respx_mock: MockRouter, client: BeeperDesktop) -> None:
# Test that follow_redirects=False prevents following redirects
respx_mock.post("/redirect").mock(
return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
)
with pytest.raises(APIStatusError) as exc_info:
- self.client.post(
- "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response
- )
+ client.post("/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response)
assert exc_info.value.response.status_code == 302
assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected"
class TestAsyncBeeperDesktop:
- client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True)
-
@pytest.mark.respx(base_url=base_url)
- @pytest.mark.asyncio
- async def test_raw_response(self, respx_mock: MockRouter) -> None:
+ async def test_raw_response(self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop) -> None:
respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
- response = await self.client.post("/foo", cast_to=httpx.Response)
+ response = await async_client.post("/foo", cast_to=httpx.Response)
assert response.status_code == 200
assert isinstance(response, httpx.Response)
assert response.json() == {"foo": "bar"}
@pytest.mark.respx(base_url=base_url)
- @pytest.mark.asyncio
- async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None:
+ async def test_raw_response_for_binary(self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop) -> None:
respx_mock.post("/foo").mock(
return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}')
)
- response = await self.client.post("/foo", cast_to=httpx.Response)
+ response = await async_client.post("/foo", cast_to=httpx.Response)
assert response.status_code == 200
assert isinstance(response, httpx.Response)
assert response.json() == {"foo": "bar"}
- def test_copy(self) -> None:
- copied = self.client.copy()
- assert id(copied) != id(self.client)
+ def test_copy(self, async_client: AsyncBeeperDesktop) -> None:
+ copied = async_client.copy()
+ assert id(copied) != id(async_client)
- copied = self.client.copy(access_token="another My Access Token")
+ copied = async_client.copy(access_token="another My Access Token")
assert copied.access_token == "another My Access Token"
- assert self.client.access_token == "My Access Token"
+ assert async_client.access_token == "My Access Token"
- def test_copy_default_options(self) -> None:
+ def test_copy_default_options(self, async_client: AsyncBeeperDesktop) -> None:
# options that have a default are overridden correctly
- copied = self.client.copy(max_retries=7)
+ copied = async_client.copy(max_retries=7)
assert copied.max_retries == 7
- assert self.client.max_retries == 2
+ assert async_client.max_retries == 2
copied2 = copied.copy(max_retries=6)
assert copied2.max_retries == 6
assert copied.max_retries == 7
# timeout
- assert isinstance(self.client.timeout, httpx.Timeout)
- copied = self.client.copy(timeout=None)
+ assert isinstance(async_client.timeout, httpx.Timeout)
+ copied = async_client.copy(timeout=None)
assert copied.timeout is None
- assert isinstance(self.client.timeout, httpx.Timeout)
+ assert isinstance(async_client.timeout, httpx.Timeout)
- def test_copy_default_headers(self) -> None:
+ async def test_copy_default_headers(self) -> None:
client = AsyncBeeperDesktop(
base_url=base_url,
access_token=access_token,
@@ -975,8 +1110,9 @@ def test_copy_default_headers(self) -> None:
match="`default_headers` and `set_default_headers` arguments are mutually exclusive",
):
client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"})
+ await client.close()
- def test_copy_default_query(self) -> None:
+ async def test_copy_default_query(self) -> None:
client = AsyncBeeperDesktop(
base_url=base_url, access_token=access_token, _strict_response_validation=True, default_query={"foo": "bar"}
)
@@ -1012,13 +1148,15 @@ def test_copy_default_query(self) -> None:
):
client.copy(set_default_query={}, default_query={"foo": "Bar"})
- def test_copy_signature(self) -> None:
+ await client.close()
+
+ def test_copy_signature(self, async_client: AsyncBeeperDesktop) -> None:
# ensure the same parameters that can be passed to the client are defined in the `.copy()` method
init_signature = inspect.signature(
# mypy doesn't like that we access the `__init__` property.
- self.client.__init__, # type: ignore[misc]
+ async_client.__init__, # type: ignore[misc]
)
- copy_signature = inspect.signature(self.client.copy)
+ copy_signature = inspect.signature(async_client.copy)
exclude_params = {"transport", "proxies", "_strict_response_validation"}
for name in init_signature.parameters.keys():
@@ -1029,12 +1167,12 @@ def test_copy_signature(self) -> None:
assert copy_param is not None, f"copy() signature is missing the {name} param"
@pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12")
- def test_copy_build_request(self) -> None:
+ def test_copy_build_request(self, async_client: AsyncBeeperDesktop) -> None:
options = FinalRequestOptions(method="get", url="/foo")
def build_request(options: FinalRequestOptions) -> None:
- client = self.client.copy()
- client._build_request(options)
+ client_copy = async_client.copy()
+ client_copy._build_request(options)
# ensure that the machinery is warmed up before tracing starts.
build_request(options)
@@ -1091,12 +1229,12 @@ def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.Statistic
print(frame)
raise AssertionError()
- async def test_request_timeout(self) -> None:
- request = self.client._build_request(FinalRequestOptions(method="get", url="/foo"))
+ async def test_request_timeout(self, async_client: AsyncBeeperDesktop) -> None:
+ request = async_client._build_request(FinalRequestOptions(method="get", url="/foo"))
timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
assert timeout == DEFAULT_TIMEOUT
- request = self.client._build_request(
+ request = async_client._build_request(
FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))
)
timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
@@ -1111,6 +1249,8 @@ async def test_client_timeout_option(self) -> None:
timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
assert timeout == httpx.Timeout(0)
+ await client.close()
+
async def test_http_client_timeout_option(self) -> None:
# custom timeout given to the httpx client should be used
async with httpx.AsyncClient(timeout=None) as http_client:
@@ -1122,6 +1262,8 @@ async def test_http_client_timeout_option(self) -> None:
timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
assert timeout == httpx.Timeout(None)
+ await client.close()
+
# no timeout given to the httpx client should not use the httpx default
async with httpx.AsyncClient() as http_client:
client = AsyncBeeperDesktop(
@@ -1132,6 +1274,8 @@ async def test_http_client_timeout_option(self) -> None:
timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
assert timeout == DEFAULT_TIMEOUT
+ await client.close()
+
# explicitly passing the default timeout currently results in it being ignored
async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
client = AsyncBeeperDesktop(
@@ -1142,6 +1286,8 @@ async def test_http_client_timeout_option(self) -> None:
timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
assert timeout == DEFAULT_TIMEOUT # our default
+ await client.close()
+
def test_invalid_http_client(self) -> None:
with pytest.raises(TypeError, match="Invalid `http_client` arg"):
with httpx.Client() as http_client:
@@ -1152,18 +1298,18 @@ def test_invalid_http_client(self) -> None:
http_client=cast(Any, http_client),
)
- def test_default_headers_option(self) -> None:
- client = AsyncBeeperDesktop(
+ async def test_default_headers_option(self) -> None:
+ test_client = AsyncBeeperDesktop(
base_url=base_url,
access_token=access_token,
_strict_response_validation=True,
default_headers={"X-Foo": "bar"},
)
- request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
+ request = test_client._build_request(FinalRequestOptions(method="get", url="/foo"))
assert request.headers.get("x-foo") == "bar"
assert request.headers.get("x-stainless-lang") == "python"
- client2 = AsyncBeeperDesktop(
+ test_client2 = AsyncBeeperDesktop(
base_url=base_url,
access_token=access_token,
_strict_response_validation=True,
@@ -1172,10 +1318,13 @@ def test_default_headers_option(self) -> None:
"X-Stainless-Lang": "my-overriding-header",
},
)
- request = client2._build_request(FinalRequestOptions(method="get", url="/foo"))
+ request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo"))
assert request.headers.get("x-foo") == "stainless"
assert request.headers.get("x-stainless-lang") == "my-overriding-header"
+ await test_client.close()
+ await test_client2.close()
+
def test_validate_headers(self) -> None:
client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True)
request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
@@ -1186,7 +1335,7 @@ def test_validate_headers(self) -> None:
client2 = AsyncBeeperDesktop(base_url=base_url, access_token=None, _strict_response_validation=True)
_ = client2
- def test_default_query_option(self) -> None:
+ async def test_default_query_option(self) -> None:
client = AsyncBeeperDesktop(
base_url=base_url,
access_token=access_token,
@@ -1207,8 +1356,10 @@ def test_default_query_option(self) -> None:
url = httpx.URL(request.url)
assert dict(url.params) == {"foo": "baz", "query_param": "overridden"}
- def test_request_extra_json(self) -> None:
- request = self.client._build_request(
+ await client.close()
+
+ def test_request_extra_json(self, client: BeeperDesktop) -> None:
+ request = client._build_request(
FinalRequestOptions(
method="post",
url="/foo",
@@ -1219,7 +1370,7 @@ def test_request_extra_json(self) -> None:
data = json.loads(request.content.decode("utf-8"))
assert data == {"foo": "bar", "baz": False}
- request = self.client._build_request(
+ request = client._build_request(
FinalRequestOptions(
method="post",
url="/foo",
@@ -1230,7 +1381,7 @@ def test_request_extra_json(self) -> None:
assert data == {"baz": False}
# `extra_json` takes priority over `json_data` when keys clash
- request = self.client._build_request(
+ request = client._build_request(
FinalRequestOptions(
method="post",
url="/foo",
@@ -1241,8 +1392,8 @@ def test_request_extra_json(self) -> None:
data = json.loads(request.content.decode("utf-8"))
assert data == {"foo": "bar", "baz": None}
- def test_request_extra_headers(self) -> None:
- request = self.client._build_request(
+ def test_request_extra_headers(self, client: BeeperDesktop) -> None:
+ request = client._build_request(
FinalRequestOptions(
method="post",
url="/foo",
@@ -1252,7 +1403,7 @@ def test_request_extra_headers(self) -> None:
assert request.headers.get("X-Foo") == "Foo"
# `extra_headers` takes priority over `default_headers` when keys clash
- request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request(
+ request = client.with_options(default_headers={"X-Bar": "true"})._build_request(
FinalRequestOptions(
method="post",
url="/foo",
@@ -1263,8 +1414,8 @@ def test_request_extra_headers(self) -> None:
)
assert request.headers.get("X-Bar") == "false"
- def test_request_extra_query(self) -> None:
- request = self.client._build_request(
+ def test_request_extra_query(self, client: BeeperDesktop) -> None:
+ request = client._build_request(
FinalRequestOptions(
method="post",
url="/foo",
@@ -1277,7 +1428,7 @@ def test_request_extra_query(self) -> None:
assert params == {"my_query_param": "Foo"}
# if both `query` and `extra_query` are given, they are merged
- request = self.client._build_request(
+ request = client._build_request(
FinalRequestOptions(
method="post",
url="/foo",
@@ -1291,7 +1442,7 @@ def test_request_extra_query(self) -> None:
assert params == {"bar": "1", "foo": "2"}
# `extra_query` takes priority over `query` when keys clash
- request = self.client._build_request(
+ request = client._build_request(
FinalRequestOptions(
method="post",
url="/foo",
@@ -1334,7 +1485,73 @@ def test_multipart_repeating_array(self, async_client: AsyncBeeperDesktop) -> No
]
@pytest.mark.respx(base_url=base_url)
- async def test_basic_union_response(self, respx_mock: MockRouter) -> None:
+ async def test_binary_content_upload(self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop) -> None:
+ respx_mock.post("/upload").mock(side_effect=mirror_request_content)
+
+ file_content = b"Hello, this is a test file."
+
+ response = await async_client.post(
+ "/upload",
+ content=file_content,
+ cast_to=httpx.Response,
+ options={"headers": {"Content-Type": "application/octet-stream"}},
+ )
+
+ assert response.status_code == 200
+ assert response.request.headers["Content-Type"] == "application/octet-stream"
+ assert response.content == file_content
+
+ async def test_binary_content_upload_with_asynciterator(self) -> None:
+ file_content = b"Hello, this is a test file."
+ counter = Counter()
+ iterator = _make_async_iterator([file_content], counter=counter)
+
+ async def mock_handler(request: httpx.Request) -> httpx.Response:
+ assert counter.value == 0, "the request body should not have been read"
+ return httpx.Response(200, content=await request.aread())
+
+ async with AsyncBeeperDesktop(
+ base_url=base_url,
+ access_token=access_token,
+ _strict_response_validation=True,
+ http_client=httpx.AsyncClient(transport=MockTransport(handler=mock_handler)),
+ ) as client:
+ response = await client.post(
+ "/upload",
+ content=iterator,
+ cast_to=httpx.Response,
+ options={"headers": {"Content-Type": "application/octet-stream"}},
+ )
+
+ assert response.status_code == 200
+ assert response.request.headers["Content-Type"] == "application/octet-stream"
+ assert response.content == file_content
+ assert counter.value == 1
+
+ @pytest.mark.respx(base_url=base_url)
+ async def test_binary_content_upload_with_body_is_deprecated(
+ self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop
+ ) -> None:
+ respx_mock.post("/upload").mock(side_effect=mirror_request_content)
+
+ file_content = b"Hello, this is a test file."
+
+ with pytest.deprecated_call(
+ match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead."
+ ):
+ response = await async_client.post(
+ "/upload",
+ body=file_content,
+ cast_to=httpx.Response,
+ options={"headers": {"Content-Type": "application/octet-stream"}},
+ )
+
+ assert response.status_code == 200
+ assert response.request.headers["Content-Type"] == "application/octet-stream"
+ assert response.content == file_content
+
+ @pytest.mark.respx(base_url=base_url)
+ async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop) -> None:
class Model1(BaseModel):
name: str
@@ -1343,12 +1560,14 @@ class Model2(BaseModel):
respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
- response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
+ response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
assert isinstance(response, Model2)
assert response.foo == "bar"
@pytest.mark.respx(base_url=base_url)
- async def test_union_response_different_types(self, respx_mock: MockRouter) -> None:
+ async def test_union_response_different_types(
+ self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop
+ ) -> None:
"""Union of objects with the same field name using a different type"""
class Model1(BaseModel):
@@ -1359,18 +1578,20 @@ class Model2(BaseModel):
respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
- response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
+ response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
assert isinstance(response, Model2)
assert response.foo == "bar"
respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
- response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
+ response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
assert isinstance(response, Model1)
assert response.foo == 1
@pytest.mark.respx(base_url=base_url)
- async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
+ async def test_non_application_json_content_type_for_json_data(
+ self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop
+ ) -> None:
"""
Response that sets Content-Type to something other than application/json but returns json data
"""
@@ -1386,11 +1607,11 @@ class Model(BaseModel):
)
)
- response = await self.client.get("/foo", cast_to=Model)
+ response = await async_client.get("/foo", cast_to=Model)
assert isinstance(response, Model)
assert response.foo == 2
- def test_base_url_setter(self) -> None:
+ async def test_base_url_setter(self) -> None:
client = AsyncBeeperDesktop(
base_url="https://example.com/from_init", access_token=access_token, _strict_response_validation=True
)
@@ -1400,7 +1621,9 @@ def test_base_url_setter(self) -> None:
assert client.base_url == "https://example.com/from_setter/"
- def test_base_url_env(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"):
client = AsyncBeeperDesktop(access_token=access_token, _strict_response_validation=True)
assert client.base_url == "http://localhost:5000/from/env/"
@@ -1422,7 +1645,7 @@ def test_base_url_env(self) -> None:
],
ids=["standard", "custom http client"],
)
- def test_base_url_trailing_slash(self, client: AsyncBeeperDesktop) -> None:
+ async def test_base_url_trailing_slash(self, client: AsyncBeeperDesktop) -> None:
request = client._build_request(
FinalRequestOptions(
method="post",
@@ -1431,6 +1654,7 @@ def test_base_url_trailing_slash(self, client: AsyncBeeperDesktop) -> None:
),
)
assert request.url == "http://localhost:5000/custom/path/foo"
+ await client.close()
@pytest.mark.parametrize(
"client",
@@ -1449,7 +1673,7 @@ def test_base_url_trailing_slash(self, client: AsyncBeeperDesktop) -> None:
],
ids=["standard", "custom http client"],
)
- def test_base_url_no_trailing_slash(self, client: AsyncBeeperDesktop) -> None:
+ async def test_base_url_no_trailing_slash(self, client: AsyncBeeperDesktop) -> None:
request = client._build_request(
FinalRequestOptions(
method="post",
@@ -1458,6 +1682,7 @@ def test_base_url_no_trailing_slash(self, client: AsyncBeeperDesktop) -> None:
),
)
assert request.url == "http://localhost:5000/custom/path/foo"
+ await client.close()
@pytest.mark.parametrize(
"client",
@@ -1476,7 +1701,7 @@ def test_base_url_no_trailing_slash(self, client: AsyncBeeperDesktop) -> None:
],
ids=["standard", "custom http client"],
)
- def test_absolute_request_url(self, client: AsyncBeeperDesktop) -> None:
+ async def test_absolute_request_url(self, client: AsyncBeeperDesktop) -> None:
request = client._build_request(
FinalRequestOptions(
method="post",
@@ -1485,37 +1710,39 @@ def test_absolute_request_url(self, client: AsyncBeeperDesktop) -> None:
),
)
assert request.url == "https://myapi.com/foo"
+ await client.close()
async def test_copied_client_does_not_close_http(self) -> None:
- client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True)
- assert not client.is_closed()
+ test_client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True)
+ assert not test_client.is_closed()
- copied = client.copy()
- assert copied is not client
+ copied = test_client.copy()
+ assert copied is not test_client
del copied
await asyncio.sleep(0.2)
- assert not client.is_closed()
+ assert not test_client.is_closed()
async def test_client_context_manager(self) -> None:
- client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True)
- async with client as c2:
- assert c2 is client
+ test_client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True)
+ async with test_client as c2:
+ assert c2 is test_client
assert not c2.is_closed()
- assert not client.is_closed()
- assert client.is_closed()
+ assert not test_client.is_closed()
+ assert test_client.is_closed()
@pytest.mark.respx(base_url=base_url)
- @pytest.mark.asyncio
- async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None:
+ async def test_client_response_validation_error(
+ self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop
+ ) -> None:
class Model(BaseModel):
foo: str
respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}}))
with pytest.raises(APIResponseValidationError) as exc:
- await self.client.get("/foo", cast_to=Model)
+ await async_client.get("/foo", cast_to=Model)
assert isinstance(exc.value.__cause__, ValidationError)
@@ -1529,7 +1756,6 @@ async def test_client_max_retries_validation(self) -> None:
)
@pytest.mark.respx(base_url=base_url)
- @pytest.mark.asyncio
async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
class Model(BaseModel):
name: str
@@ -1543,11 +1769,16 @@ class Model(BaseModel):
with pytest.raises(APIResponseValidationError):
await strict_client.get("/foo", cast_to=Model)
- client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=False)
+ non_strict_client = AsyncBeeperDesktop(
+ base_url=base_url, access_token=access_token, _strict_response_validation=False
+ )
- response = await client.get("/foo", cast_to=Model)
+ response = await non_strict_client.get("/foo", cast_to=Model)
assert isinstance(response, str) # type: ignore[unreachable]
+ await strict_client.close()
+ await non_strict_client.close()
+
@pytest.mark.parametrize(
"remaining_retries,retry_after,timeout",
[
@@ -1570,13 +1801,12 @@ class Model(BaseModel):
],
)
@mock.patch("time.time", mock.MagicMock(return_value=1696004797))
- @pytest.mark.asyncio
- async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None:
- client = AsyncBeeperDesktop(base_url=base_url, access_token=access_token, _strict_response_validation=True)
-
+ async def test_parse_retry_after_header(
+ self, remaining_retries: int, retry_after: str, timeout: float, async_client: AsyncBeeperDesktop
+ ) -> None:
headers = httpx.Headers({"retry-after": retry_after})
options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
- calculated = client._calculate_retry_timeout(remaining_retries, options, headers)
+ calculated = async_client._calculate_retry_timeout(remaining_retries, options, headers)
assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType]
@mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
@@ -1589,7 +1819,7 @@ async def test_retrying_timeout_errors_doesnt_leak(
with pytest.raises(APITimeoutError):
await async_client.accounts.with_streaming_response.list().__aenter__()
- assert _get_open_connections(self.client) == 0
+ assert _get_open_connections(async_client) == 0
@mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
@pytest.mark.respx(base_url=base_url)
@@ -1600,12 +1830,11 @@ async def test_retrying_status_errors_doesnt_leak(
with pytest.raises(APIStatusError):
await async_client.accounts.with_streaming_response.list().__aenter__()
- assert _get_open_connections(self.client) == 0
+ assert _get_open_connections(async_client) == 0
@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
@mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
@pytest.mark.respx(base_url=base_url)
- @pytest.mark.asyncio
@pytest.mark.parametrize("failure_mode", ["status", "exception"])
async def test_retries_taken(
self,
@@ -1637,7 +1866,6 @@ def retry_handler(_request: httpx.Request) -> httpx.Response:
@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
@mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
@pytest.mark.respx(base_url=base_url)
- @pytest.mark.asyncio
async def test_omit_retry_count_header(
self, async_client: AsyncBeeperDesktop, failures_before_success: int, respx_mock: MockRouter
) -> None:
@@ -1661,7 +1889,6 @@ def retry_handler(_request: httpx.Request) -> httpx.Response:
@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
@mock.patch("beeper_desktop_api._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
@pytest.mark.respx(base_url=base_url)
- @pytest.mark.asyncio
async def test_overwrite_retry_count_header(
self, async_client: AsyncBeeperDesktop, failures_before_success: int, respx_mock: MockRouter
) -> None:
@@ -1709,26 +1936,26 @@ async def test_default_client_creation(self) -> None:
)
@pytest.mark.respx(base_url=base_url)
- async def test_follow_redirects(self, respx_mock: MockRouter) -> None:
+ async def test_follow_redirects(self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop) -> None:
# Test that the default follow_redirects=True allows following redirects
respx_mock.post("/redirect").mock(
return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
)
respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"}))
- response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response)
+ response = await async_client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response)
assert response.status_code == 200
assert response.json() == {"status": "ok"}
@pytest.mark.respx(base_url=base_url)
- async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None:
+ async def test_follow_redirects_disabled(self, respx_mock: MockRouter, async_client: AsyncBeeperDesktop) -> None:
# Test that follow_redirects=False prevents following redirects
respx_mock.post("/redirect").mock(
return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
)
with pytest.raises(APIStatusError) as exc_info:
- await self.client.post(
+ await async_client.post(
"/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response
)
diff --git a/tests/test_models.py b/tests/test_models.py
index b275b2c..e71da85 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -9,7 +9,7 @@
from beeper_desktop_api._utils import PropertyInfo
from beeper_desktop_api._compat import PYDANTIC_V1, parse_obj, model_dump, model_json
-from beeper_desktop_api._models import BaseModel, construct_type
+from beeper_desktop_api._models import DISCRIMINATOR_CACHE, BaseModel, construct_type
class BasicModel(BaseModel):
@@ -809,7 +809,7 @@ class B(BaseModel):
UnionType = cast(Any, Union[A, B])
- assert not hasattr(UnionType, "__discriminator__")
+ assert not DISCRIMINATOR_CACHE.get(UnionType)
m = construct_type(
value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")])
@@ -818,7 +818,7 @@ class B(BaseModel):
assert m.type == "b"
assert m.data == "foo" # type: ignore[comparison-overlap]
- discriminator = UnionType.__discriminator__
+ discriminator = DISCRIMINATOR_CACHE.get(UnionType)
assert discriminator is not None
m = construct_type(
@@ -830,7 +830,7 @@ class B(BaseModel):
# if the discriminator details object stays the same between invocations then
# we hit the cache
- assert UnionType.__discriminator__ is discriminator
+ assert DISCRIMINATOR_CACHE.get(UnionType) is discriminator
@pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1")