sera-2 1.21.2__tar.gz → 1.23.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.
Files changed (50) hide show
  1. {sera_2-1.21.2 → sera_2-1.23.0}/PKG-INFO +2 -2
  2. {sera_2-1.21.2 → sera_2-1.23.0}/pyproject.toml +2 -2
  3. sera_2-1.23.0/sera/libs/api_helper.py +90 -0
  4. {sera_2-1.21.2 → sera_2-1.23.0}/sera/libs/api_test_helper.py +1 -1
  5. sera_2-1.23.0/sera/libs/base_service.py +264 -0
  6. sera_2-1.23.0/sera/libs/search_helper.py +359 -0
  7. {sera_2-1.21.2 → sera_2-1.23.0}/sera/make/make_python_api.py +65 -113
  8. {sera_2-1.21.2 → sera_2-1.23.0}/sera/make/make_python_model.py +184 -17
  9. {sera_2-1.21.2 → sera_2-1.23.0}/sera/make/make_python_services.py +3 -2
  10. {sera_2-1.21.2 → sera_2-1.23.0}/sera/make/make_typescript_model.py +21 -2
  11. {sera_2-1.21.2 → sera_2-1.23.0}/sera/misc/__init__.py +2 -0
  12. {sera_2-1.21.2 → sera_2-1.23.0}/sera/misc/_utils.py +17 -2
  13. {sera_2-1.21.2 → sera_2-1.23.0}/sera/models/_class.py +2 -2
  14. {sera_2-1.21.2 → sera_2-1.23.0}/sera/models/_collection.py +15 -11
  15. {sera_2-1.21.2 → sera_2-1.23.0}/sera/models/_constraints.py +1 -1
  16. {sera_2-1.21.2 → sera_2-1.23.0}/sera/models/_datatype.py +8 -29
  17. {sera_2-1.21.2 → sera_2-1.23.0}/sera/models/_enum.py +2 -2
  18. {sera_2-1.21.2 → sera_2-1.23.0}/sera/models/_module.py +7 -0
  19. sera_2-1.21.2/sera/libs/api_helper.py +0 -189
  20. sera_2-1.21.2/sera/libs/base_service.py +0 -176
  21. {sera_2-1.21.2 → sera_2-1.23.0}/README.md +0 -0
  22. {sera_2-1.21.2 → sera_2-1.23.0}/sera/__init__.py +0 -0
  23. {sera_2-1.21.2 → sera_2-1.23.0}/sera/constants.py +0 -0
  24. {sera_2-1.21.2 → sera_2-1.23.0}/sera/exports/__init__.py +0 -0
  25. {sera_2-1.21.2 → sera_2-1.23.0}/sera/exports/schema.py +0 -0
  26. {sera_2-1.21.2 → sera_2-1.23.0}/sera/exports/test.py +0 -0
  27. {sera_2-1.21.2 → sera_2-1.23.0}/sera/libs/__init__.py +0 -0
  28. {sera_2-1.21.2 → sera_2-1.23.0}/sera/libs/base_orm.py +0 -0
  29. {sera_2-1.21.2 → sera_2-1.23.0}/sera/libs/directed_computing_graph/__init__.py +0 -0
  30. {sera_2-1.21.2 → sera_2-1.23.0}/sera/libs/directed_computing_graph/_dcg.py +0 -0
  31. {sera_2-1.21.2 → sera_2-1.23.0}/sera/libs/directed_computing_graph/_edge.py +0 -0
  32. {sera_2-1.21.2 → sera_2-1.23.0}/sera/libs/directed_computing_graph/_flow.py +0 -0
  33. {sera_2-1.21.2 → sera_2-1.23.0}/sera/libs/directed_computing_graph/_fn_signature.py +0 -0
  34. {sera_2-1.21.2 → sera_2-1.23.0}/sera/libs/directed_computing_graph/_node.py +0 -0
  35. {sera_2-1.21.2 → sera_2-1.23.0}/sera/libs/directed_computing_graph/_runtime.py +0 -0
  36. {sera_2-1.21.2 → sera_2-1.23.0}/sera/libs/directed_computing_graph/_type_conversion.py +0 -0
  37. {sera_2-1.21.2 → sera_2-1.23.0}/sera/libs/middlewares/__init__.py +0 -0
  38. {sera_2-1.21.2 → sera_2-1.23.0}/sera/libs/middlewares/auth.py +0 -0
  39. {sera_2-1.21.2 → sera_2-1.23.0}/sera/libs/middlewares/uscp.py +0 -0
  40. {sera_2-1.21.2 → sera_2-1.23.0}/sera/make/__init__.py +0 -0
  41. {sera_2-1.21.2 → sera_2-1.23.0}/sera/make/__main__.py +0 -0
  42. {sera_2-1.21.2 → sera_2-1.23.0}/sera/make/make_app.py +0 -0
  43. {sera_2-1.21.2 → sera_2-1.23.0}/sera/misc/_formatter.py +0 -0
  44. {sera_2-1.21.2 → sera_2-1.23.0}/sera/models/__init__.py +0 -0
  45. {sera_2-1.21.2 → sera_2-1.23.0}/sera/models/_default.py +0 -0
  46. {sera_2-1.21.2 → sera_2-1.23.0}/sera/models/_multi_lingual_string.py +0 -0
  47. {sera_2-1.21.2 → sera_2-1.23.0}/sera/models/_parse.py +0 -0
  48. {sera_2-1.21.2 → sera_2-1.23.0}/sera/models/_property.py +0 -0
  49. {sera_2-1.21.2 → sera_2-1.23.0}/sera/models/_schema.py +0 -0
  50. {sera_2-1.21.2 → sera_2-1.23.0}/sera/typing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sera-2
3
- Version: 1.21.2
3
+ Version: 1.23.0
4
4
  Summary:
5
5
  Author: Binh Vu
6
6
  Author-email: bvu687@gmail.com
@@ -9,7 +9,7 @@ Classifier: Programming Language :: Python :: 3
9
9
  Classifier: Programming Language :: Python :: 3.12
10
10
  Classifier: Programming Language :: Python :: 3.13
11
11
  Requires-Dist: black (==25.1.0)
12
- Requires-Dist: codegen-2 (>=2.12.2,<3.0.0)
12
+ Requires-Dist: codegen-2 (>=2.14.0,<3.0.0)
13
13
  Requires-Dist: graph-wrapper (>=1.7.3,<2.0.0)
14
14
  Requires-Dist: isort (==6.0.1)
15
15
  Requires-Dist: litestar (>=2.15.1,<3.0.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "sera-2"
3
- version = "1.21.2"
3
+ version = "1.23.0"
4
4
  description = ""
5
5
  authors = ["Binh Vu <bvu687@gmail.com>"]
6
6
  readme = "README.md"
@@ -9,7 +9,7 @@ repository = "https://github.com/binh-vu/sera"
9
9
 
10
10
  [tool.poetry.dependencies]
11
11
  python = "^3.12"
12
- codegen-2 = "^2.12.2"
12
+ codegen-2 = "^2.14.0"
13
13
  msgspec = "^0.19.0"
14
14
  litestar = "^2.15.1"
15
15
  loguru = "^0.7.0"
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Collection, Generic, TypeVar, cast
4
+
5
+ from litestar.connection import ASGIConnection
6
+ from litestar.dto import MsgspecDTO
7
+ from litestar.dto._backend import DTOBackend
8
+ from litestar.dto._codegen_backend import DTOCodegenBackend
9
+ from litestar.enums import RequestEncodingType
10
+ from litestar.serialization import decode_json, decode_msgpack
11
+ from litestar.typing import FieldDefinition
12
+ from msgspec import Struct
13
+
14
+ from sera.libs.middlewares.uscp import SKIP_UPDATE_SYSTEM_CONTROLLED_PROPS_KEY
15
+
16
+ S = TypeVar("S", bound=Struct)
17
+
18
+
19
+ class SingleAutoUSCP(MsgspecDTO[S], Generic[S]):
20
+ """Auto Update System Controlled Property DTO"""
21
+
22
+ @classmethod
23
+ def create_for_field_definition(
24
+ cls,
25
+ field_definition: FieldDefinition,
26
+ handler_id: str,
27
+ backend_cls: type[DTOBackend] | None = None,
28
+ ) -> None:
29
+ assert backend_cls is None, "Custom backend not supported"
30
+ super().create_for_field_definition(
31
+ field_definition, handler_id, FixedDTOBackend
32
+ )
33
+
34
+ def decode_bytes(self, value: bytes):
35
+ """Decode a byte string into an object"""
36
+ backend = self._dto_backends[self.asgi_connection.route_handler.handler_id][
37
+ "data_backend"
38
+ ] # pyright: ignore
39
+ obj = backend.populate_data_from_raw(value, self.asgi_connection)
40
+ if self.asgi_connection.scope["state"][SKIP_UPDATE_SYSTEM_CONTROLLED_PROPS_KEY]:
41
+ # Skip updating system-controlled properties
42
+ # TODO: dirty fix as this assumes every struct has _is_scp_updated property. find a
43
+ # better solution and fix me!
44
+ obj._is_scp_updated = True
45
+ return obj
46
+
47
+ obj.update_system_controlled_props(self.asgi_connection)
48
+ return obj
49
+
50
+
51
+ class FixedDTOBackend(DTOCodegenBackend):
52
+ def parse_raw(
53
+ self, raw: bytes, asgi_connection: ASGIConnection
54
+ ) -> Struct | Collection[Struct]:
55
+ """Parse raw bytes into transfer model type.
56
+
57
+ 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.
58
+
59
+ Args:
60
+ raw: bytes
61
+ asgi_connection: The current ASGI Connection
62
+
63
+ Returns:
64
+ The raw bytes parsed into transfer model type.
65
+ """
66
+ request_encoding = RequestEncodingType.JSON
67
+
68
+ if (content_type := getattr(asgi_connection, "content_type", None)) and (
69
+ media_type := content_type[0]
70
+ ):
71
+ request_encoding = media_type
72
+
73
+ type_decoders = asgi_connection.route_handler.resolve_type_decoders()
74
+
75
+ if request_encoding == RequestEncodingType.MESSAGEPACK:
76
+ result = decode_msgpack(
77
+ value=raw,
78
+ target_type=self.model_type,
79
+ type_decoders=type_decoders,
80
+ strict=False,
81
+ )
82
+ else:
83
+ result = decode_json(
84
+ value=raw,
85
+ target_type=self.model_type,
86
+ type_decoders=type_decoders,
87
+ strict=False,
88
+ )
89
+
90
+ return cast("Struct | Collection[Struct]", result)
@@ -34,7 +34,7 @@ def test_get_by_id(
34
34
  assert (
35
35
  resp.status_code == 200
36
36
  ), f"Record {record} should exist but got {resp.status_code}"
37
- assert resp.json() == data
37
+ assert resp.json() == data, (resp.json(), data)
38
38
 
39
39
  for record in non_exist_records:
40
40
  resp = client.get(f"{base_url}/{record}")
@@ -0,0 +1,264 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Generic, NamedTuple, Optional, Sequence, TypeVar
4
+
5
+ from litestar.exceptions import HTTPException
6
+ from sqlalchemy import Result, Select, delete, exists, func, select
7
+ from sqlalchemy.exc import IntegrityError
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+ from sqlalchemy.orm import contains_eager, load_only
10
+
11
+ from sera.libs.base_orm import BaseORM
12
+ from sera.libs.search_helper import Query, QueryOp
13
+ from sera.misc import assert_not_null, to_snake_case
14
+ from sera.models import Cardinality, Class, DataProperty, ObjectProperty
15
+
16
+ R = TypeVar("R", bound=BaseORM)
17
+ ID = TypeVar("ID") # ID of a class
18
+ SqlResult = TypeVar("SqlResult", bound=Result)
19
+
20
+
21
+ class QueryResult(NamedTuple, Generic[R]):
22
+ records: Sequence[R]
23
+ total: Optional[int]
24
+
25
+
26
+ class BaseAsyncService(Generic[ID, R]):
27
+
28
+ instance = None
29
+
30
+ def __init__(self, cls: Class, orm_classes: dict[str, type[R]]):
31
+ # schema of the class
32
+ self.cls = cls
33
+ self.orm_cls = orm_classes[cls.name]
34
+ self.id_prop = assert_not_null(cls.get_id_property())
35
+
36
+ self._cls_id_prop = getattr(self.orm_cls, self.id_prop.name)
37
+ self.is_id_auto_increment = assert_not_null(self.id_prop.db).is_auto_increment
38
+
39
+ self.prop2orm: dict[str, type] = {
40
+ prop.name: orm_classes[prop.target.name]
41
+ for prop in cls.properties.values()
42
+ if isinstance(prop, ObjectProperty) and prop.target.db is not None
43
+ }
44
+
45
+ # figure out the join clauses so we can join the tables
46
+ # for example, to join between User, UserGroup, and Group
47
+ # the query can look like this:
48
+ # select(User)
49
+ # .join(UserGroup, UserGroup.user_id == User.id)
50
+ # .join(Group, Group.id == UserGroup.group_id)
51
+ # .options(contains_eager(User.group).contains_eager(UserGroup.group))
52
+ self.join_clauses: dict[str, list[dict]] = {}
53
+ for prop in cls.properties.values():
54
+ if (
55
+ isinstance(prop, DataProperty)
56
+ and prop.db is not None
57
+ and prop.db.foreign_key is not None
58
+ ):
59
+ target_tbl = orm_classes[prop.db.foreign_key.name]
60
+ target_cls = prop.db.foreign_key
61
+ source_fk = prop.name
62
+ # the property storing the SQLAlchemy relationship of the foreign key
63
+ source_relprop = prop.name + "_relobj"
64
+ cardinality = Cardinality.ONE_TO_ONE
65
+ elif isinstance(prop, ObjectProperty) and prop.target.db is not None:
66
+ target_tbl = orm_classes[prop.target.name]
67
+ target_cls = prop.target
68
+ source_fk = prop.name + "_id"
69
+ source_relprop = prop.name
70
+ cardinality = prop.cardinality
71
+ else:
72
+ continue
73
+
74
+ if cardinality == Cardinality.MANY_TO_MANY:
75
+ # for many-to-many, we need to import the association tables
76
+ assoc_tbl = orm_classes[f"{cls.name}{target_cls.name}"]
77
+ assoc_tbl_source_fk = to_snake_case(cls.name) + "_id"
78
+ assoc_tbl_target_fk = to_snake_case(target_cls.name) + "_id"
79
+ self.join_clauses[prop.name] = [
80
+ {
81
+ "class": assoc_tbl,
82
+ "condition": getattr(assoc_tbl, assoc_tbl_source_fk)
83
+ == getattr(self.orm_cls, self.id_prop.name),
84
+ "contains_eager": getattr(self.orm_cls, source_relprop),
85
+ },
86
+ {
87
+ "class": target_tbl,
88
+ "condition": getattr(assoc_tbl, assoc_tbl_target_fk)
89
+ == getattr(
90
+ target_tbl,
91
+ assert_not_null(target_cls.get_id_property()).name,
92
+ ),
93
+ "contains_eager": getattr(
94
+ assoc_tbl, to_snake_case(target_cls.name)
95
+ ),
96
+ },
97
+ ]
98
+ elif cardinality == Cardinality.ONE_TO_MANY:
99
+ # A -> B is 1:N, A.id is stored in B, this does not supported in SERA yet so we do not need
100
+ # to implement it
101
+ raise NotImplementedError()
102
+ else:
103
+ # A -> B is either 1:1 or N:1, we will store the foreign key is in A
104
+ # .join(B, A.<foreign_key> == B.id)
105
+ self.join_clauses[prop.name] = [
106
+ {
107
+ "class": target_tbl,
108
+ "condition": getattr(
109
+ target_tbl,
110
+ assert_not_null(target_cls.get_id_property()).name,
111
+ )
112
+ == getattr(self.orm_cls, source_fk),
113
+ "contains_eager": getattr(self.orm_cls, source_relprop),
114
+ },
115
+ ]
116
+
117
+ @classmethod
118
+ def get_instance(cls):
119
+ """Get the singleton instance of the service."""
120
+ if cls.instance is None:
121
+ # assume that the subclass overrides the __init__ method
122
+ # so that we don't need to pass the class and orm_cls
123
+ cls.instance = cls() # type: ignore[call-arg]
124
+ return cls.instance
125
+
126
+ async def search(
127
+ self,
128
+ query: Query,
129
+ session: AsyncSession,
130
+ ) -> QueryResult[R]:
131
+ """Retrieving records matched a query.
132
+
133
+ Args:
134
+ query: The search query
135
+ session: The database session
136
+ """
137
+ q = self._select()
138
+
139
+ if len(query.fields) > 0:
140
+ q = q.options(
141
+ load_only(*[getattr(self.orm_cls, field) for field in query.fields])
142
+ )
143
+
144
+ if query.unique:
145
+ q = q.distinct()
146
+
147
+ if len(query.sorted_by) > 0:
148
+ q = q.order_by(
149
+ *[
150
+ (
151
+ (
152
+ getattr(self.orm_cls, field.field).desc()
153
+ if field.order == "desc"
154
+ else getattr(self.orm_cls, field.field)
155
+ )
156
+ if field.prop is None
157
+ else (
158
+ getattr(self.prop2orm[field.prop], field.field).desc()
159
+ if field.order == "desc"
160
+ else getattr(self.prop2orm[field.prop], field.field)
161
+ )
162
+ )
163
+ for field in query.sorted_by
164
+ ]
165
+ )
166
+
167
+ if len(query.group_by) > 0:
168
+ q = q.group_by(
169
+ *[
170
+ (
171
+ getattr(self.orm_cls, field.field)
172
+ if field.prop is None
173
+ else getattr(self.prop2orm[field.prop], field.field)
174
+ )
175
+ for field in query.group_by
176
+ ]
177
+ )
178
+
179
+ for clause in query.conditions:
180
+ if clause.op == QueryOp.eq:
181
+ q = q.where(getattr(self.orm_cls, clause.field) == clause.value)
182
+ elif clause.op == QueryOp.ne:
183
+ q = q.where(getattr(self.orm_cls, clause.field) != clause.value)
184
+ elif clause.op == QueryOp.lt:
185
+ q = q.where(getattr(self.orm_cls, clause.field) < clause.value)
186
+ elif clause.op == QueryOp.lte:
187
+ q = q.where(getattr(self.orm_cls, clause.field) <= clause.value)
188
+ elif clause.op == QueryOp.gt:
189
+ q = q.where(getattr(self.orm_cls, clause.field) > clause.value)
190
+ elif clause.op == QueryOp.gte:
191
+ q = q.where(getattr(self.orm_cls, clause.field) >= clause.value)
192
+ elif clause.op == QueryOp.in_:
193
+ q = q.where(getattr(self.orm_cls, clause.field).in_(clause.value))
194
+ elif clause.op == QueryOp.not_in:
195
+ q = q.where(~getattr(self.orm_cls, clause.field).in_(clause.value))
196
+ else:
197
+ assert clause.op == QueryOp.fuzzy
198
+ # Assuming fuzzy search is implemented as a full-text search
199
+ q = q.where(
200
+ func.to_tsvector(getattr(self.orm_cls, clause.field)).match(
201
+ clause.value
202
+ )
203
+ )
204
+
205
+ for join_condition in query.join_conditions:
206
+ for join_clause in self.join_clauses[join_condition.prop]:
207
+ q = q.join(
208
+ join_clause["class"],
209
+ join_clause["condition"],
210
+ isouter=join_condition.join_type == "left",
211
+ full=join_condition.join_type == "full",
212
+ ).options(contains_eager(join_clause["contains_eager"]))
213
+
214
+ print(">>>", join_clause)
215
+
216
+ cq = select(func.count()).select_from(q.subquery())
217
+ rq = q.limit(query.limit).offset(query.offset)
218
+ records = self._process_result(await session.execute(rq)).scalars().all()
219
+ if query.return_total:
220
+ total = (await session.execute(cq)).scalar_one()
221
+ else:
222
+ total = None
223
+ return QueryResult(records, total)
224
+
225
+ async def get_by_id(self, id: ID, session: AsyncSession) -> Optional[R]:
226
+ """Retrieving a record by ID."""
227
+ q = self._select().where(self._cls_id_prop == id)
228
+ result = self._process_result(await session.execute(q)).scalar_one_or_none()
229
+ return result
230
+
231
+ async def has_id(self, id: ID, session: AsyncSession) -> bool:
232
+ """Check whether we have a record with the given ID."""
233
+ q = exists().where(self._cls_id_prop == id).select()
234
+ result = (await session.execute(q)).scalar()
235
+ return bool(result)
236
+
237
+ async def create(self, record: R, session: AsyncSession) -> R:
238
+ """Create a new record."""
239
+ if self.is_id_auto_increment:
240
+ setattr(record, self.id_prop.name, None)
241
+
242
+ try:
243
+ session.add(record)
244
+ await session.flush()
245
+ except IntegrityError:
246
+ raise HTTPException(detail="Invalid request", status_code=409)
247
+ return record
248
+
249
+ async def update(self, record: R, session: AsyncSession) -> R:
250
+ """Update an existing record."""
251
+ await session.execute(record.get_update_query())
252
+ return record
253
+
254
+ def _select(self) -> Select:
255
+ """Get the select statement for the class."""
256
+ return select(self.orm_cls)
257
+
258
+ def _process_result(self, result: SqlResult) -> SqlResult:
259
+ """Process the result of a query."""
260
+ return result
261
+
262
+ async def truncate(self, session: AsyncSession) -> None:
263
+ """Truncate the table."""
264
+ await session.execute(delete(self.orm_cls))