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 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}>"
@@ -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)