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,49 @@
1
+ """
2
+ PostgreSQL service for CloudNativePG database operations.
3
+ """
4
+
5
+ from .diff_service import DiffService, SchemaDiff
6
+ from .programmable_diff_service import (
7
+ DiffResult,
8
+ ObjectDiff,
9
+ ObjectType,
10
+ ProgrammableDiffService,
11
+ )
12
+ from .repository import Repository
13
+ from .service import PostgresService
14
+
15
+
16
+ _postgres_instance: PostgresService | None = None
17
+
18
+
19
+ def get_postgres_service() -> PostgresService | None:
20
+ """
21
+ Get PostgresService singleton instance.
22
+
23
+ Returns None if Postgres is disabled.
24
+ Uses singleton pattern to prevent connection pool exhaustion.
25
+ """
26
+ global _postgres_instance
27
+
28
+ from ...settings import settings
29
+
30
+ if not settings.postgres.enabled:
31
+ return None
32
+
33
+ if _postgres_instance is None:
34
+ _postgres_instance = PostgresService()
35
+
36
+ return _postgres_instance
37
+
38
+
39
+ __all__ = [
40
+ "DiffResult",
41
+ "DiffService",
42
+ "ObjectDiff",
43
+ "ObjectType",
44
+ "PostgresService",
45
+ "ProgrammableDiffService",
46
+ "Repository",
47
+ "SchemaDiff",
48
+ "get_postgres_service",
49
+ ]
@@ -0,0 +1,599 @@
1
+ """
2
+ Schema diff service for comparing Pydantic models against database.
3
+
4
+ Uses Alembic autogenerate to detect differences between:
5
+ - Target schema (derived from Pydantic models)
6
+ - Current database schema
7
+
8
+ Also compares programmable objects (functions, triggers, views) which
9
+ Alembic does not track.
10
+
11
+ This enables:
12
+ 1. Local development: See what would change before applying migrations
13
+ 2. CI validation: Detect drift between code and database (--check mode)
14
+ 3. Migration generation: Create incremental migration files
15
+ """
16
+
17
+ import asyncio
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Optional
22
+ import io
23
+
24
+ from alembic.autogenerate import produce_migrations, render_python_code
25
+ from alembic.operations import ops
26
+ from alembic.runtime.migration import MigrationContext
27
+ from alembic.script import ScriptDirectory
28
+ from loguru import logger
29
+ from sqlalchemy import create_engine, text
30
+ from sqlalchemy.dialects import postgresql
31
+
32
+ from ...settings import settings
33
+ from .pydantic_to_sqlalchemy import get_target_metadata
34
+
35
+
36
+ # Tables that are NOT managed by Pydantic models (infrastructure tables)
37
+ # These are created by 001_install.sql and should be excluded from diff
38
+ INFRASTRUCTURE_TABLES = {
39
+ "kv_store",
40
+ "rem_migrations",
41
+ "rate_limits",
42
+ "persons", # Legacy table - to be removed from DB
43
+ }
44
+
45
+ # Prefixes for tables that should be included in diff
46
+ # (embeddings tables are created alongside entity tables)
47
+ EMBEDDINGS_PREFIX = "embeddings_"
48
+
49
+
50
+ @dataclass
51
+ class SchemaDiff:
52
+ """Result of schema comparison."""
53
+
54
+ has_changes: bool
55
+ summary: list[str] = field(default_factory=list)
56
+ sql: str = ""
57
+ upgrade_ops: Optional[ops.UpgradeOps] = None
58
+ filtered_count: int = 0 # Number of operations filtered out by strategy
59
+ # Programmable objects (functions, triggers, views)
60
+ programmable_summary: list[str] = field(default_factory=list)
61
+ programmable_sql: str = ""
62
+
63
+ @property
64
+ def change_count(self) -> int:
65
+ """Total number of detected changes."""
66
+ return len(self.summary) + len(self.programmable_summary)
67
+
68
+
69
+ class DiffService:
70
+ """
71
+ Service for comparing Pydantic models against database schema.
72
+
73
+ Uses Alembic's autogenerate machinery without creating revision files.
74
+
75
+ Strategies:
76
+ additive: Only ADD operations (columns, tables, indexes). No drops. Safe for production.
77
+ full: All operations including DROPs. Use with caution.
78
+ safe: Additive + safe column type changes (widenings like VARCHAR(50) -> VARCHAR(256)).
79
+ """
80
+
81
+ def __init__(self, models_dir: Optional[Path] = None, strategy: str = "additive"):
82
+ """
83
+ Initialize diff service.
84
+
85
+ Args:
86
+ models_dir: Directory containing Pydantic models.
87
+ If None, uses default rem/models/entities location.
88
+ strategy: Migration strategy - 'additive' (default), 'full', or 'safe'
89
+ """
90
+ self.models_dir = models_dir
91
+ self.strategy = strategy
92
+ self._metadata = None
93
+
94
+ def get_connection_url(self) -> str:
95
+ """Build PostgreSQL connection URL from settings using psycopg (v3) driver."""
96
+ pg = settings.postgres
97
+ # Use postgresql+psycopg to use psycopg v3 (not psycopg2)
98
+ url = f"postgresql+psycopg://{pg.user}"
99
+ if pg.password:
100
+ url += f":{pg.password}"
101
+ url += f"@{pg.host}:{pg.port}/{pg.database}"
102
+ return url
103
+
104
+ def get_target_metadata(self):
105
+ """Get SQLAlchemy metadata from Pydantic models."""
106
+ if self._metadata is None:
107
+ if self.models_dir:
108
+ from .pydantic_to_sqlalchemy import build_sqlalchemy_metadata_from_pydantic
109
+ self._metadata = build_sqlalchemy_metadata_from_pydantic(self.models_dir)
110
+ else:
111
+ self._metadata = get_target_metadata()
112
+ return self._metadata
113
+
114
+ def _include_object(self, obj, name, type_, reflected, compare_to) -> bool:
115
+ """
116
+ Filter function for Alembic autogenerate.
117
+
118
+ Excludes infrastructure tables that are not managed by Pydantic models.
119
+
120
+ Args:
121
+ obj: The schema object (Table, Column, Index, etc.)
122
+ name: Object name
123
+ type_: Object type ("table", "column", "index", etc.)
124
+ reflected: True if object exists in database
125
+ compare_to: The object being compared to (if any)
126
+
127
+ Returns:
128
+ True to include in diff, False to exclude
129
+ """
130
+ if type_ == "table":
131
+ # Exclude infrastructure tables
132
+ if name in INFRASTRUCTURE_TABLES:
133
+ return False
134
+ # Include embeddings tables (they're part of the model schema)
135
+ # These are now generated in pydantic_to_sqlalchemy
136
+ return True
137
+
138
+ def compute_diff(self, include_programmable: bool = True) -> SchemaDiff:
139
+ """
140
+ Compare Pydantic models against database and return differences.
141
+
142
+ Args:
143
+ include_programmable: If True, also diff functions/triggers/views
144
+
145
+ Returns:
146
+ SchemaDiff with detected changes
147
+ """
148
+ url = self.get_connection_url()
149
+ engine = create_engine(url)
150
+ metadata = self.get_target_metadata()
151
+
152
+ summary = []
153
+ filtered_count = 0
154
+
155
+ with engine.connect() as conn:
156
+ # Create migration context for comparison
157
+ context = MigrationContext.configure(
158
+ conn,
159
+ opts={
160
+ "target_metadata": metadata,
161
+ "compare_type": True,
162
+ "compare_server_default": False, # Avoid false positives
163
+ "include_schemas": False,
164
+ "include_object": self._include_object,
165
+ },
166
+ )
167
+
168
+ # Run autogenerate comparison
169
+ migration_script = produce_migrations(context, metadata)
170
+ upgrade_ops = migration_script.upgrade_ops
171
+
172
+ # Filter operations based on strategy
173
+ if upgrade_ops and upgrade_ops.ops:
174
+ filtered_ops, filtered_count = self._filter_operations(upgrade_ops.ops)
175
+ upgrade_ops.ops = filtered_ops
176
+
177
+ # Process filtered operations
178
+ for op in filtered_ops:
179
+ summary.extend(self._describe_operation(op))
180
+
181
+ # Generate SQL if there are changes
182
+ sql = ""
183
+ if summary and upgrade_ops:
184
+ sql = self._render_sql(upgrade_ops, engine)
185
+
186
+ # Programmable objects diff (functions, triggers, views)
187
+ programmable_summary = []
188
+ programmable_sql = ""
189
+ if include_programmable:
190
+ prog_summary, prog_sql = self._compute_programmable_diff()
191
+ programmable_summary = prog_summary
192
+ programmable_sql = prog_sql
193
+
194
+ has_changes = len(summary) > 0 or len(programmable_summary) > 0
195
+
196
+ return SchemaDiff(
197
+ has_changes=has_changes,
198
+ summary=summary,
199
+ sql=sql,
200
+ upgrade_ops=upgrade_ops,
201
+ filtered_count=filtered_count,
202
+ programmable_summary=programmable_summary,
203
+ programmable_sql=programmable_sql,
204
+ )
205
+
206
+ def _compute_programmable_diff(self) -> tuple[list[str], str]:
207
+ """
208
+ Compute diff for programmable objects (functions, triggers, views).
209
+
210
+ Returns:
211
+ Tuple of (summary_lines, sync_sql)
212
+ """
213
+ from .programmable_diff_service import ProgrammableDiffService
214
+
215
+ service = ProgrammableDiffService()
216
+
217
+ # Run async diff in sync context
218
+ try:
219
+ loop = asyncio.get_event_loop()
220
+ except RuntimeError:
221
+ loop = asyncio.new_event_loop()
222
+ asyncio.set_event_loop(loop)
223
+
224
+ result = loop.run_until_complete(service.compute_diff())
225
+
226
+ summary = []
227
+ for diff in result.diffs:
228
+ if diff.status == "missing":
229
+ summary.append(f"+ {diff.object_type.value.upper()} {diff.name} (missing)")
230
+ elif diff.status == "different":
231
+ summary.append(f"~ {diff.object_type.value.upper()} {diff.name} (different)")
232
+ elif diff.status == "extra":
233
+ summary.append(f"- {diff.object_type.value.upper()} {diff.name} (extra in db)")
234
+
235
+ return summary, result.sync_sql
236
+
237
+ def _filter_operations(self, operations: list) -> tuple[list, int]:
238
+ """
239
+ Filter operations based on migration strategy.
240
+
241
+ Args:
242
+ operations: List of Alembic operations
243
+
244
+ Returns:
245
+ Tuple of (filtered_operations, count_of_filtered_out)
246
+ """
247
+ if self.strategy == "full":
248
+ # Full strategy: include everything
249
+ return operations, 0
250
+
251
+ filtered = []
252
+ filtered_count = 0
253
+
254
+ for op in operations:
255
+ if isinstance(op, ops.ModifyTableOps):
256
+ # Filter sub-operations within table
257
+ sub_filtered, sub_count = self._filter_operations(op.ops)
258
+ filtered_count += sub_count
259
+ if sub_filtered:
260
+ op.ops = sub_filtered
261
+ filtered.append(op)
262
+ elif self._is_allowed_operation(op):
263
+ filtered.append(op)
264
+ else:
265
+ filtered_count += 1
266
+
267
+ return filtered, filtered_count
268
+
269
+ def _is_allowed_operation(self, op: ops.MigrateOperation) -> bool:
270
+ """
271
+ Check if an operation is allowed by the current strategy.
272
+
273
+ Args:
274
+ op: Alembic operation
275
+
276
+ Returns:
277
+ True if operation is allowed, False if it should be filtered out
278
+ """
279
+ # Additive operations (allowed in all strategies)
280
+ if isinstance(op, (ops.CreateTableOp, ops.AddColumnOp, ops.CreateIndexOp, ops.CreateForeignKeyOp)):
281
+ return True
282
+
283
+ # Destructive operations (only allowed in 'full' strategy)
284
+ if isinstance(op, (ops.DropTableOp, ops.DropColumnOp, ops.DropIndexOp, ops.DropConstraintOp)):
285
+ return self.strategy == "full"
286
+
287
+ # Alter operations
288
+ if isinstance(op, ops.AlterColumnOp):
289
+ if self.strategy == "full":
290
+ return True
291
+ if self.strategy == "safe":
292
+ # Allow safe type changes (widenings)
293
+ return self._is_safe_type_change(op)
294
+ # additive: no alter operations
295
+ return False
296
+
297
+ # Unknown operations: allow in full, deny otherwise
298
+ return self.strategy == "full"
299
+
300
+ def _is_safe_type_change(self, op: ops.AlterColumnOp) -> bool:
301
+ """
302
+ Check if a column type change is safe (widening, not narrowing).
303
+
304
+ Safe changes:
305
+ - VARCHAR(n) -> VARCHAR(m) where m > n
306
+ - INTEGER -> BIGINT
307
+ - Adding nullable (NOT NULL -> NULL)
308
+
309
+ Args:
310
+ op: AlterColumnOp to check
311
+
312
+ Returns:
313
+ True if the change is safe
314
+ """
315
+ # Allowing nullable is always safe
316
+ if op.modify_nullable is True:
317
+ return True
318
+
319
+ # Type changes: only allow VARCHAR widenings for now
320
+ if op.modify_type is not None:
321
+ new_type = str(op.modify_type).upper()
322
+ # VARCHAR widenings are generally safe
323
+ if "VARCHAR" in new_type:
324
+ return True # Assume widening; could add length comparison
325
+
326
+ return False
327
+
328
+ def _describe_operation(self, op: ops.MigrateOperation, prefix: str = "") -> list[str]:
329
+ """Convert Alembic operation to human-readable description."""
330
+ descriptions = []
331
+
332
+ if isinstance(op, ops.CreateTableOp):
333
+ descriptions.append(f"{prefix}+ CREATE TABLE {op.table_name}")
334
+ for col in op.columns:
335
+ if hasattr(col, 'name'):
336
+ descriptions.append(f"{prefix} + column {col.name}")
337
+
338
+ elif isinstance(op, ops.DropTableOp):
339
+ descriptions.append(f"{prefix}- DROP TABLE {op.table_name}")
340
+
341
+ elif isinstance(op, ops.AddColumnOp):
342
+ col_type = str(op.column.type) if op.column.type else "unknown"
343
+ descriptions.append(f"{prefix}+ ADD COLUMN {op.table_name}.{op.column.name} ({col_type})")
344
+
345
+ elif isinstance(op, ops.DropColumnOp):
346
+ descriptions.append(f"{prefix}- DROP COLUMN {op.table_name}.{op.column_name}")
347
+
348
+ elif isinstance(op, ops.AlterColumnOp):
349
+ changes = []
350
+ if op.modify_type is not None:
351
+ changes.append(f"type -> {op.modify_type}")
352
+ if op.modify_nullable is not None:
353
+ nullable = "NULL" if op.modify_nullable else "NOT NULL"
354
+ changes.append(f"nullable -> {nullable}")
355
+ if op.modify_server_default is not None:
356
+ changes.append(f"default -> {op.modify_server_default}")
357
+ change_str = ", ".join(changes) if changes else "modified"
358
+ descriptions.append(f"{prefix}~ ALTER COLUMN {op.table_name}.{op.column_name} ({change_str})")
359
+
360
+ elif isinstance(op, ops.CreateIndexOp):
361
+ # op.columns can be strings or Column objects
362
+ if op.columns:
363
+ cols = ", ".join(
364
+ c if isinstance(c, str) else getattr(c, 'name', str(c))
365
+ for c in op.columns
366
+ )
367
+ else:
368
+ cols = "?"
369
+ descriptions.append(f"{prefix}+ CREATE INDEX {op.index_name} ON {op.table_name} ({cols})")
370
+
371
+ elif isinstance(op, ops.DropIndexOp):
372
+ descriptions.append(f"{prefix}- DROP INDEX {op.index_name}")
373
+
374
+ elif isinstance(op, ops.CreateForeignKeyOp):
375
+ descriptions.append(f"{prefix}+ CREATE FK {op.constraint_name} ON {op.source_table}")
376
+
377
+ elif isinstance(op, ops.DropConstraintOp):
378
+ descriptions.append(f"{prefix}- DROP CONSTRAINT {op.constraint_name} ON {op.table_name}")
379
+
380
+ elif isinstance(op, ops.ModifyTableOps):
381
+ # Container for multiple operations on same table
382
+ descriptions.append(f"{prefix}Table: {op.table_name}")
383
+ for sub_op in op.ops:
384
+ descriptions.extend(self._describe_operation(sub_op, prefix + " "))
385
+
386
+ else:
387
+ descriptions.append(f"{prefix}? {type(op).__name__}")
388
+
389
+ return descriptions
390
+
391
+ def _render_sql(self, upgrade_ops: ops.UpgradeOps, engine) -> str:
392
+ """Render upgrade operations as SQL statements."""
393
+ from alembic.runtime.migration import MigrationContext
394
+ from alembic.operations import Operations
395
+
396
+ sql_lines = []
397
+
398
+ # Use offline mode to generate SQL
399
+ buffer = io.StringIO()
400
+
401
+ def emit_sql(text, *args, **kwargs):
402
+ sql_lines.append(str(text))
403
+
404
+ with engine.connect() as conn:
405
+ context = MigrationContext.configure(
406
+ conn,
407
+ opts={
408
+ "as_sql": True,
409
+ "output_buffer": buffer,
410
+ "target_metadata": self.get_target_metadata(),
411
+ },
412
+ )
413
+
414
+ with context.begin_transaction():
415
+ operations = Operations(context)
416
+ for op in upgrade_ops.ops:
417
+ self._execute_op(operations, op)
418
+
419
+ return buffer.getvalue()
420
+
421
+ def _execute_op(self, operations: "Operations", op: ops.MigrateOperation):
422
+ """Execute a single operation via Operations proxy."""
423
+ from alembic.operations import Operations
424
+ from alembic.autogenerate import rewriter
425
+
426
+ if isinstance(op, ops.CreateTableOp):
427
+ operations.create_table(
428
+ op.table_name,
429
+ *op.columns,
430
+ schema=op.schema,
431
+ **op.kw,
432
+ )
433
+ elif isinstance(op, ops.DropTableOp):
434
+ operations.drop_table(op.table_name, schema=op.schema)
435
+ elif isinstance(op, ops.AddColumnOp):
436
+ operations.add_column(op.table_name, op.column, schema=op.schema)
437
+ elif isinstance(op, ops.DropColumnOp):
438
+ operations.drop_column(op.table_name, op.column_name, schema=op.schema)
439
+ elif isinstance(op, ops.AlterColumnOp):
440
+ operations.alter_column(
441
+ op.table_name,
442
+ op.column_name,
443
+ nullable=op.modify_nullable,
444
+ type_=op.modify_type,
445
+ server_default=op.modify_server_default,
446
+ schema=op.schema,
447
+ )
448
+ elif isinstance(op, ops.CreateIndexOp):
449
+ operations.create_index(
450
+ op.index_name,
451
+ op.table_name,
452
+ op.columns,
453
+ schema=op.schema,
454
+ unique=op.unique,
455
+ **op.kw,
456
+ )
457
+ elif isinstance(op, ops.DropIndexOp):
458
+ operations.drop_index(op.index_name, table_name=op.table_name, schema=op.schema)
459
+ elif isinstance(op, ops.ModifyTableOps):
460
+ for sub_op in op.ops:
461
+ self._execute_op(operations, sub_op)
462
+
463
+ def generate_migration_file(
464
+ self,
465
+ output_dir: Path,
466
+ message: str = "auto_migration",
467
+ ) -> Optional[Path]:
468
+ """
469
+ Generate a numbered migration file from the diff.
470
+
471
+ Args:
472
+ output_dir: Directory to write migration file
473
+ message: Migration description (used in filename)
474
+
475
+ Returns:
476
+ Path to generated file, or None if no changes
477
+ """
478
+ diff = self.compute_diff()
479
+
480
+ if not diff.has_changes:
481
+ logger.info("No schema changes detected")
482
+ return None
483
+
484
+ # Find next migration number
485
+ existing = sorted(output_dir.glob("*.sql"))
486
+ next_num = 1
487
+ for f in existing:
488
+ try:
489
+ num = int(f.stem.split("_")[0])
490
+ next_num = max(next_num, num + 1)
491
+ except (ValueError, IndexError):
492
+ pass
493
+
494
+ # Generate filename
495
+ safe_message = message.replace(" ", "_").replace("-", "_")[:40]
496
+ filename = f"{next_num:03d}_{safe_message}.sql"
497
+ output_path = output_dir / filename
498
+
499
+ # Write SQL
500
+ header = f"""-- Migration: {message}
501
+ -- Generated by: rem db diff --generate
502
+ -- Changes detected: {diff.change_count}
503
+ --
504
+ -- Review this file before applying!
505
+ -- Apply with: rem db migrate
506
+ --
507
+
508
+ """
509
+ # Build SQL from operations
510
+ sql_content = self._build_migration_sql(diff)
511
+
512
+ output_path.write_text(header + sql_content)
513
+ logger.info(f"Generated migration: {output_path}")
514
+
515
+ return output_path
516
+
517
+ def _build_migration_sql(self, diff: SchemaDiff) -> str:
518
+ """Build SQL from diff operations."""
519
+ if not diff.upgrade_ops or not diff.upgrade_ops.ops:
520
+ return "-- No changes\n"
521
+
522
+ lines = []
523
+ for op in diff.upgrade_ops.ops:
524
+ lines.extend(self._op_to_sql(op))
525
+
526
+ return "\n".join(lines) + "\n"
527
+
528
+ def _compile_type(self, col_type) -> str:
529
+ """Compile SQLAlchemy type to PostgreSQL DDL string.
530
+
531
+ SQLAlchemy types like ARRAY(Text) need dialect-specific compilation
532
+ to render correctly (e.g., "TEXT[]" instead of just "ARRAY").
533
+ """
534
+ try:
535
+ return col_type.compile(dialect=postgresql.dialect())
536
+ except Exception:
537
+ # Fallback to string representation if compilation fails
538
+ return str(col_type)
539
+
540
+ def _op_to_sql(self, op: ops.MigrateOperation) -> list[str]:
541
+ """Convert operation to SQL statements."""
542
+ lines = []
543
+
544
+ if isinstance(op, ops.CreateTableOp):
545
+ cols = []
546
+ for col in op.columns:
547
+ if hasattr(col, 'name') and hasattr(col, 'type'):
548
+ nullable = "" if getattr(col, 'nullable', True) else " NOT NULL"
549
+ type_str = self._compile_type(col.type)
550
+ cols.append(f" {col.name} {type_str}{nullable}")
551
+ col_str = ",\n".join(cols)
552
+ lines.append(f"CREATE TABLE IF NOT EXISTS {op.table_name} (\n{col_str}\n);")
553
+
554
+ elif isinstance(op, ops.DropTableOp):
555
+ lines.append(f"DROP TABLE IF EXISTS {op.table_name};")
556
+
557
+ elif isinstance(op, ops.AddColumnOp):
558
+ col = op.column
559
+ nullable = "" if getattr(col, 'nullable', True) else " NOT NULL"
560
+ type_str = self._compile_type(col.type)
561
+ lines.append(f"ALTER TABLE {op.table_name} ADD COLUMN IF NOT EXISTS {col.name} {type_str}{nullable};")
562
+
563
+ elif isinstance(op, ops.DropColumnOp):
564
+ lines.append(f"ALTER TABLE {op.table_name} DROP COLUMN IF EXISTS {op.column_name};")
565
+
566
+ elif isinstance(op, ops.AlterColumnOp):
567
+ if op.modify_type is not None:
568
+ type_str = self._compile_type(op.modify_type)
569
+ lines.append(f"ALTER TABLE {op.table_name} ALTER COLUMN {op.column_name} TYPE {type_str};")
570
+ if op.modify_nullable is not None:
571
+ if op.modify_nullable:
572
+ lines.append(f"ALTER TABLE {op.table_name} ALTER COLUMN {op.column_name} DROP NOT NULL;")
573
+ else:
574
+ lines.append(f"ALTER TABLE {op.table_name} ALTER COLUMN {op.column_name} SET NOT NULL;")
575
+
576
+ elif isinstance(op, ops.CreateIndexOp):
577
+ # op.columns can be strings or Column objects
578
+ if op.columns:
579
+ cols = ", ".join(
580
+ c if isinstance(c, str) else getattr(c, 'name', str(c))
581
+ for c in op.columns
582
+ )
583
+ else:
584
+ cols = ""
585
+ unique = "UNIQUE " if op.unique else ""
586
+ lines.append(f"CREATE {unique}INDEX IF NOT EXISTS {op.index_name} ON {op.table_name} ({cols});")
587
+
588
+ elif isinstance(op, ops.DropIndexOp):
589
+ lines.append(f"DROP INDEX IF EXISTS {op.index_name};")
590
+
591
+ elif isinstance(op, ops.ModifyTableOps):
592
+ lines.append(f"-- Changes to table: {op.table_name}")
593
+ for sub_op in op.ops:
594
+ lines.extend(self._op_to_sql(sub_op))
595
+
596
+ else:
597
+ lines.append(f"-- Unsupported operation: {type(op).__name__}")
598
+
599
+ return lines