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.
@@ -0,0 +1,42 @@
1
+ from contextlib import asynccontextmanager, contextmanager
2
+ from typing import AsyncIterator, Iterator
3
+
4
+ from sqlalchemy.ext.asyncio import AsyncSession as SA_AsyncSession
5
+ from sqlalchemy.orm import Session as SA_Session
6
+
7
+ from ..exceptions import RestlyConfigurationError
8
+ from ._globals import _fr_globals
9
+
10
+
11
+ @asynccontextmanager
12
+ async def open_async_session() -> AsyncIterator[SA_AsyncSession]:
13
+ """Open an async database session for use outside of request context.
14
+
15
+ Example::
16
+
17
+ async with fr.open_async_session() as session:
18
+ result = await session.execute(select(User))
19
+ """
20
+ if _fr_globals.async_make_session is None:
21
+ raise RestlyConfigurationError(
22
+ "Call fr.configure() before using open_async_session()."
23
+ )
24
+ async with _fr_globals.async_make_session() as sess:
25
+ yield sess
26
+
27
+
28
+ @contextmanager
29
+ def open_session() -> Iterator[SA_Session]:
30
+ """Open a sync database session for use outside of request context.
31
+
32
+ Example::
33
+
34
+ with fr.open_session() as session:
35
+ result = session.execute(select(User))
36
+ """
37
+ if _fr_globals.make_session is None:
38
+ raise RestlyConfigurationError(
39
+ "Call fr.configure() before using open_session()."
40
+ )
41
+ with _fr_globals.make_session() as sess:
42
+ yield sess
@@ -0,0 +1,275 @@
1
+ from collections.abc import AsyncIterator, Callable, Iterator
2
+ from inspect import signature
3
+ from typing import Annotated, Any, cast
4
+
5
+ from fastapi import Depends, FastAPI
6
+ from sqlalchemy import Engine, create_engine
7
+ from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
8
+ from sqlalchemy.ext.asyncio import AsyncSession as SA_AsyncSession
9
+ from sqlalchemy.orm import Session as SA_Session
10
+ from sqlalchemy.orm import sessionmaker
11
+
12
+ from .._exception_handlers import register_default_exception_handlers
13
+ from ..exceptions import RestlyConfigurationError
14
+ from ._globals import _fr_globals
15
+
16
+ try:
17
+ import orjson
18
+ except ImportError:
19
+ json_deserializer = None
20
+ json_serializer = None
21
+ else:
22
+
23
+ def orjson_serializer(obj):
24
+ return orjson.dumps(
25
+ obj, option=orjson.OPT_NAIVE_UTC | orjson.OPT_NON_STR_KEYS
26
+ ).decode()
27
+
28
+ json_deserializer = orjson.loads
29
+ json_serializer = orjson_serializer
30
+
31
+
32
+ def _setup_async_database_connection(
33
+ async_database_url: str | None = None,
34
+ *,
35
+ async_engine: AsyncEngine | None = None,
36
+ async_make_session: async_sessionmaker[Any] | None = None,
37
+ ) -> async_sessionmaker[Any]:
38
+ if not async_make_session:
39
+ if not async_engine:
40
+ async_engine = create_async_engine(
41
+ async_database_url, # type: ignore[arg-type]
42
+ json_serializer=json_serializer,
43
+ json_deserializer=json_deserializer,
44
+ )
45
+ async_make_session = async_sessionmaker(
46
+ bind=async_engine, autoflush=False, expire_on_commit=False
47
+ )
48
+
49
+ _fr_globals.async_database_url = async_database_url
50
+ _fr_globals.async_make_session = async_make_session
51
+ return async_make_session
52
+
53
+
54
+ def _setup_database_connection(
55
+ database_url: str | None = None,
56
+ *,
57
+ engine: Engine | None = None,
58
+ make_session: sessionmaker[Any] | None = None,
59
+ ) -> sessionmaker[Any]:
60
+ if make_session is None:
61
+ if engine is None:
62
+ engine = create_engine(
63
+ database_url, # type: ignore[arg-type]
64
+ json_serializer=json_serializer,
65
+ json_deserializer=json_deserializer,
66
+ )
67
+ make_session = sessionmaker(bind=engine, expire_on_commit=False)
68
+
69
+ _fr_globals.database_url = database_url
70
+ _fr_globals.make_session = make_session
71
+ return make_session
72
+
73
+
74
+ def configure(
75
+ app: FastAPI | None = None,
76
+ *,
77
+ async_database_url: str | None = None,
78
+ async_engine: AsyncEngine | None = None,
79
+ async_make_session: async_sessionmaker[Any] | None = None,
80
+ database_url: str | None = None,
81
+ engine: Engine | None = None,
82
+ make_session: sessionmaker[Any] | None = None,
83
+ session_generator: Callable[[], AsyncIterator[SA_AsyncSession]] | None = None,
84
+ sync_session_generator: Callable[[], Iterator[SA_Session]] | None = None,
85
+ commit_session_on_response: bool | None = None,
86
+ install_default_exception_handlers: bool = True,
87
+ ) -> None:
88
+ """Configure FastAPI-Restly. Call once at startup.
89
+
90
+ Pass async parameters (``async_database_url``, ``async_engine``, or
91
+ ``async_make_session``) to enable async support, sync parameters
92
+ (``database_url``, ``engine``, or ``make_session``) for sync support,
93
+ or both if your application uses both.
94
+
95
+ Use ``session_generator`` / ``sync_session_generator`` to plug in a
96
+ custom session factory instead of the built-in one.
97
+
98
+ By default, Restly commits built-in request sessions when an endpoint
99
+ successfully produces a response. Set ``commit_session_on_response=False``
100
+ to own commit/rollback calls yourself. Leave it unset to keep the current
101
+ default. Custom session generators always own their transaction lifecycle.
102
+
103
+ Pass your :class:`FastAPI` ``app`` to install fastapi-restly's default
104
+ exception handlers (currently: a translator that turns SQLAlchemy
105
+ :class:`~sqlalchemy.exc.IntegrityError` into HTTP 409 Conflict). Set
106
+ ``install_default_exception_handlers=False`` to opt out. If you do not
107
+ pass ``app`` here, the handlers are registered the first time a view is
108
+ mounted via :func:`fastapi_restly.include_view` instead.
109
+ """
110
+ if not any(
111
+ (
112
+ async_database_url is not None,
113
+ async_engine is not None,
114
+ async_make_session is not None,
115
+ database_url is not None,
116
+ engine is not None,
117
+ make_session is not None,
118
+ session_generator is not None,
119
+ sync_session_generator is not None,
120
+ commit_session_on_response is not None,
121
+ app is not None and install_default_exception_handlers,
122
+ )
123
+ ):
124
+ raise TypeError("fr.configure() requires at least one setup argument.")
125
+
126
+ if commit_session_on_response is not None:
127
+ _fr_globals.commit_session_on_response = commit_session_on_response
128
+ if (
129
+ async_database_url is not None
130
+ or async_engine is not None
131
+ or async_make_session is not None
132
+ ):
133
+ _setup_async_database_connection(
134
+ async_database_url=async_database_url,
135
+ async_engine=async_engine,
136
+ async_make_session=async_make_session,
137
+ )
138
+ if database_url is not None or engine is not None or make_session is not None:
139
+ _setup_database_connection(
140
+ database_url=database_url, engine=engine, make_session=make_session
141
+ )
142
+ if session_generator is not None:
143
+ _fr_globals.session_generator = session_generator
144
+ if sync_session_generator is not None:
145
+ _fr_globals.sync_session_generator = sync_session_generator
146
+ if app is not None and install_default_exception_handlers:
147
+ register_default_exception_handlers(app)
148
+
149
+
150
+ def activate_savepoint_only_mode(
151
+ make_session: async_sessionmaker[Any] | sessionmaker[Any],
152
+ ) -> None:
153
+ """
154
+ Intended for use in tests. Puts the session factory into savepoint-only mode so
155
+ that no test data is ever committed to the database. Each test can roll back
156
+ instantly by closing the session, leaving the database clean for the next test.
157
+
158
+ This is done with "create_savepoint" mode and a wrapper on engine.connect() that
159
+ begins the outer transaction before the Session can use it.
160
+ https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#session-external-transaction
161
+ """
162
+ engine = _get_sync_engine(make_session)
163
+
164
+ # Check if already activated (look for the marker attribute we set)
165
+ if hasattr(engine.connect, "_original_connect"):
166
+ return # Already activated, skip
167
+
168
+ original_connect = engine.connect
169
+
170
+ def _begin_on_connect():
171
+ connection = original_connect()
172
+ connection.begin()
173
+ return connection
174
+
175
+ # Using setattr to silence pyright
176
+ setattr(_begin_on_connect, "_original_connect", original_connect)
177
+
178
+ engine.connect = _begin_on_connect
179
+ make_session.configure(join_transaction_mode="create_savepoint")
180
+
181
+
182
+ def deactivate_savepoint_only_mode(
183
+ make_session: async_sessionmaker[Any] | sessionmaker[Any],
184
+ ) -> None:
185
+ """
186
+ Reverts the effect of `activate_savepoint_only_mode`.
187
+ Restores the original engine.connect and disables savepoint-only mode.
188
+ """
189
+ engine = _get_sync_engine(make_session)
190
+ _begin_on_connect = cast(Any, engine.connect)
191
+ if hasattr(_begin_on_connect, "_original_connect"):
192
+ # Restore the original connect that was saved by activate_savepoint_only_mode
193
+ engine.connect = _begin_on_connect._original_connect
194
+ # If engine was never activated, there is nothing to restore; this is safe to call
195
+
196
+ make_session.configure(join_transaction_mode=None)
197
+
198
+
199
+ def get_async_engine() -> AsyncEngine:
200
+ """Return the async engine registered via configure()."""
201
+ if _fr_globals.async_make_session is None:
202
+ raise RestlyConfigurationError(
203
+ "Call fr.configure() before using get_async_engine()."
204
+ )
205
+ return _fr_globals.async_make_session.kw["bind"]
206
+
207
+
208
+ def get_engine() -> Engine:
209
+ """Return the sync engine registered via configure()."""
210
+ if _fr_globals.make_session is None:
211
+ raise RestlyConfigurationError(
212
+ "Call fr.configure() before using get_engine()."
213
+ )
214
+ return _fr_globals.make_session.kw["bind"]
215
+
216
+
217
+ def _get_sync_engine(make_session: async_sessionmaker[Any] | sessionmaker[Any]) -> Engine:
218
+ engine = make_session.kw["bind"]
219
+ if isinstance(engine, AsyncEngine):
220
+ return engine.sync_engine
221
+ return engine
222
+
223
+
224
+ async def _async_generate_session() -> AsyncIterator[SA_AsyncSession]:
225
+ """FastAPI dependency for async database session."""
226
+ if _fr_globals.session_generator is not None:
227
+ async for session in _fr_globals.session_generator():
228
+ yield session
229
+ return
230
+ if _fr_globals.async_make_session is None:
231
+ raise RestlyConfigurationError(
232
+ "Call fr.configure() before using AsyncSessionDep."
233
+ )
234
+
235
+ # FastAPI does not support contextmanagers as dependency directly,
236
+ # but it does support generators.
237
+ async with _fr_globals.async_make_session() as session:
238
+ yield session
239
+ if _fr_globals.commit_session_on_response and session.is_active:
240
+ try:
241
+ await session.commit()
242
+ except Exception:
243
+ await session.rollback()
244
+ raise
245
+
246
+
247
+ def _session_dependency(dependency: Callable[..., Any]) -> Any:
248
+ depends = cast(Callable[..., Any], Depends)
249
+ if "scope" in signature(Depends).parameters:
250
+ return depends(dependency, scope="function")
251
+ return depends(dependency)
252
+
253
+
254
+ AsyncSessionDep = Annotated[SA_AsyncSession, _session_dependency(_async_generate_session)]
255
+
256
+
257
+ def _generate_session() -> Iterator[SA_Session]:
258
+ """FastAPI dependency for sync database session."""
259
+ if _fr_globals.sync_session_generator is not None:
260
+ yield from _fr_globals.sync_session_generator()
261
+ return
262
+ if _fr_globals.make_session is None:
263
+ raise RestlyConfigurationError("Call fr.configure() before using SessionDep.")
264
+
265
+ with _fr_globals.make_session() as session:
266
+ yield session
267
+ if _fr_globals.commit_session_on_response and session.is_active:
268
+ try:
269
+ session.commit()
270
+ except Exception:
271
+ session.rollback()
272
+ raise
273
+
274
+
275
+ SessionDep = Annotated[SA_Session, _session_dependency(_generate_session)]
@@ -0,0 +1,12 @@
1
+ """Public exception hierarchy for FastAPI-Restly."""
2
+
3
+
4
+ class RestlyError(Exception):
5
+ """Base class for FastAPI-Restly framework errors."""
6
+
7
+
8
+ class RestlyConfigurationError(RestlyError):
9
+ """Raised when Restly is used before required configuration is available."""
10
+
11
+
12
+ __all__ = ["RestlyConfigurationError", "RestlyError"]
@@ -0,0 +1,18 @@
1
+ from ._base import (
2
+ CASCADE_ALL_ASYNC,
3
+ CASCADE_ALL_DELETE_ORPHAN_ASYNC,
4
+ DataclassBase,
5
+ IDBase,
6
+ IDMixin,
7
+ TimestampsMixin,
8
+ )
9
+
10
+ # Public API for ``fastapi_restly.models``.
11
+ __all__ = [
12
+ "CASCADE_ALL_ASYNC",
13
+ "CASCADE_ALL_DELETE_ORPHAN_ASYNC",
14
+ "DataclassBase",
15
+ "IDBase",
16
+ "IDMixin",
17
+ "TimestampsMixin",
18
+ ]
@@ -0,0 +1,84 @@
1
+ import enum
2
+ import re
3
+ from datetime import datetime, timezone
4
+ from typing import Any
5
+
6
+ from sqlalchemy import Enum, func
7
+ from sqlalchemy.orm import (
8
+ DeclarativeBase,
9
+ Mapped,
10
+ MappedAsDataclass,
11
+ declared_attr,
12
+ mapped_column,
13
+ )
14
+
15
+ # Provide an alternative settings for relationship cascade "all" and
16
+ # "all, delete-orphan". The "refresh-expire" cascade will cause
17
+ # issues in an async context. See also:
18
+ # https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#preventing-implicit-io-when-using-asyncsession
19
+ # `CASCADE_ALL_ASYNC` should be used instead.
20
+ CASCADE_ALL_ASYNC = "save-update, merge, delete, expunge"
21
+ CASCADE_ALL_DELETE_ORPHAN_ASYNC = CASCADE_ALL_ASYNC + ", delete-orphan"
22
+
23
+
24
+ def utc_now() -> datetime:
25
+ """Replacement for the deprecated datetime.utcnow()"""
26
+ return datetime.now(timezone.utc)
27
+
28
+
29
+ class TimestampsMixin(MappedAsDataclass, kw_only=True):
30
+ """
31
+ Dataclass mixin adding UTC-aware created_at and updated_at timestamps.
32
+ """
33
+
34
+ created_at: Mapped[datetime] = mapped_column(
35
+ default_factory=utc_now, server_default=func.now()
36
+ )
37
+ updated_at: Mapped[datetime] = mapped_column(
38
+ default_factory=utc_now, onupdate=utc_now, server_default=func.now()
39
+ )
40
+
41
+
42
+ class IDMixin(MappedAsDataclass, kw_only=True):
43
+ """Dataclass mixin adding an auto-incrementing integer `id` primary key."""
44
+
45
+ id: Mapped[int] = mapped_column(init=False, primary_key=True)
46
+
47
+
48
+ class TableNameMixin:
49
+ """Mixin that auto-generates snake_case table names from class names."""
50
+
51
+ @declared_attr
52
+ @classmethod
53
+ def __tablename__(cls) -> Any:
54
+ return underscore(cls.__name__)
55
+
56
+
57
+ def underscore(name: str) -> str:
58
+ """Convert CamelCase class name to snake_case table name.
59
+
60
+ Handles acronyms correctly: HTTPServer -> http_server, XMLParser -> xml_parser.
61
+ """
62
+ # Insert underscore before an uppercase letter that follows a lowercase letter
63
+ s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
64
+ # Insert underscore before an uppercase letter that is followed by a lowercase letter
65
+ # (handles the end of an acronym: "HTTPServer" -> "HTTP_Server")
66
+ s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
67
+ return s2.lower()
68
+
69
+
70
+ class DataclassBase(TableNameMixin, MappedAsDataclass, DeclarativeBase, kw_only=True):
71
+ """SQLAlchemy declarative base with dataclass semantics."""
72
+
73
+ type_annotation_map = {
74
+ # native_enum=False so enums are persisted as strings in the
75
+ # database, not as Postgres TYPE objects. This prevents
76
+ # requiring database migrations for every enum change.
77
+ enum.Enum: Enum(enum.Enum, native_enum=False, length=64)
78
+ }
79
+
80
+
81
+ class IDBase(IDMixin, DataclassBase):
82
+ """Convenience base: DataclassBase + integer `id` primary key."""
83
+
84
+ __abstract__ = True
@@ -0,0 +1,144 @@
1
+ from typing import TypeVar as _TypeVar
2
+
3
+ import pydantic as _pydantic
4
+ from sqlalchemy.ext.asyncio import AsyncSession as _AsyncSession
5
+ from sqlalchemy.orm import DeclarativeBase as _DeclarativeBase
6
+ from sqlalchemy.orm import Session as _Session
7
+
8
+ from .schemas._base import (
9
+ _async_resolve_ids_to_sqlalchemy_objects,
10
+ _resolve_ids_to_sqlalchemy_objects,
11
+ )
12
+
13
+ _T = _TypeVar("_T", bound=_DeclarativeBase)
14
+
15
+
16
+ def build_from_schema(
17
+ session: _Session,
18
+ model_cls: type[_T],
19
+ schema_obj: _pydantic.BaseModel,
20
+ schema_cls: type[_pydantic.BaseModel] | None = None,
21
+ ) -> _T:
22
+ """Build ``model_cls`` from ``schema_obj`` and add it to ``session``.
23
+
24
+ This is the schema-to-ORM mapping primitive. It resolves Restly reference
25
+ fields, skips read-only inputs, applies schema defaults, and stages the
26
+ object in the session. It does not flush and does not run view-level
27
+ ``perform_create`` business logic.
28
+ """
29
+ from .views._base import (
30
+ apply_create_assignments,
31
+ build_create_plan,
32
+ validate_resolved_reference_consistency,
33
+ )
34
+
35
+ _resolve_ids_to_sqlalchemy_objects(session, schema_obj)
36
+ validate_resolved_reference_consistency(model_cls, schema_obj, schema_cls)
37
+ create_plan = build_create_plan(model_cls, schema_obj, schema_cls)
38
+ obj = model_cls(**create_plan.kwargs)
39
+ apply_create_assignments(obj, create_plan.post_assignments)
40
+ session.add(obj)
41
+ return obj
42
+
43
+
44
+ def apply_schema(
45
+ session: _Session,
46
+ obj: _T,
47
+ schema_obj: _pydantic.BaseModel,
48
+ schema_cls: type[_pydantic.BaseModel] | None = None,
49
+ ) -> _T:
50
+ """Apply writable fields from ``schema_obj`` to ``obj``.
51
+
52
+ This is the schema-to-ORM update primitive. It resolves Restly reference
53
+ fields and applies only writable inputs. It does not flush and does not run
54
+ view-level ``perform_update`` business logic.
55
+ """
56
+ from .views._base import (
57
+ apply_update_to_object,
58
+ validate_resolved_reference_consistency,
59
+ )
60
+
61
+ _resolve_ids_to_sqlalchemy_objects(session, schema_obj)
62
+ validate_resolved_reference_consistency(type(obj), schema_obj, schema_cls)
63
+ apply_update_to_object(obj, schema_obj, schema_cls)
64
+ return obj
65
+
66
+
67
+ def save_object(session: _Session, obj: _T) -> _T:
68
+ """Flush the session and refresh ``obj`` from the database."""
69
+ session.flush()
70
+ session.refresh(obj)
71
+ return obj
72
+
73
+
74
+ def delete_object(session: _Session, obj: _DeclarativeBase) -> None:
75
+ """Delete ``obj`` and flush the session."""
76
+ session.delete(obj)
77
+ session.flush()
78
+
79
+
80
+ async def async_build_from_schema(
81
+ session: _AsyncSession,
82
+ model_cls: type[_T],
83
+ schema_obj: _pydantic.BaseModel,
84
+ schema_cls: type[_pydantic.BaseModel] | None = None,
85
+ ) -> _T:
86
+ """Async equivalent of :func:`build_from_schema`."""
87
+ from .views._base import (
88
+ apply_create_assignments,
89
+ build_create_plan,
90
+ validate_resolved_reference_consistency,
91
+ )
92
+
93
+ await _async_resolve_ids_to_sqlalchemy_objects(session, schema_obj)
94
+ validate_resolved_reference_consistency(model_cls, schema_obj, schema_cls)
95
+ create_plan = build_create_plan(model_cls, schema_obj, schema_cls)
96
+ obj = model_cls(**create_plan.kwargs)
97
+ apply_create_assignments(obj, create_plan.post_assignments)
98
+ session.add(obj)
99
+ return obj
100
+
101
+
102
+ async def async_apply_schema(
103
+ session: _AsyncSession,
104
+ obj: _T,
105
+ schema_obj: _pydantic.BaseModel,
106
+ schema_cls: type[_pydantic.BaseModel] | None = None,
107
+ ) -> _T:
108
+ """Async equivalent of :func:`apply_schema`."""
109
+ from .views._base import (
110
+ apply_update_to_object,
111
+ validate_resolved_reference_consistency,
112
+ )
113
+
114
+ await _async_resolve_ids_to_sqlalchemy_objects(session, schema_obj)
115
+ validate_resolved_reference_consistency(type(obj), schema_obj, schema_cls)
116
+ apply_update_to_object(obj, schema_obj, schema_cls)
117
+ return obj
118
+
119
+
120
+ async def async_save_object(session: _AsyncSession, obj: _T) -> _T:
121
+ """Async equivalent of :func:`save_object`."""
122
+ await session.flush()
123
+ await session.refresh(obj)
124
+ return obj
125
+
126
+
127
+ async def async_delete_object(
128
+ session: _AsyncSession, obj: _DeclarativeBase
129
+ ) -> None:
130
+ """Async equivalent of :func:`delete_object`."""
131
+ await session.delete(obj)
132
+ await session.flush()
133
+
134
+
135
+ __all__ = [
136
+ "apply_schema",
137
+ "async_apply_schema",
138
+ "async_build_from_schema",
139
+ "async_delete_object",
140
+ "async_save_object",
141
+ "build_from_schema",
142
+ "delete_object",
143
+ "save_object",
144
+ ]
File without changes
@@ -0,0 +1,24 @@
1
+ try:
2
+ from fastapi_restly._pytest_fixtures import (
3
+ _shared_connection,
4
+ restly_app,
5
+ restly_async_session,
6
+ restly_client,
7
+ restly_project_root,
8
+ restly_session,
9
+ )
10
+ except ModuleNotFoundError as exc:
11
+ if exc.name in {"httpx", "pytest"}:
12
+ raise ModuleNotFoundError(
13
+ "fastapi_restly.pytest_fixtures requires optional testing dependencies. "
14
+ 'Install them with: pip install "fastapi-restly[testing]"'
15
+ ) from exc
16
+ raise
17
+
18
+ __all__ = [
19
+ "restly_app",
20
+ "restly_async_session",
21
+ "restly_client",
22
+ "restly_project_root",
23
+ "restly_session",
24
+ ]
@@ -0,0 +1,13 @@
1
+ from ._impl import (
2
+ DEFAULT_PAGE_SIZE,
3
+ MAX_PAGE_SIZE,
4
+ apply_list_params,
5
+ create_list_params_schema,
6
+ )
7
+
8
+ __all__ = [
9
+ "DEFAULT_PAGE_SIZE",
10
+ "MAX_PAGE_SIZE",
11
+ "apply_list_params",
12
+ "create_list_params_schema",
13
+ ]