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 +28 -0
- ff_storage/db/__init__.py +22 -0
- ff_storage/db/migrations.py +335 -0
- ff_storage/db/models.py +373 -0
- ff_storage/db/mysql.py +395 -0
- ff_storage/db/postgres.py +352 -0
- ff_storage/db/sql.py +191 -0
- ff_storage/object/__init__.py +14 -0
- ff_storage/object/base.py +233 -0
- ff_storage/object/local.py +326 -0
- ff_storage/object/s3.py +397 -0
- ff_storage-0.1.4.dist-info/METADATA +188 -0
- ff_storage-0.1.4.dist-info/RECORD +15 -0
- ff_storage-0.1.4.dist-info/WHEEL +5 -0
- ff_storage-0.1.4.dist-info/top_level.txt +1 -0
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
|