quantum-framework 0.18.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.
- quantum/__init__.py +1 -0
- quantum/cli/__init__.py +2 -0
- quantum/cli/admin.py +131 -0
- quantum/cli/check.py +223 -0
- quantum/cli/commands/__init__.py +15 -0
- quantum/cli/commands/build.py +414 -0
- quantum/cli/commands/dev.py +185 -0
- quantum/cli/commands/docs.py +329 -0
- quantum/cli/commands/lint.py +523 -0
- quantum/cli/commands/migrate.py +548 -0
- quantum/cli/commands/new.py +622 -0
- quantum/cli/commands/serve.py +190 -0
- quantum/cli/commands/test.py +193 -0
- quantum/cli/deploy.py +810 -0
- quantum/cli/hot_reload.py +951 -0
- quantum/cli/jobs.py +356 -0
- quantum/cli/mq.py +582 -0
- quantum/cli/pkg.py +390 -0
- quantum/cli/runner.py +565 -0
- quantum/cli/server_process.py +159 -0
- quantum/cli/utils.py +334 -0
- quantum/compiler/__init__.py +30 -0
- quantum/compiler/base_generator.py +367 -0
- quantum/compiler/cli.py +295 -0
- quantum/compiler/expression_transformer.py +444 -0
- quantum/compiler/javascript/__init__.py +10 -0
- quantum/compiler/javascript/generator.py +659 -0
- quantum/compiler/optimizer.py +270 -0
- quantum/compiler/python/__init__.py +10 -0
- quantum/compiler/python/generator.py +883 -0
- quantum/compiler/python/runtime.py +863 -0
- quantum/compiler/transpiler.py +330 -0
- quantum/core/__init__.py +3 -0
- quantum/core/ast_nodes.py +2619 -0
- quantum/core/config_env.py +54 -0
- quantum/core/docs_links.py +8 -0
- quantum/core/expression_diagnostics.py +87 -0
- quantum/core/expression_stdlib.py +208 -0
- quantum/core/expressions.py +677 -0
- quantum/core/features/agents/src/__init__.py +30 -0
- quantum/core/features/agents/src/ast_node.py +540 -0
- quantum/core/features/conditionals/src/__init__.py +8 -0
- quantum/core/features/conditionals/src/ast_node.py +68 -0
- quantum/core/features/data_import/src/__init__.py +0 -0
- quantum/core/features/data_import/src/ast_node.py +292 -0
- quantum/core/features/data_import/src/runtime.py +533 -0
- quantum/core/features/dump/src/__init__.py +12 -0
- quantum/core/features/dump/src/ast_node.py +106 -0
- quantum/core/features/dump/src/parser.py +61 -0
- quantum/core/features/dump/src/runtime.py +246 -0
- quantum/core/features/functions/src/__init__.py +8 -0
- quantum/core/features/functions/src/ast_node.py +149 -0
- quantum/core/features/game_engine_2d/src/__init__.py +19 -0
- quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
- quantum/core/features/game_engine_2d/src/parser.py +983 -0
- quantum/core/features/invocation/src/__init__.py +0 -0
- quantum/core/features/invocation/src/ast_node.py +147 -0
- quantum/core/features/invocation/src/runtime.py +332 -0
- quantum/core/features/knowledge_base/src/__init__.py +6 -0
- quantum/core/features/knowledge_base/src/ast_node.py +113 -0
- quantum/core/features/knowledge_base/src/parser.py +82 -0
- quantum/core/features/logging/src/__init__.py +12 -0
- quantum/core/features/logging/src/ast_node.py +111 -0
- quantum/core/features/logging/src/parser.py +50 -0
- quantum/core/features/logging/src/runtime.py +190 -0
- quantum/core/features/loops/src/__init__.py +8 -0
- quantum/core/features/loops/src/ast_node.py +60 -0
- quantum/core/features/query/src/__init__.py +0 -0
- quantum/core/features/query/src/query_validators.py +20 -0
- quantum/core/features/state_management/src/__init__.py +11 -0
- quantum/core/features/state_management/src/ast_node.py +121 -0
- quantum/core/features/terminal_engine/src/__init__.py +21 -0
- quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
- quantum/core/features/terminal_engine/src/parser.py +361 -0
- quantum/core/features/testing_engine/src/__init__.py +41 -0
- quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
- quantum/core/features/testing_engine/src/parser.py +604 -0
- quantum/core/features/theming/src/__init__.py +48 -0
- quantum/core/features/theming/src/ast_node.py +137 -0
- quantum/core/features/theming/src/presets.py +405 -0
- quantum/core/features/ui_engine/src/__init__.py +20 -0
- quantum/core/features/ui_engine/src/ast_nodes.py +1913 -0
- quantum/core/features/ui_engine/src/parser.py +1192 -0
- quantum/core/features/websocket/src/__init__.py +24 -0
- quantum/core/features/websocket/src/ast_node.py +247 -0
- quantum/core/guards.py +53 -0
- quantum/core/html_compat.py +299 -0
- quantum/core/parser.py +1551 -0
- quantum/core/parser_registry.py +213 -0
- quantum/core/parsers/__init__.py +76 -0
- quantum/core/parsers/ai/__init__.py +12 -0
- quantum/core/parsers/ai/agent_parser.py +106 -0
- quantum/core/parsers/ai/knowledge_parser.py +86 -0
- quantum/core/parsers/ai/llm_parser.py +78 -0
- quantum/core/parsers/ai/team_parser.py +88 -0
- quantum/core/parsers/base.py +322 -0
- quantum/core/parsers/composition/__init__.py +10 -0
- quantum/core/parsers/composition/import_parser.py +50 -0
- quantum/core/parsers/composition/slot_parser.py +48 -0
- quantum/core/parsers/control_flow/__init__.py +11 -0
- quantum/core/parsers/control_flow/if_parser.py +71 -0
- quantum/core/parsers/control_flow/loop_parser.py +105 -0
- quantum/core/parsers/control_flow/set_parser.py +93 -0
- quantum/core/parsers/data/__init__.py +12 -0
- quantum/core/parsers/data/data_parser.py +229 -0
- quantum/core/parsers/data/invoke_parser.py +133 -0
- quantum/core/parsers/data/query_parser.py +213 -0
- quantum/core/parsers/data/transaction_parser.py +86 -0
- quantum/core/parsers/events/__init__.py +9 -0
- quantum/core/parsers/events/dispatch_event_parser.py +44 -0
- quantum/core/parsers/forms/__init__.py +11 -0
- quantum/core/parsers/forms/action_parser.py +79 -0
- quantum/core/parsers/forms/flash_parser.py +34 -0
- quantum/core/parsers/forms/redirect_parser.py +33 -0
- quantum/core/parsers/functions/__init__.py +11 -0
- quantum/core/parsers/functions/function_parser.py +98 -0
- quantum/core/parsers/functions/param_parser.py +73 -0
- quantum/core/parsers/functions/return_parser.py +31 -0
- quantum/core/parsers/html/__init__.py +10 -0
- quantum/core/parsers/html/component_call_parser.py +130 -0
- quantum/core/parsers/html/html_parser.py +115 -0
- quantum/core/parsers/jobs/__init__.py +11 -0
- quantum/core/parsers/jobs/job_parser.py +71 -0
- quantum/core/parsers/jobs/schedule_parser.py +62 -0
- quantum/core/parsers/jobs/thread_parser.py +57 -0
- quantum/core/parsers/messaging/__init__.py +17 -0
- quantum/core/parsers/messaging/message_ack_parser.py +30 -0
- quantum/core/parsers/messaging/message_nack_parser.py +30 -0
- quantum/core/parsers/messaging/message_parser.py +114 -0
- quantum/core/parsers/messaging/queue_parser.py +61 -0
- quantum/core/parsers/messaging/websocket_parser.py +121 -0
- quantum/core/parsers/routing/__init__.py +9 -0
- quantum/core/parsers/routing/route_parser.py +41 -0
- quantum/core/parsers/scripting/__init__.py +12 -0
- quantum/core/parsers/scripting/pyclass_parser.py +62 -0
- quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
- quantum/core/parsers/scripting/pyimport_parser.py +52 -0
- quantum/core/parsers/scripting/python_parser.py +49 -0
- quantum/core/parsers/services/__init__.py +12 -0
- quantum/core/parsers/services/dump_parser.py +52 -0
- quantum/core/parsers/services/file_parser.py +48 -0
- quantum/core/parsers/services/log_parser.py +46 -0
- quantum/core/parsers/services/mail_parser.py +65 -0
- quantum/core/tiers.py +164 -0
- quantum/core/xml_lines.py +81 -0
- quantum/packages/__init__.py +28 -0
- quantum/packages/manager.py +413 -0
- quantum/packages/manifest.py +351 -0
- quantum/packages/registry.py +399 -0
- quantum/packages/resolver.py +336 -0
- quantum/plugins/__init__.py +33 -0
- quantum/plugins/hooks.py +329 -0
- quantum/plugins/loader.py +479 -0
- quantum/plugins/manifest.py +336 -0
- quantum/plugins/registry.py +371 -0
- quantum/runtime/__init__.py +25 -0
- quantum/runtime/action_handler.py +532 -0
- quantum/runtime/adapters/__init__.py +88 -0
- quantum/runtime/adapters/memory_adapter.py +690 -0
- quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
- quantum/runtime/adapters/redis_adapter.py +582 -0
- quantum/runtime/adapters/sqlite_adapter.py +414 -0
- quantum/runtime/agent_service.py +1133 -0
- quantum/runtime/ast_cache.py +506 -0
- quantum/runtime/auth_service.py +267 -0
- quantum/runtime/component.py +1048 -0
- quantum/runtime/component_composer.py +133 -0
- quantum/runtime/component_resolver.py +174 -0
- quantum/runtime/conditions.py +54 -0
- quantum/runtime/database_service.py +595 -0
- quantum/runtime/db_schema.py +100 -0
- quantum/runtime/dev_panel.py +178 -0
- quantum/runtime/email_service.py +162 -0
- quantum/runtime/error_handler.py +295 -0
- quantum/runtime/execution_context.py +283 -0
- quantum/runtime/executor_registry.py +178 -0
- quantum/runtime/executors/__init__.py +71 -0
- quantum/runtime/executors/ai/__init__.py +12 -0
- quantum/runtime/executors/ai/agent_executor.py +217 -0
- quantum/runtime/executors/ai/knowledge_executor.py +114 -0
- quantum/runtime/executors/ai/llm_executor.py +153 -0
- quantum/runtime/executors/ai/team_executor.py +171 -0
- quantum/runtime/executors/base.py +264 -0
- quantum/runtime/executors/control_flow/__init__.py +12 -0
- quantum/runtime/executors/control_flow/if_executor.py +98 -0
- quantum/runtime/executors/control_flow/loop_executor.py +308 -0
- quantum/runtime/executors/control_flow/redirect_executor.py +43 -0
- quantum/runtime/executors/control_flow/set_executor.py +464 -0
- quantum/runtime/executors/data/__init__.py +12 -0
- quantum/runtime/executors/data/data_executor.py +156 -0
- quantum/runtime/executors/data/invoke_executor.py +228 -0
- quantum/runtime/executors/data/query_executor.py +290 -0
- quantum/runtime/executors/data/transaction_executor.py +91 -0
- quantum/runtime/executors/jobs/__init__.py +11 -0
- quantum/runtime/executors/jobs/job_executor.py +190 -0
- quantum/runtime/executors/jobs/schedule_executor.py +132 -0
- quantum/runtime/executors/jobs/thread_executor.py +127 -0
- quantum/runtime/executors/messaging/__init__.py +17 -0
- quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
- quantum/runtime/executors/messaging/message_executor.py +174 -0
- quantum/runtime/executors/messaging/queue_executor.py +103 -0
- quantum/runtime/executors/messaging/websocket_executor.py +197 -0
- quantum/runtime/executors/scripting/__init__.py +11 -0
- quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
- quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
- quantum/runtime/executors/scripting/python_executor.py +249 -0
- quantum/runtime/executors/services/__init__.py +12 -0
- quantum/runtime/executors/services/dump_executor.py +72 -0
- quantum/runtime/executors/services/file_executor.py +89 -0
- quantum/runtime/executors/services/log_executor.py +77 -0
- quantum/runtime/executors/services/mail_executor.py +81 -0
- quantum/runtime/expression_cache.py +498 -0
- quantum/runtime/file_upload_service.py +326 -0
- quantum/runtime/function_registry.py +118 -0
- quantum/runtime/game_builder.py +166 -0
- quantum/runtime/game_code_generator.py +2371 -0
- quantum/runtime/game_templates.py +2006 -0
- quantum/runtime/godot_code_generator.py +4681 -0
- quantum/runtime/godot_templates.py +1449 -0
- quantum/runtime/job_executor.py +1599 -0
- quantum/runtime/knowledge_service.py +546 -0
- quantum/runtime/llm_cache.py +100 -0
- quantum/runtime/llm_providers.py +704 -0
- quantum/runtime/llm_service.py +287 -0
- quantum/runtime/local_server.py +28 -0
- quantum/runtime/logging_setup.py +141 -0
- quantum/runtime/message_broker.py +364 -0
- quantum/runtime/message_queue_service.py +571 -0
- quantum/runtime/param_validation.py +218 -0
- quantum/runtime/pypy_compat.py +317 -0
- quantum/runtime/python_bridge.py +698 -0
- quantum/runtime/query_validators.py +304 -0
- quantum/runtime/renderer.py +734 -0
- quantum/runtime/service_container.py +470 -0
- quantum/runtime/table_params.py +163 -0
- quantum/runtime/terminal_builder.py +76 -0
- quantum/runtime/terminal_code_generator.py +607 -0
- quantum/runtime/terminal_templates.py +243 -0
- quantum/runtime/testing_builder.py +77 -0
- quantum/runtime/testing_code_generator.py +833 -0
- quantum/runtime/testing_templates.py +85 -0
- quantum/runtime/ui_builder.py +222 -0
- quantum/runtime/ui_console.py +405 -0
- quantum/runtime/ui_desktop.py +56 -0
- quantum/runtime/ui_form_rules.py +193 -0
- quantum/runtime/ui_html_adapter.py +2567 -0
- quantum/runtime/ui_html_templates.py +2106 -0
- quantum/runtime/ui_mobile_adapter.py +1832 -0
- quantum/runtime/ui_mobile_templates.py +1003 -0
- quantum/runtime/ui_page_renderer.py +392 -0
- quantum/runtime/ui_pager.py +73 -0
- quantum/runtime/ui_table_edit.py +139 -0
- quantum/runtime/ui_textual_adapter.py +1866 -0
- quantum/runtime/ui_textual_templates.py +45 -0
- quantum/runtime/ui_tokens.py +465 -0
- quantum/runtime/ui_validator.py +365 -0
- quantum/runtime/ui_view.py +221 -0
- quantum/runtime/validators.py +256 -0
- quantum/runtime/web_server.py +2131 -0
- quantum/runtime/websocket_adapter.py +501 -0
- quantum/runtime/websocket_service.py +585 -0
- quantum/runtime/websocket_transport.py +289 -0
- quantum/runtime/wsgi.py +101 -0
- quantum/services.py +109 -0
- quantum/utils/__init__.py +1 -0
- quantum_admin/__init__.py +4 -0
- quantum_admin/assets/quantum-admin-connectors.js +123 -0
- quantum_admin/assets/quantum-admin.css +2058 -0
- quantum_admin/assets/quantum-source-highlight.js +106 -0
- quantum_admin/backend/__init__.py +4 -0
- quantum_admin/backend/audit_service.py +346 -0
- quantum_admin/backend/auth_service.py +394 -0
- quantum_admin/backend/component_discovery.py +766 -0
- quantum_admin/backend/config_generator.py +586 -0
- quantum_admin/backend/connector_service.py +1016 -0
- quantum_admin/backend/crud.py +557 -0
- quantum_admin/backend/database.py +226 -0
- quantum_admin/backend/db_setup_service.py +193 -0
- quantum_admin/backend/deploy_service.py +698 -0
- quantum_admin/backend/docker_service.py +422 -0
- quantum_admin/backend/environment_service.py +418 -0
- quantum_admin/backend/git_service.py +370 -0
- quantum_admin/backend/health_service.py +390 -0
- quantum_admin/backend/job_service.py +347 -0
- quantum_admin/backend/log_service.py +331 -0
- quantum_admin/backend/main.py +14426 -0
- quantum_admin/backend/models.py +986 -0
- quantum_admin/backend/pipeline_service.py +812 -0
- quantum_admin/backend/resource_manager.py +1118 -0
- quantum_admin/backend/schemas.py +419 -0
- quantum_admin/backend/secret_manager.py +277 -0
- quantum_admin/backend/settings_service.py +488 -0
- quantum_admin/backend/template_service.py +710 -0
- quantum_admin/backend/test_execution_service.py +364 -0
- quantum_admin/backend/test_generator.py +782 -0
- quantum_admin/backend/webhook_service.py +454 -0
- quantum_admin/backend/websocket_manager.py +360 -0
- quantum_admin/components/admin/_layout/AdminShell.q +121 -0
- quantum_admin/components/admin/agents.q +107 -0
- quantum_admin/components/admin/app/[name].q +558 -0
- quantum_admin/components/admin/applications.q +160 -0
- quantum_admin/components/admin/component/[...path].q +205 -0
- quantum_admin/components/admin/components.q +59 -0
- quantum_admin/components/admin/connectors.q +283 -0
- quantum_admin/components/admin/dashboard.q +86 -0
- quantum_admin/components/admin/database.q +95 -0
- quantum_admin/components/admin/features.q +69 -0
- quantum_admin/components/admin/index.q +12 -0
- quantum_admin/components/admin/jobs.q +75 -0
- quantum_admin/components/admin/login.q +65 -0
- quantum_admin/components/admin/logout.q +11 -0
- quantum_admin/components/admin/settings.q +174 -0
- quantum_admin/components/admin/source.q +45 -0
- quantum_admin/components/admin/tests.q +73 -0
- quantum_admin/run.py +13 -0
- quantum_admin/services/__init__.py +16 -0
- quantum_admin/services/_base.py +84 -0
- quantum_admin/services/_component_test_generator.py +205 -0
- quantum_admin/services/_yaml_editor.py +82 -0
- quantum_admin/services/apps.py +330 -0
- quantum_admin/services/auth.py +61 -0
- quantum_admin/services/components.py +210 -0
- quantum_admin/services/connectors.py +164 -0
- quantum_admin/services/projects.py +189 -0
- quantum_admin/services/repository.py +223 -0
- quantum_admin/services/settings.py +150 -0
- quantum_admin/services/yaml_import.py +118 -0
- quantum_framework-0.18.0.dist-info/METADATA +281 -0
- quantum_framework-0.18.0.dist-info/RECORD +333 -0
- quantum_framework-0.18.0.dist-info/WHEEL +5 -0
- quantum_framework-0.18.0.dist-info/entry_points.txt +2 -0
- quantum_framework-0.18.0.dist-info/licenses/LICENSE +21 -0
- quantum_framework-0.18.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Quantum CLI - Database Migration Commands
|
|
3
|
+
Applies migrations to a datasource declared in quantum.config.yaml (DB-6)
|
|
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(encoding="utf-8") # DB-8: nunca a codificacao do sistema
|
|
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
|
+
"""Connection to the datasource the migrations are for (DB-6).
|
|
48
|
+
|
|
49
|
+
It used to read a separate `database:` section that nothing else uses and,
|
|
50
|
+
without one, try a PostgreSQL at localhost/quantum with user postgres before
|
|
51
|
+
falling back to ./data/quantum.db. A project whose pages query
|
|
52
|
+
datasources.db (./data/app.db) had its migrations applied to a different
|
|
53
|
+
file — or to whatever local PostgreSQL answered.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, project_path: Path = None, datasource: str = None):
|
|
57
|
+
self.project_path = project_path or Path.cwd()
|
|
58
|
+
self.datasource = datasource
|
|
59
|
+
self._conn = None
|
|
60
|
+
self._db_type = None
|
|
61
|
+
|
|
62
|
+
def connect(self) -> Any:
|
|
63
|
+
nome, config = self._datasource_config()
|
|
64
|
+
driver = str(config.get('driver', '')).lower()
|
|
65
|
+
if driver == 'sqlite':
|
|
66
|
+
import sqlite3
|
|
67
|
+
caminho = Path(config.get('database') or '')
|
|
68
|
+
if not config.get('database'):
|
|
69
|
+
raise MigrationError(f"datasource '{nome}' (sqlite) has no database: path")
|
|
70
|
+
if not caminho.is_absolute():
|
|
71
|
+
caminho = self.project_path / caminho
|
|
72
|
+
caminho.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
# isolation_level=None: execute_script opens the transaction
|
|
74
|
+
# itself, so a CREATE TABLE is inside it too (DB-8).
|
|
75
|
+
self._conn = sqlite3.connect(str(caminho), isolation_level=None)
|
|
76
|
+
self._db_type = 'sqlite'
|
|
77
|
+
logger.info("Migrating datasource %s (SQLite at %s)", nome, caminho)
|
|
78
|
+
return self._conn
|
|
79
|
+
if driver in ('postgres', 'postgresql'):
|
|
80
|
+
try:
|
|
81
|
+
import psycopg2
|
|
82
|
+
except ImportError as exc:
|
|
83
|
+
raise MigrationError("datasource '%s' is PostgreSQL: pip install psycopg2-binary" % nome) from exc
|
|
84
|
+
self._conn = psycopg2.connect(
|
|
85
|
+
host=config.get('host', 'localhost'), port=int(config.get('port', 5432)),
|
|
86
|
+
dbname=config.get('database'), user=config.get('username') or config.get('user'),
|
|
87
|
+
password=config.get('password', ''), connect_timeout=5)
|
|
88
|
+
self._db_type = 'postgres'
|
|
89
|
+
logger.info("Migrating datasource %s (PostgreSQL %s)", nome, config.get('database'))
|
|
90
|
+
return self._conn
|
|
91
|
+
raise MigrationError(f"datasource '{nome}': driver {driver!r} is not supported by quantum migrate "
|
|
92
|
+
f"(use sqlite or postgres)")
|
|
93
|
+
|
|
94
|
+
def _datasource_config(self):
|
|
95
|
+
config_path = self.project_path / "quantum.config.yaml"
|
|
96
|
+
datasources = {}
|
|
97
|
+
if config_path.exists():
|
|
98
|
+
import yaml
|
|
99
|
+
from quantum.core.config_env import expand_env
|
|
100
|
+
with open(config_path, encoding='utf-8') as f:
|
|
101
|
+
datasources = (expand_env(yaml.safe_load(f) or {}).get('datasources') or {})
|
|
102
|
+
if not datasources:
|
|
103
|
+
raise MigrationError(
|
|
104
|
+
"quantum.config.yaml declares no datasources. Add one, for example:\n"
|
|
105
|
+
" datasources:\n db:\n driver: sqlite\n database: ./data/app.db")
|
|
106
|
+
if self.datasource:
|
|
107
|
+
if self.datasource not in datasources:
|
|
108
|
+
raise MigrationError(f"no datasource '{self.datasource}'; declared: {', '.join(datasources)}")
|
|
109
|
+
return self.datasource, datasources[self.datasource]
|
|
110
|
+
if len(datasources) > 1:
|
|
111
|
+
raise MigrationError(f"several datasources are declared ({', '.join(datasources)}); "
|
|
112
|
+
f"choose one with --datasource")
|
|
113
|
+
nome = next(iter(datasources))
|
|
114
|
+
return nome, datasources[nome]
|
|
115
|
+
|
|
116
|
+
def _ensure_connected(self):
|
|
117
|
+
if self._conn is None:
|
|
118
|
+
self.connect()
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def db_type(self) -> str:
|
|
122
|
+
return self._db_type
|
|
123
|
+
|
|
124
|
+
def execute(self, sql: str, params: tuple = None):
|
|
125
|
+
"""Execute SQL statement"""
|
|
126
|
+
cursor = self._conn.cursor()
|
|
127
|
+
if params:
|
|
128
|
+
cursor.execute(sql, params)
|
|
129
|
+
else:
|
|
130
|
+
cursor.execute(sql)
|
|
131
|
+
return cursor
|
|
132
|
+
|
|
133
|
+
def execute_script(self, sql: str):
|
|
134
|
+
"""Run a whole migration file, in one transaction (DB-8).
|
|
135
|
+
|
|
136
|
+
sqlite3's execute() takes ONE statement, so every migration with more
|
|
137
|
+
than one — a table and its index — failed with "You can only execute
|
|
138
|
+
one statement at a time". The statements are split here and run
|
|
139
|
+
inside an explicit BEGIN, which commit()/rollback() close: a failure
|
|
140
|
+
halfway leaves no table behind.
|
|
141
|
+
"""
|
|
142
|
+
if self._db_type != 'sqlite':
|
|
143
|
+
return self.execute(sql)
|
|
144
|
+
import sqlite3
|
|
145
|
+
cursor = self._conn.cursor()
|
|
146
|
+
if not self._conn.in_transaction:
|
|
147
|
+
cursor.execute('BEGIN')
|
|
148
|
+
pedaco = ''
|
|
149
|
+
for linha in sql.splitlines(keepends=True):
|
|
150
|
+
pedaco += linha
|
|
151
|
+
if sqlite3.complete_statement(pedaco):
|
|
152
|
+
if pedaco.strip():
|
|
153
|
+
cursor.execute(pedaco)
|
|
154
|
+
pedaco = ''
|
|
155
|
+
if pedaco.strip() and not all(l.strip().startswith('--') or not l.strip() for l in pedaco.splitlines()):
|
|
156
|
+
raise MigrationError(f"incomplete SQL statement at the end: {pedaco.strip()[:80]!r}")
|
|
157
|
+
return cursor
|
|
158
|
+
|
|
159
|
+
def commit(self):
|
|
160
|
+
self._conn.commit()
|
|
161
|
+
|
|
162
|
+
def rollback(self):
|
|
163
|
+
self._conn.rollback()
|
|
164
|
+
|
|
165
|
+
def close(self):
|
|
166
|
+
if self._conn:
|
|
167
|
+
self._conn.close()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _paths_migrations(project_path: Path) -> str:
|
|
171
|
+
"""CFG-3: paths.migrations in quantum.config.yaml (default ./migrations)."""
|
|
172
|
+
try:
|
|
173
|
+
import yaml
|
|
174
|
+
from quantum.core.config_env import expand_env
|
|
175
|
+
with open(project_path / "quantum.config.yaml", encoding="utf-8") as f:
|
|
176
|
+
config = expand_env(yaml.safe_load(f) or {})
|
|
177
|
+
return (config.get("paths") or {}).get("migrations") or "migrations"
|
|
178
|
+
except (OSError, ValueError, AttributeError):
|
|
179
|
+
return "migrations"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class MigrationRunner:
|
|
183
|
+
"""Runs database migrations"""
|
|
184
|
+
|
|
185
|
+
MIGRATIONS_TABLE = "_migrations"
|
|
186
|
+
|
|
187
|
+
def __init__(self, project_path: Path = None, datasource: str = None):
|
|
188
|
+
self.project_path = project_path or Path.cwd()
|
|
189
|
+
self.migrations_dir = self.project_path / _paths_migrations(self.project_path)
|
|
190
|
+
self.db = DatabaseConnection(self.project_path, datasource)
|
|
191
|
+
|
|
192
|
+
def _ensure_migrations_table(self):
|
|
193
|
+
"""Create migrations tracking table if it doesn't exist"""
|
|
194
|
+
if self.db.db_type == 'postgres':
|
|
195
|
+
sql = f"""
|
|
196
|
+
CREATE TABLE IF NOT EXISTS {self.MIGRATIONS_TABLE} (
|
|
197
|
+
version VARCHAR(50) PRIMARY KEY,
|
|
198
|
+
name VARCHAR(255) NOT NULL,
|
|
199
|
+
checksum VARCHAR(32) NOT NULL,
|
|
200
|
+
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
201
|
+
)
|
|
202
|
+
"""
|
|
203
|
+
else: # SQLite
|
|
204
|
+
sql = f"""
|
|
205
|
+
CREATE TABLE IF NOT EXISTS {self.MIGRATIONS_TABLE} (
|
|
206
|
+
version TEXT PRIMARY KEY,
|
|
207
|
+
name TEXT NOT NULL,
|
|
208
|
+
checksum TEXT NOT NULL,
|
|
209
|
+
applied_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
210
|
+
)
|
|
211
|
+
"""
|
|
212
|
+
self.db.execute(sql)
|
|
213
|
+
self.db.commit()
|
|
214
|
+
|
|
215
|
+
def _get_applied_migrations(self) -> Dict[str, Dict]:
|
|
216
|
+
"""Get list of already applied migrations"""
|
|
217
|
+
cursor = self.db.execute(
|
|
218
|
+
f"SELECT version, name, checksum, applied_at FROM {self.MIGRATIONS_TABLE} ORDER BY version"
|
|
219
|
+
)
|
|
220
|
+
rows = cursor.fetchall()
|
|
221
|
+
return {
|
|
222
|
+
row[0]: {
|
|
223
|
+
'version': row[0],
|
|
224
|
+
'name': row[1],
|
|
225
|
+
'checksum': row[2],
|
|
226
|
+
'applied_at': row[3]
|
|
227
|
+
}
|
|
228
|
+
for row in rows
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
def _get_pending_migrations(self) -> List[Migration]:
|
|
232
|
+
"""Get list of migrations that need to be applied"""
|
|
233
|
+
if not self.migrations_dir.exists():
|
|
234
|
+
return []
|
|
235
|
+
|
|
236
|
+
applied = self._get_applied_migrations()
|
|
237
|
+
pending = []
|
|
238
|
+
|
|
239
|
+
for f in sorted(self.migrations_dir.glob("*.sql")):
|
|
240
|
+
# `create` writes a PAIR: V001_name.sql and V001_name.down.sql.
|
|
241
|
+
# This glob used to pick up both, and the rollback file sorted
|
|
242
|
+
# FIRST ('.down.sql' < '.sql'), so `migrate up` executed the
|
|
243
|
+
# rollback — DROP TABLE and all — before the migration it belongs
|
|
244
|
+
# to, and then recorded the version as applied. On an already
|
|
245
|
+
# migrated database that is data destruction from a command whose
|
|
246
|
+
# entire job is to move forward. Reproduced before fixing.
|
|
247
|
+
if f.name.endswith('.down.sql'):
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
# Expected format: V001_create_users.sql
|
|
251
|
+
parts = f.stem.split('_', 1)
|
|
252
|
+
if len(parts) < 2:
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
version = parts[0]
|
|
256
|
+
name = parts[1]
|
|
257
|
+
|
|
258
|
+
if version not in applied:
|
|
259
|
+
pending.append(Migration(version, name, f))
|
|
260
|
+
|
|
261
|
+
return pending
|
|
262
|
+
|
|
263
|
+
def _record_migration(self, migration: Migration):
|
|
264
|
+
"""Record that a migration was applied"""
|
|
265
|
+
if self.db.db_type == 'postgres':
|
|
266
|
+
sql = f"""
|
|
267
|
+
INSERT INTO {self.MIGRATIONS_TABLE} (version, name, checksum)
|
|
268
|
+
VALUES (%s, %s, %s)
|
|
269
|
+
"""
|
|
270
|
+
else: # SQLite
|
|
271
|
+
sql = f"""
|
|
272
|
+
INSERT INTO {self.MIGRATIONS_TABLE} (version, name, checksum)
|
|
273
|
+
VALUES (?, ?, ?)
|
|
274
|
+
"""
|
|
275
|
+
self.db.execute(sql, (migration.version, migration.name, migration.checksum))
|
|
276
|
+
|
|
277
|
+
def _remove_migration_record(self, version: str):
|
|
278
|
+
"""Remove migration record for rollback"""
|
|
279
|
+
if self.db.db_type == 'postgres':
|
|
280
|
+
self.db.execute(f"DELETE FROM {self.MIGRATIONS_TABLE} WHERE version = %s", (version,))
|
|
281
|
+
else:
|
|
282
|
+
self.db.execute(f"DELETE FROM {self.MIGRATIONS_TABLE} WHERE version = ?", (version,))
|
|
283
|
+
|
|
284
|
+
def status(self) -> Dict:
|
|
285
|
+
"""Get migration status"""
|
|
286
|
+
self.db.connect()
|
|
287
|
+
self._ensure_migrations_table()
|
|
288
|
+
|
|
289
|
+
applied = self._get_applied_migrations()
|
|
290
|
+
pending = self._get_pending_migrations()
|
|
291
|
+
|
|
292
|
+
self.db.close()
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
'database_type': self.db.db_type,
|
|
296
|
+
'applied': list(applied.values()),
|
|
297
|
+
'pending': [{'version': m.version, 'name': m.name} for m in pending],
|
|
298
|
+
'total_applied': len(applied),
|
|
299
|
+
'total_pending': len(pending)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
def up(self, count: int = None) -> List[Dict]:
|
|
303
|
+
"""Apply pending migrations"""
|
|
304
|
+
self.db.connect()
|
|
305
|
+
self._ensure_migrations_table()
|
|
306
|
+
|
|
307
|
+
pending = self._get_pending_migrations()
|
|
308
|
+
if count:
|
|
309
|
+
pending = pending[:count]
|
|
310
|
+
|
|
311
|
+
results = []
|
|
312
|
+
|
|
313
|
+
for migration in pending:
|
|
314
|
+
try:
|
|
315
|
+
click.echo(f"Applying {migration.version}: {migration.name}...")
|
|
316
|
+
|
|
317
|
+
# Execute migration SQL
|
|
318
|
+
self.db.execute_script(migration.content)
|
|
319
|
+
|
|
320
|
+
# Record migration
|
|
321
|
+
self._record_migration(migration)
|
|
322
|
+
self.db.commit()
|
|
323
|
+
|
|
324
|
+
results.append({
|
|
325
|
+
'version': migration.version,
|
|
326
|
+
'name': migration.name,
|
|
327
|
+
'status': 'applied',
|
|
328
|
+
'checksum': migration.checksum
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
click.echo(f" [OK] Applied {migration.version}")
|
|
332
|
+
|
|
333
|
+
except Exception as e:
|
|
334
|
+
self.db.rollback()
|
|
335
|
+
results.append({
|
|
336
|
+
'version': migration.version,
|
|
337
|
+
'name': migration.name,
|
|
338
|
+
'status': 'failed',
|
|
339
|
+
'error': str(e)
|
|
340
|
+
})
|
|
341
|
+
click.echo(f" [FAILED] {e}")
|
|
342
|
+
break # Stop on first failure
|
|
343
|
+
|
|
344
|
+
self.db.close()
|
|
345
|
+
return results
|
|
346
|
+
|
|
347
|
+
def down(self, count: int = 1) -> List[Dict]:
|
|
348
|
+
"""Rollback migrations"""
|
|
349
|
+
self.db.connect()
|
|
350
|
+
self._ensure_migrations_table()
|
|
351
|
+
|
|
352
|
+
applied = self._get_applied_migrations()
|
|
353
|
+
if not applied:
|
|
354
|
+
click.echo("No migrations to rollback")
|
|
355
|
+
self.db.close()
|
|
356
|
+
return []
|
|
357
|
+
|
|
358
|
+
# Get migrations in reverse order
|
|
359
|
+
versions = sorted(applied.keys(), reverse=True)[:count]
|
|
360
|
+
results = []
|
|
361
|
+
|
|
362
|
+
for version in versions:
|
|
363
|
+
migration_info = applied[version]
|
|
364
|
+
|
|
365
|
+
# Look for down migration file
|
|
366
|
+
down_file = self.migrations_dir / f"{version}_{migration_info['name']}.down.sql"
|
|
367
|
+
|
|
368
|
+
if not down_file.exists():
|
|
369
|
+
click.echo(f"Warning: No down migration for {version}")
|
|
370
|
+
results.append({
|
|
371
|
+
'version': version,
|
|
372
|
+
'status': 'skipped',
|
|
373
|
+
'reason': 'No down migration file'
|
|
374
|
+
})
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
click.echo(f"Rolling back {version}: {migration_info['name']}...")
|
|
379
|
+
|
|
380
|
+
# Execute rollback SQL
|
|
381
|
+
self.db.execute_script(down_file.read_text(encoding="utf-8"))
|
|
382
|
+
|
|
383
|
+
# Remove migration record
|
|
384
|
+
self._remove_migration_record(version)
|
|
385
|
+
self.db.commit()
|
|
386
|
+
|
|
387
|
+
results.append({
|
|
388
|
+
'version': version,
|
|
389
|
+
'name': migration_info['name'],
|
|
390
|
+
'status': 'rolled_back'
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
click.echo(f" [OK] Rolled back {version}")
|
|
394
|
+
|
|
395
|
+
except Exception as e:
|
|
396
|
+
self.db.rollback()
|
|
397
|
+
results.append({
|
|
398
|
+
'version': version,
|
|
399
|
+
'status': 'failed',
|
|
400
|
+
'error': str(e)
|
|
401
|
+
})
|
|
402
|
+
click.echo(f" [FAILED] {e}")
|
|
403
|
+
break
|
|
404
|
+
|
|
405
|
+
self.db.close()
|
|
406
|
+
return results
|
|
407
|
+
|
|
408
|
+
def create(self, name: str) -> Path:
|
|
409
|
+
"""Create a new migration file"""
|
|
410
|
+
self.migrations_dir.mkdir(parents=True, exist_ok=True)
|
|
411
|
+
|
|
412
|
+
# Get next version number
|
|
413
|
+
existing = sorted(self.migrations_dir.glob("V*.sql"))
|
|
414
|
+
if existing:
|
|
415
|
+
last_version = existing[-1].stem.split('_')[0]
|
|
416
|
+
next_num = int(last_version[1:]) + 1
|
|
417
|
+
else:
|
|
418
|
+
next_num = 1
|
|
419
|
+
|
|
420
|
+
version = f"V{next_num:03d}"
|
|
421
|
+
filename = f"{version}_{name}.sql"
|
|
422
|
+
filepath = self.migrations_dir / filename
|
|
423
|
+
|
|
424
|
+
# Create template
|
|
425
|
+
template = f"""-- Migration: {name}
|
|
426
|
+
-- Version: {version}
|
|
427
|
+
-- Created: {datetime.now().isoformat()}
|
|
428
|
+
|
|
429
|
+
-- Write your SQL here
|
|
430
|
+
|
|
431
|
+
"""
|
|
432
|
+
filepath.write_text(template)
|
|
433
|
+
|
|
434
|
+
# Also create down file
|
|
435
|
+
down_filepath = self.migrations_dir / f"{version}_{name}.down.sql"
|
|
436
|
+
down_template = f"""-- Rollback Migration: {name}
|
|
437
|
+
-- Version: {version}
|
|
438
|
+
|
|
439
|
+
-- Write your rollback SQL here
|
|
440
|
+
|
|
441
|
+
"""
|
|
442
|
+
down_filepath.write_text(down_template)
|
|
443
|
+
|
|
444
|
+
return filepath
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# CLI Commands
|
|
448
|
+
|
|
449
|
+
@click.group()
|
|
450
|
+
def migrate():
|
|
451
|
+
"""Database migration commands"""
|
|
452
|
+
pass
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@migrate.command()
|
|
456
|
+
@click.option('--json', 'as_json', is_flag=True, help='Output as JSON')
|
|
457
|
+
def status(as_json: bool):
|
|
458
|
+
"""Show migration status"""
|
|
459
|
+
runner = MigrationRunner()
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
result = runner.status()
|
|
463
|
+
|
|
464
|
+
if as_json:
|
|
465
|
+
import json
|
|
466
|
+
click.echo(json.dumps(result, indent=2, default=str))
|
|
467
|
+
else:
|
|
468
|
+
click.echo(f"\nDatabase: {result['database_type']}")
|
|
469
|
+
click.echo(f"Applied migrations: {result['total_applied']}")
|
|
470
|
+
click.echo(f"Pending migrations: {result['total_pending']}")
|
|
471
|
+
|
|
472
|
+
if result['applied']:
|
|
473
|
+
click.echo("\nApplied:")
|
|
474
|
+
for m in result['applied']:
|
|
475
|
+
click.echo(f" {m['version']}: {m['name']} (applied: {m['applied_at']})")
|
|
476
|
+
|
|
477
|
+
if result['pending']:
|
|
478
|
+
click.echo("\nPending:")
|
|
479
|
+
for m in result['pending']:
|
|
480
|
+
click.echo(f" {m['version']}: {m['name']}")
|
|
481
|
+
|
|
482
|
+
except Exception as e:
|
|
483
|
+
click.echo(f"Error: {e}", err=True)
|
|
484
|
+
sys.exit(1)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@migrate.command()
|
|
488
|
+
@click.option('--count', '-n', type=int, help='Number of migrations to apply')
|
|
489
|
+
def up(count: Optional[int]):
|
|
490
|
+
"""Apply pending migrations"""
|
|
491
|
+
runner = MigrationRunner()
|
|
492
|
+
|
|
493
|
+
try:
|
|
494
|
+
results = runner.up(count=count)
|
|
495
|
+
|
|
496
|
+
applied = sum(1 for r in results if r.get('status') == 'applied')
|
|
497
|
+
failed = sum(1 for r in results if r.get('status') == 'failed')
|
|
498
|
+
|
|
499
|
+
click.echo(f"\nApplied: {applied}, Failed: {failed}")
|
|
500
|
+
|
|
501
|
+
if failed:
|
|
502
|
+
sys.exit(1)
|
|
503
|
+
|
|
504
|
+
except Exception as e:
|
|
505
|
+
click.echo(f"Error: {e}", err=True)
|
|
506
|
+
sys.exit(1)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
@migrate.command()
|
|
510
|
+
@click.option('--count', '-n', type=int, default=1, help='Number of migrations to rollback')
|
|
511
|
+
def down(count: int):
|
|
512
|
+
"""Rollback migrations"""
|
|
513
|
+
runner = MigrationRunner()
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
results = runner.down(count=count)
|
|
517
|
+
|
|
518
|
+
rolled_back = sum(1 for r in results if r.get('status') == 'rolled_back')
|
|
519
|
+
failed = sum(1 for r in results if r.get('status') == 'failed')
|
|
520
|
+
|
|
521
|
+
click.echo(f"\nRolled back: {rolled_back}, Failed: {failed}")
|
|
522
|
+
|
|
523
|
+
if failed:
|
|
524
|
+
sys.exit(1)
|
|
525
|
+
|
|
526
|
+
except Exception as e:
|
|
527
|
+
click.echo(f"Error: {e}", err=True)
|
|
528
|
+
sys.exit(1)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@migrate.command()
|
|
532
|
+
@click.argument('name')
|
|
533
|
+
def create(name: str):
|
|
534
|
+
"""Create a new migration"""
|
|
535
|
+
runner = MigrationRunner()
|
|
536
|
+
|
|
537
|
+
try:
|
|
538
|
+
filepath = runner.create(name)
|
|
539
|
+
click.echo(f"Created migration: {filepath}")
|
|
540
|
+
click.echo(f"Also created rollback: {filepath.with_suffix('.down.sql')}")
|
|
541
|
+
|
|
542
|
+
except Exception as e:
|
|
543
|
+
click.echo(f"Error: {e}", err=True)
|
|
544
|
+
sys.exit(1)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
if __name__ == '__main__':
|
|
548
|
+
migrate()
|