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.
- guidon_sdk-0.1.0/.gitignore +6 -0
- guidon_sdk-0.1.0/LICENSE +21 -0
- guidon_sdk-0.1.0/PKG-INFO +162 -0
- guidon_sdk-0.1.0/README.md +136 -0
- guidon_sdk-0.1.0/pyproject.toml +40 -0
- guidon_sdk-0.1.0/src/guidon/__init__.py +33 -0
- guidon_sdk-0.1.0/src/guidon/_http.py +273 -0
- guidon_sdk-0.1.0/src/guidon/_version.py +1 -0
- guidon_sdk-0.1.0/src/guidon/client.py +399 -0
- guidon_sdk-0.1.0/src/guidon/errors.py +75 -0
- guidon_sdk-0.1.0/src/guidon/py.typed +0 -0
- guidon_sdk-0.1.0/tests/__init__.py +0 -0
- guidon_sdk-0.1.0/tests/test_client.py +468 -0
guidon_sdk-0.1.0/LICENSE
ADDED
|
@@ -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"
|