semblance 0.2.2__tar.gz → 0.3.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.
Files changed (36) hide show
  1. {semblance-0.2.2/src/semblance.egg-info → semblance-0.3.0}/PKG-INFO +10 -4
  2. {semblance-0.2.2 → semblance-0.3.0}/README.md +8 -3
  3. {semblance-0.2.2 → semblance-0.3.0}/pyproject.toml +6 -1
  4. {semblance-0.2.2 → semblance-0.3.0}/src/semblance/api.py +325 -14
  5. {semblance-0.2.2 → semblance-0.3.0}/src/semblance/cli.py +2 -1
  6. {semblance-0.2.2 → semblance-0.3.0}/src/semblance/export.py +1 -1
  7. {semblance-0.2.2 → semblance-0.3.0}/src/semblance/factory.py +15 -0
  8. semblance-0.3.0/src/semblance/property_testing.py +140 -0
  9. semblance-0.3.0/src/semblance/rate_limit.py +48 -0
  10. {semblance-0.2.2 → semblance-0.3.0/src/semblance.egg-info}/PKG-INFO +10 -4
  11. {semblance-0.2.2 → semblance-0.3.0}/src/semblance.egg-info/SOURCES.txt +3 -0
  12. {semblance-0.2.2 → semblance-0.3.0}/src/semblance.egg-info/requires.txt +1 -0
  13. semblance-0.3.0/tests/test_phase5.py +164 -0
  14. {semblance-0.2.2 → semblance-0.3.0}/LICENSE.md +0 -0
  15. {semblance-0.2.2 → semblance-0.3.0}/setup.cfg +0 -0
  16. {semblance-0.2.2 → semblance-0.3.0}/src/semblance/__init__.py +0 -0
  17. {semblance-0.2.2 → semblance-0.3.0}/src/semblance/links.py +0 -0
  18. {semblance-0.2.2 → semblance-0.3.0}/src/semblance/pagination.py +0 -0
  19. {semblance-0.2.2 → semblance-0.3.0}/src/semblance/plugins.py +0 -0
  20. {semblance-0.2.2 → semblance-0.3.0}/src/semblance/resolver.py +0 -0
  21. {semblance-0.2.2 → semblance-0.3.0}/src/semblance/state.py +0 -0
  22. {semblance-0.2.2 → semblance-0.3.0}/src/semblance/testing.py +0 -0
  23. {semblance-0.2.2 → semblance-0.3.0}/src/semblance.egg-info/dependency_links.txt +0 -0
  24. {semblance-0.2.2 → semblance-0.3.0}/src/semblance.egg-info/entry_points.txt +0 -0
  25. {semblance-0.2.2 → semblance-0.3.0}/src/semblance.egg-info/top_level.txt +0 -0
  26. {semblance-0.2.2 → semblance-0.3.0}/tests/test_api.py +0 -0
  27. {semblance-0.2.2 → semblance-0.3.0}/tests/test_doc_examples.py +0 -0
  28. {semblance-0.2.2 → semblance-0.3.0}/tests/test_edge_cases.py +0 -0
  29. {semblance-0.2.2 → semblance-0.3.0}/tests/test_factory.py +0 -0
  30. {semblance-0.2.2 → semblance-0.3.0}/tests/test_links.py +0 -0
  31. {semblance-0.2.2 → semblance-0.3.0}/tests/test_phase2.py +0 -0
  32. {semblance-0.2.2 → semblance-0.3.0}/tests/test_phase3.py +0 -0
  33. {semblance-0.2.2 → semblance-0.3.0}/tests/test_phase4.py +0 -0
  34. {semblance-0.2.2 → semblance-0.3.0}/tests/test_plugins.py +0 -0
  35. {semblance-0.2.2 → semblance-0.3.0}/tests/test_resolver.py +0 -0
  36. {semblance-0.2.2 → semblance-0.3.0}/tests/test_state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: semblance
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Schema-driven REST API simulation with FastAPI, Pydantic, and Polyfactory
5
5
  Author: Semblance Contributors
6
6
  License-Expression: MIT
@@ -25,6 +25,7 @@ Provides-Extra: dev
25
25
  Requires-Dist: pytest>=7.0.0; extra == "dev"
26
26
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
27
27
  Requires-Dist: httpx>=0.25.0; extra == "dev"
28
+ Requires-Dist: hypothesis>=6.0.0; extra == "dev"
28
29
  Requires-Dist: ruff>=0.8.0; extra == "dev"
29
30
  Requires-Dist: mypy>=1.0.0; extra == "dev"
30
31
  Requires-Dist: bandit>=1.7.0; extra == "dev"
@@ -55,7 +56,7 @@ Define API behavior declaratively using schemas and dependency metadata—no end
55
56
  - **FastAPI-native** — Full OpenAPI, validation, async
56
57
  - **Deterministic** — Seeded generation for reproducible tests
57
58
  - **Extensible** — Custom link types via plugins
58
- - **Production-ready** — Error simulation, latency, pagination, stateful mode
59
+ - **Production-ready** — Error simulation, latency, rate limiting, pagination, stateful mode, optional response validation
59
60
 
60
61
  ## Requirements
61
62
 
@@ -111,6 +112,8 @@ def users():
111
112
  app = api.as_fastapi()
112
113
  ```
113
114
 
115
+ You can register PUT, PATCH, and DELETE endpoints the same way (`@api.put(...)`, `@api.patch(...)`, `@api.delete(..., output=None)` for 204).
116
+
114
117
  Run:
115
118
 
116
119
  ```bash
@@ -219,15 +222,18 @@ class User(BaseModel):
219
222
 
220
223
  | Feature | Description |
221
224
  |---------|-------------|
222
- | **SemblanceAPI** | GET and POST endpoints with input/output models |
225
+ | **SemblanceAPI** | GET, POST, PUT, PATCH, DELETE endpoints with input/output models |
223
226
  | **Links** | FromInput, DateRangeFrom, WhenInput, ComputedFrom |
224
227
  | **Pagination** | PageParams, PaginatedResponse[T] |
225
228
  | **Seeding** | `SemblanceAPI(seed=42)` or `seed_from="seed"` |
226
229
  | **Error simulation** | `error_rate`, `error_codes` |
227
230
  | **Latency** | `latency_ms`, `jitter_ms` |
231
+ | **Rate limiting** | `rate_limit=N` — 429 when exceeded (per endpoint, sliding window) |
228
232
  | **Filtering** | `filter_by` for list endpoints |
229
233
  | **Stateful mode** | `SemblanceAPI(stateful=True)` — POST stores, GET returns stored |
234
+ | **Response validation** | `SemblanceAPI(validate_responses=True)` — verify output conforms to model |
230
235
  | **OpenAPI** | summary, description, tags on endpoints |
236
+ | **Property-based testing** | `semblance.property_testing`: `strategy_for_input_model()`, `test_endpoint()` (Hypothesis) |
231
237
 
232
238
  ## Competitors & Alternatives
233
239
 
@@ -245,7 +251,7 @@ class User(BaseModel):
245
251
  | **Extensible (plugins)** | ✅ | <span title="Middleware-based; custom behavior via decorators or wrappers">🟡</span> | ❌ | ❌ | ✅ | <span title="Templates and response rules provide extensibility">🟡</span> |
246
252
  | **OpenAPI schema** | ✅ | ✅ | ✅ | ❌ | ✅ | <span title="Can import/export OpenAPI; design is GUI-first, not schema-first">🟡</span> |
247
253
  | **CI / pytest integration** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
248
- | **Property-based testing** | | ❌ | ❌ | ❌ | ✅ | ❌ |
254
+ | **Property-based testing** | | ❌ | ❌ | ❌ | ✅ | ❌ |
249
255
 
250
256
  🟡 = partial or configurable
251
257
 
@@ -16,7 +16,7 @@ Define API behavior declaratively using schemas and dependency metadata—no end
16
16
  - **FastAPI-native** — Full OpenAPI, validation, async
17
17
  - **Deterministic** — Seeded generation for reproducible tests
18
18
  - **Extensible** — Custom link types via plugins
19
- - **Production-ready** — Error simulation, latency, pagination, stateful mode
19
+ - **Production-ready** — Error simulation, latency, rate limiting, pagination, stateful mode, optional response validation
20
20
 
21
21
  ## Requirements
22
22
 
@@ -72,6 +72,8 @@ def users():
72
72
  app = api.as_fastapi()
73
73
  ```
74
74
 
75
+ You can register PUT, PATCH, and DELETE endpoints the same way (`@api.put(...)`, `@api.patch(...)`, `@api.delete(..., output=None)` for 204).
76
+
75
77
  Run:
76
78
 
77
79
  ```bash
@@ -180,15 +182,18 @@ class User(BaseModel):
180
182
 
181
183
  | Feature | Description |
182
184
  |---------|-------------|
183
- | **SemblanceAPI** | GET and POST endpoints with input/output models |
185
+ | **SemblanceAPI** | GET, POST, PUT, PATCH, DELETE endpoints with input/output models |
184
186
  | **Links** | FromInput, DateRangeFrom, WhenInput, ComputedFrom |
185
187
  | **Pagination** | PageParams, PaginatedResponse[T] |
186
188
  | **Seeding** | `SemblanceAPI(seed=42)` or `seed_from="seed"` |
187
189
  | **Error simulation** | `error_rate`, `error_codes` |
188
190
  | **Latency** | `latency_ms`, `jitter_ms` |
191
+ | **Rate limiting** | `rate_limit=N` — 429 when exceeded (per endpoint, sliding window) |
189
192
  | **Filtering** | `filter_by` for list endpoints |
190
193
  | **Stateful mode** | `SemblanceAPI(stateful=True)` — POST stores, GET returns stored |
194
+ | **Response validation** | `SemblanceAPI(validate_responses=True)` — verify output conforms to model |
191
195
  | **OpenAPI** | summary, description, tags on endpoints |
196
+ | **Property-based testing** | `semblance.property_testing`: `strategy_for_input_model()`, `test_endpoint()` (Hypothesis) |
192
197
 
193
198
  ## Competitors & Alternatives
194
199
 
@@ -206,7 +211,7 @@ class User(BaseModel):
206
211
  | **Extensible (plugins)** | ✅ | <span title="Middleware-based; custom behavior via decorators or wrappers">🟡</span> | ❌ | ❌ | ✅ | <span title="Templates and response rules provide extensibility">🟡</span> |
207
212
  | **OpenAPI schema** | ✅ | ✅ | ✅ | ❌ | ✅ | <span title="Can import/export OpenAPI; design is GUI-first, not schema-first">🟡</span> |
208
213
  | **CI / pytest integration** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
209
- | **Property-based testing** | | ❌ | ❌ | ❌ | ✅ | ❌ |
214
+ | **Property-based testing** | | ❌ | ❌ | ❌ | ✅ | ❌ |
210
215
 
211
216
  🟡 = partial or configurable
212
217
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "semblance"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "Schema-driven REST API simulation with FastAPI, Pydantic, and Polyfactory"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -35,6 +35,7 @@ dev = [
35
35
  "pytest>=7.0.0",
36
36
  "pytest-cov>=4.0.0",
37
37
  "httpx>=0.25.0",
38
+ "hypothesis>=6.0.0",
38
39
  "ruff>=0.8.0",
39
40
  "mypy>=1.0.0",
40
41
  "bandit>=1.7.0",
@@ -76,6 +77,10 @@ warn_unused_configs = true
76
77
  disallow_untyped_defs = false
77
78
  mypy_path = "src"
78
79
 
80
+ [[tool.mypy.overrides]]
81
+ module = ["semblance.*"]
82
+ disallow_untyped_defs = true
83
+
79
84
  [[tool.mypy.overrides]]
80
85
  module = ["polyfactory.*"]
81
86
  ignore_missing_imports = true
@@ -13,10 +13,12 @@ import re
13
13
  from collections.abc import Callable
14
14
  from typing import Annotated, Any, get_origin
15
15
 
16
- from fastapi import FastAPI, HTTPException, Query, Request
16
+ from fastapi import Body, FastAPI, HTTPException, Query, Request
17
+ from fastapi.responses import Response
17
18
  from pydantic import BaseModel
18
19
 
19
- from semblance.factory import build_response
20
+ from semblance.factory import build_response, validate_response
21
+ from semblance.rate_limit import get_limiter
20
22
  from semblance.state import StatefulStore
21
23
 
22
24
 
@@ -41,6 +43,7 @@ class EndpointSpec:
41
43
  "latency_ms",
42
44
  "jitter_ms",
43
45
  "filter_by",
46
+ "rate_limit",
44
47
  "summary",
45
48
  "description",
46
49
  "tags",
@@ -51,7 +54,7 @@ class EndpointSpec:
51
54
  path: str,
52
55
  methods: list[str],
53
56
  input_model: type[BaseModel],
54
- output_annotation: type,
57
+ output_annotation: type | None,
55
58
  handler: Callable[..., Any],
56
59
  list_count: int | str = 5,
57
60
  seed_from: str | None = None,
@@ -60,6 +63,7 @@ class EndpointSpec:
60
63
  latency_ms: float = 0,
61
64
  jitter_ms: float = 0,
62
65
  filter_by: str | None = None,
66
+ rate_limit: float | None = None,
63
67
  summary: str | None = None,
64
68
  description: str | None = None,
65
69
  tags: list[str] | None = None,
@@ -76,6 +80,7 @@ class EndpointSpec:
76
80
  self.latency_ms = latency_ms
77
81
  self.jitter_ms = jitter_ms
78
82
  self.filter_by = filter_by
83
+ self.rate_limit = rate_limit
79
84
  self.summary = summary
80
85
  self.description = description
81
86
  self.tags = tags
@@ -90,12 +95,20 @@ class SemblanceAPI:
90
95
  seed: Optional seed for deterministic random generation.
91
96
  stateful: If True, POST responses are stored and GET list endpoints
92
97
  return stored instances instead of generating new ones.
98
+ validate_responses: If True, generated responses are validated against
99
+ the output model (for development/CI; adds overhead).
93
100
  """
94
101
 
95
- def __init__(self, seed: int | None = None, stateful: bool = False) -> None:
102
+ def __init__(
103
+ self,
104
+ seed: int | None = None,
105
+ stateful: bool = False,
106
+ validate_responses: bool = False,
107
+ ) -> None:
96
108
  self._specs: list[EndpointSpec] = []
97
109
  self._seed = seed
98
110
  self._store: StatefulStore | None = StatefulStore() if stateful else None
111
+ self._validate_responses = validate_responses
99
112
 
100
113
  def clear_store(self, path: str | None = None) -> None:
101
114
  """Clear the stateful store. Only available when stateful=True."""
@@ -115,6 +128,7 @@ class SemblanceAPI:
115
128
  latency_ms: float = 0,
116
129
  jitter_ms: float = 0,
117
130
  filter_by: str | None = None,
131
+ rate_limit: float | None = None,
118
132
  summary: str | None = None,
119
133
  description: str | None = None,
120
134
  tags: list[str] | None = None,
@@ -140,6 +154,7 @@ class SemblanceAPI:
140
154
  latency_ms,
141
155
  jitter_ms,
142
156
  filter_by,
157
+ rate_limit,
143
158
  summary,
144
159
  description,
145
160
  tags,
@@ -158,6 +173,7 @@ class SemblanceAPI:
158
173
  latency_ms: float = 0,
159
174
  jitter_ms: float = 0,
160
175
  filter_by: str | None = None,
176
+ rate_limit: float | None = None,
161
177
  summary: str | None = None,
162
178
  description: str | None = None,
163
179
  tags: list[str] | None = None,
@@ -183,17 +199,147 @@ class SemblanceAPI:
183
199
  latency_ms,
184
200
  jitter_ms,
185
201
  filter_by,
202
+ rate_limit,
186
203
  summary,
187
204
  description,
188
205
  tags,
189
206
  )
190
207
 
208
+ def put(
209
+ self,
210
+ path: str,
211
+ *,
212
+ input: type[BaseModel],
213
+ output: type,
214
+ list_count: int | str = 5,
215
+ seed_from: str | None = None,
216
+ error_rate: float = 0,
217
+ error_codes: list[int] | None = None,
218
+ latency_ms: float = 0,
219
+ jitter_ms: float = 0,
220
+ filter_by: str | None = None,
221
+ rate_limit: float | None = None,
222
+ summary: str | None = None,
223
+ description: str | None = None,
224
+ tags: list[str] | None = None,
225
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
226
+ """
227
+ Register a PUT endpoint. Request body is validated against `input`.
228
+ Response is generated from `output` (single model or list[model]).
229
+
230
+ Usage:
231
+ @api.put("/users/{id}", input=UpdateUserRequest, output=User)
232
+ def update_user():
233
+ pass
234
+ """
235
+ return self._register(
236
+ path,
237
+ "PUT",
238
+ input,
239
+ output,
240
+ list_count,
241
+ seed_from,
242
+ error_rate,
243
+ error_codes,
244
+ latency_ms,
245
+ jitter_ms,
246
+ filter_by,
247
+ rate_limit,
248
+ summary,
249
+ description,
250
+ tags,
251
+ )
252
+
253
+ def patch(
254
+ self,
255
+ path: str,
256
+ *,
257
+ input: type[BaseModel],
258
+ output: type,
259
+ list_count: int | str = 5,
260
+ seed_from: str | None = None,
261
+ error_rate: float = 0,
262
+ error_codes: list[int] | None = None,
263
+ latency_ms: float = 0,
264
+ jitter_ms: float = 0,
265
+ filter_by: str | None = None,
266
+ rate_limit: float | None = None,
267
+ summary: str | None = None,
268
+ description: str | None = None,
269
+ tags: list[str] | None = None,
270
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
271
+ """
272
+ Register a PATCH endpoint. Request body is validated against `input`.
273
+ Response is generated from `output` (single model or list[model]).
274
+
275
+ Usage:
276
+ @api.patch("/users/{id}", input=PatchUserRequest, output=User)
277
+ def patch_user():
278
+ pass
279
+ """
280
+ return self._register(
281
+ path,
282
+ "PATCH",
283
+ input,
284
+ output,
285
+ list_count,
286
+ seed_from,
287
+ error_rate,
288
+ error_codes,
289
+ latency_ms,
290
+ jitter_ms,
291
+ filter_by,
292
+ rate_limit,
293
+ summary,
294
+ description,
295
+ tags,
296
+ )
297
+
298
+ def delete(
299
+ self,
300
+ path: str,
301
+ *,
302
+ input: type[BaseModel],
303
+ output: type | None = None,
304
+ rate_limit: float | None = None,
305
+ summary: str | None = None,
306
+ description: str | None = None,
307
+ tags: list[str] | None = None,
308
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
309
+ """
310
+ Register a DELETE endpoint. Path params (and optional body) are validated
311
+ against `input`. If `output` is None, returns 204 No Content.
312
+ If `output` is a model, returns 200 with generated body.
313
+
314
+ Usage:
315
+ @api.delete("/users/{id}", input=DeleteUserQuery)
316
+ def delete_user():
317
+ pass
318
+ """
319
+ return self._register(
320
+ path,
321
+ "DELETE",
322
+ input,
323
+ output,
324
+ list_count=1,
325
+ seed_from=None,
326
+ error_rate=0,
327
+ error_codes=None,
328
+ latency_ms=0,
329
+ jitter_ms=0,
330
+ filter_by=None,
331
+ rate_limit=rate_limit,
332
+ summary=summary,
333
+ description=description,
334
+ tags=tags,
335
+ )
336
+
191
337
  def _register(
192
338
  self,
193
339
  path: str,
194
340
  method: str,
195
341
  input_model: type[BaseModel],
196
- output: type,
342
+ output: type | None,
197
343
  list_count: int | str = 5,
198
344
  seed_from: str | None = None,
199
345
  error_rate: float = 0,
@@ -201,6 +347,7 @@ class SemblanceAPI:
201
347
  latency_ms: float = 0,
202
348
  jitter_ms: float = 0,
203
349
  filter_by: str | None = None,
350
+ rate_limit: float | None = None,
204
351
  summary: str | None = None,
205
352
  description: str | None = None,
206
353
  tags: list[str] | None = None,
@@ -220,6 +367,7 @@ class SemblanceAPI:
220
367
  latency_ms=latency_ms,
221
368
  jitter_ms=jitter_ms,
222
369
  filter_by=filter_by,
370
+ rate_limit=rate_limit,
223
371
  summary=summary,
224
372
  description=description,
225
373
  tags=tags,
@@ -246,6 +394,12 @@ class SemblanceAPI:
246
394
  self._register_get(app, spec)
247
395
  elif method == "POST":
248
396
  self._register_post(app, spec)
397
+ elif method == "PUT":
398
+ self._register_put(app, spec)
399
+ elif method == "PATCH":
400
+ self._register_patch(app, spec)
401
+ elif method == "DELETE":
402
+ self._register_delete(app, spec)
249
403
 
250
404
  return app
251
405
 
@@ -307,6 +461,17 @@ class SemblanceAPI:
307
461
  merged = {**data.model_dump(), **path_params}
308
462
  return input_model.model_validate(merged)
309
463
 
464
+ def _check_rate_limit(self, spec: EndpointSpec) -> None:
465
+ """Raise HTTPException 429 if rate limit exceeded."""
466
+ if spec.rate_limit is None or spec.rate_limit <= 0:
467
+ return
468
+ limiter = get_limiter()
469
+ method = spec.methods[0]
470
+ if not limiter.check_and_record(spec.path, method, spec.rate_limit):
471
+ raise HTTPException(
472
+ status_code=429, detail="Rate limit exceeded (simulated)"
473
+ )
474
+
310
475
  def _register_get(self, app: FastAPI, spec: EndpointSpec) -> None:
311
476
  input_model = spec.input_model
312
477
  output_annotation = spec.output_annotation
@@ -324,16 +489,67 @@ class SemblanceAPI:
324
489
  request: Request,
325
490
  query: Annotated[input_model, Query()],
326
491
  ) -> output_annotation:
492
+ assert output_annotation is not None
493
+ self._check_rate_limit(spec)
327
494
  merged = self._merge_path_params(
328
495
  input_model, query, dict(request.path_params)
329
496
  )
330
497
  seed = self._resolve_seed(seed_from, merged)
331
498
  self._maybe_raise_error(error_rate, error_codes, seed)
332
499
  await self._await_latency(latency_ms, jitter_ms)
500
+ response: BaseModel | list[BaseModel]
333
501
  if store is not None and get_origin(output_annotation) is list:
334
- return store.get_all(path)
502
+ response = store.get_all(path)
503
+ else:
504
+ count = self._resolve_list_count(list_count, merged)
505
+ response = build_response(
506
+ output_annotation,
507
+ input_model,
508
+ merged,
509
+ list_count=count,
510
+ seed=seed,
511
+ filter_by=filter_by,
512
+ )
513
+ if self._validate_responses:
514
+ validate_response(output_annotation, response)
515
+ return response
516
+
517
+ kwargs: dict[str, Any] = {"response_model": output_annotation}
518
+ if spec.summary is not None:
519
+ kwargs["summary"] = spec.summary
520
+ if spec.description is not None:
521
+ kwargs["description"] = spec.description
522
+ if spec.tags is not None:
523
+ kwargs["tags"] = spec.tags
524
+ app.get(spec.path, **kwargs)(handler)
525
+
526
+ def _register_post(self, app: FastAPI, spec: EndpointSpec) -> None:
527
+ assert spec.output_annotation is not None
528
+ input_model = spec.input_model
529
+ output_annotation = spec.output_annotation
530
+ list_count = spec.list_count
531
+ seed_from = spec.seed_from
532
+ error_rate = spec.error_rate
533
+ error_codes = spec.error_codes
534
+ latency_ms = spec.latency_ms
535
+ jitter_ms = spec.jitter_ms
536
+ filter_by = spec.filter_by
537
+ store = self._store
538
+ path = spec.path
539
+
540
+ async def handler(
541
+ request: Request,
542
+ body: input_model,
543
+ ) -> output_annotation:
544
+ self._check_rate_limit(spec)
545
+ merged = self._merge_path_params(
546
+ input_model, body, dict(request.path_params)
547
+ )
548
+ seed = self._resolve_seed(seed_from, merged)
549
+ self._maybe_raise_error(error_rate, error_codes, seed)
550
+ await self._await_latency(latency_ms, jitter_ms)
335
551
  count = self._resolve_list_count(list_count, merged)
336
- return build_response(
552
+ response = build_response(
337
553
  output_annotation,
338
554
  input_model,
339
555
  merged,
@@ -341,6 +557,11 @@ class SemblanceAPI:
341
557
  seed=seed,
342
558
  filter_by=filter_by,
343
559
  )
560
+ if store is not None and not isinstance(response, list):
561
+ response = store.add(path, response)
562
+ if self._validate_responses:
563
+ validate_response(output_annotation, response)
564
+ return response
344
565
 
345
566
  kwargs: dict[str, Any] = {"response_model": output_annotation}
346
567
  if spec.summary is not None:
@@ -349,9 +570,10 @@ class SemblanceAPI:
349
570
  kwargs["description"] = spec.description
350
571
  if spec.tags is not None:
351
572
  kwargs["tags"] = spec.tags
352
- app.get(spec.path, **kwargs)(handler)
573
+ app.post(spec.path, **kwargs)(handler)
353
574
 
354
- def _register_post(self, app: FastAPI, spec: EndpointSpec) -> None:
575
+ def _register_put(self, app: FastAPI, spec: EndpointSpec) -> None:
576
+ assert spec.output_annotation is not None
355
577
  input_model = spec.input_model
356
578
  output_annotation = spec.output_annotation
357
579
  list_count = spec.list_count
@@ -361,13 +583,12 @@ class SemblanceAPI:
361
583
  latency_ms = spec.latency_ms
362
584
  jitter_ms = spec.jitter_ms
363
585
  filter_by = spec.filter_by
364
- store = self._store
365
- path = spec.path
366
586
 
367
587
  async def handler(
368
588
  request: Request,
369
589
  body: input_model,
370
590
  ) -> output_annotation:
591
+ self._check_rate_limit(spec)
371
592
  merged = self._merge_path_params(
372
593
  input_model, body, dict(request.path_params)
373
594
  )
@@ -383,8 +604,8 @@ class SemblanceAPI:
383
604
  seed=seed,
384
605
  filter_by=filter_by,
385
606
  )
386
- if store is not None and not isinstance(response, list):
387
- response = store.add(path, response)
607
+ if self._validate_responses:
608
+ validate_response(output_annotation, response)
388
609
  return response
389
610
 
390
611
  kwargs: dict[str, Any] = {"response_model": output_annotation}
@@ -394,4 +615,94 @@ class SemblanceAPI:
394
615
  kwargs["description"] = spec.description
395
616
  if spec.tags is not None:
396
617
  kwargs["tags"] = spec.tags
397
- app.post(spec.path, **kwargs)(handler)
618
+ app.put(spec.path, **kwargs)(handler)
619
+
620
+ def _register_patch(self, app: FastAPI, spec: EndpointSpec) -> None:
621
+ assert spec.output_annotation is not None
622
+ input_model = spec.input_model
623
+ output_annotation = spec.output_annotation
624
+ list_count = spec.list_count
625
+ seed_from = spec.seed_from
626
+ error_rate = spec.error_rate
627
+ error_codes = spec.error_codes
628
+ latency_ms = spec.latency_ms
629
+ jitter_ms = spec.jitter_ms
630
+ filter_by = spec.filter_by
631
+
632
+ async def handler(
633
+ request: Request,
634
+ body: input_model,
635
+ ) -> output_annotation:
636
+ self._check_rate_limit(spec)
637
+ merged = self._merge_path_params(
638
+ input_model, body, dict(request.path_params)
639
+ )
640
+ seed = self._resolve_seed(seed_from, merged)
641
+ self._maybe_raise_error(error_rate, error_codes, seed)
642
+ await self._await_latency(latency_ms, jitter_ms)
643
+ count = self._resolve_list_count(list_count, merged)
644
+ response = build_response(
645
+ output_annotation,
646
+ input_model,
647
+ merged,
648
+ list_count=count,
649
+ seed=seed,
650
+ filter_by=filter_by,
651
+ )
652
+ if self._validate_responses:
653
+ validate_response(output_annotation, response)
654
+ return response
655
+
656
+ kwargs: dict[str, Any] = {"response_model": output_annotation}
657
+ if spec.summary is not None:
658
+ kwargs["summary"] = spec.summary
659
+ if spec.description is not None:
660
+ kwargs["description"] = spec.description
661
+ if spec.tags is not None:
662
+ kwargs["tags"] = spec.tags
663
+ app.patch(spec.path, **kwargs)(handler)
664
+
665
+ def _register_delete(self, app: FastAPI, spec: EndpointSpec) -> None:
666
+ input_model = spec.input_model
667
+ output_annotation = spec.output_annotation
668
+ seed_from = spec.seed_from
669
+ error_rate = spec.error_rate
670
+ error_codes = spec.error_codes
671
+ latency_ms = spec.latency_ms
672
+ jitter_ms = spec.jitter_ms
673
+
674
+ async def handler(
675
+ request: Request,
676
+ body: input_model | None = Body(None),
677
+ ) -> Any:
678
+ self._check_rate_limit(spec)
679
+ path_params = dict(request.path_params)
680
+ data: dict[str, Any] = body.model_dump() if body is not None else {}
681
+ merged = input_model.model_validate({**data, **path_params})
682
+ seed = self._resolve_seed(seed_from, merged)
683
+ self._maybe_raise_error(error_rate, error_codes, seed)
684
+ await self._await_latency(latency_ms, jitter_ms)
685
+ if output_annotation is None:
686
+ return Response(status_code=204)
687
+ response = build_response(
688
+ output_annotation,
689
+ input_model,
690
+ merged,
691
+ list_count=1,
692
+ seed=seed,
693
+ filter_by=None,
694
+ )
695
+ if self._validate_responses:
696
+ validate_response(output_annotation, response)
697
+ return response
698
+
699
+ kwargs: dict[str, Any] = {}
700
+ if output_annotation is not None:
701
+ kwargs["response_model"] = output_annotation
702
+ if spec.summary is not None:
703
+ kwargs["summary"] = spec.summary
704
+ if spec.description is not None:
705
+ kwargs["description"] = spec.description
706
+ if spec.tags is not None:
707
+ kwargs["tags"] = spec.tags
708
+ app.delete(spec.path, **kwargs)(handler)
@@ -9,9 +9,10 @@ import argparse
9
9
  import importlib.util
10
10
  import sys
11
11
  from pathlib import Path
12
+ from typing import Any
12
13
 
13
14
 
14
- def _load_app(path: str):
15
+ def _load_app(path: str) -> Any:
15
16
  """Load app from module:attr. If attr has as_fastapi(), it is called to get FastAPI app."""
16
17
  if ":" not in path:
17
18
  raise SystemExit(
@@ -16,7 +16,7 @@ from fastapi.testclient import TestClient
16
16
 
17
17
  def _get_routes(app: FastAPI) -> list[tuple[str, str, str]]:
18
18
  """Return (path, method, route_id) for each API route."""
19
- routes = []
19
+ routes: list[tuple[str, str, str]] = []
20
20
  for route in app.routes:
21
21
  if hasattr(route, "path") and hasattr(route, "methods"):
22
22
  for method in route.methods - {"HEAD", "OPTIONS"}:
@@ -187,3 +187,18 @@ def build_response(
187
187
  "Use a Pydantic BaseModel or list[SomeModel] for output."
188
188
  )
189
189
  return build_one(model, input_model, input_instance, seed=seed)
190
+
191
+
192
+ def validate_response(
193
+ output_annotation: type,
194
+ instance: BaseModel | list[BaseModel],
195
+ ) -> None:
196
+ """
197
+ Validate that instance conforms to output_annotation.
198
+ Raises ValidationError on mismatch. For list/PaginatedResponse,
199
+ validates the structure and each item.
200
+ """
201
+ from pydantic import TypeAdapter
202
+
203
+ adapter: TypeAdapter[Any] = TypeAdapter(output_annotation)
204
+ adapter.validate_python(instance)
@@ -0,0 +1,140 @@
1
+ """
2
+ Property-based testing helpers for Semblance APIs.
3
+
4
+ Generate Hypothesis strategies from input models and run endpoint tests
5
+ that validate responses against output schemas and optional invariants.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Callable
12
+ from typing import Annotated, Any, get_args, get_origin
13
+
14
+ from pydantic import BaseModel
15
+
16
+ try:
17
+ from hypothesis import given
18
+ from hypothesis import strategies as st
19
+ except ImportError as e:
20
+ raise ImportError(
21
+ "Property-based testing requires hypothesis. Install with: pip install semblance[dev]"
22
+ ) from e
23
+
24
+
25
+ def _parse_path_params(path: str) -> list[str]:
26
+ """Extract path param names from template, e.g. '/users/{id}' -> ['id']."""
27
+ return re.findall(r"\{(\w+)\}", path)
28
+
29
+
30
+ def _get_bare_annotation(annotation: type) -> type:
31
+ """Strip Annotated and Union to get a concrete type for strategy generation."""
32
+ origin = get_origin(annotation)
33
+ args = get_args(annotation)
34
+ if origin is Annotated and args:
35
+ return _get_bare_annotation(args[0])
36
+ if origin is type(None) or (origin is not None and "Union" in str(origin)) and args:
37
+ for a in args:
38
+ if a is type(None):
39
+ continue
40
+ return _get_bare_annotation(a)
41
+ return annotation
42
+
43
+
44
+ def _strategy_for_annotation(annotation: type) -> st.SearchStrategy[Any]:
45
+ """Build a Hypothesis strategy for a single field annotation."""
46
+ bare = _get_bare_annotation(annotation)
47
+ if bare is type(None):
48
+ return st.none()
49
+ try:
50
+ if isinstance(bare, type) and issubclass(bare, BaseModel):
51
+ return strategy_for_input_model(bare)
52
+ except TypeError:
53
+ pass
54
+ if bare is str:
55
+ return st.text()
56
+ if bare is int:
57
+ return st.integers()
58
+ if bare is float:
59
+ return st.floats(allow_nan=False)
60
+ if bare is bool:
61
+ return st.booleans()
62
+ try:
63
+ return st.from_type(bare)
64
+ except Exception:
65
+ return st.none()
66
+
67
+
68
+ def strategy_for_input_model(
69
+ model: type[BaseModel],
70
+ path_template: str | None = None,
71
+ ) -> st.SearchStrategy[BaseModel]:
72
+ """
73
+ Build a Hypothesis strategy that generates instances of the input model.
74
+
75
+ Use for GET (query) or POST/PUT/PATCH (body) input. If path_template is
76
+ given (e.g. '/users/{id}'), path param names are inferred; the strategy
77
+ still generates full model instances (path params can be passed separately
78
+ when building the request).
79
+ """
80
+ strategies: dict[str, st.SearchStrategy[Any]] = {}
81
+ for name, field in model.model_fields.items():
82
+ ann = field.annotation
83
+ if ann is not None:
84
+ strategies[name] = _strategy_for_annotation(ann)
85
+ else:
86
+ strategies[name] = st.none()
87
+ return st.builds(model, **strategies)
88
+
89
+
90
+ def test_endpoint(
91
+ client: Any,
92
+ method: str,
93
+ path: str,
94
+ input_strategy: st.SearchStrategy[BaseModel],
95
+ output_model: type,
96
+ path_params: dict[str, Any] | None = None,
97
+ validate_response: bool = True,
98
+ invariants: tuple[Callable[[Any, Any], bool], ...] = (),
99
+ ) -> None:
100
+ """
101
+ Run a property-based test: draw input from input_strategy, call the
102
+ endpoint, assert status and that response conforms to output_model.
103
+ Optional invariants are (input, output) -> bool; all must hold.
104
+ """
105
+ path_params = path_params or {}
106
+ path_params_from_template = {
107
+ k: (path_params.get(k) or "placeholder") for k in _parse_path_params(path)
108
+ }
109
+
110
+ @given(input_strategy)
111
+ def _run(input_instance: BaseModel) -> None:
112
+ data = input_instance.model_dump()
113
+ url = path
114
+ for key, val in path_params_from_template.items():
115
+ url = url.replace("{" + key + "}", str(val))
116
+ if method.upper() == "GET":
117
+ from urllib.parse import urlencode
118
+
119
+ query = urlencode(data)
120
+ url = f"{url}?{query}" if query else url
121
+ r = client.get(url)
122
+ elif method.upper() in ("POST", "PUT", "PATCH"):
123
+ r = client.request(method.upper(), url, json=data)
124
+ elif method.upper() == "DELETE":
125
+ r = client.delete(url)
126
+ else:
127
+ raise ValueError(f"Unsupported method {method}")
128
+ assert r.status_code in (200, 201, 204), (r.status_code, r.text)
129
+ if r.status_code == 204:
130
+ return
131
+ body = r.json()
132
+ if validate_response:
133
+ from pydantic import TypeAdapter
134
+
135
+ adapter: TypeAdapter[Any] = TypeAdapter(output_model)
136
+ adapter.validate_python(body)
137
+ for inv in invariants:
138
+ assert inv(input_instance, body), f"Invariant failed: {inv}"
139
+
140
+ _run()
@@ -0,0 +1,48 @@
1
+ """
2
+ Rate limiting simulation for endpoints.
3
+
4
+ Sliding-window: at most N requests per second per (path, method).
5
+ Per-process, in-memory; for simulation only.
6
+ """
7
+
8
+ import time
9
+ from collections import defaultdict
10
+ from threading import Lock
11
+
12
+
13
+ class RateLimiter:
14
+ """Sliding-window rate limiter keyed by (path, method)."""
15
+
16
+ def __init__(self) -> None:
17
+ self._timestamps: dict[tuple[str, str], list[float]] = defaultdict(list)
18
+ self._lock = Lock()
19
+
20
+ def check_and_record(self, path: str, method: str, limit: float) -> bool:
21
+ """
22
+ Record a request and return True if under limit, False if over limit.
23
+
24
+ Uses a 1-second sliding window: requests older than 1 second are dropped.
25
+ """
26
+ if limit <= 0:
27
+ return True
28
+ now = time.monotonic()
29
+ key = (path, method)
30
+ with self._lock:
31
+ ts_list = self._timestamps[key]
32
+ # Drop timestamps older than 1 second
33
+ ts_list[:] = [t for t in ts_list if now - t < 1.0]
34
+ if len(ts_list) >= limit:
35
+ return False
36
+ ts_list.append(now)
37
+ return True
38
+
39
+
40
+ _limiter: RateLimiter | None = None
41
+
42
+
43
+ def get_limiter() -> RateLimiter:
44
+ """Return the global rate limiter instance."""
45
+ global _limiter
46
+ if _limiter is None:
47
+ _limiter = RateLimiter()
48
+ return _limiter
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: semblance
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Schema-driven REST API simulation with FastAPI, Pydantic, and Polyfactory
5
5
  Author: Semblance Contributors
6
6
  License-Expression: MIT
@@ -25,6 +25,7 @@ Provides-Extra: dev
25
25
  Requires-Dist: pytest>=7.0.0; extra == "dev"
26
26
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
27
27
  Requires-Dist: httpx>=0.25.0; extra == "dev"
28
+ Requires-Dist: hypothesis>=6.0.0; extra == "dev"
28
29
  Requires-Dist: ruff>=0.8.0; extra == "dev"
29
30
  Requires-Dist: mypy>=1.0.0; extra == "dev"
30
31
  Requires-Dist: bandit>=1.7.0; extra == "dev"
@@ -55,7 +56,7 @@ Define API behavior declaratively using schemas and dependency metadata—no end
55
56
  - **FastAPI-native** — Full OpenAPI, validation, async
56
57
  - **Deterministic** — Seeded generation for reproducible tests
57
58
  - **Extensible** — Custom link types via plugins
58
- - **Production-ready** — Error simulation, latency, pagination, stateful mode
59
+ - **Production-ready** — Error simulation, latency, rate limiting, pagination, stateful mode, optional response validation
59
60
 
60
61
  ## Requirements
61
62
 
@@ -111,6 +112,8 @@ def users():
111
112
  app = api.as_fastapi()
112
113
  ```
113
114
 
115
+ You can register PUT, PATCH, and DELETE endpoints the same way (`@api.put(...)`, `@api.patch(...)`, `@api.delete(..., output=None)` for 204).
116
+
114
117
  Run:
115
118
 
116
119
  ```bash
@@ -219,15 +222,18 @@ class User(BaseModel):
219
222
 
220
223
  | Feature | Description |
221
224
  |---------|-------------|
222
- | **SemblanceAPI** | GET and POST endpoints with input/output models |
225
+ | **SemblanceAPI** | GET, POST, PUT, PATCH, DELETE endpoints with input/output models |
223
226
  | **Links** | FromInput, DateRangeFrom, WhenInput, ComputedFrom |
224
227
  | **Pagination** | PageParams, PaginatedResponse[T] |
225
228
  | **Seeding** | `SemblanceAPI(seed=42)` or `seed_from="seed"` |
226
229
  | **Error simulation** | `error_rate`, `error_codes` |
227
230
  | **Latency** | `latency_ms`, `jitter_ms` |
231
+ | **Rate limiting** | `rate_limit=N` — 429 when exceeded (per endpoint, sliding window) |
228
232
  | **Filtering** | `filter_by` for list endpoints |
229
233
  | **Stateful mode** | `SemblanceAPI(stateful=True)` — POST stores, GET returns stored |
234
+ | **Response validation** | `SemblanceAPI(validate_responses=True)` — verify output conforms to model |
230
235
  | **OpenAPI** | summary, description, tags on endpoints |
236
+ | **Property-based testing** | `semblance.property_testing`: `strategy_for_input_model()`, `test_endpoint()` (Hypothesis) |
231
237
 
232
238
  ## Competitors & Alternatives
233
239
 
@@ -245,7 +251,7 @@ class User(BaseModel):
245
251
  | **Extensible (plugins)** | ✅ | <span title="Middleware-based; custom behavior via decorators or wrappers">🟡</span> | ❌ | ❌ | ✅ | <span title="Templates and response rules provide extensibility">🟡</span> |
246
252
  | **OpenAPI schema** | ✅ | ✅ | ✅ | ❌ | ✅ | <span title="Can import/export OpenAPI; design is GUI-first, not schema-first">🟡</span> |
247
253
  | **CI / pytest integration** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
248
- | **Property-based testing** | | ❌ | ❌ | ❌ | ✅ | ❌ |
254
+ | **Property-based testing** | | ❌ | ❌ | ❌ | ✅ | ❌ |
249
255
 
250
256
  🟡 = partial or configurable
251
257
 
@@ -9,6 +9,8 @@ src/semblance/factory.py
9
9
  src/semblance/links.py
10
10
  src/semblance/pagination.py
11
11
  src/semblance/plugins.py
12
+ src/semblance/property_testing.py
13
+ src/semblance/rate_limit.py
12
14
  src/semblance/resolver.py
13
15
  src/semblance/state.py
14
16
  src/semblance/testing.py
@@ -26,6 +28,7 @@ tests/test_links.py
26
28
  tests/test_phase2.py
27
29
  tests/test_phase3.py
28
30
  tests/test_phase4.py
31
+ tests/test_phase5.py
29
32
  tests/test_plugins.py
30
33
  tests/test_resolver.py
31
34
  tests/test_state.py
@@ -7,6 +7,7 @@ uvicorn>=0.30.0
7
7
  pytest>=7.0.0
8
8
  pytest-cov>=4.0.0
9
9
  httpx>=0.25.0
10
+ hypothesis>=6.0.0
10
11
  ruff>=0.8.0
11
12
  mypy>=1.0.0
12
13
  bandit>=1.7.0
@@ -0,0 +1,164 @@
1
+ """Tests for Phase 5: PUT/PATCH/DELETE, rate limiting, response validation, property-based testing."""
2
+
3
+ import time
4
+
5
+ import pytest
6
+ from pydantic import BaseModel
7
+
8
+ from semblance import SemblanceAPI
9
+ from semblance.testing import test_client as make_client
10
+ from tests.example_models import User, UserQuery
11
+
12
+
13
+ class UpdateBody(BaseModel):
14
+ """Minimal body for PUT/PATCH."""
15
+
16
+ name: str = "updated"
17
+
18
+
19
+ class DeletePathInput(BaseModel):
20
+ """Path-only input for DELETE (e.g. id from path)."""
21
+
22
+ id: str = ""
23
+
24
+
25
+ # --- PUT / PATCH / DELETE ---
26
+
27
+
28
+ class TestPutPatchDelete:
29
+ def test_put_returns_generated_response(self):
30
+ api = SemblanceAPI(seed=42)
31
+ api.put("/users/{id}", input=UpdateBody, output=User)(lambda: None)
32
+ app = api.as_fastapi()
33
+ client = make_client(app)
34
+ r = client.put("/users/abc", json={"name": "put-user"})
35
+ assert r.status_code == 200
36
+ data = r.json()
37
+ assert "name" in data
38
+ assert data["name"] == "put-user"
39
+
40
+ def test_patch_returns_generated_response(self):
41
+ api = SemblanceAPI(seed=42)
42
+ api.patch("/users/{id}", input=UpdateBody, output=User)(lambda: None)
43
+ app = api.as_fastapi()
44
+ client = make_client(app)
45
+ r = client.patch("/users/xyz", json={"name": "patch-user"})
46
+ assert r.status_code == 200
47
+ data = r.json()
48
+ assert data["name"] == "patch-user"
49
+
50
+ def test_delete_204_when_no_output(self):
51
+ api = SemblanceAPI()
52
+ api.delete("/users/{id}", input=DeletePathInput)(lambda: None)
53
+ app = api.as_fastapi()
54
+ client = make_client(app)
55
+ r = client.delete("/users/123")
56
+ assert r.status_code == 204
57
+ assert r.content in (b"", b" ")
58
+
59
+ def test_delete_200_with_output_model(self):
60
+ api = SemblanceAPI(seed=42)
61
+ api.delete("/users/{id}", input=DeletePathInput, output=User)(lambda: None)
62
+ app = api.as_fastapi()
63
+ client = make_client(app)
64
+ r = client.delete("/users/123")
65
+ assert r.status_code == 200
66
+ data = r.json()
67
+ assert "name" in data
68
+
69
+
70
+ # --- Rate limiting ---
71
+
72
+
73
+ class TestRateLimit:
74
+ def test_rate_limit_returns_429_when_exceeded(self):
75
+ api = SemblanceAPI()
76
+ api.get(
77
+ "/limited",
78
+ input=UserQuery,
79
+ output=list[User],
80
+ rate_limit=2,
81
+ )(lambda: None)
82
+ app = api.as_fastapi()
83
+ client = make_client(app)
84
+ r1 = client.get("/limited?name=a")
85
+ r2 = client.get("/limited?name=b")
86
+ r3 = client.get("/limited?name=c")
87
+ assert r1.status_code == 200
88
+ assert r2.status_code == 200
89
+ assert r3.status_code == 429
90
+
91
+ def test_rate_limit_allows_after_window(self):
92
+ api = SemblanceAPI()
93
+ api.get(
94
+ "/limited2",
95
+ input=UserQuery,
96
+ output=list[User],
97
+ rate_limit=1,
98
+ )(lambda: None)
99
+ app = api.as_fastapi()
100
+ client = make_client(app)
101
+ assert client.get("/limited2?name=a").status_code == 200
102
+ assert client.get("/limited2?name=b").status_code == 429
103
+ time.sleep(1.1)
104
+ assert client.get("/limited2?name=c").status_code == 200
105
+
106
+
107
+ # --- Response validation ---
108
+
109
+
110
+ class TestValidateResponses:
111
+ def test_validate_responses_does_not_raise_on_valid_response(self):
112
+ api = SemblanceAPI(validate_responses=True)
113
+ api.get("/users", input=UserQuery, output=list[User])(lambda: None)
114
+ app = api.as_fastapi()
115
+ client = make_client(app)
116
+ r = client.get("/users?name=alice")
117
+ assert r.status_code == 200
118
+ assert isinstance(r.json(), list)
119
+
120
+
121
+ # --- Property-based testing ---
122
+
123
+
124
+ @pytest.mark.skipif(
125
+ __import__("importlib.util").util.find_spec("hypothesis") is None,
126
+ reason="hypothesis not installed",
127
+ )
128
+ class TestPropertyBased:
129
+ def test_strategy_for_input_model_generates_valid_instances(self):
130
+ from hypothesis import given
131
+
132
+ from semblance.property_testing import strategy_for_input_model
133
+
134
+ strategy = strategy_for_input_model(UserQuery)
135
+
136
+ @given(strategy)
137
+ def run(inp):
138
+ assert isinstance(inp, UserQuery)
139
+ parsed = UserQuery.model_validate(inp.model_dump())
140
+ assert parsed.name == inp.name
141
+
142
+ run()
143
+
144
+ def test_test_endpoint_get_validates_responses(self):
145
+ from hypothesis import given
146
+ from pydantic import TypeAdapter
147
+
148
+ from semblance.property_testing import strategy_for_input_model
149
+
150
+ api = SemblanceAPI(seed=42)
151
+ api.get("/users", input=UserQuery, output=list[User], list_count=2)(
152
+ lambda: None
153
+ )
154
+ app = api.as_fastapi()
155
+ client = make_client(app)
156
+ strategy = strategy_for_input_model(UserQuery)
157
+
158
+ @given(strategy)
159
+ def run(inp):
160
+ r = client.get("/users", params=inp.model_dump())
161
+ assert r.status_code == 200
162
+ TypeAdapter(list[User]).validate_python(r.json())
163
+
164
+ run()
File without changes
File without changes
File without changes
File without changes
File without changes