guidon-sdk 0.1.0__tar.gz

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,6 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .venv/
@@ -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.
@@ -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,136 @@
1
+ # guidon-sdk - Python SDK for Guidon
2
+
3
+ Official Python client for the [Guidon](https://useguidon.com) REST API
4
+ (`/api/v1`): projects, tasks, subtasks, comments, attempts, bug reports and
5
+ project knowledge (decisions, knowledge sources, roadmap, memory, tech stack).
6
+
7
+ - Standard library only (`urllib`), no dependencies.
8
+ - Python 3.9+.
9
+ - Works with Guidon Cloud (`https://useguidon.com`) and self-hosted instances.
10
+
11
+ ## Install
12
+
13
+ The PyPI release is pending. The command below will work after the `guidon-sdk` package is published. The import name remains `guidon`.
14
+
15
+ ```bash
16
+ pip install guidon-sdk
17
+ ```
18
+
19
+ ## Authenticate
20
+
21
+ Create an API key in Guidon: **Profile -> API keys**. A key acts as you, limited
22
+ to the scopes you gave it (`tasks:read`, `tasks:write`, `tasks:status`,
23
+ `comments:write`, `attempts:write`, `project:read`, `project:write`,
24
+ `reports:write`).
25
+
26
+ ```python
27
+ from guidon import Guidon
28
+
29
+ guidon = Guidon(api_key="guidon_...") # or set GUIDON_API_KEY
30
+ # Self-hosted:
31
+ guidon = Guidon(api_key="guidon_...", base_url="https://guidon.example.com") # or GUIDON_BASE_URL
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```python
37
+ project = guidon.projects.list()[0]
38
+
39
+ columns = guidon.projects.columns(project["id"]) # the board's column keys
40
+ todo = guidon.tasks.list(project["id"], status="todo") # filters applied client-side
41
+
42
+ task = guidon.tasks.create(project["id"], "Fix the jump", priority="high", status="todo")
43
+ guidon.tasks.update(task["id"], description="Feels floaty on 30 Hz monitors")
44
+ guidon.tasks.move(task["id"], "in_progress")
45
+ guidon.comments.create(task["id"], "Started on this")
46
+
47
+ sub = guidon.subtasks.create(project["id"], task["id"], "Write a repro")
48
+ print(guidon.tasks.context(task["id"])) # Markdown working context
49
+
50
+ guidon.attempts.record(
51
+ task["id"], problem="Jump height varies", approach="Fixed timestep", outcome="partial",
52
+ files_changed=["src/player.gd"],
53
+ )
54
+
55
+ guidon.decisions.create(project["id"], title="Use Godot 4.4", decision_type="technical")
56
+ guidon.memory.create(project["id"], content="Builds ship from main only", memory_type="project_rule")
57
+ ```
58
+
59
+ | Attribute | Methods |
60
+ | --- | --- |
61
+ | `projects` | `list()`, `columns(pid)`, `priorities(pid)`, `references(pid)` |
62
+ | `tasks` | `list(pid, status=, parent_task_id=)`, `list_page(pid, limit=, cursor=)`, `iter(pid)`, `get`, `create`, `update`, `move`, `delete`, `start`, `complete`, `context` |
63
+ | `subtasks` | `list(pid, parent_id)`, `create(pid, parent_id, title, ...)` |
64
+ | `comments` | `list(task_id)`, `create(task_id, content)` |
65
+ | `attempts` | `list(task_id)`, `record(task_id, problem=, approach=, outcome=, ...)` |
66
+ | `reports` | `file(pid, title, files=[(name, bytes, content_type)], ...)` |
67
+ | `decisions`, `knowledge`, `roadmap`, `memory`, `technologies` | `list(pid)`, `get(pid, id)`, `create(pid, **fields)`, `update(pid, id, **fields)`, `delete(pid, id)` |
68
+ | `request(method, path, query=, json=)` | escape hatch for routes not wrapped yet |
69
+
70
+ Results are the API's JSON objects as plain `dict`s.
71
+
72
+ ### Idempotency
73
+
74
+ Every write method takes `idempotency_key=...`, sent as the `Idempotency-Key`
75
+ header. Reuse the same key when retrying the same logical write. With a key, the
76
+ SDK also retries the write automatically on 429/502/503/504 and network errors.
77
+
78
+ ### Errors
79
+
80
+ Everything raises `guidon.GuidonError`:
81
+
82
+ ```python
83
+ from guidon import GuidonError
84
+
85
+ try:
86
+ guidon.tasks.move(task_id, "done")
87
+ except GuidonError as e:
88
+ print(e.status, e.code, e.message) # 404 not_found Task not found.
89
+ ```
90
+
91
+ - `status`: HTTP status, `0` when no response was received.
92
+ - `code`: the server's `code` if it sent one, else derived from the status with
93
+ the server's own table (`invalid_request`, `unauthorized`, `forbidden`,
94
+ `not_found`, `conflict`, `unprocessable`, `rate_limited`, `internal_error`,
95
+ `unavailable`...); or `network_error`, `timeout`, `invalid_response`,
96
+ `missing_api_key`, `invalid_base_url` when there was no usable response.
97
+ - `message`: the `error` text from the JSON body (`{"error": "..."}`).
98
+ - `hint`, `docs`: the server's "what to do now" and docs link, when sent.
99
+ - also `body`, `retry_after`, `request_id`, `method`, `url`.
100
+
101
+ GET requests are retried (default `max_retries=2`) on 429/502/503/504 and network
102
+ errors, honouring `Retry-After`. Writes are retried only with an
103
+ `idempotency_key` (then also on 409 `idempotency_in_progress`).
104
+
105
+ ### Pagination
106
+
107
+ The task list is cursor-paginated (newest first, up to 1000 per page):
108
+
109
+ ```python
110
+ for task in guidon.tasks.iter(project_id, page_size=200): # fetched page by page
111
+ print(task["title"])
112
+
113
+ page = guidon.tasks.list_page(project_id, limit=100) # {"tasks": [...], "next_cursor": ...}
114
+ while page["next_cursor"]:
115
+ page = guidon.tasks.list_page(project_id, limit=100, cursor=page["next_cursor"])
116
+ ```
117
+
118
+ `tasks.list()` fetches every page and returns a list; its `status` and
119
+ `parent_task_id` filters run client-side (the API has no server-side filters).
120
+ Against a server that doesn't paginate yet, the first page is the whole list.
121
+ The other list endpoints return everything in one response (up to 500 items
122
+ for the knowledge collections).
123
+
124
+ ## Development
125
+
126
+ ```bash
127
+ cd sdk/python
128
+ python -m unittest discover -s tests -v # mock http.server, no network
129
+ python -m build # needs `pip install build`
130
+ ```
131
+
132
+ Links: [Homepage](https://useguidon.com) - [Source](https://github.com/Two-Steps-Studio/guidon/tree/main/sdk/python) - API reference: `https://useguidon.com/openapi.json`
133
+
134
+ ## License
135
+
136
+ MIT - see [LICENSE](LICENSE). This licence covers this SDK/CLI package, not the Guidon service or the rest of the application.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "guidon-sdk"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the Guidon REST API (/api/v1)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Two Steps Studio" }]
14
+ keywords = ["guidon", "project-management", "tasks", "api", "sdk"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Software Development :: Libraries",
26
+ "Typing :: Typed",
27
+ ]
28
+ dependencies = []
29
+
30
+ [project.urls]
31
+ Homepage = "https://useguidon.com"
32
+ Source = "https://github.com/Two-Steps-Studio/guidon"
33
+ Documentation = "https://github.com/Two-Steps-Studio/guidon/tree/main/sdk/python#readme"
34
+ Issues = "https://github.com/Two-Steps-Studio/guidon/issues"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/guidon"]
38
+
39
+ [tool.hatch.build.targets.sdist]
40
+ include = ["src/guidon", "tests", "README.md", "pyproject.toml", "LICENSE"]
@@ -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
+ ]
@@ -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
+ )
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"