diff --git a/.github/issue-labeler.yml b/.github/issue-labeler.yml new file mode 100644 index 0000000..1e4fe9e --- /dev/null +++ b/.github/issue-labeler.yml @@ -0,0 +1,49 @@ +# Issue Labeler - labels issues based on title/body content + +bug: + - '(bug|fix|issue|error|crash|fail|broken)' + +enhancement: + - '(feature|enhancement|improve|add|new)' + +documentation: + - '(doc|docs|documentation|readme|guide)' + +question: + - '(question|how to|help|support|\?)' + +ai-ml: + - '(stt|tts|speech|voice|translation|translate|whisper|deepgram|deepl|gpt)' + +audio-media: + - '(audio|webrtc|websocket|ws|stream|signaling)' + +event-driven: + - '(kafka|zookeeper|event|topic|stream)' + +real-time: + - '(latency|instant|real-time|glass-to-glass)' + +infrastructure: + - '(docker|redis|postgres|alembic|migration)' + +security: + - '(auth|jwt|token|security|vulnerability|private)' + +performance: + - '(perf|optimization|speed|throughput|latency)' + +api: + - '(api|endpoint|rest|controller)' + +payment: + - '(payment|stripe|subscription|billing|invoice)' + +email: + - '(email|mail|notification|template)' + +ci-cd: + - '(ci|cd|github action|workflow|pipeline|build|stale|labeler)' + +dependencies: + - '(dependency|dependencies|upgrade|update|version)' \ No newline at end of file diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..d6399ba --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,30 @@ +backend: + - changed-files: + - any-glob-to-any-file: 'app/**/*' + +tests: + - changed-files: + - any-glob-to-any-file: 'tests/**/*' + +github-actions: + - changed-files: + - any-glob-to-any-file: '.github/**/*' + +migrations: + - changed-files: + - any-glob-to-any-file: 'alembic/**/*' + +config: + - changed-files: + - any-glob-to-any-file: + - 'pyproject.toml' + - 'requirements.txt' + - '.env.example' + - '.gitignore' + +devops: + - changed-files: + - any-glob-to-any-file: + - 'docker-compose.yml' + - 'Dockerfile' + - '.dockerignore' diff --git a/.github/owasp-suppressions.xml b/.github/owasp-suppressions.xml new file mode 100644 index 0000000..0ddbf3e --- /dev/null +++ b/.github/owasp-suppressions.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..70a47ee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + quality-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Lint with Black + run: black --check . + - name: Check imports with isort + run: isort --check-only . + - name: Type check with Mypy + run: mypy app + - name: Run tests with Pytest + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fluentmeet_test + REDIS_URL: redis://localhost:6379/1 + run: | + pytest --cov=app --cov-fail-under=5 tests/ diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..639400b --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,31 @@ +name: Code Quality + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + workflow_dispatch: + +jobs: + lint-and-typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ruff mypy + # Install project deps to help mypy find types + pip install -r requirements.txt + - name: Lint with Ruff + run: ruff check . + - name: Format check with Ruff + run: ruff format --check . + - name: Type check with Mypy + run: mypy app diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..b521ade --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,62 @@ +name: CodeQL + +on: + push: + branches: [ dev, main, develop ] + pull_request: + branches: [ main, develop ] + schedule: + - cron: '30 1 * * 0' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + actions: read + contents: read + security-events: write + issues: write + pull-requests: write + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: security-extended,security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" + + - name: Notify on failure + if: failure() && github.event.pull_request.head.repo.full_name == github.repository + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const issue = context.payload.pull_request + ? context.payload.pull_request.number + : (context.payload.issue ? context.payload.issue.number : null); + + if (issue) { + github.rest.issues.createComment({ + issue_number: issue, + owner: context.repo.owner, + repo: context.repo.repo, + body: '⚠️ CodeQL security scan failed. Please check the workflow logs.' + }); + } else { + console.log('No issue or PR number found, skipping comment creation'); + } diff --git a/.github/workflows/dependency-check.yml b/.github/workflows/dependency-check.yml new file mode 100644 index 0000000..53bcaed --- /dev/null +++ b/.github/workflows/dependency-check.yml @@ -0,0 +1,35 @@ +name: OWASP Dependency Check + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + schedule: + - cron: '0 0 * * 1' # Weekly on Mondays at midnight + workflow_dispatch: + +jobs: + depcheck: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Dependency Check + uses: dependency-check/Dependency-Check_Action@main + id: depcheck + with: + project: 'FluentMeet' + path: '.' + format: 'HTML' + out: 'reports' # Reports will be saved in the 'reports' directory + args: > + --failOnCVSS 7 + --enableRetired + + - name: Upload Test results + uses: actions/upload-artifact@v4 + with: + name: DepCheck report + path: reports diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..8efd1e7 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,58 @@ +name: Auto Labeler + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + issues: + types: [opened, edited] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + label-pr: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Label PR based on files changed + uses: actions/labeler@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/labeler.yml + + label-pr-size: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Label PR by size + uses: codelytv/pr-size-labeler@v1 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + xs_label: 'size/XS' + xs_max_size: 10 + s_label: 'size/S' + s_max_size: 100 + m_label: 'size/M' + m_max_size: 500 + l_label: 'size/L' + l_max_size: 1000 + xl_label: 'size/XL' + fail_if_xl: false + message_if_xl: > + This PR is quite large! Consider breaking it into smaller PRs for easier review. + + label-issue: + if: github.event_name == 'issues' + runs-on: ubuntu-latest + steps: + - name: Label issues based on content + uses: github/issue-labeler@v3.4 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/issue-labeler.yml + enable-versioned-regex: 0 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a5ee09b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,134 @@ +name: Release Versioning + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + bump_type: + description: 'Type of version bump (major, minor, patch)' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + +concurrency: + group: ${{ github.workflow }}-release + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.PAT_TOKEN }} + + - name: Determine version bump from commits + id: version_info + run: | + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + + if [ -z "$LAST_TAG" ]; then + COMMITS=$(git log --pretty=format:"%s" --no-merges) + else + COMMITS=$(git log $LAST_TAG..HEAD --pretty=format:"%s" --no-merges) + fi + + echo "Commits to analyze:" + echo "$COMMITS" + + BUMP_TYPE="patch" + + if echo "$COMMITS" | grep -qiE "(BREAKING[- ]CHANGE:|^[a-z]+(\([a-zA-Z0-9_-]+\))?!:)"; then + BUMP_TYPE="major" + echo "Found breaking change" + elif echo "$COMMITS" | grep -qiE "^feat(\([a-zA-Z0-9_-]+\))?:"; then + BUMP_TYPE="minor" + echo "Found feature" + elif echo "$COMMITS" | grep -qiE "^(fix|chore|docs|style|refactor|perf|test)(\([a-zA-Z0-9_-]+\))?:"; then + BUMP_TYPE="patch" + echo "Found patch-level change" + fi + + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + BUMP_TYPE="${{ github.event.inputs.bump_type }}" + fi + + echo "BUMP_TYPE=${BUMP_TYPE}" >> $GITHUB_ENV + echo "Determined bump type: $BUMP_TYPE" + + # Extract version from pyproject.toml + CURRENT_VERSION=$(grep -E '^version = "[0-9]+\.[0-9]+\.[0-9]+"' pyproject.toml | cut -d '"' -f 2) + echo "CURRENT_VERSION=${CURRENT_VERSION}" >> $GITHUB_ENV + echo "Current version: $CURRENT_VERSION" + + - name: Bump version and tag + env: + BUMP_TYPE: ${{ env.BUMP_TYPE }} + CURRENT_VERSION: ${{ env.CURRENT_VERSION }} + run: | + set -e + IFS='.' read -r -a VERSION_PARTS <<< "$CURRENT_VERSION" + MAJOR=${VERSION_PARTS[0]} + MINOR=${VERSION_PARTS[1]} + PATCH=${VERSION_PARTS[2]} + + if [ "$BUMP_TYPE" == "major" ]; then + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + elif [ "$BUMP_TYPE" == "minor" ]; then + MINOR=$((MINOR + 1)) + PATCH=0 + else + PATCH=$((PATCH + 1)) + fi + + NEW_VERSION="$MAJOR.$MINOR.$PATCH" + echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV + echo "New version: $NEW_VERSION" + + # Update pyproject.toml + sed -i "s/^version = \".*\"/version = \"$NEW_VERSION\"/" pyproject.toml + + # Update app/main.py + sed -i "s/version=\".*\"/version=\"$NEW_VERSION\"/" app/main.py + sed -i "s/\"version\": \".*\"/\"version\": \"$NEW_VERSION\"/" app/main.py + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add pyproject.toml app/main.py + git commit -m "chore(release): $NEW_VERSION [skip ci]" + git push origin main + git tag "v$NEW_VERSION" + git push origin "v$NEW_VERSION" + + - name: Generate changelog + id: changelog + run: | + set -e + # Get the previous tag or the first commit + LATEST_TAG=$(git describe --tags --abbrev=0 v${{ env.NEW_VERSION }}^ 2>/dev/null || git rev-list --max-parents=0 HEAD) + echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV + CHANGELOG=$(git log $LATEST_TAG..v${{ env.NEW_VERSION }}^ --pretty=format:"* %s") + echo "$CHANGELOG" > changelog.txt + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ env.NEW_VERSION }} + name: v${{ env.NEW_VERSION }} + body_path: changelog.txt + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..31bc79b --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,33 @@ +name: Stale Issues and PRs + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - name: Mark Stale Issues and PRs + uses: actions/stale@v9 + with: + days-before-stale: 30 + days-before-close: 7 + stale-issue-message: | + This issue has been automatically marked as stale because it has not had recent activity. + It will be closed in 7 days if no further activity occurs. + stale-pr-message: | + This pull request has been automatically marked as stale because it has not had recent activity. + It will be closed in 7 days if no further activity occurs. + close-issue-message: 'This issue was closed due to inactivity.' + close-pr-message: 'This PR was closed due to inactivity. Feel free to reopen if needed.' + stale-issue-label: 'stale' + stale-pr-label: 'stale' + exempt-issue-labels: 'pinned,security,bug,enhancement' + exempt-pr-labels: 'pinned,security,work-in-progress' + operations-per-run: 100 \ No newline at end of file diff --git a/README.md b/README.md index 5c58280..e2ba2bf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # FluentMeet 🎙️🌐 +![CI](https://github.com/afiaa/FluentMeet/actions/workflows/ci.yml/badge.svg) + **"Speak your language, they hear theirs."** FluentMeet is a state-of-the-art, real-time voice translation video conferencing platform. It eliminates language barriers in global professional collaborations by providing instantaneous, natural-sounding voice translation, allowing participants to communicate naturally in their native tongues. @@ -146,8 +148,7 @@ pytest ### **Test Coverage** Generate and view a coverage report: ```bash -pytest --cov=app tests/ -coverage html +pytest tests/ -v --cov=app --cov-report=html --cov-report=term # Open htmlcov/index.html in your browser ``` diff --git a/alembic/env.py b/alembic/env.py index 77c30e8..c2f3113 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -7,6 +7,8 @@ from alembic import context +from app.models import Base + # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config @@ -18,7 +20,7 @@ # add your model's MetaData object here # for 'autogenerate' support -from app.models import Base + target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, diff --git a/alembic/versions/11781e907181_initial_migration.py b/alembic/versions/11781e907181_initial_migration.py index da52255..f2ba412 100644 --- a/alembic/versions/11781e907181_initial_migration.py +++ b/alembic/versions/11781e907181_initial_migration.py @@ -1,49 +1,50 @@ """Initial migration Revision ID: 11781e907181 -Revises: +Revises: Create Date: 2026-03-12 14:00:55.007551 """ -from typing import Sequence, Union -from alembic import op -import sqlalchemy as sa +from collections.abc import Sequence +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision: str = '11781e907181' -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +revision: str = "11781e907181" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.create_table('users', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('email', sa.String(length=255), nullable=False), - sa.Column('hashed_password', sa.String(length=255), nullable=False), - sa.Column('full_name', sa.String(length=255), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('is_verified', sa.Boolean(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.Column('speaking_language', sa.String(length=10), nullable=False), - sa.Column('listening_language', sa.String(length=10), nullable=False), - sa.PrimaryKeyConstraint('id') + op.create_table( + "users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("hashed_password", sa.String(length=255), nullable=False), + sa.Column("full_name", sa.String(length=255), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("is_verified", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.Column("speaking_language", sa.String(length=10), nullable=False), + sa.Column("listening_language", sa.String(length=10), nullable=False), + sa.PrimaryKeyConstraint("id"), ) - op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) - op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_users_id'), table_name='users') - op.drop_index(op.f('ix_users_email'), table_name='users') - op.drop_table('users') + op.drop_index(op.f("ix_users_id"), table_name="users") + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_table("users") # ### end Alembic commands ### diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py index cd5d3f0..a222a35 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,5 +1,5 @@ from pydantic_settings import BaseSettings, SettingsConfigDict -from typing import Optional + class Settings(BaseSettings): PROJECT_NAME: str = "FluentMeet" @@ -16,7 +16,7 @@ class Settings(BaseSettings): POSTGRES_USER: str = "postgres" POSTGRES_PASSWORD: str = "postgres" POSTGRES_DB: str = "fluentmeet" - DATABASE_URL: Optional[str] = None + DATABASE_URL: str | None = None # Redis REDIS_HOST: str = "localhost" @@ -26,11 +26,12 @@ class Settings(BaseSettings): KAFKA_BOOTSTRAP_SERVERS: str = "localhost:9092" # External Services Keys - DEEPGRAM_API_KEY: Optional[str] = None - DEEPL_API_KEY: Optional[str] = None - VOICE_AI_API_KEY: Optional[str] = None - OPENAI_API_KEY: Optional[str] = None + DEEPGRAM_API_KEY: str | None = None + DEEPL_API_KEY: str | None = None + VOICE_AI_API_KEY: str | None = None + OPENAI_API_KEY: str | None = None model_config = SettingsConfigDict(env_file=".env", case_sensitive=True) + settings = Settings() diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/main.py b/app/main.py index c36db70..4dc3884 100644 --- a/app/main.py +++ b/app/main.py @@ -16,10 +16,13 @@ allow_headers=["*"], ) + @app.get("/health", tags=["health"]) -async def health_check(): +async def health_check() -> dict[str, str]: return {"status": "ok", "version": "1.0.0"} + if __name__ == "__main__": import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/app/models/__init__.py b/app/models/__init__.py index bad0278..29cde63 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,4 +1,4 @@ -from app.models.user import Base, User # noqa +from app.models.user import Base, User # Export all models for Alembic __all__ = ["Base", "User"] diff --git a/app/models/user.py b/app/models/user.py index c37b5f5..df02670 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,23 +1,29 @@ from datetime import datetime -from typing import Optional -from sqlalchemy import String, Boolean, DateTime -from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.ext.declarative import declarative_base -Base = declarative_base() +from sqlalchemy import Boolean, DateTime, String +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column(primary_key=True, index=True) - email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + email: Mapped[str] = mapped_column( + String(255), unique=True, index=True, nullable=False + ) hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) - full_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + full_name: Mapped[str | None] = mapped_column(String(255), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_verified: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) - updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow + ) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # Language preferences speaking_language: Mapped[str] = mapped_column(String(10), default="en") diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/user.py b/app/schemas/user.py index 2a4a2c8..2243cc2 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -1,21 +1,25 @@ -from pydantic import BaseModel, EmailStr, Field -from typing import Optional from datetime import datetime +from pydantic import BaseModel, EmailStr, Field + + class UserBase(BaseModel): email: EmailStr - full_name: Optional[str] = None + full_name: str | None = None speaking_language: str = "en" listening_language: str = "en" + class UserCreate(UserBase): password: str = Field(..., min_length=8) + class UserUpdate(BaseModel): - full_name: Optional[str] = None - speaking_language: Optional[str] = None - listening_language: Optional[str] = None - password: Optional[str] = Field(None, min_length=8) + full_name: str | None = None + speaking_language: str | None = None + listening_language: str | None = None + password: str | None = Field(None, min_length=8) + class UserResponse(UserBase): id: int @@ -26,12 +30,14 @@ class UserResponse(UserBase): class Config: from_attributes = True + class Token(BaseModel): access_token: str refresh_token: str token_type: str = "bearer" expires_in: int + class TokenData(BaseModel): - email: Optional[str] = None - jti: Optional[str] = None + email: str | None = None + jti: str | None = None diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/linting_issue.md b/linting_issue.md new file mode 100644 index 0000000..4ae748e --- /dev/null +++ b/linting_issue.md @@ -0,0 +1,33 @@ +# Issue: Enforce Linting, Type-Checking, and Code Style + +## Problem +The project currently uses `black` and `isort` for formatting, but lacks a comprehensive linter and consistent type-checking. This can lead to subtle bugs, inconsistent coding patterns, and poor maintainability as the codebase grows. There is no automated enforcement of these standards outside of the newly planned CI workflow. + +## Proposed Solution +Standardize the project's code style by adopting a modern linter (e.g., `ruff`) and fully configuring `mypy` for static type analysis. Update `pyproject.toml` to serve as the single source of truth for all linting and formatting configurations. + +## User Stories +- As a developer, I want clear feedback on code quality and style violations as I write code. +- As a reviewer, I want to spend less time on stylistic comments and more time on logic and architecture. + +## Acceptance Criteria +- [ ] `ruff` is added as a development dependency and configured in `pyproject.toml`. +- [ ] `mypy` is added as a development dependency and configured in `pyproject.toml`. +- [ ] Existing code is updated to pass all linting and type-checking rules. +- [ ] A `make lint` or similar command is available for local verification. +- [ ] Documentation is updated to include the coding standards and how to run the tools. + +## Proposed Technical Details +- Use `ruff` to replace multiple tools (flake8, autoflake, etc.) for performance and simplicity. +- Configure `ruff` rules to be strict but pragmatic (e.g., following the `B`, `E`, `F`, and `I` rule sets). +- Set up `mypy` with `strict = true` or a similar high-standard configuration to ensure type safety. +- Update `pyproject.toml` sections for `[tool.ruff]` and `[tool.mypy]`. + +## Tasks +- [ ] Add `ruff` and `mypy` to `requirements.txt` (or a new `requirements-dev.txt`). +- [ ] Configure `ruff` in `pyproject.toml`. +- [ ] Configure `mypy` in `pyproject.toml`. +- [ ] Run `ruff check . --fix` to address auto-fixable issues. +- [ ] Manually fix remaining linting violations. +- [ ] Fix type-checking errors reported by `mypy`. +- [ ] Update `README.md` with instructions for running linting tools. diff --git a/pyproject.toml b/pyproject.toml index ff464ad..e537c88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,102 @@ -[tool.black] +[project] +name = "FluentMeet" +version = "1.0.0" + +[tool.ruff] +# Exclude a variety of commonly ignored directories. +exclude = [ + "alembic", + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", +] + +# Same as Black. line-length = 88 -target-version = ['py311'] -include = '\.pyi?$' +indent-width = 4 + +# Assume Python 3.11 +target-version = "py311" + +[tool.ruff.lint] +# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +# Unlike Flake8, Ruff doesn't enable all `E` and `F` codes by default. +# See: https://docs.astral.sh/ruff/rules/ +select = ["B", "E", "F", "I", "W", "C90", "UP", "ASYNC", "PT", "ARG", "PTH", "SIM", "PLE", "PLW", "RUF"] +ignore = [] + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" [tool.isort] profile = "black" -line_length = 88 -multi_line_output = 3 -include_trailing_comma = true -force_grid_wrap = 0 -use_parentheses = true -ensure_newline_before_comments = true +skip = ["alembic"] + +[tool.mypy] +python_version = "3.11" +explicit_package_bases = true +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = false +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_optional = true +plugins = ["pydantic.mypy"] + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true + +[[tool.mypy.overrides]] +module = [ + "sqlalchemy.*", + "alembic.*", + "uvicorn.*", + "jose.*", + "passlib.*", + "slowapi.*", + "cloudinary.*", + "mailgun2.*", + "resend.*" +] +ignore_missing_imports = true diff --git a/requirements.txt b/requirements.txt index fd9be3c..e1c8688 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,6 +23,7 @@ charset-normalizer==3.4.5 click==8.3.1 cloudinary==1.44.1 colorama==0.4.6 +coverage==7.13.4 cryptography==46.0.5 deepgram-sdk==6.0.1 Deprecated==1.3.1 @@ -30,7 +31,7 @@ distro==1.9.0 dnspython==2.8.0 ecdsa==0.19.1 email-validator==2.3.0 -fastapi[all]==0.135.1 +fastapi==0.135.1 fastapi-cli==0.0.24 fastapi-cloud-cli==0.15.0 fastar==0.8.0 @@ -48,6 +49,7 @@ itsdangerous==2.2.0 Jinja2==3.1.6 jiter==0.13.0 jmespath==1.1.0 +librt==0.8.1 limits==5.8.0 mailgun2==2.0.1 Mako==1.3.10 @@ -55,6 +57,7 @@ markdown-it-py==4.0.0 MarkupSafe==3.0.3 mdurl==0.1.2 multidict==6.7.1 +mypy==1.19.1 mypy_extensions==1.1.0 openai==2.26.0 packaging==26.0 @@ -72,6 +75,7 @@ pydantic_core==2.41.5 Pygments==2.19.2 pytest==9.0.2 pytest-asyncio==1.3.0 +pytest-cov==7.0.0 python-dateutil==2.9.0.post0 python-dotenv==1.2.2 python-jose==3.5.0 @@ -85,6 +89,7 @@ rich==14.3.3 rich-toolkit==0.19.7 rignore==0.7.6 rsa==4.9.1 +ruff==0.15.6 s3transfer==0.14.0 sentry-sdk==2.54.0 shellingham==1.5.4 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_main.py b/tests/test_main.py index e9c52d3..563bbae 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,8 +1,10 @@ from fastapi.testclient import TestClient + from app.main import app client = TestClient(app) + def test_health_check(): response = client.get("/health") assert response.status_code == 200