outnoise 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- outnoise-1.0.0/PKG-INFO +244 -0
- outnoise-1.0.0/README.md +230 -0
- outnoise-1.0.0/pyproject.toml +30 -0
- outnoise-1.0.0/setup.cfg +4 -0
- outnoise-1.0.0/src/outnoise/__init__.py +15 -0
- outnoise-1.0.0/src/outnoise/_base.py +126 -0
- outnoise-1.0.0/src/outnoise/_version.py +1 -0
- outnoise-1.0.0/src/outnoise/async_client.py +99 -0
- outnoise-1.0.0/src/outnoise/client.py +101 -0
- outnoise-1.0.0/src/outnoise/exceptions.py +100 -0
- outnoise-1.0.0/src/outnoise/models.py +36 -0
- outnoise-1.0.0/src/outnoise/py.typed +0 -0
- outnoise-1.0.0/src/outnoise/resources.py +128 -0
- outnoise-1.0.0/src/outnoise.egg-info/PKG-INFO +244 -0
- outnoise-1.0.0/src/outnoise.egg-info/SOURCES.txt +24 -0
- outnoise-1.0.0/src/outnoise.egg-info/dependency_links.txt +1 -0
- outnoise-1.0.0/src/outnoise.egg-info/requires.txt +4 -0
- outnoise-1.0.0/src/outnoise.egg-info/top_level.txt +1 -0
- outnoise-1.0.0/tests/test_async.py +87 -0
- outnoise-1.0.0/tests/test_authentication.py +68 -0
- outnoise-1.0.0/tests/test_errors.py +28 -0
- outnoise-1.0.0/tests/test_live_backend.py +18 -0
- outnoise-1.0.0/tests/test_pagination.py +39 -0
- outnoise-1.0.0/tests/test_rate_limits.py +55 -0
- outnoise-1.0.0/tests/test_requests.py +113 -0
- outnoise-1.0.0/tests/test_resources.py +43 -0
outnoise-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: outnoise
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for the Outnoise Developer
|
|
5
|
+
Keywords: outnoise,sdk,api,social
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: Typing :: Typed
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: httpx<1.0,>=0.24
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
14
|
+
|
|
15
|
+
# outnoise-sdk
|
|
16
|
+
|
|
17
|
+
Official Python SDK for the **Outnoise Developer Platform** (formerly Chat X).
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from outnoise import Outnoise
|
|
21
|
+
|
|
22
|
+
client = Outnoise(api_key="YOUR_BOT_TOKEN")
|
|
23
|
+
print(client.users.me().username)
|
|
24
|
+
client.posts.create(content="Hello from Outnoise SDK!")
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- Package name: `outnoise-sdk` · import name: `outnoise`
|
|
28
|
+
- Python 3.8+ · built on [httpx](https://www.python-httpx.org/) · synchronous **and** asynchronous clients
|
|
29
|
+
- Typed exceptions, automatic pagination, automatic rate-limit handling, mocked-HTTP test suite
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install outnoise-sdk
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
> The package is not published yet. Until it is, install it from this folder (see [Local development](#local-development)).
|
|
40
|
+
|
|
41
|
+
## Getting started
|
|
42
|
+
|
|
43
|
+
1. On Outnoise open the Developer Portal (`/developers`) and **create an application**.
|
|
44
|
+
2. In **Permissions** enable the scopes your code needs (for example `identify`, `posts.read`, `posts.write`).
|
|
45
|
+
3. Either create a **bot** (Bot tab → copy the bot token) or use **OAuth2** (OAuth2 tab → Client ID/Secret, add a redirect URL).
|
|
46
|
+
4. Install the SDK and make a request:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import os
|
|
50
|
+
from outnoise import Outnoise
|
|
51
|
+
|
|
52
|
+
client = Outnoise(api_key=os.environ["OUTNOISE_API_KEY"]) # never hard-code credentials
|
|
53
|
+
|
|
54
|
+
me = client.users.me() # scope: identify
|
|
55
|
+
posts = client.posts.list(limit=10) # scope: posts.read
|
|
56
|
+
post = client.posts.create(content="Hello from Outnoise SDK!") # scope: posts.write
|
|
57
|
+
groups = client.groups.list(limit=10) # scope: groups.read
|
|
58
|
+
analytics = client.analytics.overview() # scope: analytics.read
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Responses are dictionaries that also allow attribute access: `me.username` and `me["username"]` both work.
|
|
62
|
+
|
|
63
|
+
## Authentication
|
|
64
|
+
|
|
65
|
+
| You pass | Sent as | Acts as | Scopes |
|
|
66
|
+
|---|---|---|---|
|
|
67
|
+
| `api_key="…"` (bot token) | `Authorization: Bot …` | your application's bot account | every scope enabled for the application |
|
|
68
|
+
| `access_token="…"` (OAuth2) | `Authorization: Bearer …` | the user who authorized your app | the scopes that user approved |
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
bot = Outnoise(api_key="onbt_…")
|
|
72
|
+
user = Outnoise(access_token="onat_…")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Pass **either** one, not both. Both can also come from the environment:
|
|
76
|
+
|
|
77
|
+
| Variable | Meaning |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `OUTNOISE_API_KEY` | bot token |
|
|
80
|
+
| `OUTNOISE_ACCESS_TOKEN` | OAuth2 access token |
|
|
81
|
+
| `OUTNOISE_BASE_URL` | API address, e.g. `http://127.0.0.1:8000/api/v1` |
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
client = Outnoise() # reads OUTNOISE_API_KEY / OUTNOISE_ACCESS_TOKEN / OUTNOISE_BASE_URL
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Getting an OAuth2 access token
|
|
88
|
+
|
|
89
|
+
Send the user to `https://<your-outnoise-host>/oauth2/authorize?client_id=APP_ID&response_type=code&redirect_uri=…&scope=identify%20posts.read&state=…`,
|
|
90
|
+
then exchange the returned `code` **on your server** (the client secret must never reach a browser):
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
import httpx
|
|
94
|
+
|
|
95
|
+
tokens = httpx.post("https://<your-outnoise-host>/oauth2/token", data={
|
|
96
|
+
"grant_type": "authorization_code", "code": code, "redirect_uri": "https://example.com/callback",
|
|
97
|
+
"client_id": APP_ID, "client_secret": CLIENT_SECRET,
|
|
98
|
+
}).json()
|
|
99
|
+
|
|
100
|
+
client = Outnoise(access_token=tokens["access_token"])
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Access tokens expire after 1 hour; renew with `grant_type=refresh_token`. Full details: `/developers/documentation/authentication`.
|
|
104
|
+
|
|
105
|
+
## Available resources
|
|
106
|
+
|
|
107
|
+
| Resource | Methods | Scope |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `client.applications` | `me()` | – |
|
|
110
|
+
| `client.users` | `me()`, `get(username)` | `identify`, `profile.read` |
|
|
111
|
+
| `client.posts` | `list(limit, offset, author)`, `iter(...)`, `get(id)`, `create(content, visibility, images, feeling)` | `posts.read`, `posts.write` |
|
|
112
|
+
| `client.groups` | `list(limit, offset, mine)`, `iter(...)`, `get(id)` | `groups.read` |
|
|
113
|
+
| `client.events` | `list(limit, offset, include_past)`, `iter(...)` | `events.read` |
|
|
114
|
+
| `client.messages` | `list(user, limit, offset)`, `iter(user)`, `send(recipient, content)` | `messages.read`, `messages.write` |
|
|
115
|
+
| `client.analytics` | `overview(days=28)` | `analytics.read` |
|
|
116
|
+
|
|
117
|
+
Notes: `visibility` is `public`, `followers`, `unlisted` or `private`. Bots may only direct-message users who authorized the application or messaged the bot first. Methods raise `ValueError` for bad arguments before any request is made.
|
|
118
|
+
|
|
119
|
+
## Pagination
|
|
120
|
+
|
|
121
|
+
List methods return a `Page` (a normal list with paging info):
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
page = client.posts.list(limit=10)
|
|
125
|
+
print(len(page), page.has_more, page.next_offset)
|
|
126
|
+
page2 = client.posts.list(limit=10, offset=page.next_offset)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
or iterate over everything — pages are fetched lazily:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
for post in client.posts.iter(author="alice", page_size=50, max_items=200):
|
|
133
|
+
print(post.content)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Error handling
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from outnoise import (Outnoise, OutnoiseError, AuthenticationError, MissingScopeError, NotFoundError,
|
|
140
|
+
RateLimitError, APIConnectionError)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
client.posts.create(content="hi")
|
|
144
|
+
except MissingScopeError as e:
|
|
145
|
+
print("Enable these scopes:", e.required_scopes)
|
|
146
|
+
except AuthenticationError:
|
|
147
|
+
print("Bad, expired or revoked credentials")
|
|
148
|
+
except RateLimitError as e:
|
|
149
|
+
print("Slow down, retry in", e.retry_after, "seconds")
|
|
150
|
+
except NotFoundError:
|
|
151
|
+
...
|
|
152
|
+
except APIConnectionError:
|
|
153
|
+
print("Could not reach Outnoise")
|
|
154
|
+
except OutnoiseError as e: # catches everything from this package
|
|
155
|
+
print(e) # e.g. "[400 content_too_long] Content too long (max 2500 characters)."
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
| Exception | When |
|
|
159
|
+
|---|---|
|
|
160
|
+
| `ConfigurationError` | missing/conflicting credentials, bad `base_url` |
|
|
161
|
+
| `APIConnectionError` / `APITimeoutError` | network failure / timeout |
|
|
162
|
+
| `InvalidRequestError` (400) | the API rejected the request |
|
|
163
|
+
| `AuthenticationError` (401) | invalid, expired or revoked token |
|
|
164
|
+
| `PermissionDeniedError` (403), `MissingScopeError` | not allowed / missing scope |
|
|
165
|
+
| `NotFoundError` (404) | unknown or invisible resource |
|
|
166
|
+
| `RateLimitError` (429) | rate limit exceeded (`.retry_after`) |
|
|
167
|
+
| `ServerError` (5xx) | server problem |
|
|
168
|
+
|
|
169
|
+
Every API error has `.status_code`, `.code`, `.message`, `.body` and `.headers`.
|
|
170
|
+
|
|
171
|
+
### Rate limits and retries
|
|
172
|
+
|
|
173
|
+
The API limits each token (reads 120/min, writes 30/min, messages 20/min). The SDK automatically:
|
|
174
|
+
|
|
175
|
+
- retries `429` responses after the `Retry-After` delay (also for `POST`, because a rate-limited request was not processed);
|
|
176
|
+
- retries `GET` requests on connection errors, timeouts and 502/503/504 with exponential backoff.
|
|
177
|
+
|
|
178
|
+
Control it with `Outnoise(max_retries=2, max_retry_wait=30)`; `max_retries=0` turns retrying off. If the server asks for a longer wait than `max_retry_wait`, `RateLimitError` is raised immediately.
|
|
179
|
+
|
|
180
|
+
## Async usage
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
import asyncio
|
|
184
|
+
from outnoise import AsyncOutnoise
|
|
185
|
+
|
|
186
|
+
async def main():
|
|
187
|
+
async with AsyncOutnoise(api_key="YOUR_BOT_TOKEN") as client:
|
|
188
|
+
me = await client.users.me()
|
|
189
|
+
post = await client.posts.create(content="Hello from async!")
|
|
190
|
+
async for p in client.posts.iter(max_items=100):
|
|
191
|
+
print(p.content)
|
|
192
|
+
|
|
193
|
+
asyncio.run(main())
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`AsyncOutnoise` has the same resources and options; every call is awaited and `iter()` is used with `async for`.
|
|
197
|
+
|
|
198
|
+
## Configuration
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
client = Outnoise(
|
|
202
|
+
api_key="…",
|
|
203
|
+
base_url="http://127.0.0.1:8000/api/v1", # custom API address (local testing, staging)
|
|
204
|
+
timeout=30, # seconds
|
|
205
|
+
max_retries=2,
|
|
206
|
+
)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The built-in default address is the beta deployment (`https://beta-chatx.hostmybotprojects.net/api/v1`). Change `DEFAULT_BASE_URL` in `src/outnoise/_base.py`, or pass `base_url` / `OUTNOISE_BASE_URL`, when you use another host. You can also hand over your own `httpx.Client` / `httpx.AsyncClient` with `http_client=` (proxies, custom transports).
|
|
210
|
+
|
|
211
|
+
## Local development
|
|
212
|
+
|
|
213
|
+
Install in editable mode — no PyPI needed:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
cd outnoise-sdk
|
|
217
|
+
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
218
|
+
pip install -e ".[dev]"
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Test it against your local Outnoise backend (use a **test** account/application, never production credentials):
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
from outnoise import Outnoise
|
|
225
|
+
client = Outnoise(api_key="TEST_KEY", base_url="http://127.0.0.1:8000/api/v1")
|
|
226
|
+
print(client.users.me())
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
(`python app.py` in the Outnoise project serves on port 3002 by default, so the local URL is usually `http://127.0.0.1:3002/api/v1`.)
|
|
230
|
+
|
|
231
|
+
## Testing
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
pytest
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The suite uses `httpx.MockTransport`, so it needs **no network and no credentials**. It covers authentication, request building, invalid credentials, API errors, pagination, rate limits, custom base URLs, every resource method and the async client.
|
|
238
|
+
`tests/test_live_backend.py` is an optional integration test that is skipped unless `OUTNOISE_TEST_BASE_URL` and `OUTNOISE_TEST_API_KEY` point at a local test backend.
|
|
239
|
+
|
|
240
|
+
## Security
|
|
241
|
+
|
|
242
|
+
- Keep bot tokens and client secrets in environment variables or a secrets manager — never in source control, browsers or mobile apps.
|
|
243
|
+
- Resetting a token or secret in the Developer Portal invalidates the old one immediately.
|
|
244
|
+
- Credentials are never included in exception messages or `repr()` output.
|
outnoise-1.0.0/README.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# outnoise-sdk
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the **Outnoise Developer Platform** (formerly Chat X).
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from outnoise import Outnoise
|
|
7
|
+
|
|
8
|
+
client = Outnoise(api_key="YOUR_BOT_TOKEN")
|
|
9
|
+
print(client.users.me().username)
|
|
10
|
+
client.posts.create(content="Hello from Outnoise SDK!")
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- Package name: `outnoise-sdk` · import name: `outnoise`
|
|
14
|
+
- Python 3.8+ · built on [httpx](https://www.python-httpx.org/) · synchronous **and** asynchronous clients
|
|
15
|
+
- Typed exceptions, automatic pagination, automatic rate-limit handling, mocked-HTTP test suite
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install outnoise-sdk
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
> The package is not published yet. Until it is, install it from this folder (see [Local development](#local-development)).
|
|
26
|
+
|
|
27
|
+
## Getting started
|
|
28
|
+
|
|
29
|
+
1. On Outnoise open the Developer Portal (`/developers`) and **create an application**.
|
|
30
|
+
2. In **Permissions** enable the scopes your code needs (for example `identify`, `posts.read`, `posts.write`).
|
|
31
|
+
3. Either create a **bot** (Bot tab → copy the bot token) or use **OAuth2** (OAuth2 tab → Client ID/Secret, add a redirect URL).
|
|
32
|
+
4. Install the SDK and make a request:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import os
|
|
36
|
+
from outnoise import Outnoise
|
|
37
|
+
|
|
38
|
+
client = Outnoise(api_key=os.environ["OUTNOISE_API_KEY"]) # never hard-code credentials
|
|
39
|
+
|
|
40
|
+
me = client.users.me() # scope: identify
|
|
41
|
+
posts = client.posts.list(limit=10) # scope: posts.read
|
|
42
|
+
post = client.posts.create(content="Hello from Outnoise SDK!") # scope: posts.write
|
|
43
|
+
groups = client.groups.list(limit=10) # scope: groups.read
|
|
44
|
+
analytics = client.analytics.overview() # scope: analytics.read
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Responses are dictionaries that also allow attribute access: `me.username` and `me["username"]` both work.
|
|
48
|
+
|
|
49
|
+
## Authentication
|
|
50
|
+
|
|
51
|
+
| You pass | Sent as | Acts as | Scopes |
|
|
52
|
+
|---|---|---|---|
|
|
53
|
+
| `api_key="…"` (bot token) | `Authorization: Bot …` | your application's bot account | every scope enabled for the application |
|
|
54
|
+
| `access_token="…"` (OAuth2) | `Authorization: Bearer …` | the user who authorized your app | the scopes that user approved |
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
bot = Outnoise(api_key="onbt_…")
|
|
58
|
+
user = Outnoise(access_token="onat_…")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Pass **either** one, not both. Both can also come from the environment:
|
|
62
|
+
|
|
63
|
+
| Variable | Meaning |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `OUTNOISE_API_KEY` | bot token |
|
|
66
|
+
| `OUTNOISE_ACCESS_TOKEN` | OAuth2 access token |
|
|
67
|
+
| `OUTNOISE_BASE_URL` | API address, e.g. `http://127.0.0.1:8000/api/v1` |
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
client = Outnoise() # reads OUTNOISE_API_KEY / OUTNOISE_ACCESS_TOKEN / OUTNOISE_BASE_URL
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Getting an OAuth2 access token
|
|
74
|
+
|
|
75
|
+
Send the user to `https://<your-outnoise-host>/oauth2/authorize?client_id=APP_ID&response_type=code&redirect_uri=…&scope=identify%20posts.read&state=…`,
|
|
76
|
+
then exchange the returned `code` **on your server** (the client secret must never reach a browser):
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import httpx
|
|
80
|
+
|
|
81
|
+
tokens = httpx.post("https://<your-outnoise-host>/oauth2/token", data={
|
|
82
|
+
"grant_type": "authorization_code", "code": code, "redirect_uri": "https://example.com/callback",
|
|
83
|
+
"client_id": APP_ID, "client_secret": CLIENT_SECRET,
|
|
84
|
+
}).json()
|
|
85
|
+
|
|
86
|
+
client = Outnoise(access_token=tokens["access_token"])
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Access tokens expire after 1 hour; renew with `grant_type=refresh_token`. Full details: `/developers/documentation/authentication`.
|
|
90
|
+
|
|
91
|
+
## Available resources
|
|
92
|
+
|
|
93
|
+
| Resource | Methods | Scope |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `client.applications` | `me()` | – |
|
|
96
|
+
| `client.users` | `me()`, `get(username)` | `identify`, `profile.read` |
|
|
97
|
+
| `client.posts` | `list(limit, offset, author)`, `iter(...)`, `get(id)`, `create(content, visibility, images, feeling)` | `posts.read`, `posts.write` |
|
|
98
|
+
| `client.groups` | `list(limit, offset, mine)`, `iter(...)`, `get(id)` | `groups.read` |
|
|
99
|
+
| `client.events` | `list(limit, offset, include_past)`, `iter(...)` | `events.read` |
|
|
100
|
+
| `client.messages` | `list(user, limit, offset)`, `iter(user)`, `send(recipient, content)` | `messages.read`, `messages.write` |
|
|
101
|
+
| `client.analytics` | `overview(days=28)` | `analytics.read` |
|
|
102
|
+
|
|
103
|
+
Notes: `visibility` is `public`, `followers`, `unlisted` or `private`. Bots may only direct-message users who authorized the application or messaged the bot first. Methods raise `ValueError` for bad arguments before any request is made.
|
|
104
|
+
|
|
105
|
+
## Pagination
|
|
106
|
+
|
|
107
|
+
List methods return a `Page` (a normal list with paging info):
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
page = client.posts.list(limit=10)
|
|
111
|
+
print(len(page), page.has_more, page.next_offset)
|
|
112
|
+
page2 = client.posts.list(limit=10, offset=page.next_offset)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
or iterate over everything — pages are fetched lazily:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
for post in client.posts.iter(author="alice", page_size=50, max_items=200):
|
|
119
|
+
print(post.content)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Error handling
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from outnoise import (Outnoise, OutnoiseError, AuthenticationError, MissingScopeError, NotFoundError,
|
|
126
|
+
RateLimitError, APIConnectionError)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
client.posts.create(content="hi")
|
|
130
|
+
except MissingScopeError as e:
|
|
131
|
+
print("Enable these scopes:", e.required_scopes)
|
|
132
|
+
except AuthenticationError:
|
|
133
|
+
print("Bad, expired or revoked credentials")
|
|
134
|
+
except RateLimitError as e:
|
|
135
|
+
print("Slow down, retry in", e.retry_after, "seconds")
|
|
136
|
+
except NotFoundError:
|
|
137
|
+
...
|
|
138
|
+
except APIConnectionError:
|
|
139
|
+
print("Could not reach Outnoise")
|
|
140
|
+
except OutnoiseError as e: # catches everything from this package
|
|
141
|
+
print(e) # e.g. "[400 content_too_long] Content too long (max 2500 characters)."
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
| Exception | When |
|
|
145
|
+
|---|---|
|
|
146
|
+
| `ConfigurationError` | missing/conflicting credentials, bad `base_url` |
|
|
147
|
+
| `APIConnectionError` / `APITimeoutError` | network failure / timeout |
|
|
148
|
+
| `InvalidRequestError` (400) | the API rejected the request |
|
|
149
|
+
| `AuthenticationError` (401) | invalid, expired or revoked token |
|
|
150
|
+
| `PermissionDeniedError` (403), `MissingScopeError` | not allowed / missing scope |
|
|
151
|
+
| `NotFoundError` (404) | unknown or invisible resource |
|
|
152
|
+
| `RateLimitError` (429) | rate limit exceeded (`.retry_after`) |
|
|
153
|
+
| `ServerError` (5xx) | server problem |
|
|
154
|
+
|
|
155
|
+
Every API error has `.status_code`, `.code`, `.message`, `.body` and `.headers`.
|
|
156
|
+
|
|
157
|
+
### Rate limits and retries
|
|
158
|
+
|
|
159
|
+
The API limits each token (reads 120/min, writes 30/min, messages 20/min). The SDK automatically:
|
|
160
|
+
|
|
161
|
+
- retries `429` responses after the `Retry-After` delay (also for `POST`, because a rate-limited request was not processed);
|
|
162
|
+
- retries `GET` requests on connection errors, timeouts and 502/503/504 with exponential backoff.
|
|
163
|
+
|
|
164
|
+
Control it with `Outnoise(max_retries=2, max_retry_wait=30)`; `max_retries=0` turns retrying off. If the server asks for a longer wait than `max_retry_wait`, `RateLimitError` is raised immediately.
|
|
165
|
+
|
|
166
|
+
## Async usage
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
import asyncio
|
|
170
|
+
from outnoise import AsyncOutnoise
|
|
171
|
+
|
|
172
|
+
async def main():
|
|
173
|
+
async with AsyncOutnoise(api_key="YOUR_BOT_TOKEN") as client:
|
|
174
|
+
me = await client.users.me()
|
|
175
|
+
post = await client.posts.create(content="Hello from async!")
|
|
176
|
+
async for p in client.posts.iter(max_items=100):
|
|
177
|
+
print(p.content)
|
|
178
|
+
|
|
179
|
+
asyncio.run(main())
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`AsyncOutnoise` has the same resources and options; every call is awaited and `iter()` is used with `async for`.
|
|
183
|
+
|
|
184
|
+
## Configuration
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
client = Outnoise(
|
|
188
|
+
api_key="…",
|
|
189
|
+
base_url="http://127.0.0.1:8000/api/v1", # custom API address (local testing, staging)
|
|
190
|
+
timeout=30, # seconds
|
|
191
|
+
max_retries=2,
|
|
192
|
+
)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The built-in default address is the beta deployment (`https://beta-chatx.hostmybotprojects.net/api/v1`). Change `DEFAULT_BASE_URL` in `src/outnoise/_base.py`, or pass `base_url` / `OUTNOISE_BASE_URL`, when you use another host. You can also hand over your own `httpx.Client` / `httpx.AsyncClient` with `http_client=` (proxies, custom transports).
|
|
196
|
+
|
|
197
|
+
## Local development
|
|
198
|
+
|
|
199
|
+
Install in editable mode — no PyPI needed:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
cd outnoise-sdk
|
|
203
|
+
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
204
|
+
pip install -e ".[dev]"
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Test it against your local Outnoise backend (use a **test** account/application, never production credentials):
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
from outnoise import Outnoise
|
|
211
|
+
client = Outnoise(api_key="TEST_KEY", base_url="http://127.0.0.1:8000/api/v1")
|
|
212
|
+
print(client.users.me())
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
(`python app.py` in the Outnoise project serves on port 3002 by default, so the local URL is usually `http://127.0.0.1:3002/api/v1`.)
|
|
216
|
+
|
|
217
|
+
## Testing
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
pytest
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The suite uses `httpx.MockTransport`, so it needs **no network and no credentials**. It covers authentication, request building, invalid credentials, API errors, pagination, rate limits, custom base URLs, every resource method and the async client.
|
|
224
|
+
`tests/test_live_backend.py` is an optional integration test that is skipped unless `OUTNOISE_TEST_BASE_URL` and `OUTNOISE_TEST_API_KEY` point at a local test backend.
|
|
225
|
+
|
|
226
|
+
## Security
|
|
227
|
+
|
|
228
|
+
- Keep bot tokens and client secrets in environment variables or a secrets manager — never in source control, browsers or mobile apps.
|
|
229
|
+
- Resetting a token or secret in the Developer Portal invalidates the old one immediately.
|
|
230
|
+
- Credentials are never included in exception messages or `repr()` output.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "outnoise"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Python SDK for the Outnoise Developer"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
dependencies = ["httpx>=0.24,<1.0"]
|
|
12
|
+
keywords = ["outnoise", "sdk", "api", "social"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = ["pytest>=7"]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["src"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.package-data]
|
|
26
|
+
outnoise = ["py.typed"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
testpaths = ["tests"]
|
|
30
|
+
pythonpath = ["src", "tests"]
|
outnoise-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Official Python SDK for the Outnoise Developer Platform."""
|
|
2
|
+
from ._version import __version__
|
|
3
|
+
from .async_client import AsyncOutnoise
|
|
4
|
+
from .client import Outnoise
|
|
5
|
+
from .exceptions import (APIConnectionError, APIStatusError, APITimeoutError, AuthenticationError,
|
|
6
|
+
ConfigurationError, InvalidRequestError, MissingScopeError, NotFoundError,
|
|
7
|
+
OutnoiseError, PermissionDeniedError, RateLimitError, ServerError)
|
|
8
|
+
from .models import AttrDict, Page
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Outnoise", "AsyncOutnoise", "Page", "AttrDict", "__version__",
|
|
12
|
+
"OutnoiseError", "ConfigurationError", "APIConnectionError", "APITimeoutError", "APIStatusError",
|
|
13
|
+
"InvalidRequestError", "AuthenticationError", "PermissionDeniedError", "MissingScopeError",
|
|
14
|
+
"NotFoundError", "RateLimitError", "ServerError",
|
|
15
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Configuration, request building and response handling shared by the sync and async clients."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import random
|
|
5
|
+
import time # noqa: F401 (patched in tests)
|
|
6
|
+
from typing import Any, Dict, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from .exceptions import APIStatusError, ConfigurationError, OutnoiseError, error_for_status
|
|
12
|
+
from .models import Page, wrap
|
|
13
|
+
|
|
14
|
+
# The beta deployment. Override with base_url=... or OUTNOISE_BASE_URL once you use another host.
|
|
15
|
+
DEFAULT_BASE_URL = "https://beta-chatx.hostmybotprojects.net/api/v1"
|
|
16
|
+
DEFAULT_TIMEOUT = 30.0
|
|
17
|
+
DEFAULT_MAX_RETRIES = 2
|
|
18
|
+
MAX_RETRY_WAIT = 30.0 # never sleep longer than this for a single retry
|
|
19
|
+
_RETRY_STATUS = (502, 503, 504) # transient server errors; retried for GET only
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClientConfig:
|
|
23
|
+
def __init__(self, api_key, access_token, base_url, timeout, max_retries, max_retry_wait):
|
|
24
|
+
api_key = api_key or os.environ.get("OUTNOISE_API_KEY") or None
|
|
25
|
+
access_token = access_token or os.environ.get("OUTNOISE_ACCESS_TOKEN") or None
|
|
26
|
+
if api_key and access_token:
|
|
27
|
+
raise ConfigurationError("Pass either api_key (bot token) or access_token (OAuth2), not both.")
|
|
28
|
+
if not api_key and not access_token:
|
|
29
|
+
raise ConfigurationError(
|
|
30
|
+
"No credentials. Pass api_key=... (bot token) or access_token=... (OAuth2), "
|
|
31
|
+
"or set OUTNOISE_API_KEY / OUTNOISE_ACCESS_TOKEN.")
|
|
32
|
+
base_url = (base_url or os.environ.get("OUTNOISE_BASE_URL") or DEFAULT_BASE_URL).strip().rstrip("/")
|
|
33
|
+
if not base_url.startswith(("http://", "https://")):
|
|
34
|
+
raise ConfigurationError(f"base_url must start with http:// or https:// (got {base_url!r}).")
|
|
35
|
+
if timeout is not None and timeout <= 0:
|
|
36
|
+
raise ConfigurationError("timeout must be positive.")
|
|
37
|
+
if max_retries < 0:
|
|
38
|
+
raise ConfigurationError("max_retries must be 0 or greater.")
|
|
39
|
+
self.base_url = base_url
|
|
40
|
+
self.timeout = DEFAULT_TIMEOUT if timeout is None else timeout
|
|
41
|
+
self.max_retries = max_retries
|
|
42
|
+
self.max_retry_wait = max_retry_wait
|
|
43
|
+
self.headers = {
|
|
44
|
+
"Authorization": ("Bot " + api_key) if api_key else ("Bearer " + access_token),
|
|
45
|
+
"Accept": "application/json",
|
|
46
|
+
"User-Agent": f"outnoise-python/{__version__}",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def __repr__(self): # never print credentials
|
|
50
|
+
return f"<ClientConfig base_url={self.base_url!r}>"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def clean_params(params: Optional[Dict[str, Any]]) -> Dict[str, str]:
|
|
54
|
+
out = {}
|
|
55
|
+
for k, v in (params or {}).items():
|
|
56
|
+
if v is None:
|
|
57
|
+
continue
|
|
58
|
+
out[k] = ("1" if v else "0") if isinstance(v, bool) else str(v)
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def retry_delay(attempt: int, retry_after: Optional[float], max_wait: float) -> Optional[float]:
|
|
63
|
+
"""Seconds to wait before retry number `attempt` (0-based), or None if we should give up."""
|
|
64
|
+
if retry_after is not None:
|
|
65
|
+
return None if retry_after > max_wait else max(0.0, retry_after)
|
|
66
|
+
return min(max_wait, 0.5 * (2 ** attempt)) * (0.75 + random.random() / 2)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_retry_after(headers) -> Optional[float]:
|
|
70
|
+
raw = headers.get("Retry-After") if headers is not None else None
|
|
71
|
+
try:
|
|
72
|
+
return float(raw) if raw is not None else None
|
|
73
|
+
except (TypeError, ValueError):
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_error(resp: "httpx.Response") -> APIStatusError:
|
|
78
|
+
body: Any = None
|
|
79
|
+
try:
|
|
80
|
+
body = resp.json()
|
|
81
|
+
except ValueError:
|
|
82
|
+
pass
|
|
83
|
+
message, code = None, None
|
|
84
|
+
if isinstance(body, dict):
|
|
85
|
+
err = body.get("error")
|
|
86
|
+
if isinstance(err, dict): # tolerate {"error": {"message": ...}}
|
|
87
|
+
message, code = err.get("message"), err.get("code")
|
|
88
|
+
else:
|
|
89
|
+
message = err if isinstance(err, str) else body.get("message")
|
|
90
|
+
code = body.get("code")
|
|
91
|
+
if not code and isinstance(err, str) and body.get("error_description"): # OAuth2 style
|
|
92
|
+
code, message = err, body["error_description"]
|
|
93
|
+
if not message:
|
|
94
|
+
snippet = (resp.text or "").strip().replace("\n", " ")[:200]
|
|
95
|
+
message = snippet or f"HTTP {resp.status_code}"
|
|
96
|
+
return error_for_status(resp.status_code, message, code, body, resp.headers, parse_retry_after(resp.headers))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_success(resp: "httpx.Response") -> Any:
|
|
100
|
+
try:
|
|
101
|
+
body = resp.json()
|
|
102
|
+
except ValueError:
|
|
103
|
+
raise OutnoiseError(f"The API returned a non-JSON response (HTTP {resp.status_code}).") from None
|
|
104
|
+
if not isinstance(body, dict) or "data" not in body:
|
|
105
|
+
raise OutnoiseError("Unexpected API response shape (missing 'data').")
|
|
106
|
+
data = wrap(body["data"])
|
|
107
|
+
if isinstance(body.get("paging"), dict) and isinstance(data, list):
|
|
108
|
+
return Page(data, body["paging"])
|
|
109
|
+
return data
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def request_kwargs(method: str, path: str, params, json_body) -> Tuple[str, Dict[str, Any]]:
|
|
113
|
+
kw: Dict[str, Any] = {}
|
|
114
|
+
q = clean_params(params)
|
|
115
|
+
if q:
|
|
116
|
+
kw["params"] = q
|
|
117
|
+
if json_body is not None:
|
|
118
|
+
kw["json"] = json_body
|
|
119
|
+
return method.upper(), kw
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def validate_page_args(limit: int, offset: int):
|
|
123
|
+
if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 50:
|
|
124
|
+
raise ValueError("limit must be an integer between 1 and 50.")
|
|
125
|
+
if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
|
|
126
|
+
raise ValueError("offset must be an integer of 0 or greater.")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|