quantum-framework 0.9.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 (262) hide show
  1. quantum/__init__.py +1 -0
  2. quantum/cli/__init__.py +2 -0
  3. quantum/cli/commands/__init__.py +15 -0
  4. quantum/cli/commands/build.py +417 -0
  5. quantum/cli/commands/dev.py +185 -0
  6. quantum/cli/commands/docs.py +329 -0
  7. quantum/cli/commands/lint.py +523 -0
  8. quantum/cli/commands/migrate.py +527 -0
  9. quantum/cli/commands/new.py +622 -0
  10. quantum/cli/commands/serve.py +190 -0
  11. quantum/cli/commands/test.py +193 -0
  12. quantum/cli/deploy.py +810 -0
  13. quantum/cli/hot_reload.py +951 -0
  14. quantum/cli/jobs.py +356 -0
  15. quantum/cli/mq.py +582 -0
  16. quantum/cli/pkg.py +390 -0
  17. quantum/cli/runner.py +547 -0
  18. quantum/cli/server_process.py +159 -0
  19. quantum/cli/utils.py +334 -0
  20. quantum/compiler/__init__.py +30 -0
  21. quantum/compiler/base_generator.py +367 -0
  22. quantum/compiler/cli.py +295 -0
  23. quantum/compiler/expression_transformer.py +444 -0
  24. quantum/compiler/javascript/__init__.py +10 -0
  25. quantum/compiler/javascript/generator.py +659 -0
  26. quantum/compiler/optimizer.py +270 -0
  27. quantum/compiler/python/__init__.py +10 -0
  28. quantum/compiler/python/generator.py +883 -0
  29. quantum/compiler/python/runtime.py +863 -0
  30. quantum/compiler/transpiler.py +330 -0
  31. quantum/core/__init__.py +3 -0
  32. quantum/core/ast_nodes.py +2611 -0
  33. quantum/core/expression_diagnostics.py +87 -0
  34. quantum/core/expression_stdlib.py +148 -0
  35. quantum/core/expressions.py +591 -0
  36. quantum/core/features/agents/src/__init__.py +30 -0
  37. quantum/core/features/agents/src/ast_node.py +540 -0
  38. quantum/core/features/conditionals/src/__init__.py +8 -0
  39. quantum/core/features/conditionals/src/ast_node.py +68 -0
  40. quantum/core/features/data_fetching/src/__init__.py +21 -0
  41. quantum/core/features/data_fetching/src/ast_node.py +312 -0
  42. quantum/core/features/data_fetching/src/desktop_adapter.py +351 -0
  43. quantum/core/features/data_fetching/src/html_adapter.py +474 -0
  44. quantum/core/features/data_fetching/src/parser.py +225 -0
  45. quantum/core/features/data_import/src/__init__.py +0 -0
  46. quantum/core/features/data_import/src/ast_node.py +291 -0
  47. quantum/core/features/data_import/src/runtime.py +538 -0
  48. quantum/core/features/dump/src/__init__.py +12 -0
  49. quantum/core/features/dump/src/ast_node.py +106 -0
  50. quantum/core/features/dump/src/parser.py +61 -0
  51. quantum/core/features/dump/src/runtime.py +246 -0
  52. quantum/core/features/functions/src/__init__.py +8 -0
  53. quantum/core/features/functions/src/ast_node.py +149 -0
  54. quantum/core/features/game_engine_2d/src/__init__.py +19 -0
  55. quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
  56. quantum/core/features/game_engine_2d/src/parser.py +983 -0
  57. quantum/core/features/invocation/src/__init__.py +0 -0
  58. quantum/core/features/invocation/src/ast_node.py +146 -0
  59. quantum/core/features/invocation/src/runtime.py +327 -0
  60. quantum/core/features/knowledge_base/src/__init__.py +6 -0
  61. quantum/core/features/knowledge_base/src/ast_node.py +113 -0
  62. quantum/core/features/knowledge_base/src/parser.py +82 -0
  63. quantum/core/features/logging/src/__init__.py +12 -0
  64. quantum/core/features/logging/src/ast_node.py +111 -0
  65. quantum/core/features/logging/src/parser.py +50 -0
  66. quantum/core/features/logging/src/runtime.py +190 -0
  67. quantum/core/features/loops/src/__init__.py +8 -0
  68. quantum/core/features/loops/src/ast_node.py +60 -0
  69. quantum/core/features/query/src/__init__.py +0 -0
  70. quantum/core/features/query/src/database_service.py +322 -0
  71. quantum/core/features/query/src/query_validators.py +20 -0
  72. quantum/core/features/state_management/src/__init__.py +11 -0
  73. quantum/core/features/state_management/src/ast_node.py +228 -0
  74. quantum/core/features/terminal_engine/src/__init__.py +21 -0
  75. quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
  76. quantum/core/features/terminal_engine/src/parser.py +361 -0
  77. quantum/core/features/testing_engine/src/__init__.py +41 -0
  78. quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
  79. quantum/core/features/testing_engine/src/parser.py +604 -0
  80. quantum/core/features/theming/src/__init__.py +48 -0
  81. quantum/core/features/theming/src/ast_node.py +137 -0
  82. quantum/core/features/theming/src/presets.py +405 -0
  83. quantum/core/features/ui_engine/src/__init__.py +20 -0
  84. quantum/core/features/ui_engine/src/ast_nodes.py +1854 -0
  85. quantum/core/features/ui_engine/src/parser.py +1106 -0
  86. quantum/core/features/websocket/src/__init__.py +24 -0
  87. quantum/core/features/websocket/src/ast_node.py +247 -0
  88. quantum/core/html_compat.py +299 -0
  89. quantum/core/parser.py +1235 -0
  90. quantum/core/parser_registry.py +213 -0
  91. quantum/core/parsers/__init__.py +76 -0
  92. quantum/core/parsers/ai/__init__.py +12 -0
  93. quantum/core/parsers/ai/agent_parser.py +106 -0
  94. quantum/core/parsers/ai/knowledge_parser.py +86 -0
  95. quantum/core/parsers/ai/llm_parser.py +78 -0
  96. quantum/core/parsers/ai/team_parser.py +88 -0
  97. quantum/core/parsers/base.py +322 -0
  98. quantum/core/parsers/composition/__init__.py +10 -0
  99. quantum/core/parsers/composition/import_parser.py +50 -0
  100. quantum/core/parsers/composition/slot_parser.py +48 -0
  101. quantum/core/parsers/control_flow/__init__.py +11 -0
  102. quantum/core/parsers/control_flow/if_parser.py +68 -0
  103. quantum/core/parsers/control_flow/loop_parser.py +105 -0
  104. quantum/core/parsers/control_flow/set_parser.py +89 -0
  105. quantum/core/parsers/data/__init__.py +12 -0
  106. quantum/core/parsers/data/data_parser.py +217 -0
  107. quantum/core/parsers/data/invoke_parser.py +116 -0
  108. quantum/core/parsers/data/query_parser.py +188 -0
  109. quantum/core/parsers/data/transaction_parser.py +77 -0
  110. quantum/core/parsers/events/__init__.py +9 -0
  111. quantum/core/parsers/events/dispatch_event_parser.py +44 -0
  112. quantum/core/parsers/forms/__init__.py +11 -0
  113. quantum/core/parsers/forms/action_parser.py +54 -0
  114. quantum/core/parsers/forms/flash_parser.py +34 -0
  115. quantum/core/parsers/forms/redirect_parser.py +33 -0
  116. quantum/core/parsers/functions/__init__.py +11 -0
  117. quantum/core/parsers/functions/function_parser.py +137 -0
  118. quantum/core/parsers/functions/param_parser.py +66 -0
  119. quantum/core/parsers/functions/return_parser.py +31 -0
  120. quantum/core/parsers/html/__init__.py +10 -0
  121. quantum/core/parsers/html/component_call_parser.py +130 -0
  122. quantum/core/parsers/html/html_parser.py +115 -0
  123. quantum/core/parsers/jobs/__init__.py +11 -0
  124. quantum/core/parsers/jobs/job_parser.py +71 -0
  125. quantum/core/parsers/jobs/schedule_parser.py +62 -0
  126. quantum/core/parsers/jobs/thread_parser.py +57 -0
  127. quantum/core/parsers/messaging/__init__.py +17 -0
  128. quantum/core/parsers/messaging/message_ack_parser.py +30 -0
  129. quantum/core/parsers/messaging/message_nack_parser.py +30 -0
  130. quantum/core/parsers/messaging/message_parser.py +114 -0
  131. quantum/core/parsers/messaging/queue_parser.py +61 -0
  132. quantum/core/parsers/messaging/websocket_parser.py +121 -0
  133. quantum/core/parsers/persistence/__init__.py +9 -0
  134. quantum/core/parsers/persistence/persist_parser.py +64 -0
  135. quantum/core/parsers/routing/__init__.py +9 -0
  136. quantum/core/parsers/routing/route_parser.py +41 -0
  137. quantum/core/parsers/scripting/__init__.py +12 -0
  138. quantum/core/parsers/scripting/pyclass_parser.py +62 -0
  139. quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
  140. quantum/core/parsers/scripting/pyimport_parser.py +52 -0
  141. quantum/core/parsers/scripting/python_parser.py +49 -0
  142. quantum/core/parsers/services/__init__.py +12 -0
  143. quantum/core/parsers/services/dump_parser.py +52 -0
  144. quantum/core/parsers/services/file_parser.py +48 -0
  145. quantum/core/parsers/services/log_parser.py +46 -0
  146. quantum/core/parsers/services/mail_parser.py +65 -0
  147. quantum/core/tiers.py +82 -0
  148. quantum/packages/__init__.py +28 -0
  149. quantum/packages/manager.py +413 -0
  150. quantum/packages/manifest.py +351 -0
  151. quantum/packages/registry.py +399 -0
  152. quantum/packages/resolver.py +336 -0
  153. quantum/plugins/__init__.py +33 -0
  154. quantum/plugins/hooks.py +329 -0
  155. quantum/plugins/loader.py +479 -0
  156. quantum/plugins/manifest.py +336 -0
  157. quantum/plugins/registry.py +371 -0
  158. quantum/runtime/__init__.py +28 -0
  159. quantum/runtime/action_handler.py +443 -0
  160. quantum/runtime/adapters/__init__.py +88 -0
  161. quantum/runtime/adapters/memory_adapter.py +690 -0
  162. quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
  163. quantum/runtime/adapters/redis_adapter.py +582 -0
  164. quantum/runtime/adapters/sqlite_adapter.py +414 -0
  165. quantum/runtime/agent_service.py +1133 -0
  166. quantum/runtime/api_server.py +86 -0
  167. quantum/runtime/ast_cache.py +506 -0
  168. quantum/runtime/auth_service.py +267 -0
  169. quantum/runtime/component.py +990 -0
  170. quantum/runtime/component_composer.py +319 -0
  171. quantum/runtime/component_resolver.py +174 -0
  172. quantum/runtime/database_service.py +598 -0
  173. quantum/runtime/email_service.py +162 -0
  174. quantum/runtime/error_handler.py +295 -0
  175. quantum/runtime/execution_context.py +286 -0
  176. quantum/runtime/executor_registry.py +171 -0
  177. quantum/runtime/executors/__init__.py +71 -0
  178. quantum/runtime/executors/ai/__init__.py +12 -0
  179. quantum/runtime/executors/ai/agent_executor.py +217 -0
  180. quantum/runtime/executors/ai/knowledge_executor.py +114 -0
  181. quantum/runtime/executors/ai/llm_executor.py +153 -0
  182. quantum/runtime/executors/ai/team_executor.py +171 -0
  183. quantum/runtime/executors/base.py +262 -0
  184. quantum/runtime/executors/control_flow/__init__.py +11 -0
  185. quantum/runtime/executors/control_flow/if_executor.py +93 -0
  186. quantum/runtime/executors/control_flow/loop_executor.py +307 -0
  187. quantum/runtime/executors/control_flow/set_executor.py +412 -0
  188. quantum/runtime/executors/data/__init__.py +12 -0
  189. quantum/runtime/executors/data/data_executor.py +145 -0
  190. quantum/runtime/executors/data/invoke_executor.py +176 -0
  191. quantum/runtime/executors/data/query_executor.py +256 -0
  192. quantum/runtime/executors/data/transaction_executor.py +91 -0
  193. quantum/runtime/executors/jobs/__init__.py +11 -0
  194. quantum/runtime/executors/jobs/job_executor.py +190 -0
  195. quantum/runtime/executors/jobs/schedule_executor.py +132 -0
  196. quantum/runtime/executors/jobs/thread_executor.py +127 -0
  197. quantum/runtime/executors/messaging/__init__.py +17 -0
  198. quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
  199. quantum/runtime/executors/messaging/message_executor.py +174 -0
  200. quantum/runtime/executors/messaging/queue_executor.py +103 -0
  201. quantum/runtime/executors/messaging/websocket_executor.py +197 -0
  202. quantum/runtime/executors/scripting/__init__.py +11 -0
  203. quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
  204. quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
  205. quantum/runtime/executors/scripting/python_executor.py +249 -0
  206. quantum/runtime/executors/services/__init__.py +12 -0
  207. quantum/runtime/executors/services/dump_executor.py +72 -0
  208. quantum/runtime/executors/services/file_executor.py +89 -0
  209. quantum/runtime/executors/services/log_executor.py +77 -0
  210. quantum/runtime/executors/services/mail_executor.py +81 -0
  211. quantum/runtime/expression_cache.py +498 -0
  212. quantum/runtime/file_upload_service.py +326 -0
  213. quantum/runtime/function_registry.py +118 -0
  214. quantum/runtime/game_builder.py +166 -0
  215. quantum/runtime/game_code_generator.py +2371 -0
  216. quantum/runtime/game_templates.py +2006 -0
  217. quantum/runtime/godot_code_generator.py +4681 -0
  218. quantum/runtime/godot_templates.py +1449 -0
  219. quantum/runtime/job_executor.py +1599 -0
  220. quantum/runtime/knowledge_service.py +500 -0
  221. quantum/runtime/llm_cache.py +100 -0
  222. quantum/runtime/llm_providers.py +704 -0
  223. quantum/runtime/llm_service.py +287 -0
  224. quantum/runtime/logging_setup.py +140 -0
  225. quantum/runtime/message_broker.py +364 -0
  226. quantum/runtime/message_queue_service.py +571 -0
  227. quantum/runtime/param_validation.py +184 -0
  228. quantum/runtime/pypy_compat.py +315 -0
  229. quantum/runtime/python_bridge.py +698 -0
  230. quantum/runtime/query_validators.py +304 -0
  231. quantum/runtime/renderer.py +733 -0
  232. quantum/runtime/service_container.py +444 -0
  233. quantum/runtime/terminal_builder.py +76 -0
  234. quantum/runtime/terminal_code_generator.py +607 -0
  235. quantum/runtime/terminal_templates.py +243 -0
  236. quantum/runtime/testing_builder.py +77 -0
  237. quantum/runtime/testing_code_generator.py +833 -0
  238. quantum/runtime/testing_templates.py +85 -0
  239. quantum/runtime/ui_builder.py +188 -0
  240. quantum/runtime/ui_desktop_adapter.py +1730 -0
  241. quantum/runtime/ui_desktop_templates.py +307 -0
  242. quantum/runtime/ui_html_adapter.py +2691 -0
  243. quantum/runtime/ui_html_templates.py +2297 -0
  244. quantum/runtime/ui_mobile_adapter.py +1832 -0
  245. quantum/runtime/ui_mobile_templates.py +1003 -0
  246. quantum/runtime/ui_textual_adapter.py +1866 -0
  247. quantum/runtime/ui_textual_templates.py +45 -0
  248. quantum/runtime/ui_tokens.py +465 -0
  249. quantum/runtime/ui_validator.py +365 -0
  250. quantum/runtime/validators.py +256 -0
  251. quantum/runtime/web_server.py +1766 -0
  252. quantum/runtime/websocket_adapter.py +501 -0
  253. quantum/runtime/websocket_service.py +585 -0
  254. quantum/runtime/websocket_transport.py +289 -0
  255. quantum/runtime/wsgi.py +101 -0
  256. quantum/utils/__init__.py +1 -0
  257. quantum_framework-0.9.0.dist-info/METADATA +244 -0
  258. quantum_framework-0.9.0.dist-info/RECORD +262 -0
  259. quantum_framework-0.9.0.dist-info/WHEEL +5 -0
  260. quantum_framework-0.9.0.dist-info/entry_points.txt +2 -0
  261. quantum_framework-0.9.0.dist-info/licenses/LICENSE +21 -0
  262. quantum_framework-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,527 @@
1
+ """
2
+ Quantum CLI - Database Migration Commands
3
+ Handles database migrations with automatic PostgreSQL/SQLite failover
4
+ """
5
+ import os
6
+ import sys
7
+ import hashlib
8
+ import logging
9
+ from pathlib import Path
10
+ from datetime import datetime
11
+ from typing import Optional, List, Dict, Any
12
+
13
+ import click
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class MigrationError(Exception):
19
+ """Migration operation failed"""
20
+ pass
21
+
22
+
23
+ class Migration:
24
+ """Represents a single migration file"""
25
+
26
+ def __init__(self, version: str, name: str, path: Path):
27
+ self.version = version
28
+ self.name = name
29
+ self.path = path
30
+ self._content = None
31
+
32
+ @property
33
+ def content(self) -> str:
34
+ if self._content is None:
35
+ self._content = self.path.read_text()
36
+ return self._content
37
+
38
+ @property
39
+ def checksum(self) -> str:
40
+ return hashlib.md5(self.content.encode()).hexdigest()
41
+
42
+ def __repr__(self):
43
+ return f"<Migration {self.version}: {self.name}>"
44
+
45
+
46
+ class DatabaseConnection:
47
+ """Database connection with automatic failover"""
48
+
49
+ def __init__(self, project_path: Path = None):
50
+ self.project_path = project_path or Path.cwd()
51
+ self._conn = None
52
+ self._db_type = None
53
+
54
+ def connect(self) -> Any:
55
+ """
56
+ Connect to database with automatic failover.
57
+ Tries PostgreSQL first, falls back to SQLite.
58
+ """
59
+ # Try to load database config from quantum.config.yaml
60
+ config = self._load_config()
61
+
62
+ # Try PostgreSQL
63
+ if self._try_postgres(config):
64
+ return self._conn
65
+
66
+ # Fallback to SQLite
67
+ logger.warning("PostgreSQL unavailable, falling back to SQLite")
68
+ if self._try_sqlite(config):
69
+ return self._conn
70
+
71
+ raise MigrationError("Could not connect to any database")
72
+
73
+ def _load_config(self) -> Dict:
74
+ """Load database configuration from quantum.config.yaml"""
75
+ config_path = self.project_path / "quantum.config.yaml"
76
+ if not config_path.exists():
77
+ return {}
78
+
79
+ try:
80
+ import yaml
81
+ with open(config_path) as f:
82
+ config = yaml.safe_load(f) or {}
83
+ return config.get('database', {})
84
+ except Exception as e:
85
+ logger.warning(f"Could not load config: {e}")
86
+ return {}
87
+
88
+ def _try_postgres(self, config: Dict) -> bool:
89
+ """Try to connect to PostgreSQL"""
90
+ try:
91
+ import psycopg2
92
+
93
+ # Get connection params from config or environment
94
+ host = config.get('host') or os.environ.get('QUANTUM_DB_HOST', 'localhost')
95
+ port = config.get('port') or os.environ.get('QUANTUM_DB_PORT', '5432')
96
+ database = config.get('database') or os.environ.get('QUANTUM_DB_NAME', 'quantum')
97
+ user = config.get('user') or os.environ.get('QUANTUM_DB_USER', 'postgres')
98
+ password = config.get('password') or os.environ.get('QUANTUM_DB_PASSWORD', '')
99
+
100
+ self._conn = psycopg2.connect(
101
+ host=host,
102
+ port=int(port),
103
+ database=database,
104
+ user=user,
105
+ password=password,
106
+ connect_timeout=5
107
+ )
108
+ self._db_type = 'postgres'
109
+ logger.info(f"Connected to PostgreSQL at {host}:{port}/{database}")
110
+ return True
111
+
112
+ except ImportError:
113
+ logger.debug("psycopg2 not installed")
114
+ return False
115
+ except Exception as e:
116
+ logger.debug(f"PostgreSQL connection failed: {e}")
117
+ return False
118
+
119
+ def _try_sqlite(self, config: Dict) -> bool:
120
+ """Try to connect to SQLite"""
121
+ try:
122
+ import sqlite3
123
+
124
+ db_path = config.get('sqlite_path') or self.project_path / "data" / "quantum.db"
125
+ db_path = Path(db_path)
126
+ db_path.parent.mkdir(parents=True, exist_ok=True)
127
+
128
+ self._conn = sqlite3.connect(str(db_path))
129
+ self._db_type = 'sqlite'
130
+ logger.info(f"Connected to SQLite at {db_path}")
131
+ return True
132
+
133
+ except Exception as e:
134
+ logger.error(f"SQLite connection failed: {e}")
135
+ return False
136
+
137
+ @property
138
+ def db_type(self) -> str:
139
+ return self._db_type
140
+
141
+ def execute(self, sql: str, params: tuple = None):
142
+ """Execute SQL statement"""
143
+ cursor = self._conn.cursor()
144
+ if params:
145
+ cursor.execute(sql, params)
146
+ else:
147
+ cursor.execute(sql)
148
+ return cursor
149
+
150
+ def commit(self):
151
+ self._conn.commit()
152
+
153
+ def rollback(self):
154
+ self._conn.rollback()
155
+
156
+ def close(self):
157
+ if self._conn:
158
+ self._conn.close()
159
+
160
+
161
+ class MigrationRunner:
162
+ """Runs database migrations"""
163
+
164
+ MIGRATIONS_TABLE = "_migrations"
165
+
166
+ def __init__(self, project_path: Path = None):
167
+ self.project_path = project_path or Path.cwd()
168
+ self.migrations_dir = self.project_path / "migrations"
169
+ self.db = DatabaseConnection(self.project_path)
170
+
171
+ def _ensure_migrations_table(self):
172
+ """Create migrations tracking table if it doesn't exist"""
173
+ if self.db.db_type == 'postgres':
174
+ sql = f"""
175
+ CREATE TABLE IF NOT EXISTS {self.MIGRATIONS_TABLE} (
176
+ version VARCHAR(50) PRIMARY KEY,
177
+ name VARCHAR(255) NOT NULL,
178
+ checksum VARCHAR(32) NOT NULL,
179
+ applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
180
+ )
181
+ """
182
+ else: # SQLite
183
+ sql = f"""
184
+ CREATE TABLE IF NOT EXISTS {self.MIGRATIONS_TABLE} (
185
+ version TEXT PRIMARY KEY,
186
+ name TEXT NOT NULL,
187
+ checksum TEXT NOT NULL,
188
+ applied_at TEXT DEFAULT CURRENT_TIMESTAMP
189
+ )
190
+ """
191
+ self.db.execute(sql)
192
+ self.db.commit()
193
+
194
+ def _get_applied_migrations(self) -> Dict[str, Dict]:
195
+ """Get list of already applied migrations"""
196
+ cursor = self.db.execute(
197
+ f"SELECT version, name, checksum, applied_at FROM {self.MIGRATIONS_TABLE} ORDER BY version"
198
+ )
199
+ rows = cursor.fetchall()
200
+ return {
201
+ row[0]: {
202
+ 'version': row[0],
203
+ 'name': row[1],
204
+ 'checksum': row[2],
205
+ 'applied_at': row[3]
206
+ }
207
+ for row in rows
208
+ }
209
+
210
+ def _get_pending_migrations(self) -> List[Migration]:
211
+ """Get list of migrations that need to be applied"""
212
+ if not self.migrations_dir.exists():
213
+ return []
214
+
215
+ applied = self._get_applied_migrations()
216
+ pending = []
217
+
218
+ for f in sorted(self.migrations_dir.glob("*.sql")):
219
+ # `create` writes a PAIR: V001_name.sql and V001_name.down.sql.
220
+ # This glob used to pick up both, and the rollback file sorted
221
+ # FIRST ('.down.sql' < '.sql'), so `migrate up` executed the
222
+ # rollback — DROP TABLE and all — before the migration it belongs
223
+ # to, and then recorded the version as applied. On an already
224
+ # migrated database that is data destruction from a command whose
225
+ # entire job is to move forward. Reproduced before fixing.
226
+ if f.name.endswith('.down.sql'):
227
+ continue
228
+
229
+ # Expected format: V001_create_users.sql
230
+ parts = f.stem.split('_', 1)
231
+ if len(parts) < 2:
232
+ continue
233
+
234
+ version = parts[0]
235
+ name = parts[1]
236
+
237
+ if version not in applied:
238
+ pending.append(Migration(version, name, f))
239
+
240
+ return pending
241
+
242
+ def _record_migration(self, migration: Migration):
243
+ """Record that a migration was applied"""
244
+ if self.db.db_type == 'postgres':
245
+ sql = f"""
246
+ INSERT INTO {self.MIGRATIONS_TABLE} (version, name, checksum)
247
+ VALUES (%s, %s, %s)
248
+ """
249
+ else: # SQLite
250
+ sql = f"""
251
+ INSERT INTO {self.MIGRATIONS_TABLE} (version, name, checksum)
252
+ VALUES (?, ?, ?)
253
+ """
254
+ self.db.execute(sql, (migration.version, migration.name, migration.checksum))
255
+
256
+ def _remove_migration_record(self, version: str):
257
+ """Remove migration record for rollback"""
258
+ if self.db.db_type == 'postgres':
259
+ self.db.execute(f"DELETE FROM {self.MIGRATIONS_TABLE} WHERE version = %s", (version,))
260
+ else:
261
+ self.db.execute(f"DELETE FROM {self.MIGRATIONS_TABLE} WHERE version = ?", (version,))
262
+
263
+ def status(self) -> Dict:
264
+ """Get migration status"""
265
+ self.db.connect()
266
+ self._ensure_migrations_table()
267
+
268
+ applied = self._get_applied_migrations()
269
+ pending = self._get_pending_migrations()
270
+
271
+ self.db.close()
272
+
273
+ return {
274
+ 'database_type': self.db.db_type,
275
+ 'applied': list(applied.values()),
276
+ 'pending': [{'version': m.version, 'name': m.name} for m in pending],
277
+ 'total_applied': len(applied),
278
+ 'total_pending': len(pending)
279
+ }
280
+
281
+ def up(self, count: int = None) -> List[Dict]:
282
+ """Apply pending migrations"""
283
+ self.db.connect()
284
+ self._ensure_migrations_table()
285
+
286
+ pending = self._get_pending_migrations()
287
+ if count:
288
+ pending = pending[:count]
289
+
290
+ results = []
291
+
292
+ for migration in pending:
293
+ try:
294
+ click.echo(f"Applying {migration.version}: {migration.name}...")
295
+
296
+ # Execute migration SQL
297
+ self.db.execute(migration.content)
298
+
299
+ # Record migration
300
+ self._record_migration(migration)
301
+ self.db.commit()
302
+
303
+ results.append({
304
+ 'version': migration.version,
305
+ 'name': migration.name,
306
+ 'status': 'applied',
307
+ 'checksum': migration.checksum
308
+ })
309
+
310
+ click.echo(f" [OK] Applied {migration.version}")
311
+
312
+ except Exception as e:
313
+ self.db.rollback()
314
+ results.append({
315
+ 'version': migration.version,
316
+ 'name': migration.name,
317
+ 'status': 'failed',
318
+ 'error': str(e)
319
+ })
320
+ click.echo(f" [FAILED] {e}")
321
+ break # Stop on first failure
322
+
323
+ self.db.close()
324
+ return results
325
+
326
+ def down(self, count: int = 1) -> List[Dict]:
327
+ """Rollback migrations"""
328
+ self.db.connect()
329
+ self._ensure_migrations_table()
330
+
331
+ applied = self._get_applied_migrations()
332
+ if not applied:
333
+ click.echo("No migrations to rollback")
334
+ self.db.close()
335
+ return []
336
+
337
+ # Get migrations in reverse order
338
+ versions = sorted(applied.keys(), reverse=True)[:count]
339
+ results = []
340
+
341
+ for version in versions:
342
+ migration_info = applied[version]
343
+
344
+ # Look for down migration file
345
+ down_file = self.migrations_dir / f"{version}_{migration_info['name']}.down.sql"
346
+
347
+ if not down_file.exists():
348
+ click.echo(f"Warning: No down migration for {version}")
349
+ results.append({
350
+ 'version': version,
351
+ 'status': 'skipped',
352
+ 'reason': 'No down migration file'
353
+ })
354
+ continue
355
+
356
+ try:
357
+ click.echo(f"Rolling back {version}: {migration_info['name']}...")
358
+
359
+ # Execute rollback SQL
360
+ self.db.execute(down_file.read_text())
361
+
362
+ # Remove migration record
363
+ self._remove_migration_record(version)
364
+ self.db.commit()
365
+
366
+ results.append({
367
+ 'version': version,
368
+ 'name': migration_info['name'],
369
+ 'status': 'rolled_back'
370
+ })
371
+
372
+ click.echo(f" [OK] Rolled back {version}")
373
+
374
+ except Exception as e:
375
+ self.db.rollback()
376
+ results.append({
377
+ 'version': version,
378
+ 'status': 'failed',
379
+ 'error': str(e)
380
+ })
381
+ click.echo(f" [FAILED] {e}")
382
+ break
383
+
384
+ self.db.close()
385
+ return results
386
+
387
+ def create(self, name: str) -> Path:
388
+ """Create a new migration file"""
389
+ self.migrations_dir.mkdir(parents=True, exist_ok=True)
390
+
391
+ # Get next version number
392
+ existing = sorted(self.migrations_dir.glob("V*.sql"))
393
+ if existing:
394
+ last_version = existing[-1].stem.split('_')[0]
395
+ next_num = int(last_version[1:]) + 1
396
+ else:
397
+ next_num = 1
398
+
399
+ version = f"V{next_num:03d}"
400
+ filename = f"{version}_{name}.sql"
401
+ filepath = self.migrations_dir / filename
402
+
403
+ # Create template
404
+ template = f"""-- Migration: {name}
405
+ -- Version: {version}
406
+ -- Created: {datetime.now().isoformat()}
407
+
408
+ -- Write your SQL here
409
+
410
+ """
411
+ filepath.write_text(template)
412
+
413
+ # Also create down file
414
+ down_filepath = self.migrations_dir / f"{version}_{name}.down.sql"
415
+ down_template = f"""-- Rollback Migration: {name}
416
+ -- Version: {version}
417
+
418
+ -- Write your rollback SQL here
419
+
420
+ """
421
+ down_filepath.write_text(down_template)
422
+
423
+ return filepath
424
+
425
+
426
+ # CLI Commands
427
+
428
+ @click.group()
429
+ def migrate():
430
+ """Database migration commands"""
431
+ pass
432
+
433
+
434
+ @migrate.command()
435
+ @click.option('--json', 'as_json', is_flag=True, help='Output as JSON')
436
+ def status(as_json: bool):
437
+ """Show migration status"""
438
+ runner = MigrationRunner()
439
+
440
+ try:
441
+ result = runner.status()
442
+
443
+ if as_json:
444
+ import json
445
+ click.echo(json.dumps(result, indent=2, default=str))
446
+ else:
447
+ click.echo(f"\nDatabase: {result['database_type']}")
448
+ click.echo(f"Applied migrations: {result['total_applied']}")
449
+ click.echo(f"Pending migrations: {result['total_pending']}")
450
+
451
+ if result['applied']:
452
+ click.echo("\nApplied:")
453
+ for m in result['applied']:
454
+ click.echo(f" {m['version']}: {m['name']} (applied: {m['applied_at']})")
455
+
456
+ if result['pending']:
457
+ click.echo("\nPending:")
458
+ for m in result['pending']:
459
+ click.echo(f" {m['version']}: {m['name']}")
460
+
461
+ except Exception as e:
462
+ click.echo(f"Error: {e}", err=True)
463
+ sys.exit(1)
464
+
465
+
466
+ @migrate.command()
467
+ @click.option('--count', '-n', type=int, help='Number of migrations to apply')
468
+ def up(count: Optional[int]):
469
+ """Apply pending migrations"""
470
+ runner = MigrationRunner()
471
+
472
+ try:
473
+ results = runner.up(count=count)
474
+
475
+ applied = sum(1 for r in results if r.get('status') == 'applied')
476
+ failed = sum(1 for r in results if r.get('status') == 'failed')
477
+
478
+ click.echo(f"\nApplied: {applied}, Failed: {failed}")
479
+
480
+ if failed:
481
+ sys.exit(1)
482
+
483
+ except Exception as e:
484
+ click.echo(f"Error: {e}", err=True)
485
+ sys.exit(1)
486
+
487
+
488
+ @migrate.command()
489
+ @click.option('--count', '-n', type=int, default=1, help='Number of migrations to rollback')
490
+ def down(count: int):
491
+ """Rollback migrations"""
492
+ runner = MigrationRunner()
493
+
494
+ try:
495
+ results = runner.down(count=count)
496
+
497
+ rolled_back = sum(1 for r in results if r.get('status') == 'rolled_back')
498
+ failed = sum(1 for r in results if r.get('status') == 'failed')
499
+
500
+ click.echo(f"\nRolled back: {rolled_back}, Failed: {failed}")
501
+
502
+ if failed:
503
+ sys.exit(1)
504
+
505
+ except Exception as e:
506
+ click.echo(f"Error: {e}", err=True)
507
+ sys.exit(1)
508
+
509
+
510
+ @migrate.command()
511
+ @click.argument('name')
512
+ def create(name: str):
513
+ """Create a new migration"""
514
+ runner = MigrationRunner()
515
+
516
+ try:
517
+ filepath = runner.create(name)
518
+ click.echo(f"Created migration: {filepath}")
519
+ click.echo(f"Also created rollback: {filepath.with_suffix('.down.sql')}")
520
+
521
+ except Exception as e:
522
+ click.echo(f"Error: {e}", err=True)
523
+ sys.exit(1)
524
+
525
+
526
+ if __name__ == '__main__':
527
+ migrate()