fastapi-restly 3.0.0rc1__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,131 @@
1
+ # Database layer
2
+ from .db import (
3
+ AsyncSessionDep,
4
+ FRGlobals,
5
+ RestlyContext,
6
+ SessionDep,
7
+ activate_savepoint_only_mode,
8
+ async_open_session,
9
+ configure,
10
+ deactivate_savepoint_only_mode,
11
+ get_async_engine,
12
+ get_engine,
13
+ get_fr_globals,
14
+ open_session,
15
+ use_fr_globals,
16
+ )
17
+
18
+ # Model base classes
19
+ from .models import (
20
+ DataclassBase,
21
+ IDBase,
22
+ IDMixin,
23
+ IDStampsBase,
24
+ TimestampsMixin,
25
+ async_get_one_or_create,
26
+ get_one_or_create,
27
+ )
28
+
29
+ # List endpoint query parameters
30
+ from .query import apply_list_params, create_list_params_schema
31
+
32
+ # Schema utilities
33
+ from .schemas import (
34
+ BaseSchema,
35
+ IDRef,
36
+ IDSchema,
37
+ IDStampsSchema,
38
+ ReadOnly,
39
+ TimestampsSchemaMixin,
40
+ WriteOnly,
41
+ create_schema_from_model,
42
+ resolve_ids_to_sqlalchemy_objects,
43
+ )
44
+
45
+ # Views
46
+ from .views import (
47
+ AsyncReactAdminView,
48
+ AsyncRestView,
49
+ ReactAdminView,
50
+ RestView,
51
+ View,
52
+ async_make_new_object,
53
+ async_save_object,
54
+ async_update_object,
55
+ delete,
56
+ get,
57
+ include_view,
58
+ make_new_object,
59
+ patch,
60
+ post,
61
+ put,
62
+ route,
63
+ save_object,
64
+ update_object,
65
+ )
66
+
67
+ # Public API surface for fastapi-restly.
68
+ #
69
+ # Anything not listed here is considered internal and may change without
70
+ # warning. Submodule ``__init__.py`` files have their own (narrower)
71
+ # ``__all__`` lists for submodule-level imports such as
72
+ # ``from fastapi_restly.schemas import ReadOnly``.
73
+ __all__ = [
74
+ # Database — session context managers
75
+ "async_open_session",
76
+ "open_session",
77
+ # Database — FastAPI dependencies
78
+ "AsyncSessionDep",
79
+ "SessionDep",
80
+ # Database — engine access
81
+ "get_async_engine",
82
+ "get_engine",
83
+ # Database — setup & utilities
84
+ "configure",
85
+ "activate_savepoint_only_mode",
86
+ "deactivate_savepoint_only_mode",
87
+ "RestlyContext",
88
+ "FRGlobals",
89
+ "get_fr_globals",
90
+ "use_fr_globals",
91
+ # Models
92
+ "DataclassBase",
93
+ "IDBase",
94
+ "IDMixin",
95
+ "IDStampsBase",
96
+ "TimestampsMixin",
97
+ "async_get_one_or_create",
98
+ "get_one_or_create",
99
+ # List endpoint query parameters
100
+ "apply_list_params",
101
+ "create_list_params_schema",
102
+ # Schemas
103
+ "BaseSchema",
104
+ "IDRef",
105
+ "IDSchema",
106
+ "IDStampsSchema",
107
+ "ReadOnly",
108
+ "WriteOnly",
109
+ "TimestampsSchemaMixin",
110
+ "create_schema_from_model",
111
+ "resolve_ids_to_sqlalchemy_objects",
112
+ # Views
113
+ "RestView",
114
+ "AsyncRestView",
115
+ "AsyncReactAdminView",
116
+ "ReactAdminView",
117
+ "View",
118
+ "async_make_new_object",
119
+ "async_save_object",
120
+ "async_update_object",
121
+ "delete",
122
+ "get",
123
+ "include_view",
124
+ "make_new_object",
125
+ "patch",
126
+ "post",
127
+ "put",
128
+ "route",
129
+ "save_object",
130
+ "update_object",
131
+ ]
@@ -0,0 +1,194 @@
1
+ """Default FastAPI exception handlers installed by fastapi-restly.
2
+
3
+ Currently this module provides a translation layer from SQLAlchemy
4
+ :class:`~sqlalchemy.exc.IntegrityError` (unique-constraint, foreign-key,
5
+ not-null, and check-constraint violations) into a clean HTTP 409 Conflict
6
+ response. Without this handler, an ``IntegrityError`` bubbles up to FastAPI
7
+ and turns into a 500 Internal Server Error, which is misleading for clients
8
+ (the server is fine; the request conflicts with the current state of the
9
+ resource).
10
+
11
+ The handler is installed automatically by :func:`fastapi_restly.configure`
12
+ and as a fallback by :func:`fastapi_restly.include_view`. Users can opt out
13
+ by calling ``fr.configure(install_default_exception_handlers=False)`` or by
14
+ registering their own handler for ``IntegrityError`` *before* the framework
15
+ gets a chance to install one.
16
+
17
+ The detail-message extraction is best-effort: it understands the most common
18
+ PostgreSQL SQLSTATE codes (via psycopg's ``orig.pgcode``) and the SQLite
19
+ error-message conventions. For unrecognised dialects/messages we fall back
20
+ to a generic conflict message that includes a truncated version of the
21
+ underlying error text.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any
27
+
28
+ from fastapi import FastAPI, Request
29
+ from fastapi.responses import JSONResponse
30
+ from sqlalchemy.exc import IntegrityError
31
+
32
+ # Maximum length of original-error text we are willing to echo back. Keeps
33
+ # response bodies sane and avoids accidentally leaking long SQL strings.
34
+ _MAX_ORIG_TEXT_LENGTH = 500
35
+
36
+ # Marker stored on ``app.state`` so we know we've already installed our
37
+ # handlers on this FastAPI instance. Public so it is easy to inspect from
38
+ # tests or user code.
39
+ _HANDLERS_INSTALLED_FLAG = "_fr_default_exception_handlers_installed"
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Detail extraction
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ # PostgreSQL SQLSTATE codes — see
48
+ # https://www.postgresql.org/docs/current/errcodes-appendix.html (class 23
49
+ # "Integrity Constraint Violation").
50
+ _PG_SQLSTATE_DETAILS: dict[str, str] = {
51
+ "23505": "Unique constraint violated",
52
+ "23503": "Foreign key constraint violated",
53
+ "23502": "Not-null constraint violated",
54
+ "23514": "Check constraint violated",
55
+ "23000": "Integrity constraint violated",
56
+ "23001": "Restrict violation",
57
+ "23P01": "Exclusion constraint violated",
58
+ }
59
+
60
+
61
+ def _extract_postgres_detail(orig: Any) -> str | None:
62
+ """Return a user-facing detail message for a Postgres-driver error.
63
+
64
+ Looks at ``orig.pgcode`` (set by psycopg / psycopg2 / asyncpg-via-psycopg)
65
+ and, when available, ``orig.diag.constraint_name`` /
66
+ ``orig.diag.column_name`` to enrich the message.
67
+ """
68
+ pgcode = getattr(orig, "pgcode", None)
69
+ if not pgcode:
70
+ return None
71
+
72
+ base = _PG_SQLSTATE_DETAILS.get(pgcode)
73
+ if base is None:
74
+ return None
75
+
76
+ # ``diag`` is a psycopg-specific attribute holding fielded error info.
77
+ diag = getattr(orig, "diag", None)
78
+ constraint_name = getattr(diag, "constraint_name", None) if diag else None
79
+ column_name = getattr(diag, "column_name", None) if diag else None
80
+
81
+ if pgcode == "23505" and constraint_name:
82
+ return f"{base}: {constraint_name}"
83
+ if pgcode == "23503" and constraint_name:
84
+ return f"{base}: {constraint_name}"
85
+ if pgcode == "23502" and column_name:
86
+ return f"{base} on column {column_name!r}"
87
+ if pgcode == "23514" and constraint_name:
88
+ return f"{base}: {constraint_name}"
89
+ return base
90
+
91
+
92
+ # Mapping from SQLite error-message prefixes to a clean detail message.
93
+ # SQLite's IntegrityError.args[0] (and ``str(orig)``) follow predictable
94
+ # patterns, e.g. ``"UNIQUE constraint failed: user.username"``.
95
+ _SQLITE_PREFIX_DETAILS: tuple[tuple[str, str], ...] = (
96
+ ("UNIQUE constraint failed:", "Unique constraint violated"),
97
+ ("FOREIGN KEY constraint failed", "Foreign key constraint violated"),
98
+ ("NOT NULL constraint failed:", "Not-null constraint violated"),
99
+ ("CHECK constraint failed:", "Check constraint violated"),
100
+ ("PRIMARY KEY must be unique", "Unique constraint violated (primary key)"),
101
+ )
102
+
103
+
104
+ def _extract_sqlite_detail(orig: Any) -> str | None:
105
+ """Return a user-facing detail message for a SQLite-driver error."""
106
+ text = str(orig).strip()
107
+ if not text:
108
+ return None
109
+
110
+ for prefix, base in _SQLITE_PREFIX_DETAILS:
111
+ if not text.startswith(prefix):
112
+ continue
113
+ # Try to surface the column / constraint info that SQLite tacks on
114
+ # after the colon. ``UNIQUE constraint failed: user.username`` →
115
+ # ``"Unique constraint violated on user.username"``.
116
+ remainder = text[len(prefix) :].strip().lstrip(":").strip()
117
+ if remainder:
118
+ return f"{base} on {remainder}"
119
+ return base
120
+ return None
121
+
122
+
123
+ def _build_integrity_detail(exc: IntegrityError) -> str:
124
+ """Build a clean HTTP 409 detail message from a SQLAlchemy IntegrityError.
125
+
126
+ Best-effort across dialects:
127
+
128
+ * PostgreSQL — switches on ``exc.orig.pgcode`` (SQLSTATE class 23).
129
+ * SQLite — pattern-matches ``str(exc.orig)`` against known prefixes.
130
+ * Anything else — returns a generic fallback that includes a truncated
131
+ copy of the original error text so the body is still useful for
132
+ debugging without being huge.
133
+ """
134
+ orig = getattr(exc, "orig", None)
135
+ if orig is not None:
136
+ pg_detail = _extract_postgres_detail(orig)
137
+ if pg_detail is not None:
138
+ return pg_detail
139
+
140
+ sqlite_detail = _extract_sqlite_detail(orig)
141
+ if sqlite_detail is not None:
142
+ return sqlite_detail
143
+
144
+ # Generic fallback. Prefer the original driver error text (it's usually
145
+ # the most informative); truncate so we don't dump a giant SQL statement.
146
+ raw = str(orig) if orig is not None else str(exc)
147
+ raw = raw.strip()
148
+ if len(raw) > _MAX_ORIG_TEXT_LENGTH:
149
+ raw = raw[:_MAX_ORIG_TEXT_LENGTH] + "...(truncated)"
150
+
151
+ base = "Conflict with current state of the resource"
152
+ if raw:
153
+ return f"{base}: {raw}"
154
+ return base
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # The handler & registration helper
159
+ # ---------------------------------------------------------------------------
160
+
161
+
162
+ def integrity_error_handler(request: Request, exc: Exception) -> JSONResponse:
163
+ """Translate a SQLAlchemy IntegrityError into HTTP 409 Conflict.
164
+
165
+ Signature uses ``Exception`` rather than ``IntegrityError`` to satisfy
166
+ Starlette's exception-handler typing; we narrow at runtime.
167
+ """
168
+ assert isinstance(exc, IntegrityError) # noqa: S101 - registered for IntegrityError only
169
+ detail = _build_integrity_detail(exc)
170
+ return JSONResponse(status_code=409, content={"detail": detail})
171
+
172
+
173
+ def register_default_exception_handlers(app: FastAPI) -> None:
174
+ """Idempotently install fastapi-restly default exception handlers on ``app``.
175
+
176
+ * Skips if a handler for :class:`IntegrityError` is already registered on
177
+ ``app`` — we always defer to the user.
178
+ * Skips if we have already installed handlers on this ``app`` instance
179
+ (so calling from both :func:`fastapi_restly.configure` and
180
+ :func:`fastapi_restly.include_view` is safe).
181
+ """
182
+ if getattr(app.state, _HANDLERS_INSTALLED_FLAG, False):
183
+ return
184
+
185
+ # Respect a user-registered handler if one is already in place.
186
+ if IntegrityError in app.exception_handlers:
187
+ setattr(app.state, _HANDLERS_INSTALLED_FLAG, True)
188
+ return
189
+
190
+ app.add_exception_handler(IntegrityError, integrity_error_handler)
191
+ setattr(app.state, _HANDLERS_INSTALLED_FLAG, True)
192
+
193
+
194
+ __all__ = ["integrity_error_handler", "register_default_exception_handlers"]
@@ -0,0 +1,48 @@
1
+ from ._globals import (
2
+ FRGlobals,
3
+ RestlyContext,
4
+ fr_globals,
5
+ get_fr_globals,
6
+ use_fr_globals,
7
+ )
8
+ from ._proxy import async_open_session, open_session
9
+ from ._session import (
10
+ AsyncSessionDep,
11
+ SessionDep,
12
+ activate_savepoint_only_mode,
13
+ async_generate_session,
14
+ configure,
15
+ deactivate_savepoint_only_mode,
16
+ generate_session,
17
+ get_async_engine,
18
+ get_engine,
19
+ )
20
+
21
+ # Public API for ``fastapi_restly.db``.
22
+ #
23
+ # ``async_generate_session`` and ``generate_session`` remain importable for
24
+ # advanced users (and existing tests) who plug a custom session generator
25
+ # into ``fr_globals``, but they are not part of the supported public API
26
+ # and may move into a private module in a future release.
27
+ __all__ = [
28
+ # Session context managers
29
+ "async_open_session",
30
+ "open_session",
31
+ # FastAPI dependencies
32
+ "AsyncSessionDep",
33
+ "SessionDep",
34
+ # Engine access
35
+ "get_async_engine",
36
+ "get_engine",
37
+ # Setup
38
+ "configure",
39
+ # Savepoint mode
40
+ "activate_savepoint_only_mode",
41
+ "deactivate_savepoint_only_mode",
42
+ # Globals
43
+ "RestlyContext",
44
+ "FRGlobals",
45
+ "fr_globals",
46
+ "get_fr_globals",
47
+ "use_fr_globals",
48
+ ]
@@ -0,0 +1,92 @@
1
+ from collections.abc import AsyncIterator, Callable, Iterator
2
+ from contextlib import contextmanager
3
+ from contextvars import ContextVar, Token
4
+
5
+ from sqlalchemy.ext.asyncio import AsyncSession as SA_AsyncSession
6
+ from sqlalchemy.ext.asyncio import async_sessionmaker
7
+ from sqlalchemy.orm import Session as SA_Session
8
+ from sqlalchemy.orm import sessionmaker
9
+
10
+
11
+ class RestlyContext:
12
+ """Container for Restly runtime state.
13
+
14
+ Most applications use the default process-wide context by calling
15
+ ``fr.configure(...)`` directly. Create a ``RestlyContext`` when you need
16
+ isolated Restly state in the same process, for example for tests or
17
+ multiple FastAPI apps.
18
+ """
19
+
20
+ __slots__ = (
21
+ "async_database_url",
22
+ "async_make_session",
23
+ "database_url",
24
+ "make_session",
25
+ "session_generator",
26
+ "sync_session_generator",
27
+ )
28
+
29
+ async_database_url: str | None
30
+ async_make_session: async_sessionmaker[SA_AsyncSession] | None
31
+ database_url: str | None
32
+ make_session: sessionmaker[SA_Session] | None
33
+ session_generator: Callable[[], AsyncIterator[SA_AsyncSession]] | None
34
+ sync_session_generator: Callable[[], Iterator[SA_Session]] | None
35
+
36
+ def __init__(self) -> None:
37
+ self.async_database_url = None
38
+ self.async_make_session = None
39
+ self.database_url = None
40
+ self.make_session = None
41
+ self.session_generator = None
42
+ self.sync_session_generator = None
43
+
44
+ def __enter__(self) -> "RestlyContext":
45
+ token = _restly_context_ctx.set(self)
46
+ _restly_context_token_stack.set(_restly_context_token_stack.get() + (token,))
47
+ return self
48
+
49
+ def __exit__(self, *exc_info: object) -> None:
50
+ token_stack = _restly_context_token_stack.get()
51
+ if not token_stack:
52
+ raise RuntimeError("RestlyContext was exited without being entered.")
53
+ token = token_stack[-1]
54
+ _restly_context_token_stack.set(token_stack[:-1])
55
+ _restly_context_ctx.reset(token)
56
+
57
+
58
+ FRGlobals = RestlyContext
59
+
60
+
61
+ _default_context = RestlyContext()
62
+ _restly_context_ctx: ContextVar[RestlyContext | None] = ContextVar(
63
+ "fastapi_restly_context", default=None
64
+ )
65
+ _restly_context_token_stack: ContextVar[tuple[Token[RestlyContext | None], ...]] = (
66
+ ContextVar("fastapi_restly_context_token_stack", default=())
67
+ )
68
+
69
+
70
+ def _get_restly_context() -> RestlyContext:
71
+ return _restly_context_ctx.get() or _default_context
72
+
73
+
74
+ def get_fr_globals() -> FRGlobals:
75
+ return _get_restly_context()
76
+
77
+
78
+ @contextmanager
79
+ def use_fr_globals(globals_obj: FRGlobals) -> Iterator[None]:
80
+ with globals_obj:
81
+ yield
82
+
83
+
84
+ class _FRGlobalsProxy:
85
+ def __getattr__(self, name: str):
86
+ return getattr(_get_restly_context(), name)
87
+
88
+ def __setattr__(self, name: str, value):
89
+ setattr(_get_restly_context(), name, value)
90
+
91
+
92
+ fr_globals = _FRGlobalsProxy()
@@ -0,0 +1,37 @@
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 ._globals import fr_globals
8
+
9
+
10
+ @asynccontextmanager
11
+ async def async_open_session() -> AsyncIterator[SA_AsyncSession]:
12
+ """Open an async database session for use outside of request context.
13
+
14
+ Example::
15
+
16
+ async with fr.async_open_session() as session:
17
+ result = await session.execute(select(User))
18
+ """
19
+ if fr_globals.async_make_session is None:
20
+ raise RuntimeError("Call fr.configure() before using async_open_session().")
21
+ async with fr_globals.async_make_session() as sess:
22
+ yield sess
23
+
24
+
25
+ @contextmanager
26
+ def open_session() -> Iterator[SA_Session]:
27
+ """Open a sync database session for use outside of request context.
28
+
29
+ Example::
30
+
31
+ with fr.open_session() as session:
32
+ result = session.execute(select(User))
33
+ """
34
+ if fr_globals.make_session is None:
35
+ raise RuntimeError("Call fr.configure() before using open_session().")
36
+ with fr_globals.make_session() as sess:
37
+ yield sess