remdb 0.3.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.
Potentially problematic release.
This version of remdb might be problematic. Click here for more details.
- rem/__init__.py +2 -0
- rem/agentic/README.md +650 -0
- rem/agentic/__init__.py +39 -0
- rem/agentic/agents/README.md +155 -0
- rem/agentic/agents/__init__.py +8 -0
- rem/agentic/context.py +148 -0
- rem/agentic/context_builder.py +329 -0
- rem/agentic/mcp/__init__.py +0 -0
- rem/agentic/mcp/tool_wrapper.py +107 -0
- rem/agentic/otel/__init__.py +5 -0
- rem/agentic/otel/setup.py +151 -0
- rem/agentic/providers/phoenix.py +674 -0
- rem/agentic/providers/pydantic_ai.py +572 -0
- rem/agentic/query.py +117 -0
- rem/agentic/query_helper.py +89 -0
- rem/agentic/schema.py +396 -0
- rem/agentic/serialization.py +245 -0
- rem/agentic/tools/__init__.py +5 -0
- rem/agentic/tools/rem_tools.py +231 -0
- rem/api/README.md +420 -0
- rem/api/main.py +324 -0
- rem/api/mcp_router/prompts.py +182 -0
- rem/api/mcp_router/resources.py +536 -0
- rem/api/mcp_router/server.py +213 -0
- rem/api/mcp_router/tools.py +584 -0
- rem/api/routers/auth.py +229 -0
- rem/api/routers/chat/__init__.py +5 -0
- rem/api/routers/chat/completions.py +281 -0
- rem/api/routers/chat/json_utils.py +76 -0
- rem/api/routers/chat/models.py +124 -0
- rem/api/routers/chat/streaming.py +185 -0
- rem/auth/README.md +258 -0
- rem/auth/__init__.py +26 -0
- rem/auth/middleware.py +100 -0
- rem/auth/providers/__init__.py +13 -0
- rem/auth/providers/base.py +376 -0
- rem/auth/providers/google.py +163 -0
- rem/auth/providers/microsoft.py +237 -0
- rem/cli/README.md +455 -0
- rem/cli/__init__.py +8 -0
- rem/cli/commands/README.md +126 -0
- rem/cli/commands/__init__.py +3 -0
- rem/cli/commands/ask.py +566 -0
- rem/cli/commands/configure.py +497 -0
- rem/cli/commands/db.py +493 -0
- rem/cli/commands/dreaming.py +324 -0
- rem/cli/commands/experiments.py +1302 -0
- rem/cli/commands/mcp.py +66 -0
- rem/cli/commands/process.py +245 -0
- rem/cli/commands/schema.py +183 -0
- rem/cli/commands/serve.py +106 -0
- rem/cli/dreaming.py +363 -0
- rem/cli/main.py +96 -0
- rem/config.py +237 -0
- rem/mcp_server.py +41 -0
- rem/models/core/__init__.py +49 -0
- rem/models/core/core_model.py +64 -0
- rem/models/core/engram.py +333 -0
- rem/models/core/experiment.py +628 -0
- rem/models/core/inline_edge.py +132 -0
- rem/models/core/rem_query.py +243 -0
- rem/models/entities/__init__.py +43 -0
- rem/models/entities/file.py +57 -0
- rem/models/entities/image_resource.py +88 -0
- rem/models/entities/message.py +35 -0
- rem/models/entities/moment.py +123 -0
- rem/models/entities/ontology.py +191 -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/user.py +85 -0
- rem/py.typed +0 -0
- rem/schemas/README.md +507 -0
- rem/schemas/__init__.py +6 -0
- rem/schemas/agents/README.md +92 -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 +128 -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 +16 -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 +806 -0
- rem/services/content/service.py +676 -0
- rem/services/dreaming/README.md +230 -0
- rem/services/dreaming/__init__.py +53 -0
- rem/services/dreaming/affinity_service.py +336 -0
- rem/services/dreaming/moment_service.py +264 -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/embeddings/__init__.py +11 -0
- rem/services/embeddings/api.py +120 -0
- rem/services/embeddings/worker.py +421 -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 +686 -0
- rem/services/phoenix/config.py +88 -0
- rem/services/phoenix/prompt_labels.py +477 -0
- rem/services/postgres/README.md +575 -0
- rem/services/postgres/__init__.py +23 -0
- rem/services/postgres/migration_service.py +427 -0
- rem/services/postgres/pydantic_to_sqlalchemy.py +232 -0
- rem/services/postgres/register_type.py +352 -0
- rem/services/postgres/repository.py +337 -0
- rem/services/postgres/schema_generator.py +379 -0
- rem/services/postgres/service.py +802 -0
- rem/services/postgres/sql_builder.py +354 -0
- rem/services/rem/README.md +304 -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 +145 -0
- rem/services/rem/queries.py +196 -0
- rem/services/rem/query.py +371 -0
- rem/services/rem/service.py +527 -0
- rem/services/session/README.md +374 -0
- rem/services/session/__init__.py +6 -0
- rem/services/session/compression.py +360 -0
- rem/services/session/reload.py +77 -0
- rem/settings.py +1235 -0
- rem/sql/002_install_models.sql +1068 -0
- rem/sql/background_indexes.sql +42 -0
- rem/sql/install_models.sql +1038 -0
- rem/sql/migrations/001_install.sql +503 -0
- rem/sql/migrations/002_install_models.sql +1202 -0
- rem/utils/AGENTIC_CHUNKING.md +597 -0
- rem/utils/README.md +583 -0
- rem/utils/__init__.py +43 -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/dict_utils.py +98 -0
- rem/utils/embeddings.py +423 -0
- rem/utils/examples/embeddings_example.py +305 -0
- rem/utils/examples/sql_types_example.py +202 -0
- rem/utils/markdown.py +16 -0
- rem/utils/model_helpers.py +236 -0
- rem/utils/schema_loader.py +336 -0
- rem/utils/sql_types.py +348 -0
- rem/utils/user_id.py +81 -0
- rem/utils/vision.py +330 -0
- rem/workers/README.md +506 -0
- rem/workers/__init__.py +5 -0
- rem/workers/dreaming.py +502 -0
- rem/workers/engram_processor.py +312 -0
- rem/workers/sqs_file_processor.py +193 -0
- remdb-0.3.0.dist-info/METADATA +1455 -0
- remdb-0.3.0.dist-info/RECORD +187 -0
- remdb-0.3.0.dist-info/WHEEL +4 -0
- remdb-0.3.0.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)
|