fastapi-restly 0.5.0__py3-none-any.whl
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.
- fastapi_restly/__init__.py +106 -0
- fastapi_restly/_exception_handlers.py +194 -0
- fastapi_restly/_pytest_fixtures.py +256 -0
- fastapi_restly/db/__init__.py +31 -0
- fastapi_restly/db/_globals.py +76 -0
- fastapi_restly/db/_proxy.py +42 -0
- fastapi_restly/db/_session.py +275 -0
- fastapi_restly/exceptions.py +12 -0
- fastapi_restly/models/__init__.py +18 -0
- fastapi_restly/models/_base.py +84 -0
- fastapi_restly/objects.py +144 -0
- fastapi_restly/py.typed +0 -0
- fastapi_restly/pytest_fixtures.py +24 -0
- fastapi_restly/query/__init__.py +13 -0
- fastapi_restly/query/_impl.py +594 -0
- fastapi_restly/query/_shared.py +22 -0
- fastapi_restly/schemas/__init__.py +29 -0
- fastapi_restly/schemas/_base.py +518 -0
- fastapi_restly/schemas/_generator.py +382 -0
- fastapi_restly/testing/__init__.py +20 -0
- fastapi_restly/testing/_client.py +98 -0
- fastapi_restly/testing/_fixtures.py +20 -0
- fastapi_restly/views/__init__.py +40 -0
- fastapi_restly/views/_async.py +216 -0
- fastapi_restly/views/_base.py +1294 -0
- fastapi_restly/views/_openapi.py +206 -0
- fastapi_restly/views/_react_admin.py +393 -0
- fastapi_restly/views/_sync.py +213 -0
- fastapi_restly-0.5.0.dist-info/METADATA +407 -0
- fastapi_restly-0.5.0.dist-info/RECORD +34 -0
- fastapi_restly-0.5.0.dist-info/WHEEL +5 -0
- fastapi_restly-0.5.0.dist-info/entry_points.txt +2 -0
- fastapi_restly-0.5.0.dist-info/licenses/LICENSE +21 -0
- fastapi_restly-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Schema generation utilities for auto-generating Pydantic schemas from SQLAlchemy models.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
import inspect
|
|
7
|
+
import types
|
|
8
|
+
from datetime import date, datetime, time
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from typing import Any, Union, get_args
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
|
|
13
|
+
import pydantic
|
|
14
|
+
from pydantic import Field
|
|
15
|
+
from sqlalchemy import inspect as sa_inspect
|
|
16
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, RelationshipProperty
|
|
17
|
+
|
|
18
|
+
from ._base import BaseSchema, IDSchema, ReadOnly, TimestampsSchemaMixin
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_sqlalchemy_field_type(field: Any) -> Any:
|
|
22
|
+
"""
|
|
23
|
+
Extract the Python type from a SQLAlchemy Mapped field.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
field: A SQLAlchemy Mapped field
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The Python type annotation
|
|
30
|
+
"""
|
|
31
|
+
# Get the type annotation from the Mapped field
|
|
32
|
+
if hasattr(field, "type"):
|
|
33
|
+
return field.type
|
|
34
|
+
elif hasattr(field, "__origin__"):
|
|
35
|
+
return field.__origin__
|
|
36
|
+
else:
|
|
37
|
+
# Fallback to Any if we can't determine the type
|
|
38
|
+
return Any
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_relationship_field(field: Any) -> bool:
|
|
42
|
+
"""
|
|
43
|
+
Check if a field is a SQLAlchemy relationship.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
field: A SQLAlchemy Mapped field
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
True if the field is a relationship, False otherwise
|
|
50
|
+
"""
|
|
51
|
+
if isinstance(field, RelationshipProperty):
|
|
52
|
+
return True
|
|
53
|
+
return isinstance(getattr(field, "property", None), RelationshipProperty)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_relationship_target_model(field: Any) -> type[DeclarativeBase] | None:
|
|
57
|
+
"""
|
|
58
|
+
Get the target model class for a relationship field.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
field: A SQLAlchemy relationship field
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The target model class or None if not found
|
|
65
|
+
"""
|
|
66
|
+
if not is_relationship_field(field):
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
# Try to get the target from the relationship property
|
|
70
|
+
relationship = field
|
|
71
|
+
if not isinstance(relationship, RelationshipProperty):
|
|
72
|
+
relationship = getattr(field, "property", None)
|
|
73
|
+
|
|
74
|
+
if (
|
|
75
|
+
relationship is not None
|
|
76
|
+
and hasattr(relationship, "mapper")
|
|
77
|
+
and hasattr(relationship.mapper, "class_")
|
|
78
|
+
):
|
|
79
|
+
return relationship.mapper.class_
|
|
80
|
+
|
|
81
|
+
# Try to get from the type annotation
|
|
82
|
+
if hasattr(field, "type"):
|
|
83
|
+
target_type = field.type
|
|
84
|
+
if hasattr(target_type, "__origin__") and target_type.__origin__ is list:
|
|
85
|
+
# Handle list[Model] case
|
|
86
|
+
args = get_args(target_type)
|
|
87
|
+
if args:
|
|
88
|
+
return args[0]
|
|
89
|
+
elif inspect.isclass(target_type) and issubclass(target_type, DeclarativeBase):
|
|
90
|
+
return target_type
|
|
91
|
+
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_model_fields(model_cls: type[DeclarativeBase]) -> dict[str, Any]:
|
|
96
|
+
"""
|
|
97
|
+
Extract field information from a SQLAlchemy model.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
model_cls: A SQLAlchemy model class
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Dictionary mapping field names to their types and metadata
|
|
104
|
+
"""
|
|
105
|
+
fields: dict[str, Any] = {}
|
|
106
|
+
|
|
107
|
+
mapper = sa_inspect(model_cls)
|
|
108
|
+
|
|
109
|
+
# Get all annotations from the model class and its base classes
|
|
110
|
+
all_annotations = {}
|
|
111
|
+
for cls in model_cls.mro():
|
|
112
|
+
if hasattr(cls, "__annotations__"):
|
|
113
|
+
all_annotations.update(cls.__annotations__)
|
|
114
|
+
|
|
115
|
+
for name, field_type in all_annotations.items():
|
|
116
|
+
if name.startswith("_"):
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
# Check if it's a Mapped field
|
|
120
|
+
if not hasattr(field_type, "__origin__") or field_type.__origin__ is not Mapped:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
# Extract the actual type from Mapped[Type]
|
|
124
|
+
args = get_args(field_type)
|
|
125
|
+
if not args:
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
actual_type = args[0]
|
|
129
|
+
relationship = mapper.relationships.get(name)
|
|
130
|
+
|
|
131
|
+
rel_mapper = (
|
|
132
|
+
getattr(relationship, "mapper", None) if relationship is not None else None
|
|
133
|
+
)
|
|
134
|
+
field_info: dict[str, Any] = {
|
|
135
|
+
"type": actual_type,
|
|
136
|
+
"is_relationship": relationship is not None,
|
|
137
|
+
"target_model": (rel_mapper.class_ if rel_mapper is not None else None),
|
|
138
|
+
"is_optional": False,
|
|
139
|
+
"default": None,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# Check if the field is optional (Union with None or Optional)
|
|
143
|
+
if isinstance(actual_type, types.UnionType):
|
|
144
|
+
# Python 3.10+ `str | None` syntax
|
|
145
|
+
union_args = get_args(actual_type)
|
|
146
|
+
if type(None) in union_args:
|
|
147
|
+
field_info["is_optional"] = True
|
|
148
|
+
non_none_types = [arg for arg in union_args if arg is not type(None)]
|
|
149
|
+
if non_none_types:
|
|
150
|
+
field_info["type"] = non_none_types[0]
|
|
151
|
+
elif hasattr(actual_type, "__origin__"):
|
|
152
|
+
origin = actual_type.__origin__
|
|
153
|
+
if origin is Union:
|
|
154
|
+
args = get_args(actual_type)
|
|
155
|
+
if type(None) in args:
|
|
156
|
+
field_info["is_optional"] = True
|
|
157
|
+
# Remove None from the type
|
|
158
|
+
non_none_types = [arg for arg in args if arg is not type(None)]
|
|
159
|
+
if non_none_types:
|
|
160
|
+
field_info["type"] = non_none_types[0]
|
|
161
|
+
|
|
162
|
+
if relationship is not None:
|
|
163
|
+
# Relationship fields are response-oriented in generated schemas.
|
|
164
|
+
# Keep them optional so create/update inputs can rely on FK columns.
|
|
165
|
+
field_info["is_optional"] = True
|
|
166
|
+
elif name in mapper.columns:
|
|
167
|
+
column = mapper.columns[name]
|
|
168
|
+
if column.default is not None or column.server_default is not None:
|
|
169
|
+
field_info["default"] = column.default or column.server_default
|
|
170
|
+
field_info["is_optional"] = True
|
|
171
|
+
|
|
172
|
+
fields[name] = field_info
|
|
173
|
+
|
|
174
|
+
return fields
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def create_schema_from_model(
|
|
178
|
+
model_cls: type[DeclarativeBase],
|
|
179
|
+
*,
|
|
180
|
+
schema_name: str | None = None,
|
|
181
|
+
include_relationships: bool = True,
|
|
182
|
+
include_readonly_fields: bool = True,
|
|
183
|
+
) -> type[BaseSchema]:
|
|
184
|
+
"""
|
|
185
|
+
Auto-generate a Pydantic schema from a SQLAlchemy model.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
model_cls: The SQLAlchemy model class
|
|
189
|
+
schema_name: Optional name for the generated schema class
|
|
190
|
+
include_relationships: Whether to include relationship fields
|
|
191
|
+
include_readonly_fields: Whether to include read-only fields like id, created_at, etc.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
A Pydantic schema class
|
|
195
|
+
"""
|
|
196
|
+
if schema_name is None:
|
|
197
|
+
schema_name = f"{model_cls.__name__}Read"
|
|
198
|
+
|
|
199
|
+
# Get field information from the model
|
|
200
|
+
model_fields = get_model_fields(model_cls)
|
|
201
|
+
|
|
202
|
+
# Determine base classes - start with the most specific ones
|
|
203
|
+
bases: list[type] = []
|
|
204
|
+
|
|
205
|
+
# Check if model has timestamp fields (inherits from TimestampsMixin)
|
|
206
|
+
has_timestamps = "created_at" in model_fields and "updated_at" in model_fields
|
|
207
|
+
if has_timestamps:
|
|
208
|
+
bases.append(TimestampsSchemaMixin)
|
|
209
|
+
|
|
210
|
+
# Check if model has an id field (inherits from IDBase)
|
|
211
|
+
has_id = "id" in model_fields
|
|
212
|
+
if has_id:
|
|
213
|
+
bases.append(IDSchema)
|
|
214
|
+
|
|
215
|
+
# Always include BaseSchema as the base
|
|
216
|
+
bases.append(BaseSchema)
|
|
217
|
+
|
|
218
|
+
# Create field definitions for the schema
|
|
219
|
+
field_definitions: dict[str, Any] = {}
|
|
220
|
+
read_only_fields: list[str] = []
|
|
221
|
+
|
|
222
|
+
for field_name, field_info in model_fields.items():
|
|
223
|
+
# Skip relationships if not requested
|
|
224
|
+
if field_info["is_relationship"] and not include_relationships:
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
# Determine if field should be read-only
|
|
228
|
+
is_readonly = (
|
|
229
|
+
field_name in ["id", "created_at", "updated_at"] and include_readonly_fields
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
if is_readonly:
|
|
233
|
+
read_only_fields.append(field_name)
|
|
234
|
+
|
|
235
|
+
# Convert SQLAlchemy type to Pydantic type
|
|
236
|
+
pydantic_type = convert_sqlalchemy_type_to_pydantic(
|
|
237
|
+
field_info["type"], field_info["is_optional"]
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Handle relationships
|
|
241
|
+
if field_info["is_relationship"] and field_info["target_model"]:
|
|
242
|
+
target_model = field_info["target_model"]
|
|
243
|
+
|
|
244
|
+
# Skip self-referential relationship to avoid infinite recursion
|
|
245
|
+
if target_model is model_cls:
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
if (
|
|
249
|
+
hasattr(field_info["type"], "__origin__")
|
|
250
|
+
and field_info["type"].__origin__ is list
|
|
251
|
+
):
|
|
252
|
+
# Many relationship
|
|
253
|
+
target_schema = create_schema_from_model(
|
|
254
|
+
target_model,
|
|
255
|
+
include_relationships=False, # Avoid circular references
|
|
256
|
+
include_readonly_fields=False,
|
|
257
|
+
)
|
|
258
|
+
pydantic_type = list[target_schema]
|
|
259
|
+
else:
|
|
260
|
+
# One relationship
|
|
261
|
+
target_schema = create_schema_from_model(
|
|
262
|
+
target_model,
|
|
263
|
+
include_relationships=False, # Avoid circular references
|
|
264
|
+
include_readonly_fields=False,
|
|
265
|
+
)
|
|
266
|
+
pydantic_type = target_schema
|
|
267
|
+
|
|
268
|
+
if field_info["is_optional"]:
|
|
269
|
+
pydantic_type = pydantic_type | None
|
|
270
|
+
|
|
271
|
+
# Add field to definitions - use proper Pydantic field format
|
|
272
|
+
# Don't include SQLAlchemy defaults as they're not JSON-serializable
|
|
273
|
+
if field_info["is_optional"]:
|
|
274
|
+
field_definitions[field_name] = (pydantic_type, Field(default=None))
|
|
275
|
+
else:
|
|
276
|
+
field_definitions[field_name] = (pydantic_type, ...)
|
|
277
|
+
|
|
278
|
+
# Apply ReadOnly annotation to read-only fields
|
|
279
|
+
for field_name in read_only_fields:
|
|
280
|
+
if field_name in field_definitions:
|
|
281
|
+
original_type, field_info = field_definitions[field_name]
|
|
282
|
+
# Apply ReadOnly annotation to the type
|
|
283
|
+
field_definitions[field_name] = (ReadOnly[original_type], field_info)
|
|
284
|
+
|
|
285
|
+
# Create the schema class using pydantic.create_model
|
|
286
|
+
schema_cls = pydantic.create_model( # type: ignore[call-overload]
|
|
287
|
+
schema_name,
|
|
288
|
+
__doc__=f"Auto-generated schema for {model_cls.__name__}",
|
|
289
|
+
__base__=tuple(bases),
|
|
290
|
+
**field_definitions,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
return schema_cls
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def convert_sqlalchemy_type_to_pydantic(
|
|
297
|
+
sqlalchemy_type: Any, is_optional: bool = False
|
|
298
|
+
) -> Any:
|
|
299
|
+
"""
|
|
300
|
+
Convert a SQLAlchemy type to a Pydantic-compatible type.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
sqlalchemy_type: The SQLAlchemy type
|
|
304
|
+
is_optional: Whether the field is optional
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
A Pydantic-compatible type
|
|
308
|
+
"""
|
|
309
|
+
type_name = getattr(sqlalchemy_type, "__name__", str(sqlalchemy_type))
|
|
310
|
+
|
|
311
|
+
if sqlalchemy_type is Any:
|
|
312
|
+
pydantic_type = Any
|
|
313
|
+
elif sqlalchemy_type in (
|
|
314
|
+
str,
|
|
315
|
+
int,
|
|
316
|
+
float,
|
|
317
|
+
bool,
|
|
318
|
+
dict,
|
|
319
|
+
list,
|
|
320
|
+
datetime,
|
|
321
|
+
date,
|
|
322
|
+
time,
|
|
323
|
+
UUID,
|
|
324
|
+
Decimal,
|
|
325
|
+
):
|
|
326
|
+
pydantic_type = sqlalchemy_type
|
|
327
|
+
elif isinstance(sqlalchemy_type, type) and issubclass(sqlalchemy_type, enum.Enum):
|
|
328
|
+
pydantic_type = sqlalchemy_type
|
|
329
|
+
elif isinstance(sqlalchemy_type, type) and issubclass(
|
|
330
|
+
sqlalchemy_type, DeclarativeBase
|
|
331
|
+
):
|
|
332
|
+
# Relationship targets are replaced with nested schemas later.
|
|
333
|
+
pydantic_type = sqlalchemy_type
|
|
334
|
+
elif getattr(sqlalchemy_type, "__origin__", None) is not None:
|
|
335
|
+
# Preserve parameterized container types like dict[str, Any] or list[int].
|
|
336
|
+
pydantic_type = sqlalchemy_type
|
|
337
|
+
elif type_name in {"Text", "String"}:
|
|
338
|
+
pydantic_type = str
|
|
339
|
+
elif type_name in {"Integer"}:
|
|
340
|
+
pydantic_type = int
|
|
341
|
+
elif type_name in {"Float"}:
|
|
342
|
+
pydantic_type = float
|
|
343
|
+
elif type_name in {"Boolean"}:
|
|
344
|
+
pydantic_type = bool
|
|
345
|
+
elif type_name in {"DateTime"}:
|
|
346
|
+
pydantic_type = datetime
|
|
347
|
+
elif type_name in {"Date"}:
|
|
348
|
+
pydantic_type = date
|
|
349
|
+
elif type_name in {"Time"}:
|
|
350
|
+
pydantic_type = time
|
|
351
|
+
else:
|
|
352
|
+
raise TypeError(
|
|
353
|
+
f"Unsupported field type for auto-generated schema: {sqlalchemy_type!r}"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Handle optional types
|
|
357
|
+
if is_optional:
|
|
358
|
+
pydantic_type = pydantic_type | None
|
|
359
|
+
|
|
360
|
+
return pydantic_type
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def auto_generate_schema_for_view(
|
|
364
|
+
view_cls: type, model_cls: type[DeclarativeBase], schema_name: str | None = None
|
|
365
|
+
) -> type[BaseSchema]:
|
|
366
|
+
"""
|
|
367
|
+
Auto-generate a schema for a view class if none is specified.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
view_cls: The view class
|
|
371
|
+
model_cls: The SQLAlchemy model class
|
|
372
|
+
schema_name: Optional name for the generated schema
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
A Pydantic schema class
|
|
376
|
+
"""
|
|
377
|
+
if schema_name is None:
|
|
378
|
+
schema_name = f"{model_cls.__name__}Read"
|
|
379
|
+
|
|
380
|
+
return create_schema_from_model(
|
|
381
|
+
model_cls, schema_name=schema_name, include_relationships=False
|
|
382
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
try:
|
|
2
|
+
from fastapi_restly.db import (
|
|
3
|
+
activate_savepoint_only_mode,
|
|
4
|
+
deactivate_savepoint_only_mode,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
from ._client import RestlyTestClient
|
|
8
|
+
except ModuleNotFoundError as exc:
|
|
9
|
+
if exc.name == "httpx":
|
|
10
|
+
raise ModuleNotFoundError(
|
|
11
|
+
"fastapi_restly.testing requires optional testing dependencies. "
|
|
12
|
+
'Install them with: pip install "fastapi-restly[testing]"'
|
|
13
|
+
) from exc
|
|
14
|
+
raise
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"RestlyTestClient",
|
|
18
|
+
"activate_savepoint_only_mode",
|
|
19
|
+
"deactivate_savepoint_only_mode",
|
|
20
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from fastapi.testclient import TestClient
|
|
5
|
+
|
|
6
|
+
# httpx accepts either a string or a `httpx.URL` for request URLs. The base
|
|
7
|
+
# class' `URLTypes` alias is private, so we replicate the public union here.
|
|
8
|
+
URLTypes = httpx.URL | str
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RestlyTestClient(TestClient):
|
|
12
|
+
"""Custom TestClient that automatically checks response codes and provides clear error messages."""
|
|
13
|
+
|
|
14
|
+
def assert_status(self, response: httpx.Response, expected_code: int | None = None):
|
|
15
|
+
"""Check if the response status code matches the expected code."""
|
|
16
|
+
__tracebackhide__ = True
|
|
17
|
+
|
|
18
|
+
status_code = response.status_code
|
|
19
|
+
|
|
20
|
+
if expected_code is not None and status_code == expected_code:
|
|
21
|
+
return # All good
|
|
22
|
+
|
|
23
|
+
if expected_code is None and status_code < 400:
|
|
24
|
+
return # Also fine
|
|
25
|
+
|
|
26
|
+
# Raise AssertionError with detailed error message
|
|
27
|
+
try:
|
|
28
|
+
response_content = response.json()
|
|
29
|
+
except (ValueError, TypeError, json.JSONDecodeError):
|
|
30
|
+
response_content = response.content.decode(errors="ignore")
|
|
31
|
+
|
|
32
|
+
content_str_raw = str(response_content)
|
|
33
|
+
if len(content_str_raw) > 1000:
|
|
34
|
+
content_str_raw = content_str_raw[:1000] + "...(truncated)"
|
|
35
|
+
content_str = f"Response JSON: {content_str_raw}"
|
|
36
|
+
|
|
37
|
+
# Safe method/URL extraction
|
|
38
|
+
try:
|
|
39
|
+
method = response.request.method.upper()
|
|
40
|
+
url = str(response.request.url)
|
|
41
|
+
request_info = f"{method} {url}"
|
|
42
|
+
except Exception:
|
|
43
|
+
request_info = "(request info unavailable)"
|
|
44
|
+
|
|
45
|
+
raise AssertionError(
|
|
46
|
+
f"Expected {request_info} to return {expected_code}, got {status_code}\n"
|
|
47
|
+
f"{content_str}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def get(
|
|
51
|
+
self, url: URLTypes, *, assert_status_code: int | None = 200, **kwargs
|
|
52
|
+
) -> httpx.Response:
|
|
53
|
+
"""Make a GET request. Asserts the response status code matches `assert_status_code` (default: 200).
|
|
54
|
+
Pass `assert_status_code=None` to skip the assertion."""
|
|
55
|
+
__tracebackhide__ = True
|
|
56
|
+
response = super().get(url, **kwargs)
|
|
57
|
+
self.assert_status(response, assert_status_code)
|
|
58
|
+
return response
|
|
59
|
+
|
|
60
|
+
def post(
|
|
61
|
+
self, url: URLTypes, *, assert_status_code: int | None = 201, **kwargs
|
|
62
|
+
) -> httpx.Response:
|
|
63
|
+
"""Make a POST request. Asserts the response status code matches `assert_status_code` (default: 201).
|
|
64
|
+
Pass `assert_status_code=None` to skip the assertion."""
|
|
65
|
+
__tracebackhide__ = True
|
|
66
|
+
response = super().post(url, **kwargs)
|
|
67
|
+
self.assert_status(response, assert_status_code)
|
|
68
|
+
return response
|
|
69
|
+
|
|
70
|
+
def put(
|
|
71
|
+
self, url: URLTypes, *, assert_status_code: int | None = 200, **kwargs
|
|
72
|
+
) -> httpx.Response:
|
|
73
|
+
"""Make a PUT request. Asserts the response status code matches `assert_status_code` (default: 200).
|
|
74
|
+
Pass `assert_status_code=None` to skip the assertion."""
|
|
75
|
+
__tracebackhide__ = True
|
|
76
|
+
response = super().put(url, **kwargs)
|
|
77
|
+
self.assert_status(response, assert_status_code)
|
|
78
|
+
return response
|
|
79
|
+
|
|
80
|
+
def patch(
|
|
81
|
+
self, url: URLTypes, *, assert_status_code: int | None = 200, **kwargs
|
|
82
|
+
) -> httpx.Response:
|
|
83
|
+
"""Make a PATCH request. Asserts the response status code matches `assert_status_code` (default: 200).
|
|
84
|
+
Pass `assert_status_code=None` to skip the assertion."""
|
|
85
|
+
__tracebackhide__ = True
|
|
86
|
+
response = super().patch(url, **kwargs)
|
|
87
|
+
self.assert_status(response, assert_status_code)
|
|
88
|
+
return response
|
|
89
|
+
|
|
90
|
+
def delete(
|
|
91
|
+
self, url: URLTypes, *, assert_status_code: int | None = 204, **kwargs
|
|
92
|
+
) -> httpx.Response:
|
|
93
|
+
"""Make a DELETE request. Asserts the response status code matches `assert_status_code` (default: 204).
|
|
94
|
+
Pass `assert_status_code=None` to skip the assertion."""
|
|
95
|
+
__tracebackhide__ = True
|
|
96
|
+
response = super().delete(url, **kwargs)
|
|
97
|
+
self.assert_status(response, assert_status_code)
|
|
98
|
+
return response
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Compatibility import path for Restly's private pytest fixture implementation."""
|
|
2
|
+
|
|
3
|
+
from fastapi_restly._pytest_fixtures import (
|
|
4
|
+
_activate_savepoint_only_mode_sessions,
|
|
5
|
+
_run_alembic_upgrade,
|
|
6
|
+
_shared_connection,
|
|
7
|
+
restly_app,
|
|
8
|
+
restly_async_session,
|
|
9
|
+
restly_client,
|
|
10
|
+
restly_project_root,
|
|
11
|
+
restly_session,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"restly_app",
|
|
16
|
+
"restly_async_session",
|
|
17
|
+
"restly_client",
|
|
18
|
+
"restly_project_root",
|
|
19
|
+
"restly_session",
|
|
20
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from ._async import AsyncRestView
|
|
2
|
+
from ._base import (
|
|
3
|
+
BaseRestView,
|
|
4
|
+
ListingResult,
|
|
5
|
+
View,
|
|
6
|
+
ViewRoute,
|
|
7
|
+
delete,
|
|
8
|
+
get,
|
|
9
|
+
include_view,
|
|
10
|
+
patch,
|
|
11
|
+
post,
|
|
12
|
+
put,
|
|
13
|
+
route,
|
|
14
|
+
)
|
|
15
|
+
from ._react_admin import AsyncReactAdminView, ReactAdminView
|
|
16
|
+
from ._sync import RestView
|
|
17
|
+
|
|
18
|
+
# Public API for ``fastapi_restly.views``.
|
|
19
|
+
#
|
|
20
|
+
# Submodule exports are supported public API for users working in this
|
|
21
|
+
# subsystem. Some names, such as ``BaseRestView``, are intentionally kept out
|
|
22
|
+
# of the top-level ``fastapi_restly`` namespace because they are advanced
|
|
23
|
+
# building blocks rather than the primary import path.
|
|
24
|
+
__all__ = [
|
|
25
|
+
"RestView",
|
|
26
|
+
"AsyncRestView",
|
|
27
|
+
"AsyncReactAdminView",
|
|
28
|
+
"ReactAdminView",
|
|
29
|
+
"BaseRestView",
|
|
30
|
+
"ListingResult",
|
|
31
|
+
"View",
|
|
32
|
+
"ViewRoute",
|
|
33
|
+
"delete",
|
|
34
|
+
"get",
|
|
35
|
+
"include_view",
|
|
36
|
+
"patch",
|
|
37
|
+
"post",
|
|
38
|
+
"put",
|
|
39
|
+
"route",
|
|
40
|
+
]
|