plansom-sdk 0.0.1__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.
- plansom_sdk/__init__.py +21 -0
- plansom_sdk/_version.py +6 -0
- plansom_sdk/async_client.py +67 -0
- plansom_sdk/auth.py +265 -0
- plansom_sdk/client.py +67 -0
- plansom_sdk/exceptions.py +72 -0
- plansom_sdk/models.py +382 -0
- plansom_sdk/pagination.py +99 -0
- plansom_sdk/py.typed +0 -0
- plansom_sdk/resources/__init__.py +2 -0
- plansom_sdk/resources/goals.py +152 -0
- plansom_sdk/resources/organizations.py +84 -0
- plansom_sdk/resources/search.py +32 -0
- plansom_sdk/resources/tasks.py +133 -0
- plansom_sdk/resources/teams.py +62 -0
- plansom_sdk/resources/users.py +38 -0
- plansom_sdk/transport/__init__.py +3 -0
- plansom_sdk/transport/http.py +170 -0
- plansom_sdk/transport/retry.py +29 -0
- plansom_sdk-0.0.1.dist-info/METADATA +173 -0
- plansom_sdk-0.0.1.dist-info/RECORD +24 -0
- plansom_sdk-0.0.1.dist-info/WHEEL +5 -0
- plansom_sdk-0.0.1.dist-info/licenses/LICENSE +177 -0
- plansom_sdk-0.0.1.dist-info/top_level.txt +1 -0
plansom_sdk/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Plansom SDK — Python client for be3's v3 API."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .client import Plansom
|
|
5
|
+
from .async_client import PlansomAsync
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
PlansomError,
|
|
8
|
+
PlansomAPIError,
|
|
9
|
+
PlansomAuthError,
|
|
10
|
+
PlansomConnectionError,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Plansom",
|
|
15
|
+
"PlansomAsync",
|
|
16
|
+
"PlansomError",
|
|
17
|
+
"PlansomAPIError",
|
|
18
|
+
"PlansomAuthError",
|
|
19
|
+
"PlansomConnectionError",
|
|
20
|
+
"__version__",
|
|
21
|
+
]
|
plansom_sdk/_version.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Single source of truth for the package version, kept separate from
|
|
2
|
+
__init__.py so leaf modules (e.g. transport/http.py, for the User-Agent
|
|
3
|
+
header) can import it without risking a circular import through the
|
|
4
|
+
package's own __init__.py."""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.0.1"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Plansom SDK — asynchronous client. Mirrors client.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from .auth import AuthStrategy, OAuthTokenAuth
|
|
8
|
+
from .resources.goals import AsyncGoals
|
|
9
|
+
from .resources.organizations import AsyncOrganizations
|
|
10
|
+
from .resources.search import AsyncSearch
|
|
11
|
+
from .resources.tasks import AsyncTasks
|
|
12
|
+
from .resources.teams import AsyncTeams
|
|
13
|
+
from .resources.users import AsyncUsers
|
|
14
|
+
from .transport.http import DEFAULT_BASE_URL, AsyncTransport
|
|
15
|
+
from .transport.retry import RetryConfig
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _require_auth(auth: Optional[AuthStrategy]):
|
|
19
|
+
async def get_token() -> str:
|
|
20
|
+
if auth is None:
|
|
21
|
+
raise RuntimeError("PlansomAsync(auth=...) is required to make API calls.")
|
|
22
|
+
return await auth.async_get_token()
|
|
23
|
+
|
|
24
|
+
return get_token
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PlansomAsync:
|
|
28
|
+
"""Asynchronous Plansom API client.
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
from plansom_sdk import PlansomAsync
|
|
32
|
+
from plansom_sdk.auth import OAuthTokenAuth
|
|
33
|
+
|
|
34
|
+
client = PlansomAsync(auth=await OAuthTokenAuth.from_authorization_code_async(...))
|
|
35
|
+
page = await client.goals.list(organization=org_id)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
auth: Optional[AuthStrategy] = None,
|
|
41
|
+
*,
|
|
42
|
+
base_url: Optional[str] = None,
|
|
43
|
+
timeout_ms: Optional[int] = None,
|
|
44
|
+
retry_config: Optional[RetryConfig] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
resolved_base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
47
|
+
if isinstance(auth, OAuthTokenAuth) and auth.base_url != resolved_base_url:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"PlansomAsync is configured for {resolved_base_url!r} but its OAuthTokenAuth is "
|
|
50
|
+
f"configured for {auth.base_url!r} — resource calls and token refresh would "
|
|
51
|
+
f"silently hit different environments."
|
|
52
|
+
)
|
|
53
|
+
self._transport = AsyncTransport(
|
|
54
|
+
_require_auth(auth), base_url=base_url, timeout_ms=timeout_ms, retry_config=retry_config
|
|
55
|
+
)
|
|
56
|
+
self.goals = AsyncGoals(self._transport)
|
|
57
|
+
self.tasks = AsyncTasks(self._transport)
|
|
58
|
+
self.users = AsyncUsers(self._transport)
|
|
59
|
+
self.teams = AsyncTeams(self._transport)
|
|
60
|
+
self.organizations = AsyncOrganizations(self._transport)
|
|
61
|
+
self.search = AsyncSearch(self._transport)
|
|
62
|
+
|
|
63
|
+
async def __aenter__(self) -> "PlansomAsync":
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
async def __aexit__(self, *exc_info) -> None:
|
|
67
|
+
await self._transport.aclose()
|
plansom_sdk/auth.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Authentication for the Plansom SDK.
|
|
2
|
+
|
|
3
|
+
Two strategies: `APIKeyAuth`, for a caller acting as its own Plansom account
|
|
4
|
+
(not yet available — be3 has not shipped key issuance), and `OAuthTokenAuth`,
|
|
5
|
+
for third-party software acting on behalf of its own end-users (live).
|
|
6
|
+
`/o/token/` (django-oauth-toolkit, confidential clients only — every
|
|
7
|
+
registered app gets a `client_secret`, no PKCE/public-client path) issues and
|
|
8
|
+
refreshes tokens scoped to `read:goals_tasks`. OAuth-authenticated requests
|
|
9
|
+
are served by the same `/api/v3/goals/` and `/api/v3/tasks/` endpoints as any
|
|
10
|
+
other authenticated caller, so no OAuth-specific routing exists elsewhere in
|
|
11
|
+
the SDK.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from datetime import datetime, timedelta, timezone
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from .exceptions import wrap_http_error
|
|
23
|
+
from .transport.http import DEFAULT_BASE_URL
|
|
24
|
+
|
|
25
|
+
# Refresh this long before actual expiry so a request never races a token
|
|
26
|
+
# that's valid when checked but expired by the time be3 receives it.
|
|
27
|
+
_TOKEN_EXPIRY_SKEW_SECONDS = 60
|
|
28
|
+
|
|
29
|
+
_TOKEN_PATH = "/o/token/"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthStrategy(ABC):
|
|
33
|
+
"""Base interface every auth strategy implements. `get_token()` returns
|
|
34
|
+
the raw bearer token value sent as `Authorization: Bearer <token>` on
|
|
35
|
+
every request (see `transport/http.py`)."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def get_token(self) -> str:
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
async def async_get_token(self) -> str:
|
|
42
|
+
"""Default: delegate to the sync path — correct for strategies with
|
|
43
|
+
no I/O (e.g. `APIKeyAuth`, a plain in-memory return). Override when a
|
|
44
|
+
real async implementation is needed so the async client never blocks
|
|
45
|
+
the event loop on a sync HTTP call — see `OAuthTokenAuth`, whose
|
|
46
|
+
refresh does a real POST to `/o/token/`.
|
|
47
|
+
"""
|
|
48
|
+
return self.get_token()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class APIKeyAuth(AuthStrategy):
|
|
52
|
+
"""For a caller acting as its own Plansom account.
|
|
53
|
+
|
|
54
|
+
Intended usage once be3 ships "Profile -> API Keys -> Generate API Key":
|
|
55
|
+
|
|
56
|
+
client = Plansom(auth=APIKeyAuth(api_key="pk_live_..."))
|
|
57
|
+
|
|
58
|
+
PENDING: be3 does not yet issue API keys.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, api_key: str) -> None:
|
|
62
|
+
self.api_key = api_key
|
|
63
|
+
|
|
64
|
+
def get_token(self) -> str:
|
|
65
|
+
raise NotImplementedError(
|
|
66
|
+
"API Key authentication is not yet available — be3 has not shipped "
|
|
67
|
+
"the API Key issuance endpoint yet."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class OAuthTokenAuth(AuthStrategy):
|
|
72
|
+
"""For third-party software acting on behalf of its own end-users.
|
|
73
|
+
|
|
74
|
+
Running the browser redirect/consent step and storing the resulting
|
|
75
|
+
tokens is the third-party app's own job, not the SDK's. What the SDK does
|
|
76
|
+
own: exchanging an authorization code for a token
|
|
77
|
+
(`from_authorization_code`) and keeping that token fresh
|
|
78
|
+
(`get_token`/`async_get_token` refresh transparently, on demand, using
|
|
79
|
+
the refresh token — no background timers).
|
|
80
|
+
|
|
81
|
+
Already have a token (e.g. restored from your own storage) and just want
|
|
82
|
+
to use it:
|
|
83
|
+
|
|
84
|
+
auth = OAuthTokenAuth("existing-access-token")
|
|
85
|
+
client = Plansom(auth=auth)
|
|
86
|
+
|
|
87
|
+
Have a refresh token too, and want the SDK to refresh automatically once
|
|
88
|
+
the access token nears expiry:
|
|
89
|
+
|
|
90
|
+
auth = OAuthTokenAuth(
|
|
91
|
+
"existing-access-token",
|
|
92
|
+
refresh_token="...",
|
|
93
|
+
expires_at=..., # datetime, tz-aware
|
|
94
|
+
client_id="...",
|
|
95
|
+
client_secret="...",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
Starting from an authorization code (the common case — right after your
|
|
99
|
+
redirect handler receives `?code=...`):
|
|
100
|
+
|
|
101
|
+
auth = OAuthTokenAuth.from_authorization_code(
|
|
102
|
+
code=request.GET["code"],
|
|
103
|
+
client_id="...",
|
|
104
|
+
client_secret="...",
|
|
105
|
+
redirect_uri="https://myapp.example/oauth/callback",
|
|
106
|
+
)
|
|
107
|
+
client = Plansom(auth=auth)
|
|
108
|
+
|
|
109
|
+
`refresh_token`/`expires_at`/`client_id`/`client_secret` are all
|
|
110
|
+
optional: without them (e.g. a bare access token with no refresh
|
|
111
|
+
material), `get_token`/`async_get_token` just return the token as-is —
|
|
112
|
+
an eventual 401 from be3 surfaces as `PlansomAuthError`, same as any
|
|
113
|
+
other auth failure.
|
|
114
|
+
|
|
115
|
+
`base_url` (internal use only) must match the `base_url` given to
|
|
116
|
+
`Plansom`/`PlansomAsync`, or construction raises `ValueError`.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
access_token: str,
|
|
122
|
+
*,
|
|
123
|
+
refresh_token: Optional[str] = None,
|
|
124
|
+
expires_at: Optional[datetime] = None,
|
|
125
|
+
client_id: Optional[str] = None,
|
|
126
|
+
client_secret: Optional[str] = None,
|
|
127
|
+
base_url: Optional[str] = None,
|
|
128
|
+
) -> None:
|
|
129
|
+
self.access_token = access_token
|
|
130
|
+
self.refresh_token = refresh_token
|
|
131
|
+
self.expires_at = expires_at
|
|
132
|
+
self.client_id = client_id
|
|
133
|
+
self.client_secret = client_secret
|
|
134
|
+
self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def base_url(self) -> str:
|
|
138
|
+
return self._base_url
|
|
139
|
+
|
|
140
|
+
def get_token(self) -> str:
|
|
141
|
+
if self._needs_refresh():
|
|
142
|
+
self._refresh_sync()
|
|
143
|
+
return self.access_token
|
|
144
|
+
|
|
145
|
+
async def async_get_token(self) -> str:
|
|
146
|
+
if self._needs_refresh():
|
|
147
|
+
await self._refresh_async()
|
|
148
|
+
return self.access_token
|
|
149
|
+
|
|
150
|
+
def _can_refresh(self) -> bool:
|
|
151
|
+
return bool(self.refresh_token and self.client_id and self.client_secret)
|
|
152
|
+
|
|
153
|
+
def _needs_refresh(self) -> bool:
|
|
154
|
+
if self.expires_at is None or not self._can_refresh():
|
|
155
|
+
return False
|
|
156
|
+
return datetime.now(timezone.utc) >= self.expires_at - timedelta(
|
|
157
|
+
seconds=_TOKEN_EXPIRY_SKEW_SECONDS
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def _refresh_params(self) -> dict:
|
|
161
|
+
return {
|
|
162
|
+
"grant_type": "refresh_token",
|
|
163
|
+
"refresh_token": self.refresh_token,
|
|
164
|
+
"client_id": self.client_id,
|
|
165
|
+
"client_secret": self.client_secret,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
def _apply_token_response(self, body: dict) -> None:
|
|
169
|
+
self.access_token = body["access_token"]
|
|
170
|
+
self.refresh_token = body.get("refresh_token", self.refresh_token)
|
|
171
|
+
expires_in = body.get("expires_in")
|
|
172
|
+
self.expires_at = (
|
|
173
|
+
datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
|
174
|
+
if expires_in is not None
|
|
175
|
+
else None
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def _refresh_sync(self) -> None:
|
|
179
|
+
with httpx.Client() as http_client:
|
|
180
|
+
response = http_client.post(f"{self._base_url}{_TOKEN_PATH}", data=self._refresh_params())
|
|
181
|
+
if response.status_code >= 400:
|
|
182
|
+
raise wrap_http_error(response)
|
|
183
|
+
self._apply_token_response(response.json())
|
|
184
|
+
|
|
185
|
+
async def _refresh_async(self) -> None:
|
|
186
|
+
async with httpx.AsyncClient() as http_client:
|
|
187
|
+
response = await http_client.post(f"{self._base_url}{_TOKEN_PATH}", data=self._refresh_params())
|
|
188
|
+
if response.status_code >= 400:
|
|
189
|
+
raise wrap_http_error(response)
|
|
190
|
+
self._apply_token_response(response.json())
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def from_authorization_code(
|
|
194
|
+
cls,
|
|
195
|
+
*,
|
|
196
|
+
code: str,
|
|
197
|
+
client_id: str,
|
|
198
|
+
client_secret: str,
|
|
199
|
+
redirect_uri: str,
|
|
200
|
+
base_url: Optional[str] = None,
|
|
201
|
+
) -> "OAuthTokenAuth":
|
|
202
|
+
"""Exchange an authorization code for a token. Call this once, right
|
|
203
|
+
after your redirect handler receives `?code=...`."""
|
|
204
|
+
resolved_base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
205
|
+
with httpx.Client() as http_client:
|
|
206
|
+
response = http_client.post(
|
|
207
|
+
f"{resolved_base_url}{_TOKEN_PATH}",
|
|
208
|
+
data={
|
|
209
|
+
"grant_type": "authorization_code",
|
|
210
|
+
"code": code,
|
|
211
|
+
"redirect_uri": redirect_uri,
|
|
212
|
+
"client_id": client_id,
|
|
213
|
+
"client_secret": client_secret,
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
if response.status_code >= 400:
|
|
217
|
+
raise wrap_http_error(response)
|
|
218
|
+
return cls._from_token_response(
|
|
219
|
+
response.json(), client_id=client_id, client_secret=client_secret, base_url=resolved_base_url
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
@classmethod
|
|
223
|
+
async def from_authorization_code_async(
|
|
224
|
+
cls,
|
|
225
|
+
*,
|
|
226
|
+
code: str,
|
|
227
|
+
client_id: str,
|
|
228
|
+
client_secret: str,
|
|
229
|
+
redirect_uri: str,
|
|
230
|
+
base_url: Optional[str] = None,
|
|
231
|
+
) -> "OAuthTokenAuth":
|
|
232
|
+
"""Async counterpart of `from_authorization_code`, for callers
|
|
233
|
+
exchanging the code from inside an async redirect handler."""
|
|
234
|
+
resolved_base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
235
|
+
async with httpx.AsyncClient() as http_client:
|
|
236
|
+
response = await http_client.post(
|
|
237
|
+
f"{resolved_base_url}{_TOKEN_PATH}",
|
|
238
|
+
data={
|
|
239
|
+
"grant_type": "authorization_code",
|
|
240
|
+
"code": code,
|
|
241
|
+
"redirect_uri": redirect_uri,
|
|
242
|
+
"client_id": client_id,
|
|
243
|
+
"client_secret": client_secret,
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
if response.status_code >= 400:
|
|
247
|
+
raise wrap_http_error(response)
|
|
248
|
+
return cls._from_token_response(
|
|
249
|
+
response.json(), client_id=client_id, client_secret=client_secret, base_url=resolved_base_url
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
@classmethod
|
|
253
|
+
def _from_token_response(cls, body: dict, *, client_id: str, client_secret: str, base_url: str) -> "OAuthTokenAuth":
|
|
254
|
+
expires_in = body.get("expires_in")
|
|
255
|
+
expires_at = (
|
|
256
|
+
datetime.now(timezone.utc) + timedelta(seconds=expires_in) if expires_in is not None else None
|
|
257
|
+
)
|
|
258
|
+
return cls(
|
|
259
|
+
body["access_token"],
|
|
260
|
+
refresh_token=body.get("refresh_token"),
|
|
261
|
+
expires_at=expires_at,
|
|
262
|
+
client_id=client_id,
|
|
263
|
+
client_secret=client_secret,
|
|
264
|
+
base_url=base_url,
|
|
265
|
+
)
|
plansom_sdk/client.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Plansom SDK — synchronous client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from .auth import AuthStrategy, OAuthTokenAuth
|
|
8
|
+
from .resources.goals import Goals
|
|
9
|
+
from .resources.organizations import Organizations
|
|
10
|
+
from .resources.search import Search
|
|
11
|
+
from .resources.tasks import Tasks
|
|
12
|
+
from .resources.teams import Teams
|
|
13
|
+
from .resources.users import Users
|
|
14
|
+
from .transport.http import DEFAULT_BASE_URL, SyncTransport
|
|
15
|
+
from .transport.retry import RetryConfig
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _require_auth(auth: Optional[AuthStrategy]):
|
|
19
|
+
def get_token() -> str:
|
|
20
|
+
if auth is None:
|
|
21
|
+
raise RuntimeError("Plansom(auth=...) is required to make API calls.")
|
|
22
|
+
return auth.get_token()
|
|
23
|
+
|
|
24
|
+
return get_token
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Plansom:
|
|
28
|
+
"""Synchronous Plansom API client.
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
from plansom_sdk import Plansom
|
|
32
|
+
from plansom_sdk.auth import OAuthTokenAuth
|
|
33
|
+
|
|
34
|
+
client = Plansom(auth=OAuthTokenAuth.from_authorization_code(...))
|
|
35
|
+
page = client.goals.list(organization=org_id)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
auth: Optional[AuthStrategy] = None,
|
|
41
|
+
*,
|
|
42
|
+
base_url: Optional[str] = None,
|
|
43
|
+
timeout_ms: Optional[int] = None,
|
|
44
|
+
retry_config: Optional[RetryConfig] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
resolved_base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
47
|
+
if isinstance(auth, OAuthTokenAuth) and auth.base_url != resolved_base_url:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"Plansom is configured for {resolved_base_url!r} but its OAuthTokenAuth is "
|
|
50
|
+
f"configured for {auth.base_url!r} — resource calls and token refresh would "
|
|
51
|
+
f"silently hit different environments."
|
|
52
|
+
)
|
|
53
|
+
self._transport = SyncTransport(
|
|
54
|
+
_require_auth(auth), base_url=base_url, timeout_ms=timeout_ms, retry_config=retry_config
|
|
55
|
+
)
|
|
56
|
+
self.goals = Goals(self._transport)
|
|
57
|
+
self.tasks = Tasks(self._transport)
|
|
58
|
+
self.users = Users(self._transport)
|
|
59
|
+
self.teams = Teams(self._transport)
|
|
60
|
+
self.organizations = Organizations(self._transport)
|
|
61
|
+
self.search = Search(self._transport)
|
|
62
|
+
|
|
63
|
+
def __enter__(self) -> "Plansom":
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def __exit__(self, *exc_info) -> None:
|
|
67
|
+
self._transport.close()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Typed exceptions for the Plansom SDK.
|
|
2
|
+
|
|
3
|
+
`wrap_http_error` builds a typed exception straight from an `httpx.Response`,
|
|
4
|
+
so callers only ever need to catch this package's own hierarchy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PlansomError(Exception):
|
|
13
|
+
"""Base class for all errors raised by the Plansom SDK."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PlansomConnectionError(PlansomError):
|
|
17
|
+
"""Network-level failure (timeout, DNS, connection reset) — no response was
|
|
18
|
+
received from be3 at all, so there's no status code to classify."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, *, source: Exception) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.source = source
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PlansomAPIError(PlansomError):
|
|
26
|
+
"""be3 returned an error response."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str, status_code: int, body: str) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.status_code = status_code
|
|
31
|
+
self.body = body
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PlansomAuthError(PlansomAPIError):
|
|
35
|
+
"""401 / 403 — missing, invalid, or insufficiently-scoped credentials."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PlansomNotFoundError(PlansomAPIError):
|
|
39
|
+
"""404 — resource does not exist, or isn't visible to the caller."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PlansomRateLimitError(PlansomAPIError):
|
|
43
|
+
"""429 — too many requests."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PlansomValidationError(PlansomAPIError):
|
|
47
|
+
"""400 — request rejected by be3's validation."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PlansomServerError(PlansomAPIError):
|
|
51
|
+
"""5xx — be3 failed unexpectedly."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_STATUS_CODE_MAP = {
|
|
55
|
+
400: PlansomValidationError,
|
|
56
|
+
401: PlansomAuthError,
|
|
57
|
+
403: PlansomAuthError,
|
|
58
|
+
404: PlansomNotFoundError,
|
|
59
|
+
429: PlansomRateLimitError,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def wrap_http_error(response: httpx.Response) -> PlansomAPIError:
|
|
64
|
+
"""Translate a non-2xx httpx.Response into the SDK's exception hierarchy."""
|
|
65
|
+
error_cls = _STATUS_CODE_MAP.get(response.status_code)
|
|
66
|
+
if error_cls is None:
|
|
67
|
+
error_cls = PlansomServerError if response.status_code >= 500 else PlansomAPIError
|
|
68
|
+
return error_cls(
|
|
69
|
+
f"be3 returned {response.status_code} for {response.request.method} {response.request.url}",
|
|
70
|
+
response.status_code,
|
|
71
|
+
response.text,
|
|
72
|
+
)
|