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.
@@ -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.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"