tempest-fastapi-sdk 0.1.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.
Files changed (36) hide show
  1. tempest_fastapi_sdk/__init__.py +110 -0
  2. tempest_fastapi_sdk/api/__init__.py +11 -0
  3. tempest_fastapi_sdk/api/handlers.py +59 -0
  4. tempest_fastapi_sdk/db/__init__.py +14 -0
  5. tempest_fastapi_sdk/db/_alembic_templates/__init__.py +1 -0
  6. tempest_fastapi_sdk/db/_alembic_templates/env.py.template +73 -0
  7. tempest_fastapi_sdk/db/connection.py +257 -0
  8. tempest_fastapi_sdk/db/migrations.py +353 -0
  9. tempest_fastapi_sdk/db/model.py +249 -0
  10. tempest_fastapi_sdk/db/repository.py +716 -0
  11. tempest_fastapi_sdk/exceptions/__init__.py +29 -0
  12. tempest_fastapi_sdk/exceptions/base.py +66 -0
  13. tempest_fastapi_sdk/exceptions/conflict.py +22 -0
  14. tempest_fastapi_sdk/exceptions/forbidden.py +18 -0
  15. tempest_fastapi_sdk/exceptions/jwt.py +25 -0
  16. tempest_fastapi_sdk/exceptions/not_found.py +22 -0
  17. tempest_fastapi_sdk/exceptions/unauthorized.py +23 -0
  18. tempest_fastapi_sdk/exceptions/upload.py +27 -0
  19. tempest_fastapi_sdk/exceptions/validation.py +22 -0
  20. tempest_fastapi_sdk/schemas/__init__.py +15 -0
  21. tempest_fastapi_sdk/schemas/base.py +64 -0
  22. tempest_fastapi_sdk/schemas/pagination.py +132 -0
  23. tempest_fastapi_sdk/schemas/response.py +69 -0
  24. tempest_fastapi_sdk/settings/__init__.py +7 -0
  25. tempest_fastapi_sdk/settings/base.py +43 -0
  26. tempest_fastapi_sdk/utils/__init__.py +61 -0
  27. tempest_fastapi_sdk/utils/datetime.py +35 -0
  28. tempest_fastapi_sdk/utils/dict.py +36 -0
  29. tempest_fastapi_sdk/utils/email.py +152 -0
  30. tempest_fastapi_sdk/utils/jwt.py +141 -0
  31. tempest_fastapi_sdk/utils/password.py +76 -0
  32. tempest_fastapi_sdk/utils/regex.py +275 -0
  33. tempest_fastapi_sdk/utils/upload.py +197 -0
  34. tempest_fastapi_sdk-0.1.0.dist-info/METADATA +1499 -0
  35. tempest_fastapi_sdk-0.1.0.dist-info/RECORD +36 -0
  36. tempest_fastapi_sdk-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,110 @@
1
+ """tempest-fastapi-sdk — shared FastAPI/SQLAlchemy/Pydantic primitives."""
2
+
3
+ from tempest_fastapi_sdk.api import (
4
+ app_exception_handler,
5
+ register_exception_handlers,
6
+ )
7
+ from tempest_fastapi_sdk.db import (
8
+ NAMING_CONVENTION,
9
+ AlembicHelper,
10
+ AsyncDatabaseManager,
11
+ BaseModel,
12
+ BaseRepository,
13
+ )
14
+ from tempest_fastapi_sdk.exceptions import (
15
+ AppException,
16
+ ConflictException,
17
+ ExpiredTokenException,
18
+ FileTooLargeException,
19
+ ForbiddenException,
20
+ InvalidFileTypeException,
21
+ InvalidTokenException,
22
+ NotFoundException,
23
+ UnauthorizedException,
24
+ ValidationException,
25
+ )
26
+ from tempest_fastapi_sdk.schemas import (
27
+ BasePaginationFilterSchema,
28
+ BasePaginationSchema,
29
+ BaseResponseSchema,
30
+ BaseSchema,
31
+ )
32
+ from tempest_fastapi_sdk.settings import BaseAppSettings
33
+ from tempest_fastapi_sdk.utils import (
34
+ CNPJ,
35
+ CNPJ_PATTERN,
36
+ CPF,
37
+ CPF_CNPJ_PATTERN,
38
+ CPF_PATTERN,
39
+ PHONE_BR_PATTERN,
40
+ CPFOrCNPJ,
41
+ EmailUtils,
42
+ JWTUtils,
43
+ PasswordUtils,
44
+ PhoneBR,
45
+ UploadUtils,
46
+ is_valid_cnpj,
47
+ is_valid_cpf,
48
+ is_valid_cpf_cnpj,
49
+ is_valid_phone_br,
50
+ modify_dict,
51
+ normalize_cnpj,
52
+ normalize_cpf,
53
+ normalize_cpf_cnpj,
54
+ normalize_phone_br,
55
+ only_digits,
56
+ to_utc,
57
+ utcnow,
58
+ )
59
+
60
+ __version__: str = "0.1.0"
61
+
62
+ __all__: list[str] = [
63
+ "CNPJ",
64
+ "CNPJ_PATTERN",
65
+ "CPF",
66
+ "CPF_CNPJ_PATTERN",
67
+ "CPF_PATTERN",
68
+ "NAMING_CONVENTION",
69
+ "PHONE_BR_PATTERN",
70
+ "AlembicHelper",
71
+ "AppException",
72
+ "AsyncDatabaseManager",
73
+ "BaseAppSettings",
74
+ "BaseModel",
75
+ "BasePaginationFilterSchema",
76
+ "BasePaginationSchema",
77
+ "BaseRepository",
78
+ "BaseResponseSchema",
79
+ "BaseSchema",
80
+ "CPFOrCNPJ",
81
+ "ConflictException",
82
+ "EmailUtils",
83
+ "ExpiredTokenException",
84
+ "FileTooLargeException",
85
+ "ForbiddenException",
86
+ "InvalidFileTypeException",
87
+ "InvalidTokenException",
88
+ "JWTUtils",
89
+ "NotFoundException",
90
+ "PasswordUtils",
91
+ "PhoneBR",
92
+ "UnauthorizedException",
93
+ "UploadUtils",
94
+ "ValidationException",
95
+ "__version__",
96
+ "app_exception_handler",
97
+ "is_valid_cnpj",
98
+ "is_valid_cpf",
99
+ "is_valid_cpf_cnpj",
100
+ "is_valid_phone_br",
101
+ "modify_dict",
102
+ "normalize_cnpj",
103
+ "normalize_cpf",
104
+ "normalize_cpf_cnpj",
105
+ "normalize_phone_br",
106
+ "only_digits",
107
+ "register_exception_handlers",
108
+ "to_utc",
109
+ "utcnow",
110
+ ]
@@ -0,0 +1,11 @@
1
+ """FastAPI integration primitives exposed at module level."""
2
+
3
+ from tempest_fastapi_sdk.api.handlers import (
4
+ app_exception_handler,
5
+ register_exception_handlers,
6
+ )
7
+
8
+ __all__: list[str] = [
9
+ "app_exception_handler",
10
+ "register_exception_handlers",
11
+ ]
@@ -0,0 +1,59 @@
1
+ """FastAPI exception handlers for ``AppException`` and friends."""
2
+
3
+ from fastapi import FastAPI, Request
4
+ from fastapi.responses import JSONResponse
5
+
6
+ from tempest_fastapi_sdk.exceptions.base import AppException
7
+
8
+
9
+ async def app_exception_handler(
10
+ request: Request,
11
+ exc: AppException,
12
+ ) -> JSONResponse:
13
+ """Serialize an :class:`AppException` to the SDK JSON envelope.
14
+
15
+ Emits the shape::
16
+
17
+ {
18
+ "detail": "<message>",
19
+ "code": "<machine-readable code>",
20
+ "details": {...}
21
+ }
22
+
23
+ Args:
24
+ request (Request): The incoming HTTP request. Unused — kept
25
+ for signature compatibility with FastAPI handlers.
26
+ exc (AppException): The exception raised.
27
+
28
+ Returns:
29
+ JSONResponse: The serialized response.
30
+ """
31
+ del request
32
+ return JSONResponse(
33
+ status_code=exc.status_code,
34
+ content={
35
+ "detail": exc.detail,
36
+ "code": exc.code,
37
+ "details": exc.details,
38
+ },
39
+ headers=exc.headers,
40
+ )
41
+
42
+
43
+ def register_exception_handlers(app: FastAPI) -> None:
44
+ """Register the SDK's exception handlers on a FastAPI app.
45
+
46
+ Wires :class:`AppException` to :func:`app_exception_handler` so
47
+ every subclass returned by routers, services and repositories is
48
+ serialized consistently.
49
+
50
+ Args:
51
+ app (FastAPI): The FastAPI application to wire.
52
+ """
53
+ app.add_exception_handler(AppException, app_exception_handler) # type: ignore[arg-type]
54
+
55
+
56
+ __all__: list[str] = [
57
+ "app_exception_handler",
58
+ "register_exception_handlers",
59
+ ]
@@ -0,0 +1,14 @@
1
+ """Database primitives exposed at module level."""
2
+
3
+ from tempest_fastapi_sdk.db.connection import AsyncDatabaseManager
4
+ from tempest_fastapi_sdk.db.migrations import AlembicHelper
5
+ from tempest_fastapi_sdk.db.model import NAMING_CONVENTION, BaseModel
6
+ from tempest_fastapi_sdk.db.repository import BaseRepository
7
+
8
+ __all__: list[str] = [
9
+ "NAMING_CONVENTION",
10
+ "AlembicHelper",
11
+ "AsyncDatabaseManager",
12
+ "BaseModel",
13
+ "BaseRepository",
14
+ ]
@@ -0,0 +1 @@
1
+ """Bundled Alembic templates loaded via importlib.resources."""
@@ -0,0 +1,73 @@
1
+ """Alembic environment script.
2
+
3
+ Generated by tempest-fastapi-sdk. Edit the metadata import below
4
+ when your project moves the BaseModel re-export.
5
+ """
6
+
7
+ import asyncio
8
+ from logging.config import fileConfig
9
+
10
+ from alembic import context
11
+ from sqlalchemy import pool
12
+ from sqlalchemy.engine import Connection
13
+ from sqlalchemy.ext.asyncio import async_engine_from_config
14
+
15
+ # >>> tempest-fastapi-sdk: metadata import (edit if needed) <<<
16
+ __METADATA_IMPORT__
17
+ # >>> end tempest-fastapi-sdk block <<<
18
+
19
+ config = context.config
20
+ if config.config_file_name is not None:
21
+ fileConfig(config.config_file_name)
22
+
23
+
24
+ def run_migrations_offline() -> None:
25
+ """Run migrations in 'offline' mode, emitting SQL to stdout."""
26
+ url = config.get_main_option("sqlalchemy.url")
27
+ context.configure(
28
+ url=url,
29
+ target_metadata=target_metadata,
30
+ literal_binds=True,
31
+ dialect_opts={"paramstyle": "named"},
32
+ compare_type=True,
33
+ compare_server_default=True,
34
+ render_as_batch=True,
35
+ )
36
+ with context.begin_transaction():
37
+ context.run_migrations()
38
+
39
+
40
+ def do_run_migrations(connection: Connection) -> None:
41
+ """Configure and run migrations against an open connection."""
42
+ context.configure(
43
+ connection=connection,
44
+ target_metadata=target_metadata,
45
+ compare_type=True,
46
+ compare_server_default=True,
47
+ render_as_batch=True,
48
+ )
49
+ with context.begin_transaction():
50
+ context.run_migrations()
51
+
52
+
53
+ async def run_async_migrations() -> None:
54
+ """Build the async engine and dispatch migrations."""
55
+ connectable = async_engine_from_config(
56
+ config.get_section(config.config_ini_section, {}),
57
+ prefix="sqlalchemy.",
58
+ poolclass=pool.NullPool,
59
+ )
60
+ async with connectable.connect() as connection:
61
+ await connection.run_sync(do_run_migrations)
62
+ await connectable.dispose()
63
+
64
+
65
+ def run_migrations_online() -> None:
66
+ """Run migrations in 'online' mode against the configured database."""
67
+ asyncio.run(run_async_migrations())
68
+
69
+
70
+ if context.is_offline_mode():
71
+ run_migrations_offline()
72
+ else:
73
+ run_migrations_online()
@@ -0,0 +1,257 @@
1
+ """Async database manager with engine/session lifecycle helpers."""
2
+
3
+ from collections.abc import AsyncGenerator
4
+ from contextlib import asynccontextmanager
5
+ from typing import Any
6
+
7
+ from sqlalchemy import text
8
+ from sqlalchemy.engine import make_url
9
+ from sqlalchemy.ext.asyncio import (
10
+ AsyncEngine,
11
+ AsyncSession,
12
+ async_sessionmaker,
13
+ create_async_engine,
14
+ )
15
+ from sqlalchemy.pool import Pool
16
+
17
+ from tempest_fastapi_sdk.db.model import BaseModel
18
+
19
+
20
+ class AsyncDatabaseManager:
21
+ """Manage the async SQLAlchemy engine and session lifecycle.
22
+
23
+ Handles engine creation tailored to the database backend (SQLite
24
+ gets ``check_same_thread=False`` by default, everything else
25
+ gets a pooled config), session factory construction, and table
26
+ create/drop helpers. Designed to be instantiated once per
27
+ application and reused across requests.
28
+
29
+ Backend detection uses ``sqlalchemy.engine.make_url`` so URLs
30
+ like ``sqlite+aiosqlite://...`` are matched precisely without
31
+ relying on substring tricks.
32
+
33
+ Attributes:
34
+ db_url (str): The database connection URL.
35
+ is_sqlite (bool): Whether the URL targets a SQLite backend.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ db_url: str,
41
+ *,
42
+ echo: bool = False,
43
+ pool_size: int = 10,
44
+ max_overflow: int = 20,
45
+ pool_recycle: int = 3600,
46
+ connect_args: dict[str, Any] | None = None,
47
+ poolclass: type[Pool] | None = None,
48
+ **engine_kwargs: Any,
49
+ ) -> None:
50
+ """Initialize the manager (does not open connections yet).
51
+
52
+ Args:
53
+ db_url (str): The database connection URL.
54
+ echo (bool): Whether to emit SQL to stdout.
55
+ pool_size (int): Number of permanent connections in the
56
+ pool. Ignored for SQLite URLs.
57
+ max_overflow (int): Extra connections allowed above the
58
+ pool size. Ignored for SQLite URLs.
59
+ pool_recycle (int): Recycle connections older than this
60
+ many seconds. Ignored for SQLite URLs.
61
+ connect_args (dict[str, Any] | None): Driver-level
62
+ arguments forwarded to ``create_async_engine``
63
+ (e.g. ``{"ssl": "require"}`` for asyncpg). SQLite
64
+ always receives ``check_same_thread=False`` unless
65
+ explicitly overridden here.
66
+ poolclass (type[Pool] | None): Override SQLAlchemy's
67
+ default pool class. Useful for tests
68
+ (``poolclass=NullPool``) or specialized topologies.
69
+ **engine_kwargs: Any additional keyword arguments are
70
+ passed through to ``create_async_engine`` verbatim.
71
+ """
72
+ self.db_url: str = db_url
73
+ self.is_sqlite: bool = make_url(db_url).get_backend_name() == "sqlite"
74
+ self._echo: bool = echo
75
+ self._pool_size: int = pool_size
76
+ self._max_overflow: int = max_overflow
77
+ self._pool_recycle: int = pool_recycle
78
+ self._connect_args: dict[str, Any] = dict(connect_args or {})
79
+ self._poolclass: type[Pool] | None = poolclass
80
+ self._engine_kwargs: dict[str, Any] = engine_kwargs
81
+ self._engine: AsyncEngine | None = None
82
+ self._session_maker: async_sessionmaker[AsyncSession] | None = None
83
+
84
+ @property
85
+ def is_connected(self) -> bool:
86
+ """Whether the engine is currently initialized.
87
+
88
+ Returns:
89
+ bool: ``True`` if :meth:`connect` has been called and
90
+ :meth:`disconnect` has not.
91
+ """
92
+ return self._engine is not None
93
+
94
+ async def connect(self) -> None:
95
+ """Create the engine and session factory if they don't exist.
96
+
97
+ Idempotent — calling twice is a no-op.
98
+ """
99
+ if self._engine is not None:
100
+ return
101
+
102
+ kwargs: dict[str, Any] = {"echo": self._echo, **self._engine_kwargs}
103
+ connect_args = dict(self._connect_args)
104
+
105
+ if self.is_sqlite:
106
+ connect_args.setdefault("check_same_thread", False)
107
+ else:
108
+ kwargs.setdefault("pool_pre_ping", True)
109
+ kwargs.setdefault("pool_recycle", self._pool_recycle)
110
+ kwargs.setdefault("pool_size", self._pool_size)
111
+ kwargs.setdefault("max_overflow", self._max_overflow)
112
+
113
+ if connect_args:
114
+ kwargs["connect_args"] = connect_args
115
+ if self._poolclass is not None:
116
+ kwargs["poolclass"] = self._poolclass
117
+
118
+ self._engine = create_async_engine(self.db_url, **kwargs)
119
+ self._session_maker = async_sessionmaker(
120
+ self._engine,
121
+ expire_on_commit=False,
122
+ class_=AsyncSession,
123
+ )
124
+
125
+ async def disconnect(self) -> None:
126
+ """Dispose the engine and clear the session factory.
127
+
128
+ Safe to call multiple times.
129
+ """
130
+ if self._engine is not None:
131
+ await self._engine.dispose()
132
+ self._engine = None
133
+ self._session_maker = None
134
+
135
+ def _require_session_maker(self) -> async_sessionmaker[AsyncSession]:
136
+ """Return the session maker, raising if uninitialized.
137
+
138
+ Returns:
139
+ async_sessionmaker[AsyncSession]: The configured factory.
140
+
141
+ Raises:
142
+ RuntimeError: If :meth:`connect` has not run yet.
143
+ """
144
+ if self._session_maker is None:
145
+ raise RuntimeError(
146
+ "AsyncDatabaseManager is not connected. "
147
+ "Call await manager.connect() before using sessions."
148
+ )
149
+ return self._session_maker
150
+
151
+ async def get_session(self) -> AsyncSession:
152
+ """Return a new ``AsyncSession`` bound to the engine.
153
+
154
+ Lazy-connects on first use. The caller is responsible for
155
+ closing the session (use :meth:`get_session_context` for
156
+ managed lifecycle).
157
+
158
+ Returns:
159
+ AsyncSession: A new session.
160
+ """
161
+ if self._engine is None:
162
+ await self.connect()
163
+ return self._require_session_maker()()
164
+
165
+ @asynccontextmanager
166
+ async def get_session_context(self) -> AsyncGenerator[AsyncSession]:
167
+ """Yield a session that auto-commits on exit and rolls back on error.
168
+
169
+ Yields:
170
+ AsyncSession: A managed session.
171
+
172
+ Raises:
173
+ Exception: Re-raises whatever the caller raised inside
174
+ the ``async with`` block, after rolling back.
175
+ """
176
+ if self._engine is None:
177
+ await self.connect()
178
+ session = self._require_session_maker()()
179
+ try:
180
+ yield session
181
+ await session.commit()
182
+ except Exception:
183
+ await session.rollback()
184
+ raise
185
+ finally:
186
+ await session.close()
187
+
188
+ async def session_dependency(self) -> AsyncGenerator[AsyncSession]:
189
+ """FastAPI dependency yielding one session per request.
190
+
191
+ Use as ``Depends(db.session_dependency)``. Differs from
192
+ :meth:`get_session_context` in that it does **not** commit on
193
+ success — commits are the responsibility of the service /
194
+ repository layer. The session is closed when the request
195
+ scope ends; failures bubble up unchanged.
196
+
197
+ Yields:
198
+ AsyncSession: A request-scoped session.
199
+ """
200
+ if self._engine is None:
201
+ await self.connect()
202
+ session = self._require_session_maker()()
203
+ try:
204
+ yield session
205
+ finally:
206
+ await session.close()
207
+
208
+ async def health_check(self) -> bool:
209
+ """Return whether a trivial ``SELECT 1`` succeeds.
210
+
211
+ Suitable for ``/health`` endpoints. Swallows every exception
212
+ and returns ``False`` so callers can branch on the result
213
+ without dealing with driver-specific error types.
214
+
215
+ Returns:
216
+ bool: ``True`` when the database responded with ``1``,
217
+ ``False`` on any failure.
218
+ """
219
+ try:
220
+ if self._engine is None:
221
+ await self.connect()
222
+ async with self._require_session_maker()() as session:
223
+ result = await session.execute(text("SELECT 1"))
224
+ return result.scalar() == 1
225
+ except Exception:
226
+ return False
227
+
228
+ async def create_tables(self) -> None:
229
+ """Issue ``CREATE TABLE`` for every model registered on ``BaseModel``.
230
+
231
+ Intended for tests and local development. Production schemas
232
+ should be managed by Alembic (see
233
+ :class:`tempest_fastapi_sdk.db.migrations.AlembicHelper`).
234
+ """
235
+ if self._engine is None:
236
+ await self.connect()
237
+ if self._engine is None:
238
+ raise RuntimeError("Engine is not connected.")
239
+ async with self._engine.begin() as conn:
240
+ await conn.run_sync(BaseModel.metadata.create_all)
241
+
242
+ async def drop_tables(self) -> None:
243
+ """Issue ``DROP TABLE`` for every model registered on ``BaseModel``.
244
+
245
+ Intended for tests and local development.
246
+ """
247
+ if self._engine is None:
248
+ await self.connect()
249
+ if self._engine is None:
250
+ raise RuntimeError("Engine is not connected.")
251
+ async with self._engine.begin() as conn:
252
+ await conn.run_sync(BaseModel.metadata.drop_all)
253
+
254
+
255
+ __all__: list[str] = [
256
+ "AsyncDatabaseManager",
257
+ ]