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.
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,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sera-2
3
- Version: 1.12.6
3
+ Version: 1.13.1
4
4
  Summary:
5
5
  Author: Binh Vu
6
6
  Author-email: bvu687@gmail.com
@@ -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=r-Gqg9Lb_wR3UrbNvfXXgt_qs5bts0t2Ve7aquuF_OI,1155
37
+ sera/models/_schema.py,sha256=VxJEiqgVvbXgcSUK4UW6JnRcggk4nsooVSE6MyXmfNY,1636
34
38
  sera/typing.py,sha256=Q4QMfbtfrCjC9tFfsZPhsAnbNX4lm4NHQ9lmjNXYdV0,772
35
- sera_2-1.12.6.dist-info/METADATA,sha256=MlOqmC3MJASvXLuS8k0KO5UE8325j9Ff53RArvIn5Wo,867
36
- sera_2-1.12.6.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
37
- sera_2-1.12.6.dist-info/RECORD,,
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,,