remdb 0.3.242__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.
Potentially problematic release.
This version of remdb might be problematic. Click here for more details.
- rem/__init__.py +129 -0
- rem/agentic/README.md +760 -0
- rem/agentic/__init__.py +54 -0
- rem/agentic/agents/README.md +155 -0
- rem/agentic/agents/__init__.py +38 -0
- rem/agentic/agents/agent_manager.py +311 -0
- rem/agentic/agents/sse_simulator.py +502 -0
- rem/agentic/context.py +425 -0
- rem/agentic/context_builder.py +360 -0
- rem/agentic/llm_provider_models.py +301 -0
- rem/agentic/mcp/__init__.py +0 -0
- rem/agentic/mcp/tool_wrapper.py +273 -0
- rem/agentic/otel/__init__.py +5 -0
- rem/agentic/otel/setup.py +240 -0
- rem/agentic/providers/phoenix.py +926 -0
- rem/agentic/providers/pydantic_ai.py +854 -0
- rem/agentic/query.py +117 -0
- rem/agentic/query_helper.py +89 -0
- rem/agentic/schema.py +737 -0
- rem/agentic/serialization.py +245 -0
- rem/agentic/tools/__init__.py +5 -0
- rem/agentic/tools/rem_tools.py +242 -0
- rem/api/README.md +657 -0
- rem/api/deps.py +253 -0
- rem/api/main.py +460 -0
- rem/api/mcp_router/prompts.py +182 -0
- rem/api/mcp_router/resources.py +820 -0
- rem/api/mcp_router/server.py +243 -0
- rem/api/mcp_router/tools.py +1605 -0
- rem/api/middleware/tracking.py +172 -0
- rem/api/routers/admin.py +520 -0
- rem/api/routers/auth.py +898 -0
- rem/api/routers/chat/__init__.py +5 -0
- rem/api/routers/chat/child_streaming.py +394 -0
- rem/api/routers/chat/completions.py +702 -0
- rem/api/routers/chat/json_utils.py +76 -0
- rem/api/routers/chat/models.py +202 -0
- rem/api/routers/chat/otel_utils.py +33 -0
- rem/api/routers/chat/sse_events.py +546 -0
- rem/api/routers/chat/streaming.py +950 -0
- rem/api/routers/chat/streaming_utils.py +327 -0
- rem/api/routers/common.py +18 -0
- rem/api/routers/dev.py +87 -0
- rem/api/routers/feedback.py +276 -0
- rem/api/routers/messages.py +620 -0
- rem/api/routers/models.py +86 -0
- rem/api/routers/query.py +362 -0
- rem/api/routers/shared_sessions.py +422 -0
- rem/auth/README.md +258 -0
- rem/auth/__init__.py +36 -0
- rem/auth/jwt.py +367 -0
- rem/auth/middleware.py +318 -0
- rem/auth/providers/__init__.py +16 -0
- rem/auth/providers/base.py +376 -0
- rem/auth/providers/email.py +215 -0
- rem/auth/providers/google.py +163 -0
- rem/auth/providers/microsoft.py +237 -0
- rem/cli/README.md +517 -0
- rem/cli/__init__.py +8 -0
- rem/cli/commands/README.md +299 -0
- rem/cli/commands/__init__.py +3 -0
- rem/cli/commands/ask.py +549 -0
- rem/cli/commands/cluster.py +1808 -0
- rem/cli/commands/configure.py +495 -0
- rem/cli/commands/db.py +828 -0
- rem/cli/commands/dreaming.py +324 -0
- rem/cli/commands/experiments.py +1698 -0
- rem/cli/commands/mcp.py +66 -0
- rem/cli/commands/process.py +388 -0
- rem/cli/commands/query.py +109 -0
- rem/cli/commands/scaffold.py +47 -0
- rem/cli/commands/schema.py +230 -0
- rem/cli/commands/serve.py +106 -0
- rem/cli/commands/session.py +453 -0
- rem/cli/dreaming.py +363 -0
- rem/cli/main.py +123 -0
- rem/config.py +244 -0
- rem/mcp_server.py +41 -0
- rem/models/core/__init__.py +49 -0
- rem/models/core/core_model.py +70 -0
- rem/models/core/engram.py +333 -0
- rem/models/core/experiment.py +672 -0
- rem/models/core/inline_edge.py +132 -0
- rem/models/core/rem_query.py +246 -0
- rem/models/entities/__init__.py +68 -0
- rem/models/entities/domain_resource.py +38 -0
- rem/models/entities/feedback.py +123 -0
- rem/models/entities/file.py +57 -0
- rem/models/entities/image_resource.py +88 -0
- rem/models/entities/message.py +64 -0
- rem/models/entities/moment.py +123 -0
- rem/models/entities/ontology.py +181 -0
- rem/models/entities/ontology_config.py +131 -0
- rem/models/entities/resource.py +95 -0
- rem/models/entities/schema.py +87 -0
- rem/models/entities/session.py +84 -0
- rem/models/entities/shared_session.py +180 -0
- rem/models/entities/subscriber.py +175 -0
- rem/models/entities/user.py +93 -0
- rem/py.typed +0 -0
- rem/registry.py +373 -0
- rem/schemas/README.md +507 -0
- rem/schemas/__init__.py +6 -0
- rem/schemas/agents/README.md +92 -0
- rem/schemas/agents/core/agent-builder.yaml +235 -0
- rem/schemas/agents/core/moment-builder.yaml +178 -0
- rem/schemas/agents/core/rem-query-agent.yaml +226 -0
- rem/schemas/agents/core/resource-affinity-assessor.yaml +99 -0
- rem/schemas/agents/core/simple-assistant.yaml +19 -0
- rem/schemas/agents/core/user-profile-builder.yaml +163 -0
- rem/schemas/agents/examples/contract-analyzer.yaml +317 -0
- rem/schemas/agents/examples/contract-extractor.yaml +134 -0
- rem/schemas/agents/examples/cv-parser.yaml +263 -0
- rem/schemas/agents/examples/hello-world.yaml +37 -0
- rem/schemas/agents/examples/query.yaml +54 -0
- rem/schemas/agents/examples/simple.yaml +21 -0
- rem/schemas/agents/examples/test.yaml +29 -0
- rem/schemas/agents/rem.yaml +132 -0
- rem/schemas/evaluators/hello-world/default.yaml +77 -0
- rem/schemas/evaluators/rem/faithfulness.yaml +219 -0
- rem/schemas/evaluators/rem/lookup-correctness.yaml +182 -0
- rem/schemas/evaluators/rem/retrieval-precision.yaml +199 -0
- rem/schemas/evaluators/rem/retrieval-recall.yaml +211 -0
- rem/schemas/evaluators/rem/search-correctness.yaml +192 -0
- rem/services/__init__.py +18 -0
- rem/services/audio/INTEGRATION.md +308 -0
- rem/services/audio/README.md +376 -0
- rem/services/audio/__init__.py +15 -0
- rem/services/audio/chunker.py +354 -0
- rem/services/audio/transcriber.py +259 -0
- rem/services/content/README.md +1269 -0
- rem/services/content/__init__.py +5 -0
- rem/services/content/providers.py +760 -0
- rem/services/content/service.py +762 -0
- rem/services/dreaming/README.md +230 -0
- rem/services/dreaming/__init__.py +53 -0
- rem/services/dreaming/affinity_service.py +322 -0
- rem/services/dreaming/moment_service.py +251 -0
- rem/services/dreaming/ontology_service.py +54 -0
- rem/services/dreaming/user_model_service.py +297 -0
- rem/services/dreaming/utils.py +39 -0
- rem/services/email/__init__.py +10 -0
- rem/services/email/service.py +522 -0
- rem/services/email/templates.py +360 -0
- rem/services/embeddings/__init__.py +11 -0
- rem/services/embeddings/api.py +127 -0
- rem/services/embeddings/worker.py +435 -0
- rem/services/fs/README.md +662 -0
- rem/services/fs/__init__.py +62 -0
- rem/services/fs/examples.py +206 -0
- rem/services/fs/examples_paths.py +204 -0
- rem/services/fs/git_provider.py +935 -0
- rem/services/fs/local_provider.py +760 -0
- rem/services/fs/parsing-hooks-examples.md +172 -0
- rem/services/fs/paths.py +276 -0
- rem/services/fs/provider.py +460 -0
- rem/services/fs/s3_provider.py +1042 -0
- rem/services/fs/service.py +186 -0
- rem/services/git/README.md +1075 -0
- rem/services/git/__init__.py +17 -0
- rem/services/git/service.py +469 -0
- rem/services/phoenix/EXPERIMENT_DESIGN.md +1146 -0
- rem/services/phoenix/README.md +453 -0
- rem/services/phoenix/__init__.py +46 -0
- rem/services/phoenix/client.py +960 -0
- rem/services/phoenix/config.py +88 -0
- rem/services/phoenix/prompt_labels.py +477 -0
- rem/services/postgres/README.md +757 -0
- rem/services/postgres/__init__.py +49 -0
- rem/services/postgres/diff_service.py +599 -0
- rem/services/postgres/migration_service.py +427 -0
- rem/services/postgres/programmable_diff_service.py +635 -0
- rem/services/postgres/pydantic_to_sqlalchemy.py +562 -0
- rem/services/postgres/register_type.py +353 -0
- rem/services/postgres/repository.py +481 -0
- rem/services/postgres/schema_generator.py +661 -0
- rem/services/postgres/service.py +802 -0
- rem/services/postgres/sql_builder.py +355 -0
- rem/services/rate_limit.py +113 -0
- rem/services/rem/README.md +318 -0
- rem/services/rem/__init__.py +23 -0
- rem/services/rem/exceptions.py +71 -0
- rem/services/rem/executor.py +293 -0
- rem/services/rem/parser.py +180 -0
- rem/services/rem/queries.py +196 -0
- rem/services/rem/query.py +371 -0
- rem/services/rem/service.py +608 -0
- rem/services/session/README.md +374 -0
- rem/services/session/__init__.py +13 -0
- rem/services/session/compression.py +488 -0
- rem/services/session/pydantic_messages.py +310 -0
- rem/services/session/reload.py +85 -0
- rem/services/user_service.py +130 -0
- rem/settings.py +1877 -0
- rem/sql/background_indexes.sql +52 -0
- rem/sql/migrations/001_install.sql +983 -0
- rem/sql/migrations/002_install_models.sql +3157 -0
- rem/sql/migrations/003_optional_extensions.sql +326 -0
- rem/sql/migrations/004_cache_system.sql +282 -0
- rem/sql/migrations/005_schema_update.sql +145 -0
- rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
- rem/utils/AGENTIC_CHUNKING.md +597 -0
- rem/utils/README.md +628 -0
- rem/utils/__init__.py +61 -0
- rem/utils/agentic_chunking.py +622 -0
- rem/utils/batch_ops.py +343 -0
- rem/utils/chunking.py +108 -0
- rem/utils/clip_embeddings.py +276 -0
- rem/utils/constants.py +97 -0
- rem/utils/date_utils.py +228 -0
- rem/utils/dict_utils.py +98 -0
- rem/utils/embeddings.py +436 -0
- rem/utils/examples/embeddings_example.py +305 -0
- rem/utils/examples/sql_types_example.py +202 -0
- rem/utils/files.py +323 -0
- rem/utils/markdown.py +16 -0
- rem/utils/mime_types.py +158 -0
- rem/utils/model_helpers.py +492 -0
- rem/utils/schema_loader.py +649 -0
- rem/utils/sql_paths.py +146 -0
- rem/utils/sql_types.py +350 -0
- rem/utils/user_id.py +81 -0
- rem/utils/vision.py +325 -0
- rem/workers/README.md +506 -0
- rem/workers/__init__.py +7 -0
- rem/workers/db_listener.py +579 -0
- rem/workers/db_maintainer.py +74 -0
- rem/workers/dreaming.py +502 -0
- rem/workers/engram_processor.py +312 -0
- rem/workers/sqs_file_processor.py +193 -0
- rem/workers/unlogged_maintainer.py +463 -0
- remdb-0.3.242.dist-info/METADATA +1632 -0
- remdb-0.3.242.dist-info/RECORD +235 -0
- remdb-0.3.242.dist-info/WHEEL +4 -0
- remdb-0.3.242.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Migration service for managing Alembic-based database migrations.
|
|
3
|
+
|
|
4
|
+
This service provides:
|
|
5
|
+
1. SQL diff generation between target database and Pydantic models
|
|
6
|
+
2. Migration planning (dry-run)
|
|
7
|
+
3. Migration application
|
|
8
|
+
4. SQL file execution
|
|
9
|
+
5. Safety validation for migration operations
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
from contextlib import redirect_stdout
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from alembic import command
|
|
23
|
+
from alembic.config import Config
|
|
24
|
+
from alembic.script import ScriptDirectory
|
|
25
|
+
from loguru import logger
|
|
26
|
+
|
|
27
|
+
from ...settings import settings
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class MigrationSafetyResult:
|
|
32
|
+
"""Result of migration safety validation."""
|
|
33
|
+
|
|
34
|
+
is_safe: bool
|
|
35
|
+
errors: list[str]
|
|
36
|
+
warnings: list[str]
|
|
37
|
+
sql: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MigrationService:
|
|
41
|
+
"""
|
|
42
|
+
Service for managing database migrations using Alembic.
|
|
43
|
+
|
|
44
|
+
Integrates Alembic with REM's Pydantic model-first approach.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, alembic_cfg_path: Optional[Path] = None):
|
|
48
|
+
"""
|
|
49
|
+
Initialize migration service.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
alembic_cfg_path: Path to alembic.ini (defaults to package alembic.ini)
|
|
53
|
+
"""
|
|
54
|
+
if alembic_cfg_path is None:
|
|
55
|
+
# Find alembic.ini in package root
|
|
56
|
+
import rem
|
|
57
|
+
|
|
58
|
+
package_root = Path(rem.__file__).parent.parent.parent
|
|
59
|
+
alembic_cfg_path = package_root / "alembic.ini"
|
|
60
|
+
|
|
61
|
+
if not alembic_cfg_path.exists():
|
|
62
|
+
raise FileNotFoundError(f"Alembic config not found: {alembic_cfg_path}")
|
|
63
|
+
|
|
64
|
+
self.alembic_cfg = Config(str(alembic_cfg_path))
|
|
65
|
+
self.alembic_cfg.set_main_option(
|
|
66
|
+
"script_location", str(alembic_cfg_path.parent / "alembic")
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def get_connection_string(self, target_db: Optional[str] = None) -> str:
|
|
70
|
+
"""
|
|
71
|
+
Build PostgreSQL connection string.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
target_db: Override database name (useful for comparing against different DB)
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
PostgreSQL connection string
|
|
78
|
+
"""
|
|
79
|
+
pg = settings.postgres
|
|
80
|
+
|
|
81
|
+
url = f"postgresql://{pg.user}"
|
|
82
|
+
if pg.password:
|
|
83
|
+
url += f":{pg.password}"
|
|
84
|
+
|
|
85
|
+
db_name = target_db or pg.database
|
|
86
|
+
url += f"@{pg.host}:{pg.port}/{db_name}"
|
|
87
|
+
|
|
88
|
+
return url
|
|
89
|
+
|
|
90
|
+
def generate_migration_sql(
|
|
91
|
+
self,
|
|
92
|
+
output_file: Optional[Path] = None,
|
|
93
|
+
target_db: Optional[str] = None,
|
|
94
|
+
message: str = "Auto-generated migration",
|
|
95
|
+
) -> str:
|
|
96
|
+
"""
|
|
97
|
+
Generate SQL diff between current models and target database using Alembic autogenerate.
|
|
98
|
+
|
|
99
|
+
This uses Alembic's autogenerate feature to compare the Pydantic models
|
|
100
|
+
(via SQLAlchemy metadata) with the target database schema.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
output_file: Path to write SQL file (if None, returns SQL string)
|
|
104
|
+
target_db: Target database name (overrides settings)
|
|
105
|
+
message: Migration message/description
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Generated SQL as string
|
|
109
|
+
"""
|
|
110
|
+
# Override database URL if target_db provided
|
|
111
|
+
url = self.get_connection_string(target_db)
|
|
112
|
+
self.alembic_cfg.set_main_option("sqlalchemy.url", url)
|
|
113
|
+
|
|
114
|
+
# Generate migration SQL using autogenerate
|
|
115
|
+
# We'll use upgrade --sql to generate SQL without applying
|
|
116
|
+
stdout_buffer = io.StringIO()
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
# First, create an autogenerate revision to detect changes
|
|
120
|
+
with redirect_stdout(stdout_buffer):
|
|
121
|
+
command.revision(
|
|
122
|
+
self.alembic_cfg,
|
|
123
|
+
message=message,
|
|
124
|
+
autogenerate=True,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Get the revision that was just created
|
|
128
|
+
script_dir = ScriptDirectory.from_config(self.alembic_cfg)
|
|
129
|
+
revision = script_dir.get_current_head()
|
|
130
|
+
|
|
131
|
+
if revision is None:
|
|
132
|
+
logger.warning("No changes detected - models match database")
|
|
133
|
+
return "-- No changes detected"
|
|
134
|
+
|
|
135
|
+
# Now generate SQL from that revision
|
|
136
|
+
sql_buffer = io.StringIO()
|
|
137
|
+
with redirect_stdout(sql_buffer):
|
|
138
|
+
command.upgrade(
|
|
139
|
+
self.alembic_cfg,
|
|
140
|
+
f"{revision}:head",
|
|
141
|
+
sql=True,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
sql = sql_buffer.getvalue()
|
|
145
|
+
|
|
146
|
+
# If no SQL was generated, check the revision file
|
|
147
|
+
if not sql.strip() or "-- Running upgrade" not in sql:
|
|
148
|
+
# Read the revision file directly
|
|
149
|
+
version_path = script_dir.get_revision(revision).path
|
|
150
|
+
sql = f"-- Generated from Alembic revision: {revision}\n"
|
|
151
|
+
sql += f"-- Migration file: {version_path}\n"
|
|
152
|
+
sql += "-- Run: alembic upgrade head\n"
|
|
153
|
+
sql += "-- Or use this migration file to review/edit before applying\n\n"
|
|
154
|
+
|
|
155
|
+
# Try to extract upgrade operations from the revision file
|
|
156
|
+
if version_path:
|
|
157
|
+
with open(version_path, "r") as f:
|
|
158
|
+
content = f.read()
|
|
159
|
+
# Extract the upgrade function
|
|
160
|
+
import re
|
|
161
|
+
|
|
162
|
+
upgrade_match = re.search(
|
|
163
|
+
r"def upgrade\(\).*?:\s*(.*?)(?=def downgrade|$)",
|
|
164
|
+
content,
|
|
165
|
+
re.DOTALL,
|
|
166
|
+
)
|
|
167
|
+
if upgrade_match:
|
|
168
|
+
upgrade_code = upgrade_match.group(1).strip()
|
|
169
|
+
if upgrade_code and upgrade_code != "pass":
|
|
170
|
+
sql += f"-- Upgrade operations:\n{upgrade_code}\n"
|
|
171
|
+
|
|
172
|
+
# Write to output file if specified
|
|
173
|
+
if output_file and sql.strip():
|
|
174
|
+
output_file.write_text(sql)
|
|
175
|
+
logger.info(f"Migration SQL written to {output_file}")
|
|
176
|
+
|
|
177
|
+
return sql if sql.strip() else "-- No changes detected"
|
|
178
|
+
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.error(f"Failed to generate migration: {e}")
|
|
181
|
+
return f"-- Error generating migration: {e}"
|
|
182
|
+
|
|
183
|
+
def plan_migration(self, target_db: Optional[str] = None) -> str:
|
|
184
|
+
"""
|
|
185
|
+
Plan a migration (dry-run) showing what would change.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
target_db: Target database to compare against
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Human-readable migration plan
|
|
192
|
+
"""
|
|
193
|
+
sql = self.generate_migration_sql(target_db=target_db)
|
|
194
|
+
|
|
195
|
+
if "No changes detected" in sql:
|
|
196
|
+
return "No changes detected between models and database."
|
|
197
|
+
|
|
198
|
+
return f"Migration Plan:\n\n{sql}"
|
|
199
|
+
|
|
200
|
+
def apply_sql_file(
|
|
201
|
+
self, sql_file: Path, connection_string: Optional[str] = None
|
|
202
|
+
) -> bool:
|
|
203
|
+
"""
|
|
204
|
+
Apply a SQL file to the database using psql.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
sql_file: Path to SQL file
|
|
208
|
+
connection_string: PostgreSQL connection string (uses settings if not provided)
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
True if successful, False otherwise
|
|
212
|
+
"""
|
|
213
|
+
if not sql_file.exists():
|
|
214
|
+
raise FileNotFoundError(f"SQL file not found: {sql_file}")
|
|
215
|
+
|
|
216
|
+
conn_str = connection_string or self.get_connection_string()
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
result = subprocess.run(
|
|
220
|
+
["psql", conn_str, "-f", str(sql_file), "-v", "ON_ERROR_STOP=1"],
|
|
221
|
+
capture_output=True,
|
|
222
|
+
text=True,
|
|
223
|
+
check=True,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
logger.info(f"Successfully applied {sql_file}")
|
|
227
|
+
if result.stdout:
|
|
228
|
+
logger.debug(result.stdout)
|
|
229
|
+
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
except subprocess.CalledProcessError as e:
|
|
233
|
+
logger.error(f"Failed to apply {sql_file}: {e.stderr or e.stdout}")
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
except FileNotFoundError:
|
|
237
|
+
logger.error(
|
|
238
|
+
"psql command not found. Ensure PostgreSQL client is installed."
|
|
239
|
+
)
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
def apply_migration(self, target_db: Optional[str] = None) -> bool:
|
|
243
|
+
"""
|
|
244
|
+
Generate and apply migration in one step.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
target_db: Target database to migrate
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
True if successful, False otherwise
|
|
251
|
+
"""
|
|
252
|
+
with tempfile.NamedTemporaryFile(
|
|
253
|
+
mode="w", suffix=".sql", delete=False
|
|
254
|
+
) as f:
|
|
255
|
+
sql_file = Path(f.name)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
sql = self.generate_migration_sql(output_file=sql_file, target_db=target_db)
|
|
259
|
+
|
|
260
|
+
if "No changes detected" in sql:
|
|
261
|
+
logger.info("No migration needed")
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
return self.apply_sql_file(sql_file)
|
|
265
|
+
|
|
266
|
+
finally:
|
|
267
|
+
# Clean up temp file
|
|
268
|
+
if sql_file.exists():
|
|
269
|
+
sql_file.unlink()
|
|
270
|
+
|
|
271
|
+
def get_current_revision(self) -> Optional[str]:
|
|
272
|
+
"""
|
|
273
|
+
Get current database revision.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Current revision ID or None if no migrations applied
|
|
277
|
+
"""
|
|
278
|
+
try:
|
|
279
|
+
stdout_buffer = io.StringIO()
|
|
280
|
+
with redirect_stdout(stdout_buffer):
|
|
281
|
+
command.current(self.alembic_cfg)
|
|
282
|
+
output = stdout_buffer.getvalue()
|
|
283
|
+
# Parse the output to get revision ID
|
|
284
|
+
import re
|
|
285
|
+
|
|
286
|
+
match = re.search(r"([a-f0-9]+)\s+\(head\)", output)
|
|
287
|
+
if match:
|
|
288
|
+
return match.group(1)
|
|
289
|
+
return None
|
|
290
|
+
except Exception as e:
|
|
291
|
+
logger.error(f"Could not get current revision: {e}")
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
def validate_migration_safety(
|
|
295
|
+
self, sql: str, safe_mode: Optional[str] = None
|
|
296
|
+
) -> MigrationSafetyResult:
|
|
297
|
+
"""
|
|
298
|
+
Validate migration SQL against safety rules.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
sql: Migration SQL to validate
|
|
302
|
+
safe_mode: Override safety mode (uses settings if not provided)
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
MigrationSafetyResult with validation results
|
|
306
|
+
"""
|
|
307
|
+
mode = safe_mode or settings.migration.mode
|
|
308
|
+
errors: list[str] = []
|
|
309
|
+
warnings: list[str] = []
|
|
310
|
+
|
|
311
|
+
# Normalize SQL for pattern matching
|
|
312
|
+
sql_upper = sql.upper()
|
|
313
|
+
|
|
314
|
+
# Check for DROP COLUMN
|
|
315
|
+
if re.search(r"\bDROP\s+COLUMN\b", sql_upper, re.IGNORECASE):
|
|
316
|
+
if mode in ("additive", "strict"):
|
|
317
|
+
errors.append("DROP COLUMN not allowed in safe mode")
|
|
318
|
+
elif not settings.migration.allow_drop_columns:
|
|
319
|
+
errors.append(
|
|
320
|
+
"DROP COLUMN not allowed (MIGRATION__ALLOW_DROP_COLUMNS=false)"
|
|
321
|
+
)
|
|
322
|
+
elif settings.migration.unsafe_alter_warning:
|
|
323
|
+
warnings.append("Migration contains DROP COLUMN operations")
|
|
324
|
+
|
|
325
|
+
# Check for DROP TABLE
|
|
326
|
+
if re.search(r"\bDROP\s+TABLE\b", sql_upper, re.IGNORECASE):
|
|
327
|
+
if mode in ("additive", "strict"):
|
|
328
|
+
errors.append("DROP TABLE not allowed in safe mode")
|
|
329
|
+
elif not settings.migration.allow_drop_tables:
|
|
330
|
+
errors.append(
|
|
331
|
+
"DROP TABLE not allowed (MIGRATION__ALLOW_DROP_TABLES=false)"
|
|
332
|
+
)
|
|
333
|
+
elif settings.migration.unsafe_alter_warning:
|
|
334
|
+
warnings.append("Migration contains DROP TABLE operations")
|
|
335
|
+
|
|
336
|
+
# Check for ALTER COLUMN (type changes)
|
|
337
|
+
if re.search(r"\bALTER\s+COLUMN\s+\w+\s+TYPE\b", sql_upper, re.IGNORECASE):
|
|
338
|
+
if mode == "strict":
|
|
339
|
+
errors.append("ALTER COLUMN TYPE not allowed in strict mode")
|
|
340
|
+
elif not settings.migration.allow_alter_columns:
|
|
341
|
+
errors.append(
|
|
342
|
+
"ALTER COLUMN TYPE not allowed (MIGRATION__ALLOW_ALTER_COLUMNS=false)"
|
|
343
|
+
)
|
|
344
|
+
elif settings.migration.unsafe_alter_warning:
|
|
345
|
+
warnings.append("Migration contains ALTER COLUMN TYPE operations")
|
|
346
|
+
|
|
347
|
+
# Check for RENAME COLUMN
|
|
348
|
+
if re.search(r"\bRENAME\s+COLUMN\b", sql_upper, re.IGNORECASE):
|
|
349
|
+
if mode == "strict":
|
|
350
|
+
errors.append("RENAME COLUMN not allowed in strict mode")
|
|
351
|
+
elif not settings.migration.allow_rename_columns:
|
|
352
|
+
errors.append(
|
|
353
|
+
"RENAME COLUMN not allowed (MIGRATION__ALLOW_RENAME_COLUMNS=false)"
|
|
354
|
+
)
|
|
355
|
+
elif settings.migration.unsafe_alter_warning:
|
|
356
|
+
warnings.append("Migration contains RENAME COLUMN operations")
|
|
357
|
+
|
|
358
|
+
# Check for RENAME TABLE / ALTER TABLE RENAME
|
|
359
|
+
if re.search(
|
|
360
|
+
r"\b(RENAME\s+TABLE|ALTER\s+TABLE\s+\w+\s+RENAME\s+TO)\b",
|
|
361
|
+
sql_upper,
|
|
362
|
+
re.IGNORECASE,
|
|
363
|
+
):
|
|
364
|
+
if mode == "strict":
|
|
365
|
+
errors.append("RENAME TABLE not allowed in strict mode")
|
|
366
|
+
elif not settings.migration.allow_rename_tables:
|
|
367
|
+
errors.append(
|
|
368
|
+
"RENAME TABLE not allowed (MIGRATION__ALLOW_RENAME_TABLES=false)"
|
|
369
|
+
)
|
|
370
|
+
elif settings.migration.unsafe_alter_warning:
|
|
371
|
+
warnings.append("Migration contains RENAME TABLE operations")
|
|
372
|
+
|
|
373
|
+
# Check for other ALTER operations
|
|
374
|
+
if (
|
|
375
|
+
re.search(r"\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE)
|
|
376
|
+
and settings.migration.unsafe_alter_warning
|
|
377
|
+
):
|
|
378
|
+
# Only warn if not already warned above
|
|
379
|
+
if not any("ALTER" in w for w in warnings):
|
|
380
|
+
warnings.append("Migration contains ALTER TABLE operations")
|
|
381
|
+
|
|
382
|
+
is_safe = len(errors) == 0
|
|
383
|
+
|
|
384
|
+
return MigrationSafetyResult(
|
|
385
|
+
is_safe=is_safe,
|
|
386
|
+
errors=errors,
|
|
387
|
+
warnings=warnings,
|
|
388
|
+
sql=sql,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
def generate_migration_sql_safe(
|
|
392
|
+
self,
|
|
393
|
+
output_file: Optional[Path] = None,
|
|
394
|
+
target_db: Optional[str] = None,
|
|
395
|
+
message: str = "Auto-generated migration",
|
|
396
|
+
safe_mode: Optional[str] = None,
|
|
397
|
+
) -> MigrationSafetyResult:
|
|
398
|
+
"""
|
|
399
|
+
Generate migration SQL with safety validation.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
output_file: Path to write SQL file (if None, returns SQL string)
|
|
403
|
+
target_db: Target database name (overrides settings)
|
|
404
|
+
message: Migration message/description
|
|
405
|
+
safe_mode: Override safety mode (permissive, additive, strict)
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
MigrationSafetyResult with validation results
|
|
409
|
+
"""
|
|
410
|
+
# Generate SQL
|
|
411
|
+
sql = self.generate_migration_sql(
|
|
412
|
+
output_file=None, # Don't write yet, validate first
|
|
413
|
+
target_db=target_db,
|
|
414
|
+
message=message,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# Validate safety
|
|
418
|
+
result = self.validate_migration_safety(sql, safe_mode=safe_mode)
|
|
419
|
+
|
|
420
|
+
# Write to file only if safe and output_file provided
|
|
421
|
+
if result.is_safe and output_file:
|
|
422
|
+
output_file.write_text(sql)
|
|
423
|
+
logger.info(f"Migration SQL written to {output_file}")
|
|
424
|
+
elif not result.is_safe:
|
|
425
|
+
logger.warning("Migration contains unsafe operations, not writing to file")
|
|
426
|
+
|
|
427
|
+
return result
|