From be205f3dcc50dec8bca0633ea1828de5b6b0cde5 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sun, 8 Feb 2026 05:27:03 -0800 Subject: [PATCH] feat: add pre-commit hooks for local CI checks - Black (auto-fix formatting on commit) - Flake8 (block on syntax errors and undefined names) - Bandit (security scan, non-blocking to match CI) - Tests (run on pre-push only) - Apply Black formatting fixes caught by the new hooks --- .pre-commit-config.yaml | 28 +++++++++++++++++ database/models.py | 66 +++++++++++++++++++++++++++-------------- database/operations.py | 30 ++++++++++++------- py/api/images.py | 4 ++- requirements-dev.txt | 3 ++ utils/diagnostics.py | 18 +++++++---- 6 files changed, 110 insertions(+), 39 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..62888fa --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +repos: + - repo: https://github.com/psf/black + rev: 25.1.0 + hooks: + - id: black + args: [--line-length=88, --target-version=py310] + + - repo: local + hooks: + - id: flake8 + name: flake8 + entry: flake8 --select=E9,F63,F7,F82 --show-source --exclude=reference/,web/ + language: system + types: [python] + + - id: bandit + name: bandit + entry: bash -c 'bandit "$@" -ll -q 2>&1; true' + language: system + types: [python] + exclude: ^(tests/|reference/) + + - id: tests + name: tests + entry: python -m unittest discover tests/ -v + language: system + pass_filenames: false + stages: [pre-push] diff --git a/database/models.py b/database/models.py index 0a028d6..e026029 100644 --- a/database/models.py +++ b/database/models.py @@ -68,7 +68,8 @@ class PromptModel: - prompts table: Stores prompt text and metadata - generated_images table: Links generated images to their source prompts """ - conn.execute(""" + conn.execute( + """ CREATE TABLE IF NOT EXISTS prompts ( id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, @@ -80,10 +81,12 @@ class PromptModel: notes TEXT, hash TEXT UNIQUE ) - """) + """ + ) # Create images table for gallery functionality - conn.execute(""" + conn.execute( + """ CREATE TABLE IF NOT EXISTS generated_images ( id INTEGER PRIMARY KEY AUTOINCREMENT, prompt_id INTEGER NOT NULL, @@ -100,16 +103,20 @@ class PromptModel: FOREIGN KEY (prompt_id) REFERENCES prompts(id) ON DELETE CASCADE, UNIQUE(prompt_id, filename) ) - """) + """ + ) # Create normalized tag tables (junction table pattern) - conn.execute(""" + conn.execute( + """ CREATE TABLE IF NOT EXISTS tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE ) - """) - conn.execute(""" + """ + ) + conn.execute( + """ CREATE TABLE IF NOT EXISTS prompt_tags ( prompt_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, @@ -117,7 +124,8 @@ class PromptModel: FOREIGN KEY (prompt_id) REFERENCES prompts(id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE ) - """) + """ + ) # Add unique constraint to existing databases (migration) self._migrate_add_unique_constraint(conn) @@ -200,7 +208,8 @@ class PromptModel: self.logger.info("Migrating database: removing workflow_name column") # Create new table without workflow_name - conn.execute(""" + conn.execute( + """ CREATE TABLE prompts_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, @@ -212,14 +221,17 @@ class PromptModel: notes TEXT, hash TEXT UNIQUE ) - """) + """ + ) # Copy data from old table to new table - conn.execute(""" + conn.execute( + """ INSERT INTO prompts_new (id, text, created_at, updated_at, category, tags, rating, notes, hash) SELECT id, text, created_at, updated_at, category, tags, rating, notes, hash FROM prompts - """) + """ + ) # Drop old table and rename new one conn.execute("DROP TABLE prompts") @@ -252,7 +264,8 @@ class PromptModel: ) # Create new table with correct types - conn.execute(""" + conn.execute( + """ CREATE TABLE generated_images_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, prompt_id INTEGER NOT NULL, @@ -268,10 +281,12 @@ class PromptModel: parameters TEXT, FOREIGN KEY (prompt_id) REFERENCES prompts(id) ON DELETE CASCADE ) - """) + """ + ) # Copy data, converting prompt_id from TEXT to INTEGER - conn.execute(""" + conn.execute( + """ INSERT INTO generated_images_new (id, prompt_id, image_path, filename, generation_time, file_size, width, height, format, workflow_data, prompt_metadata, parameters) @@ -280,7 +295,8 @@ class PromptModel: FROM generated_images WHERE prompt_id != '' AND prompt_id IS NOT NULL AND CAST(prompt_id AS INTEGER) IN (SELECT id FROM prompts) - """) + """ + ) # Drop old table and rename new one conn.execute("DROP TABLE generated_images") @@ -329,13 +345,15 @@ class PromptModel: ) # First, remove duplicates keeping only the most recent (highest id) - conn.execute(""" + conn.execute( + """ DELETE FROM generated_images WHERE id NOT IN ( SELECT MAX(id) FROM generated_images GROUP BY prompt_id, filename ) - """) + """ + ) duplicates_removed = conn.total_changes if duplicates_removed > 0: @@ -344,7 +362,8 @@ class PromptModel: ) # Create new table with UNIQUE constraint - conn.execute(""" + conn.execute( + """ CREATE TABLE IF NOT EXISTS generated_images_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, prompt_id INTEGER NOT NULL, @@ -361,17 +380,20 @@ class PromptModel: FOREIGN KEY (prompt_id) REFERENCES prompts(id) ON DELETE CASCADE, UNIQUE(prompt_id, filename) ) - """) + """ + ) # Copy data - conn.execute(""" + conn.execute( + """ INSERT INTO generated_images_new (id, prompt_id, image_path, filename, generation_time, file_size, width, height, format, workflow_data, prompt_metadata, parameters) SELECT id, prompt_id, image_path, filename, generation_time, file_size, width, height, format, workflow_data, prompt_metadata, parameters FROM generated_images - """) + """ + ) # Drop old table and rename new one conn.execute("DROP TABLE generated_images") diff --git a/database/operations.py b/database/operations.py index 2e04d9a..079a305 100644 --- a/database/operations.py +++ b/database/operations.py @@ -946,14 +946,16 @@ class PromptDatabase: # Find duplicates by text content (case-insensitive) # Note: Removed ORDER BY from GROUP_CONCAT for SQLite compatibility # We'll sort the IDs manually after fetching - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT LOWER(TRIM(text)) as normalized_text, COUNT(*) as count, GROUP_CONCAT(id) as ids, GROUP_CONCAT(created_at) as created_dates FROM prompts GROUP BY LOWER(TRIM(text)) HAVING COUNT(*) > 1 - """) + """ + ) duplicate_groups = cursor.fetchall() self.logger.debug( @@ -1018,14 +1020,16 @@ class PromptDatabase: # Find duplicates by text content (case-insensitive) # Note: Removed ORDER BY from GROUP_CONCAT for SQLite compatibility # We'll sort the IDs manually after fetching - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT LOWER(TRIM(text)) as normalized_text, COUNT(*) as count, GROUP_CONCAT(id) as ids, GROUP_CONCAT(created_at) as created_dates FROM prompts GROUP BY LOWER(TRIM(text)) HAVING COUNT(*) > 1 - """) + """ + ) duplicates = cursor.fetchall() self.logger.debug( @@ -1745,7 +1749,8 @@ class PromptDatabase: Returns number of prompts removed. """ with self.model.get_connection() as conn: - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT p.id FROM prompts p LEFT JOIN generated_images gi ON p.id = gi.prompt_id WHERE gi.prompt_id IS NULL @@ -1754,7 +1759,8 @@ class PromptDatabase: JOIN tags t ON pt.tag_id = t.id WHERE pt.prompt_id = p.id AND t.name = '__protected__' ) - """) + """ + ) orphaned = [row["id"] for row in cursor.fetchall()] if not orphaned: return 0 @@ -1770,22 +1776,26 @@ class PromptDatabase: issues: List[str] = [] with self.model.get_connection() as conn: # Check for orphaned prompt_tags entries - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT pt.prompt_id, pt.tag_id FROM prompt_tags pt LEFT JOIN prompts p ON pt.prompt_id = p.id WHERE p.id IS NULL - """) + """ + ) for ref in cursor.fetchall(): issues.append( f"prompt_tags entry references non-existent prompt {ref['prompt_id']}" ) # Check for orphaned image entries - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT gi.id, gi.prompt_id FROM generated_images gi LEFT JOIN prompts p ON gi.prompt_id = p.id WHERE p.id IS NULL - """) + """ + ) for ref in cursor.fetchall(): issues.append( f"Image {ref['id']} references non-existent prompt {ref['prompt_id']}" diff --git a/py/api/images.py b/py/api/images.py index 64a2acf..53bdc89 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -780,7 +780,9 @@ class ImageRoutesMixin: self.logger.warning( f"Failed to generate thumbnail for {media_file.name}: {e}" ) - error_msg = f"Failed to generate thumbnail for {media_file.name}" + error_msg = ( + f"Failed to generate thumbnail for {media_file.name}" + ) errors.append(error_msg) if len(errors) <= 5: diff --git a/requirements-dev.txt b/requirements-dev.txt index c045ae3..f2539a6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -9,3 +9,6 @@ flake8>=7.0.0 # Security scanning bandit[toml]>=1.7.0 + +# Pre-commit hooks +pre-commit>=4.0.0 diff --git a/utils/diagnostics.py b/utils/diagnostics.py index 60bb7c6..a611d2f 100644 --- a/utils/diagnostics.py +++ b/utils/diagnostics.py @@ -137,10 +137,12 @@ class GalleryDiagnostics: self.logger.info(f" [NOTE] Prompts in database: {prompt_count}") # Check if generated_images table exists - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT name FROM sqlite_master WHERE type='table' AND name='generated_images' - """) + """ + ) has_images_table = cursor.fetchone() is not None self.logger.info(f" [IMG] Images table exists: {has_images_table}") @@ -174,10 +176,12 @@ class GalleryDiagnostics: conn.row_factory = sqlite3.Row # Check if table exists - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT name FROM sqlite_master WHERE type='table' AND name='generated_images' - """) + """ + ) if not cursor.fetchone(): return { @@ -191,13 +195,15 @@ class GalleryDiagnostics: self.logger.info(f" [STATS] Images in database: {image_count}") # Get recent images - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT gi.*, p.text FROM generated_images gi LEFT JOIN prompts p ON gi.prompt_id = p.id ORDER BY gi.generation_time DESC LIMIT 5 - """) + """ + ) recent_images = [dict(row) for row in cursor.fetchall()] self.logger.info(f" [TIME] Recent images: {len(recent_images)}")