basst 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.
- basst-0.1.0/PKG-INFO +237 -0
- basst-0.1.0/README.md +226 -0
- basst-0.1.0/pyproject.toml +18 -0
- basst-0.1.0/src/basst/__init__.py +0 -0
- basst-0.1.0/src/basst/_sentinel.py +14 -0
- basst-0.1.0/src/basst/builder.py +226 -0
- basst-0.1.0/src/basst/errors.py +146 -0
- basst-0.1.0/src/basst/matchers.py +109 -0
- basst-0.1.0/src/basst/py.typed +0 -0
- basst-0.1.0/src/basst/response.py +321 -0
basst-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: basst
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fluent HTTP API testing library with Pydantic model binding
|
|
5
|
+
Author: Gitznik
|
|
6
|
+
Author-email: Gitznik <dev@robswebhub.net>
|
|
7
|
+
Requires-Dist: httpx
|
|
8
|
+
Requires-Dist: pydantic
|
|
9
|
+
Requires-Python: >=3.13
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Hypex (working name)
|
|
13
|
+
|
|
14
|
+
Pydantic-first HTTP testing client for Python. Fluent request builder, one-step model binding, native Python assertions.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install hypex
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from hypex import Client, matchers
|
|
26
|
+
from pydantic import BaseModel
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Address(BaseModel):
|
|
30
|
+
city: str
|
|
31
|
+
zip: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class User(BaseModel):
|
|
35
|
+
id: int
|
|
36
|
+
name: str
|
|
37
|
+
email: str
|
|
38
|
+
age: int | None
|
|
39
|
+
roles: list[str]
|
|
40
|
+
address: Address
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
api = Client("https://api.example.com")
|
|
44
|
+
|
|
45
|
+
user = (
|
|
46
|
+
api.get(f"/users/{user_id}")
|
|
47
|
+
.header("X-Token", "secret")
|
|
48
|
+
.expect()
|
|
49
|
+
.status(200)
|
|
50
|
+
.header("content-type", matchers.contains("json"))
|
|
51
|
+
.model(User)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
assert user.name == "Ada"
|
|
55
|
+
assert "@" in user.email
|
|
56
|
+
assert "admin" in user.roles
|
|
57
|
+
assert user.address.city == "Paris"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Request Building
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
api = Client(
|
|
64
|
+
"https://api.example.com",
|
|
65
|
+
headers={"X-Token": "secret"},
|
|
66
|
+
auth=("user", "pass"),
|
|
67
|
+
timeout=5.0,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Builder methods are chainable
|
|
71
|
+
resp = (
|
|
72
|
+
api.post("/users")
|
|
73
|
+
.json({"name": "Ada", "email": "ada@example.com"})
|
|
74
|
+
.header("X-Request-Id", "abc123")
|
|
75
|
+
.timeout(10.0)
|
|
76
|
+
.expect()
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
user = resp.status(201).model(User)
|
|
80
|
+
assert user.name == "Ada"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Collections
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
users = (
|
|
87
|
+
api.get("/users")
|
|
88
|
+
.query(page=1, limit=10)
|
|
89
|
+
.expect()
|
|
90
|
+
.status(200)
|
|
91
|
+
.model_list(User)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
assert len(users) > 0
|
|
95
|
+
assert users[0].name == "Ada"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Response Assertions
|
|
99
|
+
|
|
100
|
+
Assertion methods are chainable and raise on failure:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from hypex import matchers
|
|
104
|
+
|
|
105
|
+
resp = api.get(f"/users/{user_id}").expect()
|
|
106
|
+
|
|
107
|
+
# Chain status and header assertions, then bind model
|
|
108
|
+
user = (
|
|
109
|
+
resp
|
|
110
|
+
.status(200)
|
|
111
|
+
.header("content-type", matchers.contains("json"))
|
|
112
|
+
.header("x-request-id") # no matcher — just asserts the header exists
|
|
113
|
+
.model(User)
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Matchers
|
|
118
|
+
|
|
119
|
+
The `matchers` module provides built-in assertion functions for header values:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from hypex import matchers
|
|
123
|
+
|
|
124
|
+
matchers.equals("application/json")
|
|
125
|
+
matchers.contains("json")
|
|
126
|
+
matchers.starts_with("application/")
|
|
127
|
+
matchers.ends_with("+json")
|
|
128
|
+
matchers.matches(r"^application/(json|.+\+json)$") # regex
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
A matcher is any callable `(str) -> None` that raises `AssertionError` on failure.
|
|
132
|
+
Write your own for reusable, domain-specific checks:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
import uuid
|
|
136
|
+
|
|
137
|
+
def is_uuid(value: str) -> None:
|
|
138
|
+
try:
|
|
139
|
+
uuid.UUID(value)
|
|
140
|
+
except ValueError:
|
|
141
|
+
raise AssertionError(f"Expected UUID, got: {value}")
|
|
142
|
+
|
|
143
|
+
resp.header("x-request-id", is_uuid)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Raw Response Access
|
|
147
|
+
|
|
148
|
+
Accessor methods return values for use in plain Python:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
resp = api.get("/health").expect()
|
|
152
|
+
|
|
153
|
+
# Raw values
|
|
154
|
+
code = resp.status_code
|
|
155
|
+
content_type = resp.get_header("content-type")
|
|
156
|
+
all_headers = resp.get_headers()
|
|
157
|
+
data = resp.json()
|
|
158
|
+
text = resp.text
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Content-Type Validation
|
|
162
|
+
|
|
163
|
+
Model binding validates the response content-type before parsing. By default,
|
|
164
|
+
`application/json` and any `+json` suffix are accepted.
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
# Default: accepts application/json, application/problem+json,
|
|
168
|
+
# application/vnd.api+json, etc.
|
|
169
|
+
user = resp.model(User)
|
|
170
|
+
|
|
171
|
+
# Prefix match: assert it's specifically application/problem+json
|
|
172
|
+
problem = resp.model(ProblemDetail, content_type="problem")
|
|
173
|
+
|
|
174
|
+
# Exact match: full MIME type
|
|
175
|
+
data = resp.model(MyModel, content_type="application/vnd.api+json")
|
|
176
|
+
|
|
177
|
+
# Skip content-type validation entirely
|
|
178
|
+
data = resp.model(MyModel, content_type=None)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Async
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from hypex import AsyncClient
|
|
185
|
+
|
|
186
|
+
aapi = AsyncClient("https://api.example.com")
|
|
187
|
+
|
|
188
|
+
# await at .expect() — everything after is synchronous
|
|
189
|
+
resp = await aapi.get(f"/users/{user_id}").expect()
|
|
190
|
+
user = resp.status(200).model(User)
|
|
191
|
+
assert user.name == "Ada"
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Context Managers
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
# Sync
|
|
198
|
+
with Client("https://api.example.com") as api:
|
|
199
|
+
user = api.get(f"/users/{user_id}").expect().status(200).model(User)
|
|
200
|
+
|
|
201
|
+
# Async
|
|
202
|
+
async with AsyncClient("https://api.example.com") as api:
|
|
203
|
+
resp = await api.get(f"/users/{user_id}").expect()
|
|
204
|
+
user = resp.status(200).model(User)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Error Messages
|
|
208
|
+
|
|
209
|
+
When things fail, hypex provides context about the request and response:
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
hypex.StatusError: Expected status 200, got 404
|
|
213
|
+
GET https://api.example.com/users/999
|
|
214
|
+
Response body: {"detail": "Not found"}
|
|
215
|
+
|
|
216
|
+
hypex.HeaderError: Header "x-cache" expected to equal "HIT", got "MISS"
|
|
217
|
+
GET https://api.example.com/users/1
|
|
218
|
+
|
|
219
|
+
hypex.ContentTypeError: Expected JSON-compatible content-type, got text/html
|
|
220
|
+
GET https://api.example.com/users/1
|
|
221
|
+
Response body: <html>...
|
|
222
|
+
|
|
223
|
+
hypex.ModelError: Failed to validate response as User
|
|
224
|
+
GET https://api.example.com/users/1
|
|
225
|
+
Response body: {"id": "not-an-int", "name": "Ada", ...}
|
|
226
|
+
Validation errors:
|
|
227
|
+
id: Input should be a valid integer [type=int_parsing]
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Design Principles
|
|
231
|
+
|
|
232
|
+
- Pydantic models are the primary response surface.
|
|
233
|
+
- `model()` and `model_list()` return real model instances, not proxies.
|
|
234
|
+
- Use native Python assertions on model fields.
|
|
235
|
+
- No custom assertion DSL, no JSONPath, no string selectors.
|
|
236
|
+
- The async boundary is only at `.expect()` — everything after is synchronous.
|
|
237
|
+
- Client is reusable — each verb call creates a fresh request builder.
|
basst-0.1.0/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Hypex (working name)
|
|
2
|
+
|
|
3
|
+
Pydantic-first HTTP testing client for Python. Fluent request builder, one-step model binding, native Python assertions.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install hypex
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from hypex import Client, matchers
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Address(BaseModel):
|
|
19
|
+
city: str
|
|
20
|
+
zip: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class User(BaseModel):
|
|
24
|
+
id: int
|
|
25
|
+
name: str
|
|
26
|
+
email: str
|
|
27
|
+
age: int | None
|
|
28
|
+
roles: list[str]
|
|
29
|
+
address: Address
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
api = Client("https://api.example.com")
|
|
33
|
+
|
|
34
|
+
user = (
|
|
35
|
+
api.get(f"/users/{user_id}")
|
|
36
|
+
.header("X-Token", "secret")
|
|
37
|
+
.expect()
|
|
38
|
+
.status(200)
|
|
39
|
+
.header("content-type", matchers.contains("json"))
|
|
40
|
+
.model(User)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
assert user.name == "Ada"
|
|
44
|
+
assert "@" in user.email
|
|
45
|
+
assert "admin" in user.roles
|
|
46
|
+
assert user.address.city == "Paris"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Request Building
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
api = Client(
|
|
53
|
+
"https://api.example.com",
|
|
54
|
+
headers={"X-Token": "secret"},
|
|
55
|
+
auth=("user", "pass"),
|
|
56
|
+
timeout=5.0,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Builder methods are chainable
|
|
60
|
+
resp = (
|
|
61
|
+
api.post("/users")
|
|
62
|
+
.json({"name": "Ada", "email": "ada@example.com"})
|
|
63
|
+
.header("X-Request-Id", "abc123")
|
|
64
|
+
.timeout(10.0)
|
|
65
|
+
.expect()
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
user = resp.status(201).model(User)
|
|
69
|
+
assert user.name == "Ada"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Collections
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
users = (
|
|
76
|
+
api.get("/users")
|
|
77
|
+
.query(page=1, limit=10)
|
|
78
|
+
.expect()
|
|
79
|
+
.status(200)
|
|
80
|
+
.model_list(User)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
assert len(users) > 0
|
|
84
|
+
assert users[0].name == "Ada"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Response Assertions
|
|
88
|
+
|
|
89
|
+
Assertion methods are chainable and raise on failure:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from hypex import matchers
|
|
93
|
+
|
|
94
|
+
resp = api.get(f"/users/{user_id}").expect()
|
|
95
|
+
|
|
96
|
+
# Chain status and header assertions, then bind model
|
|
97
|
+
user = (
|
|
98
|
+
resp
|
|
99
|
+
.status(200)
|
|
100
|
+
.header("content-type", matchers.contains("json"))
|
|
101
|
+
.header("x-request-id") # no matcher — just asserts the header exists
|
|
102
|
+
.model(User)
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Matchers
|
|
107
|
+
|
|
108
|
+
The `matchers` module provides built-in assertion functions for header values:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from hypex import matchers
|
|
112
|
+
|
|
113
|
+
matchers.equals("application/json")
|
|
114
|
+
matchers.contains("json")
|
|
115
|
+
matchers.starts_with("application/")
|
|
116
|
+
matchers.ends_with("+json")
|
|
117
|
+
matchers.matches(r"^application/(json|.+\+json)$") # regex
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
A matcher is any callable `(str) -> None` that raises `AssertionError` on failure.
|
|
121
|
+
Write your own for reusable, domain-specific checks:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
import uuid
|
|
125
|
+
|
|
126
|
+
def is_uuid(value: str) -> None:
|
|
127
|
+
try:
|
|
128
|
+
uuid.UUID(value)
|
|
129
|
+
except ValueError:
|
|
130
|
+
raise AssertionError(f"Expected UUID, got: {value}")
|
|
131
|
+
|
|
132
|
+
resp.header("x-request-id", is_uuid)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Raw Response Access
|
|
136
|
+
|
|
137
|
+
Accessor methods return values for use in plain Python:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
resp = api.get("/health").expect()
|
|
141
|
+
|
|
142
|
+
# Raw values
|
|
143
|
+
code = resp.status_code
|
|
144
|
+
content_type = resp.get_header("content-type")
|
|
145
|
+
all_headers = resp.get_headers()
|
|
146
|
+
data = resp.json()
|
|
147
|
+
text = resp.text
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Content-Type Validation
|
|
151
|
+
|
|
152
|
+
Model binding validates the response content-type before parsing. By default,
|
|
153
|
+
`application/json` and any `+json` suffix are accepted.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
# Default: accepts application/json, application/problem+json,
|
|
157
|
+
# application/vnd.api+json, etc.
|
|
158
|
+
user = resp.model(User)
|
|
159
|
+
|
|
160
|
+
# Prefix match: assert it's specifically application/problem+json
|
|
161
|
+
problem = resp.model(ProblemDetail, content_type="problem")
|
|
162
|
+
|
|
163
|
+
# Exact match: full MIME type
|
|
164
|
+
data = resp.model(MyModel, content_type="application/vnd.api+json")
|
|
165
|
+
|
|
166
|
+
# Skip content-type validation entirely
|
|
167
|
+
data = resp.model(MyModel, content_type=None)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Async
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from hypex import AsyncClient
|
|
174
|
+
|
|
175
|
+
aapi = AsyncClient("https://api.example.com")
|
|
176
|
+
|
|
177
|
+
# await at .expect() — everything after is synchronous
|
|
178
|
+
resp = await aapi.get(f"/users/{user_id}").expect()
|
|
179
|
+
user = resp.status(200).model(User)
|
|
180
|
+
assert user.name == "Ada"
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Context Managers
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
# Sync
|
|
187
|
+
with Client("https://api.example.com") as api:
|
|
188
|
+
user = api.get(f"/users/{user_id}").expect().status(200).model(User)
|
|
189
|
+
|
|
190
|
+
# Async
|
|
191
|
+
async with AsyncClient("https://api.example.com") as api:
|
|
192
|
+
resp = await api.get(f"/users/{user_id}").expect()
|
|
193
|
+
user = resp.status(200).model(User)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Error Messages
|
|
197
|
+
|
|
198
|
+
When things fail, hypex provides context about the request and response:
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
hypex.StatusError: Expected status 200, got 404
|
|
202
|
+
GET https://api.example.com/users/999
|
|
203
|
+
Response body: {"detail": "Not found"}
|
|
204
|
+
|
|
205
|
+
hypex.HeaderError: Header "x-cache" expected to equal "HIT", got "MISS"
|
|
206
|
+
GET https://api.example.com/users/1
|
|
207
|
+
|
|
208
|
+
hypex.ContentTypeError: Expected JSON-compatible content-type, got text/html
|
|
209
|
+
GET https://api.example.com/users/1
|
|
210
|
+
Response body: <html>...
|
|
211
|
+
|
|
212
|
+
hypex.ModelError: Failed to validate response as User
|
|
213
|
+
GET https://api.example.com/users/1
|
|
214
|
+
Response body: {"id": "not-an-int", "name": "Ada", ...}
|
|
215
|
+
Validation errors:
|
|
216
|
+
id: Input should be a valid integer [type=int_parsing]
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Design Principles
|
|
220
|
+
|
|
221
|
+
- Pydantic models are the primary response surface.
|
|
222
|
+
- `model()` and `model_list()` return real model instances, not proxies.
|
|
223
|
+
- Use native Python assertions on model fields.
|
|
224
|
+
- No custom assertion DSL, no JSONPath, no string selectors.
|
|
225
|
+
- The async boundary is only at `.expect()` — everything after is synchronous.
|
|
226
|
+
- Client is reusable — each verb call creates a fresh request builder.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "basst"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Fluent HTTP API testing library with Pydantic model binding"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Gitznik", email = "dev@robswebhub.net" }]
|
|
7
|
+
requires-python = ">=3.13"
|
|
8
|
+
dependencies = ["httpx", "pydantic"]
|
|
9
|
+
|
|
10
|
+
[dependency-groups]
|
|
11
|
+
dev = ["pytest", "pytest-asyncio", "respx", "pyright"]
|
|
12
|
+
|
|
13
|
+
[tool.pytest.ini_options]
|
|
14
|
+
asyncio_mode = "auto"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.9.18,<0.10.0"]
|
|
18
|
+
build-backend = "uv_build"
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Sentinel value to distinguish 'not provided' from ``None``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _Unset(Enum):
|
|
9
|
+
"""Sentinel to distinguish 'not set' from ``None``."""
|
|
10
|
+
|
|
11
|
+
token = 0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_UNSET = _Unset.token
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Request builder for constructing and firing HTTP requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum, StrEnum
|
|
6
|
+
from typing import Any, Self
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from basst._sentinel import _Unset, _UNSET
|
|
11
|
+
from basst.response import Response
|
|
12
|
+
|
|
13
|
+
HeaderTypes = httpx.Headers | dict[str, str] | list[tuple[str, str]]
|
|
14
|
+
"""Type alias for accepted header input formats.
|
|
15
|
+
|
|
16
|
+
Mirrors httpx's internal ``HeaderTypes``. Accepts ``httpx.Headers``,
|
|
17
|
+
a ``dict[str, str]``, or a ``list[tuple[str, str]]`` (for multi-value
|
|
18
|
+
headers).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Method(StrEnum):
|
|
23
|
+
"""HTTP methods supported by the request builder.
|
|
24
|
+
|
|
25
|
+
Inherits from ``str`` so values pass directly to httpx without
|
|
26
|
+
conversion.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
GET = "GET"
|
|
30
|
+
POST = "POST"
|
|
31
|
+
PUT = "PUT"
|
|
32
|
+
PATCH = "PATCH"
|
|
33
|
+
DELETE = "DELETE"
|
|
34
|
+
HEAD = "HEAD"
|
|
35
|
+
OPTIONS = "OPTIONS"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RequestBuilder:
|
|
39
|
+
"""Fluent builder for a single HTTP request.
|
|
40
|
+
|
|
41
|
+
Created by Client verb methods. All builder methods return ``self``
|
|
42
|
+
for chaining. Call :meth:`expect` to fire the request and get a
|
|
43
|
+
:class:`~basst.response.Response`.
|
|
44
|
+
|
|
45
|
+
The builder only tracks per-request overrides. The underlying
|
|
46
|
+
``httpx.Client`` holds base configuration (base_url, headers, auth,
|
|
47
|
+
timeout) and httpx merges them automatically.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
client: The httpx.Client to send the request through.
|
|
51
|
+
method: The HTTP method.
|
|
52
|
+
path: The URL path (relative to the client's base_url).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
client: httpx.Client,
|
|
59
|
+
method: Method,
|
|
60
|
+
path: str,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._client = client
|
|
63
|
+
self._method = method
|
|
64
|
+
self._path = path
|
|
65
|
+
self._headers = httpx.Headers()
|
|
66
|
+
self._params: dict[str, str | int | float | bool] = {}
|
|
67
|
+
self._cookies: dict[str, str] = {}
|
|
68
|
+
self._json: Any = None
|
|
69
|
+
self._data: dict[str, Any] | None = None
|
|
70
|
+
self._auth: tuple[str, str] | httpx.Auth | None | _Unset = _UNSET
|
|
71
|
+
self._timeout: float | None | _Unset = _UNSET
|
|
72
|
+
|
|
73
|
+
# -- Builder methods (all return self for chaining) --
|
|
74
|
+
|
|
75
|
+
def header(self, name: str, value: str) -> Self:
|
|
76
|
+
"""Add a single header to the request.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
name: Header name (case-insensitive).
|
|
80
|
+
value: Header value.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
self, for chaining.
|
|
84
|
+
"""
|
|
85
|
+
self._headers[name] = value
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def headers(self, headers: HeaderTypes) -> Self:
|
|
89
|
+
"""Add multiple headers to the request.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
headers: Headers as ``httpx.Headers``, a ``dict``, or a
|
|
93
|
+
list of ``(name, value)`` tuples (for multi-value
|
|
94
|
+
headers).
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
self, for chaining.
|
|
98
|
+
"""
|
|
99
|
+
self._headers.update(headers)
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def query(self, **params: str | int | float | bool) -> Self:
|
|
103
|
+
"""Add query parameters to the request URL.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
**params: Query parameter names and values.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
self, for chaining.
|
|
110
|
+
"""
|
|
111
|
+
self._params.update(params)
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def cookie(self, name: str, value: str) -> Self:
|
|
115
|
+
"""Add a cookie to the request.
|
|
116
|
+
|
|
117
|
+
Cookies are sent via the ``Cookie`` header.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
name: Cookie name.
|
|
121
|
+
value: Cookie value.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
self, for chaining.
|
|
125
|
+
"""
|
|
126
|
+
self._cookies[name] = value
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def json(self, data: Any) -> Self:
|
|
130
|
+
"""Set the JSON request body.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
data: Any JSON-serialisable value. Must not be ``None``
|
|
134
|
+
(use a different body method or omit the call instead).
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
self, for chaining.
|
|
138
|
+
"""
|
|
139
|
+
self._json = data
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
def data(self, data: dict[str, Any]) -> Self:
|
|
143
|
+
"""Set form-encoded request body.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
data: Form field names and values.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
self, for chaining.
|
|
150
|
+
"""
|
|
151
|
+
self._data = data
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
def auth(self, auth: tuple[str, str] | httpx.Auth | None) -> Self:
|
|
155
|
+
"""Set authentication for this request.
|
|
156
|
+
|
|
157
|
+
Overrides any client-level auth. Pass ``None`` to explicitly
|
|
158
|
+
disable authentication for this request.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
auth: A ``(username, password)`` tuple, an ``httpx.Auth``
|
|
162
|
+
instance, or ``None`` to disable.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
self, for chaining.
|
|
166
|
+
"""
|
|
167
|
+
self._auth = auth
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
def timeout(self, seconds: float | None) -> Self:
|
|
171
|
+
"""Set timeout for this request.
|
|
172
|
+
|
|
173
|
+
Overrides any client-level timeout. Pass ``None`` to disable
|
|
174
|
+
the timeout entirely (wait forever).
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
seconds: Timeout in seconds, or ``None`` to disable.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
self, for chaining.
|
|
181
|
+
"""
|
|
182
|
+
self._timeout = seconds
|
|
183
|
+
return self
|
|
184
|
+
|
|
185
|
+
# -- Terminal method --
|
|
186
|
+
|
|
187
|
+
def expect(self) -> Response:
|
|
188
|
+
"""Fire the HTTP request and return a Response.
|
|
189
|
+
|
|
190
|
+
Passes only the builder's accumulated overrides to
|
|
191
|
+
``httpx.Client.request()``. httpx merges them with any
|
|
192
|
+
client-level defaults (base_url, headers, auth, timeout).
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
A :class:`~basst.response.Response` wrapping the
|
|
196
|
+
httpx response.
|
|
197
|
+
"""
|
|
198
|
+
kwargs: dict[str, Any] = {}
|
|
199
|
+
|
|
200
|
+
# Merge cookies into headers (per-request cookies= is deprecated
|
|
201
|
+
# in httpx 0.28+).
|
|
202
|
+
if self._cookies:
|
|
203
|
+
cookie_str = "; ".join(
|
|
204
|
+
f"{name}={value}" for name, value in self._cookies.items()
|
|
205
|
+
)
|
|
206
|
+
self._headers["cookie"] = cookie_str
|
|
207
|
+
|
|
208
|
+
if self._headers:
|
|
209
|
+
kwargs["headers"] = self._headers
|
|
210
|
+
if self._params:
|
|
211
|
+
kwargs["params"] = self._params
|
|
212
|
+
if self._json is not None:
|
|
213
|
+
kwargs["json"] = self._json
|
|
214
|
+
if self._data is not None:
|
|
215
|
+
kwargs["data"] = self._data
|
|
216
|
+
if self._auth is not _UNSET:
|
|
217
|
+
kwargs["auth"] = self._auth
|
|
218
|
+
if self._timeout is not _UNSET:
|
|
219
|
+
kwargs["timeout"] = self._timeout
|
|
220
|
+
|
|
221
|
+
raw_response = self._client.request(
|
|
222
|
+
self._method,
|
|
223
|
+
self._path,
|
|
224
|
+
**kwargs,
|
|
225
|
+
)
|
|
226
|
+
return Response(raw_response)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Error classes for basst."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _truncate(body: str, max_len: int = 200) -> str:
|
|
5
|
+
"""Truncate a body string, appending '...' if it exceeds max_len."""
|
|
6
|
+
if len(body) <= max_len:
|
|
7
|
+
return body
|
|
8
|
+
return body[:max_len] + "..."
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BasstError(Exception):
|
|
12
|
+
"""Base error for all basst errors."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StatusError(BasstError):
|
|
16
|
+
"""Raised when the response status code does not match the expected value."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
*,
|
|
21
|
+
expected: int,
|
|
22
|
+
actual: int,
|
|
23
|
+
method: str,
|
|
24
|
+
url: str,
|
|
25
|
+
body: str,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.expected = expected
|
|
28
|
+
self.actual = actual
|
|
29
|
+
self.method = method
|
|
30
|
+
self.url = url
|
|
31
|
+
self.body = body
|
|
32
|
+
super().__init__(str(self))
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str:
|
|
35
|
+
return (
|
|
36
|
+
f"Expected status {self.expected}, got {self.actual}\n"
|
|
37
|
+
f" {self.method} {self.url}\n"
|
|
38
|
+
f" Response body: {_truncate(self.body)}"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class HeaderError(BasstError):
|
|
43
|
+
"""Raised when a header assertion fails."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
name: str,
|
|
49
|
+
method: str,
|
|
50
|
+
url: str,
|
|
51
|
+
message: str | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.name = name
|
|
54
|
+
self.method = method
|
|
55
|
+
self.url = url
|
|
56
|
+
self.message = message
|
|
57
|
+
super().__init__(str(self))
|
|
58
|
+
|
|
59
|
+
def __str__(self) -> str:
|
|
60
|
+
if self.message is None:
|
|
61
|
+
headline = f'Header "{self.name}" not found'
|
|
62
|
+
else:
|
|
63
|
+
headline = f'Header "{self.name}" {self.message}'
|
|
64
|
+
return f"{headline}\n {self.method} {self.url}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ContentTypeError(BasstError):
|
|
68
|
+
"""Raised when the response content-type does not match the expected value."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
expected: str,
|
|
74
|
+
actual: str,
|
|
75
|
+
method: str,
|
|
76
|
+
url: str,
|
|
77
|
+
body: str,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.expected = expected
|
|
80
|
+
self.actual = actual
|
|
81
|
+
self.method = method
|
|
82
|
+
self.url = url
|
|
83
|
+
self.body = body
|
|
84
|
+
super().__init__(str(self))
|
|
85
|
+
|
|
86
|
+
def __str__(self) -> str:
|
|
87
|
+
return (
|
|
88
|
+
f"Expected content-type {self.expected}, got {self.actual}\n"
|
|
89
|
+
f" {self.method} {self.url}\n"
|
|
90
|
+
f" Response body: {_truncate(self.body)}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ModelError(BasstError):
|
|
95
|
+
"""Raised when pydantic model validation fails."""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
model_name: str,
|
|
101
|
+
errors: str,
|
|
102
|
+
method: str,
|
|
103
|
+
url: str,
|
|
104
|
+
body: str,
|
|
105
|
+
) -> None:
|
|
106
|
+
self.model_name = model_name
|
|
107
|
+
self.errors = errors
|
|
108
|
+
self.method = method
|
|
109
|
+
self.url = url
|
|
110
|
+
self.body = body
|
|
111
|
+
super().__init__(str(self))
|
|
112
|
+
|
|
113
|
+
def __str__(self) -> str:
|
|
114
|
+
return (
|
|
115
|
+
f"Failed to validate response as {self.model_name}\n"
|
|
116
|
+
f" {self.method} {self.url}\n"
|
|
117
|
+
f" Validation errors:\n"
|
|
118
|
+
f" {self.errors}\n"
|
|
119
|
+
f" Response body: {_truncate(self.body)}"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class JsonError(BasstError):
|
|
124
|
+
"""Raised when the response body cannot be parsed as JSON."""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
method: str,
|
|
130
|
+
url: str,
|
|
131
|
+
body: str,
|
|
132
|
+
detail: str,
|
|
133
|
+
) -> None:
|
|
134
|
+
self.method = method
|
|
135
|
+
self.url = url
|
|
136
|
+
self.body = body
|
|
137
|
+
self.detail = detail
|
|
138
|
+
super().__init__(str(self))
|
|
139
|
+
|
|
140
|
+
def __str__(self) -> str:
|
|
141
|
+
return (
|
|
142
|
+
f"Failed to parse response body as JSON\n"
|
|
143
|
+
f" {self.method} {self.url}\n"
|
|
144
|
+
f" Parse error: {self.detail}\n"
|
|
145
|
+
f" Response body: {_truncate(self.body)}"
|
|
146
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Matchers for header value assertions.
|
|
2
|
+
|
|
3
|
+
Matchers are callables with the signature ``(str) -> None``. They raise
|
|
4
|
+
``AssertionError`` on failure and return ``None`` on success.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
Matcher = Callable[[str], None]
|
|
11
|
+
"""A callable that validates a string value.
|
|
12
|
+
|
|
13
|
+
Takes a single string argument and returns ``None`` on success.
|
|
14
|
+
Raises ``AssertionError`` with a descriptive message on failure.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def equals(expected: str) -> Matcher:
|
|
19
|
+
"""Create a matcher that asserts exact string equality.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
expected: The expected string value.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
A matcher that raises ``AssertionError`` if the value does not
|
|
26
|
+
equal ``expected``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def _check(value: str) -> None:
|
|
30
|
+
if value != expected:
|
|
31
|
+
raise AssertionError(f'expected "{value}" to equal "{expected}"')
|
|
32
|
+
|
|
33
|
+
return _check
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def contains(substring: str) -> Matcher:
|
|
37
|
+
"""Create a matcher that asserts a substring is present.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
substring: The substring to search for.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
A matcher that raises ``AssertionError`` if ``substring`` is
|
|
44
|
+
not found in the value.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def _check(value: str) -> None:
|
|
48
|
+
if substring not in value:
|
|
49
|
+
raise AssertionError(f'expected "{value}" to contain "{substring}"')
|
|
50
|
+
|
|
51
|
+
return _check
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def starts_with(prefix: str) -> Matcher:
|
|
55
|
+
"""Create a matcher that asserts a string prefix.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
prefix: The expected prefix.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
A matcher that raises ``AssertionError`` if the value does not
|
|
62
|
+
start with ``prefix``.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def _check(value: str) -> None:
|
|
66
|
+
if not value.startswith(prefix):
|
|
67
|
+
raise AssertionError(f'expected "{value}" to start with "{prefix}"')
|
|
68
|
+
|
|
69
|
+
return _check
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def ends_with(suffix: str) -> Matcher:
|
|
73
|
+
"""Create a matcher that asserts a string suffix.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
suffix: The expected suffix.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
A matcher that raises ``AssertionError`` if the value does not
|
|
80
|
+
end with ``suffix``.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def _check(value: str) -> None:
|
|
84
|
+
if not value.endswith(suffix):
|
|
85
|
+
raise AssertionError(f'expected "{value}" to end with "{suffix}"')
|
|
86
|
+
|
|
87
|
+
return _check
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def matches(pattern: str) -> Matcher:
|
|
91
|
+
"""Create a matcher that asserts a regex pattern match.
|
|
92
|
+
|
|
93
|
+
Uses ``re.search``, so the pattern can match anywhere in the value.
|
|
94
|
+
Anchor with ``^`` and ``$`` for a full match.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
pattern: A regular expression pattern.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A matcher that raises ``AssertionError`` if the pattern is not
|
|
101
|
+
found in the value.
|
|
102
|
+
"""
|
|
103
|
+
compiled = re.compile(pattern)
|
|
104
|
+
|
|
105
|
+
def _check(value: str) -> None:
|
|
106
|
+
if not compiled.search(value):
|
|
107
|
+
raise AssertionError(f'expected "{value}" to match pattern "{pattern}"')
|
|
108
|
+
|
|
109
|
+
return _check
|
|
File without changes
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Response wrapper with accessor methods and assertions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, TypeVar
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from pydantic import BaseModel, TypeAdapter, ValidationError
|
|
9
|
+
|
|
10
|
+
from basst._sentinel import _Unset, _UNSET
|
|
11
|
+
from basst.errors import (
|
|
12
|
+
ContentTypeError,
|
|
13
|
+
HeaderError,
|
|
14
|
+
JsonError,
|
|
15
|
+
ModelError,
|
|
16
|
+
StatusError,
|
|
17
|
+
)
|
|
18
|
+
from basst.matchers import Matcher
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T", bound=BaseModel)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Response:
|
|
24
|
+
"""Wraps an httpx.Response with accessor methods and assertions.
|
|
25
|
+
|
|
26
|
+
Assertion methods are chainable (return self) and raise on failure.
|
|
27
|
+
Accessor methods return values.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
response: The underlying httpx.Response to wrap.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, response: httpx.Response) -> None:
|
|
34
|
+
self._response = response
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def _method(self) -> str:
|
|
38
|
+
"""HTTP method from the original request."""
|
|
39
|
+
return self._response.request.method
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def _url(self) -> str:
|
|
43
|
+
"""URL from the original request."""
|
|
44
|
+
return str(self._response.request.url)
|
|
45
|
+
|
|
46
|
+
# -- Assertion methods (chainable) --
|
|
47
|
+
|
|
48
|
+
def status(self, code: int) -> Response:
|
|
49
|
+
"""Assert that the response status code matches the expected value.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
code: The expected HTTP status code.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
self, for chaining.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
StatusError: If the actual status code does not match.
|
|
59
|
+
"""
|
|
60
|
+
if self._response.status_code != code:
|
|
61
|
+
raise StatusError(
|
|
62
|
+
expected=code,
|
|
63
|
+
actual=self._response.status_code,
|
|
64
|
+
method=self._method,
|
|
65
|
+
url=self._url,
|
|
66
|
+
body=self.text,
|
|
67
|
+
)
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def header(self, name: str, matcher: Matcher | None = None) -> Response:
|
|
71
|
+
"""Assert that a response header exists and optionally matches a value.
|
|
72
|
+
|
|
73
|
+
When called with just a name, asserts the header is present. When a
|
|
74
|
+
matcher is also provided, asserts the header exists **and** the matcher
|
|
75
|
+
passes against the header value.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
name: The header name (case-insensitive).
|
|
79
|
+
matcher: Optional matcher to validate the header value.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
self, for chaining.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
HeaderError: If the header is missing or the matcher fails.
|
|
86
|
+
"""
|
|
87
|
+
value = self._response.headers.get(name)
|
|
88
|
+
if value is None:
|
|
89
|
+
raise HeaderError(
|
|
90
|
+
name=name,
|
|
91
|
+
method=self._method,
|
|
92
|
+
url=self._url,
|
|
93
|
+
)
|
|
94
|
+
if matcher is None:
|
|
95
|
+
return self
|
|
96
|
+
try:
|
|
97
|
+
matcher(value)
|
|
98
|
+
except AssertionError as exc:
|
|
99
|
+
raise HeaderError(
|
|
100
|
+
name=name,
|
|
101
|
+
method=self._method,
|
|
102
|
+
url=self._url,
|
|
103
|
+
message=str(exc),
|
|
104
|
+
) from None
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
# -- Model binding --
|
|
108
|
+
|
|
109
|
+
def model(
|
|
110
|
+
self, model_type: type[T], *, content_type: str | None | _Unset = _UNSET
|
|
111
|
+
) -> T:
|
|
112
|
+
"""Validate the response body against a Pydantic model.
|
|
113
|
+
|
|
114
|
+
Validates the response content-type, parses the JSON body, and
|
|
115
|
+
returns a model instance. Content-type parameters in the response
|
|
116
|
+
(e.g. ``charset=utf-8``) are always ignored during comparison.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
model_type: The Pydantic model class to validate against.
|
|
120
|
+
content_type: Controls content-type validation. Must not
|
|
121
|
+
contain parameters (no ``;``). Not provided (default):
|
|
122
|
+
accepts ``application/json`` or any ``+json`` suffix.
|
|
123
|
+
``None``: skips validation. Contains ``/``: exact media
|
|
124
|
+
type match. Otherwise: treated as a prefix matching
|
|
125
|
+
``application/{value}+json``.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
An instance of ``model_type``.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
ValueError: If ``content_type`` contains parameters.
|
|
132
|
+
ContentTypeError: If the response content-type does not match.
|
|
133
|
+
ModelError: If the response body fails Pydantic validation.
|
|
134
|
+
"""
|
|
135
|
+
self._validate_content_type(content_type)
|
|
136
|
+
data = self.json()
|
|
137
|
+
try:
|
|
138
|
+
return model_type.model_validate(data)
|
|
139
|
+
except ValidationError as exc:
|
|
140
|
+
raise ModelError(
|
|
141
|
+
model_name=model_type.__name__,
|
|
142
|
+
errors=str(exc),
|
|
143
|
+
method=self._method,
|
|
144
|
+
url=self._url,
|
|
145
|
+
body=self.text,
|
|
146
|
+
) from None
|
|
147
|
+
|
|
148
|
+
def model_list(
|
|
149
|
+
self, model_type: type[T], *, content_type: str | None | _Unset = _UNSET
|
|
150
|
+
) -> list[T]:
|
|
151
|
+
"""Validate the response body as a list of Pydantic models.
|
|
152
|
+
|
|
153
|
+
Validates the response content-type, parses the JSON body as a
|
|
154
|
+
list, and returns a list of model instances. Content-type
|
|
155
|
+
parameters in the response (e.g. ``charset=utf-8``) are always
|
|
156
|
+
ignored during comparison.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
model_type: The Pydantic model class for each list element.
|
|
160
|
+
content_type: Controls content-type validation. Same rules as
|
|
161
|
+
:meth:`model`.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
A list of ``model_type`` instances.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
ValueError: If ``content_type`` contains parameters.
|
|
168
|
+
ContentTypeError: If the response content-type does not match.
|
|
169
|
+
ModelError: If any element fails Pydantic validation.
|
|
170
|
+
"""
|
|
171
|
+
self._validate_content_type(content_type)
|
|
172
|
+
data = self.json()
|
|
173
|
+
try:
|
|
174
|
+
# NOTE: mypy and ty flag list[model_type] as an invalid type
|
|
175
|
+
# expression because model_type is a variable, not a static type.
|
|
176
|
+
# Pyright accepts it because TypeAdapter is designed for runtime
|
|
177
|
+
# type expressions. We use pyright as our type checker.
|
|
178
|
+
adapter = TypeAdapter(list[model_type])
|
|
179
|
+
return adapter.validate_python(data)
|
|
180
|
+
except ValidationError as exc:
|
|
181
|
+
raise ModelError(
|
|
182
|
+
model_name=model_type.__name__,
|
|
183
|
+
errors=str(exc),
|
|
184
|
+
method=self._method,
|
|
185
|
+
url=self._url,
|
|
186
|
+
body=self.text,
|
|
187
|
+
) from None
|
|
188
|
+
|
|
189
|
+
def _validate_content_type(self, content_type: str | None | _Unset) -> None:
|
|
190
|
+
"""Validate the response content-type header.
|
|
191
|
+
|
|
192
|
+
Content-type parameters (e.g. ``charset=utf-8``) in the response
|
|
193
|
+
are always stripped before comparison — only the media type is
|
|
194
|
+
matched. Passing a ``content_type`` string that itself contains
|
|
195
|
+
parameters (a ``;``) is not supported and raises ``ValueError``.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
content_type: The expected content-type constraint. See
|
|
199
|
+
:meth:`model` for the full rules.
|
|
200
|
+
|
|
201
|
+
Raises:
|
|
202
|
+
ValueError: If ``content_type`` contains a ``;`` (parameters).
|
|
203
|
+
ContentTypeError: If the response content-type does not match.
|
|
204
|
+
"""
|
|
205
|
+
if content_type is None:
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
if isinstance(content_type, str) and ";" in content_type:
|
|
209
|
+
raise ValueError(
|
|
210
|
+
f"content_type must not contain parameters (got {content_type!r}). "
|
|
211
|
+
"Response content-type parameters (e.g. charset) are always "
|
|
212
|
+
"ignored during comparison."
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
raw_ct = self._response.headers.get("content-type", "")
|
|
216
|
+
# Strip parameters (e.g. "; charset=utf-8") and normalise case.
|
|
217
|
+
media_type = raw_ct.split(";")[0].strip().lower()
|
|
218
|
+
|
|
219
|
+
if content_type is _UNSET:
|
|
220
|
+
if media_type != "application/json" and not media_type.endswith("+json"):
|
|
221
|
+
raise ContentTypeError(
|
|
222
|
+
expected="application/json or *+json",
|
|
223
|
+
actual=raw_ct,
|
|
224
|
+
method=self._method,
|
|
225
|
+
url=self._url,
|
|
226
|
+
body=self.text,
|
|
227
|
+
)
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
if "/" in content_type:
|
|
231
|
+
# Exact match.
|
|
232
|
+
if media_type != content_type.lower():
|
|
233
|
+
raise ContentTypeError(
|
|
234
|
+
expected=content_type,
|
|
235
|
+
actual=raw_ct,
|
|
236
|
+
method=self._method,
|
|
237
|
+
url=self._url,
|
|
238
|
+
body=self.text,
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
# Prefix mode: matches application/{value}+json.
|
|
242
|
+
expected_media = f"application/{content_type.lower()}+json"
|
|
243
|
+
if media_type != expected_media:
|
|
244
|
+
raise ContentTypeError(
|
|
245
|
+
expected=expected_media,
|
|
246
|
+
actual=raw_ct,
|
|
247
|
+
method=self._method,
|
|
248
|
+
url=self._url,
|
|
249
|
+
body=self.text,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# -- Accessor methods --
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def status_code(self) -> int:
|
|
256
|
+
"""The raw HTTP status code."""
|
|
257
|
+
return self._response.status_code
|
|
258
|
+
|
|
259
|
+
def get_header(self, name: str) -> str:
|
|
260
|
+
"""Return the value of a response header.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
name: The header name (case-insensitive).
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
The header value as a string.
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
HeaderError: If the header is not present in the response.
|
|
270
|
+
"""
|
|
271
|
+
try:
|
|
272
|
+
return self._response.headers[name]
|
|
273
|
+
except KeyError:
|
|
274
|
+
raise HeaderError(
|
|
275
|
+
name=name,
|
|
276
|
+
method=self._method,
|
|
277
|
+
url=self._url,
|
|
278
|
+
) from None
|
|
279
|
+
|
|
280
|
+
def get_headers(self) -> httpx.Headers:
|
|
281
|
+
"""Return all response headers.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
The response headers as an ``httpx.Headers`` object. Supports
|
|
285
|
+
dict-like access for single-value lookups and ``.multi_items()``
|
|
286
|
+
or ``.get_list(name)`` for multi-value headers.
|
|
287
|
+
"""
|
|
288
|
+
return self._response.headers
|
|
289
|
+
|
|
290
|
+
def json(self) -> Any:
|
|
291
|
+
"""Parse the response body as JSON.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
The parsed JSON value.
|
|
295
|
+
|
|
296
|
+
Raises:
|
|
297
|
+
JsonError: If the response body is not valid JSON.
|
|
298
|
+
"""
|
|
299
|
+
try:
|
|
300
|
+
return self._response.json()
|
|
301
|
+
except Exception as exc:
|
|
302
|
+
raise JsonError(
|
|
303
|
+
method=self._method,
|
|
304
|
+
url=self._url,
|
|
305
|
+
body=self.text,
|
|
306
|
+
detail=str(exc),
|
|
307
|
+
) from None
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def raw(self) -> httpx.Response:
|
|
311
|
+
"""The underlying httpx.Response object.
|
|
312
|
+
|
|
313
|
+
Use this as an escape hatch when you need access to httpx features
|
|
314
|
+
that basst does not wrap directly.
|
|
315
|
+
"""
|
|
316
|
+
return self._response
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def text(self) -> str:
|
|
320
|
+
"""The raw response body as a string."""
|
|
321
|
+
return self._response.text
|