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 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