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.

Files changed (235) hide show
  1. rem/__init__.py +129 -0
  2. rem/agentic/README.md +760 -0
  3. rem/agentic/__init__.py +54 -0
  4. rem/agentic/agents/README.md +155 -0
  5. rem/agentic/agents/__init__.py +38 -0
  6. rem/agentic/agents/agent_manager.py +311 -0
  7. rem/agentic/agents/sse_simulator.py +502 -0
  8. rem/agentic/context.py +425 -0
  9. rem/agentic/context_builder.py +360 -0
  10. rem/agentic/llm_provider_models.py +301 -0
  11. rem/agentic/mcp/__init__.py +0 -0
  12. rem/agentic/mcp/tool_wrapper.py +273 -0
  13. rem/agentic/otel/__init__.py +5 -0
  14. rem/agentic/otel/setup.py +240 -0
  15. rem/agentic/providers/phoenix.py +926 -0
  16. rem/agentic/providers/pydantic_ai.py +854 -0
  17. rem/agentic/query.py +117 -0
  18. rem/agentic/query_helper.py +89 -0
  19. rem/agentic/schema.py +737 -0
  20. rem/agentic/serialization.py +245 -0
  21. rem/agentic/tools/__init__.py +5 -0
  22. rem/agentic/tools/rem_tools.py +242 -0
  23. rem/api/README.md +657 -0
  24. rem/api/deps.py +253 -0
  25. rem/api/main.py +460 -0
  26. rem/api/mcp_router/prompts.py +182 -0
  27. rem/api/mcp_router/resources.py +820 -0
  28. rem/api/mcp_router/server.py +243 -0
  29. rem/api/mcp_router/tools.py +1605 -0
  30. rem/api/middleware/tracking.py +172 -0
  31. rem/api/routers/admin.py +520 -0
  32. rem/api/routers/auth.py +898 -0
  33. rem/api/routers/chat/__init__.py +5 -0
  34. rem/api/routers/chat/child_streaming.py +394 -0
  35. rem/api/routers/chat/completions.py +702 -0
  36. rem/api/routers/chat/json_utils.py +76 -0
  37. rem/api/routers/chat/models.py +202 -0
  38. rem/api/routers/chat/otel_utils.py +33 -0
  39. rem/api/routers/chat/sse_events.py +546 -0
  40. rem/api/routers/chat/streaming.py +950 -0
  41. rem/api/routers/chat/streaming_utils.py +327 -0
  42. rem/api/routers/common.py +18 -0
  43. rem/api/routers/dev.py +87 -0
  44. rem/api/routers/feedback.py +276 -0
  45. rem/api/routers/messages.py +620 -0
  46. rem/api/routers/models.py +86 -0
  47. rem/api/routers/query.py +362 -0
  48. rem/api/routers/shared_sessions.py +422 -0
  49. rem/auth/README.md +258 -0
  50. rem/auth/__init__.py +36 -0
  51. rem/auth/jwt.py +367 -0
  52. rem/auth/middleware.py +318 -0
  53. rem/auth/providers/__init__.py +16 -0
  54. rem/auth/providers/base.py +376 -0
  55. rem/auth/providers/email.py +215 -0
  56. rem/auth/providers/google.py +163 -0
  57. rem/auth/providers/microsoft.py +237 -0
  58. rem/cli/README.md +517 -0
  59. rem/cli/__init__.py +8 -0
  60. rem/cli/commands/README.md +299 -0
  61. rem/cli/commands/__init__.py +3 -0
  62. rem/cli/commands/ask.py +549 -0
  63. rem/cli/commands/cluster.py +1808 -0
  64. rem/cli/commands/configure.py +495 -0
  65. rem/cli/commands/db.py +828 -0
  66. rem/cli/commands/dreaming.py +324 -0
  67. rem/cli/commands/experiments.py +1698 -0
  68. rem/cli/commands/mcp.py +66 -0
  69. rem/cli/commands/process.py +388 -0
  70. rem/cli/commands/query.py +109 -0
  71. rem/cli/commands/scaffold.py +47 -0
  72. rem/cli/commands/schema.py +230 -0
  73. rem/cli/commands/serve.py +106 -0
  74. rem/cli/commands/session.py +453 -0
  75. rem/cli/dreaming.py +363 -0
  76. rem/cli/main.py +123 -0
  77. rem/config.py +244 -0
  78. rem/mcp_server.py +41 -0
  79. rem/models/core/__init__.py +49 -0
  80. rem/models/core/core_model.py +70 -0
  81. rem/models/core/engram.py +333 -0
  82. rem/models/core/experiment.py +672 -0
  83. rem/models/core/inline_edge.py +132 -0
  84. rem/models/core/rem_query.py +246 -0
  85. rem/models/entities/__init__.py +68 -0
  86. rem/models/entities/domain_resource.py +38 -0
  87. rem/models/entities/feedback.py +123 -0
  88. rem/models/entities/file.py +57 -0
  89. rem/models/entities/image_resource.py +88 -0
  90. rem/models/entities/message.py +64 -0
  91. rem/models/entities/moment.py +123 -0
  92. rem/models/entities/ontology.py +181 -0
  93. rem/models/entities/ontology_config.py +131 -0
  94. rem/models/entities/resource.py +95 -0
  95. rem/models/entities/schema.py +87 -0
  96. rem/models/entities/session.py +84 -0
  97. rem/models/entities/shared_session.py +180 -0
  98. rem/models/entities/subscriber.py +175 -0
  99. rem/models/entities/user.py +93 -0
  100. rem/py.typed +0 -0
  101. rem/registry.py +373 -0
  102. rem/schemas/README.md +507 -0
  103. rem/schemas/__init__.py +6 -0
  104. rem/schemas/agents/README.md +92 -0
  105. rem/schemas/agents/core/agent-builder.yaml +235 -0
  106. rem/schemas/agents/core/moment-builder.yaml +178 -0
  107. rem/schemas/agents/core/rem-query-agent.yaml +226 -0
  108. rem/schemas/agents/core/resource-affinity-assessor.yaml +99 -0
  109. rem/schemas/agents/core/simple-assistant.yaml +19 -0
  110. rem/schemas/agents/core/user-profile-builder.yaml +163 -0
  111. rem/schemas/agents/examples/contract-analyzer.yaml +317 -0
  112. rem/schemas/agents/examples/contract-extractor.yaml +134 -0
  113. rem/schemas/agents/examples/cv-parser.yaml +263 -0
  114. rem/schemas/agents/examples/hello-world.yaml +37 -0
  115. rem/schemas/agents/examples/query.yaml +54 -0
  116. rem/schemas/agents/examples/simple.yaml +21 -0
  117. rem/schemas/agents/examples/test.yaml +29 -0
  118. rem/schemas/agents/rem.yaml +132 -0
  119. rem/schemas/evaluators/hello-world/default.yaml +77 -0
  120. rem/schemas/evaluators/rem/faithfulness.yaml +219 -0
  121. rem/schemas/evaluators/rem/lookup-correctness.yaml +182 -0
  122. rem/schemas/evaluators/rem/retrieval-precision.yaml +199 -0
  123. rem/schemas/evaluators/rem/retrieval-recall.yaml +211 -0
  124. rem/schemas/evaluators/rem/search-correctness.yaml +192 -0
  125. rem/services/__init__.py +18 -0
  126. rem/services/audio/INTEGRATION.md +308 -0
  127. rem/services/audio/README.md +376 -0
  128. rem/services/audio/__init__.py +15 -0
  129. rem/services/audio/chunker.py +354 -0
  130. rem/services/audio/transcriber.py +259 -0
  131. rem/services/content/README.md +1269 -0
  132. rem/services/content/__init__.py +5 -0
  133. rem/services/content/providers.py +760 -0
  134. rem/services/content/service.py +762 -0
  135. rem/services/dreaming/README.md +230 -0
  136. rem/services/dreaming/__init__.py +53 -0
  137. rem/services/dreaming/affinity_service.py +322 -0
  138. rem/services/dreaming/moment_service.py +251 -0
  139. rem/services/dreaming/ontology_service.py +54 -0
  140. rem/services/dreaming/user_model_service.py +297 -0
  141. rem/services/dreaming/utils.py +39 -0
  142. rem/services/email/__init__.py +10 -0
  143. rem/services/email/service.py +522 -0
  144. rem/services/email/templates.py +360 -0
  145. rem/services/embeddings/__init__.py +11 -0
  146. rem/services/embeddings/api.py +127 -0
  147. rem/services/embeddings/worker.py +435 -0
  148. rem/services/fs/README.md +662 -0
  149. rem/services/fs/__init__.py +62 -0
  150. rem/services/fs/examples.py +206 -0
  151. rem/services/fs/examples_paths.py +204 -0
  152. rem/services/fs/git_provider.py +935 -0
  153. rem/services/fs/local_provider.py +760 -0
  154. rem/services/fs/parsing-hooks-examples.md +172 -0
  155. rem/services/fs/paths.py +276 -0
  156. rem/services/fs/provider.py +460 -0
  157. rem/services/fs/s3_provider.py +1042 -0
  158. rem/services/fs/service.py +186 -0
  159. rem/services/git/README.md +1075 -0
  160. rem/services/git/__init__.py +17 -0
  161. rem/services/git/service.py +469 -0
  162. rem/services/phoenix/EXPERIMENT_DESIGN.md +1146 -0
  163. rem/services/phoenix/README.md +453 -0
  164. rem/services/phoenix/__init__.py +46 -0
  165. rem/services/phoenix/client.py +960 -0
  166. rem/services/phoenix/config.py +88 -0
  167. rem/services/phoenix/prompt_labels.py +477 -0
  168. rem/services/postgres/README.md +757 -0
  169. rem/services/postgres/__init__.py +49 -0
  170. rem/services/postgres/diff_service.py +599 -0
  171. rem/services/postgres/migration_service.py +427 -0
  172. rem/services/postgres/programmable_diff_service.py +635 -0
  173. rem/services/postgres/pydantic_to_sqlalchemy.py +562 -0
  174. rem/services/postgres/register_type.py +353 -0
  175. rem/services/postgres/repository.py +481 -0
  176. rem/services/postgres/schema_generator.py +661 -0
  177. rem/services/postgres/service.py +802 -0
  178. rem/services/postgres/sql_builder.py +355 -0
  179. rem/services/rate_limit.py +113 -0
  180. rem/services/rem/README.md +318 -0
  181. rem/services/rem/__init__.py +23 -0
  182. rem/services/rem/exceptions.py +71 -0
  183. rem/services/rem/executor.py +293 -0
  184. rem/services/rem/parser.py +180 -0
  185. rem/services/rem/queries.py +196 -0
  186. rem/services/rem/query.py +371 -0
  187. rem/services/rem/service.py +608 -0
  188. rem/services/session/README.md +374 -0
  189. rem/services/session/__init__.py +13 -0
  190. rem/services/session/compression.py +488 -0
  191. rem/services/session/pydantic_messages.py +310 -0
  192. rem/services/session/reload.py +85 -0
  193. rem/services/user_service.py +130 -0
  194. rem/settings.py +1877 -0
  195. rem/sql/background_indexes.sql +52 -0
  196. rem/sql/migrations/001_install.sql +983 -0
  197. rem/sql/migrations/002_install_models.sql +3157 -0
  198. rem/sql/migrations/003_optional_extensions.sql +326 -0
  199. rem/sql/migrations/004_cache_system.sql +282 -0
  200. rem/sql/migrations/005_schema_update.sql +145 -0
  201. rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
  202. rem/utils/AGENTIC_CHUNKING.md +597 -0
  203. rem/utils/README.md +628 -0
  204. rem/utils/__init__.py +61 -0
  205. rem/utils/agentic_chunking.py +622 -0
  206. rem/utils/batch_ops.py +343 -0
  207. rem/utils/chunking.py +108 -0
  208. rem/utils/clip_embeddings.py +276 -0
  209. rem/utils/constants.py +97 -0
  210. rem/utils/date_utils.py +228 -0
  211. rem/utils/dict_utils.py +98 -0
  212. rem/utils/embeddings.py +436 -0
  213. rem/utils/examples/embeddings_example.py +305 -0
  214. rem/utils/examples/sql_types_example.py +202 -0
  215. rem/utils/files.py +323 -0
  216. rem/utils/markdown.py +16 -0
  217. rem/utils/mime_types.py +158 -0
  218. rem/utils/model_helpers.py +492 -0
  219. rem/utils/schema_loader.py +649 -0
  220. rem/utils/sql_paths.py +146 -0
  221. rem/utils/sql_types.py +350 -0
  222. rem/utils/user_id.py +81 -0
  223. rem/utils/vision.py +325 -0
  224. rem/workers/README.md +506 -0
  225. rem/workers/__init__.py +7 -0
  226. rem/workers/db_listener.py +579 -0
  227. rem/workers/db_maintainer.py +74 -0
  228. rem/workers/dreaming.py +502 -0
  229. rem/workers/engram_processor.py +312 -0
  230. rem/workers/sqs_file_processor.py +193 -0
  231. rem/workers/unlogged_maintainer.py +463 -0
  232. remdb-0.3.242.dist-info/METADATA +1632 -0
  233. remdb-0.3.242.dist-info/RECORD +235 -0
  234. remdb-0.3.242.dist-info/WHEEL +4 -0
  235. 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