remdb 0.3.7__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 (187) hide show
  1. rem/__init__.py +2 -0
  2. rem/agentic/README.md +650 -0
  3. rem/agentic/__init__.py +39 -0
  4. rem/agentic/agents/README.md +155 -0
  5. rem/agentic/agents/__init__.py +8 -0
  6. rem/agentic/context.py +148 -0
  7. rem/agentic/context_builder.py +329 -0
  8. rem/agentic/mcp/__init__.py +0 -0
  9. rem/agentic/mcp/tool_wrapper.py +107 -0
  10. rem/agentic/otel/__init__.py +5 -0
  11. rem/agentic/otel/setup.py +151 -0
  12. rem/agentic/providers/phoenix.py +674 -0
  13. rem/agentic/providers/pydantic_ai.py +572 -0
  14. rem/agentic/query.py +117 -0
  15. rem/agentic/query_helper.py +89 -0
  16. rem/agentic/schema.py +396 -0
  17. rem/agentic/serialization.py +245 -0
  18. rem/agentic/tools/__init__.py +5 -0
  19. rem/agentic/tools/rem_tools.py +231 -0
  20. rem/api/README.md +420 -0
  21. rem/api/main.py +324 -0
  22. rem/api/mcp_router/prompts.py +182 -0
  23. rem/api/mcp_router/resources.py +536 -0
  24. rem/api/mcp_router/server.py +213 -0
  25. rem/api/mcp_router/tools.py +584 -0
  26. rem/api/routers/auth.py +229 -0
  27. rem/api/routers/chat/__init__.py +5 -0
  28. rem/api/routers/chat/completions.py +281 -0
  29. rem/api/routers/chat/json_utils.py +76 -0
  30. rem/api/routers/chat/models.py +124 -0
  31. rem/api/routers/chat/streaming.py +185 -0
  32. rem/auth/README.md +258 -0
  33. rem/auth/__init__.py +26 -0
  34. rem/auth/middleware.py +100 -0
  35. rem/auth/providers/__init__.py +13 -0
  36. rem/auth/providers/base.py +376 -0
  37. rem/auth/providers/google.py +163 -0
  38. rem/auth/providers/microsoft.py +237 -0
  39. rem/cli/README.md +455 -0
  40. rem/cli/__init__.py +8 -0
  41. rem/cli/commands/README.md +126 -0
  42. rem/cli/commands/__init__.py +3 -0
  43. rem/cli/commands/ask.py +566 -0
  44. rem/cli/commands/configure.py +497 -0
  45. rem/cli/commands/db.py +493 -0
  46. rem/cli/commands/dreaming.py +324 -0
  47. rem/cli/commands/experiments.py +1302 -0
  48. rem/cli/commands/mcp.py +66 -0
  49. rem/cli/commands/process.py +245 -0
  50. rem/cli/commands/schema.py +183 -0
  51. rem/cli/commands/serve.py +106 -0
  52. rem/cli/dreaming.py +363 -0
  53. rem/cli/main.py +96 -0
  54. rem/config.py +237 -0
  55. rem/mcp_server.py +41 -0
  56. rem/models/core/__init__.py +49 -0
  57. rem/models/core/core_model.py +64 -0
  58. rem/models/core/engram.py +333 -0
  59. rem/models/core/experiment.py +628 -0
  60. rem/models/core/inline_edge.py +132 -0
  61. rem/models/core/rem_query.py +243 -0
  62. rem/models/entities/__init__.py +43 -0
  63. rem/models/entities/file.py +57 -0
  64. rem/models/entities/image_resource.py +88 -0
  65. rem/models/entities/message.py +35 -0
  66. rem/models/entities/moment.py +123 -0
  67. rem/models/entities/ontology.py +191 -0
  68. rem/models/entities/ontology_config.py +131 -0
  69. rem/models/entities/resource.py +95 -0
  70. rem/models/entities/schema.py +87 -0
  71. rem/models/entities/user.py +85 -0
  72. rem/py.typed +0 -0
  73. rem/schemas/README.md +507 -0
  74. rem/schemas/__init__.py +6 -0
  75. rem/schemas/agents/README.md +92 -0
  76. rem/schemas/agents/core/moment-builder.yaml +178 -0
  77. rem/schemas/agents/core/rem-query-agent.yaml +226 -0
  78. rem/schemas/agents/core/resource-affinity-assessor.yaml +99 -0
  79. rem/schemas/agents/core/simple-assistant.yaml +19 -0
  80. rem/schemas/agents/core/user-profile-builder.yaml +163 -0
  81. rem/schemas/agents/examples/contract-analyzer.yaml +317 -0
  82. rem/schemas/agents/examples/contract-extractor.yaml +134 -0
  83. rem/schemas/agents/examples/cv-parser.yaml +263 -0
  84. rem/schemas/agents/examples/hello-world.yaml +37 -0
  85. rem/schemas/agents/examples/query.yaml +54 -0
  86. rem/schemas/agents/examples/simple.yaml +21 -0
  87. rem/schemas/agents/examples/test.yaml +29 -0
  88. rem/schemas/agents/rem.yaml +128 -0
  89. rem/schemas/evaluators/hello-world/default.yaml +77 -0
  90. rem/schemas/evaluators/rem/faithfulness.yaml +219 -0
  91. rem/schemas/evaluators/rem/lookup-correctness.yaml +182 -0
  92. rem/schemas/evaluators/rem/retrieval-precision.yaml +199 -0
  93. rem/schemas/evaluators/rem/retrieval-recall.yaml +211 -0
  94. rem/schemas/evaluators/rem/search-correctness.yaml +192 -0
  95. rem/services/__init__.py +16 -0
  96. rem/services/audio/INTEGRATION.md +308 -0
  97. rem/services/audio/README.md +376 -0
  98. rem/services/audio/__init__.py +15 -0
  99. rem/services/audio/chunker.py +354 -0
  100. rem/services/audio/transcriber.py +259 -0
  101. rem/services/content/README.md +1269 -0
  102. rem/services/content/__init__.py +5 -0
  103. rem/services/content/providers.py +801 -0
  104. rem/services/content/service.py +676 -0
  105. rem/services/dreaming/README.md +230 -0
  106. rem/services/dreaming/__init__.py +53 -0
  107. rem/services/dreaming/affinity_service.py +336 -0
  108. rem/services/dreaming/moment_service.py +264 -0
  109. rem/services/dreaming/ontology_service.py +54 -0
  110. rem/services/dreaming/user_model_service.py +297 -0
  111. rem/services/dreaming/utils.py +39 -0
  112. rem/services/embeddings/__init__.py +11 -0
  113. rem/services/embeddings/api.py +120 -0
  114. rem/services/embeddings/worker.py +421 -0
  115. rem/services/fs/README.md +662 -0
  116. rem/services/fs/__init__.py +62 -0
  117. rem/services/fs/examples.py +206 -0
  118. rem/services/fs/examples_paths.py +204 -0
  119. rem/services/fs/git_provider.py +935 -0
  120. rem/services/fs/local_provider.py +760 -0
  121. rem/services/fs/parsing-hooks-examples.md +172 -0
  122. rem/services/fs/paths.py +276 -0
  123. rem/services/fs/provider.py +460 -0
  124. rem/services/fs/s3_provider.py +1042 -0
  125. rem/services/fs/service.py +186 -0
  126. rem/services/git/README.md +1075 -0
  127. rem/services/git/__init__.py +17 -0
  128. rem/services/git/service.py +469 -0
  129. rem/services/phoenix/EXPERIMENT_DESIGN.md +1146 -0
  130. rem/services/phoenix/README.md +453 -0
  131. rem/services/phoenix/__init__.py +46 -0
  132. rem/services/phoenix/client.py +686 -0
  133. rem/services/phoenix/config.py +88 -0
  134. rem/services/phoenix/prompt_labels.py +477 -0
  135. rem/services/postgres/README.md +575 -0
  136. rem/services/postgres/__init__.py +23 -0
  137. rem/services/postgres/migration_service.py +427 -0
  138. rem/services/postgres/pydantic_to_sqlalchemy.py +232 -0
  139. rem/services/postgres/register_type.py +352 -0
  140. rem/services/postgres/repository.py +337 -0
  141. rem/services/postgres/schema_generator.py +379 -0
  142. rem/services/postgres/service.py +802 -0
  143. rem/services/postgres/sql_builder.py +354 -0
  144. rem/services/rem/README.md +304 -0
  145. rem/services/rem/__init__.py +23 -0
  146. rem/services/rem/exceptions.py +71 -0
  147. rem/services/rem/executor.py +293 -0
  148. rem/services/rem/parser.py +145 -0
  149. rem/services/rem/queries.py +196 -0
  150. rem/services/rem/query.py +371 -0
  151. rem/services/rem/service.py +527 -0
  152. rem/services/session/README.md +374 -0
  153. rem/services/session/__init__.py +6 -0
  154. rem/services/session/compression.py +360 -0
  155. rem/services/session/reload.py +77 -0
  156. rem/settings.py +1235 -0
  157. rem/sql/002_install_models.sql +1068 -0
  158. rem/sql/background_indexes.sql +42 -0
  159. rem/sql/install_models.sql +1038 -0
  160. rem/sql/migrations/001_install.sql +503 -0
  161. rem/sql/migrations/002_install_models.sql +1202 -0
  162. rem/utils/AGENTIC_CHUNKING.md +597 -0
  163. rem/utils/README.md +583 -0
  164. rem/utils/__init__.py +43 -0
  165. rem/utils/agentic_chunking.py +622 -0
  166. rem/utils/batch_ops.py +343 -0
  167. rem/utils/chunking.py +108 -0
  168. rem/utils/clip_embeddings.py +276 -0
  169. rem/utils/dict_utils.py +98 -0
  170. rem/utils/embeddings.py +423 -0
  171. rem/utils/examples/embeddings_example.py +305 -0
  172. rem/utils/examples/sql_types_example.py +202 -0
  173. rem/utils/markdown.py +16 -0
  174. rem/utils/model_helpers.py +236 -0
  175. rem/utils/schema_loader.py +336 -0
  176. rem/utils/sql_types.py +348 -0
  177. rem/utils/user_id.py +81 -0
  178. rem/utils/vision.py +330 -0
  179. rem/workers/README.md +506 -0
  180. rem/workers/__init__.py +5 -0
  181. rem/workers/dreaming.py +502 -0
  182. rem/workers/engram_processor.py +312 -0
  183. rem/workers/sqs_file_processor.py +193 -0
  184. remdb-0.3.7.dist-info/METADATA +1473 -0
  185. remdb-0.3.7.dist-info/RECORD +187 -0
  186. remdb-0.3.7.dist-info/WHEEL +4 -0
  187. remdb-0.3.7.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
@@ -0,0 +1,232 @@
1
+ """
2
+ Convert Pydantic models to SQLAlchemy metadata for Alembic autogenerate.
3
+
4
+ This module bridges REM's Pydantic-first approach with Alembic's SQLAlchemy requirement
5
+ by dynamically building SQLAlchemy Table objects from Pydantic model definitions.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from loguru import logger
12
+ from pydantic import BaseModel
13
+ from sqlalchemy import (
14
+ JSON,
15
+ Boolean,
16
+ Column,
17
+ DateTime,
18
+ Float,
19
+ Integer,
20
+ MetaData,
21
+ String,
22
+ Table,
23
+ Text,
24
+ )
25
+ from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
26
+
27
+ from .schema_generator import SchemaGenerator
28
+
29
+
30
+ def pydantic_type_to_sqlalchemy(
31
+ field_type: Any, field_info: Any
32
+ ) -> Any:
33
+ """
34
+ Map Pydantic field type to SQLAlchemy column type.
35
+
36
+ Args:
37
+ field_type: Pydantic field type annotation
38
+ field_info: Pydantic FieldInfo object
39
+
40
+ Returns:
41
+ SQLAlchemy column type
42
+ """
43
+ # Get the origin type (handles Optional, List, etc.)
44
+ import typing
45
+
46
+ origin = typing.get_origin(field_type)
47
+ args = typing.get_args(field_type)
48
+
49
+ # Handle Optional types
50
+ if origin is typing.Union:
51
+ # Optional[X] is Union[X, None]
52
+ non_none_types = [t for t in args if t is not type(None)]
53
+ if non_none_types:
54
+ field_type = non_none_types[0]
55
+ origin = typing.get_origin(field_type)
56
+ args = typing.get_args(field_type)
57
+
58
+ # Handle list types -> PostgreSQL ARRAY
59
+ if origin is list:
60
+ if args:
61
+ inner_type = args[0]
62
+ if inner_type is str:
63
+ return ARRAY(Text)
64
+ elif inner_type is int:
65
+ return ARRAY(Integer)
66
+ elif inner_type is float:
67
+ return ARRAY(Float)
68
+ return ARRAY(Text) # Default to text array
69
+
70
+ # Handle dict types -> JSONB
71
+ if origin is dict or field_type is dict:
72
+ return JSONB
73
+
74
+ # Handle basic types
75
+ if field_type is str:
76
+ # Check if there's a max_length constraint
77
+ max_length = getattr(field_info, "max_length", None)
78
+ if max_length:
79
+ return String(max_length)
80
+ return Text
81
+
82
+ if field_type is int:
83
+ return Integer
84
+
85
+ if field_type is float:
86
+ return Float
87
+
88
+ if field_type is bool:
89
+ return Boolean
90
+
91
+ # Handle datetime
92
+ from datetime import datetime
93
+
94
+ if field_type is datetime:
95
+ return DateTime
96
+
97
+ # Handle UUID
98
+ from uuid import UUID as UUIDType
99
+
100
+ if field_type is UUIDType:
101
+ return UUID(as_uuid=True)
102
+
103
+ # Handle enums
104
+ import enum
105
+
106
+ if isinstance(field_type, type) and issubclass(field_type, enum.Enum):
107
+ return String(50)
108
+
109
+ # Default to Text for unknown types
110
+ logger.warning(f"Unknown field type {field_type}, defaulting to Text")
111
+ return Text
112
+
113
+
114
+ def build_sqlalchemy_metadata_from_pydantic(models_dir: Path) -> MetaData:
115
+ """
116
+ Build SQLAlchemy MetaData from Pydantic models.
117
+
118
+ This function:
119
+ 1. Discovers Pydantic models in the given directory
120
+ 2. Infers table names and column definitions
121
+ 3. Creates SQLAlchemy Table objects
122
+ 4. Returns a MetaData object for Alembic
123
+
124
+ Args:
125
+ models_dir: Directory containing Pydantic models
126
+
127
+ Returns:
128
+ SQLAlchemy MetaData object
129
+ """
130
+ metadata = MetaData()
131
+ generator = SchemaGenerator()
132
+
133
+ # Discover models
134
+ models = generator.discover_models(models_dir)
135
+ logger.info(f"Discovered {len(models)} models for metadata generation")
136
+
137
+ for model_name, model_class in models.items():
138
+ # Infer table name
139
+ table_name = generator.infer_table_name(model_class)
140
+ logger.debug(f"Building table {table_name} from model {model_name}")
141
+
142
+ # Build columns
143
+ columns = []
144
+
145
+ for field_name, field_info in model_class.model_fields.items():
146
+ # Get field type
147
+ field_type = field_info.annotation
148
+
149
+ # Map to SQLAlchemy type
150
+ sa_type = pydantic_type_to_sqlalchemy(field_type, field_info)
151
+
152
+ # Determine nullable
153
+ nullable = not field_info.is_required()
154
+
155
+ # Get default value
156
+ from pydantic_core import PydanticUndefined
157
+
158
+ default = None
159
+ if field_info.default is not PydanticUndefined and field_info.default is not None:
160
+ default = field_info.default
161
+ elif field_info.default_factory is not None:
162
+ # For default_factory, we'll use the server default if possible
163
+ factory = field_info.default_factory
164
+ # Handle common default factories
165
+ if factory.__name__ == "list":
166
+ default = "ARRAY[]::TEXT[]" # PostgreSQL empty array
167
+ elif factory.__name__ == "dict":
168
+ default = "'{}'::jsonb" # PostgreSQL empty JSON
169
+ else:
170
+ default = None
171
+
172
+ # Handle special fields
173
+ server_default = None
174
+ primary_key = False
175
+
176
+ if field_name == "id":
177
+ primary_key = True
178
+ if sa_type == UUID(as_uuid=True):
179
+ server_default = "uuid_generate_v4()"
180
+ elif field_name in ("created_at", "updated_at"):
181
+ server_default = "CURRENT_TIMESTAMP"
182
+ elif isinstance(default, str) and default.startswith("ARRAY["):
183
+ server_default = default
184
+ default = None
185
+ elif isinstance(default, str) and "::jsonb" in default:
186
+ server_default = default
187
+ default = None
188
+
189
+ # Create column - only pass server_default if it's a string SQL expression
190
+ column_kwargs = {
191
+ "type_": sa_type,
192
+ "primary_key": primary_key,
193
+ "nullable": nullable,
194
+ }
195
+
196
+ if server_default is not None:
197
+ from sqlalchemy import text
198
+ column_kwargs["server_default"] = text(server_default)
199
+
200
+ column = Column(field_name, **column_kwargs)
201
+
202
+ columns.append(column)
203
+
204
+ # Create table
205
+ if columns:
206
+ Table(table_name, metadata, *columns)
207
+ logger.debug(f"Created table {table_name} with {len(columns)} columns")
208
+
209
+ logger.info(f"Built metadata with {len(metadata.tables)} tables")
210
+ return metadata
211
+
212
+
213
+ def get_target_metadata() -> MetaData:
214
+ """
215
+ Get SQLAlchemy metadata for Alembic autogenerate.
216
+
217
+ This is the main entry point used by alembic/env.py.
218
+
219
+ Returns:
220
+ SQLAlchemy MetaData object representing current Pydantic models
221
+ """
222
+ # Find models directory
223
+ import rem
224
+
225
+ package_root = Path(rem.__file__).parent.parent.parent
226
+ models_dir = package_root / "src" / "rem" / "models" / "entities"
227
+
228
+ if not models_dir.exists():
229
+ logger.error(f"Models directory not found: {models_dir}")
230
+ return MetaData()
231
+
232
+ return build_sqlalchemy_metadata_from_pydantic(models_dir)