supython 0.5.0__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.
Files changed (188) hide show
  1. supython/__init__.py +8 -0
  2. supython/admin/__init__.py +3 -0
  3. supython/admin/api/__init__.py +24 -0
  4. supython/admin/api/auth.py +118 -0
  5. supython/admin/api/auth_templates.py +67 -0
  6. supython/admin/api/auth_users.py +225 -0
  7. supython/admin/api/db.py +174 -0
  8. supython/admin/api/functions.py +92 -0
  9. supython/admin/api/jobs.py +192 -0
  10. supython/admin/api/ops.py +224 -0
  11. supython/admin/api/realtime.py +281 -0
  12. supython/admin/api/service_auth.py +49 -0
  13. supython/admin/api/service_auth_templates.py +83 -0
  14. supython/admin/api/service_auth_users.py +346 -0
  15. supython/admin/api/service_db.py +214 -0
  16. supython/admin/api/service_functions.py +287 -0
  17. supython/admin/api/service_jobs.py +282 -0
  18. supython/admin/api/service_ops.py +213 -0
  19. supython/admin/api/service_realtime.py +30 -0
  20. supython/admin/api/service_storage.py +220 -0
  21. supython/admin/api/storage.py +117 -0
  22. supython/admin/api/system.py +37 -0
  23. supython/admin/audit.py +29 -0
  24. supython/admin/deps.py +22 -0
  25. supython/admin/errors.py +16 -0
  26. supython/admin/schemas.py +310 -0
  27. supython/admin/session.py +52 -0
  28. supython/admin/spa.py +38 -0
  29. supython/admin/static/assets/Alert-dluGVkos.js +49 -0
  30. supython/admin/static/assets/Audit-Njung3HI.js +2 -0
  31. supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
  32. supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
  33. supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
  34. supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
  35. supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
  36. supython/admin/static/assets/Crons-B67vc39F.js +2 -0
  37. supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
  38. supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
  39. supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
  40. supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
  41. supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
  42. supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
  43. supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
  44. supython/admin/static/assets/Input-DppYTq9C.js +259 -0
  45. supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
  46. supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
  47. supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
  48. supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
  49. supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
  50. supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
  51. supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
  52. supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
  53. supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
  54. supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
  55. supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
  56. supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
  57. supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
  58. supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
  59. supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
  60. supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
  61. supython/admin/static/assets/Space-n5-XcguU.js +400 -0
  62. supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
  63. supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
  64. supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
  65. supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
  66. supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
  67. supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
  68. supython/admin/static/assets/Users-wzwajhlh.js +2 -0
  69. supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
  70. supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
  71. supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
  72. supython/admin/static/assets/get-Ca6unauB.js +2 -0
  73. supython/admin/static/assets/index-CeE6v959.js +951 -0
  74. supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
  75. supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
  76. supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
  77. supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
  78. supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
  79. supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
  80. supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
  81. supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
  82. supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
  83. supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
  84. supython/admin/static/favicon.svg +1 -0
  85. supython/admin/static/icons.svg +24 -0
  86. supython/admin/static/index.html +24 -0
  87. supython/app.py +149 -0
  88. supython/auth/__init__.py +3 -0
  89. supython/auth/_email_job.py +11 -0
  90. supython/auth/providers/__init__.py +34 -0
  91. supython/auth/providers/github.py +22 -0
  92. supython/auth/providers/google.py +19 -0
  93. supython/auth/providers/oauth.py +56 -0
  94. supython/auth/providers/registry.py +16 -0
  95. supython/auth/ratelimit.py +39 -0
  96. supython/auth/router.py +282 -0
  97. supython/auth/schemas.py +79 -0
  98. supython/auth/service.py +587 -0
  99. supython/body_size.py +184 -0
  100. supython/cli.py +1653 -0
  101. supython/client/__init__.py +67 -0
  102. supython/client/_auth.py +249 -0
  103. supython/client/_client.py +145 -0
  104. supython/client/_config.py +92 -0
  105. supython/client/_functions.py +69 -0
  106. supython/client/_storage.py +255 -0
  107. supython/client/py.typed +0 -0
  108. supython/db.py +151 -0
  109. supython/db_admin.py +8 -0
  110. supython/functions/__init__.py +19 -0
  111. supython/functions/context.py +262 -0
  112. supython/functions/loader.py +307 -0
  113. supython/functions/router.py +228 -0
  114. supython/functions/schemas.py +50 -0
  115. supython/gen/__init__.py +5 -0
  116. supython/gen/_introspect.py +137 -0
  117. supython/gen/types_py.py +270 -0
  118. supython/gen/types_ts.py +365 -0
  119. supython/health.py +229 -0
  120. supython/hooks.py +117 -0
  121. supython/jobs/__init__.py +31 -0
  122. supython/jobs/backends.py +97 -0
  123. supython/jobs/context.py +58 -0
  124. supython/jobs/cron.py +152 -0
  125. supython/jobs/cron_inproc.py +118 -0
  126. supython/jobs/decorators.py +76 -0
  127. supython/jobs/registry.py +79 -0
  128. supython/jobs/router.py +136 -0
  129. supython/jobs/schemas.py +92 -0
  130. supython/jobs/service.py +311 -0
  131. supython/jobs/worker.py +219 -0
  132. supython/jwks.py +257 -0
  133. supython/keyset.py +279 -0
  134. supython/logging_config.py +291 -0
  135. supython/mail.py +33 -0
  136. supython/mailer.py +65 -0
  137. supython/migrate.py +81 -0
  138. supython/migrations/0001_extensions_and_roles.sql +46 -0
  139. supython/migrations/0002_auth_schema.sql +66 -0
  140. supython/migrations/0003_demo_todos.sql +42 -0
  141. supython/migrations/0004_auth_v0_2.sql +47 -0
  142. supython/migrations/0005_storage_schema.sql +117 -0
  143. supython/migrations/0006_realtime_schema.sql +206 -0
  144. supython/migrations/0007_jobs_schema.sql +254 -0
  145. supython/migrations/0008_jobs_last_error.sql +56 -0
  146. supython/migrations/0009_auth_rate_limits.sql +33 -0
  147. supython/migrations/0010_worker_heartbeat.sql +14 -0
  148. supython/migrations/0011_admin_schema.sql +45 -0
  149. supython/migrations/0012_auth_banned_until.sql +10 -0
  150. supython/migrations/0013_email_templates.sql +19 -0
  151. supython/migrations/0014_realtime_payload_warning.sql +96 -0
  152. supython/migrations/0015_backups_schema.sql +14 -0
  153. supython/passwords.py +15 -0
  154. supython/realtime/__init__.py +6 -0
  155. supython/realtime/broker.py +814 -0
  156. supython/realtime/protocol.py +234 -0
  157. supython/realtime/router.py +184 -0
  158. supython/realtime/schemas.py +207 -0
  159. supython/realtime/service.py +261 -0
  160. supython/realtime/topics.py +175 -0
  161. supython/realtime/websocket.py +586 -0
  162. supython/scaffold/__init__.py +5 -0
  163. supython/scaffold/init_project.py +133 -0
  164. supython/scaffold/templates/Caddyfile.tmpl +4 -0
  165. supython/scaffold/templates/README.md.tmpl +22 -0
  166. supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
  167. supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
  168. supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
  169. supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
  170. supython/scaffold/templates/env.example.tmpl +149 -0
  171. supython/scaffold/templates/functions_README.md.tmpl +21 -0
  172. supython/scaffold/templates/gitignore.tmpl +14 -0
  173. supython/scaffold/templates/migrations/.gitkeep +0 -0
  174. supython/secretset.py +347 -0
  175. supython/security_headers.py +78 -0
  176. supython/settings.py +198 -0
  177. supython/storage/__init__.py +5 -0
  178. supython/storage/backends.py +392 -0
  179. supython/storage/router.py +341 -0
  180. supython/storage/schemas.py +50 -0
  181. supython/storage/service.py +445 -0
  182. supython/storage/signing.py +119 -0
  183. supython/tokens.py +85 -0
  184. supython-0.5.0.dist-info/METADATA +714 -0
  185. supython-0.5.0.dist-info/RECORD +188 -0
  186. supython-0.5.0.dist-info/WHEEL +4 -0
  187. supython-0.5.0.dist-info/entry_points.txt +2 -0
  188. supython-0.5.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,137 @@
1
+ """Shared Postgres schema introspection queries used by type generators.
2
+
3
+ Both ``types_py.py`` (Python dataclasses) and ``types_ts.py`` (TypeScript
4
+ ``Database`` interface) use these functions to fetch metadata from
5
+ ``information_schema`` and ``pg_catalog``.
6
+ """
7
+
8
+ import asyncpg
9
+
10
+
11
+ async def _fetch_enums(
12
+ conn: asyncpg.Connection, schemas: list[str]
13
+ ) -> dict[tuple[str, str], list[str]]:
14
+ rows = await conn.fetch(
15
+ """
16
+ select n.nspname as schema,
17
+ t.typname as name,
18
+ e.enumlabel as label
19
+ from pg_type t
20
+ join pg_enum e on e.enumtypid = t.oid
21
+ join pg_namespace n on n.oid = t.typnamespace
22
+ where n.nspname = any($1::text[])
23
+ order by n.nspname, t.typname, e.enumsortorder
24
+ """,
25
+ list(schemas),
26
+ )
27
+ out: dict[tuple[str, str], list[str]] = {}
28
+ for r in rows:
29
+ out.setdefault((r["schema"], r["name"]), []).append(r["label"])
30
+ return out
31
+
32
+
33
+ async def _fetch_tables(
34
+ conn: asyncpg.Connection, schemas: list[str]
35
+ ) -> list[tuple[str, str]]:
36
+ rows = await conn.fetch(
37
+ """
38
+ select table_schema, table_name
39
+ from information_schema.tables
40
+ where table_schema = any($1::text[])
41
+ and table_type in ('BASE TABLE', 'VIEW')
42
+ order by table_schema, table_name
43
+ """,
44
+ list(schemas),
45
+ )
46
+ return [(r["table_schema"], r["table_name"]) for r in rows]
47
+
48
+
49
+ async def _fetch_columns(
50
+ conn: asyncpg.Connection, schemas: list[str]
51
+ ) -> dict[tuple[str, str], list[asyncpg.Record]]:
52
+ rows = await conn.fetch(
53
+ """
54
+ select c.table_schema,
55
+ c.table_name,
56
+ c.column_name,
57
+ c.ordinal_position,
58
+ c.is_nullable,
59
+ c.data_type,
60
+ c.udt_schema,
61
+ c.udt_name,
62
+ c.column_default,
63
+ c.is_generated,
64
+ c.is_identity,
65
+ e.data_type as element_data_type,
66
+ e.udt_schema as element_udt_schema,
67
+ e.udt_name as element_udt_name
68
+ from information_schema.columns c
69
+ join information_schema.tables t
70
+ on t.table_catalog = c.table_catalog
71
+ and t.table_schema = c.table_schema
72
+ and t.table_name = c.table_name
73
+ left join information_schema.element_types e
74
+ on e.object_catalog = c.table_catalog
75
+ and e.object_schema = c.table_schema
76
+ and e.object_name = c.table_name
77
+ and e.object_type = case t.table_type
78
+ when 'VIEW' then 'VIEW'
79
+ else 'TABLE'
80
+ end
81
+ and e.collection_type_identifier = c.dtd_identifier
82
+ where c.table_schema = any($1::text[])
83
+ and t.table_type in ('BASE TABLE', 'VIEW')
84
+ order by c.table_schema, c.table_name, c.ordinal_position
85
+ """,
86
+ list(schemas),
87
+ )
88
+ grouped: dict[tuple[str, str], list[asyncpg.Record]] = {}
89
+ for r in rows:
90
+ grouped.setdefault((r["table_schema"], r["table_name"]), []).append(r)
91
+ return grouped
92
+
93
+
94
+ async def _fetch_relationships(
95
+ conn: asyncpg.Connection, schemas: list[str]
96
+ ) -> dict[tuple[str, str], list[dict[str, any]]]: # type: ignore[no-any-return]
97
+ rows = await conn.fetch(
98
+ """
99
+ select
100
+ n.nspname as table_schema,
101
+ c.relname as table_name,
102
+ con.conname as constraint_name,
103
+ (
104
+ select array_agg(a.attname order by ord)
105
+ from unnest(con.conkey) with ordinality as t(attnum, ord)
106
+ join pg_attribute a
107
+ on a.attnum = t.attnum and a.attrelid = con.conrelid
108
+ ) as columns,
109
+ ref_c.relname as foreign_table_name,
110
+ (
111
+ select array_agg(a.attname order by ord)
112
+ from unnest(con.confkey) with ordinality as t(attnum, ord)
113
+ join pg_attribute a
114
+ on a.attnum = t.attnum and a.attrelid = con.confrelid
115
+ ) as foreign_columns
116
+ from pg_constraint con
117
+ join pg_class c on c.oid = con.conrelid
118
+ join pg_namespace n on n.oid = c.relnamespace
119
+ join pg_class ref_c on ref_c.oid = con.confrelid
120
+ where con.contype = 'f'
121
+ and n.nspname = any($1::text[])
122
+ order by n.nspname, c.relname, con.conname
123
+ """,
124
+ list(schemas),
125
+ )
126
+ out: dict[tuple[str, str], list[dict[str, any]]] = {} # type: ignore[no-any-return]
127
+ for r in rows:
128
+ key = (r["table_schema"], r["table_name"])
129
+ out.setdefault(key, []).append(
130
+ {
131
+ "foreignKeyName": r["constraint_name"],
132
+ "columns": r["columns"],
133
+ "referencedRelation": r["foreign_table_name"],
134
+ "referencedColumns": r["foreign_columns"],
135
+ }
136
+ )
137
+ return out
@@ -0,0 +1,270 @@
1
+ """Render a typed ``types.py`` from Postgres schema introspection.
2
+
3
+ Public entry point: :func:`render_types_py`. The returned string is the full
4
+ contents of a self-contained Python module with one ``StrEnum`` per Postgres
5
+ enum and one ``@dataclass`` + matching ``TypedDict`` per table or view in the
6
+ selected schemas.
7
+
8
+ The generator opens its own asyncpg connection and closes it before returning;
9
+ it does not depend on the supython service running.
10
+ """
11
+
12
+ import keyword
13
+ import re
14
+ from datetime import UTC, datetime
15
+
16
+ import asyncpg
17
+
18
+ from ..settings import get_settings
19
+ from ._introspect import _fetch_columns, _fetch_enums, _fetch_tables
20
+
21
+ _SIMPLE_MAP: dict[str, tuple[str, str | None]] = {
22
+ "text": ("str", None),
23
+ "varchar": ("str", None),
24
+ "bpchar": ("str", None),
25
+ "char": ("str", None),
26
+ "citext": ("str", None),
27
+ "name": ("str", None),
28
+ "int2": ("int", None),
29
+ "int4": ("int", None),
30
+ "int8": ("int", None),
31
+ "float4": ("float", None),
32
+ "float8": ("float", None),
33
+ "numeric": ("Decimal", "Decimal"),
34
+ "money": ("Decimal", "Decimal"),
35
+ "bool": ("bool", None),
36
+ "uuid": ("UUID", "UUID"),
37
+ "timestamptz": ("datetime", "datetime"),
38
+ "timestamp": ("datetime", "datetime"),
39
+ "date": ("date", "date"),
40
+ "time": ("time", "time"),
41
+ "timetz": ("time", "time"),
42
+ "interval": ("timedelta", "timedelta"),
43
+ "bytea": ("bytes", None),
44
+ "json": ("dict[str, Any]", "Any"),
45
+ "jsonb": ("dict[str, Any]", "Any"),
46
+ "inet": ("str", None),
47
+ "cidr": ("str", None),
48
+ "macaddr": ("str", None),
49
+ "tsvector": ("str", None),
50
+ "tsquery": ("str", None),
51
+ "bit": ("str", None),
52
+ "varbit": ("str", None),
53
+ "oid": ("int", None),
54
+ }
55
+
56
+
57
+ def _safe_col_name(name: str) -> str:
58
+ if keyword.iskeyword(name) or keyword.issoftkeyword(name):
59
+ return f"{name}_"
60
+ return name
61
+
62
+
63
+ async def render_types_py(schemas: list[str]) -> str:
64
+ """Connect to ``DATABASE_URL`` and return a rendered ``types.py`` module."""
65
+ s = get_settings()
66
+ conn = await asyncpg.connect(s.database_url)
67
+ try:
68
+ enums = await _fetch_enums(conn, schemas)
69
+ tables = await _fetch_tables(conn, schemas)
70
+ columns = await _fetch_columns(conn, schemas)
71
+ finally:
72
+ await conn.close()
73
+ return _render(schemas, enums, tables, columns)
74
+
75
+
76
+
77
+ def _class_name(schema: str, table: str, schemas: list[str]) -> str:
78
+ base = "".join(part.capitalize() for part in re.split(r"[_\-]", table) if part)
79
+ if not base:
80
+ base = "Table"
81
+ if schema == "public" or len(schemas) == 1:
82
+ return base
83
+ return f"{schema.capitalize()}{base}"
84
+
85
+
86
+ def _safe_enum_attr(label: str) -> str:
87
+ s = re.sub(r"[^A-Za-z0-9_]", "_", label).upper()
88
+ if not s or s[0].isdigit():
89
+ s = "_" + s
90
+ return s
91
+
92
+
93
+ def _pg_to_py(
94
+ udt_schema: str,
95
+ udt_name: str,
96
+ data_type: str,
97
+ element: tuple[str, str, str] | None,
98
+ enum_classes: dict[tuple[str, str], str],
99
+ imports: set[str],
100
+ ) -> tuple[str, str | None]:
101
+ """Return (annotation, unmapped_comment_or_None)."""
102
+ if data_type == "ARRAY" and element is not None:
103
+ elem_ann, elem_unmapped = _pg_to_py(
104
+ element[1], element[2], element[0], None, enum_classes, imports
105
+ )
106
+ return f"list[{elem_ann}]", elem_unmapped
107
+
108
+ if data_type == "USER-DEFINED" and (udt_schema, udt_name) in enum_classes:
109
+ return enum_classes[(udt_schema, udt_name)], None
110
+
111
+ if udt_name in _SIMPLE_MAP:
112
+ ann, imp = _SIMPLE_MAP[udt_name]
113
+ if imp:
114
+ imports.add(imp)
115
+ return ann, None
116
+
117
+ imports.add("Any")
118
+ return "Any", f"unmapped: {udt_schema}.{udt_name}"
119
+
120
+
121
+ def _render(
122
+ schemas: list[str],
123
+ enums: dict[tuple[str, str], list[str]],
124
+ tables: list[tuple[str, str]],
125
+ columns: dict[tuple[str, str], list[asyncpg.Record]],
126
+ ) -> str:
127
+ imports: set[str] = set()
128
+
129
+ enum_classes: dict[tuple[str, str], str] = {
130
+ (schema, name): _class_name(schema, name, schemas)
131
+ for (schema, name) in enums
132
+ }
133
+
134
+ body: list[str] = []
135
+
136
+ for (schema, name), labels in sorted(enums.items()):
137
+ cls = enum_classes[(schema, name)]
138
+ body.append("")
139
+ body.append(f"# --- enum {schema}.{name} {'-' * (60 - len(schema) - len(name))}")
140
+ body.append("")
141
+ body.append(f"class {cls}(StrEnum):")
142
+ for lbl in labels:
143
+ body.append(f" {_safe_enum_attr(lbl)} = {lbl!r}")
144
+
145
+ has_table = False
146
+ for schema, table in tables:
147
+ cols = columns.get((schema, table), [])
148
+ if not cols:
149
+ continue
150
+ has_table = True
151
+ cls = _class_name(schema, table, schemas)
152
+
153
+ rendered_cols: list[tuple[str, str, bool, str | None]] = []
154
+ for c in cols:
155
+ element = None
156
+ if c["element_data_type"]:
157
+ element = (
158
+ c["element_data_type"],
159
+ c["element_udt_schema"],
160
+ c["element_udt_name"],
161
+ )
162
+ ann, unmapped = _pg_to_py(
163
+ c["udt_schema"],
164
+ c["udt_name"],
165
+ c["data_type"],
166
+ element,
167
+ enum_classes,
168
+ imports,
169
+ )
170
+ nullable = c["is_nullable"] == "YES"
171
+ col_name = _safe_col_name(c["column_name"])
172
+ rendered_cols.append((col_name, ann, nullable, unmapped))
173
+
174
+ body.append("")
175
+ body.append(f"# --- {schema}.{table} {'-' * (64 - len(schema) - len(table))}")
176
+ body.append("")
177
+ body.append("@dataclass(kw_only=True, slots=True)")
178
+ body.append(f"class {cls}:")
179
+ for col_name, ann, nullable, unmapped in rendered_cols:
180
+ line = (
181
+ f" {col_name}: {ann} | None = None"
182
+ if nullable
183
+ else f" {col_name}: {ann}"
184
+ )
185
+ if unmapped:
186
+ line += f" # {unmapped}"
187
+ body.append(line)
188
+
189
+ body.append("")
190
+ body.append(" @classmethod")
191
+ body.append(f' def from_record(cls, record: "object") -> "{cls}":')
192
+ body.append(" fields = cls.__dataclass_fields__")
193
+ body.append(
194
+ " return cls(**{f: v for f, v in record.items() if f in fields})"
195
+ )
196
+
197
+ has_kw = any(
198
+ _safe_col_name(c["column_name"]) != c["column_name"]
199
+ for c in cols
200
+ )
201
+ if has_kw:
202
+ _emit_typeddict_functional(body, cls, rendered_cols)
203
+ else:
204
+ _emit_typeddict_class(body, cls, rendered_cols)
205
+
206
+ header: list[str] = []
207
+ header.append('"""Generated by `supython gen types --lang py`. Do not edit.')
208
+ header.append("")
209
+ header.append(f"Schemas: {', '.join(schemas)}")
210
+ header.append(f"Generated at: {datetime.now(UTC).isoformat()}")
211
+ header.append('"""')
212
+
213
+ import_lines: list[str] = []
214
+ if has_table:
215
+ import_lines.append("from dataclasses import dataclass")
216
+
217
+ datetime_syms = sorted(
218
+ {i for i in imports if i in {"datetime", "date", "time", "timedelta"}}
219
+ )
220
+ if datetime_syms:
221
+ import_lines.append(f"from datetime import {', '.join(datetime_syms)}")
222
+ if "Decimal" in imports:
223
+ import_lines.append("from decimal import Decimal")
224
+ if enums:
225
+ import_lines.append("from enum import StrEnum")
226
+ typing_syms: set[str] = set()
227
+ if "Any" in imports:
228
+ typing_syms.add("Any")
229
+ if has_table:
230
+ typing_syms.add("TypedDict")
231
+ if typing_syms:
232
+ import_lines.append(f"from typing import {', '.join(sorted(typing_syms))}")
233
+ if "UUID" in imports:
234
+ import_lines.append("from uuid import UUID")
235
+
236
+ parts: list[str] = []
237
+ parts.extend(header)
238
+ if import_lines:
239
+ parts.append("")
240
+ parts.extend(import_lines)
241
+ parts.extend(body)
242
+ return "\n".join(parts).rstrip() + "\n"
243
+
244
+
245
+ def _emit_typeddict_class(
246
+ body: list[str],
247
+ cls: str,
248
+ rendered_cols: list[tuple[str, str, bool, str | None]],
249
+ ) -> None:
250
+ body.append("")
251
+ body.append(f"class {cls}Row(TypedDict):")
252
+ for col_name, ann, nullable, _ in rendered_cols:
253
+ body.append(
254
+ f" {col_name}: {ann} | None" if nullable else f" {col_name}: {ann}"
255
+ )
256
+
257
+
258
+ def _emit_typeddict_functional(
259
+ body: list[str],
260
+ cls: str,
261
+ rendered_cols: list[tuple[str, str, bool, str | None]],
262
+ ) -> None:
263
+ body.append(f"{cls}Row = TypedDict(\"{cls}Row\", {{")
264
+ for col_name, ann, nullable, _ in rendered_cols:
265
+ ann_str = f"{ann} | None" if nullable else ann
266
+ body.append(f' "{col_name}": {ann_str},')
267
+ body.append("})")
268
+
269
+
270
+ __all__ = ["render_types_py"]