refactor: 重构项目结构,将前端和后端代码分离到独立目录

- 将前端代码移动到 frontend/ 目录
- 将后端代码移动到 backend/ 目录
- 更新 .gitignore 以包含 Python 和前端构建产物
- 修复 LLM JSON 解析问题,增强错误处理
- 修复前端配置默认值,改为从后端获取
- 删除 AdminDashboard 中的数据库信息和统计卡片
- 完善系统配置管理,支持从后端获取默认配置
This commit is contained in:
lintsinghua 2025-11-26 21:11:12 +08:00
parent cc9ef80278
commit 6ce5b3c6c1
250 changed files with 11667 additions and 6311 deletions

72
.gitignore vendored
View File

@ -96,3 +96,75 @@ jspm_packages/
tmp/
temp/
.vercel
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
.eggs/
pip-log.txt
pip-delete-this-directory.txt
.pytest_cache/
.coverage
htmlcov/
.tox/
.venv/
venv/
ENV/
env/
# Backend specific
backend/.venv/
backend/venv/
backend/__pycache__/
backend/*.pyc
backend/.pytest_cache/
backend/.coverage
backend/alembic.ini.bak
# Frontend specific
frontend/node_modules/
frontend/dist/
frontend/build/
frontend/.vite/
frontend/.next/
frontend/.nuxt/
frontend/.cache/
frontend/.parcel-cache/
frontend/.turbo/
frontend/.svelte-kit/
# Database
*.db
*.sqlite
*.sqlite3
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.project
.classpath
.settings/
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Secrets and keys
*.pem
*.key
*.cert
*.crt
secrets/
.secrets/

2
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.venv/
venv/

1
backend/.python-version Normal file
View File

@ -0,0 +1 @@
3.13

15
backend/Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Command is overridden by docker-compose for dev
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

93
backend/README_UV.md Normal file
View File

@ -0,0 +1,93 @@
# 使用 uv 管理 Python 依赖
本项目已迁移到使用 [uv](https://github.com/astral-sh/uv) 作为 Python 依赖管理器。
## 快速开始
### 安装 uv
```bash
# macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# 或使用 Homebrew
brew install uv
```
### 安装依赖
```bash
cd backend
uv sync
```
这会自动创建虚拟环境并安装所有依赖。
### 运行项目
```bash
# 激活虚拟环境uv 会自动管理)
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
# 或使用 uv 直接运行
uv run python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
### 数据库迁移
```bash
uv run alembic upgrade head
```
### 添加新依赖
```bash
# 添加依赖
uv add package-name
# 添加开发依赖
uv add --dev package-name
```
### 更新依赖
```bash
uv sync --upgrade
```
### 其他常用命令
```bash
# 查看已安装的包
uv pip list
# 运行 Python 脚本
uv run python script.py
# 运行 Alembic 命令
uv run alembic <command>
```
## 从 pip/venv 迁移
如果你之前使用 pip 和 venv
1. 删除旧的虚拟环境(可选):
```bash
rm -rf venv
```
2. 使用 uv 同步依赖:
```bash
uv sync
```
3. 之后使用 `uv run` 运行命令,或激活 uv 创建的虚拟环境。
## 优势
- **速度快**:比 pip 快 10-100 倍
- **可复现**:自动生成锁文件
- **简单**:一个命令管理所有依赖
- **兼容**:完全兼容 pip 和 requirements.txt

82
backend/UV_MIGRATION.md Normal file
View File

@ -0,0 +1,82 @@
# 迁移到 uv 依赖管理器
## ✅ 已完成的工作
1. **初始化 uv 项目**
- 创建了 `pyproject.toml` 配置文件
- 所有依赖已迁移到 `pyproject.toml`
2. **依赖管理**
- 使用 `uv sync` 安装所有依赖
- 创建了 `.venv` 虚拟环境uv 自动管理)
- 生成了 `requirements-lock.txt` 锁定文件
3. **工具和脚本**
- 创建了 `start.sh` 启动脚本
- 创建了 `README_UV.md` 使用文档
## 📝 使用方式
### 启动服务
```bash
cd backend
./start.sh
```
或手动启动:
```bash
cd backend
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
### 数据库迁移
```bash
cd backend
uv run alembic upgrade head
```
### 添加新依赖
```bash
cd backend
uv add package-name
```
## 🔄 从旧环境迁移
如果你之前使用 `venv``pip`
1. **删除旧虚拟环境**(可选):
```bash
rm -rf venv
```
2. **使用 uv 同步**
```bash
uv sync
```
3. **之后使用 `uv run` 运行所有命令**
## 📦 依赖文件说明
- `pyproject.toml` - 项目配置和依赖声明(主要文件)
- `requirements.txt` - 保留用于兼容性,但建议使用 `pyproject.toml`
- `requirements-lock.txt` - 自动生成的锁定文件(确保可复现)
## ⚠️ 注意事项
- uv 创建的虚拟环境在 `.venv/` 目录(不是 `venv/`
- 使用 `uv run` 运行命令会自动使用正确的虚拟环境
- 也可以手动激活:`source .venv/bin/activate`
## 🎯 优势
- ⚡ **速度快**:比 pip 快 10-100 倍
- 🔒 **可复现**:自动生成锁定文件
- 🎯 **简单**:一个命令管理所有依赖
- 🔄 **兼容**:完全兼容 pip 和 requirements.txt

103
backend/alembic.ini Normal file
View File

@ -0,0 +1,103 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library to be installed.
# string value is passed to dateutil.tz.gettz()
# leave blank for local time
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; this defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator"
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

90
backend/alembic/env.py Normal file
View File

@ -0,0 +1,90 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from app.db.base import Base
from app.models import * # noqa
from app.core.config import settings
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View File

@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,157 @@
"""Initial migration - create all tables
Revision ID: 001_initial
Revises:
Create Date: 2024-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '001_initial'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create users table
op.create_table(
'users',
sa.Column('id', sa.String(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('full_name', sa.String(), nullable=True),
sa.Column('avatar_url', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.Column('is_superuser', sa.Boolean(), server_default='false', nullable=False),
sa.Column('role', sa.String(), server_default='user', nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
# Create projects table
op.create_table(
'projects',
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('repository_url', sa.String(), nullable=True),
sa.Column('repository_type', sa.String(), nullable=True),
sa.Column('default_branch', sa.String(), nullable=True),
sa.Column('programming_languages', sa.Text(), nullable=True),
sa.Column('owner_id', sa.String(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('status', sa.String(), server_default='active', nullable=False),
sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_projects_owner_id'), 'projects', ['owner_id'], unique=False)
# Create project_members table
op.create_table(
'project_members',
sa.Column('id', sa.String(), nullable=False),
sa.Column('project_id', sa.String(), sa.ForeignKey('projects.id'), nullable=False),
sa.Column('user_id', sa.String(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('role', sa.String(), server_default='member', nullable=False),
sa.Column('permissions', sa.Text(), server_default='{}', nullable=True),
sa.Column('joined_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# Create audit_tasks table
op.create_table(
'audit_tasks',
sa.Column('id', sa.String(), nullable=False),
sa.Column('project_id', sa.String(), sa.ForeignKey('projects.id'), nullable=False),
sa.Column('created_by', sa.String(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('task_type', sa.String(), nullable=False),
sa.Column('status', sa.String(), server_default='pending', nullable=False),
sa.Column('branch_name', sa.String(), nullable=True),
sa.Column('exclude_patterns', sa.Text(), server_default='[]', nullable=True),
sa.Column('scan_config', sa.Text(), server_default='{}', nullable=True),
sa.Column('total_files', sa.Integer(), server_default='0', nullable=False),
sa.Column('scanned_files', sa.Integer(), server_default='0', nullable=False),
sa.Column('total_lines', sa.Integer(), server_default='0', nullable=False),
sa.Column('issues_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('quality_score', sa.Float(), server_default='0.0', nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_audit_tasks_status'), 'audit_tasks', ['status'], unique=False)
# Create audit_issues table
op.create_table(
'audit_issues',
sa.Column('id', sa.String(), nullable=False),
sa.Column('task_id', sa.String(), sa.ForeignKey('audit_tasks.id'), nullable=False),
sa.Column('file_path', sa.String(), nullable=False),
sa.Column('line_number', sa.Integer(), nullable=True),
sa.Column('column_number', sa.Integer(), nullable=True),
sa.Column('issue_type', sa.String(), nullable=False),
sa.Column('severity', sa.String(), nullable=False),
sa.Column('title', sa.String(), nullable=True),
sa.Column('message', sa.Text(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('suggestion', sa.Text(), nullable=True),
sa.Column('code_snippet', sa.Text(), nullable=True),
sa.Column('ai_explanation', sa.Text(), nullable=True),
sa.Column('status', sa.String(), server_default='open', nullable=False),
sa.Column('resolved_by', sa.String(), sa.ForeignKey('users.id'), nullable=True),
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# Create instant_analyses table
op.create_table(
'instant_analyses',
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), sa.ForeignKey('users.id'), nullable=True),
sa.Column('language', sa.String(), nullable=False),
sa.Column('code_content', sa.Text(), server_default='', nullable=True),
sa.Column('analysis_result', sa.Text(), server_default='{}', nullable=True),
sa.Column('issues_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('quality_score', sa.Float(), server_default='0.0', nullable=False),
sa.Column('analysis_time', sa.Float(), server_default='0.0', nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# Create user_configs table
op.create_table(
'user_configs',
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('llm_config', sa.Text(), server_default='{}', nullable=True),
sa.Column('other_config', sa.Text(), server_default='{}', nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id')
)
def downgrade() -> None:
op.drop_table('user_configs')
op.drop_table('instant_analyses')
op.drop_table('audit_issues')
op.drop_index(op.f('ix_audit_tasks_status'), table_name='audit_tasks')
op.drop_table('audit_tasks')
op.drop_table('project_members')
op.drop_index(op.f('ix_projects_owner_id'), table_name='projects')
op.drop_table('projects')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')

View File

@ -0,0 +1,31 @@
"""add_missing_user_fields
Revision ID: 5fc1cc05d5d0
Revises: 001_initial
Create Date: 2025-11-26 20:27:00.645441
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5fc1cc05d5d0'
down_revision = '001_initial'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add missing columns to users table
op.add_column('users', sa.Column('phone', sa.String(), nullable=True))
op.add_column('users', sa.Column('github_username', sa.String(), nullable=True))
op.add_column('users', sa.Column('gitlab_username', sa.String(), nullable=True))
def downgrade() -> None:
# Remove added columns
op.drop_column('users', 'gitlab_username')
op.drop_column('users', 'github_username')
op.drop_column('users', 'phone')

View File

@ -0,0 +1,27 @@
"""add_is_active_to_projects
Revision ID: 73889a94a455
Revises: 5fc1cc05d5d0
Create Date: 2025-11-26 20:40:11.375161
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '73889a94a455'
down_revision = '5fc1cc05d5d0'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add is_active column to projects table
op.add_column('projects', sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False))
def downgrade() -> None:
# Remove is_active column from projects table
op.drop_column('projects', 'is_active')

50
backend/app/api/deps.py Normal file
View File

@ -0,0 +1,50 @@
from typing import Generator, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.core import security
from app.core.config import settings
from app.db.session import get_db
from app.models.user import User
from app.schemas import token as token_schema
reusable_oauth2 = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login"
)
async def get_current_user(
db: AsyncSession = Depends(get_db),
token: str = Depends(reusable_oauth2)
) -> User:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
)
token_data = token_schema.TokenPayload(**payload)
except (JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无法验证凭据",
headers={"WWW-Authenticate": "Bearer"},
)
result = await db.execute(select(User).where(User.id == token_data.sub))
user = result.scalars().first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if not user.is_active:
raise HTTPException(status_code=400, detail="用户已被禁用")
return user
async def get_current_active_superuser(
current_user: User = Depends(get_current_user),
) -> User:
if not current_user.is_superuser:
raise HTTPException(
status_code=400, detail="权限不足"
)
return current_user

View File

12
backend/app/api/v1/api.py Normal file
View File

@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.api.v1.endpoints import auth, users, projects, tasks, scan, members, config, database
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(projects.router, prefix="/projects", tags=["projects"])
api_router.include_router(members.router, prefix="/projects", tags=["members"])
api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
api_router.include_router(scan.router, prefix="/scan", tags=["scan"])
api_router.include_router(config.router, prefix="/config", tags=["config"])
api_router.include_router(database.router, prefix="/database", tags=["database"])

View File

View File

@ -0,0 +1,84 @@
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from pydantic import BaseModel, EmailStr
from app.api import deps
from app.core import security
from app.core.config import settings
from app.db.session import get_db
from app.models.user import User
from app.schemas.token import Token
from app.schemas.user import User as UserSchema, UserCreate
router = APIRouter()
class RegisterRequest(BaseModel):
email: EmailStr
password: str
full_name: str
@router.post("/login", response_model=Token)
async def login(
db: AsyncSession = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends()
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests.
Username field should contain the email address.
"""
result = await db.execute(select(User).where(User.email == form_data.username))
user = result.scalars().first()
if not user or not security.verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=400, detail="邮箱或密码错误")
elif not user.is_active:
raise HTTPException(status_code=400, detail="用户已被禁用")
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return {
"access_token": security.create_access_token(
user.id, expires_delta=access_token_expires
),
"token_type": "bearer",
}
@router.post("/register", response_model=UserSchema)
async def register(
*,
db: AsyncSession = Depends(get_db),
user_in: RegisterRequest,
) -> Any:
"""
Register a new user.
"""
# Check if user already exists
result = await db.execute(select(User).where(User.email == user_in.email))
existing_user = result.scalars().first()
if existing_user:
raise HTTPException(
status_code=400,
detail="该邮箱已被注册",
)
# Check if this is the first user (make them admin)
count_result = await db.execute(select(User))
all_users = count_result.scalars().all()
is_first_user = len(all_users) == 0
# Create new user
db_user = User(
email=user_in.email,
hashed_password=security.get_password_hash(user_in.password),
full_name=user_in.full_name,
is_active=True,
is_superuser=is_first_user,
role="admin" if is_first_user else "member",
)
db.add(db_user)
await db.commit()
await db.refresh(db_user)
return db_user

View File

@ -0,0 +1,218 @@
"""
用户配置API端点
"""
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from pydantic import BaseModel
import json
from app.api import deps
from app.db.session import get_db
from app.models.user_config import UserConfig
from app.models.user import User
from app.core.config import settings
router = APIRouter()
class LLMConfigSchema(BaseModel):
"""LLM配置Schema"""
llmProvider: Optional[str] = None
llmApiKey: Optional[str] = None
llmModel: Optional[str] = None
llmBaseUrl: Optional[str] = None
llmTimeout: Optional[int] = None
llmTemperature: Optional[float] = None
llmMaxTokens: Optional[int] = None
llmCustomHeaders: Optional[str] = None
# 平台专用配置
geminiApiKey: Optional[str] = None
openaiApiKey: Optional[str] = None
claudeApiKey: Optional[str] = None
qwenApiKey: Optional[str] = None
deepseekApiKey: Optional[str] = None
zhipuApiKey: Optional[str] = None
moonshotApiKey: Optional[str] = None
baiduApiKey: Optional[str] = None
minimaxApiKey: Optional[str] = None
doubaoApiKey: Optional[str] = None
ollamaBaseUrl: Optional[str] = None
class OtherConfigSchema(BaseModel):
"""其他配置Schema"""
githubToken: Optional[str] = None
gitlabToken: Optional[str] = None
maxAnalyzeFiles: Optional[int] = None
llmConcurrency: Optional[int] = None
llmGapMs: Optional[int] = None
outputLanguage: Optional[str] = None
class UserConfigRequest(BaseModel):
"""用户配置请求"""
llmConfig: Optional[LLMConfigSchema] = None
otherConfig: Optional[OtherConfigSchema] = None
class UserConfigResponse(BaseModel):
"""用户配置响应"""
id: str
user_id: str
llmConfig: dict
otherConfig: dict
created_at: str
updated_at: Optional[str] = None
class Config:
from_attributes = True
def get_default_config() -> dict:
"""获取系统默认配置"""
return {
"llmConfig": {
"llmProvider": settings.LLM_PROVIDER,
"llmApiKey": "",
"llmModel": settings.LLM_MODEL or "",
"llmBaseUrl": settings.LLM_BASE_URL or "",
"llmTimeout": settings.LLM_TIMEOUT * 1000, # 转换为毫秒
"llmTemperature": settings.LLM_TEMPERATURE,
"llmMaxTokens": settings.LLM_MAX_TOKENS,
"llmCustomHeaders": "",
"geminiApiKey": settings.GEMINI_API_KEY or "",
"openaiApiKey": settings.OPENAI_API_KEY or "",
"claudeApiKey": settings.CLAUDE_API_KEY or "",
"qwenApiKey": settings.QWEN_API_KEY or "",
"deepseekApiKey": settings.DEEPSEEK_API_KEY or "",
"zhipuApiKey": settings.ZHIPU_API_KEY or "",
"moonshotApiKey": settings.MOONSHOT_API_KEY or "",
"baiduApiKey": settings.BAIDU_API_KEY or "",
"minimaxApiKey": settings.MINIMAX_API_KEY or "",
"doubaoApiKey": settings.DOUBAO_API_KEY or "",
"ollamaBaseUrl": settings.OLLAMA_BASE_URL or "http://localhost:11434/v1",
},
"otherConfig": {
"githubToken": settings.GITHUB_TOKEN or "",
"gitlabToken": settings.GITLAB_TOKEN or "",
"maxAnalyzeFiles": settings.MAX_ANALYZE_FILES,
"llmConcurrency": settings.LLM_CONCURRENCY,
"llmGapMs": settings.LLM_GAP_MS,
"outputLanguage": settings.OUTPUT_LANGUAGE,
}
}
@router.get("/defaults")
async def get_default_config_endpoint() -> Any:
"""获取系统默认配置(无需认证)"""
return get_default_config()
@router.get("/me", response_model=UserConfigResponse)
async def get_my_config(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""获取当前用户的配置(合并用户配置和系统默认配置)"""
result = await db.execute(
select(UserConfig).where(UserConfig.user_id == current_user.id)
)
config = result.scalar_one_or_none()
# 获取系统默认配置
default_config = get_default_config()
if not config:
# 返回系统默认配置
return UserConfigResponse(
id="",
user_id=current_user.id,
llmConfig=default_config["llmConfig"],
otherConfig=default_config["otherConfig"],
created_at="",
)
# 合并用户配置和默认配置(用户配置优先)
user_llm_config = json.loads(config.llm_config) if config.llm_config else {}
user_other_config = json.loads(config.other_config) if config.other_config else {}
merged_llm_config = {**default_config["llmConfig"], **user_llm_config}
merged_other_config = {**default_config["otherConfig"], **user_other_config}
return UserConfigResponse(
id=config.id,
user_id=config.user_id,
llmConfig=merged_llm_config,
otherConfig=merged_other_config,
created_at=config.created_at.isoformat() if config.created_at else "",
updated_at=config.updated_at.isoformat() if config.updated_at else None,
)
@router.put("/me", response_model=UserConfigResponse)
async def update_my_config(
config_in: UserConfigRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""更新当前用户的配置"""
result = await db.execute(
select(UserConfig).where(UserConfig.user_id == current_user.id)
)
config = result.scalar_one_or_none()
if not config:
# 创建新配置
config = UserConfig(
user_id=current_user.id,
llm_config=json.dumps(config_in.llmConfig.dict(exclude_none=True) if config_in.llmConfig else {}),
other_config=json.dumps(config_in.otherConfig.dict(exclude_none=True) if config_in.otherConfig else {}),
)
db.add(config)
else:
# 更新现有配置
if config_in.llmConfig:
existing_llm = json.loads(config.llm_config) if config.llm_config else {}
existing_llm.update(config_in.llmConfig.dict(exclude_none=True))
config.llm_config = json.dumps(existing_llm)
if config_in.otherConfig:
existing_other = json.loads(config.other_config) if config.other_config else {}
existing_other.update(config_in.otherConfig.dict(exclude_none=True))
config.other_config = json.dumps(existing_other)
await db.commit()
await db.refresh(config)
return UserConfigResponse(
id=config.id,
user_id=config.user_id,
llmConfig=json.loads(config.llm_config) if config.llm_config else {},
otherConfig=json.loads(config.other_config) if config.other_config else {},
created_at=config.created_at.isoformat() if config.created_at else "",
updated_at=config.updated_at.isoformat() if config.updated_at else None,
)
@router.delete("/me")
async def delete_my_config(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""删除当前用户的配置(恢复为默认)"""
result = await db.execute(
select(UserConfig).where(UserConfig.user_id == current_user.id)
)
config = result.scalar_one_or_none()
if config:
await db.delete(config)
await db.commit()
return {"message": "配置已删除"}

View File

@ -0,0 +1,692 @@
"""
数据库管理API端点
提供数据导出导入清空等功能
"""
from typing import Any, Dict, List
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from pydantic import BaseModel
import json
from datetime import datetime
from app.api import deps
from app.db.session import get_db
from app.models.user import User
from app.models.project import Project, ProjectMember
from app.models.audit import AuditTask, AuditIssue
from app.models.analysis import InstantAnalysis
from app.models.user_config import UserConfig
router = APIRouter()
class DatabaseExportResponse(BaseModel):
"""数据库导出响应"""
export_date: str
user_id: str
data: Dict[str, Any]
class Config:
from_attributes = True
@router.get("/export", response_model=DatabaseExportResponse)
async def export_database(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
导出当前用户的所有数据
包括项目任务问题即时分析用户配置
"""
try:
# 1. 获取用户的所有项目
projects_result = await db.execute(
select(Project)
.where(Project.owner_id == current_user.id)
.options(selectinload(Project.tasks))
)
projects = projects_result.scalars().all()
# 2. 获取用户的所有任务
tasks_result = await db.execute(
select(AuditTask)
.where(AuditTask.created_by == current_user.id)
.options(selectinload(AuditTask.issues))
)
tasks = tasks_result.scalars().all()
# 3. 获取用户的所有问题(通过任务关联)
task_ids = [task.id for task in tasks]
issues = []
if task_ids:
issues_result = await db.execute(
select(AuditIssue)
.where(AuditIssue.task_id.in_(task_ids))
)
issues = issues_result.scalars().all()
# 4. 获取用户的即时分析记录
analyses_result = await db.execute(
select(InstantAnalysis)
.where(InstantAnalysis.user_id == current_user.id)
)
analyses = analyses_result.scalars().all()
# 5. 获取用户的配置
config_result = await db.execute(
select(UserConfig)
.where(UserConfig.user_id == current_user.id)
)
config = config_result.scalar_one_or_none()
# 6. 获取用户参与的项目(作为成员)
members_result = await db.execute(
select(ProjectMember)
.where(ProjectMember.user_id == current_user.id)
.options(selectinload(ProjectMember.project))
)
members = members_result.scalars().all()
# 7. 构建导出数据
export_data = {
"version": "1.0.0",
"export_date": datetime.utcnow().isoformat(),
"user": {
"id": current_user.id,
"email": current_user.email,
"full_name": current_user.full_name,
},
"projects": [
{
"id": p.id,
"name": p.name,
"description": p.description,
"repository_url": p.repository_url,
"repository_type": p.repository_type,
"default_branch": p.default_branch,
"programming_languages": json.loads(p.programming_languages) if p.programming_languages else [],
"is_active": p.is_active,
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
}
for p in projects
],
"tasks": [
{
"id": t.id,
"project_id": t.project_id,
"task_type": t.task_type,
"status": t.status,
"branch_name": t.branch_name,
"exclude_patterns": json.loads(t.exclude_patterns) if t.exclude_patterns else [],
"total_files": t.total_files,
"scanned_files": t.scanned_files,
"total_lines": t.total_lines,
"issues_count": t.issues_count,
"quality_score": t.quality_score,
"started_at": t.started_at.isoformat() if t.started_at else None,
"completed_at": t.completed_at.isoformat() if t.completed_at else None,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
for t in tasks
],
"issues": [
{
"id": i.id,
"task_id": i.task_id,
"file_path": i.file_path,
"line_number": i.line_number,
"column_number": i.column_number,
"issue_type": i.issue_type,
"severity": i.severity,
"title": i.title,
"message": i.message,
"description": i.description,
"suggestion": i.suggestion,
"code_snippet": i.code_snippet,
"ai_explanation": i.ai_explanation,
"status": i.status,
"created_at": i.created_at.isoformat() if i.created_at else None,
}
for i in issues
],
"instant_analyses": [
{
"id": a.id,
"language": a.language,
"issues_count": a.issues_count,
"quality_score": a.quality_score,
"analysis_time": a.analysis_time,
"created_at": a.created_at.isoformat() if a.created_at else None,
}
for a in analyses
],
"user_config": {
"llm_config": json.loads(config.llm_config) if config and config.llm_config else {},
"other_config": json.loads(config.other_config) if config and config.other_config else {},
} if config else {},
"project_members": [
{
"id": m.id,
"project_id": m.project_id,
"role": m.role,
"permissions": json.loads(m.permissions) if m.permissions else {},
"joined_at": m.joined_at.isoformat() if m.joined_at else None,
}
for m in members
],
}
return DatabaseExportResponse(
export_date=export_data["export_date"],
user_id=current_user.id,
data=export_data
)
except Exception as e:
print(f"导出数据失败: {e}")
raise HTTPException(status_code=500, detail=f"导出数据失败: {str(e)}")
class DatabaseImportRequest(BaseModel):
"""数据库导入请求"""
data: Dict[str, Any]
@router.post("/import")
async def import_database(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
JSON 文件导入数据
注意导入会合并数据不会删除现有数据
"""
try:
# 读取文件内容
content = await file.read()
import_data = json.loads(content.decode('utf-8'))
if not isinstance(import_data, dict) or "data" not in import_data:
raise HTTPException(status_code=400, detail="无效的导入文件格式")
data = import_data["data"]
# 验证用户ID只能导入自己的数据
if data.get("user", {}).get("id") != current_user.id:
raise HTTPException(status_code=403, detail="只能导入自己的数据")
imported_count = {
"projects": 0,
"tasks": 0,
"issues": 0,
"analyses": 0,
"config": 0,
}
# 1. 导入项目(跳过已存在的)
if "projects" in data:
for p_data in data["projects"]:
existing = await db.get(Project, p_data.get("id"))
if not existing:
project = Project(
id=p_data.get("id"),
name=p_data.get("name"),
description=p_data.get("description"),
repository_url=p_data.get("repository_url"),
repository_type=p_data.get("repository_type"),
default_branch=p_data.get("default_branch"),
programming_languages=json.dumps(p_data.get("programming_languages", [])),
owner_id=current_user.id,
is_active=p_data.get("is_active", True) if "is_active" in p_data else (p_data.get("status") != "inactive" if "status" in p_data else True),
)
db.add(project)
imported_count["projects"] += 1
await db.commit()
# 2. 导入任务(需要先有项目)
if "tasks" in data:
for t_data in data["tasks"]:
existing = await db.get(AuditTask, t_data.get("id"))
if not existing:
# 检查项目是否存在
project = await db.get(Project, t_data.get("project_id"))
if project:
task = AuditTask(
id=t_data.get("id"),
project_id=t_data.get("project_id"),
created_by=current_user.id,
task_type=t_data.get("task_type"),
status=t_data.get("status", "pending"),
branch_name=t_data.get("branch_name"),
exclude_patterns=json.dumps(t_data.get("exclude_patterns", [])),
scan_config=json.dumps(t_data.get("scan_config", {})),
total_files=t_data.get("total_files", 0),
scanned_files=t_data.get("scanned_files", 0),
total_lines=t_data.get("total_lines", 0),
issues_count=t_data.get("issues_count", 0),
quality_score=t_data.get("quality_score", 0.0),
)
db.add(task)
imported_count["tasks"] += 1
await db.commit()
# 3. 导入问题(需要先有任务)
if "issues" in data:
for i_data in data["issues"]:
existing = await db.get(AuditIssue, i_data.get("id"))
if not existing:
# 检查任务是否存在
task = await db.get(AuditTask, i_data.get("task_id"))
if task:
issue = AuditIssue(
id=i_data.get("id"),
task_id=i_data.get("task_id"),
file_path=i_data.get("file_path"),
line_number=i_data.get("line_number"),
column_number=i_data.get("column_number"),
issue_type=i_data.get("issue_type"),
severity=i_data.get("severity"),
title=i_data.get("title"),
message=i_data.get("message"),
description=i_data.get("description"),
suggestion=i_data.get("suggestion"),
code_snippet=i_data.get("code_snippet"),
ai_explanation=i_data.get("ai_explanation"),
status=i_data.get("status", "open"),
)
db.add(issue)
imported_count["issues"] += 1
await db.commit()
# 4. 导入即时分析
if "instant_analyses" in data:
for a_data in data["instant_analyses"]:
existing = await db.get(InstantAnalysis, a_data.get("id"))
if not existing:
analysis = InstantAnalysis(
id=a_data.get("id"),
user_id=current_user.id,
language=a_data.get("language"),
code_content="",
analysis_result=json.dumps(a_data.get("analysis_result", {})),
issues_count=a_data.get("issues_count", 0),
quality_score=a_data.get("quality_score", 0.0),
analysis_time=a_data.get("analysis_time", 0.0),
)
db.add(analysis)
imported_count["analyses"] += 1
await db.commit()
# 5. 导入用户配置(合并)
if "user_config" in data and data["user_config"]:
config_result = await db.execute(
select(UserConfig)
.where(UserConfig.user_id == current_user.id)
)
config = config_result.scalar_one_or_none()
if not config:
config = UserConfig(
user_id=current_user.id,
llm_config=json.dumps(data["user_config"].get("llm_config", {})),
other_config=json.dumps(data["user_config"].get("other_config", {})),
)
db.add(config)
else:
# 合并配置
existing_llm = json.loads(config.llm_config) if config.llm_config else {}
existing_other = json.loads(config.other_config) if config.other_config else {}
existing_llm.update(data["user_config"].get("llm_config", {}))
existing_other.update(data["user_config"].get("other_config", {}))
config.llm_config = json.dumps(existing_llm)
config.other_config = json.dumps(existing_other)
imported_count["config"] = 1
await db.commit()
return {
"message": "数据导入成功",
"imported": imported_count
}
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="无效的 JSON 文件格式")
except Exception as e:
print(f"导入数据失败: {e}")
await db.rollback()
raise HTTPException(status_code=500, detail=f"导入数据失败: {str(e)}")
@router.delete("/clear")
async def clear_database(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
清空当前用户的所有数据
注意此操作不可恢复请谨慎使用
"""
try:
deleted_count = {
"projects": 0,
"tasks": 0,
"issues": 0,
"analyses": 0,
"config": 0,
}
# 1. 删除用户的所有问题(通过任务)
tasks_result = await db.execute(
select(AuditTask)
.where(AuditTask.created_by == current_user.id)
)
tasks = tasks_result.scalars().all()
task_ids = [task.id for task in tasks]
if task_ids:
issues_result = await db.execute(
select(AuditIssue)
.where(AuditIssue.task_id.in_(task_ids))
)
issues = issues_result.scalars().all()
for issue in issues:
await db.delete(issue)
deleted_count["issues"] = len(issues)
# 2. 删除用户的所有任务
for task in tasks:
await db.delete(task)
deleted_count["tasks"] = len(tasks)
# 3. 删除用户的所有项目
projects_result = await db.execute(
select(Project)
.where(Project.owner_id == current_user.id)
)
projects = projects_result.scalars().all()
for project in projects:
await db.delete(project)
deleted_count["projects"] = len(projects)
# 4. 删除用户的即时分析
analyses_result = await db.execute(
select(InstantAnalysis)
.where(InstantAnalysis.user_id == current_user.id)
)
analyses = analyses_result.scalars().all()
for analysis in analyses:
await db.delete(analysis)
deleted_count["analyses"] = len(analyses)
# 5. 删除用户配置
config_result = await db.execute(
select(UserConfig)
.where(UserConfig.user_id == current_user.id)
)
config = config_result.scalar_one_or_none()
if config:
await db.delete(config)
deleted_count["config"] = 1
# 6. 删除用户的项目成员关系(作为成员)
members_result = await db.execute(
select(ProjectMember)
.where(ProjectMember.user_id == current_user.id)
)
members = members_result.scalars().all()
for member in members:
await db.delete(member)
await db.commit()
return {
"message": "数据已清空",
"deleted": deleted_count
}
except Exception as e:
print(f"清空数据失败: {e}")
await db.rollback()
raise HTTPException(status_code=500, detail=f"清空数据失败: {str(e)}")
class DatabaseStatsResponse(BaseModel):
"""数据库统计信息响应"""
total_projects: int
active_projects: int
total_tasks: int
completed_tasks: int
pending_tasks: int
running_tasks: int
failed_tasks: int
total_issues: int
open_issues: int
resolved_issues: int
critical_issues: int
high_issues: int
medium_issues: int
low_issues: int
total_analyses: int
total_members: int
has_config: bool
@router.get("/stats", response_model=DatabaseStatsResponse)
async def get_database_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
获取当前用户的数据库统计信息
"""
try:
# 1. 项目统计
projects_result = await db.execute(
select(Project)
.where(Project.owner_id == current_user.id)
)
projects = projects_result.scalars().all()
total_projects = len(projects)
active_projects = len([p for p in projects if p.is_active])
# 2. 任务统计
tasks_result = await db.execute(
select(AuditTask)
.where(AuditTask.created_by == current_user.id)
)
tasks = tasks_result.scalars().all()
total_tasks = len(tasks)
completed_tasks = len([t for t in tasks if t.status == "completed"])
pending_tasks = len([t for t in tasks if t.status == "pending"])
running_tasks = len([t for t in tasks if t.status == "running"])
failed_tasks = len([t for t in tasks if t.status == "failed"])
# 3. 问题统计
task_ids = [task.id for task in tasks]
total_issues = 0
open_issues = 0
resolved_issues = 0
critical_issues = 0
high_issues = 0
medium_issues = 0
low_issues = 0
if task_ids:
issues_result = await db.execute(
select(AuditIssue)
.where(AuditIssue.task_id.in_(task_ids))
)
issues = issues_result.scalars().all()
total_issues = len(issues)
open_issues = len([i for i in issues if i.status == "open"])
resolved_issues = len([i for i in issues if i.status == "resolved"])
critical_issues = len([i for i in issues if i.severity == "critical"])
high_issues = len([i for i in issues if i.severity == "high"])
medium_issues = len([i for i in issues if i.severity == "medium"])
low_issues = len([i for i in issues if i.severity == "low"])
# 4. 即时分析统计
analyses_result = await db.execute(
select(InstantAnalysis)
.where(InstantAnalysis.user_id == current_user.id)
)
analyses = analyses_result.scalars().all()
total_analyses = len(analyses)
# 5. 项目成员统计
members_result = await db.execute(
select(ProjectMember)
.where(ProjectMember.user_id == current_user.id)
)
members = members_result.scalars().all()
total_members = len(members)
# 6. 配置检查
config_result = await db.execute(
select(UserConfig)
.where(UserConfig.user_id == current_user.id)
)
has_config = config_result.scalar_one_or_none() is not None
return DatabaseStatsResponse(
total_projects=total_projects,
active_projects=active_projects,
total_tasks=total_tasks,
completed_tasks=completed_tasks,
pending_tasks=pending_tasks,
running_tasks=running_tasks,
failed_tasks=failed_tasks,
total_issues=total_issues,
open_issues=open_issues,
resolved_issues=resolved_issues,
critical_issues=critical_issues,
high_issues=high_issues,
medium_issues=medium_issues,
low_issues=low_issues,
total_analyses=total_analyses,
total_members=total_members,
has_config=has_config,
)
except Exception as e:
print(f"获取统计信息失败: {e}")
raise HTTPException(status_code=500, detail=f"获取统计信息失败: {str(e)}")
class DatabaseHealthResponse(BaseModel):
"""数据库健康检查响应"""
status: str # healthy, warning, error
database_connected: bool
total_records: int
last_backup_date: str | None
issues: List[str]
warnings: List[str]
@router.get("/health", response_model=DatabaseHealthResponse)
async def check_database_health(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
检查数据库健康状态
"""
try:
issues = []
warnings = []
database_connected = True
total_records = 0
last_backup_date = None
# 1. 检查数据库连接
try:
await db.execute(select(1))
except Exception as e:
database_connected = False
issues.append(f"数据库连接失败: {str(e)}")
if database_connected:
# 2. 统计总记录数
try:
projects_count = len((await db.execute(
select(Project).where(Project.owner_id == current_user.id)
)).scalars().all())
tasks_count = len((await db.execute(
select(AuditTask).where(AuditTask.created_by == current_user.id)
)).scalars().all())
analyses_count = len((await db.execute(
select(InstantAnalysis).where(InstantAnalysis.user_id == current_user.id)
)).scalars().all())
total_records = projects_count + tasks_count + analyses_count
except Exception as e:
warnings.append(f"统计记录数时出错: {str(e)}")
# 3. 检查数据完整性
try:
# 检查孤立的任务(项目不存在)
tasks_result = await db.execute(
select(AuditTask).where(AuditTask.created_by == current_user.id)
)
tasks = tasks_result.scalars().all()
orphan_tasks = 0
for task in tasks:
project = await db.get(Project, task.project_id)
if not project:
orphan_tasks += 1
if orphan_tasks > 0:
warnings.append(f"发现 {orphan_tasks} 个孤立任务(关联的项目不存在)")
# 检查孤立的问题(任务不存在)
if tasks:
task_ids = [task.id for task in tasks]
issues_result = await db.execute(
select(AuditIssue).where(AuditIssue.task_id.in_(task_ids))
)
issues_list = issues_result.scalars().all()
orphan_issues = 0
for issue in issues_list:
task = await db.get(AuditTask, issue.task_id)
if not task:
orphan_issues += 1
if orphan_issues > 0:
warnings.append(f"发现 {orphan_issues} 个孤立问题(关联的任务不存在)")
except Exception as e:
warnings.append(f"数据完整性检查时出错: {str(e)}")
# 4. 确定健康状态
if not database_connected or issues:
status = "error"
elif warnings:
status = "warning"
else:
status = "healthy"
return DatabaseHealthResponse(
status=status,
database_connected=database_connected,
total_records=total_records,
last_backup_date=last_backup_date,
issues=issues,
warnings=warnings,
)
except Exception as e:
print(f"健康检查失败: {e}")
raise HTTPException(status_code=500, detail=f"健康检查失败: {str(e)}")

View File

@ -0,0 +1,210 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from pydantic import BaseModel
from datetime import datetime
from app.api import deps
from app.db.session import get_db
from app.models.project import Project, ProjectMember
from app.models.user import User
router = APIRouter()
# Schemas
class UserSchema(BaseModel):
id: str
email: Optional[str] = None
full_name: Optional[str] = None
avatar_url: Optional[str] = None
role: Optional[str] = None
class Config:
from_attributes = True
class ProjectMemberSchema(BaseModel):
id: str
project_id: str
user_id: str
role: str
permissions: Optional[str] = None
joined_at: datetime
created_at: datetime
user: Optional[UserSchema] = None
class Config:
from_attributes = True
class AddMemberRequest(BaseModel):
user_id: str
role: str = "member"
class UpdateMemberRequest(BaseModel):
role: Optional[str] = None
permissions: Optional[str] = None
@router.get("/{project_id}/members", response_model=List[ProjectMemberSchema])
async def get_project_members(
project_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Get all members of a project.
"""
# Verify project exists
project = await db.get(Project, project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
result = await db.execute(
select(ProjectMember)
.options(selectinload(ProjectMember.user))
.where(ProjectMember.project_id == project_id)
.order_by(ProjectMember.joined_at.desc())
)
return result.scalars().all()
@router.post("/{project_id}/members", response_model=ProjectMemberSchema)
async def add_project_member(
project_id: str,
member_in: AddMemberRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Add a member to a project.
"""
# Verify project exists
project = await db.get(Project, project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# Check if user is project owner or admin
if project.owner_id != current_user.id and not current_user.is_superuser:
raise HTTPException(status_code=403, detail="权限不足")
# Check if user exists
user = await db.get(User, member_in.user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# Check if already a member
existing = await db.execute(
select(ProjectMember)
.where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == member_in.user_id
)
)
if existing.scalars().first():
raise HTTPException(status_code=400, detail="用户已是项目成员")
# Create member
member = ProjectMember(
project_id=project_id,
user_id=member_in.user_id,
role=member_in.role,
permissions="{}"
)
db.add(member)
await db.commit()
await db.refresh(member)
# Reload with user relationship
result = await db.execute(
select(ProjectMember)
.options(selectinload(ProjectMember.user))
.where(ProjectMember.id == member.id)
)
return result.scalars().first()
@router.put("/{project_id}/members/{member_id}", response_model=ProjectMemberSchema)
async def update_project_member(
project_id: str,
member_id: str,
member_update: UpdateMemberRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Update a project member's role or permissions.
"""
# Verify project exists
project = await db.get(Project, project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# Check permissions
if project.owner_id != current_user.id and not current_user.is_superuser:
raise HTTPException(status_code=403, detail="权限不足")
# Get member
result = await db.execute(
select(ProjectMember)
.where(ProjectMember.id == member_id, ProjectMember.project_id == project_id)
)
member = result.scalars().first()
if not member:
raise HTTPException(status_code=404, detail="成员不存在")
# Update fields
if member_update.role:
member.role = member_update.role
if member_update.permissions:
member.permissions = member_update.permissions
await db.commit()
await db.refresh(member)
# Reload with user relationship
result = await db.execute(
select(ProjectMember)
.options(selectinload(ProjectMember.user))
.where(ProjectMember.id == member.id)
)
return result.scalars().first()
@router.delete("/{project_id}/members/{member_id}")
async def remove_project_member(
project_id: str,
member_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Remove a member from a project.
"""
# Verify project exists
project = await db.get(Project, project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# Check permissions
if project.owner_id != current_user.id and not current_user.is_superuser:
raise HTTPException(status_code=403, detail="权限不足")
# Get member
result = await db.execute(
select(ProjectMember)
.where(ProjectMember.id == member_id, ProjectMember.project_id == project_id)
)
member = result.scalars().first()
if not member:
raise HTTPException(status_code=404, detail="成员不存在")
await db.delete(member)
await db.commit()
return {"message": "成员已移除"}

View File

@ -0,0 +1,315 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from pydantic import BaseModel
from datetime import datetime
from app.api import deps
from app.db.session import get_db, AsyncSessionLocal
from app.models.project import Project
from app.models.user import User
from app.models.audit import AuditTask, AuditIssue
from app.models.user_config import UserConfig
from app.services.scanner import scan_repo_task
router = APIRouter()
# Schemas
class ProjectCreate(BaseModel):
name: str
repository_url: Optional[str] = None
repository_type: Optional[str] = "other"
description: Optional[str] = None
default_branch: Optional[str] = "main"
programming_languages: Optional[List[str]] = None
class ProjectUpdate(BaseModel):
name: Optional[str] = None
repository_url: Optional[str] = None
repository_type: Optional[str] = None
description: Optional[str] = None
default_branch: Optional[str] = None
programming_languages: Optional[List[str]] = None
class OwnerSchema(BaseModel):
id: str
email: Optional[str] = None
full_name: Optional[str] = None
avatar_url: Optional[str] = None
role: Optional[str] = None
class Config:
from_attributes = True
class ProjectResponse(BaseModel):
id: str
name: str
description: Optional[str] = None
repository_url: Optional[str] = None
repository_type: Optional[str] = None
default_branch: Optional[str] = None
programming_languages: Optional[str] = None
owner_id: str
is_active: bool
created_at: datetime
updated_at: Optional[datetime] = None
owner: Optional[OwnerSchema] = None
class Config:
from_attributes = True
class StatsResponse(BaseModel):
total_projects: int
active_projects: int
total_tasks: int
completed_tasks: int
total_issues: int
resolved_issues: int
@router.post("/", response_model=ProjectResponse)
async def create_project(
*,
db: AsyncSession = Depends(get_db),
project_in: ProjectCreate,
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Create new project.
"""
import json
project = Project(
name=project_in.name,
repository_url=project_in.repository_url,
repository_type=project_in.repository_type or "other",
description=project_in.description,
default_branch=project_in.default_branch or "main",
programming_languages=json.dumps(project_in.programming_languages or []),
owner_id=current_user.id
)
db.add(project)
await db.commit()
await db.refresh(project)
return project
@router.get("/", response_model=List[ProjectResponse])
async def read_projects(
db: AsyncSession = Depends(get_db),
skip: int = 0,
limit: int = 100,
include_deleted: bool = False,
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Retrieve projects.
"""
query = select(Project).options(selectinload(Project.owner))
if not include_deleted:
query = query.where(Project.is_active == True)
query = query.order_by(Project.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@router.get("/deleted", response_model=List[ProjectResponse])
async def read_deleted_projects(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Retrieve deleted (soft-deleted) projects for current user.
"""
result = await db.execute(
select(Project)
.options(selectinload(Project.owner))
.where(Project.owner_id == current_user.id)
.where(Project.is_active == False)
.order_by(Project.updated_at.desc())
)
return result.scalars().all()
@router.get("/stats", response_model=StatsResponse)
async def get_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Get overall statistics.
"""
projects_result = await db.execute(select(Project))
projects = projects_result.scalars().all()
tasks_result = await db.execute(select(AuditTask))
tasks = tasks_result.scalars().all()
issues_result = await db.execute(select(AuditIssue))
issues = issues_result.scalars().all()
return {
"total_projects": len(projects),
"active_projects": len([p for p in projects if p.is_active]),
"total_tasks": len(tasks),
"completed_tasks": len([t for t in tasks if t.status == "completed"]),
"total_issues": len(issues),
"resolved_issues": len([i for i in issues if i.status == "resolved"]),
}
@router.get("/{id}", response_model=ProjectResponse)
async def read_project(
id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Get project by ID.
"""
result = await db.execute(
select(Project)
.options(selectinload(Project.owner))
.where(Project.id == id)
)
project = result.scalars().first()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
return project
@router.put("/{id}", response_model=ProjectResponse)
async def update_project(
id: str,
*,
db: AsyncSession = Depends(get_db),
project_in: ProjectUpdate,
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Update project.
"""
import json
result = await db.execute(select(Project).where(Project.id == id))
project = result.scalars().first()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
update_data = project_in.model_dump(exclude_unset=True)
if "programming_languages" in update_data and update_data["programming_languages"] is not None:
update_data["programming_languages"] = json.dumps(update_data["programming_languages"])
for field, value in update_data.items():
setattr(project, field, value)
project.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(project)
return project
@router.delete("/{id}")
async def delete_project(
id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Soft delete project.
"""
result = await db.execute(select(Project).where(Project.id == id))
project = result.scalars().first()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查权限:只有项目所有者可以删除
if project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="无权删除此项目")
project.is_active = False
project.updated_at = datetime.utcnow()
await db.commit()
return {"message": "项目已删除"}
@router.post("/{id}/restore")
async def restore_project(
id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Restore soft-deleted project.
"""
result = await db.execute(select(Project).where(Project.id == id))
project = result.scalars().first()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查权限:只有项目所有者可以恢复
if project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="无权恢复此项目")
project.is_active = True
project.updated_at = datetime.utcnow()
await db.commit()
return {"message": "项目已恢复"}
@router.delete("/{id}/permanent")
async def permanently_delete_project(
id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Permanently delete project.
"""
result = await db.execute(select(Project).where(Project.id == id))
project = result.scalars().first()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查权限:只有项目所有者可以永久删除
if project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="无权永久删除此项目")
await db.delete(project)
await db.commit()
return {"message": "项目已永久删除"}
@router.post("/{id}/scan")
async def scan_project(
id: str,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Start a scan task.
"""
project = await db.get(Project, id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# Create Task Record
task = AuditTask(
project_id=project.id,
created_by=current_user.id,
task_type="repository",
status="pending"
)
db.add(task)
await db.commit()
await db.refresh(task)
# 获取用户配置
from sqlalchemy.future import select
import json
result = await db.execute(
select(UserConfig).where(UserConfig.user_id == current_user.id)
)
config = result.scalar_one_or_none()
user_config = {}
if config:
user_config = {
'llmConfig': json.loads(config.llm_config) if config.llm_config else {},
'otherConfig': json.loads(config.other_config) if config.other_config else {},
}
# Trigger Background Task
background_tasks.add_task(scan_repo_task, task.id, AsyncSessionLocal, user_config)
return {"task_id": task.id, "status": "started"}

View File

@ -0,0 +1,316 @@
from fastapi import APIRouter, UploadFile, File, Depends, BackgroundTasks, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from typing import Any, List, Optional
from pydantic import BaseModel
from datetime import datetime
import uuid
import shutil
import os
import json
from pathlib import Path
import zipfile
import asyncio
from app.api import deps
from app.db.session import get_db, AsyncSessionLocal
from app.models.audit import AuditTask, AuditIssue
from app.models.user import User
from app.models.project import Project
from app.models.analysis import InstantAnalysis
from app.models.user_config import UserConfig
from app.services.llm.service import LLMService
from app.services.scanner import task_control, is_text_file, should_exclude, get_language_from_path
from app.core.config import settings
router = APIRouter()
# 支持的文件扩展名
TEXT_EXTENSIONS = [
".js", ".ts", ".tsx", ".jsx", ".py", ".java", ".go", ".rs",
".cpp", ".c", ".h", ".cc", ".hh", ".cs", ".php", ".rb",
".kt", ".swift", ".sql", ".sh", ".json", ".yml", ".yaml"
]
async def process_zip_task(task_id: str, file_path: str, db_session_factory, user_config: dict = None):
"""后台ZIP文件处理任务"""
async with db_session_factory() as db:
task = await db.get(AuditTask, task_id)
if not task:
return
try:
task.status = "running"
task.started_at = datetime.utcnow()
await db.commit()
# 创建使用用户配置的LLM服务实例
llm_service = LLMService(user_config=user_config or {})
# Extract ZIP
extract_dir = Path(f"/tmp/{task_id}")
extract_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(file_path, 'r') as zip_ref:
zip_ref.extractall(extract_dir)
# Find files
files_to_scan = []
for root, dirs, files in os.walk(extract_dir):
# 排除常见非代码目录
dirs[:] = [d for d in dirs if d not in ['node_modules', '__pycache__', '.git', 'dist', 'build', 'vendor']]
for file in files:
full_path = Path(root) / file
rel_path = str(full_path.relative_to(extract_dir))
# 检查文件类型和排除规则
if is_text_file(rel_path) and not should_exclude(rel_path):
try:
content = full_path.read_text(errors='ignore')
if len(content) <= settings.MAX_FILE_SIZE_BYTES:
files_to_scan.append({
"path": rel_path,
"content": content
})
except:
pass
# 限制文件数量
files_to_scan = files_to_scan[:settings.MAX_ANALYZE_FILES]
task.total_files = len(files_to_scan)
await db.commit()
print(f"📊 ZIP任务 {task_id}: 找到 {len(files_to_scan)} 个文件")
total_issues = 0
total_lines = 0
quality_scores = []
scanned_files = 0
failed_files = 0
for file_info in files_to_scan:
# 检查是否取消
if task_control.is_cancelled(task_id):
print(f"🛑 ZIP任务 {task_id} 已被取消")
task.status = "cancelled"
task.completed_at = datetime.utcnow()
await db.commit()
task_control.cleanup_task(task_id)
return
try:
content = file_info['content']
total_lines += content.count('\n') + 1
language = get_language_from_path(file_info['path'])
result = await llm_service.analyze_code(content, language)
issues = result.get("issues", [])
for i in issues:
issue = AuditIssue(
task_id=task.id,
file_path=file_info['path'],
line_number=i.get('line', 1),
column_number=i.get('column'),
issue_type=i.get('type', 'maintainability'),
severity=i.get('severity', 'low'),
title=i.get('title', 'Issue'),
message=i.get('title', 'Issue'),
description=i.get('description'),
suggestion=i.get('suggestion'),
code_snippet=i.get('code_snippet'),
ai_explanation=json.dumps(i.get('xai')) if i.get('xai') else None,
status="open"
)
db.add(issue)
total_issues += 1
if "quality_score" in result:
quality_scores.append(result["quality_score"])
scanned_files += 1
task.scanned_files = scanned_files
task.total_lines = total_lines
task.issues_count = total_issues
await db.commit()
print(f"📈 ZIP任务 {task_id}: 进度 {scanned_files}/{len(files_to_scan)}")
# 请求间隔
await asyncio.sleep(settings.LLM_GAP_MS / 1000)
except Exception as file_error:
failed_files += 1
print(f"❌ ZIP任务分析文件失败 ({file_info['path']}): {file_error}")
await asyncio.sleep(settings.LLM_GAP_MS / 1000)
# 完成任务
task.status = "completed"
task.completed_at = datetime.utcnow()
task.scanned_files = scanned_files
task.total_lines = total_lines
task.issues_count = total_issues
task.quality_score = sum(quality_scores) / len(quality_scores) if quality_scores else 100.0
await db.commit()
print(f"✅ ZIP任务 {task_id} 完成: 扫描 {scanned_files} 个文件, 发现 {total_issues} 个问题")
task_control.cleanup_task(task_id)
except Exception as e:
print(f"❌ ZIP扫描失败: {e}")
task.status = "failed"
task.completed_at = datetime.utcnow()
await db.commit()
task_control.cleanup_task(task_id)
finally:
# Cleanup
if os.path.exists(file_path):
os.remove(file_path)
if extract_dir.exists():
shutil.rmtree(extract_dir)
@router.post("/upload-zip")
async def scan_zip(
project_id: str,
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Upload and scan a ZIP file.
"""
# Verify project exists
project = await db.get(Project, project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# Validate file
if not file.filename.lower().endswith('.zip'):
raise HTTPException(status_code=400, detail="请上传ZIP格式文件")
# Save Uploaded File
file_id = str(uuid.uuid4())
file_path = f"/tmp/{file_id}.zip"
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# Check file size
file_size = os.path.getsize(file_path)
if file_size > 100 * 1024 * 1024: # 100MB limit
os.remove(file_path)
raise HTTPException(status_code=400, detail="文件大小不能超过100MB")
# Create Task
task = AuditTask(
project_id=project_id,
created_by=current_user.id,
task_type="zip_upload",
status="pending",
scan_config="{}"
)
db.add(task)
await db.commit()
await db.refresh(task)
# 获取用户配置
user_config = await get_user_config_dict(db, current_user.id)
# Trigger Background Task
background_tasks.add_task(process_zip_task, task.id, file_path, AsyncSessionLocal, user_config)
return {"task_id": task.id, "status": "queued"}
class InstantAnalysisRequest(BaseModel):
code: str
language: str
class InstantAnalysisResponse(BaseModel):
id: str
user_id: str
language: str
issues_count: int
quality_score: float
analysis_time: float
created_at: datetime
class Config:
from_attributes = True
async def get_user_config_dict(db: AsyncSession, user_id: str) -> dict:
"""获取用户配置字典"""
result = await db.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
config = result.scalar_one_or_none()
if not config:
return {}
return {
'llmConfig': json.loads(config.llm_config) if config.llm_config else {},
'otherConfig': json.loads(config.other_config) if config.other_config else {},
}
@router.post("/instant")
async def instant_analysis(
req: InstantAnalysisRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Perform instant code analysis.
"""
# 获取用户配置
user_config = await get_user_config_dict(db, current_user.id)
# 创建使用用户配置的LLM服务实例
llm_service = LLMService(user_config=user_config)
start_time = datetime.utcnow()
result = await llm_service.analyze_code(req.code, req.language)
end_time = datetime.utcnow()
duration = (end_time - start_time).total_seconds()
# Save record
analysis = InstantAnalysis(
user_id=current_user.id,
language=req.language,
code_content="", # Do not persist code for privacy
analysis_result=json.dumps(result),
issues_count=len(result.get("issues", [])),
quality_score=result.get("quality_score", 0),
analysis_time=duration
)
db.add(analysis)
await db.commit()
await db.refresh(analysis)
# Return result directly to frontend
return result
@router.get("/instant/history", response_model=List[InstantAnalysisResponse])
async def get_instant_analysis_history(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
limit: int = 20,
) -> Any:
"""
Get user's instant analysis history.
"""
result = await db.execute(
select(InstantAnalysis)
.where(InstantAnalysis.user_id == current_user.id)
.order_by(InstantAnalysis.created_at.desc())
.limit(limit)
)
return result.scalars().all()

View File

@ -0,0 +1,200 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from pydantic import BaseModel
from datetime import datetime
from app.api import deps
from app.db.session import get_db
from app.models.audit import AuditTask, AuditIssue
from app.models.project import Project
from app.models.user import User
from app.services.scanner import task_control
router = APIRouter()
# Schemas
class AuditIssueSchema(BaseModel):
id: str
task_id: str
file_path: str
line_number: Optional[int] = None
column_number: Optional[int] = None
issue_type: str
severity: str
title: Optional[str] = None
message: Optional[str] = None
description: Optional[str] = None
suggestion: Optional[str] = None
code_snippet: Optional[str] = None
ai_explanation: Optional[str] = None
status: str
resolved_by: Optional[str] = None
resolved_at: Optional[datetime] = None
created_at: datetime
class Config:
from_attributes = True
class IssueUpdateSchema(BaseModel):
status: Optional[str] = None
class ProjectSchema(BaseModel):
id: str
name: str
description: Optional[str] = None
repository_url: Optional[str] = None
repository_type: Optional[str] = None
default_branch: Optional[str] = None
programming_languages: Optional[str] = None
owner_id: str
is_active: bool
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class AuditTaskSchema(BaseModel):
id: str
project_id: str
task_type: str
status: str
branch_name: Optional[str] = None
exclude_patterns: Optional[str] = None
scan_config: Optional[str] = None
total_files: int = 0
scanned_files: int = 0
total_lines: int = 0
issues_count: int = 0
quality_score: float = 0.0
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
created_by: str
created_at: datetime
project: Optional[ProjectSchema] = None
class Config:
from_attributes = True
@router.get("/", response_model=List[AuditTaskSchema])
async def list_tasks(
project_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
List all tasks, optionally filtered by project.
"""
query = select(AuditTask).options(selectinload(AuditTask.project))
if project_id:
query = query.where(AuditTask.project_id == project_id)
query = query.order_by(AuditTask.created_at.desc())
result = await db.execute(query)
return result.scalars().all()
@router.get("/{id}", response_model=AuditTaskSchema)
async def read_task(
id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Get task status by ID.
"""
result = await db.execute(
select(AuditTask)
.options(selectinload(AuditTask.project))
.where(AuditTask.id == id)
)
task = result.scalars().first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
return task
@router.post("/{id}/cancel")
async def cancel_task(
id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Cancel a running task.
"""
result = await db.execute(select(AuditTask).where(AuditTask.id == id))
task = result.scalars().first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if task.status not in ["pending", "running"]:
raise HTTPException(status_code=400, detail="只能取消待处理或运行中的任务")
# 标记任务为取消
task_control.cancel_task(id)
# 更新数据库状态
task.status = "cancelled"
task.completed_at = datetime.utcnow()
await db.commit()
return {"message": "任务已取消", "task_id": id}
@router.get("/{id}/issues", response_model=List[AuditIssueSchema])
async def read_task_issues(
id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Get issues for a specific task.
"""
result = await db.execute(
select(AuditIssue)
.where(AuditIssue.task_id == id)
.order_by(
# 按严重程度排序
AuditIssue.severity.desc(),
AuditIssue.created_at.desc()
)
)
return result.scalars().all()
@router.patch("/{task_id}/issues/{issue_id}", response_model=AuditIssueSchema)
async def update_issue(
task_id: str,
issue_id: str,
issue_update: IssueUpdateSchema,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Update issue status (e.g., resolve, mark as false positive).
"""
result = await db.execute(
select(AuditIssue)
.where(AuditIssue.id == issue_id, AuditIssue.task_id == task_id)
)
issue = result.scalars().first()
if not issue:
raise HTTPException(status_code=404, detail="问题不存在")
if issue_update.status:
issue.status = issue_update.status
if issue_update.status == "resolved":
issue.resolved_by = current_user.id
issue.resolved_at = datetime.utcnow()
await db.commit()
await db.refresh(issue)
return issue

View File

@ -0,0 +1,66 @@
from typing import Any, List
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.encoders import jsonable_encoder
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.api import deps
from app.core import security
from app.db.session import get_db
from app.models.user import User
from app.schemas.user import User as UserSchema, UserCreate, UserUpdate
router = APIRouter()
@router.get("/", response_model=List[UserSchema])
async def read_users(
db: AsyncSession = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Retrieve users.
"""
result = await db.execute(select(User).offset(skip).limit(limit))
users = result.scalars().all()
return users
@router.post("/", response_model=UserSchema)
async def create_user(
*,
db: AsyncSession = Depends(get_db),
user_in: UserCreate,
) -> Any:
"""
Create new user.
"""
result = await db.execute(select(User).where(User.email == user_in.email))
user = result.scalars().first()
if user:
raise HTTPException(
status_code=400,
detail="The user with this username already exists in the system.",
)
db_user = User(
email=user_in.email,
hashed_password=security.get_password_hash(user_in.password),
full_name=user_in.full_name,
is_active=user_in.is_active,
is_superuser=user_in.is_superuser,
)
db.add(db_user)
await db.commit()
await db.refresh(db_user)
return db_user
@router.get("/me", response_model=UserSchema)
async def read_user_me(
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
Get current user.
"""
return current_user

View File

View File

@ -0,0 +1,82 @@
from typing import List, Union, Optional
from pydantic import AnyHttpUrl, validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
PROJECT_NAME: str = "XCodeReviewer"
API_V1_STR: str = "/api/v1"
# SECURITY
SECRET_KEY: str = "changethis_in_production_to_a_long_random_string"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days
# CORS
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
@validator("BACKEND_CORS_ORIGINS", pre=True)
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
elif isinstance(v, (list, str)):
return v
raise ValueError(v)
# POSTGRES
POSTGRES_SERVER: str = "db"
POSTGRES_USER: str = "postgres"
POSTGRES_PASSWORD: str = "postgres"
POSTGRES_DB: str = "xcodereviewer"
DATABASE_URL: str | None = None
@validator("DATABASE_URL", pre=True)
def assemble_db_connection(cls, v: str | None, values: dict[str, any]) -> str:
if isinstance(v, str):
return v
return str(f"postgresql+asyncpg://{values.get('POSTGRES_USER')}:{values.get('POSTGRES_PASSWORD')}@{values.get('POSTGRES_SERVER')}/{values.get('POSTGRES_DB')}")
# LLM配置
LLM_PROVIDER: str = "openai" # gemini, openai, claude, qwen, deepseek, zhipu, moonshot, baidu, minimax, doubao, ollama
LLM_API_KEY: Optional[str] = None
LLM_MODEL: Optional[str] = None # 不指定时使用provider的默认模型
LLM_BASE_URL: Optional[str] = None # 自定义API端点如中转站
LLM_TIMEOUT: int = 150 # 超时时间(秒)
LLM_TEMPERATURE: float = 0.1
LLM_MAX_TOKENS: int = 4096
# 各LLM提供商的API Key配置兼容单独配置
OPENAI_API_KEY: Optional[str] = None
OPENAI_BASE_URL: Optional[str] = None
GEMINI_API_KEY: Optional[str] = None
CLAUDE_API_KEY: Optional[str] = None
QWEN_API_KEY: Optional[str] = None
DEEPSEEK_API_KEY: Optional[str] = None
ZHIPU_API_KEY: Optional[str] = None
MOONSHOT_API_KEY: Optional[str] = None
BAIDU_API_KEY: Optional[str] = None # 格式: api_key:secret_key
MINIMAX_API_KEY: Optional[str] = None
DOUBAO_API_KEY: Optional[str] = None
OLLAMA_BASE_URL: Optional[str] = "http://localhost:11434/v1"
# GitHub配置
GITHUB_TOKEN: Optional[str] = None
# GitLab配置
GITLAB_TOKEN: Optional[str] = None
# 扫描配置
MAX_ANALYZE_FILES: int = 50 # 最大分析文件数
MAX_FILE_SIZE_BYTES: int = 200 * 1024 # 最大文件大小 200KB
LLM_CONCURRENCY: int = 3 # LLM并发数
LLM_GAP_MS: int = 2000 # LLM请求间隔毫秒
# 输出语言配置 - 支持 zh-CN中文和 en-US英文
OUTPUT_LANGUAGE: str = "zh-CN"
class Config:
case_sensitive = True
env_file = ".env"
settings = Settings()

View File

@ -0,0 +1,29 @@
from datetime import datetime, timedelta
from typing import Any, Union
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = settings.ALGORITHM
def create_access_token(
subject: Union[str, Any], expires_delta: timedelta = None
) -> str:
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)

View File

12
backend/app/db/base.py Normal file
View File

@ -0,0 +1,12 @@
from sqlalchemy.orm import as_declarative, declared_attr
@as_declarative()
class Base:
id: str
__name__: str
# Generate __tablename__ automatically
@declared_attr
def __tablename__(cls) -> str:
return cls.__name__.lower() + "s"

17
backend/app/db/session.py Normal file
View File

@ -0,0 +1,17 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=True, future=True)
AsyncSessionLocal = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()

28
backend/app/main.py Normal file
View File

@ -0,0 +1,28 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.api.v1.api import api_router
app = FastAPI(
title=settings.PROJECT_NAME,
openapi_url=f"{settings.API_V1_STR}/openapi.json"
)
# Configure CORS - Allow all origins in development
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, replace with specific frontend URL
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=settings.API_V1_STR)
@app.get("/health")
async def health_check():
return {"status": "ok"}
@app.get("/")
async def root():
return {"message": "Welcome to XCodeReviewer API", "docs": "/docs"}

View File

@ -0,0 +1,5 @@
from .user import User
from .project import Project, ProjectMember
from .audit import AuditTask, AuditIssue
from .analysis import InstantAnalysis

View File

@ -0,0 +1,24 @@
import uuid
from sqlalchemy import Column, String, Integer, DateTime, Float, Text, ForeignKey
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.db.base import Base
class InstantAnalysis(Base):
__tablename__ = "instant_analyses"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String, ForeignKey("users.id"), nullable=True) # Can be anonymous? Logic says usually logged in, but localDB allowed check.
language = Column(String, nullable=False)
code_content = Column(Text, default="")
analysis_result = Column(Text, default="{}")
issues_count = Column(Integer, default=0)
quality_score = Column(Float, default=0.0)
analysis_time = Column(Float, default=0.0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
user = relationship("User", backref="instant_analyses")

View File

@ -0,0 +1,67 @@
import uuid
from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Text, Float
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.db.base import Base
class AuditTask(Base):
__tablename__ = "audit_tasks"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
project_id = Column(String, ForeignKey("projects.id"), nullable=False)
created_by = Column(String, ForeignKey("users.id"), nullable=False)
task_type = Column(String, nullable=False)
status = Column(String, default="pending", index=True)
branch_name = Column(String, nullable=True)
exclude_patterns = Column(Text, default="[]")
scan_config = Column(Text, default="{}")
# Stats
total_files = Column(Integer, default=0)
scanned_files = Column(Integer, default=0)
total_lines = Column(Integer, default=0)
issues_count = Column(Integer, default=0)
quality_score = Column(Float, default=0.0)
started_at = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
project = relationship("Project", back_populates="tasks")
creator = relationship("User", foreign_keys=[created_by])
issues = relationship("AuditIssue", back_populates="task", cascade="all, delete-orphan")
class AuditIssue(Base):
__tablename__ = "audit_issues"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
task_id = Column(String, ForeignKey("audit_tasks.id"), nullable=False)
file_path = Column(String, nullable=False)
line_number = Column(Integer, nullable=True)
column_number = Column(Integer, nullable=True)
issue_type = Column(String, nullable=False)
severity = Column(String, nullable=False) # critical, high, medium, low
# 问题信息
title = Column(String, nullable=True) # 问题标题
message = Column(Text, nullable=True) # 兼容旧字段同title
description = Column(Text, nullable=True) # 详细描述
suggestion = Column(Text, nullable=True) # 修复建议
code_snippet = Column(Text, nullable=True) # 问题代码片段
ai_explanation = Column(Text, nullable=True) # AI解释JSON格式的xai字段
status = Column(String, default="open") # open, resolved, false_positive
resolved_by = Column(String, ForeignKey("users.id"), nullable=True)
resolved_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
task = relationship("AuditTask", back_populates="issues")
resolver = relationship("User", foreign_keys=[resolved_by])

View File

@ -0,0 +1,44 @@
import uuid
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Text
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.db.base import Base
class Project(Base):
__tablename__ = "projects"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, index=True, nullable=False)
description = Column(Text, nullable=True)
repository_url = Column(String, nullable=True)
repository_type = Column(String, default="other")
default_branch = Column(String, default="main")
programming_languages = Column(Text, default="[]") # Stored as JSON string
owner_id = Column(String, ForeignKey("users.id"), nullable=False)
is_active = Column(Boolean(), default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
owner = relationship("User", backref="projects")
members = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan")
tasks = relationship("AuditTask", back_populates="project", cascade="all, delete-orphan")
class ProjectMember(Base):
__tablename__ = "project_members"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
project_id = Column(String, ForeignKey("projects.id"), nullable=False)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
role = Column(String, default="member")
permissions = Column(Text, default="{}")
joined_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
project = relationship("Project", back_populates="members")
user = relationship("User", backref="project_memberships")

View File

@ -0,0 +1,25 @@
import uuid
from sqlalchemy import Column, String, Boolean, DateTime
from sqlalchemy.sql import func
from app.db.base import Base
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
full_name = Column(String, index=True)
is_active = Column(Boolean(), default=True)
is_superuser = Column(Boolean(), default=False)
# Profile fields
phone = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
role = Column(String, default="member")
github_username = Column(String, nullable=True)
gitlab_username = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@ -0,0 +1,30 @@
"""
用户配置模型 - 存储用户的LLM和其他配置
"""
import uuid
from sqlalchemy import Column, String, Text, DateTime, ForeignKey
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.db.base import Base
class UserConfig(Base):
"""用户配置表"""
__tablename__ = "user_configs"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String, ForeignKey("users.id"), nullable=False, unique=True)
# LLM配置JSON格式存储
llm_config = Column(Text, default="{}")
# 其他配置JSON格式存储
other_config = Column(Text, default="{}")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
user = relationship("User", backref="config")

View File

View File

@ -0,0 +1,10 @@
from typing import Optional
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str
class TokenPayload(BaseModel):
sub: Optional[str] = None

View File

@ -0,0 +1,35 @@
from typing import Optional
from pydantic import BaseModel, EmailStr
class UserBase(BaseModel):
email: Optional[EmailStr] = None
is_active: Optional[bool] = True
is_superuser: bool = False
full_name: Optional[str] = None
# Profile fields
phone: Optional[str] = None
avatar_url: Optional[str] = None
role: str = "member"
github_username: Optional[str] = None
gitlab_username: Optional[str] = None
class UserCreate(UserBase):
email: EmailStr
password: str
full_name: str
class UserUpdate(UserBase):
password: Optional[str] = None
class UserInDBBase(UserBase):
id: str
created_at: Optional[object] = None # Datetime
updated_at: Optional[object] = None
class Config:
from_attributes = True
class User(UserInDBBase):
pass

View File

View File

@ -0,0 +1,30 @@
"""
LLM适配器模块
"""
from .openai_adapter import OpenAIAdapter
from .gemini_adapter import GeminiAdapter
from .claude_adapter import ClaudeAdapter
from .deepseek_adapter import DeepSeekAdapter
from .qwen_adapter import QwenAdapter
from .zhipu_adapter import ZhipuAdapter
from .moonshot_adapter import MoonshotAdapter
from .baidu_adapter import BaiduAdapter
from .minimax_adapter import MinimaxAdapter
from .doubao_adapter import DoubaoAdapter
from .ollama_adapter import OllamaAdapter
__all__ = [
'OpenAIAdapter',
'GeminiAdapter',
'ClaudeAdapter',
'DeepSeekAdapter',
'QwenAdapter',
'ZhipuAdapter',
'MoonshotAdapter',
'BaiduAdapter',
'MinimaxAdapter',
'DoubaoAdapter',
'OllamaAdapter',
]

View File

@ -0,0 +1,137 @@
"""
百度文心一言适配器
"""
import httpx
import json
from typing import Optional
from ..base_adapter import BaseLLMAdapter
from ..types import LLMConfig, LLMRequest, LLMResponse, LLMError
class BaiduAdapter(BaseLLMAdapter):
"""百度文心一言API适配器"""
# 模型名称到API端点的映射
MODEL_ENDPOINTS = {
"ERNIE-4.0": "completions_pro",
"ERNIE-3.5-8K": "completions",
"ERNIE-3.5-128K": "ernie-3.5-128k",
"ERNIE-Speed": "ernie_speed",
"ERNIE-Lite": "ernie-lite-8k",
}
def __init__(self, config: LLMConfig):
super().__init__(config)
self._access_token: Optional[str] = None
self._base_url = config.base_url or "https://aip.baidubce.com"
async def _get_access_token(self) -> str:
"""获取百度API的access_token
注意百度API使用API Key和Secret Key来获取access_token
这里假设api_key格式为: "api_key:secret_key"
"""
if self._access_token:
return self._access_token
# 解析API Key和Secret Key
if ":" not in self.config.api_key:
raise LLMError(
"百度API需要同时提供API Key和Secret Key格式api_key:secret_key",
provider="baidu"
)
api_key, secret_key = self.config.api_key.split(":", 1)
url = f"{self._base_url}/oauth/2.0/token"
params = {
"grant_type": "client_credentials",
"client_id": api_key,
"client_secret": secret_key,
}
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(url, params=params)
if response.status_code != 200:
raise LLMError(
f"获取百度access_token失败: {response.text}",
provider="baidu",
status_code=response.status_code
)
data = response.json()
self._access_token = data.get("access_token")
if not self._access_token:
raise LLMError(
f"百度API返回的access_token为空: {response.text}",
provider="baidu"
)
return self._access_token
async def _do_complete(self, request: LLMRequest) -> LLMResponse:
"""执行实际的API调用"""
access_token = await self._get_access_token()
# 获取模型对应的API端点
model = self.config.model or "ERNIE-3.5-8K"
endpoint = self.MODEL_ENDPOINTS.get(model, "completions")
url = f"{self._base_url}/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/{endpoint}?access_token={access_token}"
messages = [{"role": m.role, "content": m.content} for m in request.messages]
payload = {
"messages": messages,
"temperature": request.temperature or self.config.temperature,
"top_p": request.top_p or self.config.top_p,
}
if request.max_tokens or self.config.max_tokens:
payload["max_output_tokens"] = request.max_tokens or self.config.max_tokens
async with httpx.AsyncClient(timeout=self.config.timeout) as client:
response = await client.post(
url,
json=payload,
headers={"Content-Type": "application/json"}
)
if response.status_code != 200:
raise LLMError(
f"百度API错误: {response.text}",
provider="baidu",
status_code=response.status_code
)
data = response.json()
if "error_code" in data:
raise LLMError(
f"百度API错误: {data.get('error_msg', '未知错误')}",
provider="baidu",
status_code=data.get("error_code")
)
return LLMResponse(
content=data.get("result", ""),
model=model,
usage=data.get("usage"),
finish_reason=data.get("finish_reason")
)
async def validate_config(self) -> bool:
"""验证配置是否有效"""
try:
await self._get_access_token()
return True
except Exception:
return False
def get_provider(self) -> str:
return "baidu"
def get_model(self) -> str:
return self.config.model or "ERNIE-3.5-8K"

View File

@ -0,0 +1,93 @@
"""
Anthropic Claude适配器
"""
from typing import Dict, Any
from ..base_adapter import BaseLLMAdapter
from ..types import LLMRequest, LLMResponse, LLMUsage, DEFAULT_BASE_URLS, LLMProvider
class ClaudeAdapter(BaseLLMAdapter):
"""Claude适配器"""
@property
def base_url(self) -> str:
return self.config.base_url or DEFAULT_BASE_URLS.get(LLMProvider.CLAUDE, "https://api.anthropic.com/v1")
async def complete(self, request: LLMRequest) -> LLMResponse:
try:
await self.validate_config()
return await self.retry(lambda: self._send_request(request))
except Exception as error:
self.handle_error(error, "Claude API调用失败")
async def _send_request(self, request: LLMRequest) -> LLMResponse:
# Claude API需要将system消息分离
system_message = None
messages = []
for msg in request.messages:
if msg.role == "system":
system_message = msg.content
else:
messages.append({
"role": msg.role,
"content": msg.content
})
request_body: Dict[str, Any] = {
"model": self.config.model,
"messages": messages,
"max_tokens": request.max_tokens if request.max_tokens is not None else self.config.max_tokens or 4096,
"temperature": request.temperature if request.temperature is not None else self.config.temperature,
"top_p": request.top_p if request.top_p is not None else self.config.top_p,
}
if system_message:
request_body["system"] = system_message
# 构建请求头
headers = {
"x-api-key": self.config.api_key,
"anthropic-version": "2023-06-01",
}
url = f"{self.base_url.rstrip('/')}/messages"
response = await self.client.post(
url,
headers=self.build_headers(headers),
json=request_body
)
if response.status_code != 200:
error_data = response.json() if response.text else {}
error_msg = error_data.get("error", {}).get("message", f"HTTP {response.status_code}")
raise Exception(f"{error_msg}")
data = response.json()
if not data.get("content") or not data["content"][0]:
raise Exception("API响应格式异常: 缺少content字段")
usage = None
if "usage" in data:
usage = LLMUsage(
prompt_tokens=data["usage"].get("input_tokens", 0),
completion_tokens=data["usage"].get("output_tokens", 0),
total_tokens=data["usage"].get("input_tokens", 0) + data["usage"].get("output_tokens", 0)
)
return LLMResponse(
content=data["content"][0].get("text", ""),
model=data.get("model"),
usage=usage,
finish_reason=data.get("stop_reason")
)
async def validate_config(self) -> bool:
await super().validate_config()
if not self.config.model.startswith("claude-"):
raise Exception(f"无效的Claude模型: {self.config.model}")
return True

View File

@ -0,0 +1,81 @@
"""
DeepSeek适配器 - 兼容OpenAI格式
"""
from typing import Dict, Any
from ..base_adapter import BaseLLMAdapter
from ..types import LLMRequest, LLMResponse, LLMUsage, DEFAULT_BASE_URLS, LLMProvider
class DeepSeekAdapter(BaseLLMAdapter):
"""DeepSeek适配器"""
@property
def base_url(self) -> str:
return self.config.base_url or DEFAULT_BASE_URLS.get(LLMProvider.DEEPSEEK, "https://api.deepseek.com")
async def complete(self, request: LLMRequest) -> LLMResponse:
try:
await self.validate_config()
return await self.retry(lambda: self._send_request(request))
except Exception as error:
self.handle_error(error, "DeepSeek API调用失败")
async def _send_request(self, request: LLMRequest) -> LLMResponse:
# DeepSeek API兼容OpenAI格式
headers = {
"Authorization": f"Bearer {self.config.api_key}",
}
messages = [{"role": msg.role, "content": msg.content} for msg in request.messages]
request_body: Dict[str, Any] = {
"model": self.config.model,
"messages": messages,
"temperature": request.temperature if request.temperature is not None else self.config.temperature,
"max_tokens": request.max_tokens if request.max_tokens is not None else self.config.max_tokens,
"top_p": request.top_p if request.top_p is not None else self.config.top_p,
"frequency_penalty": self.config.frequency_penalty,
"presence_penalty": self.config.presence_penalty,
}
url = f"{self.base_url.rstrip('/')}/v1/chat/completions"
response = await self.client.post(
url,
headers=self.build_headers(headers),
json=request_body
)
if response.status_code != 200:
error_data = response.json() if response.text else {}
error_msg = error_data.get("error", {}).get("message", f"HTTP {response.status_code}")
raise Exception(f"{error_msg}")
data = response.json()
choice = data.get("choices", [{}])[0]
if not choice:
raise Exception("API响应格式异常: 缺少choices字段")
usage = None
if "usage" in data:
usage = LLMUsage(
prompt_tokens=data["usage"].get("prompt_tokens", 0),
completion_tokens=data["usage"].get("completion_tokens", 0),
total_tokens=data["usage"].get("total_tokens", 0)
)
return LLMResponse(
content=choice.get("message", {}).get("content", ""),
model=data.get("model"),
usage=usage,
finish_reason=choice.get("finish_reason")
)
async def validate_config(self) -> bool:
await super().validate_config()
if not self.config.model:
raise Exception("未指定DeepSeek模型")
return True

View File

@ -0,0 +1,87 @@
"""
字节跳动豆包适配器
"""
import httpx
from ..base_adapter import BaseLLMAdapter
from ..types import LLMConfig, LLMRequest, LLMResponse, LLMError
class DoubaoAdapter(BaseLLMAdapter):
"""字节跳动豆包API适配器
豆包使用OpenAI兼容的API格式
"""
def __init__(self, config: LLMConfig):
super().__init__(config)
self._base_url = config.base_url or "https://ark.cn-beijing.volces.com/api/v3"
async def _do_complete(self, request: LLMRequest) -> LLMResponse:
"""执行实际的API调用"""
url = f"{self._base_url}/chat/completions"
messages = [{"role": m.role, "content": m.content} for m in request.messages]
payload = {
"model": self.config.model or "doubao-pro-32k",
"messages": messages,
"temperature": request.temperature or self.config.temperature,
"top_p": request.top_p or self.config.top_p,
}
if request.max_tokens or self.config.max_tokens:
payload["max_tokens"] = request.max_tokens or self.config.max_tokens
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.config.api_key}",
}
async with httpx.AsyncClient(timeout=self.config.timeout) as client:
response = await client.post(url, json=payload, headers=headers)
if response.status_code != 200:
raise LLMError(
f"豆包API错误: {response.text}",
provider="doubao",
status_code=response.status_code
)
data = response.json()
if "error" in data:
raise LLMError(
f"豆包API错误: {data['error'].get('message', '未知错误')}",
provider="doubao"
)
choices = data.get("choices", [])
if not choices:
raise LLMError("豆包API返回空响应", provider="doubao")
return LLMResponse(
content=choices[0].get("message", {}).get("content", ""),
model=data.get("model", self.config.model or "doubao-pro-32k"),
usage=data.get("usage"),
finish_reason=choices[0].get("finish_reason")
)
async def validate_config(self) -> bool:
"""验证配置是否有效"""
try:
test_request = LLMRequest(
messages=[{"role": "user", "content": "Hi"}],
max_tokens=10
)
await self._do_complete(test_request)
return True
except Exception:
return False
def get_provider(self) -> str:
return "doubao"
def get_model(self) -> str:
return self.config.model or "doubao-pro-32k"

View File

@ -0,0 +1,114 @@
"""
Google Gemini适配器 - 支持官方API和中转站
"""
from typing import Dict, Any, List
from ..base_adapter import BaseLLMAdapter
from ..types import LLMRequest, LLMResponse, LLMUsage, DEFAULT_BASE_URLS, LLMProvider
class GeminiAdapter(BaseLLMAdapter):
"""Gemini适配器"""
@property
def base_url(self) -> str:
return self.config.base_url or DEFAULT_BASE_URLS.get(LLMProvider.GEMINI, "https://generativelanguage.googleapis.com/v1beta")
async def complete(self, request: LLMRequest) -> LLMResponse:
try:
await self.validate_config()
return await self.retry(lambda: self._generate_content(request))
except Exception as error:
self.handle_error(error, "Gemini API调用失败")
async def _generate_content(self, request: LLMRequest) -> LLMResponse:
# 转换消息格式为 Gemini 格式
contents: List[Dict[str, Any]] = []
system_content = ""
for msg in request.messages:
if msg.role == "system":
system_content = msg.content
else:
role = "model" if msg.role == "assistant" else "user"
contents.append({
"role": role,
"parts": [{"text": msg.content}]
})
# 将系统消息合并到第一条用户消息
if system_content and contents:
contents[0]["parts"][0]["text"] = f"{system_content}\n\n{contents[0]['parts'][0]['text']}"
# 构建请求体
request_body = {
"contents": contents,
"generationConfig": {
"temperature": request.temperature if request.temperature is not None else self.config.temperature,
"maxOutputTokens": request.max_tokens if request.max_tokens is not None else self.config.max_tokens,
"topP": request.top_p if request.top_p is not None else self.config.top_p,
}
}
# API Key 在 URL 参数中
url = f"{self.base_url}/models/{self.config.model}:generateContent?key={self.config.api_key}"
response = await self.client.post(
url,
headers=self.build_headers(),
json=request_body
)
if response.status_code != 200:
error_data = response.json() if response.text else {}
error_msg = error_data.get("error", {}).get("message", f"HTTP {response.status_code}")
raise Exception(f"{error_msg}")
data = response.json()
# 解析 Gemini 响应格式
candidates = data.get("candidates", [])
if not candidates:
# 检查是否有错误信息
if "error" in data:
error_msg = data["error"].get("message", "未知错误")
raise Exception(f"Gemini API错误: {error_msg}")
raise Exception("API响应格式异常: 缺少candidates字段")
candidate = candidates[0]
if not candidate or "content" not in candidate:
raise Exception("API响应格式异常: 缺少content字段")
text_parts = candidate.get("content", {}).get("parts", [])
if not text_parts:
raise Exception("API响应格式异常: content.parts为空")
text = "".join(part.get("text", "") for part in text_parts)
# 检查响应内容是否为空
if not text or not text.strip():
finish_reason = candidate.get("finishReason", "unknown")
raise Exception(f"Gemini返回空响应 - Finish Reason: {finish_reason}")
usage = None
if "usageMetadata" in data:
usage_data = data["usageMetadata"]
usage = LLMUsage(
prompt_tokens=usage_data.get("promptTokenCount", 0),
completion_tokens=usage_data.get("candidatesTokenCount", 0),
total_tokens=usage_data.get("totalTokenCount", 0)
)
return LLMResponse(
content=text,
model=self.config.model,
usage=usage,
finish_reason=candidate.get("finishReason", "stop")
)
async def validate_config(self) -> bool:
await super().validate_config()
if not self.config.model.startswith("gemini-"):
raise Exception(f"无效的Gemini模型: {self.config.model}")
return True

View File

@ -0,0 +1,84 @@
"""
MiniMax适配器
"""
import httpx
from ..base_adapter import BaseLLMAdapter
from ..types import LLMConfig, LLMRequest, LLMResponse, LLMError
class MinimaxAdapter(BaseLLMAdapter):
"""MiniMax API适配器"""
def __init__(self, config: LLMConfig):
super().__init__(config)
self._base_url = config.base_url or "https://api.minimax.chat/v1"
async def _do_complete(self, request: LLMRequest) -> LLMResponse:
"""执行实际的API调用"""
url = f"{self._base_url}/text/chatcompletion_v2"
messages = [{"role": m.role, "content": m.content} for m in request.messages]
payload = {
"model": self.config.model or "abab6.5-chat",
"messages": messages,
"temperature": request.temperature or self.config.temperature,
"top_p": request.top_p or self.config.top_p,
}
if request.max_tokens or self.config.max_tokens:
payload["max_tokens"] = request.max_tokens or self.config.max_tokens
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.config.api_key}",
}
async with httpx.AsyncClient(timeout=self.config.timeout) as client:
response = await client.post(url, json=payload, headers=headers)
if response.status_code != 200:
raise LLMError(
f"MiniMax API错误: {response.text}",
provider="minimax",
status_code=response.status_code
)
data = response.json()
if data.get("base_resp", {}).get("status_code") != 0:
raise LLMError(
f"MiniMax API错误: {data.get('base_resp', {}).get('status_msg', '未知错误')}",
provider="minimax"
)
choices = data.get("choices", [])
if not choices:
raise LLMError("MiniMax API返回空响应", provider="minimax")
return LLMResponse(
content=choices[0].get("message", {}).get("content", ""),
model=self.config.model or "abab6.5-chat",
usage=data.get("usage"),
finish_reason=choices[0].get("finish_reason")
)
async def validate_config(self) -> bool:
"""验证配置是否有效"""
try:
test_request = LLMRequest(
messages=[{"role": "user", "content": "Hi"}],
max_tokens=10
)
await self._do_complete(test_request)
return True
except Exception:
return False
def get_provider(self) -> str:
return "minimax"
def get_model(self) -> str:
return self.config.model or "abab6.5-chat"

View File

@ -0,0 +1,79 @@
"""
月之暗面 Kimi适配器 - 兼容OpenAI格式
"""
from typing import Dict, Any
from ..base_adapter import BaseLLMAdapter
from ..types import LLMRequest, LLMResponse, LLMUsage, DEFAULT_BASE_URLS, LLMProvider
class MoonshotAdapter(BaseLLMAdapter):
"""月之暗面Kimi适配器"""
@property
def base_url(self) -> str:
return self.config.base_url or DEFAULT_BASE_URLS.get(LLMProvider.MOONSHOT, "https://api.moonshot.cn/v1")
async def complete(self, request: LLMRequest) -> LLMResponse:
try:
await self.validate_config()
return await self.retry(lambda: self._send_request(request))
except Exception as error:
self.handle_error(error, "Moonshot API调用失败")
async def _send_request(self, request: LLMRequest) -> LLMResponse:
# Moonshot API兼容OpenAI格式
headers = {
"Authorization": f"Bearer {self.config.api_key}",
}
messages = [{"role": msg.role, "content": msg.content} for msg in request.messages]
request_body: Dict[str, Any] = {
"model": self.config.model,
"messages": messages,
"temperature": request.temperature if request.temperature is not None else self.config.temperature,
"max_tokens": request.max_tokens if request.max_tokens is not None else self.config.max_tokens,
"top_p": request.top_p if request.top_p is not None else self.config.top_p,
}
url = f"{self.base_url.rstrip('/')}/chat/completions"
response = await self.client.post(
url,
headers=self.build_headers(headers),
json=request_body
)
if response.status_code != 200:
error_data = response.json() if response.text else {}
error_msg = error_data.get("error", {}).get("message", f"HTTP {response.status_code}")
raise Exception(f"{error_msg}")
data = response.json()
choice = data.get("choices", [{}])[0]
if not choice:
raise Exception("API响应格式异常: 缺少choices字段")
usage = None
if "usage" in data:
usage = LLMUsage(
prompt_tokens=data["usage"].get("prompt_tokens", 0),
completion_tokens=data["usage"].get("completion_tokens", 0),
total_tokens=data["usage"].get("total_tokens", 0)
)
return LLMResponse(
content=choice.get("message", {}).get("content", ""),
model=data.get("model"),
usage=usage,
finish_reason=choice.get("finish_reason")
)
async def validate_config(self) -> bool:
await super().validate_config()
if not self.config.model:
raise Exception("未指定Moonshot模型")
return True

View File

@ -0,0 +1,82 @@
"""
Ollama本地大模型适配器 - 兼容OpenAI格式
"""
from typing import Dict, Any
from ..base_adapter import BaseLLMAdapter
from ..types import LLMRequest, LLMResponse, LLMUsage, DEFAULT_BASE_URLS, LLMProvider
class OllamaAdapter(BaseLLMAdapter):
"""Ollama本地模型适配器"""
@property
def base_url(self) -> str:
return self.config.base_url or DEFAULT_BASE_URLS.get(LLMProvider.OLLAMA, "http://localhost:11434/v1")
async def complete(self, request: LLMRequest) -> LLMResponse:
try:
# Ollama本地运行跳过API Key验证
return await self.retry(lambda: self._send_request(request))
except Exception as error:
self.handle_error(error, "Ollama API调用失败")
async def _send_request(self, request: LLMRequest) -> LLMResponse:
# Ollama兼容OpenAI格式
headers = {}
if self.config.api_key:
headers["Authorization"] = f"Bearer {self.config.api_key}"
messages = [{"role": msg.role, "content": msg.content} for msg in request.messages]
request_body: Dict[str, Any] = {
"model": self.config.model,
"messages": messages,
"temperature": request.temperature if request.temperature is not None else self.config.temperature,
"top_p": request.top_p if request.top_p is not None else self.config.top_p,
}
# Ollama的max_tokens参数名可能不同
if request.max_tokens or self.config.max_tokens:
request_body["num_predict"] = request.max_tokens or self.config.max_tokens
url = f"{self.base_url.rstrip('/')}/chat/completions"
response = await self.client.post(
url,
headers=self.build_headers(headers) if headers else self.build_headers(),
json=request_body
)
if response.status_code != 200:
error_data = response.json() if response.text else {}
error_msg = error_data.get("error", {}).get("message", f"HTTP {response.status_code}")
raise Exception(f"{error_msg}")
data = response.json()
choice = data.get("choices", [{}])[0]
if not choice:
raise Exception("API响应格式异常: 缺少choices字段")
usage = None
if "usage" in data:
usage = LLMUsage(
prompt_tokens=data["usage"].get("prompt_tokens", 0),
completion_tokens=data["usage"].get("completion_tokens", 0),
total_tokens=data["usage"].get("total_tokens", 0)
)
return LLMResponse(
content=choice.get("message", {}).get("content", ""),
model=data.get("model"),
usage=usage,
finish_reason=choice.get("finish_reason")
)
async def validate_config(self) -> bool:
# Ollama本地运行不需要API Key
if not self.config.model:
raise Exception("未指定Ollama模型")
return True

View File

@ -0,0 +1,92 @@
"""
OpenAI适配器 (支持GPT系列和OpenAI兼容API)
"""
from typing import Dict, Any
from ..base_adapter import BaseLLMAdapter
from ..types import LLMRequest, LLMResponse, LLMUsage, DEFAULT_BASE_URLS, LLMProvider
class OpenAIAdapter(BaseLLMAdapter):
"""OpenAI适配器"""
@property
def base_url(self) -> str:
return self.config.base_url or DEFAULT_BASE_URLS.get(LLMProvider.OPENAI, "https://api.openai.com/v1")
async def complete(self, request: LLMRequest) -> LLMResponse:
try:
await self.validate_config()
return await self.retry(lambda: self._send_request(request))
except Exception as error:
self.handle_error(error, "OpenAI API调用失败")
async def _send_request(self, request: LLMRequest) -> LLMResponse:
# 构建请求头
headers = {
"Authorization": f"Bearer {self.config.api_key}",
}
# 检测是否为推理模型o1/o3系列
model_name = self.config.model.lower()
is_reasoning_model = "o1" in model_name or "o3" in model_name
# 构建请求体
messages = [{"role": msg.role, "content": msg.content} for msg in request.messages]
request_body: Dict[str, Any] = {
"model": self.config.model,
"messages": messages,
"temperature": request.temperature if request.temperature is not None else self.config.temperature,
"top_p": request.top_p if request.top_p is not None else self.config.top_p,
"frequency_penalty": self.config.frequency_penalty,
"presence_penalty": self.config.presence_penalty,
}
# 推理模型使用max_completion_tokens其他模型使用max_tokens
max_tokens = request.max_tokens if request.max_tokens is not None else self.config.max_tokens
if is_reasoning_model:
request_body["max_completion_tokens"] = max_tokens
else:
request_body["max_tokens"] = max_tokens
url = f"{self.base_url.rstrip('/')}/chat/completions"
response = await self.client.post(
url,
headers=self.build_headers(headers),
json=request_body
)
if response.status_code != 200:
error_data = response.json() if response.text else {}
error_msg = error_data.get("error", {}).get("message", f"HTTP {response.status_code}")
raise Exception(f"{error_msg}")
data = response.json()
choice = data.get("choices", [{}])[0]
if not choice:
raise Exception("API响应格式异常: 缺少choices字段")
usage = None
if "usage" in data:
usage = LLMUsage(
prompt_tokens=data["usage"].get("prompt_tokens", 0),
completion_tokens=data["usage"].get("completion_tokens", 0),
total_tokens=data["usage"].get("total_tokens", 0)
)
return LLMResponse(
content=choice.get("message", {}).get("content", ""),
model=data.get("model"),
usage=usage,
finish_reason=choice.get("finish_reason")
)
async def validate_config(self) -> bool:
await super().validate_config()
if not self.config.model:
raise Exception("未指定OpenAI模型")
return True

View File

@ -0,0 +1,79 @@
"""
阿里云通义千问适配器 - 兼容OpenAI格式
"""
from typing import Dict, Any
from ..base_adapter import BaseLLMAdapter
from ..types import LLMRequest, LLMResponse, LLMUsage, DEFAULT_BASE_URLS, LLMProvider
class QwenAdapter(BaseLLMAdapter):
"""通义千问适配器"""
@property
def base_url(self) -> str:
return self.config.base_url or DEFAULT_BASE_URLS.get(LLMProvider.QWEN, "https://dashscope.aliyuncs.com/compatible-mode/v1")
async def complete(self, request: LLMRequest) -> LLMResponse:
try:
await self.validate_config()
return await self.retry(lambda: self._send_request(request))
except Exception as error:
self.handle_error(error, "通义千问 API调用失败")
async def _send_request(self, request: LLMRequest) -> LLMResponse:
# 通义千问兼容OpenAI格式
headers = {
"Authorization": f"Bearer {self.config.api_key}",
}
messages = [{"role": msg.role, "content": msg.content} for msg in request.messages]
request_body: Dict[str, Any] = {
"model": self.config.model,
"messages": messages,
"temperature": request.temperature if request.temperature is not None else self.config.temperature,
"max_tokens": request.max_tokens if request.max_tokens is not None else self.config.max_tokens,
"top_p": request.top_p if request.top_p is not None else self.config.top_p,
}
url = f"{self.base_url.rstrip('/')}/chat/completions"
response = await self.client.post(
url,
headers=self.build_headers(headers),
json=request_body
)
if response.status_code != 200:
error_data = response.json() if response.text else {}
error_msg = error_data.get("error", {}).get("message", f"HTTP {response.status_code}")
raise Exception(f"{error_msg}")
data = response.json()
choice = data.get("choices", [{}])[0]
if not choice:
raise Exception("API响应格式异常: 缺少choices字段")
usage = None
if "usage" in data:
usage = LLMUsage(
prompt_tokens=data["usage"].get("prompt_tokens", 0),
completion_tokens=data["usage"].get("completion_tokens", 0),
total_tokens=data["usage"].get("total_tokens", 0)
)
return LLMResponse(
content=choice.get("message", {}).get("content", ""),
model=data.get("model"),
usage=usage,
finish_reason=choice.get("finish_reason")
)
async def validate_config(self) -> bool:
await super().validate_config()
if not self.config.model:
raise Exception("未指定通义千问模型")
return True

View File

@ -0,0 +1,79 @@
"""
智谱AI适配器 (GLM系列) - 兼容OpenAI格式
"""
from typing import Dict, Any
from ..base_adapter import BaseLLMAdapter
from ..types import LLMRequest, LLMResponse, LLMUsage, DEFAULT_BASE_URLS, LLMProvider
class ZhipuAdapter(BaseLLMAdapter):
"""智谱AI适配器"""
@property
def base_url(self) -> str:
return self.config.base_url or DEFAULT_BASE_URLS.get(LLMProvider.ZHIPU, "https://open.bigmodel.cn/api/paas/v4")
async def complete(self, request: LLMRequest) -> LLMResponse:
try:
await self.validate_config()
return await self.retry(lambda: self._send_request(request))
except Exception as error:
self.handle_error(error, "智谱AI API调用失败")
async def _send_request(self, request: LLMRequest) -> LLMResponse:
# 智谱AI兼容OpenAI格式
headers = {
"Authorization": f"Bearer {self.config.api_key}",
}
messages = [{"role": msg.role, "content": msg.content} for msg in request.messages]
request_body: Dict[str, Any] = {
"model": self.config.model,
"messages": messages,
"temperature": request.temperature if request.temperature is not None else self.config.temperature,
"max_tokens": request.max_tokens if request.max_tokens is not None else self.config.max_tokens,
"top_p": request.top_p if request.top_p is not None else self.config.top_p,
}
url = f"{self.base_url.rstrip('/')}/chat/completions"
response = await self.client.post(
url,
headers=self.build_headers(headers),
json=request_body
)
if response.status_code != 200:
error_data = response.json() if response.text else {}
error_msg = error_data.get("error", {}).get("message", f"HTTP {response.status_code}")
raise Exception(f"{error_msg}")
data = response.json()
choice = data.get("choices", [{}])[0]
if not choice:
raise Exception("API响应格式异常: 缺少choices字段")
usage = None
if "usage" in data:
usage = LLMUsage(
prompt_tokens=data["usage"].get("prompt_tokens", 0),
completion_tokens=data["usage"].get("completion_tokens", 0),
total_tokens=data["usage"].get("total_tokens", 0)
)
return LLMResponse(
content=choice.get("message", {}).get("content", ""),
model=data.get("model"),
usage=usage,
finish_reason=choice.get("finish_reason")
)
async def validate_config(self) -> bool:
await super().validate_config()
if not self.config.model:
raise Exception("未指定智谱AI模型")
return True

View File

@ -0,0 +1,134 @@
"""
LLM适配器基类
"""
import asyncio
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
import httpx
from .types import LLMConfig, LLMRequest, LLMResponse, LLMProvider, LLMError
class BaseLLMAdapter(ABC):
"""LLM适配器基类"""
def __init__(self, config: LLMConfig):
self.config = config
self._client: Optional[httpx.AsyncClient] = None
@property
def client(self) -> httpx.AsyncClient:
"""获取HTTP客户端"""
if self._client is None:
self._client = httpx.AsyncClient(timeout=self.config.timeout)
return self._client
@abstractmethod
async def complete(self, request: LLMRequest) -> LLMResponse:
"""发送请求并获取响应"""
pass
def get_provider(self) -> LLMProvider:
"""获取提供商名称"""
return self.config.provider
def get_model(self) -> str:
"""获取模型名称"""
return self.config.model
async def validate_config(self) -> bool:
"""验证配置是否有效"""
if not self.config.api_key:
raise LLMError(
"API Key未配置",
self.config.provider
)
return True
async def with_timeout(self, coro, timeout_seconds: Optional[int] = None) -> Any:
"""处理超时"""
timeout = timeout_seconds or self.config.timeout
try:
return await asyncio.wait_for(coro, timeout=timeout)
except asyncio.TimeoutError:
raise LLMError(
f"请求超时 ({timeout}s)",
self.config.provider
)
def handle_error(self, error: Any, context: str = "") -> None:
"""处理API错误"""
message = str(error)
status_code = getattr(error, 'status_code', None)
# 针对不同错误类型提供更详细的信息
if "超时" in message or "timeout" in message.lower():
message = f"请求超时 ({self.config.timeout}s)。建议:\n" \
f"1. 检查网络连接是否正常\n" \
f"2. 尝试增加超时时间\n" \
f"3. 验证API端点是否正确"
elif status_code == 401 or status_code == 403:
message = f"API认证失败。建议\n" \
f"1. 检查API Key是否正确配置\n" \
f"2. 确认API Key是否有效且未过期\n" \
f"3. 验证API Key权限是否充足"
elif status_code == 429:
message = f"API调用频率超限。建议\n" \
f"1. 等待一段时间后重试\n" \
f"2. 降低并发数\n" \
f"3. 增加请求间隔"
elif status_code and status_code >= 500:
message = f"API服务异常 ({status_code})。建议:\n" \
f"1. 稍后重试\n" \
f"2. 检查服务商状态页面\n" \
f"3. 尝试切换其他LLM提供商"
full_message = f"{context}: {message}" if context else message
raise LLMError(
full_message,
self.config.provider,
status_code,
error
)
async def retry(self, fn, max_attempts: int = 3, delay: float = 1.0) -> Any:
"""重试逻辑"""
last_error = None
for attempt in range(max_attempts):
try:
return await fn()
except Exception as error:
last_error = error
status_code = getattr(error, 'status_code', None)
# 如果是4xx错误客户端错误不重试
if status_code and 400 <= status_code < 500:
raise error
# 最后一次尝试时不等待
if attempt < max_attempts - 1:
# 指数退避
await asyncio.sleep(delay * (2 ** attempt))
raise last_error
def build_headers(self, additional_headers: Dict[str, str] = None) -> Dict[str, str]:
"""构建请求头"""
headers = {
"Content-Type": "application/json",
}
if additional_headers:
headers.update(additional_headers)
if self.config.custom_headers:
headers.update(self.config.custom_headers)
return headers
async def close(self):
"""关闭客户端"""
if self._client:
await self._client.aclose()
self._client = None

View File

@ -0,0 +1,165 @@
"""
LLM工厂类 - 统一创建和管理LLM适配器
"""
from typing import Dict, List, Optional
from .types import LLMConfig, LLMProvider, DEFAULT_MODELS
from .base_adapter import BaseLLMAdapter
from .adapters import (
OpenAIAdapter,
GeminiAdapter,
ClaudeAdapter,
DeepSeekAdapter,
QwenAdapter,
ZhipuAdapter,
MoonshotAdapter,
BaiduAdapter,
MinimaxAdapter,
DoubaoAdapter,
OllamaAdapter,
)
class LLMFactory:
"""LLM工厂类"""
_adapters: Dict[str, BaseLLMAdapter] = {}
@classmethod
def create_adapter(cls, config: LLMConfig) -> BaseLLMAdapter:
"""创建LLM适配器实例"""
cache_key = cls._get_cache_key(config)
# 从缓存中获取
if cache_key in cls._adapters:
return cls._adapters[cache_key]
# 创建新的适配器实例
adapter = cls._instantiate_adapter(config)
# 缓存实例
cls._adapters[cache_key] = adapter
return adapter
@classmethod
def _instantiate_adapter(cls, config: LLMConfig) -> BaseLLMAdapter:
"""根据提供商类型实例化适配器"""
# 如果未指定模型,使用默认模型
if not config.model:
config.model = DEFAULT_MODELS.get(config.provider, "gpt-4o-mini")
adapter_map = {
LLMProvider.OPENAI: OpenAIAdapter,
LLMProvider.GEMINI: GeminiAdapter,
LLMProvider.CLAUDE: ClaudeAdapter,
LLMProvider.DEEPSEEK: DeepSeekAdapter,
LLMProvider.QWEN: QwenAdapter,
LLMProvider.ZHIPU: ZhipuAdapter,
LLMProvider.MOONSHOT: MoonshotAdapter,
LLMProvider.BAIDU: BaiduAdapter,
LLMProvider.MINIMAX: MinimaxAdapter,
LLMProvider.DOUBAO: DoubaoAdapter,
LLMProvider.OLLAMA: OllamaAdapter,
}
adapter_class = adapter_map.get(config.provider)
if not adapter_class:
raise ValueError(f"不支持的LLM提供商: {config.provider}")
return adapter_class(config)
@classmethod
def _get_cache_key(cls, config: LLMConfig) -> str:
"""生成缓存键"""
api_key_prefix = config.api_key[:8] if config.api_key else "no-key"
return f"{config.provider.value}:{config.model}:{api_key_prefix}"
@classmethod
def clear_cache(cls) -> None:
"""清除缓存"""
cls._adapters.clear()
@classmethod
def get_supported_providers(cls) -> List[LLMProvider]:
"""获取支持的提供商列表"""
return list(LLMProvider)
@classmethod
def get_default_model(cls, provider: LLMProvider) -> str:
"""获取提供商的默认模型"""
return DEFAULT_MODELS.get(provider, "gpt-4o-mini")
@classmethod
def get_available_models(cls, provider: LLMProvider) -> List[str]:
"""获取提供商的可用模型列表"""
models = {
LLMProvider.GEMINI: [
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-1.5-flash",
"gemini-1.5-pro",
],
LLMProvider.OPENAI: [
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-4",
"gpt-3.5-turbo",
"o1-preview",
"o1-mini",
],
LLMProvider.CLAUDE: [
"claude-3-5-sonnet-20241022",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
],
LLMProvider.QWEN: [
"qwen-turbo",
"qwen-plus",
"qwen-max",
"qwen-max-longcontext",
],
LLMProvider.DEEPSEEK: [
"deepseek-chat",
"deepseek-coder",
],
LLMProvider.ZHIPU: [
"glm-4-flash",
"glm-4",
"glm-4-plus",
"glm-4-long",
],
LLMProvider.MOONSHOT: [
"moonshot-v1-8k",
"moonshot-v1-32k",
"moonshot-v1-128k",
],
LLMProvider.BAIDU: [
"ERNIE-3.5-8K",
"ERNIE-4.0-8K",
"ERNIE-Speed-8K",
],
LLMProvider.MINIMAX: [
"abab6.5-chat",
"abab6.5s-chat",
"abab5.5-chat",
],
LLMProvider.DOUBAO: [
"doubao-pro-32k",
"doubao-pro-128k",
"doubao-lite-32k",
],
LLMProvider.OLLAMA: [
"llama3",
"llama3.1",
"llama3.2",
"codellama",
"mistral",
"deepseek-coder-v2",
"qwen2.5-coder",
],
}
return models.get(provider, [])

View File

@ -0,0 +1,586 @@
"""
LLM服务 - 代码分析核心服务
支持中英文双语输出
"""
import json
import re
import logging
from typing import Dict, Any, Optional
from .types import LLMConfig, LLMProvider, LLMMessage, LLMRequest, DEFAULT_MODELS
from .factory import LLMFactory
from app.core.config import settings
logger = logging.getLogger(__name__)
class LLMService:
"""LLM服务类"""
def __init__(self, user_config: Optional[Dict[str, Any]] = None):
"""
初始化LLM服务
Args:
user_config: 用户配置字典包含llmConfig字段
"""
self._config: Optional[LLMConfig] = None
self._user_config = user_config or {}
@property
def config(self) -> LLMConfig:
"""获取LLM配置优先使用用户配置然后使用系统配置"""
if self._config is None:
user_llm_config = self._user_config.get('llmConfig', {})
# 优先使用用户配置的provider否则使用系统配置
provider_str = user_llm_config.get('llmProvider') or getattr(settings, 'LLM_PROVIDER', 'openai')
provider = self._parse_provider(provider_str)
# 获取API Key - 优先级:用户配置 > 系统通用配置 > 系统平台专属配置
api_key = (
user_llm_config.get('llmApiKey') or
getattr(settings, 'LLM_API_KEY', '') or
self._get_provider_api_key_from_user_config(provider, user_llm_config) or
self._get_provider_api_key(provider)
)
# 获取Base URL
base_url = (
user_llm_config.get('llmBaseUrl') or
getattr(settings, 'LLM_BASE_URL', None) or
self._get_provider_base_url(provider)
)
# 获取模型
model = (
user_llm_config.get('llmModel') or
getattr(settings, 'LLM_MODEL', '') or
DEFAULT_MODELS.get(provider, 'gpt-4o-mini')
)
# 获取超时时间(用户配置是毫秒,系统配置是秒)
timeout_ms = user_llm_config.get('llmTimeout')
if timeout_ms:
# 用户配置是毫秒,转换为秒
timeout = int(timeout_ms / 1000) if timeout_ms > 1000 else int(timeout_ms)
else:
# 系统配置是秒
timeout = int(getattr(settings, 'LLM_TIMEOUT', 150))
# 获取温度
temperature = user_llm_config.get('llmTemperature') if user_llm_config.get('llmTemperature') is not None else float(getattr(settings, 'LLM_TEMPERATURE', 0.1))
# 获取最大token数
max_tokens = user_llm_config.get('llmMaxTokens') or int(getattr(settings, 'LLM_MAX_TOKENS', 4096))
self._config = LLMConfig(
provider=provider,
api_key=api_key,
model=model,
base_url=base_url,
timeout=timeout,
temperature=temperature,
max_tokens=max_tokens,
)
return self._config
def _get_provider_api_key_from_user_config(self, provider: LLMProvider, user_llm_config: Dict[str, Any]) -> Optional[str]:
"""从用户配置中获取平台专属API Key"""
provider_key_map = {
LLMProvider.OPENAI: 'openaiApiKey',
LLMProvider.GEMINI: 'geminiApiKey',
LLMProvider.CLAUDE: 'claudeApiKey',
LLMProvider.QWEN: 'qwenApiKey',
LLMProvider.DEEPSEEK: 'deepseekApiKey',
LLMProvider.ZHIPU: 'zhipuApiKey',
LLMProvider.MOONSHOT: 'moonshotApiKey',
LLMProvider.BAIDU: 'baiduApiKey',
LLMProvider.MINIMAX: 'minimaxApiKey',
LLMProvider.DOUBAO: 'doubaoApiKey',
}
key_name = provider_key_map.get(provider)
if key_name:
return user_llm_config.get(key_name)
return None
def _get_provider_api_key(self, provider: LLMProvider) -> str:
"""根据提供商获取API Key"""
provider_key_map = {
LLMProvider.OPENAI: 'OPENAI_API_KEY',
LLMProvider.GEMINI: 'GEMINI_API_KEY',
LLMProvider.CLAUDE: 'CLAUDE_API_KEY',
LLMProvider.QWEN: 'QWEN_API_KEY',
LLMProvider.DEEPSEEK: 'DEEPSEEK_API_KEY',
LLMProvider.ZHIPU: 'ZHIPU_API_KEY',
LLMProvider.MOONSHOT: 'MOONSHOT_API_KEY',
LLMProvider.BAIDU: 'BAIDU_API_KEY',
LLMProvider.MINIMAX: 'MINIMAX_API_KEY',
LLMProvider.DOUBAO: 'DOUBAO_API_KEY',
LLMProvider.OLLAMA: None, # Ollama 不需要 API Key
}
key_name = provider_key_map.get(provider)
if key_name:
return getattr(settings, key_name, '') or ''
return 'ollama' # Ollama的默认值
def _get_provider_base_url(self, provider: LLMProvider) -> Optional[str]:
"""根据提供商获取Base URL"""
if provider == LLMProvider.OPENAI:
return getattr(settings, 'OPENAI_BASE_URL', None)
elif provider == LLMProvider.OLLAMA:
return getattr(settings, 'OLLAMA_BASE_URL', 'http://localhost:11434/v1')
return None
def _parse_provider(self, provider_str: str) -> LLMProvider:
"""解析provider字符串"""
provider_map = {
'gemini': LLMProvider.GEMINI,
'openai': LLMProvider.OPENAI,
'claude': LLMProvider.CLAUDE,
'qwen': LLMProvider.QWEN,
'deepseek': LLMProvider.DEEPSEEK,
'zhipu': LLMProvider.ZHIPU,
'moonshot': LLMProvider.MOONSHOT,
'baidu': LLMProvider.BAIDU,
'minimax': LLMProvider.MINIMAX,
'doubao': LLMProvider.DOUBAO,
'ollama': LLMProvider.OLLAMA,
}
return provider_map.get(provider_str.lower(), LLMProvider.OPENAI)
def _get_output_language(self) -> str:
"""获取输出语言配置(优先使用用户配置)"""
user_other_config = self._user_config.get('otherConfig', {})
return user_other_config.get('outputLanguage') or getattr(settings, 'OUTPUT_LANGUAGE', 'zh-CN')
def _build_system_prompt(self, is_chinese: bool) -> str:
"""构建系统提示词(支持中英文)"""
schema = """{
"issues": [
{
"type": "security|bug|performance|style|maintainability",
"severity": "critical|high|medium|low",
"title": "string",
"description": "string",
"suggestion": "string",
"line": 1,
"column": 1,
"code_snippet": "string",
"ai_explanation": "string",
"xai": {
"what": "string",
"why": "string",
"how": "string",
"learn_more": "string(optional)"
}
}
],
"quality_score": 0-100,
"summary": {
"total_issues": number,
"critical_issues": number,
"high_issues": number,
"medium_issues": number,
"low_issues": number
},
"metrics": {
"complexity": 0-100,
"maintainability": 0-100,
"security": 0-100,
"performance": 0-100
}
}"""
if is_chinese:
return f"""⚠️⚠️⚠️ 只输出JSON禁止输出其他任何格式禁止markdown禁止文本分析
你是一个专业的代码审计助手你的任务是分析代码并返回严格符合JSON Schema的结果
最重要输出格式要求
1. 必须只输出纯JSON对象{{开始}}结束
2. 禁止在JSON前后添加任何文字说明markdown标记
3. 禁止输出```json或###等markdown语法
4. 如果是文档文件如README也必须以JSON格式输出分析结果
内容要求
1. 所有文本内容必须统一使用简体中文
2. JSON字符串值中的特殊字符必须正确转义换行用\\n双引号用\\",反斜杠用\\\\
3. code_snippet字段必须使用\\n表示换行
请从以下维度全面分析代码
- 编码规范和代码风格
- 潜在的 Bug 和逻辑错误
- 性能问题和优化建议
- 安全漏洞和风险
- 可维护性和可读性
- 最佳实践和设计模式
输出格式必须严格符合以下 JSON Schema
{schema}
注意
- title: 问题的简短标题中文
- description: 详细描述问题中文
- suggestion: 具体的修复建议中文
- line: 问题所在的行号从1开始计数必须准确对应代码中的行号
- column: 问题所在的列号从1开始计数指向问题代码的起始位置
- code_snippet: 包含问题的代码片段建议包含问题行及其前后1-2行作为上下文保持原始缩进格式
- ai_explanation: AI 的深入解释中文
- xai.what: 这是什么问题中文
- xai.why: 为什么会有这个问题中文
- xai.how: 如何修复这个问题中文
重要关于行号和代码片段
1. line 必须是问题代码的行号代码左侧有"行号|"标注例如"25| const x = 1"表示第25行line字段必须填25
2. column 是问题代码在该行中的起始列位置从1开始不包括"行号|"前缀部分
3. code_snippet 应该包含问题代码及其上下文前后各1-2去掉"行号|"前缀保持原始代码的缩进
4. 如果代码片段包含多行必须使用 \\n 表示换行符这是JSON的要求
5. 如果无法确定准确的行号不要填写line和column字段不要填0
严格禁止
- 禁止在任何字段中使用英文所有内容必须是简体中文
- 禁止在JSON字符串值中使用真实换行符必须用\\n转义
- 禁止输出markdown代码块标记```json
重要提醒line字段必须从代码左侧的行号标注中读取不要猜测或填0"""
else:
return f"""⚠️⚠️⚠️ OUTPUT JSON ONLY! NO OTHER FORMAT! NO MARKDOWN! NO TEXT ANALYSIS! ⚠️⚠️⚠️
You are a professional code auditing assistant. Your task is to analyze code and return results in strict JSON Schema format.
MOST IMPORTANTOutput format requirements:
1. MUST output pure JSON object only, starting with {{ and ending with }}
2. NO text, explanation, or markdown markers before or after JSON
3. NO ```json or ### markdown syntax
4. Even for document files (like README), output analysis in JSON format
Content requirements:
1. All text content MUST be in English ONLY
2. Special characters in JSON strings must be properly escaped (\\n for newlines, \\" for quotes, \\\\ for backslashes)
3. code_snippet field MUST use \\n for newlines
Please comprehensively analyze the code from the following dimensions:
- Coding standards and code style
- Potential bugs and logical errors
- Performance issues and optimization suggestions
- Security vulnerabilities and risks
- Maintainability and readability
- Best practices and design patterns
The output format MUST strictly conform to the following JSON Schema:
{schema}
Note:
- title: Brief title of the issue (in English)
- description: Detailed description of the issue (in English)
- suggestion: Specific fix suggestions (in English)
- line: Line number where the issue occurs (1-indexed, must accurately correspond to the line in the code)
- column: Column number where the issue starts (1-indexed, pointing to the start position of the problematic code)
- code_snippet: Code snippet containing the issue (should include the problem line plus 1-2 lines before and after for context, preserve original indentation)
- ai_explanation: AI's in-depth explanation (in English)
- xai.what: What is this issue (in English)
- xai.why: Why does this issue exist (in English)
- xai.how: How to fix this issue (in English)
IMPORTANTAbout line numbers and code snippets:
1. 'line' MUST be the line number from code!!! Code has "lineNumber|" prefix, e.g. "25| const x = 1" means line 25, you MUST set line to 25
2. 'column' is the starting column position in that line (1-indexed, excluding the "lineNumber|" prefix)
3. 'code_snippet' should include the problematic code with context (1-2 lines before/after), remove "lineNumber|" prefix, preserve indentation
4. If code snippet has multiple lines, use \\n for newlines (JSON requirement)
5. If you cannot determine the exact line number, do NOT fill line and column fields (don't use 0)
STRICTLY PROHIBITED:
- NO Chinese characters in any field - English ONLY
- NO real newline characters in JSON string values - must use \\n
- NO markdown code block markers (like ```json)
CRITICAL: Read line numbers from the "lineNumber|" prefix on the left of each code line. Do NOT guess or use 0!"""
async def analyze_code(self, code: str, language: str) -> Dict[str, Any]:
"""
分析代码并返回结构化问题
支持中英文输出
"""
# 获取输出语言配置
output_language = self._get_output_language()
is_chinese = output_language == 'zh-CN'
# 添加行号帮助LLM定位问题
code_with_lines = '\n'.join(
f"{i+1}| {line}" for i, line in enumerate(code.split('\n'))
)
# 构建系统提示词
system_prompt = self._build_system_prompt(is_chinese)
# 构建用户提示词
if is_chinese:
user_prompt = f"""编程语言: {language}
代码已标注行号格式行号| 代码内容请根据行号准确填写 line 字段
请分析以下代码:
{code_with_lines}"""
else:
user_prompt = f"""Programming Language: {language}
Code is annotated with line numbers (format: lineNumber| code), please fill the 'line' field accurately based on these numbers!
Please analyze the following code:
{code_with_lines}"""
try:
adapter = LLMFactory.create_adapter(self.config)
request = LLMRequest(
messages=[
LLMMessage(role="system", content=system_prompt),
LLMMessage(role="user", content=user_prompt)
],
temperature=0.1,
)
response = await adapter.complete(request)
content = response.content
# 检查响应内容是否为空
if not content or not content.strip():
logger.warning(f"LLM返回空响应 - Provider: {self.config.provider.value}, Model: {self.config.model}")
logger.warning(f"响应详情 - Finish Reason: {response.finish_reason}, Usage: {response.usage}")
return self._get_default_response()
# 尝试从响应中提取JSON
result = self._parse_json(content)
return result
except Exception as e:
logger.error(f"LLM Analysis failed: {e}", exc_info=True)
logger.error(f"Provider: {self.config.provider.value}, Model: {self.config.model}")
return self._get_default_response()
def _parse_json(self, text: str) -> Dict[str, Any]:
"""从LLM响应中解析JSON增强版"""
# 检查输入是否为空
if not text or not text.strip():
logger.warning("LLM响应内容为空无法解析JSON")
return self._get_default_response()
def clean_text(s: str) -> str:
"""清理文本中的控制字符"""
# 移除BOM和零宽字符
s = s.replace('\ufeff', '').replace('\u200b', '').replace('\u200c', '').replace('\u200d', '')
return s
def fix_json_format(s: str) -> str:
"""修复常见的JSON格式问题"""
s = s.strip()
# 移除尾部逗号
s = re.sub(r',(\s*[}\]])', r'\1', s)
# 修复未转义的换行符(在字符串值中)
s = re.sub(r':\s*"([^"]*)\n([^"]*)"', r': "\1\\n\2"', s)
return s
def aggressive_fix_json(s: str) -> str:
"""激进的JSON修复尝试修复更多格式问题"""
s = clean_text(s)
s = s.strip()
# 找到第一个 { 和最后一个 }
start_idx = s.find('{')
if start_idx == -1:
raise ValueError("No JSON object found")
# 尝试找到最后一个 }
last_brace = s.rfind('}')
if last_brace > start_idx:
s = s[start_idx:last_brace + 1]
# 修复常见的JSON问题
# 1. 移除尾部逗号
s = re.sub(r',(\s*[}\]])', r'\1', s)
# 2. 修复单引号为双引号(仅在键名中,小心处理)
s = re.sub(r"'(\w+)'\s*:", r'"\1":', s)
# 3. 修复未转义的控制字符(在字符串值中,但不在键名中)
# 只移除不在引号内的控制字符,或未转义的换行符/制表符
lines = []
in_string = False
escape_next = False
for char in s:
if escape_next:
escape_next = False
lines.append(char)
continue
if char == '\\':
escape_next = True
lines.append(char)
continue
if char == '"':
in_string = not in_string
lines.append(char)
continue
# 如果在字符串外,移除控制字符;如果在字符串内,保留(假设已转义)
if not in_string and ord(char) < 32 and char not in ['\n', '\t', '\r']:
continue # 跳过控制字符
lines.append(char)
s = ''.join(lines)
return s
# 尝试多种方式解析
attempts = [
# 1. 直接解析
lambda: json.loads(text),
# 2. 清理后解析
lambda: json.loads(fix_json_format(clean_text(text))),
# 3. 从markdown代码块提取
lambda: self._extract_from_markdown(text),
# 4. 智能提取JSON对象
lambda: self._extract_json_object(clean_text(text)),
# 5. 修复截断的JSON
lambda: self._fix_truncated_json(clean_text(text)),
# 6. 激进修复后解析
lambda: json.loads(aggressive_fix_json(text)),
]
last_error = None
for i, attempt in enumerate(attempts):
try:
result = attempt()
if result and isinstance(result, dict):
if i > 0:
logger.info(f"✅ JSON解析成功方法 {i + 1}/{len(attempts)}")
return result
except Exception as e:
last_error = e
if i == 0:
logger.debug(f"直接解析失败,尝试其他方法... {e}")
# 所有尝试都失败
logger.warning("⚠️ 无法解析LLM响应为JSON")
logger.warning(f"原始内容长度: {len(text)} 字符")
logger.warning(f"原始内容前500字符: {text[:500]}")
logger.warning(f"原始内容后500字符: {text[-500:] if len(text) > 500 else text}")
if last_error:
logger.warning(f"最后错误: {type(last_error).__name__}: {str(last_error)}")
return self._get_default_response()
def _extract_from_markdown(self, text: str) -> Dict[str, Any]:
"""从markdown代码块提取JSON"""
match = re.search(r'```(?:json)?\s*(\{[\s\S]*?\})\s*```', text)
if match:
return json.loads(match.group(1))
raise ValueError("No markdown code block found")
def _extract_json_object(self, text: str) -> Dict[str, Any]:
"""智能提取JSON对象"""
start_idx = text.find('{')
if start_idx == -1:
raise ValueError("No JSON object found")
# 考虑字符串内的花括号和转义字符
brace_count = 0
bracket_count = 0
in_string = False
escape_next = False
end_idx = -1
for i in range(start_idx, len(text)):
char = text[i]
if escape_next:
escape_next = False
continue
if char == '\\':
escape_next = True
continue
if char == '"' and not escape_next:
in_string = not in_string
continue
if not in_string:
if char == '{':
brace_count += 1
elif char == '}':
brace_count -= 1
if brace_count == 0 and bracket_count == 0:
end_idx = i + 1
break
elif char == '[':
bracket_count += 1
elif char == ']':
bracket_count -= 1
if end_idx == -1:
# 如果找不到完整的JSON尝试使用最后一个 }
last_brace = text.rfind('}')
if last_brace > start_idx:
end_idx = last_brace + 1
else:
raise ValueError("Incomplete JSON object")
json_str = text[start_idx:end_idx]
# 修复格式问题
json_str = re.sub(r',(\s*[}\]])', r'\1', json_str)
# 尝试修复未闭合的括号
open_braces = json_str.count('{') - json_str.count('}')
open_brackets = json_str.count('[') - json_str.count(']')
if open_braces > 0:
json_str += '}' * open_braces
if open_brackets > 0:
json_str += ']' * open_brackets
return json.loads(json_str)
def _fix_truncated_json(self, text: str) -> Dict[str, Any]:
"""修复截断的JSON"""
start_idx = text.find('{')
if start_idx == -1:
raise ValueError("Cannot fix truncated JSON")
json_str = text[start_idx:]
# 计算缺失的闭合符号
open_braces = json_str.count('{')
close_braces = json_str.count('}')
open_brackets = json_str.count('[')
close_brackets = json_str.count(']')
# 补全缺失的闭合符号
json_str += ']' * max(0, open_brackets - close_brackets)
json_str += '}' * max(0, open_braces - close_braces)
# 修复格式
json_str = re.sub(r',(\s*[}\]])', r'\1', json_str)
return json.loads(json_str)
def _get_default_response(self) -> Dict[str, Any]:
"""返回默认响应"""
return {
"issues": [],
"quality_score": 80,
"summary": {
"total_issues": 0,
"critical_issues": 0,
"high_issues": 0,
"medium_issues": 0,
"low_issues": 0
},
"metrics": {
"complexity": 80,
"maintainability": 80,
"security": 80,
"performance": 80
}
}
# 全局服务实例
llm_service = LLMService()

View File

@ -0,0 +1,120 @@
"""
LLM服务类型定义
"""
from enum import Enum
from typing import Optional, Dict, Any, List
from dataclasses import dataclass, field
class LLMProvider(str, Enum):
"""支持的LLM提供商类型"""
GEMINI = "gemini" # Google Gemini
OPENAI = "openai" # OpenAI (GPT系列)
CLAUDE = "claude" # Anthropic Claude
QWEN = "qwen" # 阿里云通义千问
DEEPSEEK = "deepseek" # DeepSeek
ZHIPU = "zhipu" # 智谱AI (GLM系列)
MOONSHOT = "moonshot" # 月之暗面 Kimi
BAIDU = "baidu" # 百度文心一言
MINIMAX = "minimax" # MiniMax
DOUBAO = "doubao" # 字节豆包
OLLAMA = "ollama" # Ollama 本地大模型
@dataclass
class LLMConfig:
"""LLM配置"""
provider: LLMProvider
api_key: str
model: str
base_url: Optional[str] = None
timeout: int = 150
temperature: float = 0.2
max_tokens: int = 4096
top_p: float = 1.0
frequency_penalty: float = 0
presence_penalty: float = 0
custom_headers: Dict[str, str] = field(default_factory=dict)
@dataclass
class LLMMessage:
"""LLM请求消息"""
role: str # 'system', 'user', 'assistant'
content: str
@dataclass
class LLMRequest:
"""LLM请求参数"""
messages: List[LLMMessage]
temperature: Optional[float] = None
max_tokens: Optional[int] = None
top_p: Optional[float] = None
stream: bool = False
@dataclass
class LLMUsage:
"""Token使用统计"""
prompt_tokens: int
completion_tokens: int
total_tokens: int
@dataclass
class LLMResponse:
"""LLM响应"""
content: str
model: Optional[str] = None
usage: Optional[LLMUsage] = None
finish_reason: Optional[str] = None
class LLMError(Exception):
"""LLM错误"""
def __init__(
self,
message: str,
provider: Optional[LLMProvider] = None,
status_code: Optional[int] = None,
original_error: Optional[Any] = None
):
super().__init__(message)
self.provider = provider
self.status_code = status_code
self.original_error = original_error
# 各平台默认模型
DEFAULT_MODELS: Dict[LLMProvider, str] = {
LLMProvider.GEMINI: "gemini-2.5-flash",
LLMProvider.OPENAI: "gpt-4o-mini",
LLMProvider.CLAUDE: "claude-3-5-sonnet-20241022",
LLMProvider.QWEN: "qwen-turbo",
LLMProvider.DEEPSEEK: "deepseek-chat",
LLMProvider.ZHIPU: "glm-4-flash",
LLMProvider.MOONSHOT: "moonshot-v1-8k",
LLMProvider.BAIDU: "ERNIE-3.5-8K",
LLMProvider.MINIMAX: "abab6.5-chat",
LLMProvider.DOUBAO: "doubao-pro-32k",
LLMProvider.OLLAMA: "llama3",
}
# 各平台API端点
DEFAULT_BASE_URLS: Dict[LLMProvider, str] = {
LLMProvider.OPENAI: "https://api.openai.com/v1",
LLMProvider.QWEN: "https://dashscope.aliyuncs.com/compatible-mode/v1",
LLMProvider.DEEPSEEK: "https://api.deepseek.com",
LLMProvider.ZHIPU: "https://open.bigmodel.cn/api/paas/v4",
LLMProvider.MOONSHOT: "https://api.moonshot.cn/v1",
LLMProvider.BAIDU: "https://aip.baidubce.com/rpc/2.0/ai_custom/v1",
LLMProvider.MINIMAX: "https://api.minimax.chat/v1",
LLMProvider.DOUBAO: "https://ark.cn-beijing.volces.com/api/v3",
LLMProvider.OLLAMA: "http://localhost:11434/v1",
LLMProvider.GEMINI: "https://generativelanguage.googleapis.com/v1beta",
LLMProvider.CLAUDE: "https://api.anthropic.com/v1",
}

View File

@ -0,0 +1,374 @@
"""
仓库扫描服务 - 支持GitHub和GitLab仓库扫描
"""
import asyncio
import httpx
from typing import List, Dict, Any, Optional
from datetime import datetime
from urllib.parse import urlparse, quote
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit import AuditTask, AuditIssue
from app.models.project import Project
from app.services.llm.service import LLMService
from app.core.config import settings
# 支持的文本文件扩展名
TEXT_EXTENSIONS = [
".js", ".ts", ".tsx", ".jsx", ".py", ".java", ".go", ".rs",
".cpp", ".c", ".h", ".cc", ".hh", ".cs", ".php", ".rb",
".kt", ".swift", ".sql", ".sh", ".json", ".yml", ".yaml"
]
# 排除的目录和文件模式
EXCLUDE_PATTERNS = [
"node_modules/", "vendor/", "dist/", "build/", ".git/",
"__pycache__/", ".pytest_cache/", "coverage/", ".nyc_output/",
".vscode/", ".idea/", ".vs/", "target/", "out/",
"__MACOSX/", ".DS_Store", "package-lock.json", "yarn.lock",
"pnpm-lock.yaml", ".min.js", ".min.css", ".map"
]
def is_text_file(path: str) -> bool:
"""检查是否为文本文件"""
return any(path.lower().endswith(ext) for ext in TEXT_EXTENSIONS)
def should_exclude(path: str, exclude_patterns: List[str] = None) -> bool:
"""检查是否应该排除该文件"""
all_patterns = EXCLUDE_PATTERNS + (exclude_patterns or [])
return any(pattern in path for pattern in all_patterns)
def get_language_from_path(path: str) -> str:
"""从文件路径获取语言类型"""
ext = path.split('.')[-1].lower() if '.' in path else ''
language_map = {
'js': 'javascript', 'jsx': 'javascript',
'ts': 'typescript', 'tsx': 'typescript',
'py': 'python', 'java': 'java', 'go': 'go',
'rs': 'rust', 'cpp': 'cpp', 'c': 'cpp',
'cc': 'cpp', 'h': 'cpp', 'hh': 'cpp',
'cs': 'csharp', 'php': 'php', 'rb': 'ruby',
'kt': 'kotlin', 'swift': 'swift'
}
return language_map.get(ext, 'text')
class TaskControlManager:
"""任务控制管理器 - 用于取消运行中的任务"""
def __init__(self):
self._cancelled_tasks: set = set()
def cancel_task(self, task_id: str):
"""取消任务"""
self._cancelled_tasks.add(task_id)
print(f"🛑 任务 {task_id} 已标记为取消")
def is_cancelled(self, task_id: str) -> bool:
"""检查任务是否被取消"""
return task_id in self._cancelled_tasks
def cleanup_task(self, task_id: str):
"""清理已完成任务的控制状态"""
self._cancelled_tasks.discard(task_id)
# 全局任务控制器
task_control = TaskControlManager()
async def github_api(url: str, token: str = None) -> Any:
"""调用GitHub API"""
headers = {"Accept": "application/vnd.github+json"}
t = token or settings.GITHUB_TOKEN
if t:
headers["Authorization"] = f"Bearer {t}"
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(url, headers=headers)
if response.status_code == 403:
raise Exception("GitHub API 403请配置 GITHUB_TOKEN 或确认仓库权限/频率限制")
if response.status_code != 200:
raise Exception(f"GitHub API {response.status_code}: {url}")
return response.json()
async def gitlab_api(url: str, token: str = None) -> Any:
"""调用GitLab API"""
headers = {"Content-Type": "application/json"}
t = token or settings.GITLAB_TOKEN
if t:
headers["PRIVATE-TOKEN"] = t
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(url, headers=headers)
if response.status_code == 401:
raise Exception("GitLab API 401请配置 GITLAB_TOKEN 或确认仓库权限")
if response.status_code == 403:
raise Exception("GitLab API 403请确认仓库权限/频率限制")
if response.status_code != 200:
raise Exception(f"GitLab API {response.status_code}: {url}")
return response.json()
async def fetch_file_content(url: str, headers: Dict[str, str] = None) -> Optional[str]:
"""获取文件内容"""
async with httpx.AsyncClient(timeout=30) as client:
try:
response = await client.get(url, headers=headers or {})
if response.status_code == 200:
return response.text
except Exception as e:
print(f"获取文件内容失败: {url}, 错误: {e}")
return None
async def get_github_files(repo_url: str, branch: str, token: str = None) -> List[Dict[str, str]]:
"""获取GitHub仓库文件列表"""
# 解析仓库URL
match = repo_url.rstrip('/').rstrip('.git')
if 'github.com/' in match:
parts = match.split('github.com/')[-1].split('/')
if len(parts) >= 2:
owner, repo = parts[0], parts[1]
else:
raise Exception("GitHub 仓库 URL 格式错误")
else:
raise Exception("GitHub 仓库 URL 格式错误")
# 获取仓库文件树
tree_url = f"https://api.github.com/repos/{owner}/{repo}/git/trees/{quote(branch)}?recursive=1"
tree_data = await github_api(tree_url, token)
files = []
for item in tree_data.get("tree", []):
if item.get("type") == "blob" and is_text_file(item["path"]) and not should_exclude(item["path"]):
size = item.get("size", 0)
if size <= settings.MAX_FILE_SIZE_BYTES:
files.append({
"path": item["path"],
"url": f"https://raw.githubusercontent.com/{owner}/{repo}/{quote(branch)}/{item['path']}"
})
return files
async def get_gitlab_files(repo_url: str, branch: str, token: str = None) -> List[Dict[str, str]]:
"""获取GitLab仓库文件列表"""
parsed = urlparse(repo_url)
base = f"{parsed.scheme}://{parsed.netloc}"
# 从URL中提取token如果存在
extracted_token = token
if parsed.username:
if parsed.username == 'oauth2' and parsed.password:
extracted_token = parsed.password
elif parsed.username and not parsed.password:
extracted_token = parsed.username
# 解析项目路径
path = parsed.path.strip('/').rstrip('.git')
if not path:
raise Exception("GitLab 仓库 URL 格式错误")
project_path = quote(path, safe='')
# 获取仓库文件树
tree_url = f"{base}/api/v4/projects/{project_path}/repository/tree?ref={quote(branch)}&recursive=true&per_page=100"
tree_data = await gitlab_api(tree_url, extracted_token)
files = []
for item in tree_data:
if item.get("type") == "blob" and is_text_file(item["path"]) and not should_exclude(item["path"]):
files.append({
"path": item["path"],
"url": f"{base}/api/v4/projects/{project_path}/repository/files/{quote(item['path'], safe='')}/raw?ref={quote(branch)}",
"token": extracted_token
})
return files
async def scan_repo_task(task_id: str, db_session_factory, user_config: dict = None):
"""
后台仓库扫描任务
Args:
task_id: 任务ID
db_session_factory: 数据库会话工厂
user_config: 用户配置字典包含llmConfig和otherConfig
"""
async with db_session_factory() as db:
task = await db.get(AuditTask, task_id)
if not task:
return
try:
# 1. 更新状态为运行中
task.status = "running"
task.started_at = datetime.utcnow()
await db.commit()
# 创建使用用户配置的LLM服务实例
llm_service = LLMService(user_config=user_config or {})
# 2. 获取项目信息
project = await db.get(Project, task.project_id)
if not project or not project.repository_url:
raise Exception("仓库地址不存在")
repo_url = project.repository_url
branch = task.branch_name or project.default_branch or "main"
repo_type = project.repository_type or "other"
print(f"🚀 开始扫描仓库: {repo_url}, 分支: {branch}, 类型: {repo_type}")
# 3. 获取文件列表
# 从用户配置中读取 GitHub/GitLab Token优先使用用户配置然后使用系统配置
user_other_config = (user_config or {}).get('otherConfig', {})
github_token = user_other_config.get('githubToken') or settings.GITHUB_TOKEN
gitlab_token = user_other_config.get('gitlabToken') or settings.GITLAB_TOKEN
files: List[Dict[str, str]] = []
extracted_gitlab_token = None
if repo_type == "github":
files = await get_github_files(repo_url, branch, github_token)
elif repo_type == "gitlab":
files = await get_gitlab_files(repo_url, branch, gitlab_token)
# GitLab文件可能带有token
if files and 'token' in files[0]:
extracted_gitlab_token = files[0].get('token')
else:
raise Exception("不支持的仓库类型,仅支持 GitHub 和 GitLab 仓库")
# 限制文件数量
files = files[:settings.MAX_ANALYZE_FILES]
task.total_files = len(files)
await db.commit()
print(f"📊 获取到 {len(files)} 个文件,开始分析")
# 4. 分析文件
total_issues = 0
total_lines = 0
quality_scores = []
scanned_files = 0
failed_files = 0
consecutive_failures = 0
MAX_CONSECUTIVE_FAILURES = 5
for file_info in files:
# 检查是否取消
if task_control.is_cancelled(task_id):
print(f"🛑 任务 {task_id} 已被用户取消")
task.status = "cancelled"
task.completed_at = datetime.utcnow()
await db.commit()
task_control.cleanup_task(task_id)
return
# 检查连续失败次数
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
print(f"❌ 任务 {task_id}: 连续失败 {consecutive_failures} 次,停止分析")
raise Exception(f"连续失败 {consecutive_failures} 次,可能是 LLM API 服务异常")
try:
# 获取文件内容
headers = {}
# 使用提取的 GitLab token 或用户配置的 token
token_to_use = extracted_gitlab_token or gitlab_token
if token_to_use:
headers["PRIVATE-TOKEN"] = token_to_use
content = await fetch_file_content(file_info["url"], headers)
if not content:
continue
if len(content) > settings.MAX_FILE_SIZE_BYTES:
continue
total_lines += content.count('\n') + 1
language = get_language_from_path(file_info["path"])
# LLM分析
analysis = await llm_service.analyze_code(content, language)
# 再次检查是否取消LLM分析后
if task_control.is_cancelled(task_id):
print(f"🛑 任务 {task_id} 在LLM分析后被取消")
task.status = "cancelled"
task.completed_at = datetime.utcnow()
await db.commit()
task_control.cleanup_task(task_id)
return
# 保存问题
issues = analysis.get("issues", [])
for issue in issues:
audit_issue = AuditIssue(
task_id=task.id,
file_path=file_info["path"],
line_number=issue.get("line", 1),
column_number=issue.get("column"),
issue_type=issue.get("type", "maintainability"),
severity=issue.get("severity", "low"),
title=issue.get("title", "Issue"),
message=issue.get("description") or issue.get("title", "Issue"),
suggestion=issue.get("suggestion"),
code_snippet=issue.get("code_snippet"),
ai_explanation=issue.get("ai_explanation"),
status="open"
)
db.add(audit_issue)
total_issues += 1
if "quality_score" in analysis:
quality_scores.append(analysis["quality_score"])
consecutive_failures = 0 # 成功后重置
scanned_files += 1
# 更新进度
task.scanned_files = scanned_files
task.total_lines = total_lines
task.issues_count = total_issues
await db.commit()
print(f"📈 任务 {task_id}: 进度 {scanned_files}/{len(files)} ({int(scanned_files/len(files)*100)}%)")
# 请求间隔
await asyncio.sleep(settings.LLM_GAP_MS / 1000)
except Exception as file_error:
failed_files += 1
consecutive_failures += 1
print(f"❌ 分析文件失败 ({file_info['path']}): {file_error}")
await asyncio.sleep(settings.LLM_GAP_MS / 1000)
# 5. 完成任务
avg_quality_score = sum(quality_scores) / len(quality_scores) if quality_scores else 100.0
task.status = "completed"
task.completed_at = datetime.utcnow()
task.scanned_files = scanned_files
task.total_lines = total_lines
task.issues_count = total_issues
task.quality_score = avg_quality_score
await db.commit()
print(f"✅ 任务 {task_id} 完成: 扫描 {scanned_files} 个文件, 发现 {total_issues} 个问题, 质量分 {avg_quality_score:.1f}")
task_control.cleanup_task(task_id)
except Exception as e:
print(f"❌ 扫描失败: {e}")
task.status = "failed"
task.completed_at = datetime.utcnow()
await db.commit()
task_control.cleanup_task(task_id)

33
backend/env.example Normal file
View File

@ -0,0 +1,33 @@
# =============================================
# XCodeReviewer Backend 配置文件
# 复制此文件为 .env 并填入实际配置
# =============================================
# ------------ 数据库配置 ------------
POSTGRES_SERVER=localhost
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=xcodereviewer
# ------------ 安全配置 ------------
SECRET_KEY=your-super-secret-key-change-this-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=11520
# ------------ LLM配置 ------------
# 支持的provider: openai, gemini, claude, qwen, deepseek, zhipu, moonshot, baidu, minimax, doubao, ollama
LLM_PROVIDER=openai
LLM_API_KEY=sk-your-api-key
LLM_MODEL=
LLM_BASE_URL=
LLM_TIMEOUT=150
LLM_TEMPERATURE=0.1
LLM_MAX_TOKENS=4096
# ------------ 仓库扫描配置 ------------
GITHUB_TOKEN=
GITLAB_TOKEN=
MAX_ANALYZE_FILES=50
MAX_FILE_SIZE_BYTES=204800
LLM_CONCURRENCY=3
LLM_GAP_MS=2000

6
backend/main.py Normal file
View File

@ -0,0 +1,6 @@
def main():
print("Hello from xcodereviewer-backend!")
if __name__ == "__main__":
main()

21
backend/pyproject.toml Normal file
View File

@ -0,0 +1,21 @@
[project]
name = "xcodereviewer-backend"
version = "0.1.0"
description = "XCodeReviewer Backend API"
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.100.0",
"uvicorn[standard]",
"sqlalchemy>=2.0.0",
"asyncpg",
"alembic",
"pydantic>=2.0.0",
"pydantic-settings",
"passlib[bcrypt]",
"python-jose[cryptography]",
"python-multipart",
"httpx",
"email-validator",
"greenlet",
"bcrypt<5.0.0",
]

View File

@ -0,0 +1,108 @@
# This file was autogenerated by uv via the following command:
# uv pip compile requirements.txt -o requirements-lock.txt
alembic==1.17.2
# via -r requirements.txt
annotated-doc==0.0.4
# via fastapi
annotated-types==0.7.0
# via pydantic
anyio==4.11.0
# via
# httpx
# starlette
# watchfiles
asyncpg==0.31.0
# via -r requirements.txt
bcrypt==5.0.0
# via passlib
certifi==2025.11.12
# via
# httpcore
# httpx
cffi==2.0.0
# via cryptography
click==8.3.1
# via uvicorn
cryptography==46.0.3
# via python-jose
ecdsa==0.19.1
# via python-jose
fastapi==0.122.0
# via -r requirements.txt
h11==0.16.0
# via
# httpcore
# uvicorn
httpcore==1.0.9
# via httpx
httptools==0.7.1
# via uvicorn
httpx==0.28.1
# via -r requirements.txt
idna==3.11
# via
# anyio
# httpx
mako==1.3.10
# via alembic
markupsafe==3.0.3
# via mako
passlib==1.7.4
# via -r requirements.txt
pyasn1==0.6.1
# via
# python-jose
# rsa
pycparser==2.23
# via cffi
pydantic==2.12.4
# via
# -r requirements.txt
# fastapi
# pydantic-settings
pydantic-core==2.41.5
# via pydantic
pydantic-settings==2.12.0
# via -r requirements.txt
python-dotenv==1.2.1
# via
# pydantic-settings
# uvicorn
python-jose==3.5.0
# via -r requirements.txt
python-multipart==0.0.20
# via -r requirements.txt
pyyaml==6.0.3
# via uvicorn
rsa==4.9.1
# via python-jose
six==1.17.0
# via ecdsa
sniffio==1.3.1
# via anyio
sqlalchemy==2.0.44
# via
# -r requirements.txt
# alembic
starlette==0.50.0
# via fastapi
typing-extensions==4.15.0
# via
# alembic
# fastapi
# pydantic
# pydantic-core
# sqlalchemy
# typing-inspection
typing-inspection==0.4.2
# via
# pydantic
# pydantic-settings
uvicorn==0.38.0
# via -r requirements.txt
uvloop==0.22.1
# via uvicorn
watchfiles==1.1.1
# via uvicorn
websockets==15.0.1
# via uvicorn

12
backend/requirements.txt Normal file
View File

@ -0,0 +1,12 @@
fastapi>=0.100.0
uvicorn[standard]
sqlalchemy>=2.0.0
asyncpg
alembic
pydantic>=2.0.0
pydantic-settings
passlib[bcrypt]
python-jose[cryptography]
python-multipart
httpx

28
backend/start.sh Executable file
View File

@ -0,0 +1,28 @@
#!/bin/bash
# 使用 uv 启动后端服务
set -e
echo "🚀 启动 XCodeReviewer 后端服务..."
# 检查 uv 是否安装
if ! command -v uv &> /dev/null; then
echo "❌ 未找到 uv请先安装"
echo " curl -LsSf https://astral.sh/uv/install.sh | sh"
exit 1
fi
# 同步依赖(如果需要)
if [ ! -d ".venv" ]; then
echo "📦 首次运行,正在安装依赖..."
uv sync
fi
# 运行数据库迁移
echo "🔄 运行数据库迁移..."
uv run alembic upgrade head
# 启动服务
echo "✅ 启动后端服务..."
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

907
backend/uv.lock Normal file
View File

@ -0,0 +1,907 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "alembic"
version = "1.17.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/a6/74c8cadc2882977d80ad756a13857857dbcf9bd405bc80b662eb10651282/alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e", size = 1988064, upload-time = "2025-11-14T20:35:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/88/6237e97e3385b57b5f1528647addea5cc03d4d65d5979ab24327d41fb00d/alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6", size = 248554, upload-time = "2025-11-14T20:35:05.699Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
]
[[package]]
name = "asyncpg"
version = "0.31.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" },
{ url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" },
{ url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" },
{ url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" },
{ url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" },
{ url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" },
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
{ url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
{ url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
{ url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
{ url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
{ url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
{ url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
{ url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
]
[[package]]
name = "bcrypt"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719, upload-time = "2025-02-28T01:22:34.539Z" },
{ url = "https://files.pythonhosted.org/packages/a1/e2/58ff6e2a22eca2e2cff5370ae56dba29d70b1ea6fc08ee9115c3ae367795/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb", size = 272001, upload-time = "2025-02-28T01:22:38.078Z" },
{ url = "https://files.pythonhosted.org/packages/37/1f/c55ed8dbe994b1d088309e366749633c9eb90d139af3c0a50c102ba68a1a/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180", size = 277451, upload-time = "2025-02-28T01:22:40.787Z" },
{ url = "https://files.pythonhosted.org/packages/d7/1c/794feb2ecf22fe73dcfb697ea7057f632061faceb7dcf0f155f3443b4d79/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f", size = 272792, upload-time = "2025-02-28T01:22:43.144Z" },
{ url = "https://files.pythonhosted.org/packages/13/b7/0b289506a3f3598c2ae2bdfa0ea66969812ed200264e3f61df77753eee6d/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09", size = 289752, upload-time = "2025-02-28T01:22:45.56Z" },
{ url = "https://files.pythonhosted.org/packages/dc/24/d0fb023788afe9e83cc118895a9f6c57e1044e7e1672f045e46733421fe6/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d", size = 277762, upload-time = "2025-02-28T01:22:47.023Z" },
{ url = "https://files.pythonhosted.org/packages/e4/38/cde58089492e55ac4ef6c49fea7027600c84fd23f7520c62118c03b4625e/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd", size = 272384, upload-time = "2025-02-28T01:22:49.221Z" },
{ url = "https://files.pythonhosted.org/packages/de/6a/d5026520843490cfc8135d03012a413e4532a400e471e6188b01b2de853f/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af", size = 277329, upload-time = "2025-02-28T01:22:51.603Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a3/4fc5255e60486466c389e28c12579d2829b28a527360e9430b4041df4cf9/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231", size = 305241, upload-time = "2025-02-28T01:22:53.283Z" },
{ url = "https://files.pythonhosted.org/packages/c7/15/2b37bc07d6ce27cc94e5b10fd5058900eb8fb11642300e932c8c82e25c4a/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c", size = 309617, upload-time = "2025-02-28T01:22:55.461Z" },
{ url = "https://files.pythonhosted.org/packages/5f/1f/99f65edb09e6c935232ba0430c8c13bb98cb3194b6d636e61d93fe60ac59/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f", size = 335751, upload-time = "2025-02-28T01:22:57.81Z" },
{ url = "https://files.pythonhosted.org/packages/00/1b/b324030c706711c99769988fcb694b3cb23f247ad39a7823a78e361bdbb8/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d", size = 355965, upload-time = "2025-02-28T01:22:59.181Z" },
{ url = "https://files.pythonhosted.org/packages/aa/dd/20372a0579dd915dfc3b1cd4943b3bca431866fcb1dfdfd7518c3caddea6/bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", size = 155316, upload-time = "2025-02-28T01:23:00.763Z" },
{ url = "https://files.pythonhosted.org/packages/6d/52/45d969fcff6b5577c2bf17098dc36269b4c02197d551371c023130c0f890/bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669", size = 147752, upload-time = "2025-02-28T01:23:02.908Z" },
{ url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019, upload-time = "2025-02-28T01:23:05.838Z" },
{ url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174, upload-time = "2025-02-28T01:23:07.274Z" },
{ url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870, upload-time = "2025-02-28T01:23:09.151Z" },
{ url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601, upload-time = "2025-02-28T01:23:11.461Z" },
{ url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660, upload-time = "2025-02-28T01:23:12.989Z" },
{ url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083, upload-time = "2025-02-28T01:23:14.5Z" },
{ url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237, upload-time = "2025-02-28T01:23:16.686Z" },
{ url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737, upload-time = "2025-02-28T01:23:18.897Z" },
{ url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741, upload-time = "2025-02-28T01:23:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472, upload-time = "2025-02-28T01:23:23.183Z" },
{ url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606, upload-time = "2025-02-28T01:23:25.361Z" },
{ url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867, upload-time = "2025-02-28T01:23:26.875Z" },
{ url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589, upload-time = "2025-02-28T01:23:28.381Z" },
{ url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794, upload-time = "2025-02-28T01:23:30.187Z" },
{ url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969, upload-time = "2025-02-28T01:23:31.945Z" },
{ url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158, upload-time = "2025-02-28T01:23:34.161Z" },
{ url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285, upload-time = "2025-02-28T01:23:35.765Z" },
{ url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583, upload-time = "2025-02-28T01:23:38.021Z" },
{ url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896, upload-time = "2025-02-28T01:23:39.575Z" },
{ url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492, upload-time = "2025-02-28T01:23:40.901Z" },
{ url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213, upload-time = "2025-02-28T01:23:42.653Z" },
{ url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162, upload-time = "2025-02-28T01:23:43.964Z" },
{ url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856, upload-time = "2025-02-28T01:23:46.011Z" },
{ url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726, upload-time = "2025-02-28T01:23:47.575Z" },
{ url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664, upload-time = "2025-02-28T01:23:49.059Z" },
{ url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128, upload-time = "2025-02-28T01:23:50.399Z" },
{ url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598, upload-time = "2025-02-28T01:23:51.775Z" },
{ url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" },
]
[[package]]
name = "certifi"
version = "2025.11.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "46.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
{ url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
{ url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
{ url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
{ url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
{ url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
{ url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
{ url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
{ url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
{ url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
{ url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "ecdsa"
version = "0.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
]
[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]]
name = "fastapi"
version = "0.122.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/de/3ee97a4f6ffef1fb70bf20561e4f88531633bb5045dc6cebc0f8471f764d/fastapi-0.122.0.tar.gz", hash = "sha256:cd9b5352031f93773228af8b4c443eedc2ac2aa74b27780387b853c3726fb94b", size = 346436, upload-time = "2025-11-24T19:17:47.95Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/93/aa8072af4ff37b795f6bbf43dcaf61115f40f49935c7dbb180c9afc3f421/fastapi-0.122.0-py3-none-any.whl", hash = "sha256:a456e8915dfc6c8914a50d9651133bd47ec96d331c5b44600baa635538a30d67", size = 110671, upload-time = "2025-11-24T19:17:45.96Z" },
]
[[package]]
name = "greenlet"
version = "3.2.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
{ url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" },
{ url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" },
{ url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" },
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
{ url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" },
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
{ url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
{ url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "mako"
version = "1.3.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "passlib"
version = "1.7.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
]
[package.optional-dependencies]
bcrypt = [
{ name = "bcrypt" },
]
[[package]]
name = "pyasn1"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
]
[[package]]
name = "pycparser"
version = "2.23"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
[[package]]
name = "pydantic"
version = "2.12.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "python-jose"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ecdsa" },
{ name = "pyasn1" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
]
[package.optional-dependencies]
cryptography = [
{ name = "cryptography" },
]
[[package]]
name = "python-multipart"
version = "0.0.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.44"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479, upload-time = "2025-10-10T16:03:37.671Z" },
{ url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212, upload-time = "2025-10-10T16:03:41.755Z" },
{ url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353, upload-time = "2025-10-10T15:35:31.221Z" },
{ url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222, upload-time = "2025-10-10T15:43:50.124Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614, upload-time = "2025-10-10T15:35:32.578Z" },
{ url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248, upload-time = "2025-10-10T15:43:51.862Z" },
{ url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275, upload-time = "2025-10-10T15:03:26.096Z" },
{ url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901, upload-time = "2025-10-10T15:03:27.548Z" },
{ url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" },
]
[[package]]
name = "starlette"
version = "0.50.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
[[package]]
name = "websockets"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
[[package]]
name = "xcodereviewer-backend"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "alembic" },
{ name = "asyncpg" },
{ name = "bcrypt" },
{ name = "email-validator" },
{ name = "fastapi" },
{ name = "greenlet" },
{ name = "httpx" },
{ name = "passlib", extra = ["bcrypt"] },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.metadata]
requires-dist = [
{ name = "alembic" },
{ name = "asyncpg" },
{ name = "bcrypt", specifier = "<5.0.0" },
{ name = "email-validator" },
{ name = "fastapi", specifier = ">=0.100.0" },
{ name = "greenlet" },
{ name = "httpx" },
{ name = "passlib", extras = ["bcrypt"] },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "pydantic-settings" },
{ name = "python-jose", extras = ["cryptography"] },
{ name = "python-multipart" },
{ name = "sqlalchemy", specifier = ">=2.0.0" },
{ name = "uvicorn", extras = ["standard"] },
]

View File

@ -1,95 +1,52 @@
version: '3.8'
services:
# XCodeReviewer 前端应用
xcodereviewer:
build:
context: .
dockerfile: Dockerfile
# 构建参数 - 从 .env 文件或环境变量传入
args:
# LLM 通用配置
- VITE_LLM_PROVIDER=${VITE_LLM_PROVIDER:-gemini}
- VITE_LLM_API_KEY=${VITE_LLM_API_KEY}
- VITE_LLM_MODEL=${VITE_LLM_MODEL}
- VITE_LLM_BASE_URL=${VITE_LLM_BASE_URL}
- VITE_LLM_TIMEOUT=${VITE_LLM_TIMEOUT:-150000}
- VITE_LLM_TEMPERATURE=${VITE_LLM_TEMPERATURE:-0.2}
- VITE_LLM_MAX_TOKENS=${VITE_LLM_MAX_TOKENS:-4096}
# Google Gemini 配置
- VITE_GEMINI_API_KEY=${VITE_GEMINI_API_KEY}
- VITE_GEMINI_MODEL=${VITE_GEMINI_MODEL:-gemini-2.5-flash}
- VITE_GEMINI_TIMEOUT_MS=${VITE_GEMINI_TIMEOUT_MS:-25000}
# OpenAI 配置
- VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY}
- VITE_OPENAI_MODEL=${VITE_OPENAI_MODEL:-gpt-4o-mini}
- VITE_OPENAI_BASE_URL=${VITE_OPENAI_BASE_URL}
# Claude 配置
- VITE_CLAUDE_API_KEY=${VITE_CLAUDE_API_KEY}
- VITE_CLAUDE_MODEL=${VITE_CLAUDE_MODEL:-claude-3-5-sonnet-20241022}
# 通义千问配置
- VITE_QWEN_API_KEY=${VITE_QWEN_API_KEY}
- VITE_QWEN_MODEL=${VITE_QWEN_MODEL:-qwen-turbo}
# DeepSeek 配置
- VITE_DEEPSEEK_API_KEY=${VITE_DEEPSEEK_API_KEY}
- VITE_DEEPSEEK_MODEL=${VITE_DEEPSEEK_MODEL:-deepseek-chat}
# 智谱AI 配置
- VITE_ZHIPU_API_KEY=${VITE_ZHIPU_API_KEY}
- VITE_ZHIPU_MODEL=${VITE_ZHIPU_MODEL:-glm-4-flash}
# Moonshot 配置
- VITE_MOONSHOT_API_KEY=${VITE_MOONSHOT_API_KEY}
- VITE_MOONSHOT_MODEL=${VITE_MOONSHOT_MODEL:-moonshot-v1-8k}
# 百度文心一言配置
- VITE_BAIDU_API_KEY=${VITE_BAIDU_API_KEY}
- VITE_BAIDU_MODEL=${VITE_BAIDU_MODEL:-ERNIE-3.5-8K}
# MiniMax 配置
- VITE_MINIMAX_API_KEY=${VITE_MINIMAX_API_KEY}
- VITE_MINIMAX_MODEL=${VITE_MINIMAX_MODEL:-abab6.5-chat}
# 豆包配置
- VITE_DOUBAO_API_KEY=${VITE_DOUBAO_API_KEY}
- VITE_DOUBAO_MODEL=${VITE_DOUBAO_MODEL:-doubao-pro-32k}
# Ollama 配置
- VITE_OLLAMA_API_KEY=${VITE_OLLAMA_API_KEY:-ollama}
- VITE_OLLAMA_MODEL=${VITE_OLLAMA_MODEL:-llama3}
- VITE_OLLAMA_BASE_URL=${VITE_OLLAMA_BASE_URL:-http://localhost:11434/v1}
# 数据库配置
- VITE_USE_LOCAL_DB=${VITE_USE_LOCAL_DB:-true}
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
# GitHub 配置
- VITE_GITHUB_TOKEN=${VITE_GITHUB_TOKEN}
# 应用配置
- VITE_APP_ID=${VITE_APP_ID:-xcodereviewer}
- VITE_MAX_ANALYZE_FILES=${VITE_MAX_ANALYZE_FILES:-40}
- VITE_LLM_CONCURRENCY=${VITE_LLM_CONCURRENCY:-2}
- VITE_LLM_GAP_MS=${VITE_LLM_GAP_MS:-500}
- VITE_OUTPUT_LANGUAGE=${VITE_OUTPUT_LANGUAGE:-zh-CN}
container_name: xcodereviewer-app
db:
image: postgres:15-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=xcodereviewer
ports:
- "5174:80"
restart: unless-stopped
networks:
- xcodereviewer-network
- "5432:5432"
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
networks:
xcodereviewer-network:
driver: bridge
backend:
build:
context: ./backend
volumes:
- ./backend:/app
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/xcodereviewer
- SECRET_KEY=changethisdevsecret
- ALGORITHM=HS256
- ACCESS_TOKEN_EXPIRE_MINUTES=30
depends_on:
db:
condition: service_healthy
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
frontend:
build:
context: ./frontend
volumes:
- ./frontend:/app
- /app/node_modules
ports:
- "5173:5173"
environment:
- VITE_API_BASE_URL=/api
depends_on:
- backend
command: npm run dev -- --host
volumes:
postgres_data:

18
frontend/Dockerfile Normal file
View File

@ -0,0 +1,18 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml* package-lock.json* ./
RUN if [ -f pnpm-lock.yaml ]; then \
corepack enable && pnpm install; \
else \
npm install; \
fi
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]

File diff suppressed because it is too large Load Diff

View File

@ -82,6 +82,7 @@
"@types/lodash": "^4.17.16",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@types/unist": "^3.0.3",
"@types/video-react": "^0.15.8",
"@typescript/native-preview": "7.0.0-dev.20250819.1",
"@vitejs/plugin-react": "^4.3.4",

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 463 KiB

After

Width:  |  Height:  |  Size: 463 KiB

View File

Before

Width:  |  Height:  |  Size: 368 KiB

After

Width:  |  Height:  |  Size: 368 KiB

View File

Before

Width:  |  Height:  |  Size: 421 KiB

After

Width:  |  Height:  |  Size: 421 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 194 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

53
frontend/src/app/App.tsx Normal file
View File

@ -0,0 +1,53 @@
import { BrowserRouter, Routes, Route, Navigate, Outlet } from "react-router-dom";
import { Toaster } from "sonner";
import Header from "@/components/layout/Header";
import routes from "./routes";
import { AuthProvider } from "@/shared/context/AuthContext";
import { ProtectedRoute } from "./ProtectedRoute";
import Login from "@/pages/Login";
import Register from "@/pages/Register";
import NotFound from "@/pages/NotFound";
function AppLayout() {
return (
<div className="min-h-screen gradient-bg">
<Header />
<main className="container-responsive py-4 md:py-6">
<Outlet />
</main>
</div>
);
}
function App() {
return (
<AuthProvider>
<BrowserRouter>
<Toaster position="top-right" />
<Routes>
{/* Public Routes */}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
{/* Protected Routes */}
<Route element={<ProtectedRoute />}>
<Route element={<AppLayout />}>
{routes.map((route) => (
<Route
key={route.path}
path={route.path}
element={route.element}
/>
))}
</Route>
</Route>
{/* Catch all */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
export default App;

View File

@ -0,0 +1,18 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '@/shared/context/AuthContext';
export const ProtectedRoute = () => {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return <div className="flex h-screen items-center justify-center">Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <Outlet />;
};

View File

@ -3,16 +3,9 @@ import { createRoot } from "react-dom/client";
import "@/assets/styles/globals.css";
import App from "./App.tsx";
import { AppWrapper } from "@/components/layout/PageMeta";
import { isLocalMode } from "@/shared/config/database";
import { initLocalDatabase } from "@/shared/utils/initLocalDB";
import { ErrorBoundary } from "@/components/common/ErrorBoundary";
import "@/shared/utils/fetchWrapper"; // 初始化fetch拦截器
// 初始化本地数据库
if (isLocalMode) {
initLocalDatabase().catch(console.error);
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ErrorBoundary>

Some files were not shown because too many files have changed in this diff Show More