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.
@@ -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
+ ]
@@ -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
+ )