pyclann 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.
- pyclann-0.1.0/PKG-INFO +208 -0
- pyclann-0.1.0/README.md +177 -0
- pyclann-0.1.0/pyclann/__init__.py +57 -0
- pyclann-0.1.0/pyclann/_http.py +145 -0
- pyclann-0.1.0/pyclann/client.py +1371 -0
- pyclann-0.1.0/pyclann/exceptions.py +23 -0
- pyclann-0.1.0/pyclann/models.py +500 -0
- pyclann-0.1.0/pyclann.egg-info/PKG-INFO +208 -0
- pyclann-0.1.0/pyclann.egg-info/SOURCES.txt +16 -0
- pyclann-0.1.0/pyclann.egg-info/dependency_links.txt +1 -0
- pyclann-0.1.0/pyclann.egg-info/requires.txt +13 -0
- pyclann-0.1.0/pyclann.egg-info/top_level.txt +1 -0
- pyclann-0.1.0/pyproject.toml +67 -0
- pyclann-0.1.0/setup.cfg +4 -0
- pyclann-0.1.0/tests/test_client.py +718 -0
- pyclann-0.1.0/tests/test_exceptions.py +41 -0
- pyclann-0.1.0/tests/test_http.py +263 -0
- pyclann-0.1.0/tests/test_models.py +444 -0
pyclann-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyclann
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client library for the Clann family-tree API
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/ullav-dev/pyclann
|
|
7
|
+
Project-URL: Documentation, https://ullav-dev.github.io/pyclann/
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/ullav-dev/pyclann/issues
|
|
9
|
+
Keywords: clann,genealogy,family-tree,ullav
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: requests>=2.28
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-cov>=4; extra == "dev"
|
|
24
|
+
Requires-Dist: responses>=0.23; extra == "dev"
|
|
25
|
+
Requires-Dist: ruff; extra == "dev"
|
|
26
|
+
Requires-Dist: mypy; extra == "dev"
|
|
27
|
+
Requires-Dist: types-requests; extra == "dev"
|
|
28
|
+
Provides-Extra: docs
|
|
29
|
+
Requires-Dist: mkdocs-material>=9; extra == "docs"
|
|
30
|
+
Requires-Dist: mkdocstrings[python]>=0.25; extra == "docs"
|
|
31
|
+
|
|
32
|
+
# pyclann
|
|
33
|
+
|
|
34
|
+
Python client library for the [Clann](https://github.com/colinmanning/clann-server) family-tree API.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install pyclann
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from pyclann import ClannClient
|
|
46
|
+
|
|
47
|
+
client = ClannClient(
|
|
48
|
+
api_url="http://localhost:8090",
|
|
49
|
+
auth_url="http://localhost:8081", # ullav-user-management service
|
|
50
|
+
)
|
|
51
|
+
client.login(email="user@example.com", password="secret")
|
|
52
|
+
|
|
53
|
+
# Family trees
|
|
54
|
+
trees = client.trees.list(owner="alice")
|
|
55
|
+
tree = client.trees.create("walsh-family", "Walsh Family", owner="alice")
|
|
56
|
+
|
|
57
|
+
# Persons
|
|
58
|
+
father = client.persons.create(
|
|
59
|
+
"Walsh", "Patrick", "Male",
|
|
60
|
+
trees=["walsh-family"],
|
|
61
|
+
date_of_birth="1820-06-01",
|
|
62
|
+
created_by="alice",
|
|
63
|
+
)
|
|
64
|
+
son = client.persons.create(
|
|
65
|
+
"Walsh", "Michael", "Male",
|
|
66
|
+
trees=["walsh-family"],
|
|
67
|
+
created_by="alice",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Relationships
|
|
71
|
+
client.relationships.add(son.id, "Father", father.id)
|
|
72
|
+
rels = client.relationships.get(son.id) # RelationshipsResponse
|
|
73
|
+
|
|
74
|
+
# Life events
|
|
75
|
+
client.life_events.create(
|
|
76
|
+
father.id, "Born in Galway", "Birth",
|
|
77
|
+
date="1820-06-01",
|
|
78
|
+
created_by="alice",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Research notes
|
|
82
|
+
note = client.notes.create(
|
|
83
|
+
"Walsh Family Research",
|
|
84
|
+
trees=["walsh-family"],
|
|
85
|
+
body="Found records at Galway archives.",
|
|
86
|
+
is_shared=True,
|
|
87
|
+
created_by="alice",
|
|
88
|
+
)
|
|
89
|
+
client.notes.create_reply(note.id, "Also check Dublin records.", created_by="alice")
|
|
90
|
+
|
|
91
|
+
# Profile picture
|
|
92
|
+
with open("patrick.jpg", "rb") as f:
|
|
93
|
+
client.persons.upload_image(father.id, f.read(), "image/jpeg")
|
|
94
|
+
|
|
95
|
+
image_bytes = client.persons.get_image(father.id) # public endpoint, no auth needed
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Authentication
|
|
99
|
+
|
|
100
|
+
`ClannClient` authenticates against the `ullav-user-management` service, which issues
|
|
101
|
+
the JWT accepted by the Clann server.
|
|
102
|
+
|
|
103
|
+
- `api_url` — Clann server base URL (e.g. `http://clann-server:8090`)
|
|
104
|
+
- `auth_url` — auth service base URL (e.g. `http://ullav-user-management:8081`);
|
|
105
|
+
omit if both services are behind the same proxy
|
|
106
|
+
|
|
107
|
+
Call `client.login(email, password)` before any other method. Tokens expire according
|
|
108
|
+
to the server's configuration; call `login()` again to refresh.
|
|
109
|
+
|
|
110
|
+
## Resource clients
|
|
111
|
+
|
|
112
|
+
| Attribute | Resource |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `client.trees` | Family tree CRUD, primary flag, team assignment, avatar image |
|
|
115
|
+
| `client.persons` | Person CRUD, tree membership, profile/life-story media |
|
|
116
|
+
| `client.relationships` | Add/remove father/mother/sibling/spouse edges, family-tree view |
|
|
117
|
+
| `client.life_events` | Life event CRUD per person |
|
|
118
|
+
| `client.notes` | Research note CRUD, folder assignment, replies |
|
|
119
|
+
| `client.folders` | Research folder CRUD |
|
|
120
|
+
| `client.chat` | AI chat session and message management |
|
|
121
|
+
| `client.ai_settings` | Per-user AI provider settings (encrypted BYOK) |
|
|
122
|
+
|
|
123
|
+
## Error handling
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from pyclann import ClannAuthError, ClannNotFoundError, ClannValidationError
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
tree = client.trees.get("non-existent-tree")
|
|
130
|
+
except ClannNotFoundError:
|
|
131
|
+
print("tree not found")
|
|
132
|
+
except ClannAuthError:
|
|
133
|
+
client.login(email, password) # token expired — re-authenticate
|
|
134
|
+
except ClannValidationError as e:
|
|
135
|
+
print("bad request:", e)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
| Exception | HTTP status |
|
|
139
|
+
|---|---|
|
|
140
|
+
| `ClannAuthError` | 401 / 403, or `login()` not called |
|
|
141
|
+
| `ClannNotFoundError` | 404 |
|
|
142
|
+
| `ClannValidationError` | 400 |
|
|
143
|
+
| `ClannServerError` | 5xx |
|
|
144
|
+
| `ClannError` | base class |
|
|
145
|
+
|
|
146
|
+
## Record IDs
|
|
147
|
+
|
|
148
|
+
The Clann API uses SurrealDB record IDs of the form `"table:ulid"`, e.g.
|
|
149
|
+
`"person:01jd4a8xyz"`. All client methods that take an `*_id` parameter accept
|
|
150
|
+
either the full form or the bare ULID — the library strips the table prefix when
|
|
151
|
+
building URL paths.
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
person = client.persons.get("person:01jd4a8xyz") # full ID
|
|
155
|
+
person = client.persons.get("01jd4a8xyz") # bare ULID — both work
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Note: when specifying a `related_id` in relationship calls, always pass the **full**
|
|
159
|
+
record ID (`"person:01jd4a8xyz"`), since the server uses it verbatim in URL paths.
|
|
160
|
+
|
|
161
|
+
## Relationship types
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from pyclann import RelationshipType, SiblingType
|
|
165
|
+
|
|
166
|
+
# Add a father
|
|
167
|
+
client.relationships.add(child.id, RelationshipType.FATHER, father.id)
|
|
168
|
+
|
|
169
|
+
# Add a sibling — sibling_type is required
|
|
170
|
+
client.relationships.add(
|
|
171
|
+
person.id,
|
|
172
|
+
RelationshipType.SIBLING,
|
|
173
|
+
sibling.id,
|
|
174
|
+
sibling_type=SiblingType.BROTHER,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Add a spouse with dates
|
|
178
|
+
client.relationships.add(
|
|
179
|
+
person.id,
|
|
180
|
+
RelationshipType.SPOUSE,
|
|
181
|
+
spouse.id,
|
|
182
|
+
spouse_from="1845-09-14",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Remove a relationship
|
|
186
|
+
client.relationships.remove(child.id, RelationshipType.FATHER, father.id)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Image uploads
|
|
190
|
+
|
|
191
|
+
Profile pictures accept JPEG or PNG only (max 2 MB). Life-story media accepts
|
|
192
|
+
the same image formats on Individual/Family plans; Professional/Enterprise plans
|
|
193
|
+
also allow video (MP4, MOV, WebM), audio (MP3, WAV, OGG), and PDF.
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
# Upload
|
|
197
|
+
with open("profile.jpg", "rb") as f:
|
|
198
|
+
client.persons.upload_image(person.id, f.read(), "image/jpeg")
|
|
199
|
+
|
|
200
|
+
# Download — no login() required
|
|
201
|
+
data = client.persons.get_image(person.id)
|
|
202
|
+
with open("downloaded.jpg", "wb") as f:
|
|
203
|
+
f.write(data)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Licence
|
|
207
|
+
|
|
208
|
+
MIT
|
pyclann-0.1.0/README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# pyclann
|
|
2
|
+
|
|
3
|
+
Python client library for the [Clann](https://github.com/colinmanning/clann-server) family-tree API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install pyclann
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from pyclann import ClannClient
|
|
15
|
+
|
|
16
|
+
client = ClannClient(
|
|
17
|
+
api_url="http://localhost:8090",
|
|
18
|
+
auth_url="http://localhost:8081", # ullav-user-management service
|
|
19
|
+
)
|
|
20
|
+
client.login(email="user@example.com", password="secret")
|
|
21
|
+
|
|
22
|
+
# Family trees
|
|
23
|
+
trees = client.trees.list(owner="alice")
|
|
24
|
+
tree = client.trees.create("walsh-family", "Walsh Family", owner="alice")
|
|
25
|
+
|
|
26
|
+
# Persons
|
|
27
|
+
father = client.persons.create(
|
|
28
|
+
"Walsh", "Patrick", "Male",
|
|
29
|
+
trees=["walsh-family"],
|
|
30
|
+
date_of_birth="1820-06-01",
|
|
31
|
+
created_by="alice",
|
|
32
|
+
)
|
|
33
|
+
son = client.persons.create(
|
|
34
|
+
"Walsh", "Michael", "Male",
|
|
35
|
+
trees=["walsh-family"],
|
|
36
|
+
created_by="alice",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Relationships
|
|
40
|
+
client.relationships.add(son.id, "Father", father.id)
|
|
41
|
+
rels = client.relationships.get(son.id) # RelationshipsResponse
|
|
42
|
+
|
|
43
|
+
# Life events
|
|
44
|
+
client.life_events.create(
|
|
45
|
+
father.id, "Born in Galway", "Birth",
|
|
46
|
+
date="1820-06-01",
|
|
47
|
+
created_by="alice",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Research notes
|
|
51
|
+
note = client.notes.create(
|
|
52
|
+
"Walsh Family Research",
|
|
53
|
+
trees=["walsh-family"],
|
|
54
|
+
body="Found records at Galway archives.",
|
|
55
|
+
is_shared=True,
|
|
56
|
+
created_by="alice",
|
|
57
|
+
)
|
|
58
|
+
client.notes.create_reply(note.id, "Also check Dublin records.", created_by="alice")
|
|
59
|
+
|
|
60
|
+
# Profile picture
|
|
61
|
+
with open("patrick.jpg", "rb") as f:
|
|
62
|
+
client.persons.upload_image(father.id, f.read(), "image/jpeg")
|
|
63
|
+
|
|
64
|
+
image_bytes = client.persons.get_image(father.id) # public endpoint, no auth needed
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Authentication
|
|
68
|
+
|
|
69
|
+
`ClannClient` authenticates against the `ullav-user-management` service, which issues
|
|
70
|
+
the JWT accepted by the Clann server.
|
|
71
|
+
|
|
72
|
+
- `api_url` — Clann server base URL (e.g. `http://clann-server:8090`)
|
|
73
|
+
- `auth_url` — auth service base URL (e.g. `http://ullav-user-management:8081`);
|
|
74
|
+
omit if both services are behind the same proxy
|
|
75
|
+
|
|
76
|
+
Call `client.login(email, password)` before any other method. Tokens expire according
|
|
77
|
+
to the server's configuration; call `login()` again to refresh.
|
|
78
|
+
|
|
79
|
+
## Resource clients
|
|
80
|
+
|
|
81
|
+
| Attribute | Resource |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `client.trees` | Family tree CRUD, primary flag, team assignment, avatar image |
|
|
84
|
+
| `client.persons` | Person CRUD, tree membership, profile/life-story media |
|
|
85
|
+
| `client.relationships` | Add/remove father/mother/sibling/spouse edges, family-tree view |
|
|
86
|
+
| `client.life_events` | Life event CRUD per person |
|
|
87
|
+
| `client.notes` | Research note CRUD, folder assignment, replies |
|
|
88
|
+
| `client.folders` | Research folder CRUD |
|
|
89
|
+
| `client.chat` | AI chat session and message management |
|
|
90
|
+
| `client.ai_settings` | Per-user AI provider settings (encrypted BYOK) |
|
|
91
|
+
|
|
92
|
+
## Error handling
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from pyclann import ClannAuthError, ClannNotFoundError, ClannValidationError
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
tree = client.trees.get("non-existent-tree")
|
|
99
|
+
except ClannNotFoundError:
|
|
100
|
+
print("tree not found")
|
|
101
|
+
except ClannAuthError:
|
|
102
|
+
client.login(email, password) # token expired — re-authenticate
|
|
103
|
+
except ClannValidationError as e:
|
|
104
|
+
print("bad request:", e)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
| Exception | HTTP status |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `ClannAuthError` | 401 / 403, or `login()` not called |
|
|
110
|
+
| `ClannNotFoundError` | 404 |
|
|
111
|
+
| `ClannValidationError` | 400 |
|
|
112
|
+
| `ClannServerError` | 5xx |
|
|
113
|
+
| `ClannError` | base class |
|
|
114
|
+
|
|
115
|
+
## Record IDs
|
|
116
|
+
|
|
117
|
+
The Clann API uses SurrealDB record IDs of the form `"table:ulid"`, e.g.
|
|
118
|
+
`"person:01jd4a8xyz"`. All client methods that take an `*_id` parameter accept
|
|
119
|
+
either the full form or the bare ULID — the library strips the table prefix when
|
|
120
|
+
building URL paths.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
person = client.persons.get("person:01jd4a8xyz") # full ID
|
|
124
|
+
person = client.persons.get("01jd4a8xyz") # bare ULID — both work
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Note: when specifying a `related_id` in relationship calls, always pass the **full**
|
|
128
|
+
record ID (`"person:01jd4a8xyz"`), since the server uses it verbatim in URL paths.
|
|
129
|
+
|
|
130
|
+
## Relationship types
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from pyclann import RelationshipType, SiblingType
|
|
134
|
+
|
|
135
|
+
# Add a father
|
|
136
|
+
client.relationships.add(child.id, RelationshipType.FATHER, father.id)
|
|
137
|
+
|
|
138
|
+
# Add a sibling — sibling_type is required
|
|
139
|
+
client.relationships.add(
|
|
140
|
+
person.id,
|
|
141
|
+
RelationshipType.SIBLING,
|
|
142
|
+
sibling.id,
|
|
143
|
+
sibling_type=SiblingType.BROTHER,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Add a spouse with dates
|
|
147
|
+
client.relationships.add(
|
|
148
|
+
person.id,
|
|
149
|
+
RelationshipType.SPOUSE,
|
|
150
|
+
spouse.id,
|
|
151
|
+
spouse_from="1845-09-14",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Remove a relationship
|
|
155
|
+
client.relationships.remove(child.id, RelationshipType.FATHER, father.id)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Image uploads
|
|
159
|
+
|
|
160
|
+
Profile pictures accept JPEG or PNG only (max 2 MB). Life-story media accepts
|
|
161
|
+
the same image formats on Individual/Family plans; Professional/Enterprise plans
|
|
162
|
+
also allow video (MP4, MOV, WebM), audio (MP3, WAV, OGG), and PDF.
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
# Upload
|
|
166
|
+
with open("profile.jpg", "rb") as f:
|
|
167
|
+
client.persons.upload_image(person.id, f.read(), "image/jpeg")
|
|
168
|
+
|
|
169
|
+
# Download — no login() required
|
|
170
|
+
data = client.persons.get_image(person.id)
|
|
171
|
+
with open("downloaded.jpg", "wb") as f:
|
|
172
|
+
f.write(data)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Licence
|
|
176
|
+
|
|
177
|
+
MIT
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""pyclann — Python client library for the Clann family-tree API."""
|
|
2
|
+
|
|
3
|
+
from .client import ClannClient
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
ClannAuthError,
|
|
6
|
+
ClannError,
|
|
7
|
+
ClannNotFoundError,
|
|
8
|
+
ClannServerError,
|
|
9
|
+
ClannValidationError,
|
|
10
|
+
)
|
|
11
|
+
from .models import (
|
|
12
|
+
ChatMessage,
|
|
13
|
+
ChatSession,
|
|
14
|
+
EventType,
|
|
15
|
+
FamilyTree,
|
|
16
|
+
FamilyTreeNode,
|
|
17
|
+
LifeEvent,
|
|
18
|
+
LoginInfo,
|
|
19
|
+
Person,
|
|
20
|
+
RelationshipsResponse,
|
|
21
|
+
RelationshipType,
|
|
22
|
+
ResearchFolder,
|
|
23
|
+
ResearchNote,
|
|
24
|
+
Sex,
|
|
25
|
+
SiblingType,
|
|
26
|
+
SpouseInfo,
|
|
27
|
+
UserAiSettings,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
# Client
|
|
32
|
+
"ClannClient",
|
|
33
|
+
# Exceptions
|
|
34
|
+
"ClannError",
|
|
35
|
+
"ClannAuthError",
|
|
36
|
+
"ClannNotFoundError",
|
|
37
|
+
"ClannValidationError",
|
|
38
|
+
"ClannServerError",
|
|
39
|
+
# Enumerations
|
|
40
|
+
"Sex",
|
|
41
|
+
"RelationshipType",
|
|
42
|
+
"SiblingType",
|
|
43
|
+
"EventType",
|
|
44
|
+
# Models
|
|
45
|
+
"LoginInfo",
|
|
46
|
+
"FamilyTree",
|
|
47
|
+
"Person",
|
|
48
|
+
"LifeEvent",
|
|
49
|
+
"SpouseInfo",
|
|
50
|
+
"RelationshipsResponse",
|
|
51
|
+
"FamilyTreeNode",
|
|
52
|
+
"ResearchNote",
|
|
53
|
+
"ResearchFolder",
|
|
54
|
+
"ChatSession",
|
|
55
|
+
"ChatMessage",
|
|
56
|
+
"UserAiSettings",
|
|
57
|
+
]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Internal HTTP session: request dispatch and error mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from .exceptions import (
|
|
10
|
+
ClannAuthError,
|
|
11
|
+
ClannError,
|
|
12
|
+
ClannNotFoundError,
|
|
13
|
+
ClannServerError,
|
|
14
|
+
ClannValidationError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _compact(d: dict[str, Any]) -> dict[str, Any]:
|
|
19
|
+
"""Return a copy of *d* with all ``None``-valued keys removed."""
|
|
20
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _bare_id(record_id: str) -> str:
|
|
24
|
+
"""Strip the SurrealDB table prefix from a record ID for use in URL paths.
|
|
25
|
+
|
|
26
|
+
``"person:01jd4a8xyz"`` → ``"01jd4a8xyz"``. Bare IDs pass through unchanged.
|
|
27
|
+
"""
|
|
28
|
+
return record_id.split(":", 1)[-1] if ":" in record_id else record_id
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _HttpSession:
|
|
32
|
+
"""Thin wrapper around :class:`requests.Session` that handles auth headers and
|
|
33
|
+
maps non-2xx responses to typed exceptions."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, api_url: str, auth_url: str) -> None:
|
|
36
|
+
self._api_url = api_url.rstrip("/")
|
|
37
|
+
self._auth_url = auth_url.rstrip("/")
|
|
38
|
+
self._session = requests.Session()
|
|
39
|
+
self._session.headers.update({"Content-Type": "application/json"})
|
|
40
|
+
self._token: str | None = None
|
|
41
|
+
|
|
42
|
+
def set_token(self, token: str) -> None:
|
|
43
|
+
"""Store the JWT and attach it to all subsequent requests."""
|
|
44
|
+
self._token = token
|
|
45
|
+
self._session.headers["Authorization"] = f"Bearer {token}"
|
|
46
|
+
|
|
47
|
+
def _require_auth(self) -> None:
|
|
48
|
+
if self._token is None:
|
|
49
|
+
raise ClannAuthError("Not authenticated — call ClannClient.login() first.")
|
|
50
|
+
|
|
51
|
+
def _raise_for_status(self, response: requests.Response) -> None:
|
|
52
|
+
if response.ok:
|
|
53
|
+
return
|
|
54
|
+
try:
|
|
55
|
+
body = response.json()
|
|
56
|
+
message = body.get("error") or body.get("message") or response.text
|
|
57
|
+
except Exception:
|
|
58
|
+
message = response.text
|
|
59
|
+
if response.status_code == 401:
|
|
60
|
+
raise ClannAuthError(message)
|
|
61
|
+
if response.status_code == 403:
|
|
62
|
+
raise ClannAuthError(f"Forbidden: {message}")
|
|
63
|
+
if response.status_code == 404:
|
|
64
|
+
raise ClannNotFoundError(message)
|
|
65
|
+
if response.status_code == 400:
|
|
66
|
+
raise ClannValidationError(message)
|
|
67
|
+
if response.status_code >= 500:
|
|
68
|
+
raise ClannServerError(f"Server error {response.status_code}: {message}")
|
|
69
|
+
raise ClannError(f"HTTP {response.status_code}: {message}")
|
|
70
|
+
|
|
71
|
+
def _json_or_none(self, response: requests.Response) -> Any:
|
|
72
|
+
if not response.content:
|
|
73
|
+
return None
|
|
74
|
+
return response.json()
|
|
75
|
+
|
|
76
|
+
# ── auth service ──────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
def post_auth(self, path: str, json: Any) -> Any:
|
|
79
|
+
"""POST to the authentication service (no JWT header required)."""
|
|
80
|
+
response = self._session.post(f"{self._auth_url}{path}", json=json)
|
|
81
|
+
self._raise_for_status(response)
|
|
82
|
+
return response.json()
|
|
83
|
+
|
|
84
|
+
# ── Clann API ─────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
87
|
+
self._require_auth()
|
|
88
|
+
response = self._session.get(f"{self._api_url}{path}", params=params)
|
|
89
|
+
self._raise_for_status(response)
|
|
90
|
+
return response.json()
|
|
91
|
+
|
|
92
|
+
def get_bytes(self, path: str) -> bytes:
|
|
93
|
+
"""Fetch a binary resource (e.g. an image). No auth required."""
|
|
94
|
+
response = self._session.get(f"{self._api_url}{path}")
|
|
95
|
+
self._raise_for_status(response)
|
|
96
|
+
return response.content
|
|
97
|
+
|
|
98
|
+
def post(self, path: str, json: Any = None) -> Any:
|
|
99
|
+
self._require_auth()
|
|
100
|
+
response = self._session.post(f"{self._api_url}{path}", json=json)
|
|
101
|
+
self._raise_for_status(response)
|
|
102
|
+
return self._json_or_none(response)
|
|
103
|
+
|
|
104
|
+
def post_multipart(
|
|
105
|
+
self,
|
|
106
|
+
path: str,
|
|
107
|
+
field: str,
|
|
108
|
+
data: bytes,
|
|
109
|
+
filename: str,
|
|
110
|
+
content_type: str,
|
|
111
|
+
) -> Any:
|
|
112
|
+
"""Upload a file using ``multipart/form-data``.
|
|
113
|
+
|
|
114
|
+
The Content-Type header is removed from the session for this request so
|
|
115
|
+
that *requests* can set the correct ``multipart/form-data; boundary=…``
|
|
116
|
+
header automatically.
|
|
117
|
+
"""
|
|
118
|
+
self._require_auth()
|
|
119
|
+
files: dict[str, tuple[str, bytes, str]] = {field: (filename, data, content_type)}
|
|
120
|
+
saved = self._session.headers.pop("Content-Type", None)
|
|
121
|
+
try:
|
|
122
|
+
response = self._session.post(f"{self._api_url}{path}", files=files)
|
|
123
|
+
self._raise_for_status(response)
|
|
124
|
+
return self._json_or_none(response)
|
|
125
|
+
finally:
|
|
126
|
+
if saved is not None:
|
|
127
|
+
self._session.headers["Content-Type"] = saved
|
|
128
|
+
|
|
129
|
+
def put(self, path: str, json: Any = None) -> Any:
|
|
130
|
+
self._require_auth()
|
|
131
|
+
response = self._session.put(f"{self._api_url}{path}", json=json)
|
|
132
|
+
self._raise_for_status(response)
|
|
133
|
+
return self._json_or_none(response)
|
|
134
|
+
|
|
135
|
+
def patch(self, path: str, json: Any = None) -> Any:
|
|
136
|
+
self._require_auth()
|
|
137
|
+
response = self._session.patch(f"{self._api_url}{path}", json=json)
|
|
138
|
+
self._raise_for_status(response)
|
|
139
|
+
return self._json_or_none(response)
|
|
140
|
+
|
|
141
|
+
def delete(self, path: str, json: Any = None) -> Any:
|
|
142
|
+
self._require_auth()
|
|
143
|
+
response = self._session.delete(f"{self._api_url}{path}", json=json)
|
|
144
|
+
self._raise_for_status(response)
|
|
145
|
+
return self._json_or_none(response)
|