Initial commit with Phase 0 Scaffolding
This commit is contained in:
parent
b393607602
commit
c545d4b17d
51 changed files with 7064 additions and 4 deletions
25
backend/Dockerfile
Normal file
25
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
FROM python:3.12-slim AS backend
|
||||
|
||||
# System dependencies: fonts for PDF export + fontconfig
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
fonts-liberation \
|
||||
fontconfig && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
fc-cache -fv
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python dependencies
|
||||
COPY pyproject.toml ./
|
||||
RUN pip install --no-cache-dir -e ".[dev]"
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create data directories (will be overridden by volume mounts)
|
||||
RUN mkdir -p /data/pdfs /data/thumbnails /data/db
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
38
backend/alembic.ini
Normal file
38
backend/alembic.ini
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Alembic configuration for PaperJet
|
||||
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
sqlalchemy.url = sqlite:////data/db/app.sqlite
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[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
|
||||
47
backend/alembic/env.py
Normal file
47
backend/alembic/env.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Alembic environment configuration for PaperJet."""
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.db import Base
|
||||
from app.models import * # noqa: F401, F403 — ensure all models are imported
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
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()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
25
backend/alembic/script.py.mako
Normal file
25
backend/alembic/script.py.mako
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
1
backend/alembic/versions/.gitkeep
Normal file
1
backend/alembic/versions/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Alembic versions directory
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# PaperJet Backend
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# API v1 package init
|
||||
10
backend/app/api/v1/__init__.py
Normal file
10
backend/app/api/v1/__init__.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"""API v1 router — aggregates all sub-routers."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.health import router as health_router
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
# Health (unauthenticated)
|
||||
router.include_router(health_router)
|
||||
13
backend/app/api/v1/health.py
Normal file
13
backend/app/api/v1/health.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""Health check endpoint — unauthenticated."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health_check() -> dict[str, str]:
|
||||
"""Return service health status and version."""
|
||||
return {"status": "ok", "version": settings.APP_VERSION}
|
||||
1
backend/app/auth/__init__.py
Normal file
1
backend/app/auth/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# PaperJet backend auth
|
||||
47
backend/app/config.py
Normal file
47
backend/app/config.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""
|
||||
PaperJet backend configuration.
|
||||
|
||||
All settings are driven by environment variables (see .env.example).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
# --- Security ---
|
||||
SECRET_KEY: str = "change-me-in-production"
|
||||
|
||||
# --- Upload ---
|
||||
MAX_UPLOAD_MB: int = 200
|
||||
|
||||
# --- Retention ---
|
||||
TRASH_RETENTION_DAYS: int = 30
|
||||
AUTO_VERSION_RETENTION_DAYS: int = 30
|
||||
|
||||
# --- Cookie ---
|
||||
COOKIE_SECURE: bool = False # Set True in production (behind HTTPS proxy)
|
||||
COOKIE_MAX_AGE_SECONDS: int = 7 * 24 * 60 * 60 # 7 days
|
||||
COOKIE_NAME: str = "paperjet_session"
|
||||
|
||||
# --- Database ---
|
||||
DATABASE_PATH: Path = Path("/data/db/app.sqlite")
|
||||
|
||||
# --- Storage ---
|
||||
PDF_STORAGE_PATH: Path = Path("/data/pdfs")
|
||||
THUMBNAILS_PATH: Path = Path("/data/thumbnails")
|
||||
|
||||
# --- App ---
|
||||
APP_VERSION: str = "0.1.0"
|
||||
DEBUG: bool = False
|
||||
|
||||
# --- Rate Limiting ---
|
||||
LOGIN_MAX_ATTEMPTS: int = 5
|
||||
LOGIN_LOCKOUT_SECONDS: int = 900 # 15 minutes
|
||||
|
||||
model_config = {"env_prefix": "PAPERJET_", "env_file": ".env", "extra": "ignore"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
58
backend/app/db.py
Normal file
58
backend/app/db.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""
|
||||
Database engine, session management, and WAL mode setup.
|
||||
|
||||
SQLite with WAL mode for single-user concurrent read/write safety.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import event, create_engine, Engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""SQLAlchemy declarative base for all models."""
|
||||
pass
|
||||
|
||||
|
||||
def _set_sqlite_wal_mode(dbapi_conn: Any, _connection_record: Any) -> None:
|
||||
"""Enable WAL mode and other SQLite performance pragmas on every connection."""
|
||||
cursor = dbapi_conn.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
|
||||
def create_db_engine() -> Engine:
|
||||
"""Create the SQLAlchemy engine with SQLite and WAL mode."""
|
||||
# Ensure the database directory exists
|
||||
settings.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
engine = create_engine(
|
||||
f"sqlite:///{settings.DATABASE_PATH}",
|
||||
echo=settings.DEBUG,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
# Register WAL mode on every new connection
|
||||
event.listen(engine, "connect", _set_sqlite_wal_mode)
|
||||
|
||||
return engine
|
||||
|
||||
|
||||
engine = create_db_engine()
|
||||
SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
|
||||
|
||||
|
||||
def get_db() -> Session:
|
||||
"""FastAPI dependency that yields a database session."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db # type: ignore[misc]
|
||||
finally:
|
||||
db.close()
|
||||
85
backend/app/main.py
Normal file
85
backend/app/main.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""
|
||||
PaperJet backend — FastAPI application factory.
|
||||
|
||||
This is the main entry point. The lifespan handler initializes the database
|
||||
and ensures required directories exist on startup.
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api.v1 import router as v1_router
|
||||
from app.config import settings
|
||||
from app.db import Base, engine
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan: startup and shutdown tasks."""
|
||||
# Create all tables (Alembic handles migrations in production,
|
||||
# but this ensures tables exist for development/first run)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Ensure storage directories exist
|
||||
settings.PDF_STORAGE_PATH.mkdir(parents=True, exist_ok=True)
|
||||
settings.THUMBNAILS_PATH.mkdir(parents=True, exist_ok=True)
|
||||
settings.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown: dispose of the engine
|
||||
engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="PaperJet",
|
||||
description="Self-hosted PDF editor API",
|
||||
version=settings.APP_VERSION,
|
||||
docs_url="/api/v1/docs" if settings.DEBUG else None,
|
||||
redoc_url="/api/v1/redoc" if settings.DEBUG else None,
|
||||
openapi_url="/api/v1/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
# --- Error handlers ---
|
||||
|
||||
@app.exception_handler(404)
|
||||
async def not_found_handler(request: Request, exc: Any) -> JSONResponse:
|
||||
"""Consistent 404 error envelope."""
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"error": {"code": "not_found", "message": "Resource not found"}},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(422)
|
||||
async def validation_error_handler(request: Request, exc: Any) -> JSONResponse:
|
||||
"""Consistent validation error envelope."""
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={
|
||||
"error": {
|
||||
"code": "validation_error",
|
||||
"message": "Request validation failed",
|
||||
"details": exc.errors() if hasattr(exc, "errors") else str(exc),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(500)
|
||||
async def internal_error_handler(request: Request, exc: Any) -> JSONResponse:
|
||||
"""Consistent 500 error envelope."""
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": {"code": "internal_error", "message": "Internal server error"}},
|
||||
)
|
||||
|
||||
|
||||
# --- Mount routers ---
|
||||
app.include_router(v1_router)
|
||||
13
backend/app/models/__init__.py
Normal file
13
backend/app/models/__init__.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""
|
||||
SQLAlchemy models for PaperJet.
|
||||
|
||||
All models use UUIDv4 string primary keys (except the singleton settings row).
|
||||
Timestamps are stored as ISO8601 TEXT columns.
|
||||
"""
|
||||
|
||||
from app.models.settings import Settings
|
||||
from app.models.document import Document
|
||||
from app.models.annotation_state import AnnotationState
|
||||
from app.models.version import Version
|
||||
|
||||
__all__ = ["Settings", "Document", "AnnotationState", "Version"]
|
||||
32
backend/app/models/annotation_state.py
Normal file
32
backend/app/models/annotation_state.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""AnnotationState model — current working annotation layer per document."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import ForeignKey, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class AnnotationState(Base):
|
||||
"""
|
||||
Current working annotation state for a document.
|
||||
|
||||
One row per document. `data` is a JSON text column containing
|
||||
the full annotation array — opaque to the server except during export.
|
||||
"""
|
||||
|
||||
__tablename__ = "annotation_states"
|
||||
|
||||
document_id: Mapped[str] = mapped_column(
|
||||
Text, ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
data: Mapped[str] = mapped_column(Text, default="[]") # JSON array
|
||||
updated_at: Mapped[str] = mapped_column(Text, default=_now_iso, onupdate=_now_iso)
|
||||
|
||||
# Relationships
|
||||
document: Mapped["Document"] = relationship("Document", back_populates="annotation_state")
|
||||
58
backend/app/models/document.py
Normal file
58
backend/app/models/document.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""Document model — uploaded PDFs with soft-delete support."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Index, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
def _uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class Document(Base):
|
||||
"""
|
||||
A single uploaded PDF document.
|
||||
|
||||
Supports soft deletion via `deleted_at` — when set, the document
|
||||
is in the trash and can be restored or permanently purged.
|
||||
"""
|
||||
|
||||
__tablename__ = "documents"
|
||||
|
||||
id: Mapped[str] = mapped_column(Text, primary_key=True, default=_uuid)
|
||||
title: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
original_filename: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
file_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
thumbnail_path: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
page_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(Text, default=_now_iso)
|
||||
updated_at: Mapped[str] = mapped_column(Text, default=_now_iso, onupdate=_now_iso)
|
||||
deleted_at: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||
|
||||
# Relationships
|
||||
annotation_state: Mapped["AnnotationState"] = relationship(
|
||||
"AnnotationState",
|
||||
back_populates="document",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
versions: Mapped[list["Version"]] = relationship(
|
||||
"Version",
|
||||
back_populates="document",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Version.created_at.desc()",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_documents_updated_at", "updated_at"),
|
||||
Index("ix_documents_deleted_at", "deleted_at"),
|
||||
)
|
||||
31
backend/app/models/settings.py
Normal file
31
backend/app/models/settings.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""Settings model — singleton row for app-wide configuration."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class Settings(Base):
|
||||
"""
|
||||
Singleton settings row (id=1).
|
||||
|
||||
Stores the hashed password and app-level config.
|
||||
password_hash is NULL until first-run setup.
|
||||
"""
|
||||
|
||||
__tablename__ = "settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||
created_at: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
default=lambda: datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
updated_at: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
default=lambda: datetime.now(timezone.utc).isoformat(),
|
||||
onupdate=lambda: datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
45
backend/app/models/version.py
Normal file
45
backend/app/models/version.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""Version model — annotation state snapshots for history/recovery."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
def _uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class Version(Base):
|
||||
"""
|
||||
A snapshot of a document's annotation state at a point in time.
|
||||
|
||||
Versions serve as a safety net for accidental annotation loss.
|
||||
`kind` is either 'manual' (user-created) or 'auto' (system-created
|
||||
at safety boundaries like document close or before destructive ops).
|
||||
"""
|
||||
|
||||
__tablename__ = "versions"
|
||||
|
||||
id: Mapped[str] = mapped_column(Text, primary_key=True, default=_uuid)
|
||||
document_id: Mapped[str] = mapped_column(
|
||||
Text, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
label: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||
data: Mapped[str] = mapped_column(Text, nullable=False) # JSON snapshot
|
||||
kind: Mapped[str] = mapped_column(Text, nullable=False) # 'manual' | 'auto'
|
||||
created_at: Mapped[str] = mapped_column(Text, default=_now_iso)
|
||||
|
||||
# Relationships
|
||||
document: Mapped["Document"] = relationship("Document", back_populates="versions")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_versions_document_created", "document_id", "created_at"),
|
||||
)
|
||||
1
backend/app/schemas/__init__.py
Normal file
1
backend/app/schemas/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# PaperJet backend schemas
|
||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# PaperJet backend services
|
||||
1
backend/app/tests/__init__.py
Normal file
1
backend/app/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Backend tests
|
||||
48
backend/pyproject.toml
Normal file
48
backend/pyproject.toml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
[project]
|
||||
name = "paperjet-backend"
|
||||
version = "0.1.0"
|
||||
description = "PaperJet — self-hosted PDF editor backend"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"pymupdf>=1.25.0",
|
||||
"sqlalchemy>=2.0.0",
|
||||
"alembic>=1.14.0",
|
||||
"pydantic>=2.10.0",
|
||||
"pydantic-settings>=2.7.0",
|
||||
"argon2-cffi>=23.1.0",
|
||||
"itsdangerous>=2.2.0",
|
||||
"python-multipart>=0.0.18",
|
||||
"aiofiles>=24.1.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"httpx>=0.28.0",
|
||||
"ruff>=0.8.0",
|
||||
"mypy>=1.13.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=75.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
strict = true
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["app/tests"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue