sera-2 1.12.6__py3-none-any.whl → 1.13.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/exports/__init__.py +0 -0
- sera/exports/schema.py +157 -0
- sera/exports/test.py +70 -0
- sera/libs/api_test_helper.py +43 -0
- sera/models/_schema.py +11 -0
- {sera_2-1.12.6.dist-info → sera_2-1.13.1.dist-info}/METADATA +1 -1
- {sera_2-1.12.6.dist-info → sera_2-1.13.1.dist-info}/RECORD +8 -4
- {sera_2-1.12.6.dist-info → sera_2-1.13.1.dist-info}/WHEEL +0 -0
sera/exports/__init__.py
ADDED
File without changes
|
sera/exports/schema.py
ADDED
@@ -0,0 +1,157 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
from typing import Annotated
|
3
|
+
|
4
|
+
import typer
|
5
|
+
|
6
|
+
from sera.models import Cardinality, Class, DataProperty, Schema, parse_schema
|
7
|
+
from sera.models._datatype import DataType
|
8
|
+
|
9
|
+
|
10
|
+
def get_prisma_field_type(datatype: DataType) -> str:
|
11
|
+
pytype = datatype.get_python_type().type
|
12
|
+
if pytype == "str":
|
13
|
+
return "String"
|
14
|
+
if pytype == "int":
|
15
|
+
return "Int"
|
16
|
+
if pytype == "float":
|
17
|
+
return "Float"
|
18
|
+
if pytype == "bool":
|
19
|
+
return "Boolean"
|
20
|
+
if pytype == "bytes":
|
21
|
+
return "Bytes"
|
22
|
+
if pytype == "dict":
|
23
|
+
return "Json"
|
24
|
+
if pytype == "datetime":
|
25
|
+
return "DateTime"
|
26
|
+
if pytype == "list[str]":
|
27
|
+
return "String[]"
|
28
|
+
if pytype == "list[int]":
|
29
|
+
return "Int[]"
|
30
|
+
if pytype == "list[float]":
|
31
|
+
return "Float[]"
|
32
|
+
if pytype == "list[bool]":
|
33
|
+
return "Boolean[]"
|
34
|
+
if pytype == "list[bytes]":
|
35
|
+
return "Bytes[]"
|
36
|
+
if pytype == "list[dict]":
|
37
|
+
return "Json[]"
|
38
|
+
if pytype == "list[datetime]":
|
39
|
+
return "DateTime[]"
|
40
|
+
|
41
|
+
raise ValueError(f"Unsupported data type for Prisma: {pytype}")
|
42
|
+
|
43
|
+
|
44
|
+
def to_prisma_model(schema: Schema, cls: Class, lines: list[str]):
|
45
|
+
"""Convert a Sera Class to a Prisma model string representation."""
|
46
|
+
lines.append(f"model {cls.name} {{")
|
47
|
+
|
48
|
+
if cls.db is None:
|
49
|
+
# This class has no database mapping, we must generate a default key for it
|
50
|
+
lines.append(
|
51
|
+
f" {'id'.ljust(30)} {'Int'.ljust(10)} @id @default(autoincrement())"
|
52
|
+
)
|
53
|
+
# lines.append(f" @@unique([%s])" % ", ".join(cls.properties.keys()))
|
54
|
+
|
55
|
+
for prop in cls.properties.values():
|
56
|
+
propattrs = ""
|
57
|
+
if isinstance(prop, DataProperty):
|
58
|
+
proptype = get_prisma_field_type(prop.datatype)
|
59
|
+
if prop.is_optional:
|
60
|
+
proptype = f"{proptype}?"
|
61
|
+
if prop.db is not None and prop.db.is_primary_key:
|
62
|
+
propattrs += "@id "
|
63
|
+
|
64
|
+
lines.append(f" {prop.name.ljust(30)} {proptype.ljust(10)} {propattrs}")
|
65
|
+
continue
|
66
|
+
|
67
|
+
if prop.cardinality == Cardinality.MANY_TO_MANY:
|
68
|
+
# For many-to-many relationships, we need to handle the join table
|
69
|
+
lines.append(
|
70
|
+
f" {prop.name.ljust(30)} {(prop.target.name + '[]').ljust(10)}"
|
71
|
+
)
|
72
|
+
else:
|
73
|
+
lines.append(
|
74
|
+
f" {(prop.name + '_').ljust(30)} {prop.target.name.ljust(10)} @relation(fields: [{prop.name}], references: [id])"
|
75
|
+
)
|
76
|
+
lines.append(f" {prop.name.ljust(30)} {'Int'.ljust(10)} @unique")
|
77
|
+
|
78
|
+
lines.append("")
|
79
|
+
for upstream_cls, reverse_upstream_prop in schema.get_upstream_classes(cls):
|
80
|
+
if (
|
81
|
+
reverse_upstream_prop.cardinality == Cardinality.MANY_TO_ONE
|
82
|
+
or reverse_upstream_prop.cardinality == Cardinality.MANY_TO_MANY
|
83
|
+
):
|
84
|
+
|
85
|
+
proptype = f"{upstream_cls.name}[]"
|
86
|
+
else:
|
87
|
+
proptype = upstream_cls.name + "?"
|
88
|
+
lines.append(f" {upstream_cls.name.lower().ljust(30)} {proptype.ljust(10)}")
|
89
|
+
|
90
|
+
lines.append("}\n")
|
91
|
+
|
92
|
+
|
93
|
+
def export_prisma_schema(schema: Schema, outfile: Path):
|
94
|
+
"""Export Prisma schema file"""
|
95
|
+
lines = []
|
96
|
+
|
97
|
+
# Datasource
|
98
|
+
lines.append("datasource db {")
|
99
|
+
lines.append(
|
100
|
+
' provider = "postgresql"'
|
101
|
+
) # Defaulting to postgresql as per user context
|
102
|
+
lines.append(' url = env("DATABASE_URL")')
|
103
|
+
lines.append("}\n")
|
104
|
+
|
105
|
+
# Generator
|
106
|
+
lines.append("generator client {")
|
107
|
+
lines.append(' provider = "prisma-client-py"')
|
108
|
+
lines.append(" recursive_type_depth = 5")
|
109
|
+
lines.append("}\n")
|
110
|
+
|
111
|
+
# Enums
|
112
|
+
if schema.enums:
|
113
|
+
for enum_name, enum_def in schema.enums.items():
|
114
|
+
lines.append(f"enum {enum_name} {{")
|
115
|
+
# Assuming enum_def.values is a list of strings based on previous errors
|
116
|
+
for val_str in enum_def.values:
|
117
|
+
lines.append(f" {val_str}")
|
118
|
+
lines.append("}\\n")
|
119
|
+
|
120
|
+
# Models
|
121
|
+
for cls in schema.topological_sort():
|
122
|
+
to_prisma_model(schema, cls, lines)
|
123
|
+
|
124
|
+
with outfile.open("w", encoding="utf-8") as f:
|
125
|
+
f.write("\n".join(lines))
|
126
|
+
|
127
|
+
|
128
|
+
app = typer.Typer(pretty_exceptions_short=True, pretty_exceptions_enable=False)
|
129
|
+
|
130
|
+
|
131
|
+
@app.command()
|
132
|
+
def cli(
|
133
|
+
schema_files: Annotated[
|
134
|
+
list[Path],
|
135
|
+
typer.Option(
|
136
|
+
"-s", help="YAML schema files. Multiple files are merged automatically"
|
137
|
+
),
|
138
|
+
],
|
139
|
+
outfile: Annotated[
|
140
|
+
Path,
|
141
|
+
typer.Option(
|
142
|
+
"-o", "--output", help="Output file for the Prisma schema", writable=True
|
143
|
+
),
|
144
|
+
],
|
145
|
+
):
|
146
|
+
schema = parse_schema(
|
147
|
+
"sera",
|
148
|
+
schema_files,
|
149
|
+
)
|
150
|
+
export_prisma_schema(
|
151
|
+
schema,
|
152
|
+
outfile,
|
153
|
+
)
|
154
|
+
|
155
|
+
|
156
|
+
if __name__ == "__main__":
|
157
|
+
app()
|
sera/exports/test.py
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
from sqlalchemy import (
|
2
|
+
Column,
|
3
|
+
ForeignKey,
|
4
|
+
Integer,
|
5
|
+
MetaData,
|
6
|
+
String,
|
7
|
+
Table,
|
8
|
+
create_engine,
|
9
|
+
)
|
10
|
+
from sqlalchemy.schema import CreateTable
|
11
|
+
|
12
|
+
# Define your SQLAlchemy engine (dialect matters for SQL output)
|
13
|
+
# Using a specific dialect helps generate appropriate SQL
|
14
|
+
# engine = create_engine(
|
15
|
+
# "postgresql+psycopg2://user:password@host:port/database", echo=False
|
16
|
+
# )
|
17
|
+
# Or for SQLite:
|
18
|
+
# engine = create_engine("sqlite:///:memory:")
|
19
|
+
# Or for MySQL:
|
20
|
+
# engine = create_engine("mysql+mysqlconnector://user:password@host:port/database")
|
21
|
+
|
22
|
+
|
23
|
+
metadata_obj = MetaData()
|
24
|
+
|
25
|
+
user_table = Table(
|
26
|
+
"users",
|
27
|
+
metadata_obj,
|
28
|
+
Column("id", Integer, primary_key=True),
|
29
|
+
Column("name", String(50)),
|
30
|
+
Column("email", String(100), unique=True),
|
31
|
+
)
|
32
|
+
|
33
|
+
address_table = Table(
|
34
|
+
"addresses",
|
35
|
+
metadata_obj,
|
36
|
+
Column("id", Integer, primary_key=True),
|
37
|
+
Column("user_id", Integer, ForeignKey("users.id"), nullable=False),
|
38
|
+
Column("street_name", String(100)),
|
39
|
+
Column("city", String(50)),
|
40
|
+
)
|
41
|
+
|
42
|
+
# --- ORM Example ---
|
43
|
+
# from sqlalchemy.orm import declarative_base, Mapped, mapped_column
|
44
|
+
# Base = declarative_base()
|
45
|
+
# metadata_obj = Base.metadata
|
46
|
+
# class User(Base): # ... (define as above)
|
47
|
+
# class Address(Base): # ... (define as above)
|
48
|
+
# -------------------
|
49
|
+
|
50
|
+
print("--- Generating DDL for PostgreSQL ---")
|
51
|
+
|
52
|
+
|
53
|
+
def generate_schema_ddl(metadata, engine_dialect):
|
54
|
+
for table in metadata.sorted_tables:
|
55
|
+
# The CreateTable construct can be compiled to a string
|
56
|
+
# specific to the dialect of the engine.
|
57
|
+
create_table_ddl = CreateTable(table).compile(dialect=engine_dialect)
|
58
|
+
print(str(create_table_ddl).strip() + ";\n")
|
59
|
+
|
60
|
+
|
61
|
+
from sqlalchemy.dialects import postgresql, sqlite
|
62
|
+
|
63
|
+
generate_schema_ddl(metadata_obj, postgresql.dialect())
|
64
|
+
|
65
|
+
# # Example with a different dialect (e.g., SQLite)
|
66
|
+
# # Note: You don't need a live connection for this, just the dialect.
|
67
|
+
|
68
|
+
|
69
|
+
# print("\n--- Generating DDL for SQLite ---")
|
70
|
+
# generate_schema_ddl(metadata_obj, sqlite.dialect())
|
@@ -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}"
|
sera/models/_schema.py
CHANGED
@@ -34,3 +34,14 @@ class Schema:
|
|
34
34
|
|
35
35
|
# Convert sorted names back to Class objects
|
36
36
|
return [self.classes[name] for name in sorted_names]
|
37
|
+
|
38
|
+
def get_upstream_classes(self, cls: Class) -> list[tuple[Class, ObjectProperty]]:
|
39
|
+
"""
|
40
|
+
Get all classes that depend on the given class.
|
41
|
+
"""
|
42
|
+
upstream_classes = []
|
43
|
+
for other_cls in self.classes.values():
|
44
|
+
for prop in other_cls.properties.values():
|
45
|
+
if isinstance(prop, ObjectProperty) and prop.target.name == cls.name:
|
46
|
+
upstream_classes.append((other_cls, prop))
|
47
|
+
return upstream_classes
|
@@ -1,7 +1,11 @@
|
|
1
1
|
sera/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
2
2
|
sera/constants.py,sha256=mzAaMyIx8TJK0-RYYJ5I24C4s0Uvj26OLMJmBo0pxHI,123
|
3
|
+
sera/exports/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
4
|
+
sera/exports/schema.py,sha256=3F5Kx3r0zOmiVpDf9vnzGhtdcuF-vfgMhBbp4Ko4fgw,4740
|
5
|
+
sera/exports/test.py,sha256=jK1EJmLGiy7eREpnY_68IIVRH43uH8S_u5Z7STPbXOM,2002
|
3
6
|
sera/libs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
4
7
|
sera/libs/api_helper.py,sha256=47y1kcwk3Xd2ZEMnUj_0OwCuUmgwOs5kYrE95BDVUn4,5411
|
8
|
+
sera/libs/api_test_helper.py,sha256=3tRr8sLN4dBSrHgKAHMmyoENI0xh7K_JLel8AvujU7k,1323
|
5
9
|
sera/libs/base_orm.py,sha256=5hOH_diUeaABm3cpE2-9u50VRqG1QW2osPQnvVHIhIA,3365
|
6
10
|
sera/libs/base_service.py,sha256=AX1WoTHte6Z_birkkfagkNE6BrCLTlTjQE4jEsKEaAY,5152
|
7
11
|
sera/libs/dag/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
@@ -30,8 +34,8 @@ sera/models/_module.py,sha256=8QRSCubZmdDP9rL58rGAS6X5VCrkc1ZHvuMu1I1KrWk,5043
|
|
30
34
|
sera/models/_multi_lingual_string.py,sha256=JETN6k00VH4wrA4w5vAHMEJV8fp3SY9bJebskFTjQLA,1186
|
31
35
|
sera/models/_parse.py,sha256=q_YZ7PrHWIN85_WW-fPP7-2gLXlGWM2-EIdbYXuG7Xg,10052
|
32
36
|
sera/models/_property.py,sha256=4y9F58D6DoX25-6aWPBRiE72nCPQy0KWlGNDTZXSV-8,6038
|
33
|
-
sera/models/_schema.py,sha256=
|
37
|
+
sera/models/_schema.py,sha256=VxJEiqgVvbXgcSUK4UW6JnRcggk4nsooVSE6MyXmfNY,1636
|
34
38
|
sera/typing.py,sha256=Q4QMfbtfrCjC9tFfsZPhsAnbNX4lm4NHQ9lmjNXYdV0,772
|
35
|
-
sera_2-1.
|
36
|
-
sera_2-1.
|
37
|
-
sera_2-1.
|
39
|
+
sera_2-1.13.1.dist-info/METADATA,sha256=xM2la_NEr7esZtrLlTbwARiHlqp43Y0VVqQR4zuFEmo,867
|
40
|
+
sera_2-1.13.1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
41
|
+
sera_2-1.13.1.dist-info/RECORD,,
|
File without changes
|