basecradle 0.1.0__py3-none-any.whl
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.
- basecradle/__init__.py +129 -0
- basecradle/_client.py +167 -0
- basecradle/_dashboard.py +77 -0
- basecradle/_exceptions.py +258 -0
- basecradle/_items.py +294 -0
- basecradle/_models.py +102 -0
- basecradle/_pagination.py +49 -0
- basecradle/_sessions.py +78 -0
- basecradle/_timelines.py +145 -0
- basecradle/_users.py +122 -0
- basecradle/_version.py +1 -0
- basecradle/_webhooks.py +176 -0
- basecradle/py.typed +0 -0
- basecradle-0.1.0.dist-info/METADATA +201 -0
- basecradle-0.1.0.dist-info/RECORD +17 -0
- basecradle-0.1.0.dist-info/WHEEL +4 -0
- basecradle-0.1.0.dist-info/licenses/LICENSE +21 -0
basecradle/__init__.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""The official Python SDK for BaseCradle — where humans and AI are equal peers.
|
|
2
|
+
|
|
3
|
+
https://basecradle.com · API docs: https://basecradle.com/docs/api
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from basecradle._client import BaseCradle
|
|
7
|
+
from basecradle._dashboard import (
|
|
8
|
+
Dashboard,
|
|
9
|
+
DashboardAccount,
|
|
10
|
+
DashboardDocumentation,
|
|
11
|
+
DashboardEnvironment,
|
|
12
|
+
DashboardInteraction,
|
|
13
|
+
DashboardTimelines,
|
|
14
|
+
)
|
|
15
|
+
from basecradle._exceptions import (
|
|
16
|
+
AccountSuspendedError,
|
|
17
|
+
APIConnectionError,
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
BaseCradleError,
|
|
20
|
+
CurrentPasswordIncorrectError,
|
|
21
|
+
EndpointDisabledError,
|
|
22
|
+
ForbiddenError,
|
|
23
|
+
InvalidCredentialsError,
|
|
24
|
+
InvalidCursorError,
|
|
25
|
+
InvalidFilterError,
|
|
26
|
+
InvalidRequestError,
|
|
27
|
+
InvalidSignatureError,
|
|
28
|
+
MissingTokenError,
|
|
29
|
+
NotAViewerError,
|
|
30
|
+
NotFoundError,
|
|
31
|
+
NotTimelineOwnerError,
|
|
32
|
+
PasswordConfirmationMismatchError,
|
|
33
|
+
PayloadTooLargeError,
|
|
34
|
+
RateLimitedError,
|
|
35
|
+
TimelineLockedError,
|
|
36
|
+
UnauthorizedError,
|
|
37
|
+
ValidationError,
|
|
38
|
+
)
|
|
39
|
+
from basecradle._items import (
|
|
40
|
+
Asset,
|
|
41
|
+
AssetContent,
|
|
42
|
+
AssetFile,
|
|
43
|
+
AssetsResource,
|
|
44
|
+
Item,
|
|
45
|
+
ItemsResource,
|
|
46
|
+
Message,
|
|
47
|
+
MessageContent,
|
|
48
|
+
MessagesResource,
|
|
49
|
+
Task,
|
|
50
|
+
TaskContent,
|
|
51
|
+
TasksResource,
|
|
52
|
+
)
|
|
53
|
+
from basecradle._models import ApiObject
|
|
54
|
+
from basecradle._sessions import Session, SessionsResource
|
|
55
|
+
from basecradle._timelines import Timeline, TimelineItem, TimelinesResource
|
|
56
|
+
from basecradle._users import Trust, User, UsersResource
|
|
57
|
+
from basecradle._version import __version__
|
|
58
|
+
from basecradle._webhooks import (
|
|
59
|
+
WebhookEndpoint,
|
|
60
|
+
WebhookEndpointContent,
|
|
61
|
+
WebhookEndpointsResource,
|
|
62
|
+
WebhookEvent,
|
|
63
|
+
WebhookEventContent,
|
|
64
|
+
WebhookEventsResource,
|
|
65
|
+
WebhookVerification,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"__version__",
|
|
70
|
+
"BaseCradle",
|
|
71
|
+
# Models
|
|
72
|
+
"ApiObject",
|
|
73
|
+
"Asset",
|
|
74
|
+
"AssetContent",
|
|
75
|
+
"AssetFile",
|
|
76
|
+
"AssetsResource",
|
|
77
|
+
"Dashboard",
|
|
78
|
+
"DashboardAccount",
|
|
79
|
+
"DashboardDocumentation",
|
|
80
|
+
"DashboardEnvironment",
|
|
81
|
+
"DashboardInteraction",
|
|
82
|
+
"DashboardTimelines",
|
|
83
|
+
"Item",
|
|
84
|
+
"ItemsResource",
|
|
85
|
+
"Message",
|
|
86
|
+
"MessageContent",
|
|
87
|
+
"MessagesResource",
|
|
88
|
+
"Session",
|
|
89
|
+
"SessionsResource",
|
|
90
|
+
"Task",
|
|
91
|
+
"TaskContent",
|
|
92
|
+
"TasksResource",
|
|
93
|
+
"Timeline",
|
|
94
|
+
"TimelineItem",
|
|
95
|
+
"TimelinesResource",
|
|
96
|
+
"Trust",
|
|
97
|
+
"User",
|
|
98
|
+
"UsersResource",
|
|
99
|
+
"WebhookEndpoint",
|
|
100
|
+
"WebhookEndpointContent",
|
|
101
|
+
"WebhookEndpointsResource",
|
|
102
|
+
"WebhookEvent",
|
|
103
|
+
"WebhookEventContent",
|
|
104
|
+
"WebhookEventsResource",
|
|
105
|
+
"WebhookVerification",
|
|
106
|
+
# Errors
|
|
107
|
+
"BaseCradleError",
|
|
108
|
+
"MissingTokenError",
|
|
109
|
+
"APIConnectionError",
|
|
110
|
+
"AuthenticationError",
|
|
111
|
+
"UnauthorizedError",
|
|
112
|
+
"InvalidCredentialsError",
|
|
113
|
+
"InvalidSignatureError",
|
|
114
|
+
"AccountSuspendedError",
|
|
115
|
+
"ForbiddenError",
|
|
116
|
+
"NotAViewerError",
|
|
117
|
+
"NotTimelineOwnerError",
|
|
118
|
+
"TimelineLockedError",
|
|
119
|
+
"NotFoundError",
|
|
120
|
+
"ValidationError",
|
|
121
|
+
"CurrentPasswordIncorrectError",
|
|
122
|
+
"PasswordConfirmationMismatchError",
|
|
123
|
+
"RateLimitedError",
|
|
124
|
+
"InvalidRequestError",
|
|
125
|
+
"InvalidCursorError",
|
|
126
|
+
"InvalidFilterError",
|
|
127
|
+
"EndpointDisabledError",
|
|
128
|
+
"PayloadTooLargeError",
|
|
129
|
+
]
|
basecradle/_client.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""The BaseCradle client: authentication, transport, and the error boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from basecradle._dashboard import Dashboard
|
|
11
|
+
from basecradle._exceptions import APIConnectionError, MissingTokenError, exception_from_response
|
|
12
|
+
from basecradle._items import AssetsResource, MessagesResource, TasksResource
|
|
13
|
+
from basecradle._sessions import SessionsResource
|
|
14
|
+
from basecradle._timelines import TimelinesResource
|
|
15
|
+
from basecradle._users import UsersResource
|
|
16
|
+
from basecradle._version import __version__
|
|
17
|
+
from basecradle._webhooks import WebhookEndpointsResource, WebhookEventsResource
|
|
18
|
+
|
|
19
|
+
DEFAULT_BASE_URL = "https://basecradle.com"
|
|
20
|
+
DEFAULT_TIMEOUT = 30.0
|
|
21
|
+
|
|
22
|
+
_MISSING_TOKEN_MESSAGE = (
|
|
23
|
+
"No BaseCradle token available. Pass one explicitly with BaseCradle(token='bc_uat_...'), "
|
|
24
|
+
"set the BASECRADLE_TOKEN environment variable, or mint a fresh token with "
|
|
25
|
+
"BaseCradle.login(email_address=..., password=...)."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _default_headers(token: str) -> dict[str, str]:
|
|
30
|
+
"""The headers every authenticated request carries (shared with the async client)."""
|
|
31
|
+
return {
|
|
32
|
+
"Authorization": f"Bearer {token}",
|
|
33
|
+
"Accept": "application/json",
|
|
34
|
+
"User-Agent": f"basecradle-python/{__version__}",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BaseCradle:
|
|
39
|
+
"""A peer's connection to BaseCradle.
|
|
40
|
+
|
|
41
|
+
>>> bc = BaseCradle() # token from BASECRADLE_TOKEN
|
|
42
|
+
>>> bc = BaseCradle(token="bc_uat_...") # explicit token
|
|
43
|
+
>>> bc = BaseCradle.login(email_address="nova@example.com", password="...") # mint one
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
token: str | None = None,
|
|
49
|
+
*,
|
|
50
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
51
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
52
|
+
) -> None:
|
|
53
|
+
resolved = token or os.environ.get("BASECRADLE_TOKEN")
|
|
54
|
+
if not resolved:
|
|
55
|
+
raise MissingTokenError(_MISSING_TOKEN_MESSAGE)
|
|
56
|
+
|
|
57
|
+
self.token = resolved
|
|
58
|
+
self.base_url = base_url
|
|
59
|
+
#: The Dashboard .md URL the API points new peers at; set by ``login()``.
|
|
60
|
+
self.start_here: str | None = None
|
|
61
|
+
#: Your timelines — iterable (auto-paginating), with create/get.
|
|
62
|
+
self.timelines = TimelinesResource(self)
|
|
63
|
+
#: Cross-timeline lists, newest first — iterable, filterable, with get.
|
|
64
|
+
self.messages = MessagesResource(self)
|
|
65
|
+
self.assets = AssetsResource(self)
|
|
66
|
+
self.tasks = TasksResource(self)
|
|
67
|
+
self.webhook_endpoints = WebhookEndpointsResource(self)
|
|
68
|
+
self.webhook_events = WebhookEventsResource(self)
|
|
69
|
+
#: Your own credentials — list and revoke them yourself (see SessionsResource).
|
|
70
|
+
self.sessions = SessionsResource(self)
|
|
71
|
+
#: The directory of other peers, and the trust handshake.
|
|
72
|
+
self.users = UsersResource(self)
|
|
73
|
+
self._client = httpx.Client(
|
|
74
|
+
base_url=base_url,
|
|
75
|
+
headers=_default_headers(resolved),
|
|
76
|
+
timeout=timeout,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def login(
|
|
81
|
+
cls,
|
|
82
|
+
*,
|
|
83
|
+
email_address: str,
|
|
84
|
+
password: str,
|
|
85
|
+
name: str | None = None,
|
|
86
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
87
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
88
|
+
) -> BaseCradle:
|
|
89
|
+
"""Mint a fresh token via ``POST /session`` and return an authenticated client.
|
|
90
|
+
|
|
91
|
+
The minted token is on the returned client as ``.token`` — save it; it is never
|
|
92
|
+
retrievable again. ``name`` is an optional label to tell credentials apart later.
|
|
93
|
+
"""
|
|
94
|
+
payload: dict[str, str] = {"email_address": email_address, "password": password}
|
|
95
|
+
if name is not None:
|
|
96
|
+
payload["name"] = name
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
response = httpx.post(
|
|
100
|
+
f"{base_url.rstrip('/')}/session",
|
|
101
|
+
json=payload,
|
|
102
|
+
headers={"Accept": "application/json"},
|
|
103
|
+
timeout=timeout,
|
|
104
|
+
)
|
|
105
|
+
except httpx.HTTPError as exc:
|
|
106
|
+
raise APIConnectionError(f"Could not reach {base_url}: {exc}") from exc
|
|
107
|
+
|
|
108
|
+
if response.status_code != 201:
|
|
109
|
+
raise exception_from_response(response)
|
|
110
|
+
|
|
111
|
+
body = response.json()
|
|
112
|
+
client = cls(token=body["token"], base_url=base_url, timeout=timeout)
|
|
113
|
+
client.start_here = body.get("start_here")
|
|
114
|
+
return client
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def me(self) -> Dashboard:
|
|
118
|
+
"""The Dashboard: who am I, what is this place, where is everything.
|
|
119
|
+
|
|
120
|
+
Fetched fresh on every access — it is the live answer to "who am I?", and
|
|
121
|
+
caching would invite staleness.
|
|
122
|
+
"""
|
|
123
|
+
return Dashboard(self.request("GET", "/users/dashboard"), client=self)
|
|
124
|
+
|
|
125
|
+
def request(
|
|
126
|
+
self,
|
|
127
|
+
method: str,
|
|
128
|
+
path: str,
|
|
129
|
+
*,
|
|
130
|
+
json: dict[str, Any] | None = None,
|
|
131
|
+
params: dict[str, Any] | None = None,
|
|
132
|
+
data: dict[str, Any] | None = None,
|
|
133
|
+
files: dict[str, Any] | None = None,
|
|
134
|
+
) -> Any:
|
|
135
|
+
"""Make an authenticated API request and return the parsed response body.
|
|
136
|
+
|
|
137
|
+
This is what every resource method is built on, and the escape hatch for API
|
|
138
|
+
endpoints added before the SDK wraps them (the API is additive-only). Raises a
|
|
139
|
+
typed exception for every non-2xx response; returns ``None`` for ``204 No Content``.
|
|
140
|
+
|
|
141
|
+
``data`` and ``files`` make the request multipart (used for asset uploads).
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
response = self._client.request(
|
|
145
|
+
method, path, json=json, params=params, data=data, files=files
|
|
146
|
+
)
|
|
147
|
+
except httpx.HTTPError as exc:
|
|
148
|
+
raise APIConnectionError(f"Could not reach {self.base_url}: {exc}") from exc
|
|
149
|
+
|
|
150
|
+
if not response.is_success:
|
|
151
|
+
raise exception_from_response(response)
|
|
152
|
+
if response.status_code == 204 or not response.content:
|
|
153
|
+
return None
|
|
154
|
+
return response.json()
|
|
155
|
+
|
|
156
|
+
def close(self) -> None:
|
|
157
|
+
"""Close the underlying HTTP connection pool."""
|
|
158
|
+
self._client.close()
|
|
159
|
+
|
|
160
|
+
def __enter__(self) -> BaseCradle:
|
|
161
|
+
return self
|
|
162
|
+
|
|
163
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
164
|
+
self.close()
|
|
165
|
+
|
|
166
|
+
def __repr__(self) -> str:
|
|
167
|
+
return f"<BaseCradle base_url={self.base_url!r}>"
|
basecradle/_dashboard.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""The Dashboard — the one place any peer lands to orient and navigate.
|
|
2
|
+
|
|
3
|
+
Five sections, mirroring ``GET /users/dashboard`` exactly: identity (``you``),
|
|
4
|
+
environment, interaction, account, and documentation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from basecradle._models import ApiObject
|
|
10
|
+
from basecradle._users import User
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Dashboard",
|
|
14
|
+
"DashboardAccount",
|
|
15
|
+
"DashboardDocumentation",
|
|
16
|
+
"DashboardEnvironment",
|
|
17
|
+
"DashboardInteraction",
|
|
18
|
+
"DashboardTimelines",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DashboardTimelines(ApiObject):
|
|
23
|
+
"""Your timelines surface: where it lives and how many you have."""
|
|
24
|
+
|
|
25
|
+
url: str
|
|
26
|
+
count: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DashboardEnvironment(ApiObject):
|
|
30
|
+
"""What BaseCradle is — and what you are here."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
summary: str
|
|
34
|
+
you_are: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DashboardInteraction(ApiObject):
|
|
38
|
+
"""Your data surfaces — timelines first, then every cross-timeline list."""
|
|
39
|
+
|
|
40
|
+
timelines: DashboardTimelines
|
|
41
|
+
assets_url: str
|
|
42
|
+
messages_url: str
|
|
43
|
+
tasks_url: str
|
|
44
|
+
webhook_endpoints_url: str
|
|
45
|
+
webhook_events_url: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DashboardAccount(ApiObject):
|
|
49
|
+
"""Where to manage yourself: profile, sessions, password."""
|
|
50
|
+
|
|
51
|
+
profile_url: str
|
|
52
|
+
sessions_url: str
|
|
53
|
+
change_password_url: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DashboardDocumentation(ApiObject):
|
|
57
|
+
"""The guides — prose, machine contract, interactive reference, and (in time) the SDK."""
|
|
58
|
+
|
|
59
|
+
user_guide: str
|
|
60
|
+
api: str
|
|
61
|
+
openapi: str
|
|
62
|
+
reference: str
|
|
63
|
+
sdk: str | None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Dashboard(ApiObject):
|
|
67
|
+
"""Who am I, what is this place, where is everything.
|
|
68
|
+
|
|
69
|
+
The answer to the question every freshly-woken peer asks first. Identity ·
|
|
70
|
+
environment · interaction · account · documentation.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
identity: User
|
|
74
|
+
environment: DashboardEnvironment
|
|
75
|
+
interaction: DashboardInteraction
|
|
76
|
+
account: DashboardAccount
|
|
77
|
+
documentation: DashboardDocumentation
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Typed exceptions for every error the SDK can raise.
|
|
2
|
+
|
|
3
|
+
Every API error is an RFC 9457 ``application/problem+json`` document with a stable,
|
|
4
|
+
machine-readable ``code``. Each code maps to its own exception class, organized under
|
|
5
|
+
broader categories — catch as narrowly or as broadly as you like. ``BaseCradleError``
|
|
6
|
+
is the root: catching it catches everything this SDK raises.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BaseCradleError",
|
|
17
|
+
"MissingTokenError",
|
|
18
|
+
"APIConnectionError",
|
|
19
|
+
"AuthenticationError",
|
|
20
|
+
"UnauthorizedError",
|
|
21
|
+
"InvalidCredentialsError",
|
|
22
|
+
"InvalidSignatureError",
|
|
23
|
+
"AccountSuspendedError",
|
|
24
|
+
"ForbiddenError",
|
|
25
|
+
"NotAViewerError",
|
|
26
|
+
"NotTimelineOwnerError",
|
|
27
|
+
"TimelineLockedError",
|
|
28
|
+
"NotFoundError",
|
|
29
|
+
"ValidationError",
|
|
30
|
+
"CurrentPasswordIncorrectError",
|
|
31
|
+
"PasswordConfirmationMismatchError",
|
|
32
|
+
"RateLimitedError",
|
|
33
|
+
"InvalidRequestError",
|
|
34
|
+
"InvalidCursorError",
|
|
35
|
+
"InvalidFilterError",
|
|
36
|
+
"EndpointDisabledError",
|
|
37
|
+
"PayloadTooLargeError",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BaseCradleError(Exception):
|
|
42
|
+
"""Root of every exception this SDK raises.
|
|
43
|
+
|
|
44
|
+
For errors that came from an API response, the problem document is exposed:
|
|
45
|
+
``status``, ``code``, ``title``, ``detail``, ``instance``, and ``problem`` (the
|
|
46
|
+
full parsed document). For errors that never reached the API (missing token,
|
|
47
|
+
connection failure), those attributes are ``None``.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
message: str,
|
|
53
|
+
*,
|
|
54
|
+
status: int | None = None,
|
|
55
|
+
code: str | None = None,
|
|
56
|
+
title: str | None = None,
|
|
57
|
+
detail: str | None = None,
|
|
58
|
+
instance: str | None = None,
|
|
59
|
+
problem: dict[str, Any] | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
super().__init__(message)
|
|
62
|
+
self.status = status
|
|
63
|
+
self.code = code
|
|
64
|
+
self.title = title
|
|
65
|
+
self.detail = detail
|
|
66
|
+
self.instance = instance
|
|
67
|
+
self.problem = problem
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class MissingTokenError(BaseCradleError):
|
|
71
|
+
"""No token was provided and ``BASECRADLE_TOKEN`` is not set."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class APIConnectionError(BaseCradleError):
|
|
75
|
+
"""The request never got an API response (DNS failure, refused connection, timeout).
|
|
76
|
+
|
|
77
|
+
The underlying ``httpx`` exception is preserved as ``__cause__``.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# --- 401: authentication ---------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class AuthenticationError(BaseCradleError):
|
|
85
|
+
"""Authentication failed (HTTP 401)."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class UnauthorizedError(AuthenticationError):
|
|
89
|
+
"""``unauthorized`` — the Bearer token is missing or invalid."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class InvalidCredentialsError(AuthenticationError):
|
|
93
|
+
"""``invalid_credentials`` — sign-in failed: the email address or password is wrong."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class InvalidSignatureError(AuthenticationError):
|
|
97
|
+
"""``invalid_signature`` — webhook ingest: the signature is missing or does not match."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --- 403: account / permissions --------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class AccountSuspendedError(BaseCradleError):
|
|
104
|
+
"""``account_suspended`` — credentials were valid but the account is suspended."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ForbiddenError(BaseCradleError):
|
|
108
|
+
"""Authenticated but not allowed (HTTP 403)."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class NotAViewerError(ForbiddenError):
|
|
112
|
+
"""``not_a_viewer`` — you are not a viewer (owner or participant) of the timeline."""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class NotTimelineOwnerError(ForbiddenError):
|
|
116
|
+
"""``not_timeline_owner`` — the action requires being the timeline's owner."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TimelineLockedError(ForbiddenError):
|
|
120
|
+
"""``timeline_locked`` — the timeline is locked and not accepting new content."""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --- 404 --------------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class NotFoundError(BaseCradleError):
|
|
127
|
+
"""``not_found`` — no record exists for the given UUID (or it is hidden from you)."""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --- 422: validation --------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class ValidationError(BaseCradleError):
|
|
134
|
+
"""A submitted record failed validation (HTTP 422).
|
|
135
|
+
|
|
136
|
+
``errors`` maps attribute name → list of messages (empty when the API sent none).
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(self, message: str, *, errors: dict[str, list[str]] | None = None, **kwargs: Any):
|
|
140
|
+
super().__init__(message, **kwargs)
|
|
141
|
+
self.errors = errors or {}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class CurrentPasswordIncorrectError(ValidationError):
|
|
145
|
+
"""``current_password_incorrect`` — password change: the current password is incorrect."""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class PasswordConfirmationMismatchError(ValidationError):
|
|
149
|
+
"""``password_confirmation_mismatch`` — the new password and its confirmation differ."""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# --- 429 --------------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class RateLimitedError(BaseCradleError):
|
|
156
|
+
"""``rate_limited`` — too many requests in the window.
|
|
157
|
+
|
|
158
|
+
``retry_after`` is the number of seconds to wait (from the ``Retry-After`` header),
|
|
159
|
+
or ``None`` if the header was absent.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(self, message: str, *, retry_after: int | None = None, **kwargs: Any):
|
|
163
|
+
super().__init__(message, **kwargs)
|
|
164
|
+
self.retry_after = retry_after
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --- 400: malformed requests ------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class InvalidRequestError(BaseCradleError):
|
|
171
|
+
"""The request was malformed (HTTP 400)."""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class InvalidCursorError(InvalidRequestError):
|
|
175
|
+
"""``invalid_cursor`` — the ``before`` pagination cursor is not a valid record UUID."""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class InvalidFilterError(InvalidRequestError):
|
|
179
|
+
"""``invalid_filter`` — a list filter value is malformed."""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# --- webhook ingest ----------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class EndpointDisabledError(BaseCradleError):
|
|
186
|
+
"""``endpoint_disabled`` — webhook ingest: the endpoint is not accepting deliveries."""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class PayloadTooLargeError(BaseCradleError):
|
|
190
|
+
"""``payload_too_large`` — webhook ingest: the request body exceeds the maximum size."""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# --- the code → class registry -----------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
_CODE_TO_ERROR: dict[str, type[BaseCradleError]] = {
|
|
196
|
+
"unauthorized": UnauthorizedError,
|
|
197
|
+
"invalid_credentials": InvalidCredentialsError,
|
|
198
|
+
"invalid_signature": InvalidSignatureError,
|
|
199
|
+
"account_suspended": AccountSuspendedError,
|
|
200
|
+
"not_a_viewer": NotAViewerError,
|
|
201
|
+
"not_timeline_owner": NotTimelineOwnerError,
|
|
202
|
+
"timeline_locked": TimelineLockedError,
|
|
203
|
+
"not_found": NotFoundError,
|
|
204
|
+
"validation_failed": ValidationError,
|
|
205
|
+
"current_password_incorrect": CurrentPasswordIncorrectError,
|
|
206
|
+
"password_confirmation_mismatch": PasswordConfirmationMismatchError,
|
|
207
|
+
"rate_limited": RateLimitedError,
|
|
208
|
+
"invalid_cursor": InvalidCursorError,
|
|
209
|
+
"invalid_filter": InvalidFilterError,
|
|
210
|
+
"endpoint_disabled": EndpointDisabledError,
|
|
211
|
+
"payload_too_large": PayloadTooLargeError,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def exception_from_response(response: httpx.Response) -> BaseCradleError:
|
|
216
|
+
"""Build the right exception for a non-2xx API response.
|
|
217
|
+
|
|
218
|
+
Pure function of the response — the async client reuses it unchanged.
|
|
219
|
+
|
|
220
|
+
Unknown codes fall back to ``BaseCradleError`` (the API is additive-only; a new
|
|
221
|
+
error code must never crash the SDK), and non-problem+json bodies (e.g. a proxy's
|
|
222
|
+
HTML error page) produce a ``BaseCradleError`` carrying just the HTTP status.
|
|
223
|
+
"""
|
|
224
|
+
try:
|
|
225
|
+
problem = response.json()
|
|
226
|
+
except ValueError:
|
|
227
|
+
problem = None
|
|
228
|
+
|
|
229
|
+
if not isinstance(problem, dict) or "code" not in problem:
|
|
230
|
+
return BaseCradleError(
|
|
231
|
+
f"API request failed with HTTP {response.status_code}",
|
|
232
|
+
status=response.status_code,
|
|
233
|
+
problem=problem if isinstance(problem, dict) else None,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
code = problem.get("code")
|
|
237
|
+
detail = problem.get("detail")
|
|
238
|
+
title = problem.get("title")
|
|
239
|
+
message = detail or title or f"API request failed with HTTP {response.status_code}"
|
|
240
|
+
|
|
241
|
+
common: dict[str, Any] = {
|
|
242
|
+
"status": problem.get("status", response.status_code),
|
|
243
|
+
"code": code,
|
|
244
|
+
"title": title,
|
|
245
|
+
"detail": detail,
|
|
246
|
+
"instance": problem.get("instance"),
|
|
247
|
+
"problem": problem,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
error_class = _CODE_TO_ERROR.get(code, BaseCradleError)
|
|
251
|
+
|
|
252
|
+
if issubclass(error_class, ValidationError):
|
|
253
|
+
return error_class(message, errors=problem.get("errors"), **common)
|
|
254
|
+
if issubclass(error_class, RateLimitedError):
|
|
255
|
+
retry_after_header = response.headers.get("Retry-After")
|
|
256
|
+
retry_after = int(retry_after_header) if retry_after_header is not None else None
|
|
257
|
+
return error_class(message, retry_after=retry_after, **common)
|
|
258
|
+
return error_class(message, **common)
|