sera-2 1.13.0__tar.gz → 1.14.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 (42) hide show
  1. {sera_2-1.13.0 → sera_2-1.14.0}/PKG-INFO +2 -2
  2. {sera_2-1.13.0 → sera_2-1.14.0}/pyproject.toml +2 -2
  3. sera_2-1.14.0/sera/exports/schema.py +183 -0
  4. sera_2-1.14.0/sera/libs/api_test_helper.py +43 -0
  5. {sera_2-1.13.0 → sera_2-1.14.0}/sera/make/make_python_api.py +17 -16
  6. {sera_2-1.13.0 → sera_2-1.14.0}/sera/make/make_python_model.py +356 -108
  7. {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_parse.py +62 -0
  8. {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_property.py +40 -0
  9. {sera_2-1.13.0 → sera_2-1.14.0}/sera/typing.py +8 -0
  10. sera_2-1.13.0/sera/exports/schema.py +0 -157
  11. {sera_2-1.13.0 → sera_2-1.14.0}/README.md +0 -0
  12. {sera_2-1.13.0 → sera_2-1.14.0}/sera/__init__.py +0 -0
  13. {sera_2-1.13.0 → sera_2-1.14.0}/sera/constants.py +0 -0
  14. {sera_2-1.13.0 → sera_2-1.14.0}/sera/exports/__init__.py +0 -0
  15. {sera_2-1.13.0 → sera_2-1.14.0}/sera/exports/test.py +0 -0
  16. {sera_2-1.13.0 → sera_2-1.14.0}/sera/libs/__init__.py +0 -0
  17. {sera_2-1.13.0 → sera_2-1.14.0}/sera/libs/api_helper.py +0 -0
  18. {sera_2-1.13.0 → sera_2-1.14.0}/sera/libs/base_orm.py +0 -0
  19. {sera_2-1.13.0 → sera_2-1.14.0}/sera/libs/base_service.py +0 -0
  20. {sera_2-1.13.0 → sera_2-1.14.0}/sera/libs/dag/__init__.py +0 -0
  21. {sera_2-1.13.0 → sera_2-1.14.0}/sera/libs/dag/_dag.py +0 -0
  22. {sera_2-1.13.0 → sera_2-1.14.0}/sera/libs/middlewares/__init__.py +0 -0
  23. {sera_2-1.13.0 → sera_2-1.14.0}/sera/libs/middlewares/auth.py +0 -0
  24. {sera_2-1.13.0 → sera_2-1.14.0}/sera/libs/middlewares/uscp.py +0 -0
  25. {sera_2-1.13.0 → sera_2-1.14.0}/sera/make/__init__.py +0 -0
  26. {sera_2-1.13.0 → sera_2-1.14.0}/sera/make/__main__.py +0 -0
  27. {sera_2-1.13.0 → sera_2-1.14.0}/sera/make/make_app.py +0 -0
  28. {sera_2-1.13.0 → sera_2-1.14.0}/sera/make/make_python_services.py +0 -0
  29. {sera_2-1.13.0 → sera_2-1.14.0}/sera/make/make_typescript_model.py +0 -0
  30. {sera_2-1.13.0 → sera_2-1.14.0}/sera/misc/__init__.py +0 -0
  31. {sera_2-1.13.0 → sera_2-1.14.0}/sera/misc/_formatter.py +0 -0
  32. {sera_2-1.13.0 → sera_2-1.14.0}/sera/misc/_utils.py +0 -0
  33. {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/__init__.py +0 -0
  34. {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_class.py +0 -0
  35. {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_collection.py +0 -0
  36. {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_constraints.py +0 -0
  37. {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_datatype.py +0 -0
  38. {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_default.py +0 -0
  39. {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_enum.py +0 -0
  40. {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_module.py +0 -0
  41. {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_multi_lingual_string.py +0 -0
  42. {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_schema.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sera-2
3
- Version: 1.13.0
3
+ Version: 1.14.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,<26.0.0)
12
- Requires-Dist: codegen-2 (>=2.11.0,<3.0.0)
12
+ Requires-Dist: codegen-2 (>=2.11.1,<3.0.0)
13
13
  Requires-Dist: isort (>=6.0.1,<7.0.0)
14
14
  Requires-Dist: litestar (>=2.15.1,<3.0.0)
15
15
  Requires-Dist: loguru (>=0.7.0,<0.8.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "sera-2"
3
- version = "1.13.0"
3
+ version = "1.14.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.11.0"
12
+ codegen-2 = "^2.11.1"
13
13
  msgspec = "^0.19.0"
14
14
  litestar = "^2.15.1"
15
15
  loguru = "^0.7.0"
@@ -0,0 +1,183 @@
1
+ from pathlib import Path
2
+ from typing import Annotated
3
+
4
+ import orjson
5
+ import typer
6
+
7
+ from sera.misc import assert_not_null, to_snake_case
8
+ from sera.models import Cardinality, DataProperty, Schema, parse_schema
9
+
10
+
11
+ def export_tbls(schema: Schema, outfile: Path):
12
+ out = {
13
+ "name": schema.name,
14
+ "tables": [],
15
+ "relations": [],
16
+ }
17
+
18
+ DUMMY_IDPROP = "dkey"
19
+
20
+ for cls in schema.topological_sort():
21
+ table = {
22
+ "name": cls.name,
23
+ "type": "BASE TABLE",
24
+ "columns": [],
25
+ "constraints": [],
26
+ }
27
+
28
+ if cls.db is None:
29
+ # This class has no database mapping, we must generate a default key for it
30
+ table["columns"].append(
31
+ {
32
+ "name": DUMMY_IDPROP,
33
+ "type": "UNSET",
34
+ "nullable": False,
35
+ }
36
+ )
37
+
38
+ for prop in cls.properties.values():
39
+ column = {
40
+ "name": prop.name,
41
+ "nullable": not prop.is_optional,
42
+ }
43
+
44
+ if isinstance(prop, DataProperty):
45
+ column["type"] = prop.datatype.get_python_type().type
46
+ if prop.db is not None and prop.db.is_primary_key:
47
+ table["constraints"].append(
48
+ {
49
+ "name": f"{cls.name}_pkey",
50
+ "type": "PRIMARY KEY",
51
+ "def": f"PRIMARY KEY ({prop.name})",
52
+ "table": cls.name,
53
+ "referenced_table": cls.name,
54
+ "columns": [prop.name],
55
+ }
56
+ )
57
+ else:
58
+ if prop.cardinality == Cardinality.MANY_TO_MANY:
59
+ # For many-to-many relationships, we need to create a join table
60
+ jointable = {
61
+ "name": f"{cls.name}{prop.target.name}",
62
+ "type": "JOIN TABLE",
63
+ "columns": [
64
+ {
65
+ "name": f"{to_snake_case(cls.name)}_id",
66
+ "type": assert_not_null(cls.get_id_property())
67
+ .datatype.get_python_type()
68
+ .type,
69
+ "nullable": False,
70
+ },
71
+ {
72
+ "name": f"{to_snake_case(prop.target.name)}_id",
73
+ "type": assert_not_null(prop.target.get_id_property())
74
+ .datatype.get_python_type()
75
+ .type,
76
+ "nullable": False,
77
+ },
78
+ ],
79
+ }
80
+ out["tables"].append(jointable)
81
+ out["relations"].extend(
82
+ [
83
+ {
84
+ "table": f"{cls.name}{prop.target.name}",
85
+ "columns": [f"{to_snake_case(cls.name)}_id"],
86
+ "cardinality": "zero_or_more",
87
+ "parent_table": cls.name,
88
+ "parent_columns": [
89
+ assert_not_null(cls.get_id_property()).name
90
+ ],
91
+ "parent_cardinality": "zero_or_one",
92
+ "def": "", # LiamERD does not use `def` so we can leave it empty for now
93
+ },
94
+ {
95
+ "table": f"{cls.name}{prop.target.name}",
96
+ "columns": [f"{to_snake_case(prop.target.name)}_id"],
97
+ "cardinality": "zero_or_more",
98
+ "parent_table": prop.target.name,
99
+ "parent_columns": [
100
+ assert_not_null(prop.target.get_id_property()).name
101
+ ],
102
+ "parent_cardinality": "zero_or_one",
103
+ "def": "",
104
+ },
105
+ ]
106
+ )
107
+ # we actually want to skip adding this N-to-N column
108
+ continue
109
+
110
+ if prop.target.db is not None:
111
+ idprop = assert_not_null(prop.target.get_id_property())
112
+ idprop_name = idprop.name
113
+ column["type"] = idprop.datatype.get_python_type().type
114
+ else:
115
+ column["type"] = prop.target.name
116
+ idprop_name = (
117
+ DUMMY_IDPROP # a dummy property name for visualization purposes
118
+ )
119
+ assert idprop_name not in prop.target.properties
120
+
121
+ # somehow LiamERD only support zero_or_more or zero_or_one (exactly one does not work)
122
+ if prop.cardinality == Cardinality.ONE_TO_ONE:
123
+ cardinality = "zero_or_one"
124
+ parent_cardinality = "zero_or_one"
125
+ elif prop.cardinality == Cardinality.ONE_TO_MANY:
126
+ cardinality = "zero_or_one"
127
+ parent_cardinality = "zero_or_more"
128
+ elif prop.cardinality == Cardinality.MANY_TO_ONE:
129
+ cardinality = "zero_or_more"
130
+ parent_cardinality = "zero_or_one"
131
+ elif prop.cardinality == Cardinality.MANY_TO_MANY:
132
+ raise Exception("Unreachable")
133
+
134
+ out["relations"].append(
135
+ {
136
+ "table": cls.name,
137
+ "columns": [prop.name],
138
+ "cardinality": cardinality,
139
+ "parent_table": prop.target.name,
140
+ "parent_columns": [idprop_name],
141
+ "parent_cardinality": parent_cardinality,
142
+ "def": f"FOREIGN KEY ({prop.name}) REFERENCES {prop.target.name}({idprop_name})",
143
+ }
144
+ )
145
+
146
+ table["columns"].append(column)
147
+
148
+ out["tables"].append(table)
149
+
150
+ outfile.parent.mkdir(parents=True, exist_ok=True)
151
+ outfile.write_bytes(orjson.dumps(out, option=orjson.OPT_INDENT_2))
152
+
153
+
154
+ app = typer.Typer(pretty_exceptions_short=True, pretty_exceptions_enable=False)
155
+
156
+
157
+ @app.command()
158
+ def cli(
159
+ schema_files: Annotated[
160
+ list[Path],
161
+ typer.Option(
162
+ "-s", help="YAML schema files. Multiple files are merged automatically"
163
+ ),
164
+ ],
165
+ outfile: Annotated[
166
+ Path,
167
+ typer.Option(
168
+ "-o", "--output", help="Output file for the tbls schema", writable=True
169
+ ),
170
+ ],
171
+ ):
172
+ schema = parse_schema(
173
+ "sera",
174
+ schema_files,
175
+ )
176
+ export_tbls(
177
+ schema,
178
+ outfile,
179
+ )
180
+
181
+
182
+ if __name__ == "__main__":
183
+ app()
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from litestar import Litestar
4
+ from litestar.testing import TestClient
5
+
6
+
7
+ def test_has(
8
+ client: TestClient[Litestar],
9
+ base_url: str,
10
+ exist_records: list[int | str],
11
+ non_exist_records: list[int | str],
12
+ ) -> None:
13
+ for record in exist_records:
14
+ resp = client.head(f"{base_url}/{record}")
15
+ assert (
16
+ resp.status_code == 204
17
+ ), f"Record {record} should exist but got {resp.status_code}"
18
+
19
+ for record in non_exist_records:
20
+ resp = client.head(f"{base_url}/{record}")
21
+ assert (
22
+ resp.status_code == 404
23
+ ), f"Record {record} should not exist but got {resp.status_code}"
24
+
25
+
26
+ def test_get_by_id(
27
+ client: TestClient[Litestar],
28
+ base_url: str,
29
+ exist_records: dict[int | str, dict],
30
+ non_exist_records: list[int | str],
31
+ ) -> None:
32
+ for record, data in exist_records.items():
33
+ resp = client.get(f"{base_url}/{record}")
34
+ assert (
35
+ resp.status_code == 200
36
+ ), f"Record {record} should exist but got {resp.status_code}"
37
+ assert resp.json() == data
38
+
39
+ for record in non_exist_records:
40
+ resp = client.get(f"{base_url}/{record}")
41
+ assert (
42
+ resp.status_code == 404
43
+ ), f"Record {record} should not exist but got {resp.status_code}"
@@ -7,8 +7,7 @@ from loguru import logger
7
7
 
8
8
  from sera.misc import assert_not_null, to_snake_case
9
9
  from sera.models import App, DataCollection, Module, Package, SystemControlledMode
10
-
11
- GLOBAL_IDENTS = {"AsyncSession": "sqlalchemy.ext.asyncio.AsyncSession"}
10
+ from sera.typing import GLOBAL_IDENTS
12
11
 
13
12
 
14
13
  def make_python_api(app: App, collections: Sequence[DataCollection]):
@@ -544,19 +543,20 @@ def make_python_create_api(collection: DataCollection, target_pkg: Package):
544
543
  )
545
544
  program.import_(
546
545
  app.models.data.path
547
- + f".{collection.get_pymodule_name()}.Upsert{collection.name}",
546
+ + f".{collection.get_pymodule_name()}.Create{collection.name}",
548
547
  True,
549
548
  )
550
549
 
551
550
  # assuming the collection has only one class
552
551
  cls = collection.cls
553
- has_restricted_system_controlled_prop = any(
554
- prop.data.is_system_controlled == SystemControlledMode.RESTRICTED
552
+ is_on_create_update_props = any(
553
+ prop.data.system_controlled is not None
554
+ and prop.data.system_controlled.is_on_create_value_updated()
555
555
  for prop in cls.properties.values()
556
556
  )
557
557
  idprop = assert_not_null(cls.get_id_property())
558
558
 
559
- if has_restricted_system_controlled_prop:
559
+ if is_on_create_update_props:
560
560
  program.import_("sera.libs.api_helper.SingleAutoUSCP", True)
561
561
 
562
562
  func_name = "create"
@@ -575,11 +575,11 @@ def make_python_create_api(collection: DataCollection, target_pkg: Package):
575
575
  "dto",
576
576
  PredefinedFn.item_getter(
577
577
  expr.ExprIdent("SingleAutoUSCP"),
578
- expr.ExprIdent(f"Upsert{cls.name}"),
578
+ expr.ExprIdent(f"Create{cls.name}"),
579
579
  ),
580
580
  )
581
581
  ]
582
- if has_restricted_system_controlled_prop
582
+ if is_on_create_update_props
583
583
  else []
584
584
  ),
585
585
  )
@@ -589,7 +589,7 @@ def make_python_create_api(collection: DataCollection, target_pkg: Package):
589
589
  [
590
590
  DeferredVar.simple(
591
591
  "data",
592
- expr.ExprIdent(f"Upsert{cls.name}"),
592
+ expr.ExprIdent(f"Create{cls.name}"),
593
593
  ),
594
594
  DeferredVar.simple(
595
595
  "session",
@@ -652,7 +652,7 @@ def make_python_update_api(collection: DataCollection, target_pkg: Package):
652
652
  )
653
653
  program.import_(
654
654
  app.models.data.path
655
- + f".{collection.get_pymodule_name()}.Upsert{collection.name}",
655
+ + f".{collection.get_pymodule_name()}.Update{collection.name}",
656
656
  True,
657
657
  )
658
658
 
@@ -661,11 +661,12 @@ def make_python_update_api(collection: DataCollection, target_pkg: Package):
661
661
  id_prop = assert_not_null(cls.get_id_property())
662
662
  id_type = id_prop.datatype.get_python_type().type
663
663
 
664
- has_restricted_system_controlled_prop = any(
665
- prop.data.is_system_controlled == SystemControlledMode.RESTRICTED
664
+ is_on_update_update_props = any(
665
+ prop.data.system_controlled is not None
666
+ and prop.data.system_controlled.is_on_update_value_updated()
666
667
  for prop in cls.properties.values()
667
668
  )
668
- if has_restricted_system_controlled_prop:
669
+ if is_on_update_update_props:
669
670
  program.import_("sera.libs.api_helper.SingleAutoUSCP", True)
670
671
 
671
672
  func_name = "update"
@@ -684,11 +685,11 @@ def make_python_update_api(collection: DataCollection, target_pkg: Package):
684
685
  "dto",
685
686
  PredefinedFn.item_getter(
686
687
  expr.ExprIdent("SingleAutoUSCP"),
687
- expr.ExprIdent(f"Upsert{cls.name}"),
688
+ expr.ExprIdent(f"Update{cls.name}"),
688
689
  ),
689
690
  )
690
691
  ]
691
- if has_restricted_system_controlled_prop
692
+ if is_on_update_update_props
692
693
  else []
693
694
  ),
694
695
  )
@@ -702,7 +703,7 @@ def make_python_update_api(collection: DataCollection, target_pkg: Package):
702
703
  ),
703
704
  DeferredVar.simple(
704
705
  "data",
705
- expr.ExprIdent(f"Upsert{cls.name}"),
706
+ expr.ExprIdent(f"Update{cls.name}"),
706
707
  ),
707
708
  DeferredVar.simple(
708
709
  "session",