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.
- {sera_2-1.13.0 → sera_2-1.14.0}/PKG-INFO +2 -2
- {sera_2-1.13.0 → sera_2-1.14.0}/pyproject.toml +2 -2
- sera_2-1.14.0/sera/exports/schema.py +183 -0
- sera_2-1.14.0/sera/libs/api_test_helper.py +43 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/make/make_python_api.py +17 -16
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/make/make_python_model.py +356 -108
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_parse.py +62 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_property.py +40 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/typing.py +8 -0
- sera_2-1.13.0/sera/exports/schema.py +0 -157
- {sera_2-1.13.0 → sera_2-1.14.0}/README.md +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/__init__.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/constants.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/exports/__init__.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/exports/test.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/libs/__init__.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/libs/api_helper.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/libs/base_orm.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/libs/base_service.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/libs/dag/__init__.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/libs/dag/_dag.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/libs/middlewares/__init__.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/libs/middlewares/auth.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/libs/middlewares/uscp.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/make/__init__.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/make/__main__.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/make/make_app.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/make/make_python_services.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/make/make_typescript_model.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/misc/__init__.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/misc/_formatter.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/misc/_utils.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/__init__.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_class.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_collection.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_constraints.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_datatype.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_default.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_enum.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_module.py +0 -0
- {sera_2-1.13.0 → sera_2-1.14.0}/sera/models/_multi_lingual_string.py +0 -0
- {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.
|
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.
|
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.
|
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.
|
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()}.
|
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
|
-
|
554
|
-
prop.data.
|
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
|
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"
|
578
|
+
expr.ExprIdent(f"Create{cls.name}"),
|
579
579
|
),
|
580
580
|
)
|
581
581
|
]
|
582
|
-
if
|
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"
|
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()}.
|
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
|
-
|
665
|
-
prop.data.
|
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
|
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"
|
688
|
+
expr.ExprIdent(f"Update{cls.name}"),
|
688
689
|
),
|
689
690
|
)
|
690
691
|
]
|
691
|
-
if
|
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"
|
706
|
+
expr.ExprIdent(f"Update{cls.name}"),
|
706
707
|
),
|
707
708
|
DeferredVar.simple(
|
708
709
|
"session",
|