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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.