sera-2 1.9.1__py3-none-any.whl → 1.11.2__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.
sera/libs/api_helper.py CHANGED
@@ -1,10 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import re
4
+ from typing import Collection, Generic, cast
4
5
 
5
6
  from litestar import Request, status_codes
7
+ from litestar.connection import ASGIConnection
8
+ from litestar.dto import MsgspecDTO
9
+ from litestar.dto._backend import DTOBackend
10
+ from litestar.dto._codegen_backend import DTOCodegenBackend
11
+ from litestar.enums import RequestEncodingType
6
12
  from litestar.exceptions import HTTPException
13
+ from litestar.serialization import decode_json, decode_msgpack
14
+ from litestar.typing import FieldDefinition
15
+ from msgspec import Struct
16
+
7
17
  from sera.libs.base_service import Query, QueryOp
18
+ from sera.libs.middlewares.uscp import STATE_SYSTEM_CONTROLLED_PROP_KEY
19
+ from sera.typing import T
8
20
 
9
21
  # for parsing field names and operations from query string
10
22
  FIELD_REG = re.compile(r"(?P<name>[a-zA-Z_0-9]+)(?:\[(?P<op>[a-zA-Z0-9]+)\])?")
@@ -64,3 +76,72 @@ def parse_query(request: Request, fields: set[str], debug: bool) -> Query:
64
76
  continue
65
77
 
66
78
  return query
79
+
80
+
81
+ class SingleAutoUSCP(MsgspecDTO[T], Generic[T]):
82
+ """Auto Update System Controlled Property DTO"""
83
+
84
+ @classmethod
85
+ def create_for_field_definition(
86
+ cls,
87
+ field_definition: FieldDefinition,
88
+ handler_id: str,
89
+ backend_cls: type[DTOBackend] | None = None,
90
+ ) -> None:
91
+ assert backend_cls is None, "Custom backend not supported"
92
+ super().create_for_field_definition(
93
+ field_definition, handler_id, FixedDTOBackend
94
+ )
95
+
96
+ def decode_bytes(self, value: bytes):
97
+ """Decode a byte string into an object"""
98
+ backend = self._dto_backends[self.asgi_connection.route_handler.handler_id][
99
+ "data_backend"
100
+ ] # pyright: ignore
101
+ obj = backend.populate_data_from_raw(value, self.asgi_connection)
102
+ obj.update_system_controlled_props(
103
+ self.asgi_connection.scope["state"][STATE_SYSTEM_CONTROLLED_PROP_KEY]
104
+ )
105
+ return obj
106
+
107
+
108
+ class FixedDTOBackend(DTOCodegenBackend):
109
+ def parse_raw(
110
+ self, raw: bytes, asgi_connection: ASGIConnection
111
+ ) -> Struct | Collection[Struct]:
112
+ """Parse raw bytes into transfer model type.
113
+
114
+ Note: instead of decoding into self.annotation, which I encounter this error: https://github.com/litestar-org/litestar/issues/4181; we have to use self.model_type, which is the original type.
115
+
116
+ Args:
117
+ raw: bytes
118
+ asgi_connection: The current ASGI Connection
119
+
120
+ Returns:
121
+ The raw bytes parsed into transfer model type.
122
+ """
123
+ request_encoding = RequestEncodingType.JSON
124
+
125
+ if (content_type := getattr(asgi_connection, "content_type", None)) and (
126
+ media_type := content_type[0]
127
+ ):
128
+ request_encoding = media_type
129
+
130
+ type_decoders = asgi_connection.route_handler.resolve_type_decoders()
131
+
132
+ if request_encoding == RequestEncodingType.MESSAGEPACK:
133
+ result = decode_msgpack(
134
+ value=raw,
135
+ target_type=self.model_type,
136
+ type_decoders=type_decoders,
137
+ strict=False,
138
+ )
139
+ else:
140
+ result = decode_json(
141
+ value=raw,
142
+ target_type=self.model_type,
143
+ type_decoders=type_decoders,
144
+ strict=False,
145
+ )
146
+
147
+ return cast("Struct | Collection[Struct]", result)
sera/libs/base_service.py CHANGED
@@ -4,12 +4,13 @@ from enum import Enum
4
4
  from math import dist
5
5
  from typing import Annotated, Any, Generic, NamedTuple, Optional, Sequence, TypeVar
6
6
 
7
+ from sqlalchemy import Result, Select, exists, func, select
8
+ from sqlalchemy.orm import Session, load_only
9
+
7
10
  from sera.libs.base_orm import BaseORM
8
11
  from sera.misc import assert_not_null
9
12
  from sera.models import Class
10
13
  from sera.typing import FieldName, T, doc
11
- from sqlalchemy import Result, Select, exists, func, select
12
- from sqlalchemy.orm import Session, load_only
13
14
 
14
15
 
15
16
  class QueryOp(str, Enum):
@@ -42,12 +43,24 @@ class QueryResult(NamedTuple, Generic[R]):
42
43
 
43
44
  class BaseService(Generic[ID, R]):
44
45
 
46
+ instance = None
47
+
45
48
  def __init__(self, cls: Class, orm_cls: type[R]):
46
49
  self.cls = cls
47
50
  self.orm_cls = orm_cls
48
51
  self.id_prop = assert_not_null(cls.get_id_property())
49
52
 
50
53
  self._cls_id_prop = getattr(self.orm_cls, self.id_prop.name)
54
+ self.is_id_auto_increment = self.id_prop.db.is_auto_increment
55
+
56
+ @classmethod
57
+ def get_instance(cls):
58
+ """Get the singleton instance of the service."""
59
+ if cls.instance is None:
60
+ # assume that the subclass overrides the __init__ method
61
+ # so that we don't need to pass the class and orm_cls
62
+ cls.instance = cls()
63
+ return cls.instance
51
64
 
52
65
  def get(
53
66
  self,
@@ -90,7 +103,7 @@ class BaseService(Generic[ID, R]):
90
103
 
91
104
  cq = select(func.count()).select_from(q.subquery())
92
105
  rq = q.limit(limit).offset(offset)
93
- records = self._process_result(session.execute(q)).scalars().all()
106
+ records = self._process_result(session.execute(rq)).scalars().all()
94
107
  total = session.execute(cq).scalar_one()
95
108
  return QueryResult(records, total)
96
109
 
@@ -108,6 +121,9 @@ class BaseService(Generic[ID, R]):
108
121
 
109
122
  def create(self, record: R, session: Session) -> R:
110
123
  """Create a new record."""
124
+ if self.is_id_auto_increment:
125
+ setattr(record, self.id_prop.name, None)
126
+
111
127
  session.add(record)
112
128
  session.commit()
113
129
  return record
File without changes
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Callable, Generator, Generic, Optional, Sequence, Type
5
+
6
+ from litestar import Request
7
+ from litestar.connection import ASGIConnection
8
+ from litestar.exceptions import NotAuthorizedException
9
+ from litestar.middleware import AbstractAuthenticationMiddleware, AuthenticationResult
10
+ from litestar.types import ASGIApp, Method, Scopes
11
+ from litestar.types.composite_types import Dependencies
12
+ from sqlalchemy import select
13
+ from sqlalchemy.orm import Session
14
+
15
+ from sera.typing import T
16
+
17
+
18
+ class AuthMiddleware(AbstractAuthenticationMiddleware, Generic[T]):
19
+ """
20
+ Middleware to handle authentication for the API.
21
+
22
+ This middleware checks if the user is authenticated to access
23
+ the requested resource. If not, it raises an HTTPException with a 401 status code.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ app: ASGIApp,
29
+ user_handler: Callable[[str], T],
30
+ exclude: str | list[str] | None = None,
31
+ exclude_from_auth_key: str = "exclude_from_auth",
32
+ exclude_http_methods: Sequence[Method] | None = None,
33
+ scopes: Scopes | None = None,
34
+ ) -> None:
35
+ super().__init__(
36
+ app=app,
37
+ exclude=exclude,
38
+ exclude_from_auth_key=exclude_from_auth_key,
39
+ exclude_http_methods=exclude_http_methods,
40
+ scopes=scopes,
41
+ )
42
+ self.user_handler = user_handler
43
+
44
+ async def authenticate_request(
45
+ self, connection: ASGIConnection
46
+ ) -> AuthenticationResult:
47
+ # do something here.
48
+ if "userid" not in connection.session:
49
+ raise NotAuthorizedException(
50
+ detail="Invalid credentials",
51
+ )
52
+
53
+ userid: str = connection.session["userid"]
54
+ expired_at = datetime.fromtimestamp(
55
+ connection.session.get("exp", 0), timezone.utc
56
+ )
57
+ if expired_at < datetime.now(timezone.utc):
58
+ raise NotAuthorizedException(
59
+ detail="Credentials expired",
60
+ )
61
+
62
+ user = self.user_handler(userid)
63
+ if user is None:
64
+ raise NotAuthorizedException(
65
+ detail="User not found",
66
+ )
67
+
68
+ return AuthenticationResult(user, None)
69
+
70
+ @staticmethod
71
+ def save(req: Request, user: T, expired_at: datetime) -> None:
72
+ req.session["userid"] = user.id
73
+ req.session["exp"] = expired_at.timestamp()
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable
4
+
5
+ from litestar.connection.base import UserT
6
+ from litestar.middleware import AbstractMiddleware
7
+ from litestar.types import ASGIApp, Message, Receive, Scope, Scopes, Send
8
+
9
+ STATE_SYSTEM_CONTROLLED_PROP_KEY = "system_controlled_properties"
10
+
11
+
12
+ class USCPMiddleware(AbstractMiddleware):
13
+ """
14
+ Middleware to update system-controlled properties in the request.
15
+
16
+ This middleware updates the `created_at`, `updated_at`, and `deleted_at` properties
17
+ of the request with the current timestamp. It is intended to be used in a Litestar
18
+ application.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ app: ASGIApp,
24
+ get_system_controlled_props: Callable[[UserT], dict],
25
+ skip_update_system_controlled_props: Callable[[UserT], bool],
26
+ exclude: str | list[str] | None = None,
27
+ exclude_opt_key: str | None = None,
28
+ scopes: Scopes | None = None,
29
+ ) -> None:
30
+ """Initialize the middleware.
31
+
32
+ Args:
33
+ app: The ``next`` ASGI app to call.
34
+ exclude: A pattern or list of patterns to match against a request's path.
35
+ If a match is found, the middleware will be skipped.
36
+ exclude_opt_key: An identifier that is set in the route handler
37
+ ``opt`` key which allows skipping the middleware.
38
+ scopes: ASGI scope types, should be a set including
39
+ either or both 'ScopeType.HTTP' and 'ScopeType.WEBSOCKET'.
40
+ """
41
+ super().__init__(app, exclude, exclude_opt_key, scopes)
42
+ self.get_system_controlled_props = get_system_controlled_props
43
+ self.skip_update_system_controlled_props = skip_update_system_controlled_props
44
+
45
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
46
+ user = scope["user"]
47
+ if self.skip_update_system_controlled_props(user):
48
+ scope["state"][STATE_SYSTEM_CONTROLLED_PROP_KEY] = None
49
+ else:
50
+ scope["state"][STATE_SYSTEM_CONTROLLED_PROP_KEY] = (
51
+ self.get_system_controlled_props(user)
52
+ )
53
+ await self.app(scope, receive, send)
54
+
55
+
56
+ def get_scp_from_user(user: UserT, fields: dict[str, str]) -> dict:
57
+ """Get system-controlled properties from the user.
58
+
59
+ Args:
60
+ user: The user object.
61
+ fields: A list of fields to include in the system-controlled properties.
62
+
63
+ Returns:
64
+ A dictionary containing the system-controlled properties.
65
+ """
66
+ return {
67
+ data_field: getattr(user, db_field) for data_field, db_field in fields.items()
68
+ }