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.
Files changed (52) hide show
  1. stackraise/__init__.py +6 -0
  2. stackraise/ai/__init__.py +2 -0
  3. stackraise/ai/rpa.py +380 -0
  4. stackraise/ai/toolset.py +227 -0
  5. stackraise/app.py +23 -0
  6. stackraise/auth/__init__.py +2 -0
  7. stackraise/auth/model.py +24 -0
  8. stackraise/auth/service.py +240 -0
  9. stackraise/ctrl/__init__.py +4 -0
  10. stackraise/ctrl/change_stream.py +40 -0
  11. stackraise/ctrl/crud_controller.py +63 -0
  12. stackraise/ctrl/file_storage.py +68 -0
  13. stackraise/db/__init__.py +11 -0
  14. stackraise/db/adapter.py +60 -0
  15. stackraise/db/collection.py +292 -0
  16. stackraise/db/cursor.py +229 -0
  17. stackraise/db/document.py +282 -0
  18. stackraise/db/exceptions.py +9 -0
  19. stackraise/db/id.py +79 -0
  20. stackraise/db/index.py +84 -0
  21. stackraise/db/persistence.py +238 -0
  22. stackraise/db/pipeline.py +245 -0
  23. stackraise/db/protocols.py +141 -0
  24. stackraise/di.py +36 -0
  25. stackraise/event.py +150 -0
  26. stackraise/inflection.py +28 -0
  27. stackraise/io/__init__.py +3 -0
  28. stackraise/io/imap_client.py +400 -0
  29. stackraise/io/smtp_client.py +102 -0
  30. stackraise/logging.py +22 -0
  31. stackraise/model/__init__.py +11 -0
  32. stackraise/model/core.py +16 -0
  33. stackraise/model/dto.py +12 -0
  34. stackraise/model/email_message.py +88 -0
  35. stackraise/model/file.py +154 -0
  36. stackraise/model/name_email.py +45 -0
  37. stackraise/model/query_filters.py +231 -0
  38. stackraise/model/time_range.py +285 -0
  39. stackraise/model/validation.py +8 -0
  40. stackraise/templating/__init__.py +4 -0
  41. stackraise/templating/exceptions.py +23 -0
  42. stackraise/templating/image/__init__.py +2 -0
  43. stackraise/templating/image/model.py +51 -0
  44. stackraise/templating/image/processor.py +154 -0
  45. stackraise/templating/parser.py +156 -0
  46. stackraise/templating/pptx/__init__.py +3 -0
  47. stackraise/templating/pptx/pptx_engine.py +204 -0
  48. stackraise/templating/pptx/slide_renderer.py +181 -0
  49. stackraise/templating/tracer.py +57 -0
  50. stackraise-0.1.0.dist-info/METADATA +37 -0
  51. stackraise-0.1.0.dist-info/RECORD +52 -0
  52. 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,4 @@
1
+
2
+ from .crud_controller import *
3
+ from .change_stream import ChangeStream
4
+ from .file_storage import *
@@ -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 *
@@ -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