stackraise 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.
- stackraise/__init__.py +6 -0
- stackraise/ai/__init__.py +2 -0
- stackraise/ai/rpa.py +380 -0
- stackraise/ai/toolset.py +227 -0
- stackraise/app.py +23 -0
- stackraise/auth/__init__.py +2 -0
- stackraise/auth/model.py +24 -0
- stackraise/auth/service.py +240 -0
- stackraise/ctrl/__init__.py +4 -0
- stackraise/ctrl/change_stream.py +40 -0
- stackraise/ctrl/crud_controller.py +63 -0
- stackraise/ctrl/file_storage.py +68 -0
- stackraise/db/__init__.py +11 -0
- stackraise/db/adapter.py +60 -0
- stackraise/db/collection.py +292 -0
- stackraise/db/cursor.py +229 -0
- stackraise/db/document.py +282 -0
- stackraise/db/exceptions.py +9 -0
- stackraise/db/id.py +79 -0
- stackraise/db/index.py +84 -0
- stackraise/db/persistence.py +238 -0
- stackraise/db/pipeline.py +245 -0
- stackraise/db/protocols.py +141 -0
- stackraise/di.py +36 -0
- stackraise/event.py +150 -0
- stackraise/inflection.py +28 -0
- stackraise/io/__init__.py +3 -0
- stackraise/io/imap_client.py +400 -0
- stackraise/io/smtp_client.py +102 -0
- stackraise/logging.py +22 -0
- stackraise/model/__init__.py +11 -0
- stackraise/model/core.py +16 -0
- stackraise/model/dto.py +12 -0
- stackraise/model/email_message.py +88 -0
- stackraise/model/file.py +154 -0
- stackraise/model/name_email.py +45 -0
- stackraise/model/query_filters.py +231 -0
- stackraise/model/time_range.py +285 -0
- stackraise/model/validation.py +8 -0
- stackraise/templating/__init__.py +4 -0
- stackraise/templating/exceptions.py +23 -0
- stackraise/templating/image/__init__.py +2 -0
- stackraise/templating/image/model.py +51 -0
- stackraise/templating/image/processor.py +154 -0
- stackraise/templating/parser.py +156 -0
- stackraise/templating/pptx/__init__.py +3 -0
- stackraise/templating/pptx/pptx_engine.py +204 -0
- stackraise/templating/pptx/slide_renderer.py +181 -0
- stackraise/templating/tracer.py +57 -0
- stackraise-0.1.0.dist-info/METADATA +37 -0
- stackraise-0.1.0.dist-info/RECORD +52 -0
- stackraise-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
from asyncio import get_event_loop
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from hashlib import pbkdf2_hmac
|
|
7
|
+
from os import urandom
|
|
8
|
+
from typing import Annotated, ClassVar, Literal, Optional, Self
|
|
9
|
+
|
|
10
|
+
import jwt
|
|
11
|
+
from fastapi import Depends, Form, HTTPException, Security, status
|
|
12
|
+
from fastapi.security import OAuth2PasswordBearer, SecurityScopes
|
|
13
|
+
from pydantic import BaseModel, EmailStr, Field, SecretStr
|
|
14
|
+
|
|
15
|
+
from .model import BaseUserAccount
|
|
16
|
+
import stackraise.di as di
|
|
17
|
+
import stackraise.db as db
|
|
18
|
+
|
|
19
|
+
class BaseAuth[User: db.Document](di.Singleton, ABC):
|
|
20
|
+
class Settings(BaseModel):
|
|
21
|
+
secret_key: Annotated[str, Field()]
|
|
22
|
+
algorithm: Annotated[str, Field("HS256")]
|
|
23
|
+
realm: Annotated[str, Field()]
|
|
24
|
+
token_expiration_time: Annotated[timedelta, Field(timedelta(minutes=24*60))]
|
|
25
|
+
password_hashing_algorithm: str = "sha256"
|
|
26
|
+
password_hashing_iterations: int = 10000
|
|
27
|
+
|
|
28
|
+
@cached_property
|
|
29
|
+
def password_bearer(self):
|
|
30
|
+
return OAuth2PasswordBearer(
|
|
31
|
+
tokenUrl=self.login_url,
|
|
32
|
+
scopes=self.scopes,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class DecodedBearerToken:
|
|
37
|
+
"""OAuth2 Bearer Token"""
|
|
38
|
+
|
|
39
|
+
scopes: list[str]
|
|
40
|
+
token_type: Literal["bearer"]
|
|
41
|
+
subject: Optional[str]
|
|
42
|
+
expiry: Optional[datetime]
|
|
43
|
+
issued_at: Optional[datetime]
|
|
44
|
+
|
|
45
|
+
# audience: Optional[str] = Field(None, alias="aud")
|
|
46
|
+
# issuer: Optional[str] = Field(None, alias="iss")
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class EncodedBearerToken:
|
|
50
|
+
"""OAuth2 Bearer Token DTO"""
|
|
51
|
+
|
|
52
|
+
access_token: str
|
|
53
|
+
token_type: Literal["bearer"]
|
|
54
|
+
expires_in: float
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass()
|
|
58
|
+
class SignUpForm:
|
|
59
|
+
email: Annotated[EmailStr, Field()]
|
|
60
|
+
password: Annotated[str, Field(min_length=8)]
|
|
61
|
+
|
|
62
|
+
LOGIN_URL: ClassVar[str] = "/me"
|
|
63
|
+
SCOPES: ClassVar[dict[str, str]]
|
|
64
|
+
USER_ACCOUNT_CLASS: ClassVar[type[BaseUserAccount]]
|
|
65
|
+
USER_CLASS: ClassVar[type[User]]
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def PasswordBearer(cls):
|
|
69
|
+
return Depends(
|
|
70
|
+
OAuth2PasswordBearer(
|
|
71
|
+
tokenUrl=cls.LOGIN_URL,
|
|
72
|
+
scopes={k.value: v for k, v in cls.SCOPES.items()},
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def BearerGuard(cls):
|
|
78
|
+
async def bearer_guard(
|
|
79
|
+
security_scopes: SecurityScopes,
|
|
80
|
+
encoded_token: str = cls.PasswordBearer(),
|
|
81
|
+
auth: Self = di.Inject(cls),
|
|
82
|
+
) -> BaseAuth.DecodedBearerToken:
|
|
83
|
+
return auth.decode_bearer_token(encoded_token, security_scopes)
|
|
84
|
+
|
|
85
|
+
return Depends(bearer_guard)
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def ScopeGuard(cls, *scopes: list[BaseUserAccount.Scope], dep):
|
|
89
|
+
async def scope_guard(
|
|
90
|
+
bearer_token: Annotated[cls.DecodedBearerToken, cls.BearerGuard()]
|
|
91
|
+
):
|
|
92
|
+
return bearer_token
|
|
93
|
+
|
|
94
|
+
return Security(scope_guard, scopes=[v.value for v in scopes])
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def UserGuard(cls, *scopes: list[BaseUserAccount.Scope]):
|
|
98
|
+
async def user_guard(
|
|
99
|
+
bearer_token: Annotated[cls.DecodedBearerToken, cls.BearerGuard()]
|
|
100
|
+
):
|
|
101
|
+
return cls.USER_CLASS.Reference(db.Id(bearer_token.subject))
|
|
102
|
+
|
|
103
|
+
return Security(user_guard, scopes=[v.value for v in scopes])
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def api_router(cls):
|
|
107
|
+
...
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
settings: Settings
|
|
112
|
+
|
|
113
|
+
def __init__(self, settings: Settings):
|
|
114
|
+
self.settings = settings
|
|
115
|
+
|
|
116
|
+
def issue_bearer_token(
|
|
117
|
+
self,
|
|
118
|
+
subject: str,
|
|
119
|
+
scopes: list[str],
|
|
120
|
+
expire: Optional[timedelta] = None,
|
|
121
|
+
) -> EncodedBearerToken:
|
|
122
|
+
"""Issue a access token
|
|
123
|
+
Args:
|
|
124
|
+
subject (str): The subject of the token.
|
|
125
|
+
scopes (list[str]): The list of scopes for the token.
|
|
126
|
+
expire (timedelta, optional): The expiration time for the token. Defaults to timedelta(minutes=15).
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
OAuth2BearerTokenDTO: The issued access token.
|
|
130
|
+
"""
|
|
131
|
+
if expire is None:
|
|
132
|
+
expire = self.settings.token_expiration_time
|
|
133
|
+
issued_at = datetime.now(UTC)
|
|
134
|
+
access_token = jwt.encode(
|
|
135
|
+
{
|
|
136
|
+
"iat": issued_at,
|
|
137
|
+
"exp": issued_at + expire,
|
|
138
|
+
"sub": subject,
|
|
139
|
+
"scope": " ".join(scopes),
|
|
140
|
+
},
|
|
141
|
+
key=self.settings.secret_key,
|
|
142
|
+
algorithm=self.settings.algorithm,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return self.EncodedBearerToken(
|
|
146
|
+
access_token=access_token,
|
|
147
|
+
token_type="bearer",
|
|
148
|
+
expires_in=expire.total_seconds() / 60,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def decode_bearer_token(
|
|
152
|
+
self, encoded_token: str | bytes, security_scopes: SecurityScopes
|
|
153
|
+
) -> DecodedBearerToken:
|
|
154
|
+
"""Receive and validate a bearer token.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
encoded_token (str | bytes): The encoded bearer token.
|
|
158
|
+
security_scopes (SecurityScopes): The required security scopes.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
OAuth2BearerToken: The validated bearer token.
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
OAuth2InvalidToken: If the token is expired or invalid.
|
|
165
|
+
OAuth2InsufficientScope: If the token does not have sufficient scope.
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
payload = jwt.decode(
|
|
169
|
+
encoded_token,
|
|
170
|
+
key=self.settings.secret_key,
|
|
171
|
+
algorithms=[self.settings.algorithm],
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
bearer_token = self.DecodedBearerToken(
|
|
175
|
+
token_type="Bearer",
|
|
176
|
+
issued_at=datetime.fromtimestamp(payload.get("iat"), tz=UTC),
|
|
177
|
+
expiry=datetime.fromtimestamp(payload.get("exp"), tz=UTC),
|
|
178
|
+
subject=payload.get("sub", None),
|
|
179
|
+
scopes=payload.get("scope", "").split(),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
except jwt.exceptions.ExpiredSignatureError as e:
|
|
183
|
+
raise HTTPException(
|
|
184
|
+
detail="Invalid Bearer token", status_code=status.HTTP_400_BAD_REQUEST
|
|
185
|
+
) from e
|
|
186
|
+
|
|
187
|
+
has_enough_permisions = all(
|
|
188
|
+
required_scope in bearer_token.scopes
|
|
189
|
+
for required_scope in security_scopes.scopes
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if not has_enough_permisions:
|
|
193
|
+
self.raise_401_unauthorized("Insufficient scope", security_scopes)
|
|
194
|
+
|
|
195
|
+
return bearer_token
|
|
196
|
+
|
|
197
|
+
def raise_401_unauthorized(self, detail: str, security_scopes: SecurityScopes):
|
|
198
|
+
"""
|
|
199
|
+
Raises an HTTPException with status code 401 Unauthorized and sets the appropriate headers.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
detail (str): The detail message for the exception.
|
|
203
|
+
security_scopes (SecurityScopes): The security scopes associated with the request.
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
HTTPException: The raised exception with status code 401 Unauthorized and headers.
|
|
207
|
+
|
|
208
|
+
"""
|
|
209
|
+
www_authenticate = f'Bearer realm="{self.settings.realm}"'
|
|
210
|
+
|
|
211
|
+
if security_scopes.scopes:
|
|
212
|
+
www_authenticate = (
|
|
213
|
+
f'"{www_authenticate}" scope="{security_scopes.scope_str}"'
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
raise HTTPException(
|
|
217
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
218
|
+
detail=detail,
|
|
219
|
+
headers={"WWW-Authenticate": www_authenticate},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def make_password_salt(self) -> str:
|
|
223
|
+
return urandom(16).hex()
|
|
224
|
+
|
|
225
|
+
async def make_password_hash(self, salt: str, password: str) -> str:
|
|
226
|
+
"""
|
|
227
|
+
Asynchronously hash a password using SHA-256.
|
|
228
|
+
"""
|
|
229
|
+
loop = get_event_loop()
|
|
230
|
+
|
|
231
|
+
def password_hash_workload():
|
|
232
|
+
return pbkdf2_hmac(
|
|
233
|
+
self.settings.password_hashing_algorithm,
|
|
234
|
+
password.encode(),
|
|
235
|
+
bytes.fromhex(salt),
|
|
236
|
+
self.settings.password_hashing_iterations,
|
|
237
|
+
).hex()
|
|
238
|
+
|
|
239
|
+
return await loop.run_in_executor(None, password_hash_workload)
|
|
240
|
+
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
2
|
+
|
|
3
|
+
import stackraise.db as db
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
class ChangeStream:
|
|
7
|
+
|
|
8
|
+
websockets: set[WebSocket]
|
|
9
|
+
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.websockets = set()
|
|
12
|
+
#print("Initializing ChangeStream...")
|
|
13
|
+
self.api_router = APIRouter(
|
|
14
|
+
prefix=f"",
|
|
15
|
+
tags=['websockets'],
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
@self.api_router.websocket("/change-stream")
|
|
19
|
+
async def endpoint(ws: WebSocket):
|
|
20
|
+
await ws.accept()
|
|
21
|
+
try:
|
|
22
|
+
self.websockets.add(ws)
|
|
23
|
+
while True:
|
|
24
|
+
await ws.receive_json()
|
|
25
|
+
# TODO: handle subscription events ... etc
|
|
26
|
+
except WebSocketDisconnect:
|
|
27
|
+
self.websockets.remove(ws)
|
|
28
|
+
|
|
29
|
+
db.change_event_emitter.subscribe(self.broadcast)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def broadcast(self, change_event: db.ChangeEvent):
|
|
33
|
+
payload = json.dumps(change_event)
|
|
34
|
+
print(f"Broadcasting change event: {payload}")
|
|
35
|
+
for ws in tuple(self.websockets):
|
|
36
|
+
try:
|
|
37
|
+
await ws.send_text(payload)
|
|
38
|
+
except Exception as e:
|
|
39
|
+
print(e)
|
|
40
|
+
pass
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
XXX: En python 3.13 este modulo dejara de funcionar ya que __future__.annotations estaré
|
|
3
|
+
activado por defecto. Para construir un controlador CRUD debemos generar y compilar el
|
|
4
|
+
codigo python desde una plantilla de codigo (similar a namedtuple).
|
|
5
|
+
"""
|
|
6
|
+
#from __future__ import annotations # XXX: No puede ser activadoo
|
|
7
|
+
from typing import Annotated, Any
|
|
8
|
+
from fastapi import APIRouter, Query, Path, params
|
|
9
|
+
import stackraise.db as db
|
|
10
|
+
import stackraise.inflection as inflection
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Crud:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
persistent_cls: type[db.Document],
|
|
18
|
+
sorting_key: str = "_id",
|
|
19
|
+
*,
|
|
20
|
+
read_guards: list[Any] = [],
|
|
21
|
+
write_guards: list[Any] = [],
|
|
22
|
+
):
|
|
23
|
+
self.api_router = APIRouter(
|
|
24
|
+
prefix=f"/{inflection.to_slug(persistent_cls.collection.adapter.tablename)}",
|
|
25
|
+
tags=[persistent_cls.__name__],
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
#self.api_router.add_api_route()
|
|
29
|
+
|
|
30
|
+
@self.api_router.post("", dependencies=write_guards)
|
|
31
|
+
async def create(item: persistent_cls) -> persistent_cls:
|
|
32
|
+
return await item.insert()
|
|
33
|
+
|
|
34
|
+
@self.api_router.put("", dependencies=write_guards)
|
|
35
|
+
async def update(item: persistent_cls) -> persistent_cls:
|
|
36
|
+
return await item.update()
|
|
37
|
+
|
|
38
|
+
@self.api_router.get("", dependencies=read_guards)
|
|
39
|
+
async def index(
|
|
40
|
+
query: Annotated[persistent_cls.QueryFilters, Query()]
|
|
41
|
+
) -> list[persistent_cls]:
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
return persistent_cls.collection.find(query).sort(sorting_key).as_stream()
|
|
45
|
+
|
|
46
|
+
@self.api_router.get("/{ref}", dependencies=read_guards)
|
|
47
|
+
async def get_item(
|
|
48
|
+
ref: Annotated[persistent_cls.Ref, Path()]
|
|
49
|
+
) -> persistent_cls:
|
|
50
|
+
return await ref.fetch()
|
|
51
|
+
|
|
52
|
+
@self.api_router.delete("/{ref}", dependencies=write_guards)
|
|
53
|
+
async def delete_item(
|
|
54
|
+
ref: Annotated[persistent_cls.Ref, Path()]
|
|
55
|
+
) -> None:
|
|
56
|
+
return await ref.delete()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
self.create = create
|
|
60
|
+
self.update = update
|
|
61
|
+
self.index = index
|
|
62
|
+
self.get_item = get_item
|
|
63
|
+
self.delete_item = delete_item
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from typing import Annotated, Any
|
|
2
|
+
from fastapi import APIRouter, Path, UploadFile, File as FormFile, HTTPException
|
|
3
|
+
import stackraise.db as db
|
|
4
|
+
import stackraise.model as model
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FileStorage:
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
*,
|
|
11
|
+
prefix:str = '/fs',
|
|
12
|
+
read_guards: list[Any] = [],
|
|
13
|
+
write_guards: list[Any] = [],
|
|
14
|
+
):
|
|
15
|
+
self.api_router = APIRouter(
|
|
16
|
+
prefix=prefix,
|
|
17
|
+
tags=["File Storage"],
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
#self.api_router.add_api_route()
|
|
21
|
+
|
|
22
|
+
# @self.api_router.post("", dependencies=write_guards)
|
|
23
|
+
# async def create(item: persistent_cls) -> persistent_cls:
|
|
24
|
+
# return await item.insert()
|
|
25
|
+
|
|
26
|
+
# @self.api_router.put("", dependencies=write_guards)
|
|
27
|
+
# async def update(item: persistent_cls) -> persistent_cls:
|
|
28
|
+
# return await item.update()
|
|
29
|
+
|
|
30
|
+
# @self.api_router.get("", dependencies=read_guards)
|
|
31
|
+
# async def index(
|
|
32
|
+
# query: Annotated[persistent_cls.QueryFilters, Query()]
|
|
33
|
+
# ) -> list[persistent_cls]:
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# return persistent_cls.collection.find(query).sort(sorting_key).as_stream()
|
|
37
|
+
|
|
38
|
+
@self.api_router.get("/{ref}", dependencies=read_guards)
|
|
39
|
+
async def get_item(
|
|
40
|
+
ref: Annotated[model.File.Ref, Path()]
|
|
41
|
+
):
|
|
42
|
+
file = await ref.fetch()
|
|
43
|
+
return file.as_stream()
|
|
44
|
+
|
|
45
|
+
@self.api_router.post("", dependencies=write_guards)
|
|
46
|
+
async def upload_file(
|
|
47
|
+
file: Annotated[UploadFile, FormFile(...)],
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
"""Persist an uploaded binary into GridFS and return its reference."""
|
|
50
|
+
content = await file.read()
|
|
51
|
+
if not content:
|
|
52
|
+
raise HTTPException(status_code=400, detail="Uploaded file is empty")
|
|
53
|
+
|
|
54
|
+
stored_file = model.File.new(
|
|
55
|
+
filename=file.filename,
|
|
56
|
+
content_type=file.content_type or "application/octet-stream",
|
|
57
|
+
content=content,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
saved = await stored_file.insert()
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
"id": str(saved.id),
|
|
64
|
+
"filename": saved.filename,
|
|
65
|
+
"content_type": saved.content_type,
|
|
66
|
+
"length": saved.length,
|
|
67
|
+
}
|
|
68
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from ..model.query_filters import *
|
|
2
|
+
from .persistence import *
|
|
3
|
+
from .protocols import *
|
|
4
|
+
from .exceptions import *
|
|
5
|
+
from .adapter import *
|
|
6
|
+
from .cursor import *
|
|
7
|
+
from .id import *
|
|
8
|
+
from .index import *
|
|
9
|
+
from .collection import *
|
|
10
|
+
from .document import *
|
|
11
|
+
from .pipeline import *
|
stackraise/db/adapter.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from pydantic import TypeAdapter
|
|
5
|
+
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
from stackraise import inflection
|
|
8
|
+
|
|
9
|
+
from .protocols import DocumentProtocol
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Adapter[T: DocumentProtocol]:
|
|
13
|
+
def __init__(self, type: type[T]):
|
|
14
|
+
self._type = type
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def typename(self) -> str:
|
|
18
|
+
"""Return the name of the type."""
|
|
19
|
+
return self._type.__name__
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def typefullname(self) -> str:
|
|
23
|
+
"""Return the full name (__qualname__) of the type."""
|
|
24
|
+
return self._type.__qualname__
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def document_class(self) -> type[T]:
|
|
28
|
+
"""Return the document class associated with this adapter."""
|
|
29
|
+
return self._type
|
|
30
|
+
|
|
31
|
+
@cached_property
|
|
32
|
+
def tablename(self) -> str:
|
|
33
|
+
return inflection.to_tablename(self._type.__qualname__)
|
|
34
|
+
|
|
35
|
+
@cached_property
|
|
36
|
+
def slugname(self) -> str:
|
|
37
|
+
return inflection.to_slug(self._type.__qualname__)
|
|
38
|
+
|
|
39
|
+
@cached_property
|
|
40
|
+
def field_name(self) -> str:
|
|
41
|
+
return inflection.to_camelcase(self._type.__qualname__)
|
|
42
|
+
|
|
43
|
+
@cached_property
|
|
44
|
+
def item(self):
|
|
45
|
+
return TypeAdapter[T](self._type)
|
|
46
|
+
|
|
47
|
+
@cached_property
|
|
48
|
+
def list(self):
|
|
49
|
+
return TypeAdapter[T](list[self._type])
|
|
50
|
+
|
|
51
|
+
def parse_item(self, raw) -> T:
|
|
52
|
+
return self.item.validate_python(raw)
|
|
53
|
+
|
|
54
|
+
def dump_item(self, item: T, with_id=True) -> Any:
|
|
55
|
+
raw = self.item.dump_python(item, by_alias=True, exclude={"id"})
|
|
56
|
+
|
|
57
|
+
if with_id and item.id is not None:
|
|
58
|
+
raw["_id"] = item.id
|
|
59
|
+
|
|
60
|
+
return raw
|