sera-2 1.9.1__py3-none-any.whl → 1.11.1__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,
@@ -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
+ }
@@ -14,11 +14,9 @@ def make_python_api(app: App, collections: Sequence[DataCollection]):
14
14
  app.api.ensure_exists()
15
15
  app.api.pkg("routes").ensure_exists()
16
16
 
17
- # make routes & depdnencies
18
- dep_pkg = app.api.pkg("dependencies")
17
+ # make routes
19
18
  routes: list[Module] = []
20
19
  for collection in collections:
21
- make_dependency(collection, dep_pkg)
22
20
  route = app.api.pkg("routes").pkg(collection.get_pymodule_name())
23
21
 
24
22
  controllers = []
@@ -107,64 +105,20 @@ def make_main(target_pkg: Package, routes: Sequence[Module]):
107
105
  outmod.write(program)
108
106
 
109
107
 
110
- def make_dependency(collection: DataCollection, target_pkg: Package):
111
- """Generate dependency injection for the service."""
112
- app = target_pkg.app
113
-
114
- outmod = target_pkg.module(collection.get_pymodule_name())
115
- if outmod.exists():
116
- logger.info("`{}` already exists. Skip generation.", outmod.path)
117
- return
118
-
119
- ServiceNameDep = to_snake_case(f"{collection.name}ServiceDependency")
120
-
121
- program = Program()
122
- program.import_("__future__.annotations", True)
123
- program.import_(
124
- app.services.path
125
- + f".{collection.get_pymodule_name()}.{collection.get_service_name()}",
126
- True,
127
- )
128
-
129
- program.root(
130
- stmt.LineBreak(),
131
- lambda ast: ast.func(
132
- ServiceNameDep,
133
- [],
134
- expr.ExprIdent(collection.get_service_name()),
135
- is_async=True,
136
- )(
137
- lambda ast01: ast01.return_(
138
- expr.ExprFuncCall(expr.ExprIdent(collection.get_service_name()), [])
139
- )
140
- ),
141
- )
142
- outmod.write(program)
143
-
144
-
145
108
  def make_python_get_api(
146
109
  collection: DataCollection, target_pkg: Package
147
110
  ) -> tuple[Module, str]:
148
111
  """Make an endpoint for querying resources"""
149
112
  app = target_pkg.app
150
113
 
151
- ServiceNameDep = to_snake_case(f"{collection.name}ServiceDependency")
152
-
153
114
  program = Program()
154
115
  program.import_("__future__.annotations", True)
155
116
  program.import_("typing.Annotated", True)
156
- program.import_("typing.Sequence", True)
157
117
  program.import_("litestar.get", True)
158
118
  program.import_("litestar.Request", True)
159
119
  program.import_("litestar.params.Parameter", True)
160
- program.import_(app.models.db.path + ".base.get_session", True)
161
- program.import_("litestar.di.Provide", True)
162
120
  program.import_("sqlalchemy.orm.Session", True)
163
121
  program.import_(app.config.path + ".API_DEBUG", True)
164
- program.import_(
165
- f"{app.api.path}.dependencies.{collection.get_pymodule_name()}.{ServiceNameDep}",
166
- True,
167
- )
168
122
  program.import_(
169
123
  app.services.path
170
124
  + f".{collection.get_pymodule_name()}.{collection.get_service_name()}",
@@ -189,21 +143,6 @@ def make_python_get_api(
189
143
  expr.ExprIdent("get"),
190
144
  [
191
145
  expr.ExprConstant("/"),
192
- PredefinedFn.keyword_assignment(
193
- "dependencies",
194
- PredefinedFn.dict(
195
- [
196
- (
197
- expr.ExprConstant("service"),
198
- expr.ExprIdent(f"Provide({ServiceNameDep})"),
199
- ),
200
- (
201
- expr.ExprConstant("session"),
202
- expr.ExprIdent(f"Provide(get_session)"),
203
- ),
204
- ]
205
- ),
206
- ),
207
146
  ],
208
147
  )
209
148
  ),
@@ -250,10 +189,6 @@ def make_python_get_api(
250
189
  "request",
251
190
  expr.ExprIdent("Request"),
252
191
  ),
253
- DeferredVar.simple(
254
- "service",
255
- expr.ExprIdent(collection.get_service_name()),
256
- ),
257
192
  DeferredVar.simple(
258
193
  "session",
259
194
  expr.ExprIdent("Session"),
@@ -265,7 +200,17 @@ def make_python_get_api(
265
200
  stmt.SingleExprStatement(
266
201
  expr.ExprConstant("Retrieving records matched a query")
267
202
  ),
268
- lambda ast11: ast11.assign(
203
+ lambda ast100: ast100.assign(
204
+ DeferredVar.simple("service"),
205
+ expr.ExprFuncCall(
206
+ PredefinedFn.attr_getter(
207
+ expr.ExprIdent(collection.get_service_name()),
208
+ expr.ExprIdent("get_instance"),
209
+ ),
210
+ [],
211
+ ),
212
+ ),
213
+ lambda ast101: ast101.assign(
269
214
  DeferredVar.simple("query", expr.ExprIdent("ServiceQuery")),
270
215
  expr.ExprFuncCall(
271
216
  expr.ExprIdent("parse_query"),
@@ -279,10 +224,13 @@ def make_python_get_api(
279
224
  ],
280
225
  ),
281
226
  ),
282
- lambda ast12: ast12.assign(
227
+ lambda ast102: ast102.assign(
283
228
  DeferredVar.simple("result"),
284
229
  expr.ExprFuncCall(
285
- expr.ExprIdent("service.get"),
230
+ PredefinedFn.attr_getter(
231
+ expr.ExprIdent("service"),
232
+ expr.ExprIdent("get"),
233
+ ),
286
234
  [
287
235
  expr.ExprIdent("query"),
288
236
  PredefinedFn.keyword_assignment(
@@ -309,7 +257,7 @@ def make_python_get_api(
309
257
  ],
310
258
  ),
311
259
  ),
312
- lambda ast13: ast13.return_(
260
+ lambda ast103: ast103.return_(
313
261
  PredefinedFn.dict(
314
262
  [
315
263
  (
@@ -359,13 +307,7 @@ def make_python_get_by_id_api(
359
307
  program.import_("litestar.get", True)
360
308
  program.import_("litestar.status_codes", True)
361
309
  program.import_("litestar.exceptions.HTTPException", True)
362
- program.import_("litestar.di.Provide", True)
363
310
  program.import_("sqlalchemy.orm.Session", True)
364
- program.import_(app.models.db.path + ".base.get_session", True)
365
- program.import_(
366
- f"{app.api.path}.dependencies.{collection.get_pymodule_name()}.{ServiceNameDep}",
367
- True,
368
- )
369
311
  program.import_(
370
312
  app.services.path
371
313
  + f".{collection.get_pymodule_name()}.{collection.get_service_name()}",
@@ -388,21 +330,6 @@ def make_python_get_by_id_api(
388
330
  expr.ExprIdent("get"),
389
331
  [
390
332
  expr.ExprConstant("/{id:%s}" % id_type),
391
- PredefinedFn.keyword_assignment(
392
- "dependencies",
393
- PredefinedFn.dict(
394
- [
395
- (
396
- expr.ExprConstant("service"),
397
- expr.ExprIdent(f"Provide({ServiceNameDep})"),
398
- ),
399
- (
400
- expr.ExprConstant("session"),
401
- expr.ExprIdent(f"Provide(get_session)"),
402
- ),
403
- ]
404
- ),
405
- ),
406
333
  ],
407
334
  )
408
335
  ),
@@ -413,10 +340,6 @@ def make_python_get_by_id_api(
413
340
  "id",
414
341
  expr.ExprIdent(id_type),
415
342
  ),
416
- DeferredVar.simple(
417
- "service",
418
- expr.ExprIdent(collection.get_service_name()),
419
- ),
420
343
  DeferredVar.simple(
421
344
  "session",
422
345
  expr.ExprIdent("Session"),
@@ -426,6 +349,16 @@ def make_python_get_by_id_api(
426
349
  is_async=True,
427
350
  )(
428
351
  stmt.SingleExprStatement(expr.ExprConstant("Retrieving record by id")),
352
+ lambda ast100: ast100.assign(
353
+ DeferredVar.simple("service"),
354
+ expr.ExprFuncCall(
355
+ PredefinedFn.attr_getter(
356
+ expr.ExprIdent(collection.get_service_name()),
357
+ expr.ExprIdent("get_instance"),
358
+ ),
359
+ [],
360
+ ),
361
+ ),
429
362
  lambda ast11: ast11.assign(
430
363
  DeferredVar.simple("record"),
431
364
  expr.ExprFuncCall(
@@ -483,22 +416,12 @@ def make_python_has_api(
483
416
  program.import_("litestar.head", True)
484
417
  program.import_("litestar.status_codes", True)
485
418
  program.import_("litestar.exceptions.HTTPException", True)
486
- program.import_("litestar.di.Provide", True)
487
419
  program.import_("sqlalchemy.orm.Session", True)
488
- program.import_(app.models.db.path + ".base.get_session", True)
489
- program.import_(
490
- f"{app.api.path}.dependencies.{collection.get_pymodule_name()}.{ServiceNameDep}",
491
- True,
492
- )
493
420
  program.import_(
494
421
  app.services.path
495
422
  + f".{collection.get_pymodule_name()}.{collection.get_service_name()}",
496
423
  True,
497
424
  )
498
- program.import_(
499
- app.models.data.path + f".{collection.get_pymodule_name()}.{collection.name}",
500
- True,
501
- )
502
425
 
503
426
  # assuming the collection has only one class
504
427
  cls = collection.cls
@@ -516,21 +439,6 @@ def make_python_has_api(
516
439
  "status_code",
517
440
  expr.ExprIdent("status_codes.HTTP_204_NO_CONTENT"),
518
441
  ),
519
- PredefinedFn.keyword_assignment(
520
- "dependencies",
521
- PredefinedFn.dict(
522
- [
523
- (
524
- expr.ExprConstant("service"),
525
- expr.ExprIdent(f"Provide({ServiceNameDep})"),
526
- ),
527
- (
528
- expr.ExprConstant("session"),
529
- expr.ExprIdent(f"Provide(get_session)"),
530
- ),
531
- ]
532
- ),
533
- ),
534
442
  ],
535
443
  )
536
444
  ),
@@ -541,10 +449,6 @@ def make_python_has_api(
541
449
  "id",
542
450
  expr.ExprIdent(id_type),
543
451
  ),
544
- DeferredVar.simple(
545
- "service",
546
- expr.ExprIdent(collection.get_service_name()),
547
- ),
548
452
  DeferredVar.simple(
549
453
  "session",
550
454
  expr.ExprIdent("Session"),
@@ -554,6 +458,16 @@ def make_python_has_api(
554
458
  is_async=True,
555
459
  )(
556
460
  stmt.SingleExprStatement(expr.ExprConstant("Retrieving record by id")),
461
+ lambda ast100: ast100.assign(
462
+ DeferredVar.simple("service"),
463
+ expr.ExprFuncCall(
464
+ PredefinedFn.attr_getter(
465
+ expr.ExprIdent(collection.get_service_name()),
466
+ expr.ExprIdent("get_instance"),
467
+ ),
468
+ [],
469
+ ),
470
+ ),
557
471
  lambda ast11: ast11.assign(
558
472
  DeferredVar.simple("record_exist"),
559
473
  expr.ExprFuncCall(
@@ -595,27 +509,15 @@ def make_python_create_api(collection: DataCollection, target_pkg: Package):
595
509
  """Make an endpoint for creating a resource"""
596
510
  app = target_pkg.app
597
511
 
598
- ServiceNameDep = to_snake_case(f"{collection.name}ServiceDependency")
599
-
600
512
  program = Program()
601
513
  program.import_("__future__.annotations", True)
602
514
  program.import_("litestar.post", True)
603
- program.import_("litestar.di.Provide", True)
604
515
  program.import_("sqlalchemy.orm.Session", True)
605
- program.import_(app.models.db.path + ".base.get_session", True)
606
- program.import_(
607
- f"{app.api.path}.dependencies.{collection.get_pymodule_name()}.{ServiceNameDep}",
608
- True,
609
- )
610
516
  program.import_(
611
517
  app.services.path
612
518
  + f".{collection.get_pymodule_name()}.{collection.get_service_name()}",
613
519
  True,
614
520
  )
615
- program.import_(
616
- app.models.data.path + f".{collection.get_pymodule_name()}.{collection.name}",
617
- True,
618
- )
619
521
  program.import_(
620
522
  app.models.data.path
621
523
  + f".{collection.get_pymodule_name()}.Upsert{collection.name}",
@@ -624,7 +526,13 @@ def make_python_create_api(collection: DataCollection, target_pkg: Package):
624
526
 
625
527
  # assuming the collection has only one class
626
528
  cls = collection.cls
627
- id_type = assert_not_null(cls.get_id_property()).datatype.get_python_type().type
529
+ has_system_controlled_prop = any(
530
+ prop.data.is_system_controlled for prop in cls.properties.values()
531
+ )
532
+ idprop = assert_not_null(cls.get_id_property())
533
+
534
+ if has_system_controlled_prop:
535
+ program.import_("sera.libs.api_helper.SingleAutoUSCP", True)
628
536
 
629
537
  func_name = "create"
630
538
 
@@ -635,22 +543,20 @@ def make_python_create_api(collection: DataCollection, target_pkg: Package):
635
543
  expr.ExprIdent("post"),
636
544
  [
637
545
  expr.ExprConstant("/"),
638
- PredefinedFn.keyword_assignment(
639
- "dependencies",
640
- PredefinedFn.dict(
641
- [
642
- (
643
- expr.ExprConstant("service"),
644
- expr.ExprIdent(f"Provide({ServiceNameDep})"),
645
- ),
646
- (
647
- expr.ExprConstant("session"),
648
- expr.ExprIdent(f"Provide(get_session)"),
649
- ),
650
- ]
651
- ),
652
- ),
653
- ],
546
+ ]
547
+ + (
548
+ [
549
+ PredefinedFn.keyword_assignment(
550
+ "dto",
551
+ PredefinedFn.item_getter(
552
+ expr.ExprIdent("SingleAutoUSCP"),
553
+ expr.ExprIdent(f"Upsert{cls.name}"),
554
+ ),
555
+ )
556
+ ]
557
+ if has_system_controlled_prop
558
+ else []
559
+ ),
654
560
  )
655
561
  ),
656
562
  lambda ast10: ast10.func(
@@ -660,35 +566,36 @@ def make_python_create_api(collection: DataCollection, target_pkg: Package):
660
566
  "data",
661
567
  expr.ExprIdent(f"Upsert{cls.name}"),
662
568
  ),
663
- DeferredVar.simple(
664
- "service",
665
- expr.ExprIdent(collection.get_service_name()),
666
- ),
667
569
  DeferredVar.simple(
668
570
  "session",
669
571
  expr.ExprIdent("Session"),
670
572
  ),
671
573
  ],
672
- return_type=expr.ExprIdent(cls.name),
574
+ return_type=expr.ExprIdent(idprop.datatype.get_python_type().type),
673
575
  is_async=True,
674
576
  )(
675
577
  stmt.SingleExprStatement(expr.ExprConstant("Creating new record")),
578
+ lambda ast100: ast100.assign(
579
+ DeferredVar.simple("service"),
580
+ expr.ExprFuncCall(
581
+ PredefinedFn.attr_getter(
582
+ expr.ExprIdent(collection.get_service_name()),
583
+ expr.ExprIdent("get_instance"),
584
+ ),
585
+ [],
586
+ ),
587
+ ),
676
588
  lambda ast13: ast13.return_(
677
- expr.ExprMethodCall(
678
- expr.ExprIdent(cls.name),
679
- "from_db",
680
- [
681
- expr.ExprMethodCall(
682
- expr.ExprIdent("service"),
683
- "create",
684
- [
685
- expr.ExprMethodCall(
686
- expr.ExprIdent("data"), "to_db", []
687
- ),
688
- expr.ExprIdent("session"),
689
- ],
690
- )
691
- ],
589
+ PredefinedFn.attr_getter(
590
+ expr.ExprMethodCall(
591
+ expr.ExprIdent("service"),
592
+ "create",
593
+ [
594
+ expr.ExprMethodCall(expr.ExprIdent("data"), "to_db", []),
595
+ expr.ExprIdent("session"),
596
+ ],
597
+ ),
598
+ expr.ExprIdent(idprop.name),
692
599
  )
693
600
  ),
694
601
  ),
@@ -704,27 +611,15 @@ def make_python_update_api(collection: DataCollection, target_pkg: Package):
704
611
  """Make an endpoint for updating resource"""
705
612
  app = target_pkg.app
706
613
 
707
- ServiceNameDep = to_snake_case(f"{collection.name}ServiceDependency")
708
-
709
614
  program = Program()
710
615
  program.import_("__future__.annotations", True)
711
616
  program.import_("litestar.put", True)
712
- program.import_("litestar.di.Provide", True)
713
617
  program.import_("sqlalchemy.orm.Session", True)
714
- program.import_(app.models.db.path + ".base.get_session", True)
715
- program.import_(
716
- f"{app.api.path}.dependencies.{collection.get_pymodule_name()}.{ServiceNameDep}",
717
- True,
718
- )
719
618
  program.import_(
720
619
  app.services.path
721
620
  + f".{collection.get_pymodule_name()}.{collection.get_service_name()}",
722
621
  True,
723
622
  )
724
- program.import_(
725
- app.models.data.path + f".{collection.get_pymodule_name()}.{collection.name}",
726
- True,
727
- )
728
623
  program.import_(
729
624
  app.models.data.path
730
625
  + f".{collection.get_pymodule_name()}.Upsert{collection.name}",
@@ -736,6 +631,12 @@ def make_python_update_api(collection: DataCollection, target_pkg: Package):
736
631
  id_prop = assert_not_null(cls.get_id_property())
737
632
  id_type = id_prop.datatype.get_python_type().type
738
633
 
634
+ has_system_controlled_prop = any(
635
+ prop.data.is_system_controlled for prop in cls.properties.values()
636
+ )
637
+ if has_system_controlled_prop:
638
+ program.import_("sera.libs.api_helper.SingleAutoUSCP", True)
639
+
739
640
  func_name = "update"
740
641
 
741
642
  program.root(
@@ -745,22 +646,20 @@ def make_python_update_api(collection: DataCollection, target_pkg: Package):
745
646
  expr.ExprIdent("put"),
746
647
  [
747
648
  expr.ExprConstant("/{id:%s}" % id_type),
748
- PredefinedFn.keyword_assignment(
749
- "dependencies",
750
- PredefinedFn.dict(
751
- [
752
- (
753
- expr.ExprConstant("service"),
754
- expr.ExprIdent(f"Provide({ServiceNameDep})"),
755
- ),
756
- (
757
- expr.ExprConstant("session"),
758
- expr.ExprIdent(f"Provide(get_session)"),
759
- ),
760
- ]
761
- ),
762
- ),
763
- ],
649
+ ]
650
+ + (
651
+ [
652
+ PredefinedFn.keyword_assignment(
653
+ "dto",
654
+ PredefinedFn.item_getter(
655
+ expr.ExprIdent("SingleAutoUSCP"),
656
+ expr.ExprIdent(f"Upsert{cls.name}"),
657
+ ),
658
+ )
659
+ ]
660
+ if has_system_controlled_prop
661
+ else []
662
+ ),
764
663
  )
765
664
  ),
766
665
  lambda ast10: ast10.func(
@@ -774,19 +673,25 @@ def make_python_update_api(collection: DataCollection, target_pkg: Package):
774
673
  "data",
775
674
  expr.ExprIdent(f"Upsert{cls.name}"),
776
675
  ),
777
- DeferredVar.simple(
778
- "service",
779
- expr.ExprIdent(collection.get_service_name()),
780
- ),
781
676
  DeferredVar.simple(
782
677
  "session",
783
678
  expr.ExprIdent("Session"),
784
679
  ),
785
680
  ],
786
- return_type=expr.ExprIdent(cls.name),
681
+ return_type=expr.ExprIdent(id_prop.datatype.get_python_type().type),
787
682
  is_async=True,
788
683
  )(
789
684
  stmt.SingleExprStatement(expr.ExprConstant("Update an existing record")),
685
+ lambda ast100: ast100.assign(
686
+ DeferredVar.simple("service"),
687
+ expr.ExprFuncCall(
688
+ PredefinedFn.attr_getter(
689
+ expr.ExprIdent(collection.get_service_name()),
690
+ expr.ExprIdent("get_instance"),
691
+ ),
692
+ [],
693
+ ),
694
+ ),
790
695
  stmt.SingleExprStatement(
791
696
  PredefinedFn.attr_setter(
792
697
  expr.ExprIdent("data"),
@@ -795,21 +700,16 @@ def make_python_update_api(collection: DataCollection, target_pkg: Package):
795
700
  )
796
701
  ),
797
702
  lambda ast13: ast13.return_(
798
- expr.ExprMethodCall(
799
- expr.ExprIdent(cls.name),
800
- "from_db",
801
- [
802
- expr.ExprMethodCall(
803
- expr.ExprIdent("service"),
804
- "update",
805
- [
806
- expr.ExprMethodCall(
807
- expr.ExprIdent("data"), "to_db", []
808
- ),
809
- expr.ExprIdent("session"),
810
- ],
811
- )
812
- ],
703
+ PredefinedFn.attr_getter(
704
+ expr.ExprMethodCall(
705
+ expr.ExprIdent("service"),
706
+ "update",
707
+ [
708
+ expr.ExprMethodCall(expr.ExprIdent("data"), "to_db", []),
709
+ expr.ExprIdent("session"),
710
+ ],
711
+ ),
712
+ expr.ExprIdent(id_prop.name),
813
713
  )
814
714
  ),
815
715
  ),
@@ -190,6 +190,37 @@ def make_python_data_model(
190
190
  True,
191
191
  alias=f"{cls.name}DB",
192
192
  )
193
+
194
+ has_system_controlled = any(
195
+ prop.data.is_system_controlled for prop in cls.properties.values()
196
+ )
197
+ if has_system_controlled:
198
+ program.import_("typing.TypedDict", True)
199
+ program.root(
200
+ stmt.LineBreak(),
201
+ lambda ast: ast.class_(
202
+ "SystemControlledProps",
203
+ [expr.ExprIdent("TypedDict")],
204
+ )(
205
+ *[
206
+ stmt.DefClassVarStatement(
207
+ prop.name,
208
+ (
209
+ prop.get_data_model_datatype().get_python_type().type
210
+ if isinstance(prop, DataProperty)
211
+ else assert_not_null(prop.target.get_id_property())
212
+ .get_data_model_datatype()
213
+ .get_python_type()
214
+ .type
215
+ ),
216
+ )
217
+ for prop in cls.properties.values()
218
+ if prop.data.is_system_controlled
219
+ ],
220
+ ),
221
+ )
222
+
223
+ program.root.linebreak()
193
224
  cls_ast = program.root.class_(
194
225
  "Upsert" + cls.name,
195
226
  [expr.ExprIdent("msgspec.Struct"), expr.ExprIdent("kw_only=True")],
@@ -270,6 +301,69 @@ def make_python_data_model(
270
301
  # has_to_db = True
271
302
  # if any(prop for prop in cls.properties.values() if isinstance(prop, ObjectProperty) and prop.cardinality == Cardinality.MANY_TO_MANY):
272
303
  # # if the class has many-to-many relationship, we need to
304
+
305
+ if has_system_controlled:
306
+ program.import_("typing.Optional", True)
307
+ cls_ast(
308
+ stmt.LineBreak(),
309
+ stmt.Comment(
310
+ "_verified is a special marker to indicate whether the data is updated by the system."
311
+ ),
312
+ stmt.DefClassVarStatement(
313
+ "_verified", "bool", expr.ExprConstant(False)
314
+ ),
315
+ stmt.LineBreak(),
316
+ lambda ast: ast.func(
317
+ "__post_init__",
318
+ [
319
+ DeferredVar.simple("self"),
320
+ ],
321
+ )(
322
+ stmt.AssignStatement(
323
+ PredefinedFn.attr_getter(
324
+ expr.ExprIdent("self"), expr.ExprIdent("_verified")
325
+ ),
326
+ expr.ExprConstant(False),
327
+ )
328
+ ),
329
+ stmt.LineBreak(),
330
+ lambda ast: ast.func(
331
+ "update_system_controlled_props",
332
+ [
333
+ DeferredVar.simple("self"),
334
+ DeferredVar.simple(
335
+ "data",
336
+ expr.ExprIdent("Optional[SystemControlledProps]"),
337
+ ),
338
+ ],
339
+ )(
340
+ lambda ast00: ast00.if_(
341
+ expr.ExprNegation(
342
+ expr.ExprIs(expr.ExprIdent("data"), expr.ExprConstant(None))
343
+ ),
344
+ )(
345
+ *[
346
+ stmt.AssignStatement(
347
+ PredefinedFn.attr_getter(
348
+ expr.ExprIdent("self"), expr.ExprIdent(prop.name)
349
+ ),
350
+ PredefinedFn.item_getter(
351
+ expr.ExprIdent("data"), expr.ExprConstant(prop.name)
352
+ ),
353
+ )
354
+ for prop in cls.properties.values()
355
+ if prop.data.is_system_controlled
356
+ ]
357
+ ),
358
+ stmt.AssignStatement(
359
+ PredefinedFn.attr_getter(
360
+ expr.ExprIdent("self"), expr.ExprIdent("_verified")
361
+ ),
362
+ expr.ExprConstant(True),
363
+ ),
364
+ ),
365
+ )
366
+
273
367
  cls_ast(
274
368
  stmt.LineBreak(),
275
369
  lambda ast00: ast00.func(
@@ -281,6 +375,18 @@ def make_python_data_model(
281
375
  f"{cls.name}DB" if cls.db is not None else cls.name
282
376
  ),
283
377
  )(
378
+ (
379
+ stmt.AssertionStatement(
380
+ PredefinedFn.attr_getter(
381
+ expr.ExprIdent("self"), expr.ExprIdent("_verified")
382
+ ),
383
+ expr.ExprConstant(
384
+ "The model data must be verified before converting to db model"
385
+ ),
386
+ )
387
+ if has_system_controlled
388
+ else None
389
+ ),
284
390
  lambda ast10: ast10.return_(
285
391
  expr.ExprFuncCall(
286
392
  expr.ExprIdent(
@@ -293,7 +399,7 @@ def make_python_data_model(
293
399
  for prop in cls.properties.values()
294
400
  ],
295
401
  )
296
- )
402
+ ),
297
403
  ),
298
404
  )
299
405
 
@@ -423,6 +529,7 @@ def make_python_relational_model(
423
529
  # assume configuration for the app at the top level
424
530
  program.import_(f"{app.config.path}.DB_CONNECTION", True)
425
531
  program.import_(f"{app.config.path}.DB_DEBUG", True)
532
+ program.import_(f"contextlib.contextmanager", True)
426
533
 
427
534
  program.root.linebreak()
428
535
 
@@ -484,7 +591,15 @@ def make_python_relational_model(
484
591
  )
485
592
 
486
593
  program.root.linebreak()
487
- program.root.func("get_session", [], is_async=True)(
594
+ program.root.func("async_get_session", [], is_async=True)(
595
+ lambda ast00: ast00.python_stmt("with Session(engine) as session:")(
596
+ lambda ast01: ast01.python_stmt("yield session")
597
+ )
598
+ )
599
+
600
+ program.root.linebreak()
601
+ program.root.python_stmt("@contextmanager")
602
+ program.root.func("get_session", [])(
488
603
  lambda ast00: ast00.python_stmt("with Session(engine) as session:")(
489
604
  lambda ast01: ast01.python_stmt("yield session")
490
605
  )
sera/models/_parse.py CHANGED
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
  from typing import Sequence
7
7
 
8
8
  import serde.yaml
9
+
9
10
  from sera.models._class import Class, ClassDBMapInfo, Index
10
11
  from sera.models._constraints import Constraint, predefined_constraints
11
12
  from sera.models._datatype import (
@@ -101,22 +102,24 @@ def _parse_property(
101
102
  schema: Schema, prop_name: str, prop: dict
102
103
  ) -> DataProperty | ObjectProperty:
103
104
  if isinstance(prop, str):
104
- datatype = prop
105
- if datatype in schema.classes:
106
- return ObjectProperty(
107
- name=prop_name,
108
- label=_parse_multi_lingual_string(prop_name),
109
- description=_parse_multi_lingual_string(""),
110
- target=schema.classes[datatype],
111
- cardinality=Cardinality.ONE_TO_ONE,
112
- )
113
- else:
114
- return DataProperty(
115
- name=prop_name,
116
- label=_parse_multi_lingual_string(prop_name),
117
- description=_parse_multi_lingual_string(""),
118
- datatype=_parse_datatype(schema, datatype),
119
- )
105
+ # deprecated
106
+ assert False, prop
107
+ # datatype = prop
108
+ # if datatype in schema.classes:
109
+ # return ObjectProperty(
110
+ # name=prop_name,
111
+ # label=_parse_multi_lingual_string(prop_name),
112
+ # description=_parse_multi_lingual_string(""),
113
+ # target=schema.classes[datatype],
114
+ # cardinality=Cardinality.ONE_TO_ONE,
115
+ # )
116
+ # else:
117
+ # return DataProperty(
118
+ # name=prop_name,
119
+ # label=_parse_multi_lingual_string(prop_name),
120
+ # description=_parse_multi_lingual_string(""),
121
+ # datatype=_parse_datatype(schema, datatype),
122
+ # )
120
123
 
121
124
  db = prop.get("db", {})
122
125
  _data = prop.get("data", {})
@@ -128,6 +131,7 @@ def _parse_property(
128
131
  constraints=[
129
132
  _parse_constraint(constraint) for constraint in _data.get("constraints", [])
130
133
  ],
134
+ is_system_controlled=prop.get("is_system_controlled", False),
131
135
  )
132
136
 
133
137
  assert isinstance(prop, dict), prop
sera/models/_property.py CHANGED
@@ -71,6 +71,9 @@ class PropDataAttrs:
71
71
  # list of constraints applied to the data model's field
72
72
  constraints: list[Constraint] = field(default_factory=list)
73
73
 
74
+ # whether this property is controlled by the system or not
75
+ is_system_controlled: bool = False
76
+
74
77
 
75
78
  @dataclass(kw_only=True)
76
79
  class Property:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sera-2
3
- Version: 1.9.1
3
+ Version: 1.11.1
4
4
  Summary:
5
5
  Author: Binh Vu
6
6
  Author-email: bvu687@gmail.com
@@ -1,16 +1,19 @@
1
1
  sera/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  sera/constants.py,sha256=mzAaMyIx8TJK0-RYYJ5I24C4s0Uvj26OLMJmBo0pxHI,123
3
3
  sera/libs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- sera/libs/api_helper.py,sha256=hUEy0INHM18lxTQ348tgbXNceOHcjiAnqmuL_8CRpLQ,2509
4
+ sera/libs/api_helper.py,sha256=47y1kcwk3Xd2ZEMnUj_0OwCuUmgwOs5kYrE95BDVUn4,5411
5
5
  sera/libs/base_orm.py,sha256=dyh0OT2sbHku5qPJXvRzYAHRTSXvvbQaS-Qwg65Bw04,2918
6
- sera/libs/base_service.py,sha256=l5D4IjxIiz8LBRranUYddb8J0Y6SwSyetKYTLrCUdQA,4098
6
+ sera/libs/base_service.py,sha256=tKvYyCNIBLUbcZUp0EbrU9T2vHSRu_oISbkPECjZEfo,4613
7
7
  sera/libs/dag/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  sera/libs/dag/_dag.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ sera/libs/middlewares/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ sera/libs/middlewares/auth.py,sha256=aH2fJshQpJT_dw3lUr67doZ8zdf90XyakdY35qZ1iZs,2443
11
+ sera/libs/middlewares/uscp.py,sha256=H5umW8iEQSCdb_MJ5Im49kxg1E7TpxSg1p2_2A5zI1U,2600
9
12
  sera/make/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
13
  sera/make/__main__.py,sha256=G5O7s6135-708honwqMFn2yPTs06WbGQTHpupID0eZ4,1417
11
14
  sera/make/make_app.py,sha256=n9NtW73O3s_5Q31VHIRmnd-jEIcpDO7ksAsOdovde2s,5999
12
- sera/make/make_python_api.py,sha256=FcklRwGDGTKPjuhbHKyEog8PYPACi-5DA_nB1ZgxeJU,29424
13
- sera/make/make_python_model.py,sha256=v5edXKMTQ2XAYDoM7fBONx3S9H6DRUPQp9UwWWUowLg,36594
15
+ sera/make/make_python_api.py,sha256=kq5DClmEeeNgg-a3Bb_8GN9jxvjnhjmW3RfBHNzynO8,25407
16
+ sera/make/make_python_model.py,sha256=UWI-HbpFjt8n5yLAv2oGQK68LgrF-m0BUI334qtB9Mk,41228
14
17
  sera/make/make_python_services.py,sha256=RsinYZdfkrTlTn9CT50VgqGs9w6IZawsJx-KEmqfnEY,2062
15
18
  sera/make/make_typescript_model.py,sha256=fMjz4YxdGHijb2pHI4xj7rKJf1PxyJxVLYW0ivsu70c,50128
16
19
  sera/misc/__init__.py,sha256=Dh4uDq0D4N53h3zhvmwfa5a0TPVRSUvLzb0hkFuPirk,411
@@ -25,10 +28,10 @@ sera/models/_default.py,sha256=ABggW6qdPR4ZDqIPJdJ0GCGQ-7kfsfZmQ_DchgZEa-I,137
25
28
  sera/models/_enum.py,sha256=sy0q7E646F-APsqrVQ52r1fAQ_DCAeaNq5YM5QN3zIk,2070
26
29
  sera/models/_module.py,sha256=8QRSCubZmdDP9rL58rGAS6X5VCrkc1ZHvuMu1I1KrWk,5043
27
30
  sera/models/_multi_lingual_string.py,sha256=JETN6k00VH4wrA4w5vAHMEJV8fp3SY9bJebskFTjQLA,1186
28
- sera/models/_parse.py,sha256=sJYfQtwek96ltpgxExG4xUbiLnU3qvNYhTP1CeyXGjs,9746
29
- sera/models/_property.py,sha256=CmEmgOShtSyNFq05YW3tGupwCIVRzPMKudXWld8utPk,5530
31
+ sera/models/_parse.py,sha256=uw6fvvh1ucGqE2jFTCCr-e6_qMfZfSVpaPolNxmrHww,9897
32
+ sera/models/_property.py,sha256=SJSm5fZJimd2rQuL4UH_aZuNyp9v7x64xMbEVbtYx8Q,5633
30
33
  sera/models/_schema.py,sha256=r-Gqg9Lb_wR3UrbNvfXXgt_qs5bts0t2Ve7aquuF_OI,1155
31
34
  sera/typing.py,sha256=Q4QMfbtfrCjC9tFfsZPhsAnbNX4lm4NHQ9lmjNXYdV0,772
32
- sera_2-1.9.1.dist-info/METADATA,sha256=Xg1hqLX0htdcQaJxgMUXAr4e_DAiDp3BiyTXXtIfHpg,856
33
- sera_2-1.9.1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
34
- sera_2-1.9.1.dist-info/RECORD,,
35
+ sera_2-1.11.1.dist-info/METADATA,sha256=vymmUgXvGuXjkCDXFhPc0Y8ZsM-Kwb7cBBLLy4I10ss,857
36
+ sera_2-1.11.1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
37
+ sera_2-1.11.1.dist-info/RECORD,,