patera-database 0.1.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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.3
2
+ Name: patera-database
3
+ Version: 0.1.0
4
+ Requires-Dist: patera
5
+ Requires-Dist: sqlalchemy
6
+ Requires-Dist: aiosqlite
7
+ Requires-Python: >=3.12
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "patera-database"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.12"
5
+ dependencies = [
6
+ "patera",
7
+ "sqlalchemy",
8
+ "aiosqlite",
9
+ ]
10
+
11
+ [tool.uv.sources]
12
+ patera = { workspace = true }
13
+
14
+ [tool.uv.build-backend]
15
+ module-name = "patera.database"
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.10.9,<0.11.0"]
19
+ build-backend = "uv_build"
File without changes
@@ -0,0 +1,7 @@
1
+ """
2
+ NoSQL Database Module
3
+ """
4
+
5
+ from .nosql_database import NoSqlDatabase, NoSqlDatabaseConfig
6
+
7
+ __all__ = ["NoSqlDatabase", "NoSqlDatabaseConfig"]
@@ -0,0 +1,7 @@
1
+ """
2
+ NoSQL database backends
3
+ """
4
+
5
+ from .async_nosql_backend_protocol import AsyncNoSqlBackendBase
6
+
7
+ __all__ = ["AsyncNoSqlBackendBase"]
@@ -0,0 +1,140 @@
1
+ """
2
+ Protocls for async NoSQL backends.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Optional, Callable, Any, Iterable, Mapping, TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from patera import Patera
10
+
11
+
12
+ class AsyncNoSqlBackendBase(ABC):
13
+ """
14
+ Minimal async adapter interface a backend must implement.
15
+ """
16
+
17
+ @classmethod
18
+ @abstractmethod
19
+ def configure_from_app(
20
+ cls, app: "Patera", configs: dict[str, Any]
21
+ ) -> "AsyncNoSqlBackendBase":
22
+ """
23
+ Classmethod to configure backend from app config.
24
+ Called during NoSqlDatabase.init_app().
25
+ """
26
+ ...
27
+
28
+ @abstractmethod
29
+ async def connect(self) -> None: ...
30
+
31
+ @abstractmethod
32
+ async def disconnect(self) -> None: ...
33
+
34
+ @abstractmethod
35
+ def database_handle(self) -> Any:
36
+ """
37
+ Returns an object representing the 'database' to use inside handlers.
38
+ For backends without a database concept, return a client/root handle.
39
+ """
40
+ ...
41
+
42
+ @abstractmethod
43
+ def supports_transactions(self) -> bool: ...
44
+
45
+ @abstractmethod
46
+ async def start_session(self) -> Any:
47
+ """
48
+ Return a session/context object usable in transactions (or None if unsupported).
49
+ """
50
+ ...
51
+
52
+ @abstractmethod
53
+ async def with_transaction(
54
+ self, fn: Callable[..., Any], *args, session: Any = None, **kwargs
55
+ ) -> Any:
56
+ """
57
+ Execute fn inside a transaction if supported; otherwise call fn directly.
58
+ """
59
+ ...
60
+
61
+ @abstractmethod
62
+ def get_collection(self, name: str) -> Any: ...
63
+
64
+ @abstractmethod
65
+ async def find_one(
66
+ self,
67
+ collection: str,
68
+ filter: Mapping[str, Any],
69
+ *,
70
+ session: Any = None,
71
+ **kwargs,
72
+ ) -> Any: ...
73
+
74
+ @abstractmethod
75
+ async def find_many(
76
+ self,
77
+ collection: str,
78
+ filter: Mapping[str, Any] | None = None,
79
+ *,
80
+ session: Any = None,
81
+ limit: Optional[int] = None,
82
+ skip: Optional[int] = None,
83
+ sort: Optional[Iterable[tuple[str, int]]] = None,
84
+ **kwargs,
85
+ ) -> list[Any]: ...
86
+
87
+ @abstractmethod
88
+ async def insert_one(
89
+ self, collection: str, doc: Mapping[str, Any], *, session: Any = None, **kwargs
90
+ ) -> Any: ...
91
+
92
+ @abstractmethod
93
+ async def insert_many(
94
+ self,
95
+ collection: str,
96
+ docs: Iterable[Mapping[str, Any]],
97
+ *,
98
+ session: Any = None,
99
+ **kwargs,
100
+ ) -> Any: ...
101
+
102
+ @abstractmethod
103
+ async def update_one(
104
+ self,
105
+ collection: str,
106
+ filter: Mapping[str, Any],
107
+ update: Mapping[str, Any],
108
+ *,
109
+ upsert: bool = False,
110
+ session: Any = None,
111
+ **kwargs,
112
+ ) -> Any: ...
113
+
114
+ @abstractmethod
115
+ async def delete_one(
116
+ self,
117
+ collection: str,
118
+ filter: Mapping[str, Any],
119
+ *,
120
+ session: Any = None,
121
+ **kwargs,
122
+ ) -> Any: ...
123
+
124
+ @abstractmethod
125
+ async def aggregate(
126
+ self,
127
+ collection: str,
128
+ pipeline: Iterable[Mapping[str, Any]],
129
+ *,
130
+ session: Any = None,
131
+ **kwargs,
132
+ ) -> list[Any]: ...
133
+
134
+ @abstractmethod
135
+ async def execute_raw(self, *args, **kwargs) -> Any:
136
+ """
137
+ Backend escape hatch for commands that don't fit the generic surface.
138
+ For MongoDB, this could be db.command(...), collection.bulk_write(...), etc.
139
+ """
140
+ ...
@@ -0,0 +1,197 @@
1
+ """
2
+ MongoDB backend
3
+ """
4
+
5
+ from typing import Optional, Callable, Any, Iterable, Mapping, TYPE_CHECKING, cast
6
+
7
+ try:
8
+ from motor.motor_asyncio import AsyncIOMotorClient
9
+ except Exception:
10
+ raise
11
+
12
+ from .async_nosql_backend_protocol import AsyncNoSqlBackendBase
13
+
14
+ if TYPE_CHECKING:
15
+ from patera import Patera
16
+
17
+
18
+ class MongoBackend(AsyncNoSqlBackendBase):
19
+ def __init__(self, uri: str, database: Optional[str] = None) -> None:
20
+ self._client: Optional[Any] = None
21
+ self._db: Optional[Any] = None
22
+ self._uri: str = uri
23
+ self._database: Optional[str] = database
24
+
25
+ @classmethod
26
+ def configure_from_app(
27
+ cls, app: "Patera", configs: dict[str, Any]
28
+ ) -> "AsyncNoSqlBackendBase":
29
+ uri = cast(str, configs.get("DATABASE_URI"))
30
+ database = cast(str, configs.get("DATABASE"))
31
+ return cls(uri, database)
32
+
33
+ async def connect(self) -> None:
34
+ # Connect client
35
+ self._client = AsyncIOMotorClient(self._uri)
36
+ # Choose DB (or leave None; database_handle() will return client)
37
+ self._db = self._client[self._database] if self._database else None
38
+
39
+ # quick ping to verify connectivity
40
+ try:
41
+ await self._client.admin.command("ping")
42
+ except Exception as exc: # pragma: no cover
43
+ raise RuntimeError(
44
+ f"Failed to connect to MongoDB at {self._uri}: {exc}"
45
+ ) from exc
46
+
47
+ async def disconnect(self) -> None:
48
+ if self._client is not None:
49
+ self._client.close()
50
+ self._client = None
51
+ self._db = None
52
+
53
+ def database_handle(self) -> Any:
54
+ # Prefer a db handle if configured; otherwise, return client
55
+ return self._db if self._db is not None else self._client
56
+
57
+ def supports_transactions(self) -> bool:
58
+ # Mongo supports transactions on replica sets / sharded clusters with WiredTiger.
59
+ return True
60
+
61
+ async def start_session(self) -> Any:
62
+ if not self._client:
63
+ raise RuntimeError("Mongo client not connected.")
64
+ return await self._client.start_session()
65
+
66
+ async def with_transaction(
67
+ self, fn: Callable[..., Any], *args, session: Any = None, **kwargs
68
+ ) -> Any:
69
+ # If session provided, run a transaction; else just call directly.
70
+ if session is None:
71
+ return await fn(*args, **kwargs)
72
+
73
+ async def runner(sess):
74
+ return await fn(*args, session=sess, **kwargs)
75
+
76
+ # Motor session has method 'with_transaction'
77
+ return await session.with_transaction(runner)
78
+
79
+ # ---- Collection / CRUD ----
80
+
81
+ def get_collection(self, name: str) -> Any:
82
+ db_or_client = self.database_handle()
83
+ if hasattr(db_or_client, "__getitem__"): # db['collection']
84
+ return db_or_client[name]
85
+ # If only client exists but no db name given:
86
+ raise RuntimeError(
87
+ "No database selected. Set NOSQL_DATABASE or use database['<db>'][collection]."
88
+ )
89
+
90
+ async def find_one(
91
+ self,
92
+ collection: str,
93
+ filter: Mapping[str, Any],
94
+ *,
95
+ session: Any = None,
96
+ **kwargs,
97
+ ) -> Any:
98
+ coll = self.get_collection(collection)
99
+ return await coll.find_one(filter, session=session, **kwargs)
100
+
101
+ async def find_many(
102
+ self,
103
+ collection: str,
104
+ filter: Mapping[str, Any] | None = None,
105
+ *,
106
+ session: Any = None,
107
+ limit: Optional[int] = None,
108
+ skip: Optional[int] = None,
109
+ sort: Optional[Iterable[tuple[str, int]]] = None,
110
+ **kwargs,
111
+ ) -> list[Any]:
112
+ filter = filter or {}
113
+ coll = self.get_collection(collection)
114
+ cursor = coll.find(filter, session=session, **kwargs)
115
+ if sort:
116
+ cursor = cursor.sort(list(sort))
117
+ if skip:
118
+ cursor = cursor.skip(skip)
119
+ if limit:
120
+ cursor = cursor.limit(limit)
121
+ return await cursor.to_list(length=limit or 0)
122
+
123
+ async def insert_one(
124
+ self, collection: str, doc: Mapping[str, Any], *, session: Any = None, **kwargs
125
+ ) -> Any:
126
+ coll = self.get_collection(collection)
127
+ return await coll.insert_one(doc, session=session, **kwargs)
128
+
129
+ async def insert_many(
130
+ self,
131
+ collection: str,
132
+ docs: Iterable[Mapping[str, Any]],
133
+ *,
134
+ session: Any = None,
135
+ **kwargs,
136
+ ) -> Any:
137
+ coll = self.get_collection(collection)
138
+ return await coll.insert_many(list(docs), session=session, **kwargs)
139
+
140
+ async def update_one(
141
+ self,
142
+ collection: str,
143
+ filter: Mapping[str, Any],
144
+ update: Mapping[str, Any],
145
+ *,
146
+ upsert: bool = False,
147
+ session: Any = None,
148
+ **kwargs,
149
+ ) -> Any:
150
+ coll = self.get_collection(collection)
151
+ return await coll.update_one(
152
+ filter, update, upsert=upsert, session=session, **kwargs
153
+ )
154
+
155
+ async def delete_one(
156
+ self,
157
+ collection: str,
158
+ filter: Mapping[str, Any],
159
+ *,
160
+ session: Any = None,
161
+ **kwargs,
162
+ ) -> Any:
163
+ coll = self.get_collection(collection)
164
+ return await coll.delete_one(filter, session=session, **kwargs)
165
+
166
+ async def aggregate(
167
+ self,
168
+ collection: str,
169
+ pipeline: Iterable[Mapping[str, Any]],
170
+ *,
171
+ session: Any = None,
172
+ **kwargs,
173
+ ) -> list[Any]:
174
+ coll = self.get_collection(collection)
175
+ cursor = coll.aggregate(list(pipeline), session=session, **kwargs)
176
+ return await cursor.to_list(length=None)
177
+
178
+ async def execute_raw(self, *args, **kwargs) -> Any:
179
+ """
180
+ For MongoDB, you can call arbitrary db.command(...) via:
181
+ await db.execute_raw("command", {"ping": 1})
182
+ Or pass a callable to run with the database handle.
183
+ """
184
+ db = self.database_handle()
185
+ if callable(args[0]):
186
+ fn = args[0]
187
+ return await fn(db, *args[1:], **kwargs)
188
+
189
+ # Simple command passthrough:
190
+ if isinstance(args[0], str):
191
+ cmd = args[0]
192
+ arg = args[1] if len(args) > 1 else {}
193
+ return await db.command(
194
+ {cmd: arg} if not isinstance(arg, Mapping) else {cmd: 1, **arg}
195
+ )
196
+
197
+ raise TypeError("Unsupported execute_raw usage for MongoBackend.")
@@ -0,0 +1,241 @@
1
+ """
2
+ NoSQL Database Module
3
+ """
4
+
5
+ from pydantic import BaseModel, Field
6
+ from typing import (
7
+ Optional,
8
+ Callable,
9
+ Any,
10
+ Iterable,
11
+ Mapping,
12
+ TYPE_CHECKING,
13
+ TypedDict,
14
+ cast,
15
+ Type,
16
+ NotRequired,
17
+ )
18
+ from functools import wraps
19
+
20
+ from .backends.async_nosql_backend_protocol import AsyncNoSqlBackendBase
21
+ from patera.base_extension import BaseExtension
22
+ from patera.utilities import run_sync_or_async
23
+
24
+
25
+ if TYPE_CHECKING:
26
+ from patera import Patera
27
+
28
+
29
+ class _NoSqlDatabaseConfig(BaseModel):
30
+ """Configuration options for NoSqlDatabase extension."""
31
+
32
+ BACKEND: Type[AsyncNoSqlBackendBase] = Field(
33
+ description="Backend class implementing AsyncNoSqlBackendBase."
34
+ )
35
+ DATABASE_URI: str = Field(description="Connection string for the NoSQL backend.")
36
+ DATABASE_NAME: Optional[str] = Field(
37
+ default=None, description="Database / keyspace name (if backend uses it)."
38
+ )
39
+ DB_INJECT_NAME: Optional[str] = Field(
40
+ default="db", description="Kwarg name injected by decorators (database handle)."
41
+ )
42
+ SESSION_NAME: Optional[str] = Field(
43
+ default="session",
44
+ description="Kwarg name injected by @managed_database (session/transaction handle).",
45
+ )
46
+
47
+
48
+ class NoSqlDatabaseConfig(TypedDict):
49
+ BACKEND: Type[AsyncNoSqlBackendBase]
50
+ DATABASE_URI: str
51
+ DATABASE_NAME: NotRequired[str]
52
+ INJECT_NAME: NotRequired[str]
53
+ SESSION_NAME: NotRequired[str]
54
+
55
+
56
+ class NoSqlDatabase(BaseExtension):
57
+ """
58
+ A simple async NoSQL Database interface with pluggable backends.
59
+ """
60
+
61
+ def __init__(
62
+ self, db_name: str = "nosql", configs_name: str = "NOSQL_DATABASE"
63
+ ) -> None:
64
+ self._app: Optional["Patera"] = None
65
+ self._configs_name = configs_name
66
+ self._configs: dict[str, Any] = {}
67
+ self.__db_name__ = db_name
68
+
69
+ # Effective config
70
+ self.backend_name: Optional[str] = None
71
+ self.uri: Optional[str] = None
72
+ self.database: Optional[str] = None
73
+ self.inject_name: str = "db"
74
+ self.session_name: str = "session"
75
+
76
+ # Backend instance
77
+ self._backend: AsyncNoSqlBackendBase = cast(AsyncNoSqlBackendBase, None)
78
+
79
+ # ---- App lifecycle ----
80
+
81
+ def init_app(self, app: "Patera") -> None:
82
+ """
83
+ Initializes the NoSQL interface.
84
+ Required config keys (with optional variable_prefix):
85
+ - BACKEND
86
+ - DATABASE_URI
87
+ Optional:
88
+ - DATABASE
89
+ - INJECT_NAME (default "db")
90
+ - SESSION_NAME (default "session")
91
+ """
92
+ self._app = app
93
+ self._configs = app.get_conf(self._configs_name, None)
94
+ if self._configs is None:
95
+ raise ValueError(f"Missing {self._configs_name} configuration.")
96
+ self._configs = self.validate_configs(self._configs, _NoSqlDatabaseConfig)
97
+
98
+ self.backend_cls = self._configs.get("BACKEND")
99
+ self.uri = self._configs.get("DATABASE_URI")
100
+ self.database = self._configs.get("DATABASE_NAME")
101
+ self.inject_name = cast(str, self._configs.get("INJECT_NAME"))
102
+ self.session_name = cast(str, self._configs.get("SESSION_NAME"))
103
+
104
+ if not self.backend_cls or not self.uri:
105
+ raise RuntimeError(
106
+ "Missing NOSQL_BACKEND or NOSQL_DATABASE_URI configuration."
107
+ )
108
+ if not issubclass(self.backend_cls, AsyncNoSqlBackendBase):
109
+ raise RuntimeError(
110
+ "NOSQL_BACKEND must be a subclass of AsyncNoSqlBackendBase."
111
+ )
112
+
113
+ self._backend = cast(
114
+ AsyncNoSqlBackendBase, self.backend_cls
115
+ ).configure_from_app(app, self._configs)
116
+
117
+ app.add_extension(self)
118
+ app.add_on_startup_method(self.connect)
119
+ app.add_on_shutdown_method(self.disconnect)
120
+
121
+ async def connect(self) -> None:
122
+ """
123
+ Creates the backend client/connection. Runs on lifespan.start
124
+ """
125
+ if not self._backend:
126
+ raise RuntimeError("Backend not initialized. Call init_app() first.")
127
+ await self._backend.connect()
128
+
129
+ async def disconnect(self) -> None:
130
+ """
131
+ Disposes backend resources. Runs on lifespan.shutdown
132
+ """
133
+ if self._backend:
134
+ await self._backend.disconnect()
135
+
136
+ @property
137
+ def db_name(self) -> str:
138
+ return self.__db_name__
139
+
140
+ @property
141
+ def backend(self) -> AsyncNoSqlBackendBase:
142
+ if not self._backend:
143
+ raise RuntimeError("Backend not connected. Was init_app/connect called?")
144
+ return self._backend
145
+
146
+ def database_handle(self) -> Any:
147
+ return self.backend.database_handle()
148
+
149
+ def get_collection(self, name: str) -> Any:
150
+ return self.backend.get_collection(name)
151
+
152
+ async def find_one(
153
+ self, collection: str, filter: Mapping[str, Any], **kwargs
154
+ ) -> Any:
155
+ return await self.backend.find_one(collection, filter, **kwargs)
156
+
157
+ async def find_many(
158
+ self, collection: str, filter: Mapping[str, Any] | None = None, **kwargs
159
+ ) -> list[Any]:
160
+ return await self.backend.find_many(collection, filter, **kwargs)
161
+
162
+ async def insert_one(
163
+ self, collection: str, doc: Mapping[str, Any], **kwargs
164
+ ) -> Any:
165
+ return await self.backend.insert_one(collection, doc, **kwargs)
166
+
167
+ async def insert_many(
168
+ self, collection: str, docs: Iterable[Mapping[str, Any]], **kwargs
169
+ ) -> Any:
170
+ return await self.backend.insert_many(collection, docs, **kwargs)
171
+
172
+ async def update_one(
173
+ self,
174
+ collection: str,
175
+ filter: Mapping[str, Any],
176
+ update: Mapping[str, Any],
177
+ **kwargs,
178
+ ) -> Any:
179
+ return await self.backend.update_one(collection, filter, update, **kwargs)
180
+
181
+ async def delete_one(
182
+ self, collection: str, filter: Mapping[str, Any], **kwargs
183
+ ) -> Any:
184
+ return await self.backend.delete_one(collection, filter, **kwargs)
185
+
186
+ async def aggregate(
187
+ self, collection: str, pipeline: Iterable[Mapping[str, Any]], **kwargs
188
+ ) -> list[Any]:
189
+ return await self.backend.aggregate(collection, pipeline, **kwargs)
190
+
191
+ async def execute_raw(self, *args, **kwargs) -> Any:
192
+ """
193
+ Escape hatch for backend-specific commands. See MongoBackend.execute_raw docstring.
194
+ """
195
+ return await self.backend.execute_raw(*args, **kwargs)
196
+
197
+ @property
198
+ def managed_database(self) -> Callable:
199
+ """
200
+ Decorator that:
201
+ - Injects a database/client handle into handler kwargs under NOSQL_INJECT_NAME ("db" by default).
202
+ - If the backend supports transactions, opens a session+transaction for the duration
203
+ and commits/aborts automatically on exit.
204
+ """
205
+
206
+ def decorator(handler: Callable) -> Callable:
207
+ @wraps(handler)
208
+ async def wrapper(*args, **kwargs):
209
+ backend = self.backend
210
+ inject_key = self.inject_name
211
+ handle = backend.database_handle()
212
+
213
+ # default injection
214
+ kwargs[inject_key] = handle
215
+
216
+ if not backend.supports_transactions():
217
+ # No transaction semantics; just run.
218
+ return await run_sync_or_async(handler, *args, **kwargs)
219
+
220
+ # Transaction-capable flow
221
+ session = await backend.start_session()
222
+
223
+ async def _call_with_session(*_args, **_kwargs):
224
+ # Some backends (Mongo) accept 'session' in calls; handlers can pass it through.
225
+ _kwargs.setdefault(self.session_name, session)
226
+ return await run_sync_or_async(handler, *_args, **_kwargs)
227
+
228
+ try:
229
+ return await backend.with_transaction(
230
+ _call_with_session, *args, **kwargs, session=session
231
+ )
232
+ finally:
233
+ # session ended in with_transaction, but just in case
234
+ try:
235
+ await session.end_session() # type: ignore[attr-defined]
236
+ except Exception:
237
+ pass
238
+
239
+ return wrapper
240
+
241
+ return decorator
@@ -0,0 +1,22 @@
1
+ """
2
+ database module of patera
3
+ """
4
+
5
+ # re-export of some commonly used sqlalchemy objects
6
+ # and methods for convenience.
7
+ from sqlalchemy import select, Select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from .sql_database import SqlDatabase, SqlDatabaseConfig
11
+ from .sqlalchemy_async_query import AsyncQuery
12
+ from .declarative_base import DeclarativeBaseModel
13
+
14
+ __all__ = [
15
+ "SqlDatabase",
16
+ "select",
17
+ "Select",
18
+ "AsyncSession",
19
+ "AsyncQuery",
20
+ "DeclarativeBaseModel",
21
+ "SqlDatabaseConfig",
22
+ ]