diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 360a3bde..c3354bd6 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -55,8 +55,8 @@ jobs: cp .env.example .env echo "FALKORDB_HOST=localhost" >> .env echo "FALKORDB_PORT=6379" >> .env - echo "FASTAPI_SECRET_KEY=test-secret-key-for-ci" >> .env - echo "FASTAPI_DEBUG=False" >> .env + echo "FLASK_SECRET_KEY=test-secret-key-for-ci" >> .env + echo "FLASK_DEBUG=False" >> .env - name: Wait for FalkorDB run: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fc2f2443..4997da6d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,7 +42,6 @@ jobs: pipenv sync --dev - name: Install frontend dependencies - if: "exists('app/package.json')" run: | node --version || true npm --version || true @@ -51,21 +50,16 @@ jobs: - name: Create test environment file run: | cp .env.example .env - echo "FASTAPI_SECRET_KEY=test-secret-key" >> .env - echo "FASTAPI_DEBUG=False" >> .env + echo "FASTAPI_SECRET_KEY=test-secret-key" >> .env + echo "FASTAPI_DEBUG=False" >> .env - name: Run unit tests run: | pipenv run pytest tests/ -k "not e2e" --verbose - - name: Run pylint + - name: Run lint run: | - pipenv run pylint "$(git ls-files '*.py')" || true - - - name: Run frontend lint - if: "exists('app/package.json')" - run: | - (cd app && npm run lint) + make lint e2e-tests: runs-on: ubuntu-latest @@ -109,8 +103,8 @@ jobs: cp .env.example .env echo "FALKORDB_HOST=localhost" >> .env echo "FALKORDB_PORT=6379" >> .env - echo "FASTAPI_SECRET_KEY=test-secret-key-for-ci" >> .env - echo "FASTAPI_DEBUG=False" >> .env + echo "FASTAPI_SECRET_KEY=test-secret-key-for-ci" >> .env + echo "FASTAPI_DEBUG=False" >> .env - name: Wait for FalkorDB run: | diff --git a/api/app_factory.py b/api/app_factory.py index 34ac5a74..cb53445d 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -43,7 +43,13 @@ async def dispatch(self, request: Request, call_next): def create_app(): """Create and configure the FastAPI application.""" - app = FastAPI(title="QueryWeaver", description="Text2SQL with Graph-Powered Schema Understanding") + app = FastAPI( + title="QueryWeaver", + description=( + "Text2SQL with " + "Graph-Powered Schema Understanding" + ), + ) # Get secret key for sessions secret_key = os.getenv("FASTAPI_SECRET_KEY") diff --git a/api/loaders/mysql_loader.py b/api/loaders/mysql_loader.py index 6d453860..24577d18 100644 --- a/api/loaders/mysql_loader.py +++ b/api/loaders/mysql_loader.py @@ -83,7 +83,10 @@ def _parse_mysql_url(connection_url: str) -> Dict[str, str]: if connection_url.startswith('mysql://'): url = connection_url[8:] else: - raise ValueError("Invalid MySQL URL format. Expected mysql://username:password@host:port/database") + raise ValueError( + "Invalid MySQL URL format. Expected " + "mysql://username:password@host:port/database" + ) # Parse components if '@' not in url: diff --git a/api/routes/auth.py b/api/routes/auth.py index e3dd420b..5ada36c3 100644 --- a/api/routes/auth.py +++ b/api/routes/auth.py @@ -34,7 +34,13 @@ def _get_provider_client(request: Request, provider: str): def _clear_auth_session(session: dict): """Remove only auth-related keys from session instead of clearing everything.""" - for key in ["user_info", "google_token", "github_token", "token_validated_at", "oauth_google_auth"]: + for key in [ + "user_info", + "google_token", + "github_token", + "token_validated_at", + "oauth_google_auth", + ]: session.pop(key, None) @auth_router.get("/chat", name="auth.chat", response_class=HTMLResponse) @@ -148,7 +154,8 @@ async def google_authorized(request: Request) -> RedirectResponse: @auth_router.get("/login/google/callback", response_class=RedirectResponse) async def google_callback_compat(request: Request) -> RedirectResponse: qs = f"?{request.url.query}" if request.url.query else "" - return RedirectResponse(url=f"/login/google/authorized{qs}", status_code=status.HTTP_307_TEMPORARY_REDIRECT) + redirect = f"/login/google/authorized{qs}" + return RedirectResponse(url=redirect, status_code=status.HTTP_307_TEMPORARY_REDIRECT) @auth_router.get("/login/github", name="github.login", response_class=RedirectResponse) @@ -221,7 +228,8 @@ async def github_authorized(request: Request) -> RedirectResponse: @auth_router.get("/login/github/callback", response_class=RedirectResponse) async def github_callback_compat(request: Request) -> RedirectResponse: qs = f"?{request.url.query}" if request.url.query else "" - return RedirectResponse(url=f"/login/github/authorized{qs}", status_code=status.HTTP_307_TEMPORARY_REDIRECT) + redirect = f"/login/github/authorized{qs}" + return RedirectResponse(url=redirect, status_code=status.HTTP_307_TEMPORARY_REDIRECT) @auth_router.get("/logout", response_class=RedirectResponse) diff --git a/api/routes/database.py b/api/routes/database.py index cd4bcac9..4254d45f 100644 --- a/api/routes/database.py +++ b/api/routes/database.py @@ -1,8 +1,7 @@ """Database connection routes for the text2sql API.""" import logging -from typing import Dict, Any -from fastapi import APIRouter, Request, HTTPException, status +from fastapi import APIRouter, Request, HTTPException from fastapi.responses import JSONResponse from pydantic import BaseModel @@ -14,6 +13,11 @@ class DatabaseConnectionRequest(BaseModel): + """Database connection request model. + + Args: + BaseModel (_type_): _description_ + """ url: str @@ -36,7 +40,7 @@ async def connect_database(request: Request, db_request: DatabaseConnectionReque try: success = False result = "" - + # Check for PostgreSQL URL if url.startswith("postgres://") or url.startswith("postgresql://"): try: @@ -44,8 +48,11 @@ async def connect_database(request: Request, db_request: DatabaseConnectionReque success, result = PostgresLoader.load(request.state.user_id, url) except (ValueError, ConnectionError) as e: logging.error("PostgreSQL connection error: %s", str(e)) - raise HTTPException(status_code=500, detail="Failed to connect to PostgreSQL database") - + raise HTTPException( + status_code=500, + detail="Failed to connect to PostgreSQL database", + ) + # Check for MySQL URL elif url.startswith("mysql://"): try: @@ -53,10 +60,18 @@ async def connect_database(request: Request, db_request: DatabaseConnectionReque success, result = MySQLLoader.load(request.state.user_id, url) except (ValueError, ConnectionError) as e: logging.error("MySQL connection error: %s", str(e)) - raise HTTPException(status_code=500, detail="Failed to connect to MySQL database") - + raise HTTPException( + status_code=500, detail="Failed to connect to MySQL database" + ) + else: - raise HTTPException(status_code=400, detail="Invalid database URL. Supported formats: postgresql:// or mysql://") + raise HTTPException( + status_code=400, + detail=( + "Invalid database URL. Supported formats: postgresql:// " + "or mysql://" + ), + ) if success: return JSONResponse(content={ @@ -67,7 +82,7 @@ async def connect_database(request: Request, db_request: DatabaseConnectionReque # Don't return detailed error messages to prevent information exposure logging.error("Database loader failed: %s", result) raise HTTPException(status_code=400, detail="Failed to load database schema") - + except (ValueError, TypeError) as e: logging.error("Unexpected error in database connection: %s", str(e)) raise HTTPException(status_code=500, detail="Internal server error") diff --git a/api/routes/graphs.py b/api/routes/graphs.py index 9a05787e..6fbab7fb 100644 --- a/api/routes/graphs.py +++ b/api/routes/graphs.py @@ -5,9 +5,8 @@ import time from concurrent.futures import ThreadPoolExecutor from concurrent.futures import TimeoutError as FuturesTimeoutError -from typing import Dict, Any -from fastapi import APIRouter, Request, HTTPException, status, UploadFile, File, Form +from fastapi import APIRouter, Request, HTTPException, UploadFile, File from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel @@ -28,16 +27,31 @@ class GraphData(BaseModel): + """Graph data model. + + Args: + BaseModel (_type_): _description_ + """ database: str class ChatRequest(BaseModel): + """Chat request model. + + Args: + BaseModel (_type_): _description_ + """ chat: list result: list = None instructions: str = None class ConfirmRequest(BaseModel): + """Confirmation request model. + + Args: + BaseModel (_type_): _description_ + """ sql_query: str confirmation: str = "" chat: list = [] @@ -328,7 +342,10 @@ async def generate(): logging.info("Finding relevant tables took %.2f seconds", find_elapsed) # Total time for the pre-analysis phase step1_elapsed = time.perf_counter() - step1_start - logging.info("Step 1 (relevancy + table finding) took %.2f seconds", step1_elapsed) + logging.info( + "Step 1 (relevancy + table finding) took %.2f seconds", + step1_elapsed, + ) except FuturesTimeoutError: yield json.dumps( { @@ -506,13 +523,21 @@ async def generate(): @graphs_router.post("/{graph_id}/confirm") @token_required -async def confirm_destructive_operation(request: Request, graph_id: str, confirm_data: ConfirmRequest): +async def confirm_destructive_operation( + request: Request, + graph_id: str, + confirm_data: ConfirmRequest, +): """ Handle user confirmation for destructive SQL operations """ graph_id = request.state.user_id + "_" + graph_id.strip() - - confirmation = confirm_data.confirmation.strip().upper() if hasattr(confirm_data, 'confirmation') else "" + + if hasattr(confirm_data, 'confirmation'): + confirmation = confirm_data.confirmation.strip().upper() + else: + confirmation = "" + sql_query = confirm_data.sql_query if hasattr(confirm_data, 'sql_query') else "" queries_history = confirm_data.chat if hasattr(confirm_data, 'chat') else [] diff --git a/app/.eslintrc.cjs b/app/.eslintrc.cjs deleted file mode 100644 index 486fa0f3..00000000 --- a/app/.eslintrc.cjs +++ /dev/null @@ -1,14 +0,0 @@ -module.exports = { - root: true, - parser: '@typescript-eslint/parser', - parserOptions: { - ecmaVersion: 2020, - sourceType: 'module' - }, - plugins: ['@typescript-eslint'], - extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], - rules: { - // customize as needed - '@typescript-eslint/no-explicit-any': 'off' - } -}; diff --git a/app/eslint.config.cjs b/app/eslint.config.cjs new file mode 100644 index 00000000..b054baed --- /dev/null +++ b/app/eslint.config.cjs @@ -0,0 +1,31 @@ +// ESLint v9 flat config equivalent for the project's TypeScript rules +module.exports = [ + { + ignores: ['**/node_modules/**', 'dist/**'], + }, + { + languageOptions: { + parser: require('@typescript-eslint/parser'), + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + }, + }, + }, + { + plugins: { + '@typescript-eslint': require('@typescript-eslint/eslint-plugin'), + }, + }, + { + rules: { + // Base JS recommended rules + 'no-unused-vars': 'warn', + // TypeScript rules + '@typescript-eslint/no-explicit-any': 'off', + }, + linterOptions: { + reportUnusedDisableDirectives: true, + }, + }, +]; diff --git a/app/ts/modules/chat.ts b/app/ts/modules/chat.ts index a9cd41b4..9c4157a9 100644 --- a/app/ts/modules/chat.ts +++ b/app/ts/modules/chat.ts @@ -73,7 +73,6 @@ async function processStreamingResponse(response: Response) { const decoder = new TextDecoder(); let buffer = ''; - // eslint-disable-next-line no-constant-condition while (true) { const { done, value } = await reader.read(); if (done) { @@ -81,7 +80,7 @@ async function processStreamingResponse(response: Response) { try { const step = JSON.parse(buffer); addMessage(step.message || JSON.stringify(step), false); - } catch (e) { + } catch { addMessage(buffer, false); } } @@ -101,7 +100,7 @@ async function processStreamingResponse(response: Response) { try { const step = JSON.parse(message); handleStreamMessage(step); - } catch (e) { + } catch { addMessage('Failed: ' + message, false); } } diff --git a/app/ts/modules/schema.ts b/app/ts/modules/schema.ts index bdbc0992..f5550db5 100644 --- a/app/ts/modules/schema.ts +++ b/app/ts/modules/schema.ts @@ -86,9 +86,9 @@ export function showGraph(data: any) { const L = 0.2126 * r + 0.7152 * g + 0.0722 * b; return L > 0.6 ? '#111' : '#ffffff'; } - } catch (e) { - // ignore - } + } catch { + // ignore + } return '#ffffff'; }; @@ -127,7 +127,7 @@ export function showGraph(data: any) { const L = 0.2126 * r + 0.7152 * g + 0.0722 * b; return L > 0.6 ? '#111' : '#ffffff'; } - } catch (e) { /* empty */ } + } catch { /* empty */ } return '#ffffff'; }; const edgeColor = (() => { @@ -139,7 +139,7 @@ export function showGraph(data: any) { Graph.linkColor(() => edgeColor) .linkDirectionalArrowLength(6).linkDirectionalArrowRelPos(1); - } catch (e) { + } catch { Graph.linkDirectionalArrowLength(6).linkDirectionalArrowRelPos(1); } } diff --git a/tests/e2e/test_basic_functionality.py b/tests/e2e/test_basic_functionality.py index 40efadd7..371535d0 100644 --- a/tests/e2e/test_basic_functionality.py +++ b/tests/e2e/test_basic_functionality.py @@ -90,5 +90,7 @@ def test_error_handling(self, page_with_base_url): # Should handle 404 gracefully # Could be 404 page or redirect to home - response_status = page.evaluate("() => window.fetch('/nonexistent-route').then(r => r.status)") - assert response_status in [404, 302, 200] # Various valid responses + response_status = page.evaluate( + "() => window.fetch('/nonexistent-route').then(r => r.status)" + ) + assert response_status in [404, 302, 200] diff --git a/tests/e2e/test_chat_functionality.py b/tests/e2e/test_chat_functionality.py index d9d3001b..d9702c65 100644 --- a/tests/e2e/test_chat_functionality.py +++ b/tests/e2e/test_chat_functionality.py @@ -86,7 +86,10 @@ def test_input_validation(self, page_with_base_url): long_text = "a" * 1000 # Try to find any visible and enabled text input - enabled_inputs = page.locator("input[type='text']:not([disabled]):visible, textarea:not([disabled]):visible").all() + enabled_inputs = page.locator( + "input[type='text']:not([disabled]):visible, " + "textarea:not([disabled]):visible", + ).all() if enabled_inputs: # Get the first enabled input element