guidon-sdk 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.
- guidon/__init__.py +33 -0
- guidon/_http.py +273 -0
- guidon/_version.py +1 -0
- guidon/client.py +399 -0
- guidon/errors.py +75 -0
- guidon/py.typed +0 -0
- guidon_sdk-0.1.0.dist-info/METADATA +162 -0
- guidon_sdk-0.1.0.dist-info/RECORD +10 -0
- guidon_sdk-0.1.0.dist-info/WHEEL +4 -0
- guidon_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
guidon/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Official Python SDK for the Guidon REST API (https://useguidon.com)."""
|
|
2
|
+
|
|
3
|
+
from ._http import DEFAULT_BASE_URL, normalize_base_url
|
|
4
|
+
from ._version import __version__
|
|
5
|
+
from .client import (
|
|
6
|
+
UNSET,
|
|
7
|
+
AttemptsResource,
|
|
8
|
+
CommentsResource,
|
|
9
|
+
Guidon,
|
|
10
|
+
ProjectCollection,
|
|
11
|
+
ProjectsResource,
|
|
12
|
+
ReportsResource,
|
|
13
|
+
SubtasksResource,
|
|
14
|
+
TasksResource,
|
|
15
|
+
)
|
|
16
|
+
from .errors import GuidonError, code_for_status
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Guidon",
|
|
20
|
+
"GuidonError",
|
|
21
|
+
"code_for_status",
|
|
22
|
+
"normalize_base_url",
|
|
23
|
+
"DEFAULT_BASE_URL",
|
|
24
|
+
"UNSET",
|
|
25
|
+
"ProjectsResource",
|
|
26
|
+
"TasksResource",
|
|
27
|
+
"SubtasksResource",
|
|
28
|
+
"CommentsResource",
|
|
29
|
+
"AttemptsResource",
|
|
30
|
+
"ReportsResource",
|
|
31
|
+
"ProjectCollection",
|
|
32
|
+
"__version__",
|
|
33
|
+
]
|
guidon/_http.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Transport: auth, JSON, errors and retries over urllib (standard library only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import email.utils
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import random
|
|
9
|
+
import socket
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
import uuid
|
|
15
|
+
from typing import Any, Dict, Iterable, Mapping, Optional, Tuple, Union
|
|
16
|
+
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
from .errors import GuidonError, code_for_status
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://useguidon.com"
|
|
21
|
+
_API_PREFIX = "/api/v1"
|
|
22
|
+
_RETRYABLE_STATUS = {429, 502, 503, 504}
|
|
23
|
+
|
|
24
|
+
FileTuple = Tuple[str, Union[bytes, str], Optional[str]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_base_url(raw: str) -> str:
|
|
28
|
+
"""``https://host`` or ``https://host/api/v1/`` -> ``https://host/api/v1``."""
|
|
29
|
+
parsed = urllib.parse.urlsplit(raw.strip())
|
|
30
|
+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
31
|
+
raise GuidonError(f"Guidon base URL must be an http(s) URL: {raw}", code="invalid_base_url")
|
|
32
|
+
path = parsed.path.rstrip("/")
|
|
33
|
+
if not path.endswith(_API_PREFIX):
|
|
34
|
+
path += _API_PREFIX
|
|
35
|
+
return f"{parsed.scheme}://{parsed.netloc}{path}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_retry_after(value: Optional[str]) -> Optional[float]:
|
|
39
|
+
if not value:
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
seconds = float(value)
|
|
43
|
+
return seconds if seconds >= 0 else None
|
|
44
|
+
except ValueError:
|
|
45
|
+
pass
|
|
46
|
+
try:
|
|
47
|
+
when = email.utils.parsedate_to_datetime(value)
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
return None
|
|
50
|
+
return max(0.0, when.timestamp() - time.time())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def encode_multipart(fields: Mapping[str, str], files: Iterable[FileTuple]) -> Tuple[bytes, str]:
|
|
54
|
+
boundary = f"----guidon{uuid.uuid4().hex}"
|
|
55
|
+
out = bytearray()
|
|
56
|
+
for name, value in fields.items():
|
|
57
|
+
out += f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n".encode()
|
|
58
|
+
out += value.encode("utf-8") + b"\r\n"
|
|
59
|
+
for filename, data, content_type in files:
|
|
60
|
+
safe = filename.replace('"', "%22").replace("\r", "").replace("\n", "")
|
|
61
|
+
out += (
|
|
62
|
+
f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{safe}\"\r\n"
|
|
63
|
+
f"Content-Type: {content_type or 'application/octet-stream'}\r\n\r\n"
|
|
64
|
+
).encode()
|
|
65
|
+
out += (data.encode("utf-8") if isinstance(data, str) else bytes(data)) + b"\r\n"
|
|
66
|
+
out += f"--{boundary}--\r\n".encode()
|
|
67
|
+
return bytes(out), f"multipart/form-data; boundary={boundary}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
71
|
+
"""Follows redirects but never forwards the API key to another host."""
|
|
72
|
+
|
|
73
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
|
74
|
+
new = super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
75
|
+
if new is not None:
|
|
76
|
+
old_host = urllib.parse.urlsplit(req.full_url)
|
|
77
|
+
new_host = urllib.parse.urlsplit(new.full_url)
|
|
78
|
+
if (old_host.scheme, old_host.netloc) != (new_host.scheme, new_host.netloc):
|
|
79
|
+
for name in list(new.headers):
|
|
80
|
+
if name.lower() == "authorization":
|
|
81
|
+
del new.headers[name]
|
|
82
|
+
new.unredirected_hdrs.pop("Authorization", None)
|
|
83
|
+
return new
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _Multipart:
|
|
87
|
+
def __init__(self, fields: Mapping[str, str], files: Iterable[FileTuple]) -> None:
|
|
88
|
+
self.fields = dict(fields)
|
|
89
|
+
self.files = list(files)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class HttpClient:
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
api_key: Optional[str] = None,
|
|
96
|
+
base_url: Optional[str] = None,
|
|
97
|
+
*,
|
|
98
|
+
timeout: float = 30.0,
|
|
99
|
+
max_retries: int = 2,
|
|
100
|
+
user_agent: Optional[str] = None,
|
|
101
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
102
|
+
opener: Optional[urllib.request.OpenerDirector] = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
api_key = api_key or os.environ.get("GUIDON_API_KEY")
|
|
105
|
+
if not api_key:
|
|
106
|
+
raise GuidonError(
|
|
107
|
+
"No Guidon API key. Pass api_key=... or set GUIDON_API_KEY (create one in Profile -> API keys).",
|
|
108
|
+
code="missing_api_key",
|
|
109
|
+
)
|
|
110
|
+
self._api_key = api_key
|
|
111
|
+
self.base_url = normalize_base_url(base_url or os.environ.get("GUIDON_BASE_URL") or DEFAULT_BASE_URL)
|
|
112
|
+
self.timeout = timeout
|
|
113
|
+
self.max_retries = max(0, max_retries)
|
|
114
|
+
self._opener = opener or urllib.request.build_opener(_SafeRedirectHandler())
|
|
115
|
+
ua = f"guidon-sdk-python/{__version__}"
|
|
116
|
+
self._headers: Dict[str, str] = {"User-Agent": f"{ua} {user_agent}" if user_agent else ua}
|
|
117
|
+
self._headers.update(headers or {})
|
|
118
|
+
|
|
119
|
+
def url(self, path: str, query: Optional[Mapping[str, Any]] = None) -> str:
|
|
120
|
+
url = self.base_url + path
|
|
121
|
+
params = {k: ("true" if v is True else "false" if v is False else str(v)) for k, v in (query or {}).items() if v is not None}
|
|
122
|
+
if params:
|
|
123
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
124
|
+
return url
|
|
125
|
+
|
|
126
|
+
def request(
|
|
127
|
+
self,
|
|
128
|
+
method: str,
|
|
129
|
+
path: str,
|
|
130
|
+
*,
|
|
131
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
132
|
+
json_body: Any = None,
|
|
133
|
+
multipart: Optional[_Multipart] = None,
|
|
134
|
+
idempotency_key: Optional[str] = None,
|
|
135
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
136
|
+
timeout: Optional[float] = None,
|
|
137
|
+
response_type: str = "json",
|
|
138
|
+
) -> Any:
|
|
139
|
+
method = method.upper()
|
|
140
|
+
url = self.url(path, query)
|
|
141
|
+
all_headers = {
|
|
142
|
+
"Accept": "text/markdown, text/plain, application/json" if response_type == "text" else "application/json",
|
|
143
|
+
**self._headers,
|
|
144
|
+
**(headers or {}),
|
|
145
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
146
|
+
}
|
|
147
|
+
data: Optional[bytes] = None
|
|
148
|
+
if multipart is not None:
|
|
149
|
+
data, content_type = encode_multipart(multipart.fields, multipart.files)
|
|
150
|
+
all_headers["Content-Type"] = content_type
|
|
151
|
+
elif json_body is not None:
|
|
152
|
+
data = json.dumps(json_body).encode("utf-8")
|
|
153
|
+
all_headers["Content-Type"] = "application/json"
|
|
154
|
+
if idempotency_key:
|
|
155
|
+
all_headers["Idempotency-Key"] = idempotency_key
|
|
156
|
+
|
|
157
|
+
retryable = method == "GET" or bool(idempotency_key)
|
|
158
|
+
attempts = self.max_retries + 1 if retryable else 1
|
|
159
|
+
effective_timeout = self.timeout if timeout is None else timeout
|
|
160
|
+
|
|
161
|
+
attempt = 0
|
|
162
|
+
while True:
|
|
163
|
+
attempt += 1
|
|
164
|
+
req = urllib.request.Request(url, data=data, method=method, headers=all_headers)
|
|
165
|
+
try:
|
|
166
|
+
with self._opener.open(req, timeout=effective_timeout) as resp:
|
|
167
|
+
raw = resp.read()
|
|
168
|
+
status = resp.status
|
|
169
|
+
content_type = resp.headers.get("Content-Type")
|
|
170
|
+
except urllib.error.HTTPError as http_error:
|
|
171
|
+
try:
|
|
172
|
+
error = self._error_from_response(http_error, method, url)
|
|
173
|
+
finally:
|
|
174
|
+
http_error.close()
|
|
175
|
+
# 409 idempotency_in_progress: the first request with this key is still running.
|
|
176
|
+
in_progress = error.status == 409 and error.code == "idempotency_in_progress"
|
|
177
|
+
if attempt < attempts and (error.status in _RETRYABLE_STATUS or in_progress):
|
|
178
|
+
wait = error.retry_after if error.retry_after is not None else self._backoff(attempt)
|
|
179
|
+
time.sleep(min(wait, 60.0))
|
|
180
|
+
continue
|
|
181
|
+
raise error from None
|
|
182
|
+
except (socket.timeout, TimeoutError) as exc:
|
|
183
|
+
error = GuidonError(
|
|
184
|
+
f"Request timed out after {effective_timeout} s.", code="timeout", method=method, url=url
|
|
185
|
+
)
|
|
186
|
+
if attempt < attempts:
|
|
187
|
+
time.sleep(self._backoff(attempt))
|
|
188
|
+
continue
|
|
189
|
+
raise error from exc
|
|
190
|
+
except urllib.error.URLError as exc:
|
|
191
|
+
reason = exc.reason
|
|
192
|
+
if isinstance(reason, (socket.timeout, TimeoutError)):
|
|
193
|
+
error = GuidonError(
|
|
194
|
+
f"Request timed out after {effective_timeout} s.", code="timeout", method=method, url=url
|
|
195
|
+
)
|
|
196
|
+
else:
|
|
197
|
+
error = GuidonError(
|
|
198
|
+
f"Could not reach Guidon at {self.base_url}: {reason}",
|
|
199
|
+
code="network_error",
|
|
200
|
+
method=method,
|
|
201
|
+
url=url,
|
|
202
|
+
)
|
|
203
|
+
if attempt < attempts:
|
|
204
|
+
time.sleep(self._backoff(attempt))
|
|
205
|
+
continue
|
|
206
|
+
raise error from exc
|
|
207
|
+
|
|
208
|
+
text = raw.decode("utf-8", errors="replace")
|
|
209
|
+
if response_type == "text":
|
|
210
|
+
return text
|
|
211
|
+
if not text:
|
|
212
|
+
return None
|
|
213
|
+
try:
|
|
214
|
+
return json.loads(text)
|
|
215
|
+
except ValueError:
|
|
216
|
+
raise GuidonError(
|
|
217
|
+
f"Expected JSON from {method} {url}, got {content_type or 'an unknown content type'}.",
|
|
218
|
+
status=status,
|
|
219
|
+
code="invalid_response",
|
|
220
|
+
body=text[:2000],
|
|
221
|
+
method=method,
|
|
222
|
+
url=url,
|
|
223
|
+
) from None
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def _backoff(attempt: int) -> float:
|
|
227
|
+
base = min(8.0, 0.5 * 2 ** (attempt - 1))
|
|
228
|
+
return base / 2 + random.random() * base / 2
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def _error_from_response(resp: urllib.error.HTTPError, method: str, url: str) -> GuidonError:
|
|
232
|
+
try:
|
|
233
|
+
text = resp.read().decode("utf-8", errors="replace")
|
|
234
|
+
except Exception: # pragma: no cover - broken connection while reading the error
|
|
235
|
+
text = ""
|
|
236
|
+
body: Any = None
|
|
237
|
+
if text:
|
|
238
|
+
try:
|
|
239
|
+
body = json.loads(text)
|
|
240
|
+
except ValueError:
|
|
241
|
+
body = text
|
|
242
|
+
message: Optional[str] = None
|
|
243
|
+
code: Optional[str] = None
|
|
244
|
+
hint: Optional[str] = None
|
|
245
|
+
docs: Optional[str] = None
|
|
246
|
+
if isinstance(body, dict):
|
|
247
|
+
err = body.get("error")
|
|
248
|
+
if isinstance(err, str):
|
|
249
|
+
message = err
|
|
250
|
+
elif isinstance(err, dict) and isinstance(err.get("message"), str):
|
|
251
|
+
message = err["message"]
|
|
252
|
+
elif isinstance(body.get("message"), str):
|
|
253
|
+
message = body["message"]
|
|
254
|
+
if isinstance(body.get("code"), str) and body["code"]:
|
|
255
|
+
code = body["code"]
|
|
256
|
+
if isinstance(body.get("hint"), str) and body["hint"]:
|
|
257
|
+
hint = body["hint"]
|
|
258
|
+
if isinstance(body.get("docs"), str) and body["docs"]:
|
|
259
|
+
docs = body["docs"]
|
|
260
|
+
status = resp.code
|
|
261
|
+
headers = resp.headers
|
|
262
|
+
return GuidonError(
|
|
263
|
+
message or f"HTTP {status} {resp.reason or ''}".strip(),
|
|
264
|
+
status=status,
|
|
265
|
+
code=code or code_for_status(status),
|
|
266
|
+
body=body,
|
|
267
|
+
hint=hint,
|
|
268
|
+
docs=docs,
|
|
269
|
+
retry_after=_parse_retry_after(headers.get("Retry-After") if headers else None),
|
|
270
|
+
request_id=(headers.get("X-Request-Id") or headers.get("X-Vercel-Id")) if headers else None,
|
|
271
|
+
method=method,
|
|
272
|
+
url=url,
|
|
273
|
+
)
|
guidon/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
guidon/client.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Client for the Guidon REST API (/api/v1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any, Dict, Iterable, Iterator, List, Mapping, Optional, Sequence, Union
|
|
9
|
+
|
|
10
|
+
from ._http import FileTuple, HttpClient, _Multipart
|
|
11
|
+
|
|
12
|
+
JSON = Dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Unset:
|
|
16
|
+
def __repr__(self) -> str:
|
|
17
|
+
return "UNSET"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
UNSET: Any = _Unset()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _seg(value: str, name: str) -> str:
|
|
24
|
+
if not isinstance(value, str) or not value:
|
|
25
|
+
raise ValueError(f"{name} must be a non-empty string")
|
|
26
|
+
return urllib.parse.quote(value, safe="")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _drop_unset(fields: Mapping[str, Any]) -> JSON:
|
|
30
|
+
return {k: v for k, v in fields.items() if v is not UNSET}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Resource:
|
|
34
|
+
def __init__(self, http: HttpClient) -> None:
|
|
35
|
+
self._http = http
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProjectsResource(_Resource):
|
|
39
|
+
"""Projects the key's user belongs to, and their board configuration."""
|
|
40
|
+
|
|
41
|
+
def list(self) -> List[JSON]:
|
|
42
|
+
"""GET /projects. Scope ``tasks:read``."""
|
|
43
|
+
return self._http.request("GET", "/projects")["projects"]
|
|
44
|
+
|
|
45
|
+
def columns(self, project_id: str) -> List[JSON]:
|
|
46
|
+
"""GET /projects/{id}/columns - visible board columns, in order."""
|
|
47
|
+
return self._http.request("GET", f"/projects/{_seg(project_id, 'project_id')}/columns")["columns"]
|
|
48
|
+
|
|
49
|
+
def priorities(self, project_id: str) -> List[JSON]:
|
|
50
|
+
"""GET /projects/{id}/priorities - most important first."""
|
|
51
|
+
return self._http.request("GET", f"/projects/{_seg(project_id, 'project_id')}/priorities")["priorities"]
|
|
52
|
+
|
|
53
|
+
def references(self, project_id: str) -> List[JSON]:
|
|
54
|
+
"""GET /projects/{id}/references - moodboard images with short-lived signed URLs."""
|
|
55
|
+
return self._http.request("GET", f"/projects/{_seg(project_id, 'project_id')}/references")["references"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TasksResource(_Resource):
|
|
59
|
+
"""Board cards."""
|
|
60
|
+
|
|
61
|
+
def list_page(self, project_id: str, *, limit: Optional[int] = None, cursor: Optional[str] = None) -> JSON:
|
|
62
|
+
"""GET /projects/{id}/tasks - one page, newest first. Scope ``tasks:read``.
|
|
63
|
+
|
|
64
|
+
Returns ``{"tasks": [...], "next_cursor": str | None}``; pass
|
|
65
|
+
``next_cursor`` as ``cursor`` for the next page. ``limit``: 1-1000
|
|
66
|
+
(server default 1000).
|
|
67
|
+
"""
|
|
68
|
+
res = self._http.request(
|
|
69
|
+
"GET", f"/projects/{_seg(project_id, 'project_id')}/tasks", query={"limit": limit, "cursor": cursor}
|
|
70
|
+
)
|
|
71
|
+
return {"tasks": res.get("tasks") or [], "next_cursor": res.get("next_cursor")}
|
|
72
|
+
|
|
73
|
+
def iter(self, project_id: str, *, page_size: Optional[int] = None) -> Iterator[JSON]:
|
|
74
|
+
"""Every task of the project, newest first, fetched page by page as you iterate."""
|
|
75
|
+
cursor: Optional[str] = None
|
|
76
|
+
seen = set()
|
|
77
|
+
while True:
|
|
78
|
+
page = self.list_page(project_id, limit=page_size, cursor=cursor)
|
|
79
|
+
yield from page["tasks"]
|
|
80
|
+
cursor = page["next_cursor"]
|
|
81
|
+
if not cursor or cursor in seen:
|
|
82
|
+
return
|
|
83
|
+
seen.add(cursor)
|
|
84
|
+
|
|
85
|
+
def list(
|
|
86
|
+
self,
|
|
87
|
+
project_id: str,
|
|
88
|
+
*,
|
|
89
|
+
status: Union[str, Sequence[str], None] = None,
|
|
90
|
+
parent_task_id: Any = UNSET,
|
|
91
|
+
page_size: Optional[int] = None,
|
|
92
|
+
) -> List[JSON]:
|
|
93
|
+
"""All of the project's tasks (every page), newest first. Scope ``tasks:read``.
|
|
94
|
+
|
|
95
|
+
``status`` and ``parent_task_id`` (``None`` for top-level tasks only)
|
|
96
|
+
are applied client-side - the API has no server-side filters yet.
|
|
97
|
+
"""
|
|
98
|
+
tasks: List[JSON] = list(self.iter(project_id, page_size=page_size))
|
|
99
|
+
if status is not None:
|
|
100
|
+
wanted = {status} if isinstance(status, str) else set(status)
|
|
101
|
+
tasks = [t for t in tasks if t.get("status") in wanted]
|
|
102
|
+
if parent_task_id is not UNSET:
|
|
103
|
+
tasks = [t for t in tasks if t.get("parent_task_id") == parent_task_id]
|
|
104
|
+
return tasks
|
|
105
|
+
|
|
106
|
+
def get(self, task_id: str) -> JSON:
|
|
107
|
+
"""GET /tasks/{id}."""
|
|
108
|
+
return self._http.request("GET", f"/tasks/{_seg(task_id, 'task_id')}")["task"]
|
|
109
|
+
|
|
110
|
+
def create(
|
|
111
|
+
self,
|
|
112
|
+
project_id: str,
|
|
113
|
+
title: str,
|
|
114
|
+
*,
|
|
115
|
+
description: Optional[str] = None,
|
|
116
|
+
priority: Optional[str] = None,
|
|
117
|
+
status: Optional[str] = None,
|
|
118
|
+
due_date: Optional[str] = None,
|
|
119
|
+
parent_task_id: Optional[str] = None,
|
|
120
|
+
idempotency_key: Optional[str] = None,
|
|
121
|
+
) -> JSON:
|
|
122
|
+
"""POST /projects/{id}/tasks. Scope ``tasks:write``.
|
|
123
|
+
|
|
124
|
+
A top-level task defaults to ``backlog``/``medium``; with
|
|
125
|
+
``parent_task_id`` it is a subtask and starts as ``todo``/``medium``.
|
|
126
|
+
"""
|
|
127
|
+
body: JSON = {"title": title}
|
|
128
|
+
for key, value in (
|
|
129
|
+
("description", description),
|
|
130
|
+
("priority", priority),
|
|
131
|
+
("status", status),
|
|
132
|
+
("due_date", due_date),
|
|
133
|
+
("parent_task_id", parent_task_id),
|
|
134
|
+
):
|
|
135
|
+
if value is not None:
|
|
136
|
+
body[key] = value
|
|
137
|
+
return self._http.request(
|
|
138
|
+
"POST", f"/projects/{_seg(project_id, 'project_id')}/tasks", json_body=body, idempotency_key=idempotency_key
|
|
139
|
+
)["task"]
|
|
140
|
+
|
|
141
|
+
def update(
|
|
142
|
+
self,
|
|
143
|
+
task_id: str,
|
|
144
|
+
*,
|
|
145
|
+
title: Any = UNSET,
|
|
146
|
+
description: Any = UNSET,
|
|
147
|
+
priority: Any = UNSET,
|
|
148
|
+
due_date: Any = UNSET,
|
|
149
|
+
sort_order: Any = UNSET,
|
|
150
|
+
idempotency_key: Optional[str] = None,
|
|
151
|
+
) -> JSON:
|
|
152
|
+
"""PATCH /tasks/{id} - edit fields (not the status: use :meth:`move`).
|
|
153
|
+
|
|
154
|
+
Only the fields you pass are sent; ``description=None`` / ``due_date=None`` clear them.
|
|
155
|
+
"""
|
|
156
|
+
body = _drop_unset(
|
|
157
|
+
{"title": title, "description": description, "priority": priority, "due_date": due_date, "sort_order": sort_order}
|
|
158
|
+
)
|
|
159
|
+
if not body:
|
|
160
|
+
raise ValueError("update() needs at least one field")
|
|
161
|
+
return self._http.request(
|
|
162
|
+
"PATCH", f"/tasks/{_seg(task_id, 'task_id')}", json_body=body, idempotency_key=idempotency_key
|
|
163
|
+
)["task"]
|
|
164
|
+
|
|
165
|
+
def move(self, task_id: str, status: str, *, idempotency_key: Optional[str] = None) -> JSON:
|
|
166
|
+
"""PATCH /tasks/{id}/status - move the task to another column. Scope ``tasks:status``."""
|
|
167
|
+
return self._http.request(
|
|
168
|
+
"PATCH", f"/tasks/{_seg(task_id, 'task_id')}/status", json_body={"status": status}, idempotency_key=idempotency_key
|
|
169
|
+
)["task"]
|
|
170
|
+
|
|
171
|
+
def delete(self, task_id: str, *, idempotency_key: Optional[str] = None) -> None:
|
|
172
|
+
"""DELETE /tasks/{id} - permanent (project owner/admin)."""
|
|
173
|
+
self._http.request("DELETE", f"/tasks/{_seg(task_id, 'task_id')}", idempotency_key=idempotency_key)
|
|
174
|
+
|
|
175
|
+
def start(self, task_id: str, *, idempotency_key: Optional[str] = None) -> JSON:
|
|
176
|
+
"""POST /tasks/{id}/start - claim the task. Scope ``tasks:status``."""
|
|
177
|
+
return self._http.request("POST", f"/tasks/{_seg(task_id, 'task_id')}/start", idempotency_key=idempotency_key)["task"]
|
|
178
|
+
|
|
179
|
+
def complete(self, task_id: str, *, idempotency_key: Optional[str] = None) -> JSON:
|
|
180
|
+
"""POST /tasks/{id}/complete - mark done, if the project allows AI auto-complete."""
|
|
181
|
+
return self._http.request("POST", f"/tasks/{_seg(task_id, 'task_id')}/complete", idempotency_key=idempotency_key)[
|
|
182
|
+
"task"
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
def context(self, task_id: str) -> str:
|
|
186
|
+
"""GET /tasks/{id}/context - everything needed to do the task, as Markdown."""
|
|
187
|
+
return self._http.request("GET", f"/tasks/{_seg(task_id, 'task_id')}/context", response_type="text")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class SubtasksResource:
|
|
191
|
+
"""Subtasks are tasks with ``parent_task_id`` set; they always start as ``todo``/``medium``."""
|
|
192
|
+
|
|
193
|
+
def __init__(self, tasks: TasksResource) -> None:
|
|
194
|
+
self._tasks = tasks
|
|
195
|
+
|
|
196
|
+
def list(self, project_id: str, parent_task_id: str) -> List[JSON]:
|
|
197
|
+
_seg(parent_task_id, "parent_task_id")
|
|
198
|
+
return self._tasks.list(project_id, parent_task_id=parent_task_id)
|
|
199
|
+
|
|
200
|
+
def create(
|
|
201
|
+
self,
|
|
202
|
+
project_id: str,
|
|
203
|
+
parent_task_id: str,
|
|
204
|
+
title: str,
|
|
205
|
+
*,
|
|
206
|
+
description: Optional[str] = None,
|
|
207
|
+
due_date: Optional[str] = None,
|
|
208
|
+
idempotency_key: Optional[str] = None,
|
|
209
|
+
) -> JSON:
|
|
210
|
+
_seg(parent_task_id, "parent_task_id")
|
|
211
|
+
return self._tasks.create(
|
|
212
|
+
project_id,
|
|
213
|
+
title,
|
|
214
|
+
description=description,
|
|
215
|
+
due_date=due_date,
|
|
216
|
+
parent_task_id=parent_task_id,
|
|
217
|
+
idempotency_key=idempotency_key,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class CommentsResource(_Resource):
|
|
222
|
+
def list(self, task_id: str) -> List[JSON]:
|
|
223
|
+
"""GET /tasks/{id}/comment. Scope ``tasks:read``."""
|
|
224
|
+
return self._http.request("GET", f"/tasks/{_seg(task_id, 'task_id')}/comment")["comments"]
|
|
225
|
+
|
|
226
|
+
def create(self, task_id: str, content: str, *, idempotency_key: Optional[str] = None) -> JSON:
|
|
227
|
+
"""POST /tasks/{id}/comment. Scope ``comments:write``."""
|
|
228
|
+
return self._http.request(
|
|
229
|
+
"POST", f"/tasks/{_seg(task_id, 'task_id')}/comment", json_body={"content": content}, idempotency_key=idempotency_key
|
|
230
|
+
)["comment"]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class AttemptsResource(_Resource):
|
|
234
|
+
def list(self, task_id: str) -> List[JSON]:
|
|
235
|
+
"""GET /tasks/{id}/attempts, newest first."""
|
|
236
|
+
return self._http.request("GET", f"/tasks/{_seg(task_id, 'task_id')}/attempts")["attempts"]
|
|
237
|
+
|
|
238
|
+
def record(
|
|
239
|
+
self,
|
|
240
|
+
task_id: str,
|
|
241
|
+
*,
|
|
242
|
+
problem: str,
|
|
243
|
+
approach: str,
|
|
244
|
+
outcome: str,
|
|
245
|
+
result: Optional[str] = None,
|
|
246
|
+
failure_reason: Optional[str] = None,
|
|
247
|
+
agent: Optional[str] = None,
|
|
248
|
+
related_pr_url: Optional[str] = None,
|
|
249
|
+
files_changed: Optional[Sequence[str]] = None,
|
|
250
|
+
idempotency_key: Optional[str] = None,
|
|
251
|
+
) -> JSON:
|
|
252
|
+
"""POST /tasks/{id}/attempts. ``outcome``: failed, partial or succeeded. Scope ``attempts:write``."""
|
|
253
|
+
body: JSON = {"problem": problem, "approach": approach, "outcome": outcome}
|
|
254
|
+
for key, value in (
|
|
255
|
+
("result", result),
|
|
256
|
+
("failure_reason", failure_reason),
|
|
257
|
+
("agent", agent),
|
|
258
|
+
("related_pr_url", related_pr_url),
|
|
259
|
+
):
|
|
260
|
+
if value is not None:
|
|
261
|
+
body[key] = value
|
|
262
|
+
if files_changed is not None:
|
|
263
|
+
body["files_changed"] = list(files_changed)
|
|
264
|
+
return self._http.request(
|
|
265
|
+
"POST", f"/tasks/{_seg(task_id, 'task_id')}/attempts", json_body=body, idempotency_key=idempotency_key
|
|
266
|
+
)["attempt"]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class ReportsResource(_Resource):
|
|
270
|
+
def file(
|
|
271
|
+
self,
|
|
272
|
+
project_id: str,
|
|
273
|
+
title: str,
|
|
274
|
+
*,
|
|
275
|
+
description: Optional[str] = None,
|
|
276
|
+
category: Optional[str] = None,
|
|
277
|
+
source: Optional[str] = None,
|
|
278
|
+
reporter: Optional[str] = None,
|
|
279
|
+
metadata: Optional[Mapping[str, Any]] = None,
|
|
280
|
+
files: Iterable[FileTuple] = (),
|
|
281
|
+
idempotency_key: Optional[str] = None,
|
|
282
|
+
) -> JSON:
|
|
283
|
+
"""POST /projects/{id}/reports (multipart) - file a bug report.
|
|
284
|
+
|
|
285
|
+
``files`` are ``(filename, data, content_type)`` tuples. Scope
|
|
286
|
+
``reports:write``, which a key can't combine with any other scope.
|
|
287
|
+
"""
|
|
288
|
+
fields: Dict[str, str] = {"title": title}
|
|
289
|
+
for key, value in (("description", description), ("category", category), ("source", source), ("reporter", reporter)):
|
|
290
|
+
if value is not None:
|
|
291
|
+
fields[key] = value
|
|
292
|
+
if metadata is not None:
|
|
293
|
+
fields["metadata"] = json.dumps(dict(metadata))
|
|
294
|
+
return self._http.request(
|
|
295
|
+
"POST",
|
|
296
|
+
f"/projects/{_seg(project_id, 'project_id')}/reports",
|
|
297
|
+
multipart=_Multipart(fields, files),
|
|
298
|
+
idempotency_key=idempotency_key,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class ProjectCollection(_Resource):
|
|
303
|
+
"""Decisions, knowledge, roadmap, memory and tech stack: /projects/{id}/<path>[/{item_id}].
|
|
304
|
+
|
|
305
|
+
Reads need ``project:read``, writes ``project:write``. ``create``/``update``
|
|
306
|
+
take the fields as keyword arguments (``None`` clears a nullable field on update).
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
def __init__(self, http: HttpClient, path: str, list_key: str, item_key: str) -> None:
|
|
310
|
+
super().__init__(http)
|
|
311
|
+
self._path = path
|
|
312
|
+
self._list_key = list_key
|
|
313
|
+
self._item_key = item_key
|
|
314
|
+
|
|
315
|
+
def _base(self, project_id: str) -> str:
|
|
316
|
+
return f"/projects/{_seg(project_id, 'project_id')}/{self._path}"
|
|
317
|
+
|
|
318
|
+
def list(self, project_id: str) -> List[JSON]:
|
|
319
|
+
"""Up to 500 items."""
|
|
320
|
+
return self._http.request("GET", self._base(project_id))[self._list_key]
|
|
321
|
+
|
|
322
|
+
def get(self, project_id: str, item_id: str) -> JSON:
|
|
323
|
+
return self._http.request("GET", f"{self._base(project_id)}/{_seg(item_id, 'item_id')}")[self._item_key]
|
|
324
|
+
|
|
325
|
+
def create(self, project_id: str, *, idempotency_key: Optional[str] = None, **fields: Any) -> JSON:
|
|
326
|
+
return self._http.request("POST", self._base(project_id), json_body=fields, idempotency_key=idempotency_key)[
|
|
327
|
+
self._item_key
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
def update(self, project_id: str, item_id: str, *, idempotency_key: Optional[str] = None, **fields: Any) -> JSON:
|
|
331
|
+
if not fields:
|
|
332
|
+
raise ValueError("update() needs at least one field")
|
|
333
|
+
return self._http.request(
|
|
334
|
+
"PATCH", f"{self._base(project_id)}/{_seg(item_id, 'item_id')}", json_body=fields, idempotency_key=idempotency_key
|
|
335
|
+
)[self._item_key]
|
|
336
|
+
|
|
337
|
+
def delete(self, project_id: str, item_id: str, *, idempotency_key: Optional[str] = None) -> None:
|
|
338
|
+
self._http.request("DELETE", f"{self._base(project_id)}/{_seg(item_id, 'item_id')}", idempotency_key=idempotency_key)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class Guidon:
|
|
342
|
+
"""Client for the Guidon REST API.
|
|
343
|
+
|
|
344
|
+
>>> guidon = Guidon(api_key="guidon_...") # or set GUIDON_API_KEY
|
|
345
|
+
>>> project = guidon.projects.list()[0]
|
|
346
|
+
>>> task = guidon.tasks.create(project["id"], "Fix the jump", priority="high")
|
|
347
|
+
>>> guidon.tasks.move(task["id"], "in_progress")
|
|
348
|
+
|
|
349
|
+
``base_url`` defaults to ``GUIDON_BASE_URL`` or https://useguidon.com (a
|
|
350
|
+
self-hosted instance: ``https://guidon.example.com``; ``/api/v1`` is added).
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
def __init__(
|
|
354
|
+
self,
|
|
355
|
+
api_key: Optional[str] = None,
|
|
356
|
+
base_url: Optional[str] = None,
|
|
357
|
+
*,
|
|
358
|
+
timeout: float = 30.0,
|
|
359
|
+
max_retries: int = 2,
|
|
360
|
+
user_agent: Optional[str] = None,
|
|
361
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
362
|
+
opener: Optional[urllib.request.OpenerDirector] = None,
|
|
363
|
+
) -> None:
|
|
364
|
+
self._http = HttpClient(
|
|
365
|
+
api_key, base_url, timeout=timeout, max_retries=max_retries, user_agent=user_agent, headers=headers, opener=opener
|
|
366
|
+
)
|
|
367
|
+
self.projects = ProjectsResource(self._http)
|
|
368
|
+
self.tasks = TasksResource(self._http)
|
|
369
|
+
self.subtasks = SubtasksResource(self.tasks)
|
|
370
|
+
self.comments = CommentsResource(self._http)
|
|
371
|
+
self.attempts = AttemptsResource(self._http)
|
|
372
|
+
self.reports = ReportsResource(self._http)
|
|
373
|
+
self.decisions = ProjectCollection(self._http, "decisions", "decisions", "decision")
|
|
374
|
+
self.knowledge = ProjectCollection(self._http, "knowledge", "sources", "source")
|
|
375
|
+
self.roadmap = ProjectCollection(self._http, "roadmap", "phases", "phase")
|
|
376
|
+
self.memory = ProjectCollection(self._http, "memory", "memories", "memory")
|
|
377
|
+
self.technologies = ProjectCollection(self._http, "technologies", "technologies", "technology")
|
|
378
|
+
|
|
379
|
+
def me(self) -> Dict[str, Any]:
|
|
380
|
+
"""GET /me - the user this key or token acts as, and its scopes. Needs no scope."""
|
|
381
|
+
return self._http.request("GET", "/me")
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def base_url(self) -> str:
|
|
385
|
+
"""The resolved API root, e.g. ``https://useguidon.com/api/v1``."""
|
|
386
|
+
return self._http.base_url
|
|
387
|
+
|
|
388
|
+
def request(
|
|
389
|
+
self,
|
|
390
|
+
method: str,
|
|
391
|
+
path: str,
|
|
392
|
+
*,
|
|
393
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
394
|
+
json: Any = None,
|
|
395
|
+
idempotency_key: Optional[str] = None,
|
|
396
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
397
|
+
) -> Any:
|
|
398
|
+
"""Escape hatch for routes the SDK doesn't wrap yet; ``path`` is relative to /api/v1."""
|
|
399
|
+
return self._http.request(method, path, query=query, json_body=json, idempotency_key=idempotency_key, headers=headers)
|
guidon/errors.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The single exception type the SDK raises."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
# The same table the server uses for the codes it adds to its errors.
|
|
8
|
+
_STATUS_CODES = {
|
|
9
|
+
400: "invalid_request",
|
|
10
|
+
401: "unauthorized",
|
|
11
|
+
403: "forbidden",
|
|
12
|
+
404: "not_found",
|
|
13
|
+
405: "method_not_allowed",
|
|
14
|
+
409: "conflict",
|
|
15
|
+
411: "length_required",
|
|
16
|
+
413: "payload_too_large",
|
|
17
|
+
415: "unsupported_media_type",
|
|
18
|
+
422: "unprocessable",
|
|
19
|
+
429: "rate_limited",
|
|
20
|
+
500: "internal_error",
|
|
21
|
+
503: "unavailable",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def code_for_status(status: int) -> str:
|
|
26
|
+
"""Default ``code`` for an HTTP status when the error body doesn't carry one."""
|
|
27
|
+
if status in _STATUS_CODES:
|
|
28
|
+
return _STATUS_CODES[status]
|
|
29
|
+
return "internal_error" if status >= 500 else "invalid_request"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GuidonError(Exception):
|
|
33
|
+
"""An API error (``status`` >= 400) or a transport failure (``status`` 0).
|
|
34
|
+
|
|
35
|
+
``code`` is the server's machine-readable code when it sends one, otherwise
|
|
36
|
+
derived from the status (``not_found``, ``rate_limited``...), or one of
|
|
37
|
+
``network_error``, ``timeout``, ``invalid_response``, ``missing_api_key``,
|
|
38
|
+
``invalid_base_url`` for failures that never got a usable response.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
message: str,
|
|
44
|
+
*,
|
|
45
|
+
status: int = 0,
|
|
46
|
+
code: str = "invalid_request",
|
|
47
|
+
body: Any = None,
|
|
48
|
+
hint: Optional[str] = None,
|
|
49
|
+
docs: Optional[str] = None,
|
|
50
|
+
retry_after: Optional[float] = None,
|
|
51
|
+
request_id: Optional[str] = None,
|
|
52
|
+
method: Optional[str] = None,
|
|
53
|
+
url: Optional[str] = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
super().__init__(message)
|
|
56
|
+
self.message = message
|
|
57
|
+
self.status = status
|
|
58
|
+
self.code = code
|
|
59
|
+
self.body = body
|
|
60
|
+
#: The server's "what to do now" for this error, if it sent one.
|
|
61
|
+
self.hint = hint
|
|
62
|
+
#: Link to the API docs the server sent with the error, if any.
|
|
63
|
+
self.docs = docs
|
|
64
|
+
self.retry_after = retry_after
|
|
65
|
+
self.request_id = request_id
|
|
66
|
+
self.method = method
|
|
67
|
+
self.url = url
|
|
68
|
+
|
|
69
|
+
def __str__(self) -> str:
|
|
70
|
+
if self.status:
|
|
71
|
+
return f"{self.message} (HTTP {self.status}, {self.code})"
|
|
72
|
+
return f"{self.message} ({self.code})"
|
|
73
|
+
|
|
74
|
+
def __repr__(self) -> str:
|
|
75
|
+
return f"GuidonError(status={self.status!r}, code={self.code!r}, message={self.message!r})"
|
guidon/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: guidon-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Guidon REST API (/api/v1).
|
|
5
|
+
Project-URL: Homepage, https://useguidon.com
|
|
6
|
+
Project-URL: Source, https://github.com/Two-Steps-Studio/guidon
|
|
7
|
+
Project-URL: Documentation, https://github.com/Two-Steps-Studio/guidon/tree/main/sdk/python#readme
|
|
8
|
+
Project-URL: Issues, https://github.com/Two-Steps-Studio/guidon/issues
|
|
9
|
+
Author: Two Steps Studio
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api,guidon,project-management,sdk,tasks
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# guidon-sdk - Python SDK for Guidon
|
|
28
|
+
|
|
29
|
+
Official Python client for the [Guidon](https://useguidon.com) REST API
|
|
30
|
+
(`/api/v1`): projects, tasks, subtasks, comments, attempts, bug reports and
|
|
31
|
+
project knowledge (decisions, knowledge sources, roadmap, memory, tech stack).
|
|
32
|
+
|
|
33
|
+
- Standard library only (`urllib`), no dependencies.
|
|
34
|
+
- Python 3.9+.
|
|
35
|
+
- Works with Guidon Cloud (`https://useguidon.com`) and self-hosted instances.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
The PyPI release is pending. The command below will work after the `guidon-sdk` package is published. The import name remains `guidon`.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install guidon-sdk
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Authenticate
|
|
46
|
+
|
|
47
|
+
Create an API key in Guidon: **Profile -> API keys**. A key acts as you, limited
|
|
48
|
+
to the scopes you gave it (`tasks:read`, `tasks:write`, `tasks:status`,
|
|
49
|
+
`comments:write`, `attempts:write`, `project:read`, `project:write`,
|
|
50
|
+
`reports:write`).
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from guidon import Guidon
|
|
54
|
+
|
|
55
|
+
guidon = Guidon(api_key="guidon_...") # or set GUIDON_API_KEY
|
|
56
|
+
# Self-hosted:
|
|
57
|
+
guidon = Guidon(api_key="guidon_...", base_url="https://guidon.example.com") # or GUIDON_BASE_URL
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
project = guidon.projects.list()[0]
|
|
64
|
+
|
|
65
|
+
columns = guidon.projects.columns(project["id"]) # the board's column keys
|
|
66
|
+
todo = guidon.tasks.list(project["id"], status="todo") # filters applied client-side
|
|
67
|
+
|
|
68
|
+
task = guidon.tasks.create(project["id"], "Fix the jump", priority="high", status="todo")
|
|
69
|
+
guidon.tasks.update(task["id"], description="Feels floaty on 30 Hz monitors")
|
|
70
|
+
guidon.tasks.move(task["id"], "in_progress")
|
|
71
|
+
guidon.comments.create(task["id"], "Started on this")
|
|
72
|
+
|
|
73
|
+
sub = guidon.subtasks.create(project["id"], task["id"], "Write a repro")
|
|
74
|
+
print(guidon.tasks.context(task["id"])) # Markdown working context
|
|
75
|
+
|
|
76
|
+
guidon.attempts.record(
|
|
77
|
+
task["id"], problem="Jump height varies", approach="Fixed timestep", outcome="partial",
|
|
78
|
+
files_changed=["src/player.gd"],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
guidon.decisions.create(project["id"], title="Use Godot 4.4", decision_type="technical")
|
|
82
|
+
guidon.memory.create(project["id"], content="Builds ship from main only", memory_type="project_rule")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Attribute | Methods |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `projects` | `list()`, `columns(pid)`, `priorities(pid)`, `references(pid)` |
|
|
88
|
+
| `tasks` | `list(pid, status=, parent_task_id=)`, `list_page(pid, limit=, cursor=)`, `iter(pid)`, `get`, `create`, `update`, `move`, `delete`, `start`, `complete`, `context` |
|
|
89
|
+
| `subtasks` | `list(pid, parent_id)`, `create(pid, parent_id, title, ...)` |
|
|
90
|
+
| `comments` | `list(task_id)`, `create(task_id, content)` |
|
|
91
|
+
| `attempts` | `list(task_id)`, `record(task_id, problem=, approach=, outcome=, ...)` |
|
|
92
|
+
| `reports` | `file(pid, title, files=[(name, bytes, content_type)], ...)` |
|
|
93
|
+
| `decisions`, `knowledge`, `roadmap`, `memory`, `technologies` | `list(pid)`, `get(pid, id)`, `create(pid, **fields)`, `update(pid, id, **fields)`, `delete(pid, id)` |
|
|
94
|
+
| `request(method, path, query=, json=)` | escape hatch for routes not wrapped yet |
|
|
95
|
+
|
|
96
|
+
Results are the API's JSON objects as plain `dict`s.
|
|
97
|
+
|
|
98
|
+
### Idempotency
|
|
99
|
+
|
|
100
|
+
Every write method takes `idempotency_key=...`, sent as the `Idempotency-Key`
|
|
101
|
+
header. Reuse the same key when retrying the same logical write. With a key, the
|
|
102
|
+
SDK also retries the write automatically on 429/502/503/504 and network errors.
|
|
103
|
+
|
|
104
|
+
### Errors
|
|
105
|
+
|
|
106
|
+
Everything raises `guidon.GuidonError`:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from guidon import GuidonError
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
guidon.tasks.move(task_id, "done")
|
|
113
|
+
except GuidonError as e:
|
|
114
|
+
print(e.status, e.code, e.message) # 404 not_found Task not found.
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- `status`: HTTP status, `0` when no response was received.
|
|
118
|
+
- `code`: the server's `code` if it sent one, else derived from the status with
|
|
119
|
+
the server's own table (`invalid_request`, `unauthorized`, `forbidden`,
|
|
120
|
+
`not_found`, `conflict`, `unprocessable`, `rate_limited`, `internal_error`,
|
|
121
|
+
`unavailable`...); or `network_error`, `timeout`, `invalid_response`,
|
|
122
|
+
`missing_api_key`, `invalid_base_url` when there was no usable response.
|
|
123
|
+
- `message`: the `error` text from the JSON body (`{"error": "..."}`).
|
|
124
|
+
- `hint`, `docs`: the server's "what to do now" and docs link, when sent.
|
|
125
|
+
- also `body`, `retry_after`, `request_id`, `method`, `url`.
|
|
126
|
+
|
|
127
|
+
GET requests are retried (default `max_retries=2`) on 429/502/503/504 and network
|
|
128
|
+
errors, honouring `Retry-After`. Writes are retried only with an
|
|
129
|
+
`idempotency_key` (then also on 409 `idempotency_in_progress`).
|
|
130
|
+
|
|
131
|
+
### Pagination
|
|
132
|
+
|
|
133
|
+
The task list is cursor-paginated (newest first, up to 1000 per page):
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
for task in guidon.tasks.iter(project_id, page_size=200): # fetched page by page
|
|
137
|
+
print(task["title"])
|
|
138
|
+
|
|
139
|
+
page = guidon.tasks.list_page(project_id, limit=100) # {"tasks": [...], "next_cursor": ...}
|
|
140
|
+
while page["next_cursor"]:
|
|
141
|
+
page = guidon.tasks.list_page(project_id, limit=100, cursor=page["next_cursor"])
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`tasks.list()` fetches every page and returns a list; its `status` and
|
|
145
|
+
`parent_task_id` filters run client-side (the API has no server-side filters).
|
|
146
|
+
Against a server that doesn't paginate yet, the first page is the whole list.
|
|
147
|
+
The other list endpoints return everything in one response (up to 500 items
|
|
148
|
+
for the knowledge collections).
|
|
149
|
+
|
|
150
|
+
## Development
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
cd sdk/python
|
|
154
|
+
python -m unittest discover -s tests -v # mock http.server, no network
|
|
155
|
+
python -m build # needs `pip install build`
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Links: [Homepage](https://useguidon.com) - [Source](https://github.com/Two-Steps-Studio/guidon/tree/main/sdk/python) - API reference: `https://useguidon.com/openapi.json`
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT - see [LICENSE](LICENSE). This licence covers this SDK/CLI package, not the Guidon service or the rest of the application.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
guidon/__init__.py,sha256=Um1CYM9175bAOracdBJrdyqtzxaga4_uz68p8JTfzOE,731
|
|
2
|
+
guidon/_http.py,sha256=d5iVnQk3VCLWNEB-5MZeTiMNmbagvAUGiY--OAQrMjY,11161
|
|
3
|
+
guidon/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
4
|
+
guidon/client.py,sha256=ssMUExNLbtU7ksGW7BE4aeiQgAKy8aTSeail1DSypxk,16170
|
|
5
|
+
guidon/errors.py,sha256=xNj-cuoWQYXyWOHF1IhGHb4voBoXN2Pbs9xHqkqSNX4,2420
|
|
6
|
+
guidon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
guidon_sdk-0.1.0.dist-info/METADATA,sha256=Ueq5lQTB9_lTB1Zg4OtBY2X5iBznd19SNck2vjnEICY,6719
|
|
8
|
+
guidon_sdk-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
9
|
+
guidon_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=TPC4SnKUN7xUYDoGsFu2MmRMiqBVKabLkVuG3MSd2iE,1076
|
|
10
|
+
guidon_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Guidon contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|