diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 173fd378..5a59f4dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,12 @@ jobs: - name: Install frontend dependencies run: cd frontend && npm ci + - name: Lint Python + run: ruff check kernelboard/ tests/ + + - name: Lint frontend + run: cd frontend && npm run lint + - name: Run Python tests run: pytest --tb=short continue-on-error: true diff --git a/CLAUDE.md b/CLAUDE.md index 05d82a79..8a8496ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,42 @@ Look for the `hackathons` array around line 87. To add a hackathon: Upcoming lectures are pulled live from Discord's scheduled events API (5-minute cache). Requires `DISCORD_BOT_TOKEN` and `DISCORD_GUILD_ID` environment variables. +## Database Access + +The production PostgreSQL database is hosted on Heroku under the `discord-cluster-manager` app: +- **Heroku Dashboard:** https://dashboard.heroku.com/apps/discord-cluster-manager +- **Connection:** Use `heroku pg:psql -a discord-cluster-manager` to connect interactively +- **Credentials:** Use `heroku pg:credentials:url -a discord-cluster-manager` to get the connection string +- **Schema:** All tables live under the `leaderboard` schema (e.g., `leaderboard.runs`, `leaderboard.submission`, `leaderboard.leaderboard`, `leaderboard.user_info`, `leaderboard.code_files`, `leaderboard.gpu_type`, `leaderboard.submission_job_status`) + +### Key Tables +- `leaderboard.leaderboard` - Competition definitions (name, deadline, task JSONB) +- `leaderboard.submission` - User submissions linked to code files +- `leaderboard.runs` - Individual run results with scores (lower is better), GPU type, pass/fail +- `leaderboard.user_info` - User accounts (Discord/Google/GitHub OAuth) +- `leaderboard.gpu_type` - GPU types supported per leaderboard +- `leaderboard.code_files` - Submitted code with SHA256 hash +- `leaderboard.submission_job_status` - Job tracking (pending/running/succeeded/failed/timed_out) + +### Ranking Logic +Rankings are computed via SQL window functions: +1. Best run per user per GPU type (lowest score wins, must be `passed=true`, `secret=false`, `score IS NOT NULL`) +2. Global rank via `RANK() OVER (PARTITION BY leaderboard_id, runner ORDER BY score ASC)` +3. GPU priority order: B200 > H100 > MI300 > A100 > L4 > T4 + +## Linting + +CI enforces linting for both Python and the frontend. PRs will fail if either linter reports errors. + +**Python (Ruff):** +- Check: `ruff check kernelboard/ tests/` +- Auto-fix: `ruff check --fix kernelboard/ tests/` +- Config: `pyproject.toml` under `[tool.ruff]` + +**Frontend (ESLint):** +- Check: `cd frontend && npm run lint` +- Auto-fix: `cd frontend && npm run lint -- --fix` + ## Project Structure - `kernelboard/` - Flask backend diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 8ce6333d..2892257e 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -23,6 +23,12 @@ export default tseslint.config( 'warn', { allowConstantExport: true }, ], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': ['error', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }], 'eol-last': ['error', 'always'] }, }, diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 9209ff3e..56edc5f4 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,5 +1,5 @@ import { describe, it } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import App from "./App"; diff --git a/frontend/src/components/app-layout/NavUserProfile.tsx b/frontend/src/components/app-layout/NavUserProfile.tsx index a865c34e..88179772 100644 --- a/frontend/src/components/app-layout/NavUserProfile.tsx +++ b/frontend/src/components/app-layout/NavUserProfile.tsx @@ -1,6 +1,5 @@ import { useState } from "react"; import { - Alert, Avatar, Button, Divider, diff --git a/frontend/src/components/common/EllipsisWithTooltip.tsx b/frontend/src/components/common/EllipsisWithTooltip.tsx index 964b5dae..0ed5c209 100644 --- a/frontend/src/components/common/EllipsisWithTooltip.tsx +++ b/frontend/src/components/common/EllipsisWithTooltip.tsx @@ -1,8 +1,6 @@ import { Tooltip, Typography, - type SxProps, - type Theme, type TypographyVariant, } from "@mui/material"; import React, { useEffect, useRef, useState } from "react"; diff --git a/frontend/src/components/markdown-renderer/MarkdownRenderer.tsx b/frontend/src/components/markdown-renderer/MarkdownRenderer.tsx index 057be1bf..d0b8d35a 100644 --- a/frontend/src/components/markdown-renderer/MarkdownRenderer.tsx +++ b/frontend/src/components/markdown-renderer/MarkdownRenderer.tsx @@ -54,7 +54,7 @@ const MarkdownRenderer: React.FC = ({ remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeRaw, rehypeKatex]} components={{ - a: ({ node, ...props }) => ( + a: ({ node: _node, ...props }) => ( = ({ {...props} /> ), - figure: ({ node, ...props }) => ( + figure: ({ node: _node, ...props }) => (
), - figcaption: ({ node, ...props }) => ( + figcaption: ({ node: _node, ...props }) => (
= ({ {...props} /> ), - img: ({ node, ...props }) => { + img: ({ node: _node, ...props }) => { return (
{props.alt} diff --git a/frontend/src/lib/hooks/useApi.ts b/frontend/src/lib/hooks/useApi.ts index 6a767eaf..d2727e3c 100644 --- a/frontend/src/lib/hooks/useApi.ts +++ b/frontend/src/lib/hooks/useApi.ts @@ -53,13 +53,13 @@ export function fetcherApiCallback( fetcher: Fetcher, redirectMap: Record = defaultRedirectMap, ) { - const navigate = useNavigate(); - const [data, setData] = useState(null); - const [error, setError] = useState(null); - const [errorStatus, setErrorStatus] = useState(null); - const [loading, setLoading] = useState(true); + const navigate = useNavigate(); // eslint-disable-line react-hooks/rules-of-hooks + const [data, setData] = useState(null); // eslint-disable-line react-hooks/rules-of-hooks + const [error, setError] = useState(null); // eslint-disable-line react-hooks/rules-of-hooks + const [errorStatus, setErrorStatus] = useState(null); // eslint-disable-line react-hooks/rules-of-hooks + const [loading, setLoading] = useState(true); // eslint-disable-line react-hooks/rules-of-hooks - const call = useCallback( + const call = useCallback( // eslint-disable-line react-hooks/rules-of-hooks async (...params: Args) => { setLoading(true); @@ -72,8 +72,8 @@ export function fetcherApiCallback( setData(result); return result; } catch (e: any) { - let status = e.status ? e.status : 0; - let msg = e.message ? e.message : ""; + const status = e.status ? e.status : 0; + const msg = e.message ? e.message : ""; // set and logging the error if any setError(status); diff --git a/frontend/src/lib/store/authStore.ts b/frontend/src/lib/store/authStore.ts index ac636aca..afaa4d20 100644 --- a/frontend/src/lib/store/authStore.ts +++ b/frontend/src/lib/store/authStore.ts @@ -27,14 +27,14 @@ export const useAuthStore = create((set, get) => ({ ); try { const res = await getMe(); - set((s) => ({ + set((_s) => ({ me: res, loading: false, inFlight: false, error: null, })); } catch (e: any) { - set((s) => ({ + set((_s) => ({ error: e?.message ?? "Failed to fetch user", loading: false, inFlight: false, diff --git a/frontend/src/pages/home/Home.test.tsx b/frontend/src/pages/home/Home.test.tsx index 38f8f2f2..0b0a44e0 100644 --- a/frontend/src/pages/home/Home.test.tsx +++ b/frontend/src/pages/home/Home.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, within } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { BrowserRouter } from "react-router-dom"; import { ThemeProvider } from "@mui/material"; import { appTheme } from "../../components/common/styles/theme"; diff --git a/frontend/src/pages/leaderboard/Leaderboard.test.tsx b/frontend/src/pages/leaderboard/Leaderboard.test.tsx index cdfdb19a..a02d6f4a 100644 --- a/frontend/src/pages/leaderboard/Leaderboard.test.tsx +++ b/frontend/src/pages/leaderboard/Leaderboard.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, within } from "@testing-library/react"; +import { screen, fireEvent, within } from "@testing-library/react"; import { vi, expect, it, describe, beforeEach } from "vitest"; import Leaderboard from "./Leaderboard"; import * as apiHook from "../../lib/hooks/useApi"; diff --git a/frontend/src/pages/leaderboard/Leaderboard.tsx b/frontend/src/pages/leaderboard/Leaderboard.tsx index 5b2b7367..1144b285 100644 --- a/frontend/src/pages/leaderboard/Leaderboard.tsx +++ b/frontend/src/pages/leaderboard/Leaderboard.tsx @@ -269,7 +269,7 @@ export default function Leaderboard() { > Submission @@ -313,13 +313,13 @@ export default function Leaderboard() { AI Model Performance Trend - + User Performance Trend - + diff --git a/frontend/src/pages/leaderboard/components/RankingLists.tsx b/frontend/src/pages/leaderboard/components/RankingLists.tsx index e9fe3b35..9fc534c4 100644 --- a/frontend/src/pages/leaderboard/components/RankingLists.tsx +++ b/frontend/src/pages/leaderboard/components/RankingLists.tsx @@ -85,7 +85,7 @@ export default function RankingsList({ const me = useAuthStore((s) => s.me); const isAdmin = !!me?.user?.is_admin; const [expanded, setExpanded] = useState>({}); - const [colorHash, _] = useState( + const [colorHash] = useState( Math.random().toString(36).slice(2, 8), ); const [codes, setCodes] = useState>(new Map()); @@ -93,7 +93,7 @@ export default function RankingsList({ const submissionIds = useMemo(() => { if (!rankings) return []; const ids: number[] = []; - Object.entries(rankings).forEach(([key, value]) => { + Object.entries(rankings).forEach(([_key, value]) => { const li = value as any[]; if (Array.isArray(li) && li.length > 0) { li.forEach((item) => { diff --git a/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx b/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx index da01ee70..cc07fad7 100644 --- a/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx +++ b/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx @@ -90,7 +90,7 @@ const styles = { export default function SubmissionHistorySection({ leaderboardId, - leaderboardName, + leaderboardName: _leaderboardName, userId, pageSize = 10, refreshFlag, @@ -128,10 +128,10 @@ export default function SubmissionHistorySection({ setLastRefresh(new Date()); }, [leaderboardId, userId, page, pageSize, call]); - let totalPages = + const totalPages = data?.limit && data?.total ? Math.ceil(data?.total / data?.limit) : 1; - let items: Submission[] = data?.items ?? []; - let total: number = data?.total ?? 0; + const items: Submission[] = data?.items ?? []; + const total: number = data?.total ?? 0; const tooOld = lastRefresh && now - lastRefresh.getTime() > 10 * 60 * 1000; diff --git a/frontend/src/pages/news/News.test.tsx b/frontend/src/pages/news/News.test.tsx index b616338a..14f8f822 100644 --- a/frontend/src/pages/news/News.test.tsx +++ b/frontend/src/pages/news/News.test.tsx @@ -8,6 +8,7 @@ import { import { vi, describe, it, expect, beforeEach } from "vitest"; import News from "./News"; // 假设你当前文件路径为 pages/News.tsx import * as apiHook from "../../lib/hooks/useApi"; +import { useParams, useNavigate } from "react-router-dom"; // 统一 mock useApi hook vi.mock("../../lib/hooks/useApi", () => ({ @@ -52,7 +53,6 @@ const mockData = [ describe("News", () => { beforeEach(() => { vi.clearAllMocks(); - const { useParams, useNavigate } = require("react-router-dom"); (useParams as ReturnType).mockReturnValue({}); (useNavigate as ReturnType).mockReturnValue(mockNavigate); }); @@ -181,7 +181,6 @@ describe("News", () => { it("scrolls to section when slug is provided in URL", async () => { // prepare const scrollIntoViewMock = vi.fn(); - const { useParams } = require("react-router-dom"); (useParams as ReturnType).mockReturnValue({ slug: "news-2" }); const mockHookReturn = { diff --git a/kernelboard/__init__.py b/kernelboard/__init__.py index 7fa3921d..e4158156 100644 --- a/kernelboard/__init__.py +++ b/kernelboard/__init__.py @@ -1,22 +1,26 @@ import http import os -from re import L + from dotenv import load_dotenv -from flask import Flask, jsonify, redirect, session, g -from flask_login import LoginManager, current_user +from flask import Flask, make_response, redirect, send_from_directory +from flask_login import LoginManager from flask_session import Session from flask_talisman import Talisman -from kernelboard.api.auth import User, providers -from kernelboard.lib import db, env, time, score -from kernelboard import color, error, health, index, leaderboard, news + +from kernelboard import color, health +from kernelboard import error as error +from kernelboard import index as index +from kernelboard import leaderboard as leaderboard +from kernelboard import news as news from kernelboard.api import create_api_blueprint -from kernelboard.lib.redis_connection import create_redis_connection -from flask import send_from_directory, make_response +from kernelboard.api.auth import User, providers +from kernelboard.lib import db, env, score, time from kernelboard.lib.logging import configure_logging -from kernelboard.og_tags import is_social_crawler, get_og_tags_for_path, inject_og_tags -from flask_limiter import Limiter from kernelboard.lib.rate_limiter import limiter +from kernelboard.lib.redis_connection import create_redis_connection from kernelboard.lib.status_code import http_error +from kernelboard.og_tags import get_og_tags_for_path, inject_og_tags, is_social_crawler + def create_app(test_config=None): # Check if we're in development mode: @@ -123,7 +127,7 @@ def redirect_v2(path=""): return redirect(f"/{path}", code=301) @app.errorhandler(401) - def unauthorized(_error): + def handle_401(_error): return redirect("/401") @app.errorhandler(404) diff --git a/kernelboard/api/__init__.py b/kernelboard/api/__init__.py index 5dcb736a..0acf139e 100644 --- a/kernelboard/api/__init__.py +++ b/kernelboard/api/__init__.py @@ -1,15 +1,13 @@ -from requests import auth from flask import Blueprint from werkzeug.exceptions import HTTPException -from kernelboard.api.submission import submission -from kernelboard.lib.status_code import http_error, http_success + +from kernelboard.api.auth import auth_bp +from kernelboard.api.events import events_bp from kernelboard.api.leaderboard import leaderboard_bp from kernelboard.api.leaderboard_summaries import leaderboard_summaries_bp from kernelboard.api.news import news_bp -from kernelboard.api.auth import auth_bp from kernelboard.api.submission import submission_bp -from kernelboard.api.events import events_bp - +from kernelboard.lib.status_code import http_error, http_success def create_api_blueprint(): diff --git a/kernelboard/api/auth.py b/kernelboard/api/auth.py index 4a46893c..12e3c0d2 100644 --- a/kernelboard/api/auth.py +++ b/kernelboard/api/auth.py @@ -7,15 +7,17 @@ import requests from flask import ( Blueprint, - current_app as app, redirect, request, session, url_for, ) +from flask import ( + current_app as app, +) from flask_login import UserMixin, current_user, login_user, logout_user -from kernelboard.lib.auth_utils import ensure_user_info_with_token, get_user_info_from_session +from kernelboard.lib.auth_utils import ensure_user_info_with_token, get_user_info_from_session from kernelboard.lib.status_code import http_success auth_bp = Blueprint("auth", __name__) diff --git a/kernelboard/api/events.py b/kernelboard/api/events.py index 5a053acd..02f63a2a 100644 --- a/kernelboard/api/events.py +++ b/kernelboard/api/events.py @@ -1,11 +1,12 @@ -from http import HTTPStatus +import logging import os import time +from http import HTTPStatus + import requests from flask import Blueprint -from kernelboard.lib.status_code import http_error, http_success -import logging +from kernelboard.lib.status_code import http_error, http_success logger = logging.getLogger(__name__) diff --git a/kernelboard/api/leaderboard.py b/kernelboard/api/leaderboard.py index 80cf3c31..8ff0b0d3 100644 --- a/kernelboard/api/leaderboard.py +++ b/kernelboard/api/leaderboard.py @@ -1,12 +1,13 @@ -import re +import logging +import time +from http import HTTPStatus from typing import Any, List + from flask import Blueprint + from kernelboard.lib.db import get_db_connection -from kernelboard.lib.time import to_time_left from kernelboard.lib.status_code import http_error, http_success -from http import HTTPStatus -import time -import logging +from kernelboard.lib.time import to_time_left logger = logging.getLogger(__name__) diff --git a/kernelboard/api/leaderboard_summaries.py b/kernelboard/api/leaderboard_summaries.py index 4d0aef1f..65b3ad4b 100644 --- a/kernelboard/api/leaderboard_summaries.py +++ b/kernelboard/api/leaderboard_summaries.py @@ -1,9 +1,11 @@ -from flask import Blueprint, request +import logging +import time from datetime import datetime, timezone + +from flask import Blueprint, request + from kernelboard.lib.db import get_db_connection from kernelboard.lib.status_code import http_success -import time -import logging logger = logging.getLogger(__name__) diff --git a/kernelboard/api/news.py b/kernelboard/api/news.py index e65b9ce2..19367076 100644 --- a/kernelboard/api/news.py +++ b/kernelboard/api/news.py @@ -1,11 +1,12 @@ -from http import HTTPStatus +import logging import os +from datetime import datetime +from http import HTTPStatus + import yaml from flask import Blueprint, current_app -from kernelboard.lib.status_code import HttpError, http_error, http_success -from datetime import datetime -import logging +from kernelboard.lib.status_code import HttpError, http_error, http_success # logger for blueprint news_bp logger = logging.getLogger(__name__) diff --git a/kernelboard/api/submission.py b/kernelboard/api/submission.py index 8b758619..3e2520b5 100644 --- a/kernelboard/api/submission.py +++ b/kernelboard/api/submission.py @@ -1,8 +1,16 @@ +import base64 import http +import json +import logging +import os +import textwrap +import time from typing import Any, List, Optional, Tuple -from flask import Blueprint, request, jsonify -from flask_login import login_required, current_user + import requests +from flask import Blueprint, jsonify, request +from flask_login import current_user, login_required + from kernelboard.lib.auth_utils import ( get_id_and_username_from_session, get_whitelist, @@ -10,18 +18,8 @@ from kernelboard.lib.db import get_db_connection from kernelboard.lib.error import ValidationError, validate_required_fields from kernelboard.lib.file_handler import get_submission_file_info -from kernelboard.lib.status_code import http_error, http_success -import logging -import os from kernelboard.lib.rate_limiter import limiter -import time -from typing import Any, List, Tuple -import json -from typing import Any, Tuple, List -import json -import base64 -import textwrap - +from kernelboard.lib.status_code import http_error, http_success logger = logging.getLogger(__name__) @@ -58,7 +56,10 @@ def submission(): if not web_token: logger.error("user %s missing web token", user_id) return http_error( - message="cannot find user info from db for user. if this is a bug, please contact the gpumode administrator", + message=( + "cannot find user info from db for user." + " if this is a bug, please contact the gpumode administrator" + ), status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR, ) req = request.form.to_dict() @@ -125,7 +126,7 @@ def submission(): @submission_bp.route("/codes", methods=["POST"]) -def list_codes(): +def list_codes_route(): """ POST /codes Body example: @@ -175,7 +176,8 @@ def list_codes(): data={"results": results}, ) else: - # otherwise, check if user able to see the leaderboard codes (only admin can see the leaderboard codes if leaderboard is not ended) + # otherwise, check if user able to see the leaderboard codes + # (only admin can see the leaderboard codes if leaderboard is not ended) return check_admin_access_codes(user_id, leaderboard_id, submission_ids) except Exception as e: logger.error(f"faild to list codes: {e}") @@ -472,38 +474,6 @@ def log_one(base_name): return "❗ Could not find any profiling data" -def make_benchmark_log(result: dict) -> str: - num_bench = int(result.get("benchmark-count", 0)) - - def log_one(base_name): - status = result.get(f"{base_name}.status") - spec = result.get(f"{base_name}.spec") - if status == "fail": - bench_log.append(f"❌ {spec} failed testing:\n") - bench_log.append(result.get(f"{base_name}.error")) - return - - mean = result.get(f"{base_name}.mean") - err = result.get(f"{base_name}.err") - best = result.get(f"{base_name}.best") - worst = result.get(f"{base_name}.worst") - - bench_log.append(f"{spec}") - bench_log.append(f" ⏱ {format_time(mean, err)}") - if best is not None and worst is not None: - bench_log.append(f" ⚡ {format_time(best)} 🐌 {format_time(worst)}") - - bench_log = [] - for i in range(num_bench): - log_one(f"benchmark.{i}") - bench_log.append("") - - if len(bench_log) > 0: - return "\n".join(bench_log) - else: - return "❗ Could not find any benchmarks" - - def _is_crash_report(compilation: dict, passed: bool): if not passed: return True diff --git a/kernelboard/error.py b/kernelboard/error.py index c0d2eb91..4c1c03eb 100644 --- a/kernelboard/error.py +++ b/kernelboard/error.py @@ -1,6 +1,5 @@ from flask import Blueprint, render_template - blueprint = Blueprint("error", __name__, url_prefix="/") diff --git a/kernelboard/health.py b/kernelboard/health.py index 27b06830..0a460f2c 100644 --- a/kernelboard/health.py +++ b/kernelboard/health.py @@ -1,12 +1,15 @@ import os -from flask import Blueprint, current_app as app +from http import HTTPStatus + +from flask import Blueprint +from flask import current_app as app + from kernelboard.lib.db import get_db_connection +from kernelboard.lib.redis_connection import create_redis_connection from kernelboard.lib.status_code import ( - http_success, http_error, + http_success, ) -from http import HTTPStatus -from kernelboard.lib.redis_connection import create_redis_connection blueprint = Blueprint("health", __name__, url_prefix="/health") @@ -25,7 +28,7 @@ def health(): cert_reqs = os.getenv("REDIS_SSL_CERT_REQS") redis_conn = create_redis_connection(cert_reqs=cert_reqs) - if redis_conn == None: + if redis_conn is None: app.logger.error("redis_conn is None. Is REDIS_URL set?") all_checks_passed = False else: diff --git a/kernelboard/index.py b/kernelboard/index.py index b7efa575..691fcfb4 100644 --- a/kernelboard/index.py +++ b/kernelboard/index.py @@ -1,5 +1,7 @@ -from flask import Blueprint, render_template from datetime import datetime, timezone + +from flask import Blueprint, render_template + from kernelboard.lib.db import get_db_connection blueprint = Blueprint("index", __name__, url_prefix="/") @@ -127,9 +129,9 @@ def index(): cur.execute(query) leaderboards = [row[0] for row in cur.fetchall()] - for l in leaderboards: - if l["gpu_types"] is None: - l["gpu_types"] = [] + for lb in leaderboards: + if lb["gpu_types"] is None: + lb["gpu_types"] = [] return render_template( "index.html", leaderboards=leaderboards, now=datetime.now(timezone.utc) diff --git a/kernelboard/leaderboard.py b/kernelboard/leaderboard.py index 6d7130ac..7f50506e 100644 --- a/kernelboard/leaderboard.py +++ b/kernelboard/leaderboard.py @@ -1,4 +1,5 @@ -from flask import abort, Blueprint, render_template +from flask import Blueprint, abort, render_template + from kernelboard.lib.db import get_db_connection from kernelboard.lib.time import to_time_left diff --git a/kernelboard/lib/auth_utils.py b/kernelboard/lib/auth_utils.py index c3f5e578..849ee59f 100644 --- a/kernelboard/lib/auth_utils.py +++ b/kernelboard/lib/auth_utils.py @@ -1,12 +1,11 @@ +import logging import secrets -import os from typing import Any, Optional from flask import session from flask_login import current_user from kernelboard.lib.db import get_db_connection -import logging logger = logging.getLogger(__name__) diff --git a/kernelboard/lib/db.py b/kernelboard/lib/db.py index 8c741c3f..f0b4f7be 100644 --- a/kernelboard/lib/db.py +++ b/kernelboard/lib/db.py @@ -1,6 +1,8 @@ -import psycopg2 -from flask import g, Flask, current_app import logging + +import psycopg2 +from flask import Flask, current_app, g + logger = logging.getLogger(__name__) def get_db_connection() -> psycopg2.extensions.connection: """ diff --git a/kernelboard/lib/error.py b/kernelboard/lib/error.py index bbacbf0f..550c9faf 100644 --- a/kernelboard/lib/error.py +++ b/kernelboard/lib/error.py @@ -1,6 +1,6 @@ import http -from typing import List import logging +from typing import List logger = logging.getLogger(__name__) diff --git a/kernelboard/lib/file_handler.py b/kernelboard/lib/file_handler.py index 794c3b4e..2408e947 100644 --- a/kernelboard/lib/file_handler.py +++ b/kernelboard/lib/file_handler.py @@ -1,8 +1,15 @@ import ast -import re import mimetypes +import re + from werkzeug.utils import secure_filename -from kernelboard.lib.error import InvalidMimeError, InvalidSyntaxError,InvalidPythonExtensionError,MissingRequiredFieldError + +from kernelboard.lib.error import ( + InvalidMimeError, + InvalidPythonExtensionError, + InvalidSyntaxError, + MissingRequiredFieldError, +) ALLOWED_EXTS = {".py"} ALLOWED_PYTHON_MIMES = {"text/x-python", "text/x-script.python", "text/plain"} @@ -11,7 +18,10 @@ def get_submission_file_info(request): if "file" not in request.files: - raise MissingRequiredFieldError("missing required submission python file in requests.files, if this is unexpected, please contact the gpumode administrator") + raise MissingRequiredFieldError( + "missing required submission python file in requests.files," + " if this is unexpected, please contact the gpumode administrator" + ) f = request.files["file"] filename = secure_filename(f.filename or "") diff --git a/kernelboard/lib/frontend_redirect.py b/kernelboard/lib/frontend_redirect.py index 953ae015..1ac0ae0c 100644 --- a/kernelboard/lib/frontend_redirect.py +++ b/kernelboard/lib/frontend_redirect.py @@ -1,3 +1,7 @@ +import os +from urllib.parse import urlparse + +from flask import redirect FRONTEND_ORIGIN = os.getenv("FRONTEND_ORIGIN", "/v2/") # e.g. "/", "/v2/", or "https://app.example.com" diff --git a/kernelboard/lib/rate_limiter.py b/kernelboard/lib/rate_limiter.py index b2294fd9..e391a5de 100644 --- a/kernelboard/lib/rate_limiter.py +++ b/kernelboard/lib/rate_limiter.py @@ -1,8 +1,9 @@ +import logging import os +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse + from flask_limiter import Limiter from flask_login import current_user -from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode -import logging logger = logging.getLogger(__name__) diff --git a/kernelboard/lib/redis_connection.py b/kernelboard/lib/redis_connection.py index d8d28097..68463450 100644 --- a/kernelboard/lib/redis_connection.py +++ b/kernelboard/lib/redis_connection.py @@ -1,4 +1,5 @@ import os + import redis diff --git a/kernelboard/lib/status_code.py b/kernelboard/lib/status_code.py index be49d0a0..0e2d27e3 100644 --- a/kernelboard/lib/status_code.py +++ b/kernelboard/lib/status_code.py @@ -1,6 +1,7 @@ -from flask import jsonify from http import HTTPStatus +from flask import jsonify + class HttpError(Exception): def __init__(self, message, status_code=500, code=None): diff --git a/kernelboard/og_tags.py b/kernelboard/og_tags.py index e4c5a53c..13664fea 100644 --- a/kernelboard/og_tags.py +++ b/kernelboard/og_tags.py @@ -5,6 +5,7 @@ """ import re + from flask import request # Common social media crawler User-Agent patterns diff --git a/pyproject.toml b/pyproject.toml index d3b4f168..4229e8af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,3 +9,11 @@ dependencies = [ [build-system] requires = ["flit_core<4"] build-backend = "flit_core.buildapi" + +[tool.ruff] +target-version = "py312" +line-length = 120 +exclude = ["migrations", "static", ".venv", "__pycache__"] + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] diff --git a/requirements.txt b/requirements.txt index 66107fcf..d6bfce0a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,3 +23,4 @@ urllib3>=2.4.0,<3.0.0 Werkzeug>=3.1.3,<3.2.0 pyyaml>=6.0.1 flask-limiter>=3.12 +ruff>=0.8.0 diff --git a/tests/api/test_auth_api.py b/tests/api/test_auth_api.py index 236ae81c..16ff70c6 100644 --- a/tests/api/test_auth_api.py +++ b/tests/api/test_auth_api.py @@ -1,4 +1,4 @@ -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch from urllib.parse import parse_qs, urlparse @@ -71,7 +71,7 @@ def test_callback_token_request_fails(client): with patch( "kernelboard.api.auth.requests.post", return_value=mock_response - ) as post: + ): response = client.get("/api/auth/discord/callback?state=123&code=456") assert_redirect_with_error(response, "token_error") @@ -118,7 +118,7 @@ def test_callback_userinfo_response_fails(client): with patch( "kernelboard.api.auth.requests.get", return_value=userinfo_response - ) as get: + ): response = client.get( "/api/auth/discord/callback?state=123&code=456" ) diff --git a/tests/api/test_news_api.py b/tests/api/test_news_api.py index 7db1231d..b2f8eadc 100644 --- a/tests/api/test_news_api.py +++ b/tests/api/test_news_api.py @@ -1,5 +1,5 @@ from http import HTTPStatus -from unittest.mock import patch, mock_open +from unittest.mock import mock_open, patch def test_news(client): @@ -19,7 +19,6 @@ def test_skip_invalid_yaml_with_mock(client): ), patch("builtins.open", mock_open(read_data=fake_file_content)): res = client.get("/api/news") assert res.status_code == HTTPStatus.NOT_FOUND - data = res.get_json() def test_only_return_valid_content_with_mock(client): diff --git a/tests/api/test_submission_api.py b/tests/api/test_submission_api.py index 88244536..3a4428dc 100644 --- a/tests/api/test_submission_api.py +++ b/tests/api/test_submission_api.py @@ -1,15 +1,17 @@ # tests/test_submission_api.py +import datetime as dt import http from io import BytesIO from types import SimpleNamespace -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch + import flask_login -import datetime as dt -import requests import pytest -from kernelboard.lib.db import get_db_connection +import requests from psycopg2.extras import execute_values +from kernelboard.lib.db import get_db_connection + _TEST_USER_ID = "333" _TEST_WEB_AUTH_ID = "111" @@ -302,7 +304,7 @@ def test_submission_upstream_non_200_maps_to_http_error(app, client, prepare): error_response.reason = "Bad Request" error_response.json.return_value = {"detail": "invalid format"} - with patch("kernelboard.api.submission.requests.post", return_value=error_response) as mock_post: + with patch("kernelboard.api.submission.requests.post", return_value=error_response): resp = _post_submission(client) assert resp.status_code == http.HTTPStatus.BAD_REQUEST diff --git a/tests/conftest.py b/tests/conftest.py index bbf37054..3170a8b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,13 @@ -import psycopg2 -from kernelboard import create_app -import pytest import random +import secrets import string import subprocess import time -import secrets + +import psycopg2 +import pytest + +from kernelboard import create_app def get_test_redis_url(port: int): diff --git a/tests/test_db.py b/tests/test_db.py index a7056fc8..43f236b7 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,5 +1,6 @@ -import pytest import psycopg2 +import pytest + from kernelboard.lib.db import get_db_connection @@ -7,7 +8,7 @@ def test_get_and_close_db_connection(app): with app.app_context(): conn = get_db_connection() assert conn is not None - assert conn.closed == False + assert not conn.closed assert conn is get_db_connection() conn.cursor().execute("SELECT 1") diff --git a/tests/test_health.py b/tests/test_health.py index 40f3181a..8d3b6670 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -1,4 +1,5 @@ -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch + import redis diff --git a/tests/test_index.py b/tests/test_index.py index 95548a96..0dc252e0 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -1,4 +1,5 @@ import re + from bs4 import BeautifulSoup diff --git a/tests/test_time.py b/tests/test_time.py index ef461fb5..5d2e0ca2 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -1,5 +1,6 @@ from datetime import datetime, timezone -from kernelboard.lib.time import to_time_left, _to_time_left, format_datetime + +from kernelboard.lib.time import _to_time_left, format_datetime, to_time_left def test_to_time_left(): @@ -34,7 +35,7 @@ def test_to_time_left(): assert to_time_left("1970-01-01 00:00:00+00:00") == "ended" - assert to_time_left("gibberish") == None + assert to_time_left("gibberish") is None def test_format_datetime():