ff-storage 0.1.4__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.
ff_storage/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """
2
+ ff-storage: Database and file storage operations for Fenixflow applications.
3
+ """
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ # Database exports
8
+ from .db.postgres import Postgres, PostgresPool
9
+ from .db.mysql import MySQL, MySQLPool
10
+ from .db.migrations import MigrationManager
11
+
12
+ # Object storage exports
13
+ from .object import ObjectStorage, LocalObjectStorage, S3ObjectStorage
14
+
15
+ __all__ = [
16
+ # PostgreSQL
17
+ "Postgres",
18
+ "PostgresPool",
19
+ # MySQL
20
+ "MySQL",
21
+ "MySQLPool",
22
+ # Migrations
23
+ "MigrationManager",
24
+ # Object Storage
25
+ "ObjectStorage",
26
+ "LocalObjectStorage",
27
+ "S3ObjectStorage",
28
+ ]
@@ -0,0 +1,22 @@
1
+ """
2
+ Database connection and operation modules.
3
+ """
4
+
5
+ from .sql import SQL
6
+ from .postgres import Postgres, PostgresPool, PostgresBase
7
+ from .mysql import MySQL, MySQLPool, MySQLBase
8
+ from .migrations import MigrationManager
9
+
10
+ __all__ = [
11
+ "SQL",
12
+ # PostgreSQL
13
+ "Postgres",
14
+ "PostgresPool",
15
+ "PostgresBase",
16
+ # MySQL
17
+ "MySQL",
18
+ "MySQLPool",
19
+ "MySQLBase",
20
+ # Migrations
21
+ "MigrationManager",
22
+ ]
@@ -0,0 +1,335 @@
1
+ """
2
+ Simple SQL file-based migration system.
3
+ Provides version tracking and execution of SQL migration files.
4
+ """
5
+
6
+ import hashlib
7
+ import logging
8
+ from pathlib import Path
9
+ from typing import List, Optional, Tuple
10
+ from datetime import datetime, timezone
11
+
12
+
13
+ class MigrationManager:
14
+ """
15
+ Manages SQL file-based database migrations.
16
+
17
+ Migrations are SQL files named with version prefixes (e.g., 001_initial_schema.sql).
18
+ The manager tracks which migrations have been applied and runs pending ones in order.
19
+ """
20
+
21
+ def __init__(self, db_connection, migrations_path: str):
22
+ """
23
+ Initialize the migration manager.
24
+
25
+ :param db_connection: Database connection instance (Postgres, PostgresPool, etc.)
26
+ :param migrations_path: Path to directory containing migration SQL files.
27
+ """
28
+ self.db = db_connection
29
+ self.migrations_path = Path(migrations_path)
30
+ self.logger = logging.getLogger(__name__)
31
+
32
+ if not self.migrations_path.exists():
33
+ self.migrations_path.mkdir(parents=True, exist_ok=True)
34
+ self.logger.info(f"Created migrations directory: {self.migrations_path}")
35
+
36
+ def init_migrations_table(self) -> None:
37
+ """
38
+ Create the schema_migrations table if it doesn't exist.
39
+
40
+ This table tracks which migrations have been applied.
41
+ """
42
+ create_table_sql = """
43
+ CREATE TABLE IF NOT EXISTS schema_migrations (
44
+ version VARCHAR(255) PRIMARY KEY,
45
+ applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
46
+ checksum VARCHAR(64),
47
+ execution_time_ms INTEGER,
48
+ success BOOLEAN DEFAULT TRUE,
49
+ error_message TEXT
50
+ );
51
+
52
+ CREATE INDEX IF NOT EXISTS idx_migrations_applied_at
53
+ ON schema_migrations(applied_at DESC);
54
+ """
55
+
56
+ try:
57
+ self.db.execute(create_table_sql)
58
+ self.logger.info("Migrations table initialized")
59
+ except Exception as e:
60
+ self.logger.error(f"Failed to create migrations table: {e}")
61
+ raise
62
+
63
+ def get_applied_migrations(self) -> List[str]:
64
+ """
65
+ Get list of migrations that have already been applied.
66
+
67
+ :return: List of version strings that have been applied successfully.
68
+ """
69
+ # First check if migrations table exists
70
+ try:
71
+ # Try to create the table if it doesn't exist
72
+ self.init_migrations_table()
73
+ except Exception as e:
74
+ self.logger.debug(f"Could not ensure migrations table exists: {e}")
75
+
76
+ query = """
77
+ SELECT version
78
+ FROM schema_migrations
79
+ WHERE success = TRUE
80
+ ORDER BY version
81
+ """
82
+
83
+ try:
84
+ results = self.db.read_query(query)
85
+ return [row[0] for row in results]
86
+ except Exception as e:
87
+ self.logger.debug(f"Could not read migrations table: {e}")
88
+ # Table might not exist yet
89
+ return []
90
+
91
+ def get_pending_migrations(self) -> List[Tuple[str, Path]]:
92
+ """
93
+ Get list of migrations that haven't been applied yet.
94
+
95
+ :return: List of tuples (version, filepath) for pending migrations.
96
+ """
97
+ applied = set(self.get_applied_migrations())
98
+ pending = []
99
+
100
+ # Find all SQL files in migrations directory
101
+ migration_files = sorted(self.migrations_path.glob("*.sql"))
102
+
103
+ for filepath in migration_files:
104
+ # Extract version from filename (e.g., "001_initial_schema.sql" -> "001")
105
+ version = filepath.stem.split("_")[0]
106
+
107
+ # Skip if already applied
108
+ if version not in applied:
109
+ pending.append((version, filepath))
110
+
111
+ return pending
112
+
113
+ def calculate_checksum(self, filepath: Path) -> str:
114
+ """
115
+ Calculate SHA256 checksum of a migration file.
116
+
117
+ :param filepath: Path to the migration file.
118
+ :return: Hexadecimal checksum string.
119
+ """
120
+ with open(filepath, "rb") as f:
121
+ return hashlib.sha256(f.read()).hexdigest()
122
+
123
+ def apply_migration(self, version: str, filepath: Path) -> None:
124
+ """
125
+ Apply a single migration file.
126
+
127
+ :param version: Version identifier for the migration.
128
+ :param filepath: Path to the SQL file to execute.
129
+ :raises RuntimeError: If migration fails.
130
+ """
131
+ self.logger.info(f"Applying migration {version}: {filepath.name}")
132
+
133
+ start_time = datetime.now(timezone.utc)
134
+ checksum = self.calculate_checksum(filepath)
135
+ error_message = None
136
+ success = True
137
+
138
+ try:
139
+ # Read migration file
140
+ with open(filepath, "r") as f:
141
+ sql_content = f.read()
142
+
143
+ # Split by semicolons but be careful with strings/comments
144
+ # For now, execute the entire file as one statement
145
+ # More sophisticated parsing can be added if needed
146
+ statements = self._split_sql_statements(sql_content)
147
+
148
+ # Execute each statement
149
+ for statement in statements:
150
+ if statement.strip():
151
+ self.db.execute(statement)
152
+
153
+ self.logger.info(f"Successfully applied migration {version}")
154
+
155
+ except Exception as e:
156
+ error_message = str(e)
157
+ success = False
158
+ self.logger.error(f"Failed to apply migration {version}: {e}")
159
+
160
+ # Try to rollback if possible
161
+ if hasattr(self.db, "rollback"):
162
+ self.db.rollback()
163
+
164
+ raise RuntimeError(f"Migration {version} failed: {e}")
165
+
166
+ finally:
167
+ # Record migration attempt
168
+ execution_time_ms = int(
169
+ (datetime.now(timezone.utc) - start_time).total_seconds() * 1000
170
+ )
171
+
172
+ record_sql = """
173
+ INSERT INTO schema_migrations (
174
+ version, checksum, execution_time_ms, success, error_message
175
+ ) VALUES (%(version)s, %(checksum)s, %(time)s, %(success)s, %(error)s)
176
+ ON CONFLICT (version) DO UPDATE SET
177
+ applied_at = NOW(),
178
+ checksum = EXCLUDED.checksum,
179
+ execution_time_ms = EXCLUDED.execution_time_ms,
180
+ success = EXCLUDED.success,
181
+ error_message = EXCLUDED.error_message
182
+ """
183
+
184
+ try:
185
+ self.db.execute(
186
+ record_sql,
187
+ {
188
+ "version": version,
189
+ "checksum": checksum,
190
+ "time": execution_time_ms,
191
+ "success": success,
192
+ "error": error_message,
193
+ },
194
+ )
195
+ except Exception as e:
196
+ self.logger.error(f"Failed to record migration status: {e}")
197
+
198
+ def _split_sql_statements(self, sql_content: str) -> List[str]:
199
+ """
200
+ Split SQL content into individual statements.
201
+
202
+ Simple implementation that splits on semicolons.
203
+ Can be enhanced to handle strings and comments properly.
204
+
205
+ :param sql_content: The SQL file content.
206
+ :return: List of SQL statements.
207
+ """
208
+ # Simple split for now - can be enhanced
209
+ statements = []
210
+ current = []
211
+
212
+ for line in sql_content.split("\n"):
213
+ # Skip comments
214
+ if line.strip().startswith("--"):
215
+ continue
216
+
217
+ current.append(line)
218
+
219
+ # Check if line ends with semicolon
220
+ if line.rstrip().endswith(";"):
221
+ statements.append("\n".join(current))
222
+ current = []
223
+
224
+ # Add any remaining content
225
+ if current:
226
+ statements.append("\n".join(current))
227
+
228
+ return statements
229
+
230
+ def migrate(self, target_version: Optional[str] = None) -> int:
231
+ """
232
+ Run all pending migrations up to target version.
233
+
234
+ :param target_version: Optional version to migrate to. If None, runs all pending.
235
+ :return: Number of migrations applied.
236
+ """
237
+ # Ensure migrations table exists
238
+ self.init_migrations_table()
239
+
240
+ # Get pending migrations
241
+ pending = self.get_pending_migrations()
242
+
243
+ if not pending:
244
+ self.logger.info("No pending migrations")
245
+ return 0
246
+
247
+ # Filter by target version if specified
248
+ if target_version:
249
+ pending = [(v, f) for v, f in pending if v <= target_version]
250
+
251
+ self.logger.info(f"Found {len(pending)} pending migrations")
252
+
253
+ applied_count = 0
254
+ for version, filepath in pending:
255
+ try:
256
+ self.apply_migration(version, filepath)
257
+ applied_count += 1
258
+ except RuntimeError as e:
259
+ self.logger.error(f"Migration failed, stopping: {e}")
260
+ break
261
+
262
+ self.logger.info(f"Applied {applied_count} migrations")
263
+ return applied_count
264
+
265
+ def rollback(self, version: str) -> None:
266
+ """
267
+ Rollback to a specific version.
268
+
269
+ Looks for down migration files (e.g., 001_down.sql) and executes them
270
+ in reverse order to reach the target version.
271
+
272
+ :param version: Version to rollback to.
273
+ :raises NotImplementedError: Rollback not yet implemented.
274
+ """
275
+ # This would look for down migration files and execute them
276
+ # Implementation depends on migration file naming convention
277
+ raise NotImplementedError("Rollback functionality not yet implemented")
278
+
279
+ def status(self) -> dict:
280
+ """
281
+ Get current migration status.
282
+
283
+ :return: Dictionary with migration status information.
284
+ """
285
+ applied = self.get_applied_migrations()
286
+ pending = self.get_pending_migrations()
287
+
288
+ return {
289
+ "applied_count": len(applied),
290
+ "pending_count": len(pending),
291
+ "latest_applied": applied[-1] if applied else None,
292
+ "next_pending": pending[0][0] if pending else None,
293
+ "applied_versions": applied,
294
+ "pending_versions": [v for v, _ in pending],
295
+ }
296
+
297
+ def create_migration(self, name: str, content: str = "") -> Path:
298
+ """
299
+ Create a new migration file with the next version number.
300
+
301
+ :param name: Descriptive name for the migration.
302
+ :param content: Optional SQL content for the migration.
303
+ :return: Path to the created migration file.
304
+ """
305
+ # Find next version number
306
+ existing_files = list(self.migrations_path.glob("*.sql"))
307
+ if existing_files:
308
+ versions = []
309
+ for f in existing_files:
310
+ try:
311
+ version_str = f.stem.split("_")[0]
312
+ versions.append(int(version_str))
313
+ except (IndexError, ValueError):
314
+ continue
315
+ next_version = max(versions) + 1 if versions else 1
316
+ else:
317
+ next_version = 1
318
+
319
+ # Format version with leading zeros
320
+ version_str = f"{next_version:03d}"
321
+
322
+ # Create filename
323
+ safe_name = name.lower().replace(" ", "_").replace("-", "_")
324
+ filename = f"{version_str}_{safe_name}.sql"
325
+ filepath = self.migrations_path / filename
326
+
327
+ # Write content
328
+ if not content:
329
+ content = f"-- Migration: {name}\n-- Version: {version_str}\n-- Date: {datetime.now(timezone.utc).isoformat()}\n\n"
330
+
331
+ with open(filepath, "w") as f:
332
+ f.write(content)
333
+
334
+ self.logger.info(f"Created migration: {filepath}")
335
+ return filepath