winebox 0.1.4__tar.gz → 0.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {winebox-0.1.4 → winebox-0.1.5}/PKG-INFO +1 -1
  2. {winebox-0.1.4 → winebox-0.1.5}/docs/index.md +2 -0
  3. {winebox-0.1.4 → winebox-0.1.5}/docs/user-guide.md +6 -0
  4. {winebox-0.1.4 → winebox-0.1.5}/pyproject.toml +1 -1
  5. winebox-0.1.5/scripts/__init__.py +1 -0
  6. winebox-0.1.5/scripts/migrations/__init__.py +20 -0
  7. winebox-0.1.5/scripts/migrations/db_migrate_0_to_1.py +46 -0
  8. winebox-0.1.5/scripts/migrations/db_revert_1_to_0.py +108 -0
  9. winebox-0.1.5/scripts/migrations/runner.py +606 -0
  10. {winebox-0.1.4 → winebox-0.1.5}/uv.lock +1 -1
  11. {winebox-0.1.4 → winebox-0.1.5}/winebox/__init__.py +1 -1
  12. {winebox-0.1.4 → winebox-0.1.5}/.github/workflows/ci.yml +0 -0
  13. {winebox-0.1.4 → winebox-0.1.5}/.github/workflows/publish.yml +0 -0
  14. {winebox-0.1.4 → winebox-0.1.5}/.gitignore +0 -0
  15. {winebox-0.1.4 → winebox-0.1.5}/.python-version +0 -0
  16. {winebox-0.1.4 → winebox-0.1.5}/LICENSE +0 -0
  17. {winebox-0.1.4 → winebox-0.1.5}/README.md +0 -0
  18. {winebox-0.1.4 → winebox-0.1.5}/docs/_static/screenshots/cellar.png +0 -0
  19. {winebox-0.1.4 → winebox-0.1.5}/docs/_static/screenshots/checkin.png +0 -0
  20. {winebox-0.1.4 → winebox-0.1.5}/docs/_static/screenshots/dashboard.png +0 -0
  21. {winebox-0.1.4 → winebox-0.1.5}/docs/_static/screenshots/search.png +0 -0
  22. {winebox-0.1.4 → winebox-0.1.5}/docs/api-reference.md +0 -0
  23. {winebox-0.1.4 → winebox-0.1.5}/docs/conf.py +0 -0
  24. {winebox-0.1.4 → winebox-0.1.5}/docs/screenshots/cellar.png +0 -0
  25. {winebox-0.1.4 → winebox-0.1.5}/docs/screenshots/checkin.png +0 -0
  26. {winebox-0.1.4 → winebox-0.1.5}/docs/screenshots/dashboard.png +0 -0
  27. {winebox-0.1.4 → winebox-0.1.5}/docs/screenshots/login.png +0 -0
  28. {winebox-0.1.4 → winebox-0.1.5}/tasks.py +0 -0
  29. {winebox-0.1.4 → winebox-0.1.5}/tests/__init__.py +0 -0
  30. {winebox-0.1.4 → winebox-0.1.5}/tests/conftest.py +0 -0
  31. {winebox-0.1.4 → winebox-0.1.5}/tests/test_checkin_e2e.py +0 -0
  32. {winebox-0.1.4 → winebox-0.1.5}/tests/test_ocr.py +0 -0
  33. {winebox-0.1.4 → winebox-0.1.5}/tests/test_search.py +0 -0
  34. {winebox-0.1.4 → winebox-0.1.5}/tests/test_transactions.py +0 -0
  35. {winebox-0.1.4 → winebox-0.1.5}/tests/test_wines.py +0 -0
  36. {winebox-0.1.4 → winebox-0.1.5}/winebox/cli/__init__.py +0 -0
  37. {winebox-0.1.4 → winebox-0.1.5}/winebox/cli/server.py +0 -0
  38. {winebox-0.1.4 → winebox-0.1.5}/winebox/cli/user_admin.py +0 -0
  39. {winebox-0.1.4 → winebox-0.1.5}/winebox/config.py +0 -0
  40. {winebox-0.1.4 → winebox-0.1.5}/winebox/database.py +0 -0
  41. {winebox-0.1.4 → winebox-0.1.5}/winebox/main.py +0 -0
  42. {winebox-0.1.4 → winebox-0.1.5}/winebox/models/__init__.py +0 -0
  43. {winebox-0.1.4 → winebox-0.1.5}/winebox/models/inventory.py +0 -0
  44. {winebox-0.1.4 → winebox-0.1.5}/winebox/models/transaction.py +0 -0
  45. {winebox-0.1.4 → winebox-0.1.5}/winebox/models/user.py +0 -0
  46. {winebox-0.1.4 → winebox-0.1.5}/winebox/models/wine.py +0 -0
  47. {winebox-0.1.4 → winebox-0.1.5}/winebox/routers/__init__.py +0 -0
  48. {winebox-0.1.4 → winebox-0.1.5}/winebox/routers/auth.py +0 -0
  49. {winebox-0.1.4 → winebox-0.1.5}/winebox/routers/cellar.py +0 -0
  50. {winebox-0.1.4 → winebox-0.1.5}/winebox/routers/search.py +0 -0
  51. {winebox-0.1.4 → winebox-0.1.5}/winebox/routers/transactions.py +0 -0
  52. {winebox-0.1.4 → winebox-0.1.5}/winebox/routers/wines.py +0 -0
  53. {winebox-0.1.4 → winebox-0.1.5}/winebox/schemas/__init__.py +0 -0
  54. {winebox-0.1.4 → winebox-0.1.5}/winebox/schemas/transaction.py +0 -0
  55. {winebox-0.1.4 → winebox-0.1.5}/winebox/schemas/wine.py +0 -0
  56. {winebox-0.1.4 → winebox-0.1.5}/winebox/services/__init__.py +0 -0
  57. {winebox-0.1.4 → winebox-0.1.5}/winebox/services/auth.py +0 -0
  58. {winebox-0.1.4 → winebox-0.1.5}/winebox/services/image_storage.py +0 -0
  59. {winebox-0.1.4 → winebox-0.1.5}/winebox/services/ocr.py +0 -0
  60. {winebox-0.1.4 → winebox-0.1.5}/winebox/services/vision.py +0 -0
  61. {winebox-0.1.4 → winebox-0.1.5}/winebox/services/wine_parser.py +0 -0
  62. {winebox-0.1.4 → winebox-0.1.5}/winebox/static/css/style.css +0 -0
  63. {winebox-0.1.4 → winebox-0.1.5}/winebox/static/favicon.svg +0 -0
  64. {winebox-0.1.4 → winebox-0.1.5}/winebox/static/index.html +0 -0
  65. {winebox-0.1.4 → winebox-0.1.5}/winebox/static/js/app.js +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: winebox
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Wine Cellar Management Application with OCR label scanning
5
5
  Project-URL: Homepage, https://github.com/jdrumgoole/winebox
6
6
  Project-URL: Repository, https://github.com/jdrumgoole/winebox
@@ -162,6 +162,8 @@ winebox/
162
162
  │ ├── routers/ # API endpoints
163
163
  │ ├── services/ # Business logic
164
164
  │ └── static/ # Web interface
165
+ ├── scripts/ # Utility scripts
166
+ │ └── migrations/ # Database migration system
165
167
  ├── tests/ # Test suite
166
168
  ├── docs/ # Documentation
167
169
  ├── data/ # Database and images
@@ -224,6 +224,12 @@ invoke init-db # Initialize database
224
224
  invoke purge --force # Delete database and images
225
225
  invoke purge-wines --force # Delete wines but keep users
226
226
 
227
+ # Database migrations
228
+ uv run python -m scripts.migrations.runner status # Show version
229
+ uv run python -m scripts.migrations.runner up # Migrate to latest
230
+ uv run python -m scripts.migrations.runner down --to 0 # Revert to version
231
+ uv run python -m scripts.migrations.runner history # Show history
232
+
227
233
  # User management
228
234
  invoke add-user <username> --password <pass>
229
235
  invoke remove-user <username> --force
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "winebox"
3
- version = "0.1.4"
3
+ version = "0.1.5"
4
4
  description = "Wine Cellar Management Application with OCR label scanning"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -0,0 +1 @@
1
+ """Scripts package for WineBox utility scripts."""
@@ -0,0 +1,20 @@
1
+ """Database migration system for WineBox.
2
+
3
+ This package provides versioned database migrations with forward and reverse support.
4
+
5
+ Usage:
6
+ # Show current version and available migrations
7
+ uv run python -m scripts.migrations.runner status
8
+
9
+ # Migrate to latest version
10
+ uv run python -m scripts.migrations.runner up
11
+
12
+ # Migrate to specific version
13
+ uv run python -m scripts.migrations.runner up --to 2
14
+
15
+ # Revert to specific version
16
+ uv run python -m scripts.migrations.runner down --to 0
17
+
18
+ # Show migration history
19
+ uv run python -m scripts.migrations.runner history
20
+ """
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env python3
2
+ """Migration: Add full_name and anthropic_api_key columns to users table.
3
+
4
+ This migration adds user settings columns:
5
+ - full_name: Optional display name for the user
6
+ - anthropic_api_key: Optional API key for Claude Vision (per-user override)
7
+
8
+ Version: 0 -> 1
9
+ """
10
+
11
+ import sqlite3
12
+
13
+ # Migration metadata
14
+ SOURCE_VERSION = 0
15
+ TARGET_VERSION = 1
16
+ DESCRIPTION = "Add full_name and anthropic_api_key to users table"
17
+
18
+
19
+ def migrate(cursor: sqlite3.Cursor) -> None:
20
+ """Apply the forward migration.
21
+
22
+ Adds full_name and anthropic_api_key columns to the users table.
23
+ These are nullable columns, so existing users will have NULL values.
24
+ """
25
+ # Check existing columns
26
+ cursor.execute("PRAGMA table_info(users)")
27
+ columns = {row[1] for row in cursor.fetchall()}
28
+
29
+ # Add full_name column if it doesn't exist
30
+ if "full_name" not in columns:
31
+ cursor.execute("ALTER TABLE users ADD COLUMN full_name VARCHAR(255)")
32
+
33
+ # Add anthropic_api_key column if it doesn't exist
34
+ if "anthropic_api_key" not in columns:
35
+ cursor.execute("ALTER TABLE users ADD COLUMN anthropic_api_key VARCHAR(255)")
36
+
37
+
38
+ def validate(cursor: sqlite3.Cursor) -> bool:
39
+ """Validate the migration was successful.
40
+
41
+ Returns True if both columns exist in the users table.
42
+ """
43
+ cursor.execute("PRAGMA table_info(users)")
44
+ columns = {row[1] for row in cursor.fetchall()}
45
+
46
+ return "full_name" in columns and "anthropic_api_key" in columns
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env python3
2
+ """Revert migration: Remove full_name and anthropic_api_key columns from users table.
3
+
4
+ This revert removes the user settings columns added in version 1.
5
+ WARNING: Data in these columns will be lost!
6
+
7
+ Version: 1 -> 0
8
+ """
9
+
10
+ import sqlite3
11
+
12
+ # Migration metadata
13
+ SOURCE_VERSION = 1
14
+ TARGET_VERSION = 0
15
+ DESCRIPTION = "Remove full_name and anthropic_api_key from users table"
16
+
17
+
18
+ def migrate(cursor: sqlite3.Cursor) -> None:
19
+ """Apply the reverse migration (revert).
20
+
21
+ Removes full_name and anthropic_api_key columns from the users table.
22
+
23
+ Since SQLite doesn't support DROP COLUMN in older versions, we use
24
+ the table rebuild pattern:
25
+ 1. Create new table without the columns
26
+ 2. Copy data from old table
27
+ 3. Drop old table
28
+ 4. Rename new table
29
+ 5. Recreate indexes
30
+
31
+ WARNING: Data in full_name and anthropic_api_key columns will be lost!
32
+ """
33
+ # Check if columns exist (may have already been removed)
34
+ cursor.execute("PRAGMA table_info(users)")
35
+ columns = {row[1] for row in cursor.fetchall()}
36
+
37
+ if "full_name" not in columns and "anthropic_api_key" not in columns:
38
+ # Columns already removed, nothing to do
39
+ return
40
+
41
+ # Disable foreign key checks during table rebuild
42
+ cursor.execute("PRAGMA foreign_keys = OFF")
43
+
44
+ try:
45
+ # 1. Create new table without full_name and anthropic_api_key
46
+ cursor.execute("""
47
+ CREATE TABLE users_new (
48
+ id CHAR(36) PRIMARY KEY,
49
+ username VARCHAR(50) NOT NULL UNIQUE,
50
+ email VARCHAR(255) UNIQUE,
51
+ hashed_password VARCHAR(255) NOT NULL,
52
+ is_active BOOLEAN DEFAULT 1 NOT NULL,
53
+ is_admin BOOLEAN DEFAULT 0 NOT NULL,
54
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
55
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
56
+ last_login DATETIME
57
+ )
58
+ """)
59
+
60
+ # 2. Copy data from old table (excluding removed columns)
61
+ cursor.execute("""
62
+ INSERT INTO users_new (
63
+ id, username, email, hashed_password,
64
+ is_active, is_admin, created_at, updated_at, last_login
65
+ )
66
+ SELECT
67
+ id, username, email, hashed_password,
68
+ is_active, is_admin, created_at, updated_at, last_login
69
+ FROM users
70
+ """)
71
+
72
+ # 3. Drop old table
73
+ cursor.execute("DROP TABLE users")
74
+
75
+ # 4. Rename new table
76
+ cursor.execute("ALTER TABLE users_new RENAME TO users")
77
+
78
+ # 5. Recreate indexes
79
+ cursor.execute(
80
+ "CREATE UNIQUE INDEX IF NOT EXISTS ix_users_username ON users (username)"
81
+ )
82
+ cursor.execute(
83
+ "CREATE UNIQUE INDEX IF NOT EXISTS ix_users_email ON users (email)"
84
+ )
85
+
86
+ finally:
87
+ # Re-enable foreign key checks
88
+ cursor.execute("PRAGMA foreign_keys = ON")
89
+
90
+
91
+ def validate(cursor: sqlite3.Cursor) -> bool:
92
+ """Validate the revert was successful.
93
+
94
+ Returns True if both columns have been removed from the users table.
95
+ """
96
+ cursor.execute("PRAGMA table_info(users)")
97
+ columns = {row[1] for row in cursor.fetchall()}
98
+
99
+ # Verify columns are removed
100
+ if "full_name" in columns or "anthropic_api_key" in columns:
101
+ return False
102
+
103
+ # Verify essential columns still exist
104
+ required_columns = {
105
+ "id", "username", "email", "hashed_password",
106
+ "is_active", "is_admin", "created_at", "updated_at", "last_login"
107
+ }
108
+ return required_columns.issubset(columns)
@@ -0,0 +1,606 @@
1
+ #!/usr/bin/env python3
2
+ """Database migration runner for WineBox.
3
+
4
+ This module provides the main migration runner that handles:
5
+ - Schema version tracking via a schema_version table
6
+ - Forward migrations (up)
7
+ - Reverse migrations (down)
8
+ - Status reporting and history
9
+
10
+ Usage:
11
+ uv run python -m scripts.migrations.runner status
12
+ uv run python -m scripts.migrations.runner up [--to VERSION]
13
+ uv run python -m scripts.migrations.runner down --to VERSION
14
+ uv run python -m scripts.migrations.runner history
15
+ """
16
+
17
+ import argparse
18
+ import importlib
19
+ import sqlite3
20
+ import sys
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+
26
+ # Default database path
27
+ DEFAULT_DB_PATH = "data/winebox.db"
28
+
29
+ # Schema version table DDL
30
+ SCHEMA_VERSION_DDL = """
31
+ CREATE TABLE IF NOT EXISTS schema_version (
32
+ version INTEGER PRIMARY KEY,
33
+ migrate_script TEXT NOT NULL,
34
+ revert_script TEXT NOT NULL,
35
+ description TEXT NOT NULL,
36
+ applied_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
37
+ )
38
+ """
39
+
40
+
41
+ def get_db_path(db_path: str | None = None) -> str:
42
+ """Get the database path, with fallback to default."""
43
+ return db_path or DEFAULT_DB_PATH
44
+
45
+
46
+ def get_connection(db_path: str) -> sqlite3.Connection:
47
+ """Get a database connection."""
48
+ return sqlite3.connect(db_path)
49
+
50
+
51
+ def ensure_schema_version_table(cursor: sqlite3.Cursor) -> None:
52
+ """Ensure the schema_version table exists."""
53
+ cursor.execute(SCHEMA_VERSION_DDL)
54
+
55
+
56
+ def get_current_version(cursor: sqlite3.Cursor) -> int:
57
+ """Get the current schema version from the database.
58
+
59
+ Returns 0 if no migrations have been applied.
60
+ """
61
+ # Check if schema_version table exists
62
+ cursor.execute(
63
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'"
64
+ )
65
+ if not cursor.fetchone():
66
+ return 0
67
+
68
+ # Get max version
69
+ cursor.execute("SELECT MAX(version) FROM schema_version")
70
+ result = cursor.fetchone()
71
+ return result[0] if result[0] is not None else 0
72
+
73
+
74
+ def get_table_columns(cursor: sqlite3.Cursor, table_name: str) -> set[str]:
75
+ """Get column names for a table."""
76
+ cursor.execute(f"PRAGMA table_info({table_name})")
77
+ return {row[1] for row in cursor.fetchall()}
78
+
79
+
80
+ def detect_current_state(cursor: sqlite3.Cursor) -> int:
81
+ """Detect the current database state based on schema.
82
+
83
+ This is used to bootstrap the schema_version table for existing databases.
84
+ Returns the detected version based on schema inspection.
85
+ """
86
+ # Check if users table exists
87
+ cursor.execute(
88
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
89
+ )
90
+ if not cursor.fetchone():
91
+ return 0 # No users table means version 0
92
+
93
+ # Check for columns added in version 1
94
+ columns = get_table_columns(cursor, "users")
95
+ if "full_name" in columns and "anthropic_api_key" in columns:
96
+ return 1 # Has v1 columns
97
+
98
+ return 0 # Original schema
99
+
100
+
101
+ def bootstrap_schema_version(cursor: sqlite3.Cursor) -> int:
102
+ """Bootstrap the schema_version table for an existing database.
103
+
104
+ Detects the current state and inserts appropriate version records.
105
+ Returns the detected version.
106
+ """
107
+ detected_version = detect_current_state(cursor)
108
+
109
+ if detected_version >= 1:
110
+ # Insert version 1 record (bootstrapped)
111
+ cursor.execute(
112
+ """
113
+ INSERT OR IGNORE INTO schema_version
114
+ (version, migrate_script, revert_script, description, applied_at)
115
+ VALUES (?, ?, ?, ?, ?)
116
+ """,
117
+ (
118
+ 1,
119
+ "db_migrate_0_to_1.py",
120
+ "db_revert_1_to_0.py",
121
+ "Add full_name and anthropic_api_key to users table (bootstrapped)",
122
+ datetime.now().isoformat(),
123
+ ),
124
+ )
125
+
126
+ return detected_version
127
+
128
+
129
+ def load_migration_module(script_name: str) -> Any:
130
+ """Load a migration module by name."""
131
+ module_name = f"scripts.migrations.{script_name.replace('.py', '')}"
132
+ return importlib.import_module(module_name)
133
+
134
+
135
+ def get_available_migrations() -> list[dict[str, Any]]:
136
+ """Get list of available migration scripts.
137
+
138
+ Returns a list of dicts with source_version, target_version, script_name, description.
139
+ """
140
+ migrations_dir = Path(__file__).parent
141
+ migrations = []
142
+
143
+ for script_file in migrations_dir.glob("db_migrate_*.py"):
144
+ try:
145
+ module = load_migration_module(script_file.name)
146
+ migrations.append({
147
+ "source_version": module.SOURCE_VERSION,
148
+ "target_version": module.TARGET_VERSION,
149
+ "script_name": script_file.name,
150
+ "description": module.DESCRIPTION,
151
+ "type": "migrate",
152
+ })
153
+ except (ImportError, AttributeError) as e:
154
+ print(f"Warning: Could not load migration {script_file.name}: {e}")
155
+
156
+ for script_file in migrations_dir.glob("db_revert_*.py"):
157
+ try:
158
+ module = load_migration_module(script_file.name)
159
+ migrations.append({
160
+ "source_version": module.SOURCE_VERSION,
161
+ "target_version": module.TARGET_VERSION,
162
+ "script_name": script_file.name,
163
+ "description": module.DESCRIPTION,
164
+ "type": "revert",
165
+ })
166
+ except (ImportError, AttributeError) as e:
167
+ print(f"Warning: Could not load revert script {script_file.name}: {e}")
168
+
169
+ return migrations
170
+
171
+
172
+ def find_migration_path(
173
+ current_version: int,
174
+ target_version: int,
175
+ migrations: list[dict[str, Any]],
176
+ ) -> list[dict[str, Any]]:
177
+ """Find the sequence of migrations to apply.
178
+
179
+ Returns list of migration dicts in order of application.
180
+ """
181
+ if current_version == target_version:
182
+ return []
183
+
184
+ going_up = target_version > current_version
185
+ migration_type = "migrate" if going_up else "revert"
186
+
187
+ # Filter to relevant migrations
188
+ relevant = [m for m in migrations if m["type"] == migration_type]
189
+
190
+ path = []
191
+ version = current_version
192
+
193
+ while version != target_version:
194
+ # Find migration from current version
195
+ next_migration = None
196
+ for m in relevant:
197
+ if m["source_version"] == version:
198
+ if going_up and m["target_version"] > version:
199
+ next_migration = m
200
+ break
201
+ elif not going_up and m["target_version"] < version:
202
+ next_migration = m
203
+ break
204
+
205
+ if next_migration is None:
206
+ raise ValueError(
207
+ f"No migration found from version {version} "
208
+ f"{'up' if going_up else 'down'} to {target_version}"
209
+ )
210
+
211
+ path.append(next_migration)
212
+ version = next_migration["target_version"]
213
+
214
+ return path
215
+
216
+
217
+ def get_latest_version(migrations: list[dict[str, Any]]) -> int:
218
+ """Get the latest available version from migrations."""
219
+ versions = set()
220
+ for m in migrations:
221
+ versions.add(m["source_version"])
222
+ versions.add(m["target_version"])
223
+ return max(versions) if versions else 0
224
+
225
+
226
+ def apply_migration(
227
+ cursor: sqlite3.Cursor,
228
+ migration: dict[str, Any],
229
+ dry_run: bool = False,
230
+ ) -> bool:
231
+ """Apply a single migration.
232
+
233
+ Returns True if successful, False otherwise.
234
+ """
235
+ script_name = migration["script_name"]
236
+ is_revert = migration["type"] == "revert"
237
+
238
+ print(f"Applying {script_name}...")
239
+ print(f" {migration['description']}")
240
+
241
+ if dry_run:
242
+ print(" [DRY RUN] Would apply migration")
243
+ return True
244
+
245
+ try:
246
+ module = load_migration_module(script_name)
247
+ module.migrate(cursor)
248
+
249
+ # Validate the migration
250
+ if hasattr(module, "validate"):
251
+ if not module.validate(cursor):
252
+ raise RuntimeError(f"Migration validation failed for {script_name}")
253
+
254
+ # Update schema_version table
255
+ if is_revert:
256
+ # Remove the version record we're reverting from
257
+ cursor.execute(
258
+ "DELETE FROM schema_version WHERE version = ?",
259
+ (migration["source_version"],),
260
+ )
261
+ else:
262
+ # Get revert script name
263
+ revert_script = f"db_revert_{migration['target_version']}_to_{migration['source_version']}.py"
264
+ cursor.execute(
265
+ """
266
+ INSERT INTO schema_version
267
+ (version, migrate_script, revert_script, description, applied_at)
268
+ VALUES (?, ?, ?, ?, ?)
269
+ """,
270
+ (
271
+ migration["target_version"],
272
+ script_name,
273
+ revert_script,
274
+ migration["description"],
275
+ datetime.now().isoformat(),
276
+ ),
277
+ )
278
+
279
+ print(" Done.")
280
+ return True
281
+
282
+ except Exception as e:
283
+ print(f" ERROR: {e}")
284
+ return False
285
+
286
+
287
+ def cmd_status(args: argparse.Namespace) -> int:
288
+ """Show current database status."""
289
+ db_path = get_db_path(args.database)
290
+
291
+ if not Path(db_path).exists():
292
+ print(f"Database not found at: {db_path}")
293
+ print("Current version: 0 (database will be created when app starts)")
294
+ return 0
295
+
296
+ conn = get_connection(db_path)
297
+ cursor = conn.cursor()
298
+
299
+ try:
300
+ ensure_schema_version_table(cursor)
301
+ current_version = get_current_version(cursor)
302
+
303
+ # If version is 0, check if we need to bootstrap
304
+ if current_version == 0:
305
+ detected = detect_current_state(cursor)
306
+ if detected > 0:
307
+ print(f"Database at: {db_path}")
308
+ print(f"Detected version: {detected} (schema_version table not initialized)")
309
+ print()
310
+ print("Run 'up' to initialize schema_version table and bring to latest version.")
311
+ conn.close()
312
+ return 0
313
+
314
+ migrations = get_available_migrations()
315
+ latest_version = get_latest_version(migrations)
316
+
317
+ print(f"Database at: {db_path}")
318
+ print(f"Current version: {current_version}")
319
+ print(f"Latest version: {latest_version}")
320
+
321
+ if current_version < latest_version:
322
+ print()
323
+ print("Available migrations:")
324
+ path = find_migration_path(current_version, latest_version, migrations)
325
+ for m in path:
326
+ print(f" {m['script_name']}: {m['description']}")
327
+ elif current_version == latest_version:
328
+ print()
329
+ print("Database is up to date.")
330
+
331
+ conn.close()
332
+ return 0
333
+
334
+ except Exception as e:
335
+ print(f"Error: {e}")
336
+ conn.close()
337
+ return 1
338
+
339
+
340
+ def cmd_up(args: argparse.Namespace) -> int:
341
+ """Migrate up to target version."""
342
+ db_path = get_db_path(args.database)
343
+
344
+ if not Path(db_path).exists():
345
+ print(f"Database not found at: {db_path}")
346
+ print("The database will be created when the app starts.")
347
+ print("Run the app first, then run migrations if needed.")
348
+ return 1
349
+
350
+ conn = get_connection(db_path)
351
+ cursor = conn.cursor()
352
+
353
+ try:
354
+ ensure_schema_version_table(cursor)
355
+ conn.commit()
356
+
357
+ current_version = get_current_version(cursor)
358
+
359
+ # Bootstrap if needed
360
+ if current_version == 0:
361
+ detected = detect_current_state(cursor)
362
+ if detected > 0:
363
+ print(f"Bootstrapping schema_version table at version {detected}...")
364
+ bootstrap_schema_version(cursor)
365
+ conn.commit()
366
+ current_version = detected
367
+
368
+ migrations = get_available_migrations()
369
+ latest_version = get_latest_version(migrations)
370
+ target_version = args.to if args.to is not None else latest_version
371
+
372
+ if target_version < current_version:
373
+ print(f"Target version {target_version} is less than current version {current_version}.")
374
+ print("Use 'down' command to revert.")
375
+ conn.close()
376
+ return 1
377
+
378
+ if current_version == target_version:
379
+ print(f"Already at version {current_version}. Nothing to do.")
380
+ conn.close()
381
+ return 0
382
+
383
+ print(f"Migrating from version {current_version} to {target_version}...")
384
+ print()
385
+
386
+ path = find_migration_path(current_version, target_version, migrations)
387
+
388
+ for migration in path:
389
+ if not apply_migration(cursor, migration, args.dry_run):
390
+ print()
391
+ print("Migration failed. Rolling back...")
392
+ conn.rollback()
393
+ conn.close()
394
+ return 1
395
+
396
+ if not args.dry_run:
397
+ conn.commit()
398
+
399
+ print()
400
+ print(f"Successfully migrated to version {target_version}.")
401
+ conn.close()
402
+ return 0
403
+
404
+ except Exception as e:
405
+ print(f"Error: {e}")
406
+ conn.rollback()
407
+ conn.close()
408
+ return 1
409
+
410
+
411
+ def cmd_down(args: argparse.Namespace) -> int:
412
+ """Revert down to target version."""
413
+ db_path = get_db_path(args.database)
414
+
415
+ if not Path(db_path).exists():
416
+ print(f"Database not found at: {db_path}")
417
+ return 1
418
+
419
+ if args.to is None:
420
+ print("Error: --to VERSION is required for down command")
421
+ return 1
422
+
423
+ conn = get_connection(db_path)
424
+ cursor = conn.cursor()
425
+
426
+ try:
427
+ ensure_schema_version_table(cursor)
428
+ current_version = get_current_version(cursor)
429
+
430
+ # Bootstrap if needed
431
+ if current_version == 0:
432
+ detected = detect_current_state(cursor)
433
+ if detected > 0:
434
+ print(f"Bootstrapping schema_version table at version {detected}...")
435
+ bootstrap_schema_version(cursor)
436
+ conn.commit()
437
+ current_version = detected
438
+
439
+ target_version = args.to
440
+
441
+ if target_version > current_version:
442
+ print(f"Target version {target_version} is greater than current version {current_version}.")
443
+ print("Use 'up' command to migrate forward.")
444
+ conn.close()
445
+ return 1
446
+
447
+ if target_version < 0:
448
+ print("Target version cannot be negative.")
449
+ conn.close()
450
+ return 1
451
+
452
+ if current_version == target_version:
453
+ print(f"Already at version {current_version}. Nothing to do.")
454
+ conn.close()
455
+ return 0
456
+
457
+ print(f"Reverting from version {current_version} to {target_version}...")
458
+ print("WARNING: This may result in data loss!")
459
+ print()
460
+
461
+ migrations = get_available_migrations()
462
+ path = find_migration_path(current_version, target_version, migrations)
463
+
464
+ for migration in path:
465
+ if not apply_migration(cursor, migration, args.dry_run):
466
+ print()
467
+ print("Revert failed. Rolling back...")
468
+ conn.rollback()
469
+ conn.close()
470
+ return 1
471
+
472
+ if not args.dry_run:
473
+ conn.commit()
474
+
475
+ print()
476
+ print(f"Successfully reverted to version {target_version}.")
477
+ conn.close()
478
+ return 0
479
+
480
+ except Exception as e:
481
+ print(f"Error: {e}")
482
+ conn.rollback()
483
+ conn.close()
484
+ return 1
485
+
486
+
487
+ def cmd_history(args: argparse.Namespace) -> int:
488
+ """Show migration history."""
489
+ db_path = get_db_path(args.database)
490
+
491
+ if not Path(db_path).exists():
492
+ print(f"Database not found at: {db_path}")
493
+ return 1
494
+
495
+ conn = get_connection(db_path)
496
+ cursor = conn.cursor()
497
+
498
+ try:
499
+ # Check if schema_version table exists
500
+ cursor.execute(
501
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'"
502
+ )
503
+ if not cursor.fetchone():
504
+ print("No migration history (schema_version table does not exist).")
505
+ conn.close()
506
+ return 0
507
+
508
+ cursor.execute(
509
+ """
510
+ SELECT version, migrate_script, description, applied_at
511
+ FROM schema_version
512
+ ORDER BY version
513
+ """
514
+ )
515
+ rows = cursor.fetchall()
516
+
517
+ if not rows:
518
+ print("No migrations have been applied.")
519
+ else:
520
+ print("Migration history:")
521
+ print()
522
+ print(f"{'Version':<10} {'Applied At':<25} {'Description'}")
523
+ print("-" * 80)
524
+ for row in rows:
525
+ version, script, description, applied_at = row
526
+ print(f"{version:<10} {applied_at:<25} {description}")
527
+
528
+ conn.close()
529
+ return 0
530
+
531
+ except Exception as e:
532
+ print(f"Error: {e}")
533
+ conn.close()
534
+ return 1
535
+
536
+
537
+ def main() -> int:
538
+ """Main entry point."""
539
+ parser = argparse.ArgumentParser(
540
+ description="Database migration runner for WineBox",
541
+ formatter_class=argparse.RawDescriptionHelpFormatter,
542
+ epilog="""
543
+ Examples:
544
+ %(prog)s status Show current version and available migrations
545
+ %(prog)s up Migrate to latest version
546
+ %(prog)s up --to 2 Migrate to specific version
547
+ %(prog)s down --to 0 Revert to specific version
548
+ %(prog)s history Show migration history
549
+ """,
550
+ )
551
+
552
+ parser.add_argument(
553
+ "-d", "--database",
554
+ help=f"Path to database file (default: {DEFAULT_DB_PATH})",
555
+ )
556
+
557
+ subparsers = parser.add_subparsers(dest="command", help="Command to run")
558
+
559
+ # status command
560
+ status_parser = subparsers.add_parser("status", help="Show current database status")
561
+ status_parser.set_defaults(func=cmd_status)
562
+
563
+ # up command
564
+ up_parser = subparsers.add_parser("up", help="Migrate up to target version")
565
+ up_parser.add_argument(
566
+ "--to",
567
+ type=int,
568
+ help="Target version (default: latest)",
569
+ )
570
+ up_parser.add_argument(
571
+ "--dry-run",
572
+ action="store_true",
573
+ help="Show what would be done without applying changes",
574
+ )
575
+ up_parser.set_defaults(func=cmd_up)
576
+
577
+ # down command
578
+ down_parser = subparsers.add_parser("down", help="Revert down to target version")
579
+ down_parser.add_argument(
580
+ "--to",
581
+ type=int,
582
+ required=True,
583
+ help="Target version to revert to",
584
+ )
585
+ down_parser.add_argument(
586
+ "--dry-run",
587
+ action="store_true",
588
+ help="Show what would be done without applying changes",
589
+ )
590
+ down_parser.set_defaults(func=cmd_down)
591
+
592
+ # history command
593
+ history_parser = subparsers.add_parser("history", help="Show migration history")
594
+ history_parser.set_defaults(func=cmd_history)
595
+
596
+ args = parser.parse_args()
597
+
598
+ if not args.command:
599
+ parser.print_help()
600
+ return 1
601
+
602
+ return args.func(args)
603
+
604
+
605
+ if __name__ == "__main__":
606
+ sys.exit(main())
@@ -1802,7 +1802,7 @@ wheels = [
1802
1802
 
1803
1803
  [[package]]
1804
1804
  name = "winebox"
1805
- version = "0.1.3"
1805
+ version = "0.1.4"
1806
1806
  source = { editable = "." }
1807
1807
  dependencies = [
1808
1808
  { name = "aiofiles" },
@@ -1,3 +1,3 @@
1
1
  """WineBox - Wine Cellar Management Application."""
2
2
 
3
- __version__ = "0.1.4"
3
+ __version__ = "0.1.5"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes