snake-orm 0.1.0b1__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 (198) hide show
  1. snake_orm-0.1.0b1.dist-info/METADATA +366 -0
  2. snake_orm-0.1.0b1.dist-info/RECORD +198 -0
  3. snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
  4. snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
  5. snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
  6. snakeorm/__init__.py +214 -0
  7. snakeorm/advisor.py +109 -0
  8. snakeorm/cli/__init__.py +3 -0
  9. snakeorm/cli/app.py +992 -0
  10. snakeorm/cli/discovery.py +179 -0
  11. snakeorm/cli/hooks.py +86 -0
  12. snakeorm/compiler/__init__.py +3 -0
  13. snakeorm/compiler/compiler.py +330 -0
  14. snakeorm/connection.py +228 -0
  15. snakeorm/contrib/__init__.py +17 -0
  16. snakeorm/contrib/asgi.py +192 -0
  17. snakeorm/contrib/config.py +264 -0
  18. snakeorm/contrib/deliver.py +256 -0
  19. snakeorm/contrib/django.py +245 -0
  20. snakeorm/contrib/sidecar.py +44 -0
  21. snakeorm/contrib/wsgi.py +176 -0
  22. snakeorm/core/__init__.py +1 -0
  23. snakeorm/core/config.py +173 -0
  24. snakeorm/core/converters.py +172 -0
  25. snakeorm/core/exceptions.py +175 -0
  26. snakeorm/core/placement.py +15 -0
  27. snakeorm/core/sentinels.py +37 -0
  28. snakeorm/core/signals.py +114 -0
  29. snakeorm/debug/__init__.py +42 -0
  30. snakeorm/debug/assets/css/panel.css +616 -0
  31. snakeorm/debug/assets/css/snake_orm_app.css +293 -0
  32. snakeorm/debug/assets/img/icon.svg +5 -0
  33. snakeorm/debug/assets/js/history.js +425 -0
  34. snakeorm/debug/assets/js/language.js +283 -0
  35. snakeorm/debug/assets/js/panel.js +241 -0
  36. snakeorm/debug/assets/js/snake_orm_app.js +82 -0
  37. snakeorm/debug/assets/pages/cli.html +28 -0
  38. snakeorm/debug/assets/pages/config.html +34 -0
  39. snakeorm/debug/assets/pages/database_first.html +29 -0
  40. snakeorm/debug/assets/pages/help.html +21 -0
  41. snakeorm/debug/assets/pages/history.html +27 -0
  42. snakeorm/debug/capture.py +234 -0
  43. snakeorm/debug/channel.py +131 -0
  44. snakeorm/debug/collector.py +144 -0
  45. snakeorm/debug/config.py +149 -0
  46. snakeorm/debug/html.py +578 -0
  47. snakeorm/debug/matrix.py +60 -0
  48. snakeorm/debug/origin.py +42 -0
  49. snakeorm/debug/otel/__init__.py +68 -0
  50. snakeorm/debug/otel/context.py +43 -0
  51. snakeorm/debug/otel/exporter.py +277 -0
  52. snakeorm/debug/otel/payload.py +97 -0
  53. snakeorm/debug/otel/report.py +29 -0
  54. snakeorm/debug/otel/spans.py +393 -0
  55. snakeorm/debug/otel/summary.py +59 -0
  56. snakeorm/debug/record.py +54 -0
  57. snakeorm/debug/report.py +311 -0
  58. snakeorm/debug/testing.py +23 -0
  59. snakeorm/decorators/__init__.py +13 -0
  60. snakeorm/decorators/abstract.py +26 -0
  61. snakeorm/decorators/db_first.py +104 -0
  62. snakeorm/decorators/function.py +28 -0
  63. snakeorm/decorators/model.py +268 -0
  64. snakeorm/decorators/polymorphic.py +140 -0
  65. snakeorm/decorators/result.py +167 -0
  66. snakeorm/decorators/row.py +90 -0
  67. snakeorm/decorators/trigger.py +47 -0
  68. snakeorm/decorators/view.py +149 -0
  69. snakeorm/dialects/__init__.py +6 -0
  70. snakeorm/dialects/base.py +373 -0
  71. snakeorm/dialects/capabilities.py +571 -0
  72. snakeorm/dialects/literals.py +46 -0
  73. snakeorm/dialects/matrix.py +384 -0
  74. snakeorm/dialects/mysql.py +519 -0
  75. snakeorm/dialects/postgres.py +504 -0
  76. snakeorm/dialects/sqlite.py +417 -0
  77. snakeorm/drivers/__init__.py +18 -0
  78. snakeorm/drivers/asyncbase.py +73 -0
  79. snakeorm/drivers/asyncdecorators.py +244 -0
  80. snakeorm/drivers/asyncpool.py +228 -0
  81. snakeorm/drivers/asyncpsycopg.py +124 -0
  82. snakeorm/drivers/asyncpymysql.py +50 -0
  83. snakeorm/drivers/asyncsqlite.py +30 -0
  84. snakeorm/drivers/base.py +76 -0
  85. snakeorm/drivers/failures.py +181 -0
  86. snakeorm/drivers/logging.py +167 -0
  87. snakeorm/drivers/pool.py +352 -0
  88. snakeorm/drivers/psycopg.py +188 -0
  89. snakeorm/drivers/pymysql.py +176 -0
  90. snakeorm/drivers/savepoints.py +46 -0
  91. snakeorm/drivers/sqlite.py +170 -0
  92. snakeorm/drivers/threaded.py +143 -0
  93. snakeorm/drivers/timeout.py +87 -0
  94. snakeorm/dto/__init__.py +55 -0
  95. snakeorm/dto/read.py +231 -0
  96. snakeorm/dto/region.py +326 -0
  97. snakeorm/dto/resolve.py +354 -0
  98. snakeorm/dto/spec.py +201 -0
  99. snakeorm/expressions/__init__.py +88 -0
  100. snakeorm/expressions/conditional.py +178 -0
  101. snakeorm/expressions/expression.py +810 -0
  102. snakeorm/expressions/functions.py +71 -0
  103. snakeorm/expressions/keys.py +299 -0
  104. snakeorm/expressions/paths.py +43 -0
  105. snakeorm/expressions/scalar.py +293 -0
  106. snakeorm/expressions/window.py +248 -0
  107. snakeorm/fields/__init__.py +68 -0
  108. snakeorm/fields/check.py +79 -0
  109. snakeorm/fields/column.py +316 -0
  110. snakeorm/fields/enum.py +64 -0
  111. snakeorm/fields/index.py +46 -0
  112. snakeorm/fields/relationship.py +738 -0
  113. snakeorm/fields/typed.py +736 -0
  114. snakeorm/helpers/__init__.py +1 -0
  115. snakeorm/helpers/annotations.py +21 -0
  116. snakeorm/helpers/inheritance.py +41 -0
  117. snakeorm/helpers/pyliteral.py +52 -0
  118. snakeorm/helpers/pytype.py +50 -0
  119. snakeorm/introspection/__init__.py +13 -0
  120. snakeorm/introspection/base.py +30 -0
  121. snakeorm/introspection/drift.py +78 -0
  122. snakeorm/introspection/models.py +633 -0
  123. snakeorm/introspection/mysql.py +367 -0
  124. snakeorm/introspection/postgres.py +397 -0
  125. snakeorm/introspection/sqlite.py +217 -0
  126. snakeorm/introspection/unsupported.py +94 -0
  127. snakeorm/linker/__init__.py +3 -0
  128. snakeorm/linker/linker.py +521 -0
  129. snakeorm/metadata/__init__.py +41 -0
  130. snakeorm/metadata/check.py +37 -0
  131. snakeorm/metadata/column.py +201 -0
  132. snakeorm/metadata/enum_storage.py +33 -0
  133. snakeorm/metadata/fk_action.py +19 -0
  134. snakeorm/metadata/foreign_key.py +26 -0
  135. snakeorm/metadata/index.py +43 -0
  136. snakeorm/metadata/index_method.py +19 -0
  137. snakeorm/metadata/int_size.py +20 -0
  138. snakeorm/metadata/json_storage.py +21 -0
  139. snakeorm/metadata/polymorphic.py +30 -0
  140. snakeorm/metadata/primary_key.py +23 -0
  141. snakeorm/metadata/relationship.py +60 -0
  142. snakeorm/metadata/relationship_kind.py +43 -0
  143. snakeorm/metadata/routine.py +26 -0
  144. snakeorm/metadata/server_default.py +20 -0
  145. snakeorm/metadata/table.py +97 -0
  146. snakeorm/metadata/table_kind.py +23 -0
  147. snakeorm/metadata/trigger.py +62 -0
  148. snakeorm/metadata/type_params.py +348 -0
  149. snakeorm/migration/__init__.py +88 -0
  150. snakeorm/migration/asyncrunner.py +168 -0
  151. snakeorm/migration/autodetect.py +96 -0
  152. snakeorm/migration/ddl.py +1172 -0
  153. snakeorm/migration/diff.py +701 -0
  154. snakeorm/migration/loader.py +77 -0
  155. snakeorm/migration/operations.py +952 -0
  156. snakeorm/migration/planner.py +78 -0
  157. snakeorm/migration/realize.py +420 -0
  158. snakeorm/migration/renames.py +153 -0
  159. snakeorm/migration/render.py +1095 -0
  160. snakeorm/migration/runner.py +314 -0
  161. snakeorm/migration/squash.py +60 -0
  162. snakeorm/migration/state.py +107 -0
  163. snakeorm/model.py +162 -0
  164. snakeorm/py.typed +0 -0
  165. snakeorm/query/__init__.py +9 -0
  166. snakeorm/query/compound.py +288 -0
  167. snakeorm/query/join_kind.py +19 -0
  168. snakeorm/query/joined.py +143 -0
  169. snakeorm/query/query.py +1001 -0
  170. snakeorm/query/recursive.py +244 -0
  171. snakeorm/registry/__init__.py +5 -0
  172. snakeorm/registry/by_module.py +163 -0
  173. snakeorm/registry/registry.py +166 -0
  174. snakeorm/session/__init__.py +8 -0
  175. snakeorm/session/asyncsession.py +638 -0
  176. snakeorm/session/coercion.py +306 -0
  177. snakeorm/session/factory.py +29 -0
  178. snakeorm/session/guards.py +269 -0
  179. snakeorm/session/isolation.py +23 -0
  180. snakeorm/session/mapper.py +213 -0
  181. snakeorm/session/planning.py +815 -0
  182. snakeorm/session/retry.py +96 -0
  183. snakeorm/session/session.py +871 -0
  184. snakeorm/session/shared.py +355 -0
  185. snakeorm/sql/__init__.py +18 -0
  186. snakeorm/sql/adapt.py +104 -0
  187. snakeorm/sql/aggregate.py +232 -0
  188. snakeorm/sql/condition.py +359 -0
  189. snakeorm/sql/delete.py +46 -0
  190. snakeorm/sql/insert.py +149 -0
  191. snakeorm/sql/joins.py +158 -0
  192. snakeorm/sql/pk_subquery.py +54 -0
  193. snakeorm/sql/refs.py +19 -0
  194. snakeorm/sql/resolver.py +28 -0
  195. snakeorm/sql/select.py +175 -0
  196. snakeorm/sql/update.py +82 -0
  197. snakeorm/sql/value.py +552 -0
  198. snakeorm/times.py +276 -0
snakeorm/cli/app.py ADDED
@@ -0,0 +1,992 @@
1
+ """SnakeORM CLI: makemigrations, migrate, rollback, status, fresh, scaffold, check, squash, tables, table, advise, dto.
2
+
3
+ The commands import the models module first and call `snake_link()`: without resolving the FKs, the diff and the FK DDL would come out incomplete.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import importlib
10
+ import os
11
+ import pathlib
12
+ import sys
13
+ import types
14
+ import warnings
15
+ from collections.abc import Sequence
16
+ from pathlib import Path
17
+
18
+ from snakeorm.cli.discovery import find_config
19
+ from snakeorm.connection import SnakeBackend, SnakeConnectionConfig
20
+ from snakeorm.core.config import (
21
+ DB_ENV_KEYS,
22
+ DEFAULT_DATABASE,
23
+ backend_name_for,
24
+ dsn_for,
25
+ dsn_from_env,
26
+ load_env,
27
+ )
28
+ from snakeorm.core.placement import DEFAULT_SCHEMA
29
+ from snakeorm.dialects import (
30
+ MySQLDialect,
31
+ PostgresDialect,
32
+ SnakeDialect,
33
+ SQLiteDialect,
34
+ )
35
+ from snakeorm.drivers import PsycopgDriver, SnakeDriver
36
+ from snakeorm.core.exceptions import (
37
+ SnakeConfigError,
38
+ SnakeError,
39
+ SnakeMigrationError,
40
+ SnakeWarning,
41
+ )
42
+ from snakeorm.advisor import unindexed_foreign_keys
43
+ from snakeorm.introspection import (
44
+ MySQLIntrospector,
45
+ PostgresIntrospector,
46
+ SnakeIntrospector,
47
+ SQLiteIntrospector,
48
+ drift,
49
+ SnakeMirrorNames,
50
+ render_models,
51
+ unrepresentable,
52
+ )
53
+ from snakeorm.core.exceptions import SnakeDtoError
54
+ from snakeorm.dto import specs_in_source, sync_file
55
+ from snakeorm.linker.linker import snake_link
56
+ from snakeorm.metadata import SnakeColumnInfo, SnakeTableInfo
57
+ from snakeorm.migration import (
58
+ MigrationRunner,
59
+ autodetect,
60
+ current_schema,
61
+ format_narrowing_hint,
62
+ format_rename_hint,
63
+ load,
64
+ narrowing_warnings,
65
+ rename_suggestions,
66
+ render_migration,
67
+ replay,
68
+ squash,
69
+ standing_view_warning,
70
+ )
71
+
72
+ # Imported through its private name because that is the name it has. It is the topological sort the
73
+ # diff already derives from the foreign keys, and `_wipe_order` needs exactly it — writing a second
74
+ # one would be two answers to one question, which is how the drop order came to disagree with the
75
+ # `snakeorm.migration`, and that edit belongs to `migration/`, not here.
76
+ from snakeorm.migration import drop_order
77
+ from snakeorm.core.signals import models_with_signals, signals_of
78
+ from snakeorm.decorators.model import snake_table
79
+
80
+ _DEFAULT_DIR = "migrations"
81
+
82
+
83
+ def report_failure(error: BaseException) -> None:
84
+ """Writes a failure to STDERR, with the engine's own words when there are any.
85
+
86
+ `rg -n "stderr" src/snakeorm/` used to return nothing at all: every message the package printed,
87
+ error or not, went to stdout. So `snakeorm migrate --database prod > migration.log` put the
88
+ failure INSIDE the file it was told to write, the `2>&1` of the calling script captured nothing,
89
+ and CI was left with an exit code and no context.
90
+
91
+ The `__cause__` travels with it, and ALWAYS rather than behind a `--traceback`. The wrapper says
92
+ "the migration stopped after applying 2 of 5"; the cause says `column "slug" already exists`,
93
+ which is the thing the command was run to find out. A flag you have to know about is a flag
94
+ nobody sets on the run that already failed.
95
+
96
+ What a command ANSWERS keeps going to stdout — `snakeorm check > drift.txt` is a CI writing a
97
+ report, and the report is not an error.
98
+ """
99
+ print(f"Error: {error}", file=sys.stderr)
100
+ cause = error.__cause__
101
+ if cause is not None:
102
+ print(f" caused by: {type(cause).__name__}: {cause}", file=sys.stderr)
103
+
104
+
105
+ def main(argv: Sequence[str] | None = None) -> int:
106
+ """The CLI entry point. It returns the exit code (0 = ok)."""
107
+ parser = _build_parser()
108
+ args = parser.parse_args(argv)
109
+ try:
110
+ handler = args.handler
111
+ return int(handler(args))
112
+ except SnakeError as error:
113
+ report_failure(error)
114
+ return 1
115
+
116
+
117
+ def _build_parser() -> argparse.ArgumentParser:
118
+ """Build the parser with the three subcommands."""
119
+ parser = argparse.ArgumentParser(
120
+ prog="snakeorm", description="SnakeORM migrations."
121
+ )
122
+ subparsers = parser.add_subparsers(dest="command", required=True)
123
+
124
+ make = subparsers.add_parser(
125
+ "makemigrations", help="Generate a migration with the schema changes."
126
+ )
127
+ make.add_argument(
128
+ "--models",
129
+ default=None,
130
+ help="Module with the models (e.g. myapp.models). Optional: without it the app's entry "
131
+ "point is used. REQUIRED with --only, which needs it to name the domain.",
132
+ )
133
+ make.add_argument("--dir", default=_DEFAULT_DIR, help="Migrations directory.")
134
+ make.add_argument(
135
+ "--database",
136
+ default=DEFAULT_DATABASE,
137
+ help="Connection to operate on (multi-DB). Each one has its own history.",
138
+ )
139
+ make.add_argument("--name", default="auto", help="Slug for the migration name.")
140
+ make.add_argument(
141
+ "--check",
142
+ action="store_true",
143
+ help="Writes nothing: exits with a non-zero code if migrations are missing (CI gate).",
144
+ )
145
+ make.add_argument(
146
+ "--only",
147
+ action="store_true",
148
+ help="PER-DOMAIN migration: only the tables of the models declared in --models (not the whole "
149
+ "registry). FKs to other domains are resolved by name; apply them in dependency order.",
150
+ )
151
+ make.set_defaults(handler=_cmd_makemigrations)
152
+
153
+ migrate = subparsers.add_parser("migrate", help="Apply the pending migrations.")
154
+ migrate.add_argument(
155
+ "--models",
156
+ default=None,
157
+ help="Module with the models. Optional: without it the app's entry point is used.",
158
+ )
159
+ migrate.add_argument("--dir", default=_DEFAULT_DIR, help="Migrations directory.")
160
+ migrate.add_argument(
161
+ "--database",
162
+ default=DEFAULT_DATABASE,
163
+ help="Connection to operate on (multi-DB). Each one has its own history.",
164
+ )
165
+ migrate.add_argument(
166
+ "--dsn", default=None, help="DSN of the database (or environment variables)."
167
+ )
168
+ migrate.set_defaults(handler=_cmd_migrate)
169
+
170
+ rollback = subparsers.add_parser(
171
+ "rollback", help="Undo the last applied migration."
172
+ )
173
+ rollback.add_argument(
174
+ "--models",
175
+ default=None,
176
+ help="Module with the models. Optional: without it the app's entry point is used.",
177
+ )
178
+ rollback.add_argument("--dir", default=_DEFAULT_DIR, help="Migrations directory.")
179
+ rollback.add_argument(
180
+ "--database",
181
+ default=DEFAULT_DATABASE,
182
+ help="Connection to operate on (multi-DB). Each one has its own history.",
183
+ )
184
+ rollback.add_argument(
185
+ "--dsn", default=None, help="DSN of the database (or environment variables)."
186
+ )
187
+ rollback.set_defaults(handler=_cmd_rollback)
188
+
189
+ scaffold = subparsers.add_parser(
190
+ "scaffold",
191
+ help="Generate MIRROR models by reading an existing database (DB-first).",
192
+ )
193
+ scaffold.add_argument(
194
+ "mode",
195
+ choices=("create", "update"),
196
+ help="create: the first time; fails if the file already exists. update: overwrites it entirely.",
197
+ )
198
+ scaffold.add_argument("--out", required=True, help="Models file to generate.")
199
+ scaffold.add_argument("--schema", default=DEFAULT_SCHEMA, help="Schema to read.")
200
+ scaffold.add_argument(
201
+ "--keep-underscores",
202
+ action="store_true",
203
+ help="Class names keep the table's underscores (Public_project_requests) instead of CapWords.",
204
+ )
205
+ scaffold.add_argument(
206
+ "--no-schema-prefix",
207
+ action="store_true",
208
+ help="Class names drop the schema. Two schemas with the same table name then COLLIDE, and the collision is reported.",
209
+ )
210
+ scaffold.add_argument(
211
+ "--database",
212
+ default=DEFAULT_DATABASE,
213
+ help="Connection to read the schema from.",
214
+ )
215
+ scaffold.add_argument(
216
+ "--dsn", default=None, help="DSN of the database (or environment variables)."
217
+ )
218
+ scaffold.set_defaults(handler=_cmd_scaffold)
219
+
220
+ check = subparsers.add_parser(
221
+ "check", help="Compare the models against the REAL schema and report the drift."
222
+ )
223
+ check.add_argument(
224
+ "--models",
225
+ default=None,
226
+ help="Module with the models. Optional: without it the app's entry point is used.",
227
+ )
228
+ check.add_argument("--schema", default=DEFAULT_SCHEMA, help="Schema to read.")
229
+ check.add_argument(
230
+ "--database", default=DEFAULT_DATABASE, help="Connection to check."
231
+ )
232
+ check.add_argument(
233
+ "--dsn", default=None, help="DSN of the database (or environment variables)."
234
+ )
235
+ check.set_defaults(handler=_cmd_check)
236
+
237
+ sq = subparsers.add_parser(
238
+ "squash",
239
+ help="Collapse a stretch of the history into ONE migration that replaces it.",
240
+ )
241
+ sq.add_argument("--dir", default="migrations", help="Migrations directory.")
242
+ sq.add_argument(
243
+ "--database",
244
+ default=DEFAULT_DATABASE,
245
+ help="Connection whose history gets collapsed.",
246
+ )
247
+ sq.add_argument(
248
+ "--until", required=True, help="Last version of the stretch to collapse."
249
+ )
250
+ sq.add_argument("--name", required=True, help="Name of the resulting migration.")
251
+ sq.set_defaults(handler=_cmd_squash)
252
+
253
+ tables_p = subparsers.add_parser(
254
+ "tables",
255
+ help="List the tables of the models (or of the real DB with --from-db). --detail adds columns.",
256
+ )
257
+ tables_p.add_argument("--models", help="Module with the models (or use --from-db).")
258
+ tables_p.add_argument(
259
+ "--database",
260
+ default=DEFAULT_DATABASE,
261
+ help="Connection to inspect (multi-DB).",
262
+ )
263
+ tables_p.add_argument(
264
+ "--from-db",
265
+ action="store_true",
266
+ help="Read the REAL DB (introspection), not the models.",
267
+ )
268
+ tables_p.add_argument(
269
+ "--schema", default=DEFAULT_SCHEMA, help="Schema to read with --from-db."
270
+ )
271
+ tables_p.add_argument("--dsn", default=None, help="DSN of the DB (for --from-db).")
272
+ tables_p.add_argument(
273
+ "--detail",
274
+ action="store_true",
275
+ help="Also show the columns of each table.",
276
+ )
277
+ tables_p.set_defaults(handler=_cmd_tables)
278
+
279
+ table_p = subparsers.add_parser(
280
+ "table",
281
+ help="Detail of ONE table (columns and relations). Validates the name. --from-db reads the real DB.",
282
+ )
283
+ table_p.add_argument("name", help="Name of the table to inspect.")
284
+ table_p.add_argument("--models", help="Module with the models (or use --from-db).")
285
+ table_p.add_argument(
286
+ "--database",
287
+ default=DEFAULT_DATABASE,
288
+ help="Connection to inspect (multi-DB).",
289
+ )
290
+ table_p.add_argument(
291
+ "--from-db",
292
+ action="store_true",
293
+ help="Read the REAL DB (introspection), not the models.",
294
+ )
295
+ table_p.add_argument(
296
+ "--schema", default=DEFAULT_SCHEMA, help="Schema to read with --from-db."
297
+ )
298
+ table_p.add_argument("--dsn", default=None, help="DSN of the DB (for --from-db).")
299
+ table_p.set_defaults(handler=_cmd_table)
300
+
301
+ status = subparsers.add_parser(
302
+ "status",
303
+ help="List the migrations and which ones are applied (like migrate:status).",
304
+ )
305
+ status.add_argument("--dir", default=_DEFAULT_DIR, help="Migrations directory.")
306
+ status.add_argument(
307
+ "--database", default=DEFAULT_DATABASE, help="Connection (multi-DB)."
308
+ )
309
+ status.add_argument(
310
+ "--dsn", default=None, help="DSN of the DB (or environment variables)."
311
+ )
312
+ status.set_defaults(handler=_cmd_status)
313
+
314
+ fresh = subparsers.add_parser(
315
+ "fresh",
316
+ help="DROP every table and re-apply all the migrations (like migrate:fresh).",
317
+ )
318
+ fresh.add_argument(
319
+ "--models",
320
+ default=None,
321
+ help="Module with the models. Optional: without it the app's entry point is used.",
322
+ )
323
+ fresh.add_argument("--dir", default=_DEFAULT_DIR, help="Migrations directory.")
324
+ fresh.add_argument(
325
+ "--database", default=DEFAULT_DATABASE, help="Connection (multi-DB)."
326
+ )
327
+ fresh.add_argument(
328
+ "--dsn", default=None, help="DSN of the DB (or environment variables)."
329
+ )
330
+ fresh.set_defaults(handler=_cmd_fresh)
331
+
332
+ advise = subparsers.add_parser(
333
+ "advise",
334
+ help="Audit the schema and suggest indexes for the FKs that lack one.",
335
+ )
336
+ advise.add_argument(
337
+ "--models",
338
+ default=None,
339
+ help="Module with the models. Optional: without it the app's entry point is used.",
340
+ )
341
+ advise.add_argument(
342
+ "--database", default=DEFAULT_DATABASE, help="Connection to audit (multi-DB)."
343
+ )
344
+ advise.set_defaults(handler=_cmd_advise)
345
+
346
+ dto = subparsers.add_parser(
347
+ "dto",
348
+ help="Write the TypedDicts of every `snake_dto(...)` declaration into its own file.",
349
+ )
350
+ dto.add_argument(
351
+ "--file",
352
+ action="append",
353
+ required=True,
354
+ dest="files",
355
+ help="File holding `snake_dto(...)` declarations. Repeatable.",
356
+ )
357
+ dto.add_argument(
358
+ "--sync",
359
+ action="store_true",
360
+ help="WRITE the bodies. Without it the command only reports, and exits 1 if anything drifted.",
361
+ )
362
+ dto.set_defaults(handler=_cmd_dto)
363
+
364
+ return parser
365
+
366
+
367
+ def _prepare_models(module_name: str | None) -> None:
368
+ """Fill the registry with the models and link their relations (mandatory before diffing).
369
+
370
+ With `--models` the named module is imported, which is what it always did. WITHOUT it the
371
+ application's own entry point is found and imported instead — and that import IS the
372
+ registration, so the flag went from being required to being an override.
373
+
374
+ Which is the whole trick, and it is not a new mechanism: the module that boots the app already
375
+ imports the models, and the `SnakeOrmConfig` it builds already holds the connections and the
376
+ migrations directory. Asking for them again on the command line was a second source for a fact
377
+ the application had already stated — and two sources for one fact eventually disagree.
378
+ """
379
+ if module_name:
380
+ _import_named(module_name) # registers the models in the global registry
381
+ else:
382
+ find_config() # importing the entry point is what registers them
383
+ snake_link() # resolves the relations (FKs); without this the diff comes out incomplete
384
+
385
+
386
+ def _import_named(module_name: str) -> types.ModuleType:
387
+ """Imports a module the user named on the command line, from the directory they are standing in.
388
+
389
+ The working directory goes on the path first. A console script does not get it for free the way
390
+ `python -m` does, so `--models main` failed with `ModuleNotFoundError` in the very directory
391
+ holding `main.py` — while discovery, which adds the root it found, worked. Two routes to the
392
+ same import behaving differently is the kind of gap somebody debugs for an hour before
393
+ suspecting the tool.
394
+ """
395
+ cwd = str(pathlib.Path.cwd())
396
+ if cwd not in sys.path:
397
+ sys.path.insert(0, cwd)
398
+ return importlib.import_module(module_name)
399
+
400
+
401
+ def _connection_for(
402
+ args: argparse.Namespace,
403
+ ) -> tuple[SnakeDriver, SnakeDialect]:
404
+ """Driver and dialect PAIRED, taken from the application when there is one.
405
+
406
+ The CLI used to open a `PsycopgDriver` beside a `PostgresDialect` in seven places, so a project
407
+ on SQLite or MySQL could not apply a migration from the command line at all — while
408
+ `SnakeOrmConfig.migrate()` did it on all three, and said so in its own docstring: *"unlike the
409
+ CLI, which is tied to Postgres"*. One of the two was the product and the other was the
410
+ documented command.
411
+
412
+ Nothing new pairs them here. `SnakeConnectionConfig.driver_and_dialect()` already builds the two
413
+ together from the `backend`, which is what makes a driver joined to another engine's dialect
414
+ impossible to express. The CLI simply was not asking.
415
+
416
+ `--dsn` keeps its old meaning and its old shortcut: an explicit DSN is Postgres, and no
417
+ application is looked for. That is the path of a script with no `SnakeOrmConfig` to find, and it
418
+ worked before this change.
419
+ """
420
+ if args.dsn:
421
+ return PsycopgDriver.connect(_resolve_dsn(args.dsn, args.database)), (
422
+ PostgresDialect()
423
+ )
424
+ try:
425
+ connection = find_config().databases[args.database]
426
+ except SnakeConfigError:
427
+ # No application to ask: fall back to the environment, which is how a bare project has
428
+ # always resolved its database — but PAIRED BY BACKEND, not assumed to be Postgres.
429
+ #
430
+ # It used to build a `PsycopgDriver` with a `PostgresDialect` whatever the DSN said, so
431
+ # `SNAKEORM_DSN_REPORTS=sqlite:///reports.db` handed psycopg2 a SQLite URL. That is the one
432
+ # thing `SnakeConnectionConfig` exists to make INEXPRESSIBLE, bypassed by the one caller
433
+ # that did not go through it. The engine is READ from the DSN's scheme by
434
+ # `backend_name_for`, never guessed.
435
+ resolved = _resolve_dsn(None, args.database)
436
+ backend = SnakeBackend(backend_name_for(args.database))
437
+ return SnakeConnectionConfig.from_dsn(resolved, backend).driver_and_dialect()
438
+ except KeyError:
439
+ raise SnakeConfigError(
440
+ f"The application declares no connection named '{args.database}'. Its "
441
+ f"SnakeOrmConfig lists: {', '.join(sorted(find_config().databases))}."
442
+ ) from None
443
+ return connection.driver_and_dialect()
444
+
445
+
446
+ def _introspector_for(driver: SnakeDriver, dialect: SnakeDialect) -> SnakeIntrospector:
447
+ """The introspector that matches the engine, paired the same way driver and dialect are.
448
+
449
+ Reading a schema is the THIRD axis beside writing the SQL and executing it, and the CLI used to
450
+ hardcode the Postgres one — so `scaffold` and `check` did not work on the other two engines
451
+ while `architecture.md` published a green tick for all three. The tick was the wish and the
452
+ hardcoding was the product.
453
+
454
+ It matches on the DIALECT and not on the driver because the dialect is what the pairing already
455
+ goes through: a driver joined to another engine's dialect is not expressible, so matching on one
456
+ is matching on both.
457
+ """
458
+ if isinstance(dialect, MySQLDialect):
459
+ return MySQLIntrospector(driver)
460
+ if isinstance(dialect, SQLiteDialect):
461
+ return SQLiteIntrospector(driver)
462
+ return PostgresIntrospector(driver)
463
+
464
+
465
+ def _migrations_dir(args: argparse.Namespace) -> Path:
466
+ """Migrations directory of THIS connection: each DB has its own history and linear numbering.
467
+
468
+ The default connection keeps the usual directory so single-DB projects do not break.
469
+ """
470
+ base = Path(args.dir)
471
+ return base if args.database == DEFAULT_DATABASE else base / args.database
472
+
473
+
474
+ def _declared_tables(module_name: str) -> set[str]:
475
+ """Table names of the models DECLARED/re-exported in `module_name` (per-domain migrations).
476
+
477
+ A model carries `__snake_registry__`; a `@snake_result` does not (it is no table), so it stays
478
+ out.
479
+ """
480
+ module = importlib.import_module(module_name)
481
+ return {
482
+ snake_table(obj).name
483
+ for obj in vars(module).values()
484
+ if isinstance(obj, type) and hasattr(obj, "__snake_registry__")
485
+ }
486
+
487
+
488
+ def _cmd_makemigrations(args: argparse.Namespace) -> int:
489
+ """Generate a new migration file if the schema changed with respect to the history."""
490
+ module_name: str | None = args.models
491
+ directory = _migrations_dir(args)
492
+ _prepare_models(module_name)
493
+
494
+ schema = current_schema(database=args.database)
495
+ if args.only:
496
+ # PER DOMAIN: only the tables of this module; the FKs to other domains are resolved by
497
+ # autodetect by name through the global registry (the target table must exist when
498
+ # APPLYING: migrate in order).
499
+ #
500
+ # This is the one place `--models` cannot be discovered away. Everywhere else the flag only
501
+ # says "import this so the models register", and the application's entry point already does
502
+ # that. Here it says something else entirely — WHICH domain this migration is for — and no
503
+ # amount of discovery can guess which of ten domains you meant.
504
+ if module_name is None:
505
+ raise SnakeError(
506
+ "`--only` needs `--models`: it generates the migration for ONE domain, and the "
507
+ "module you name is what says which. Without it there is nothing to narrow the "
508
+ "schema down to. Drop `--only` for a migration over the whole registry."
509
+ )
510
+ declared = _declared_tables(module_name)
511
+ schema = [table for table in schema if table.name in declared]
512
+ history = load(directory)
513
+ operations = autodetect(history, schema)
514
+ if not operations:
515
+ print("No changes: the schema is already up to date with the history.")
516
+ return 0
517
+
518
+ if args.check:
519
+ # A CI gate. Unlike `check` (code vs the real DB), this compares code vs the HISTORY: "am I missing a migration to generate?".
520
+ print(
521
+ f"Missing migrations: {len(operations)} operation(s) not recorded. "
522
+ f"Run `makemigrations` without --check."
523
+ )
524
+ return 1
525
+
526
+ number = len(history) + 1
527
+ version = f"{number:04d}_{args.name}"
528
+ directory.mkdir(parents=True, exist_ok=True)
529
+ path = directory / f"{version}.py"
530
+ path.write_text(render_migration(version, operations))
531
+ print(f"Created migration {path} with {len(operations)} operation(s).")
532
+ # The diff sees a rename as drop + add, which DELETES the old column. It gets WARNED about so the human swaps it for a RenameColumn.
533
+ hint = format_rename_hint(rename_suggestions(operations))
534
+ if hint:
535
+ print(hint)
536
+ # Narrowing a type may not fit the existing rows: the warning comes when GENERATING, not once the deploy is already under way.
537
+ narrowing = format_narrowing_hint(narrowing_warnings(operations))
538
+ if narrowing:
539
+ print(narrowing)
540
+ # A destructive operation under standing views, warned HERE and not inside `autodetect()`. The
541
+ # choice is deliberate and it has a cost worth writing down: `autodetect()` is called by tests
542
+ # and by the demos' drift check on every run, where nobody is reading, so a warning raised
543
+ # there would become background noise — and background noise is how a real warning gets
544
+ # ignored. The cost is that somebody calling `autodetect()` from their own code does NOT get
545
+ # this: the ORM says it at the one moment there is a person in front of the terminal, which is
546
+ # `makemigrations`. The state is replayed a second time on purpose (`autodetect` does its own,
547
+ # and does not hand it back); it is an in-memory walk over the history already loaded.
548
+ standing = standing_view_warning(operations, replay(history).tables())
549
+ if standing:
550
+ warnings.warn(standing, SnakeWarning, stacklevel=2)
551
+ return 0
552
+
553
+
554
+ def _cmd_migrate(args: argparse.Namespace) -> int:
555
+ """Apply to the database the migrations of the directory that have not been applied yet."""
556
+ module_name: str = args.models
557
+ _prepare_models(module_name)
558
+
559
+ # The connection is resolved FIRST, and that ordering is load-bearing: a typo in `--database`
560
+ # used to exit 0 with "there are no migrations", reporting SUCCESS on a deploy command. It used
561
+ # to be `_resolve_dsn` alone; now it opens the real pair, which validates the same thing and
562
+ # more (an alias the application does not declare fails here, naming the ones it does).
563
+ driver, dialect = _connection_for(args)
564
+
565
+ migrations = load(_migrations_dir(args))
566
+ if not migrations:
567
+ driver.close()
568
+ print(f"There are no migrations for connection '{args.database}'.")
569
+ return 0
570
+
571
+ try:
572
+ runner = MigrationRunner(driver, dialect)
573
+ applied = runner.apply(migrations)
574
+ finally:
575
+ driver.close()
576
+
577
+ if applied:
578
+ print(f"Applied {len(applied)} migration(s): {', '.join(applied)}.")
579
+ else:
580
+ print("All up to date: there were no pending migrations.")
581
+ return 0
582
+
583
+
584
+ def _cmd_rollback(args: argparse.Namespace) -> int:
585
+ """Undo the last applied migration (the highest-numbered one among those registered)."""
586
+ module_name: str = args.models
587
+ _prepare_models(module_name)
588
+
589
+ # Opened before anything else: it validates the connection, same as it always did.
590
+ driver, dialect = _connection_for(args)
591
+ migrations = load(_migrations_dir(args))
592
+ try:
593
+ runner = MigrationRunner(driver, dialect)
594
+ runner.ensure_tracking_table()
595
+ applied = runner.applied_versions()
596
+ registered = [
597
+ migration for migration in migrations if migration.version in applied
598
+ ]
599
+ if not registered:
600
+ print("There is no applied migration to undo.")
601
+ return 0
602
+ last = registered[
603
+ -1
604
+ ] # the list is in linear order; the last one is the highest-numbered
605
+ runner.rollback(last)
606
+ finally:
607
+ driver.close()
608
+
609
+ print(f"Rolled back migration {last.version}.")
610
+ return 0
611
+
612
+
613
+ def _cmd_scaffold(args: argparse.Namespace) -> int:
614
+ """Generate the mirror models of an existing DB: `create` fails if the file exists, `update` overwrites it whole.
615
+
616
+ There is no mode that preserves your edits: it is a mirror of the DB, your code goes in ANOTHER
617
+ file.
618
+ """
619
+ destination = Path(args.out)
620
+ if args.mode == "create" and destination.exists():
621
+ raise SnakeConfigError(
622
+ f"{destination} already exists. Use `scaffold update` to regenerate it whole, or delete "
623
+ f"it if you prefer to start from scratch. `create` does not overwrite on purpose."
624
+ )
625
+
626
+ driver, dialect = _connection_for(args)
627
+ try:
628
+ introspector = _introspector_for(driver, dialect)
629
+ tables = introspector.tables(args.schema)
630
+ names = SnakeMirrorNames(
631
+ capwords=not args.keep_underscores,
632
+ include_schema=not args.no_schema_prefix,
633
+ )
634
+ unsupported = introspector.unsupported(args.schema) + unrepresentable(
635
+ tables, names
636
+ )
637
+ finally:
638
+ driver.close()
639
+
640
+ destination.parent.mkdir(parents=True, exist_ok=True)
641
+ destination.write_text(render_models(tables, unsupported, names))
642
+ print(
643
+ f"Generated {destination} with {len(tables)} table(s) from '{args.database}'."
644
+ )
645
+ # Whatever is not representable is ALWAYS warned about: silence reads as "all covered".
646
+ for item in unsupported:
647
+ print(f" warning: not representable in the model -> {item}")
648
+ return 0
649
+
650
+
651
+ def _cmd_dto(args: argparse.Namespace) -> int:
652
+ """Bring every generated TypedDict in step with its specs — reporting, or writing.
653
+
654
+ TWO modes and one answer, which is why they are one command. Without `--sync` it is a CHECK: it
655
+ prints the drift and exits 1, so a DTO that stopped matching its model is a red build instead of
656
+ a wrong response a front end finds out about. With `--sync` it writes exactly what the check
657
+ would have complained about.
658
+
659
+ It never imports the file it is about to rewrite. The declarations live under `if TYPE_CHECKING:`
660
+ and are READ out of the source with `ast`; the only thing imported is the models module they
661
+ name, because the compiled metadata is where the types and the nullability live. So the check
662
+ mode executes nothing of the user's at all, and a DTO file that does not import is not this
663
+ command's problem.
664
+
665
+ Nothing changes in silence in either mode. Every field added, dropped or retyped is printed by
666
+ name, because the file being edited is the user's.
667
+ """
668
+ changed = False
669
+ for name in args.files:
670
+ path = Path(name)
671
+ if not path.is_file():
672
+ raise SnakeDtoError(f"{path} does not exist, so there is nothing to read.")
673
+ # The working directory goes on the path so the MODELS module the file names can be
674
+ # imported. A console script does not get it for free the way `python -m` does.
675
+ cwd = str(pathlib.Path.cwd())
676
+ if cwd not in sys.path:
677
+ sys.path.insert(0, cwd)
678
+ source = path.read_text(encoding="utf-8")
679
+ if not specs_in_source(source, path=str(path)):
680
+ # An ERROR and not a note, because the alternative reads exactly like success. A file
681
+ # meant to hold declarations and holding none is almost always the wrong path, and
682
+ # "nothing to do" is the one answer that hides it.
683
+ raise SnakeDtoError(
684
+ f"{path} declares no snake_dto(...), so nothing was read. Check the path: the "
685
+ f"declarations go in the file the classes are written into, under "
686
+ f"`if TYPE_CHECKING:`."
687
+ )
688
+ result = sync_file(path, write=args.sync)
689
+ if not result.changed:
690
+ print(f"{path}: up to date.")
691
+ continue
692
+ changed = True
693
+ verb = "Wrote" if args.sync else "Would write"
694
+ print(f"{path}: {verb} {len(result.changes)} change(s).")
695
+ for change in result.changes:
696
+ print(f" {change.describe()}")
697
+ if changed and not args.sync:
698
+ print(
699
+ "Run `snakeorm dto --sync` to write them, and read the diff: these classes live in "
700
+ "your files."
701
+ )
702
+ return 1
703
+ return 0
704
+
705
+
706
+ def _warn_signals_and_bulk() -> None:
707
+ """Remind which models have signals connected and that the bulk path (update_where/delete_where) does NOT fire them.
708
+
709
+ It does not return an error code: it is a design warning, not drift; turning it into a failure
710
+ would block a legitimate use of the bulk path.
711
+ """
712
+ models = sorted(models_with_signals(), key=lambda model: model.__name__)
713
+ if not models:
714
+ return
715
+ print("Connected signals (remember: update_where/delete_where do NOT fire them):")
716
+ for model in models:
717
+ names = ", ".join(signal.value for signal in signals_of(model))
718
+ print(f" - {model.__name__}: {names}")
719
+
720
+
721
+ def _cmd_squash(args: argparse.Namespace) -> int:
722
+ """Collapse the history UP TO `--until` (a PREFIX, not an arbitrary stretch) into one migration that replaces it.
723
+
724
+ It does not delete the originals: they are needed as long as a DB exists with only some of the
725
+ replaced ones applied. Deleting them is a later decision of the human.
726
+ """
727
+ directory = _migrations_dir(args)
728
+ history = load(directory)
729
+ if not history:
730
+ print(f"There are no migrations to collapse in {directory}.")
731
+ return 1
732
+
733
+ versions = [migration.version for migration in history]
734
+ if args.until not in versions:
735
+ print(
736
+ f"'{args.until}' is not in the history. Available: {', '.join(versions)}",
737
+ file=sys.stderr,
738
+ )
739
+ return 1
740
+
741
+ to_squash = history[: versions.index(args.until) + 1]
742
+ try:
743
+ collapsed = squash(to_squash, version=f"{len(history) + 1:04d}_{args.name}")
744
+ except SnakeMigrationError as error:
745
+ print(f"Cannot collapse: {error}", file=sys.stderr)
746
+ return 1
747
+
748
+ path = directory / f"{collapsed.version}.py"
749
+ path.write_text(
750
+ render_migration(collapsed.version, collapsed.operations, collapsed.replaces)
751
+ )
752
+ print(
753
+ f"Created {path}: {len(to_squash)} migration(s) collapsed into "
754
+ f"{len(collapsed.operations)} operation(s)."
755
+ )
756
+ print(
757
+ "The original files have NOT been deleted: they are needed as long as a database exists "
758
+ "with only some of them applied."
759
+ )
760
+ return 0
761
+
762
+
763
+ def _cmd_check(args: argparse.Namespace) -> int:
764
+ """Compare the code against the REAL schema and return != 0 if there is drift (a CI gate).
765
+
766
+ Unlike `makemigrations` (code vs the HISTORY), it answers "did somebody touch the DB from the
767
+ outside?".
768
+ """
769
+ _prepare_models(args.models)
770
+ driver, dialect = _connection_for(args)
771
+ try:
772
+ real = _introspector_for(driver, dialect).tables(args.schema)
773
+ finally:
774
+ driver.close()
775
+
776
+ # `include_unmanaged`: a @snake_db_first mirror IS in the code, so its drift counts. What does
777
+ # not count is a table the ORM does not declare: that one belongs to somebody else.
778
+ declared = current_schema(database=args.database, include_unmanaged=True)
779
+ _warn_signals_and_bulk()
780
+ # The dialect is what makes the comparison honest: both sides are compared as the SQL type
781
+ # this engine writes, not as a Python type only the code side has.
782
+ differences = drift(declared, real, dialect)
783
+ if not differences:
784
+ print("No drift: the code and the database say the same thing.")
785
+ return 0
786
+ print(f"Drift detected ({len(differences)}):")
787
+ for item in differences:
788
+ print(f" - {item}")
789
+ return 1
790
+
791
+
792
+ def _format_column(column: SnakeColumnInfo) -> str:
793
+ """One column line for the inspection: name, Python type and flags (NULL/NOT NULL, UNIQUE)."""
794
+ flags = ["NULL" if column.nullable else "NOT NULL"]
795
+ if column.unique:
796
+ flags.append("UNIQUE")
797
+ return f"{column.name:<18} {column.python_type.__name__:<10} {' '.join(flags)}"
798
+
799
+
800
+ def _tables_for_inspect(args: argparse.Namespace) -> list[SnakeTableInfo]:
801
+ """The tables to inspect: from the REAL DB (`--from-db`, introspection) or from the models."""
802
+ if args.from_db:
803
+ driver, dialect = _connection_for(args)
804
+ try:
805
+ return _introspector_for(driver, dialect).tables(args.schema)
806
+ finally:
807
+ driver.close()
808
+ # No guard on `--models` any more: without it `_prepare_models` finds the application's entry
809
+ # point, and if there is no application either it raises naming every route it tried.
810
+ _prepare_models(args.models)
811
+ return current_schema(database=args.database)
812
+
813
+
814
+ def _cmd_tables(args: argparse.Namespace) -> int:
815
+ """List the tables (from the models or from the real DB with `--from-db`). `--detail` adds columns."""
816
+ tables = sorted(_tables_for_inspect(args), key=lambda table: table.name)
817
+ if not tables:
818
+ print("There are no tables registered for those models.")
819
+ return 0
820
+ print(f"{len(tables)} table(s):")
821
+ for table in tables:
822
+ kind = "view" if table.is_view else "table"
823
+ pk = ", ".join(column.name for column in table.primary_key.columns)
824
+ print(
825
+ f" {table.name:<22} {len(table.columns):>2} col · "
826
+ f"{len(table.relationships)} rel · PK({pk}) [{kind}]"
827
+ )
828
+ if args.detail:
829
+ for column in table.columns:
830
+ print(f" {_format_column(column)}")
831
+ return 0
832
+
833
+
834
+ def _cmd_table(args: argparse.Namespace) -> int:
835
+ """Detail of ONE table (columns + relations), from the models or from the real DB (`--from-db`).
836
+ If the name does not exist, it FAILS listing the available ones."""
837
+ by_name = {table.name: table for table in _tables_for_inspect(args)}
838
+ table = by_name.get(args.name)
839
+ if table is None:
840
+ available = ", ".join(sorted(by_name)) or "(none)"
841
+ raise SnakeError(f"unknown table '{args.name}'. Available tables: {available}")
842
+ pk_names = {column.name for column in table.primary_key.columns}
843
+ kind = "view" if table.is_view else "table"
844
+ print(f"{kind}: {table.schema}.{table.name}")
845
+ print(f" columns ({len(table.columns)}):")
846
+ for column in table.columns:
847
+ pk = " · PK" if column.name in pk_names else ""
848
+ print(f" {_format_column(column)}{pk}")
849
+ if table.relationships:
850
+ print(f" relations ({len(table.relationships)}):")
851
+ for relationship in table.relationships:
852
+ target = relationship.target
853
+ print(
854
+ f" {relationship.name:<18} -> {target} [{relationship.kind.value}]"
855
+ )
856
+ return 0
857
+
858
+
859
+ def _format_status(versions: Sequence[str], applied: set[str]) -> str:
860
+ """Migration status (applied vs pending) as text. Pure: testable without a DB."""
861
+ lines = [f"{len(versions)} migration(s):"]
862
+ pending = 0
863
+ for version in versions:
864
+ done = version in applied
865
+ pending += 0 if done else 1
866
+ lines.append(f" {'✓ applied ' if done else '· PENDING'} {version}")
867
+ lines.append("All applied." if pending == 0 else f"{pending} pending.")
868
+ return "\n".join(lines)
869
+
870
+
871
+ def _cmd_status(args: argparse.Namespace) -> int:
872
+ """List the migrations of the directory and which ones are applied in the DB (like migrate:status)."""
873
+ migrations = load(_migrations_dir(args))
874
+ if not migrations:
875
+ print(f"There are no migrations for connection '{args.database}'.")
876
+ return 0
877
+ driver, dialect = _connection_for(args)
878
+ try:
879
+ runner = MigrationRunner(driver, dialect)
880
+ runner.ensure_tracking_table()
881
+ applied = runner.applied_versions()
882
+ finally:
883
+ driver.close()
884
+ print(_format_status([migration.version for migration in migrations], applied))
885
+ return 0
886
+
887
+
888
+ def _cmd_fresh(args: argparse.Namespace) -> int:
889
+ """DROP ALL the tables of the models (+ tracking) and re-apply the migrations (migrate:fresh).
890
+
891
+ To start from scratch in dev. DESTRUCTIVE: it deletes the data. Re-creating goes through the
892
+ migrations, not through the model, so the DB ends up exactly as the history leaves it.
893
+ """
894
+ _prepare_models(args.models)
895
+ migrations = load(_migrations_dir(args))
896
+ driver, dialect = _connection_for(args)
897
+ try:
898
+ # The wipe is ASKED of the dialect, like every other piece of DDL. Writing
899
+ # `DROP TABLE ... CASCADE` here made the one DESTRUCTIVE command Postgres-only: SQLite does
900
+ # not parse the keyword and MySQL refuses to drop a referenced table, so on two engines out
901
+ # of three it failed halfway through, which is the worst moment to learn about it.
902
+ #
903
+ # The tracking table goes in the same list: it is a table like the others, and leaving it
904
+ # out left the history claiming migrations that no longer had a schema under them. It goes
905
+ # LAST because nothing points at it, so it constrains nothing.
906
+ names = _wipe_order(current_schema(database=args.database))
907
+ for statement in dialect.drop_all_sql((*names, "snake_migrations")):
908
+ driver.execute(statement, ())
909
+ driver.commit()
910
+ applied = MigrationRunner(driver, dialect).apply(migrations)
911
+ finally:
912
+ driver.close()
913
+ print(f"Database recreated from scratch: {len(applied)} migration(s) applied.")
914
+ return 0
915
+
916
+
917
+ def _wipe_order(tables: Sequence[SnakeTableInfo]) -> list[str]:
918
+ """The table names for `drop_all_sql`, each holder of a foreign key before the table it points at.
919
+
920
+ `drop_order` is the topological sort the diff already derives from the same foreign keys, so it
921
+ is REUSED rather than written twice. DECLARATION order —what `current_schema()` returns, the
922
+ order the `@snake_model` decorators ran— is not a drop order: a forward reference
923
+ (`SnakeToOne["Parent"]`) is enough to invert it, and only SQLite says so
924
+ (`FOREIGN KEY constraint failed`); Postgres's `CASCADE` and MySQL's session switch hide it.
925
+
926
+ A CYCLE has no such order, so the tables come back unordered rather than not at all, which keeps
927
+ the wipe working on all three engines. This is the only caller that may do that: a MIGRATION
928
+ that cannot order its drops must stop and name the loop, because there the order is the whole
929
+ guarantee.
930
+ """
931
+ try:
932
+ ordered = drop_order(list(tables))
933
+ except SnakeMigrationError:
934
+ ordered = list(tables)
935
+ return [table.name for table in ordered]
936
+
937
+
938
+ def _cmd_advise(args: argparse.Namespace) -> int:
939
+ """STATIC audit of the schema: it lists the FKs without an index (the ones most filtered/joined).
940
+
941
+ It is the dev half of the advisor (no DB, no queries): at scale, a correlated aggregate over an
942
+ unindexed FK drags. The PER QUERY version, live, is what the debug panel gives.
943
+ """
944
+ _prepare_models(args.models)
945
+ hints = unindexed_foreign_keys(current_schema(database=args.database))
946
+ if not hints:
947
+ print("No suggestions: every FK is indexed.")
948
+ return 0
949
+ print(f"{len(hints)} FK(s) without an index (worth indexing):")
950
+ for table, column in hints:
951
+ print(f" {table}.{column} -> snake_column(index=True)")
952
+ return 0
953
+
954
+
955
+ def _dsn_from_url_vars() -> str | None:
956
+ """Return the full DSN defined by `DATABASE_URL`/`SNAKEORM_DSN`, or `None` if there is none."""
957
+ for variable in ("DATABASE_URL", "SNAKEORM_DSN"):
958
+ value = os.environ.get(variable)
959
+ if value:
960
+ return value
961
+ return None
962
+
963
+
964
+ def _resolve_dsn(explicit: str | None, database: str = DEFAULT_DATABASE) -> str:
965
+ """Resolve the DSN by priority: `--dsn`, then `DATABASE_URL`/`SNAKEORM_DSN` (environment and `.env`), then the `DB_*` pieces.
966
+
967
+ If there is nothing, it raises `SnakeConfigError` enumerating the routes: it never falls back to
968
+ a default DSN blindly.
969
+ """
970
+ if explicit: # 1. the explicit argument has the highest priority
971
+ return explicit
972
+ if database != DEFAULT_DATABASE:
973
+ # A named connection is declared separately: it cannot fall back to the devcontainer
974
+ # defaults, or it would end up migrating the wrong database without saying a word.
975
+ return dsn_for(database)
976
+ from_env = _dsn_from_url_vars() # 2. a full DSN from a real environment variable
977
+ if from_env is not None:
978
+ return from_env
979
+ load_env() # 3. load the .env (without stepping on what is already there)
980
+ from_dotenv = _dsn_from_url_vars() # DATABASE_URL/SNAKEORM_DSN defined in the .env
981
+ if from_dotenv is not None:
982
+ return from_dotenv
983
+ if any(
984
+ os.environ.get(key) for key in DB_ENV_KEYS
985
+ ): # the DB_* pieces (from the environment or from the .env)
986
+ return dsn_from_env()
987
+ raise SnakeConfigError(
988
+ "Could not determine the database DSN. Provide it through one of these routes: "
989
+ "(1) the --dsn <dsn> argument; "
990
+ "(2) the DATABASE_URL or SNAKEORM_DSN environment variable; "
991
+ "(3) the DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME variables (or a .env file with them)."
992
+ )