From 3761f4b55055d2625ffd5407f7b31761e479bdb3 Mon Sep 17 00:00:00 2001 From: bnbong Date: Thu, 10 Sep 2026 11:19:36 +0900 Subject: [PATCH 1/2] [FIX, TEMPLATE] fix templates, inspector --- .gitattributes | 33 +++ .github/workflows/template-inspection.yml | 7 + .github/workflows/template-pr-inspection.yml | 7 + .github/workflows/template-security-scan.yml | 144 +++++---- CHANGELOG.md | 14 + docs/en/tutorial/mcp-integration.md | 22 +- docs/en/user-guide/cli-reference.md | 2 +- docs/en/user-guide/creating-projects.md | 2 +- pdm.lock | 58 ++-- pyproject.toml | 12 +- requirements-docs.txt | 6 +- src/fastapi_fastkit/__init__.py | 2 +- .../backend/inspection/context.py | 4 + .../backend/inspection/core.py | 6 +- .../backend/inspection/docker.py | 165 ++++++++++- .../backend/inspection/lint.py | 74 +++-- .../backend/inspection/smoke.py | 80 ++++- .../backend/inspection/strategies.py | 33 ++- src/fastapi_fastkit/backend/transducer.py | 103 ++++++- src/fastapi_fastkit/core/settings.py | 6 +- .../fastapi-mcp/.env-tpl | 2 +- .../fastapi-mcp/README.md-tpl | 2 +- .../fastapi-mcp/pyproject.toml-tpl | 5 +- .../fastapi-mcp/requirements.txt-tpl | 12 +- .../fastapi-mcp/setup.cfg-tpl | 5 +- .../fastapi-mcp/src/api/routes/auth.py-tpl | 18 +- .../fastapi-mcp/src/auth/dependencies.py-tpl | 4 +- .../fastapi-mcp/src/core/config.py-tpl | 2 +- .../fastapi-psql-orm/.env-tpl | 2 + src/fastapi_fastkit/fragments/auth/jwt.py.j2 | 12 +- tests/test_backends/test_inspection_docker.py | 276 +++++++++++++++++- tests/test_backends/test_inspector.py | 150 +++++++++- tests/test_backends/test_inspector_checks.py | 178 ++++++++++- .../test_interactive_config_builder.py | 4 +- .../test_interactive_validators.py | 2 +- .../test_project_builder_config_generator.py | 4 +- ...st_project_builder_dependency_collector.py | 8 +- tests/test_backends/test_transducer.py | 100 +++++++ .../test_cli_config_options.py | 2 +- uv.lock | 55 ++-- 40 files changed, 1376 insertions(+), 247 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..534eda1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,33 @@ +# Normalise line endings so template files are never checked out with CRLF. +# +# A generated project's shell scripts run inside Linux containers; a CRLF +# `scripts/pre-start.sh` makes the app container die with +# `env: 'bash\r': No such file or directory`. Contributors on Windows (or with +# `core.autocrlf=true`) would otherwise ship CRLF into the templates. + +# Let Git decide for anything it detects as text, but never rewrite to CRLF. +* text=auto eol=lf + +# Template payload files (the `-tpl` marker is stripped at generation time). +*-tpl text eol=lf +*.sh-tpl text eol=lf +Dockerfile-tpl text eol=lf +Makefile-tpl text eol=lf + +# Scripts and container files in this repository itself. +*.sh text eol=lf +*.bash text eol=lf +Dockerfile text eol=lf +Makefile text eol=lf + +# Binary assets must never be touched. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.zip binary +*.gz binary +*.woff binary +*.woff2 binary diff --git a/.github/workflows/template-inspection.yml b/.github/workflows/template-inspection.yml index 6d2df2c..31c2429 100644 --- a/.github/workflows/template-inspection.yml +++ b/.github/workflows/template-inspection.yml @@ -29,10 +29,17 @@ jobs: - name: Setup PDM uses: pdm-project/setup-pdm@v4 + with: + # Pin the interpreter: without this PDM may resolve a newer runtime + # than the one actions/setup-python provisioned. + python-version: "3.12" - name: Install dependencies run: pdm install -G dev + - name: Verify Python version + run: pdm run python --version + - name: Install UV package manager run: | curl -LsSf https://astral.sh/uv/install.sh | sh diff --git a/.github/workflows/template-pr-inspection.yml b/.github/workflows/template-pr-inspection.yml index bcf857d..f759082 100644 --- a/.github/workflows/template-pr-inspection.yml +++ b/.github/workflows/template-pr-inspection.yml @@ -29,10 +29,17 @@ jobs: - name: Setup PDM uses: pdm-project/setup-pdm@v4 + with: + # Pin the interpreter: without this PDM may resolve a newer runtime + # than the one actions/setup-python provisioned. + python-version: "3.12" - name: Install dependencies run: pdm install -G dev + - name: Verify Python version + run: pdm run python --version + - name: Setup UV uses: astral-sh/setup-uv@v7 diff --git a/.github/workflows/template-security-scan.yml b/.github/workflows/template-security-scan.yml index 821f5c9..d5682cd 100644 --- a/.github/workflows/template-security-scan.yml +++ b/.github/workflows/template-security-scan.yml @@ -32,20 +32,15 @@ jobs: - name: Run security scan on templates id: scan + env: + TEMPLATES_INPUT: ${{ github.event.inputs.templates }} run: | TEMPLATE_DIR="src/fastapi_fastkit/fastapi_project_template" RESULTS_FILE="security_scan_results.json" SCAN_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - - # Initialize results - echo '{' > $RESULTS_FILE - echo ' "scan_date": "'$SCAN_DATE'",' >> $RESULTS_FILE - echo ' "templates": [' >> $RESULTS_FILE - - TEMPLATES_INPUT="${{ github.event.inputs.templates }}" - FIRST_TEMPLATE=true - TOTAL_VULNERABILITIES=0 - AFFECTED_TEMPLATES="" + TEMP_DIR=$(mktemp -d) + MANIFEST="$TEMP_DIR/manifest.txt" + : > "$MANIFEST" for template_dir in $TEMPLATE_DIR/fastapi-*/; do template_name=$(basename "$template_dir") @@ -65,44 +60,83 @@ jobs: temp_req=$(mktemp) cp "$req_file" "$temp_req" - # Run pip-audit and capture output - audit_output=$(pip-audit -r "$temp_req" --format json 2>/dev/null || echo '[]') - rm "$temp_req" - - # Count vulnerabilities - vuln_count=$(echo "$audit_output" | python3 -c "import sys, json; data = json.load(sys.stdin); print(len(data))" 2>/dev/null || echo "0") - - if [ "$vuln_count" -gt 0 ]; then - TOTAL_VULNERABILITIES=$((TOTAL_VULNERABILITIES + vuln_count)) - AFFECTED_TEMPLATES="$AFFECTED_TEMPLATES $template_name" - echo "⚠️ Found $vuln_count vulnerabilities in $template_name" - else - echo "✅ No vulnerabilities in $template_name" - fi + audit_file="$TEMP_DIR/${template_name}.json" + err_file="$TEMP_DIR/${template_name}.err" - # Add to JSON - if [ "$FIRST_TEMPLATE" = true ]; then - FIRST_TEMPLATE=false - else - echo ' ,' >> $RESULTS_FILE + if ! pip-audit -r "$temp_req" --format json > "$audit_file" 2>"$err_file"; then + echo "⚠️ pip-audit failed for $template_name (recorded in results)" fi + rm -f "$temp_req" - echo ' {' >> $RESULTS_FILE - echo ' "name": "'$template_name'",' >> $RESULTS_FILE - echo ' "vulnerability_count": '$vuln_count',' >> $RESULTS_FILE - echo ' "vulnerabilities": '$audit_output >> $RESULTS_FILE - echo ' }' >> $RESULTS_FILE + echo "$template_name" >> "$MANIFEST" fi done - echo ' ],' >> $RESULTS_FILE - echo ' "total_vulnerabilities": '$TOTAL_VULNERABILITIES',' >> $RESULTS_FILE - echo ' "affected_templates": "'$(echo $AFFECTED_TEMPLATES | xargs)'"' >> $RESULTS_FILE - echo '}' >> $RESULTS_FILE - - # Set outputs for later steps - echo "total_vulnerabilities=$TOTAL_VULNERABILITIES" >> $GITHUB_OUTPUT - echo "affected_templates=$AFFECTED_TEMPLATES" >> $GITHUB_OUTPUT + # Assemble valid JSON results (and never do it via shell string concatenation) + TEMP_DIR="$TEMP_DIR" RESULTS_FILE="$RESULTS_FILE" SCAN_DATE="$SCAN_DATE" python3 - <<'PYEOF' + import json + import os + + temp_dir = os.environ["TEMP_DIR"] + results_file = os.environ["RESULTS_FILE"] + scan_date = os.environ["SCAN_DATE"] + + manifest_path = os.path.join(temp_dir, "manifest.txt") + with open(manifest_path) as f: + template_names = [line.strip() for line in f if line.strip()] + + templates = [] + total_vulnerabilities = 0 + affected_templates = [] + + for name in template_names: + audit_file = os.path.join(temp_dir, f"{name}.json") + err_file = os.path.join(temp_dir, f"{name}.err") + + entry = {"template": name, "vulnerability_count": 0, "vulnerabilities": []} + + try: + with open(audit_file) as af: + data = json.load(af) + dependencies = data.get("dependencies", []) + vulnerable_deps = [dep for dep in dependencies if dep.get("vulns")] + vuln_count = sum(len(dep.get("vulns", [])) for dep in dependencies) + entry["vulnerability_count"] = vuln_count + entry["vulnerabilities"] = vulnerable_deps + except Exception as exc: + error_msg = "" + if os.path.exists(err_file): + with open(err_file) as ef: + error_msg = ef.read().strip() + entry["error"] = error_msg or str(exc) + + if entry["vulnerability_count"] > 0: + total_vulnerabilities += entry["vulnerability_count"] + affected_templates.append(name) + print(f"⚠️ Found {entry['vulnerability_count']} vulnerabilities in {name}") + elif "error" not in entry: + print(f"✅ No vulnerabilities in {name}") + else: + print(f"❌ pip-audit failed for {name}: {entry['error']}") + + templates.append(entry) + + results = { + "scan_date": scan_date, + "templates": templates, + "total_vulnerabilities": total_vulnerabilities, + "affected_templates": " ".join(affected_templates), + } + + with open(results_file, "w") as rf: + json.dump(results, rf, indent=2) + + github_output = os.environ.get("GITHUB_OUTPUT") + if github_output: + with open(github_output, "a") as gh: + gh.write(f"total_vulnerabilities={total_vulnerabilities}\n") + gh.write(f"affected_templates={' '.join(affected_templates)}\n") + PYEOF - name: Upload scan results uses: actions/upload-artifact@v4 @@ -127,15 +161,27 @@ jobs: const results = JSON.parse(fs.readFileSync('security_scan_results.json', 'utf8')); results.templates.forEach(template => { + if (template.error) { + issueBody += `### ❌ ${template.template}\n\n`; + issueBody += `pip-audit failed to scan this template:\n\n`; + issueBody += "```\n" + template.error + "\n```\n\n"; + return; + } + if (template.vulnerability_count > 0) { - issueBody += `### ⚠️ ${template.name}\n\n`; + issueBody += `### ⚠️ ${template.template}\n\n`; issueBody += `Found **${template.vulnerability_count}** vulnerabilities:\n\n`; - issueBody += `| Package | Installed | Fix Versions | Vulnerability ID |\n`; - issueBody += `|---------|-----------|--------------|------------------|\n`; - - template.vulnerabilities.forEach(vuln => { - const fixVersions = vuln.fix_versions ? vuln.fix_versions.join(', ') : 'N/A'; - issueBody += `| ${vuln.name} | ${vuln.version} | ${fixVersions} | ${vuln.id} |\n`; + issueBody += `| Package | Installed | Fix Versions | Vulnerability ID / Aliases |\n`; + issueBody += `|---------|-----------|--------------|------------------------------|\n`; + + template.vulnerabilities.forEach(dep => { + (dep.vulns || []).forEach(vuln => { + const fixVersions = (vuln.fix_versions && vuln.fix_versions.length) + ? vuln.fix_versions.join(', ') + : 'no fix available'; + const idAliases = [vuln.id, ...(vuln.aliases || [])].filter(Boolean).join(', '); + issueBody += `| ${dep.name} | ${dep.version} | ${fixVersions} | ${idAliases} |\n`; + }); }); issueBody += `\n`; diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bff2ab..72d1d18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## v1.4.1 (2026-09-10) + +### Security + +- `python-jose` → `PyJWT[crypto]` in the JWT / FastAPI-Users catalog entries and the `fastapi-mcp` template (drops the unpatched `ecdsa` CVE-2024-23342); docs dependency floors raised for Dependabot advisories (mkdocs-material ≥ 9.7.7, pymdown-extensions ≥ 11.0.1, idna ≥ 3.15; urllib3 / virtualenv / filelock refreshed in lock files). +- `passlib[bcrypt]` → `pwdlib[argon2]` in the JWT / FastAPI-Users catalog entries and the `fastapi-mcp` template (passlib is unmaintained and pinned bcrypt to 4.0.1; generated projects now hash with argon2id like `fastapi-auth-jwt`). + +### Fixes + +- Template inspector's compile check no longer writes bytecode and runs before the Docker test step (fixes the weekly `fastapi-psql-orm` PermissionError); Docker strategy reclaims bind-mount ownership after container runs; CI pins PDM's interpreter to 3.12; security-scan workflow now builds a valid JSON report and counts vulnerabilities correctly. +- Template inspector parses every `docker-compose ps --format json` shape (single JSON array on Compose < 2.21, NDJSON on newer releases) and falls back to the `docker compose` plugin when the standalone binary is missing. +- Docker-backed templates are smoke-tested against the container's published port instead of requiring a host virtualenv (the weekly `fastapi-psql-orm` run now reaches and passes the smoke step). +- `startdemo` / `init` normalize copied text files to LF and keep executable bits, and a new `.gitattributes` pins template line endings, so shell scripts generated on Windows or with `core.autocrlf=true` no longer fail with `bash\r: No such file or directory`. `fastapi-psql-orm` `.env` now sets `ENVIRONMENT=development`. + ## v1.4.0 (2026-09-04) ### Features diff --git a/docs/en/tutorial/mcp-integration.md b/docs/en/tutorial/mcp-integration.md index 66367b3..3b4b9c9 100644 --- a/docs/en/tutorial/mcp-integration.md +++ b/docs/en/tutorial/mcp-integration.md @@ -68,8 +68,8 @@ Deploying FastAPI project using 'fastapi-mcp' template │ Dependency 1 │ fastapi │ │ Dependency 2 │ uvicorn │ │ Dependency 3 │ pydantic │ -│ Dependency 4 │ python-jose │ -│ Dependency 5 │ passlib │ +│ Dependency 4 │ PyJWT │ +│ Dependency 5 │ pwdlib[argon2] │ │ Dependency 6 │ python-multipart│ │ Dependency 7 │ mcp │ └──────────────┴────────────────┘ @@ -131,21 +131,21 @@ ai-integrated-api/ ```python from datetime import datetime, timedelta from typing import Optional, Dict, Any -from jose import JWTError, jwt -from passlib.context import CryptContext +import jwt +from pwdlib import PasswordHash from src.core.config import settings -# Password hashing -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +# Password hashing — argon2id, the recommended default for new applications. +password_hash = PasswordHash.recommended() def verify_password(plain_password: str, hashed_password: str) -> bool: """Password verification""" - return pwd_context.verify(plain_password, hashed_password) + return password_hash.verify(plain_password, hashed_password) def get_password_hash(password: str) -> str: """Password hashing""" - return pwd_context.hash(password) + return password_hash.hash(password) def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: """Access token generation""" @@ -189,7 +189,7 @@ def decode_token(token: str) -> Optional[Dict[str, Any]]: algorithms=[settings.ALGORITHM] ) return payload - except JWTError: + except jwt.PyJWTError: return None def verify_token(token: str, token_type: str = "access") -> Optional[str]: @@ -452,8 +452,6 @@ user_db = UserDatabase() from typing import Optional, List from fastapi import Depends, HTTPException, status, Security from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials, APIKeyHeader -from jose import JWTError - from src.auth.jwt_handler import decode_token, token_manager from src.auth.models import User, UserInDB, Permission, user_db @@ -486,7 +484,7 @@ async def get_current_user( if user_id is None: raise credentials_exception - except JWTError: + except jwt.PyJWTError: raise credentials_exception user = user_db.get_user_by_id(user_id) diff --git a/docs/en/user-guide/cli-reference.md b/docs/en/user-guide/cli-reference.md index 8f8950d..56b1e9d 100644 --- a/docs/en/user-guide/cli-reference.md +++ b/docs/en/user-guide/cli-reference.md @@ -265,7 +265,7 @@ warnings) differ. | Axis | Choices | Installed packages | Generated files | |---|---|---|---| | `database` | PostgreSQL, MySQL, MongoDB, Redis, SQLite, None | PostgreSQL: `asyncpg`, `sqlalchemy` · MySQL: `aiomysql`, `sqlalchemy` · MongoDB: `motor` · Redis: `redis[hiredis]` · SQLite: `sqlalchemy`, `aiosqlite` | A database config module at the preset's path (e.g. `src/config/database.py`) | -| `authentication` | JWT, OAuth2, FastAPI-Users, Session-based, None | JWT: `python-jose[cryptography]`, `passlib[bcrypt]` · OAuth2: `authlib`, `itsdangerous`, `httpx` · FastAPI-Users: `fastapi-users[sqlalchemy]`, `python-jose[cryptography]`, `passlib[bcrypt]` · Session-based: `itsdangerous` | An auth config module at the preset's path; OAuth2 and Session-based also add `main.py` middleware setup | +| `authentication` | JWT, OAuth2, FastAPI-Users, Session-based, None | JWT: `pyjwt[crypto]`, `pwdlib[argon2]` · OAuth2: `authlib`, `itsdangerous`, `httpx` · FastAPI-Users: `fastapi-users[sqlalchemy]`, `pyjwt[crypto]`, `pwdlib[argon2]` · Session-based: `itsdangerous` | An auth config module at the preset's path; OAuth2 and Session-based also add `main.py` middleware setup | | `async_tasks` | Celery, Dramatiq, None | Celery: `celery[redis]`, `redis[hiredis]` · Dramatiq: `dramatiq[redis]`, `redis[hiredis]` | `/worker.py` (background worker) + `/features/tasks.py` (task routes) | | `testing` | Basic, Coverage, Advanced, None | Basic: `pytest`, `pytest-asyncio`, `httpx` · Coverage: + `pytest-cov` · Advanced: + `faker`, `factory-boy` | `pytest.ini`; Advanced also adds `tests/factories.py` and `tests/test_factories.py` | | `caching` | Redis, None | Redis: `redis[hiredis]`, `fastapi-cache2`, `jinja2` | `/features/cache.py` (cached endpoints) | diff --git a/docs/en/user-guide/creating-projects.md b/docs/en/user-guide/creating-projects.md index 0859362..a2e5ab7 100644 --- a/docs/en/user-guide/creating-projects.md +++ b/docs/en/user-guide/creating-projects.md @@ -462,7 +462,7 @@ After project creation, you can add more dependencies:
```console -$ pip install requests httpx python-jose +$ pip install requests httpx pyjwt $ pip freeze > requirements.txt ``` diff --git a/pdm.lock b/pdm.lock index b939c7a..35e01e1 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev", "docs", "translation"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:3688fb0209beb1bc607ee7d6160bf9fd5474984277707edd4c96cae9484aff48" +content_hash = "sha256:f1a31fc9cbaa1b093f0abc420939719747239767abb6a8c3132612289dcd776f" [[metadata.targets]] requires_python = ">=3.12" @@ -467,13 +467,13 @@ files = [ [[package]] name = "filelock" -version = "3.28.0" +version = "3.32.6" requires_python = ">=3.10" summary = "A platform independent file lock." groups = ["dev"] files = [ - {file = "filelock-3.28.0-py3-none-any.whl", hash = "sha256:de9af6712788e7171df1b28b15eba2446c69721433fa427a9bee07b17820a9db"}, - {file = "filelock-3.28.0.tar.gz", hash = "sha256:4ed1010aae813c4ee8d9c660e4792475ee60c4a0ba76073ceaf862bd317e3ca6"}, + {file = "filelock-3.32.6-py3-none-any.whl", hash = "sha256:3f16ecd0117feae0dfc147e8c62eb5daeccd8bd800378c3ddf416de9b4feb6b1"}, + {file = "filelock-3.32.6.tar.gz", hash = "sha256:a3f55a18af3652a94d8f47d6055df434f254ca1d02ef2524850c6d249ca2512c"}, ] [[package]] @@ -545,13 +545,13 @@ files = [ [[package]] name = "idna" -version = "3.11" -requires_python = ">=3.8" +version = "3.19" +requires_python = ">=3.9" summary = "Internationalized Domain Names in Applications (IDNA)" groups = ["docs", "translation"] files = [ - {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, - {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, + {file = "idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4"}, + {file = "idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15"}, ] [[package]] @@ -896,7 +896,7 @@ files = [ [[package]] name = "mkdocs-material" -version = "9.7.6" +version = "9.7.7" requires_python = ">=3.8" summary = "Documentation that simply works" groups = ["docs"] @@ -914,8 +914,8 @@ dependencies = [ "requests>=2.30", ] files = [ - {file = "mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba"}, - {file = "mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69"}, + {file = "mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f"}, + {file = "mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855"}, ] [[package]] @@ -1208,8 +1208,8 @@ files = [ [[package]] name = "pymdown-extensions" -version = "10.21.2" -requires_python = ">=3.9" +version = "11.0.2" +requires_python = ">=3.10" summary = "Extension pack for Python Markdown." groups = ["docs"] dependencies = [ @@ -1217,8 +1217,8 @@ dependencies = [ "pyyaml", ] files = [ - {file = "pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638"}, - {file = "pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc"}, + {file = "pymdown_extensions-11.0.2-py3-none-any.whl", hash = "sha256:259910762019732caa1dfd76f3faa62c59f191d46573e80bcb1d13c0f675bbe5"}, + {file = "pymdown_extensions-11.0.2.tar.gz", hash = "sha256:9506fcbe66fa355a775b768084334238dd6805020ac4b92bea0c0dda6f8f223d"}, ] [[package]] @@ -1273,17 +1273,16 @@ files = [ [[package]] name = "python-discovery" -version = "1.2.2" +version = "1.6.0" requires_python = ">=3.8" summary = "Python interpreter discovery" groups = ["dev"] dependencies = [ "filelock>=3.15.4", - "platformdirs<5,>=4.3.6", ] files = [ - {file = "python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a"}, - {file = "python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb"}, + {file = "python_discovery-1.6.0-py3-none-any.whl", hash = "sha256:d4e244cf17b8b29819ed78003d55fbacf86eda23425b075454fff9271b79377a"}, + {file = "python_discovery-1.6.0.tar.gz", hash = "sha256:6393b4eae1be8b2182670635e7baff89ac21cb9f8e86fd1ff40c7b1144febb4c"}, ] [[package]] @@ -1463,7 +1462,7 @@ name = "types-pyyaml" version = "6.0.12.20260408" requires_python = ">=3.10" summary = "Typing stubs for PyYAML" -groups = ["docs"] +groups = ["dev", "docs"] files = [ {file = "types_pyyaml-6.0.12.20260408-py3-none-any.whl", hash = "sha256:fbc42037d12159d9c801ebfcc79ebd28335a7c13b08a4cfbc6916df78fee9384"}, {file = "types_pyyaml-6.0.12.20260408.tar.gz", hash = "sha256:92a73f2b8d7f39ef392a38131f76b970f8c66e4c42b3125ae872b7c93b556307"}, @@ -1496,33 +1495,32 @@ files = [ [[package]] name = "urllib3" -version = "2.6.3" -requires_python = ">=3.9" +version = "2.7.0" +requires_python = ">=3.10" summary = "HTTP library with thread-safe connection pooling, file post, and more." groups = ["docs"] files = [ - {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, - {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, + {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, + {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, ] [[package]] name = "virtualenv" -version = "21.2.4" -requires_python = ">=3.8" +version = "21.7.9" +requires_python = ">=3.9" summary = "Virtual Python Environment builder" groups = ["dev"] dependencies = [ "distlib<1,>=0.3.7", "filelock<4,>=3.24.2; python_version >= \"3.10\"", "filelock<=3.19.1,>=3.16.1; python_version < \"3.10\"", - "importlib-metadata>=6.6; python_version < \"3.8\"", "platformdirs<5,>=3.9.1", - "python-discovery>=1.2.2", + "python-discovery>=1.6", "typing-extensions>=4.13.2; python_version < \"3.11\"", ] files = [ - {file = "virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac"}, - {file = "virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada"}, + {file = "virtualenv-21.7.9-py3-none-any.whl", hash = "sha256:ba3b0bb41063c848d84d76a9fe3fb7711aaa0f2fe78708e8f3cd9714770e4eac"}, + {file = "virtualenv-21.7.9.tar.gz", hash = "sha256:a7e42d81d779dec8afd7dc4be71640fb959ea861bccfa5980cb4ad9f92e30675"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 6f24d50..ba899fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,8 @@ distribution = true [dependency-groups] docs = [ "mkdocs>=1.6.1", - "mkdocs-material>=9.6.15", + # mkdocs-material < 9.7.7 — DOM XSS in the client-side search integration. + "mkdocs-material>=9.7.7", "mdx-include>=1.4.2", "mkdocs-static-i18n>=1.3.0", "pyyaml>=6.0.3", @@ -94,11 +95,14 @@ docs = [ # Security floors for transitive deps surfaced by Dependabot: # pygments < 2.20.0 — ReDoS in GUID regex # requests < 2.33.0 — insecure temp file reuse in extract_zipped_paths + # idna < 3.15 — quadratic complexity in IDNA decoding (transitive via requests) "pygments>=2.20.0", "requests>=2.33.0", - # pymdown-extensions < 10.17.2 crashes on pygments 2.20+ (NoneType filename - # passed to html.escape); see pymdown-extensions#2580. - "pymdown-extensions>=10.17.2", + "idna>=3.15", + # pymdown-extensions <= 11.0.0 — ReDoS plus two path-traversal advisories; + # 10.17.2+ was already required because earlier releases crash on pygments + # 2.20+ (NoneType filename passed to html.escape), see pymdown-extensions#2580. + "pymdown-extensions>=11.0.1", ] translation = [ "openai>=2.8.1", diff --git a/requirements-docs.txt b/requirements-docs.txt index 0c99c19..c750349 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -9,7 +9,7 @@ click==8.3.2 colorama==0.4.6 cyclic==1.0.0 ghp-import==2.1.0 -idna==3.11 +idna==3.19 jinja2==3.1.6 markdown==3.10.2 markupsafe==3.0.3 @@ -17,7 +17,7 @@ mdx-include==1.4.2 mergedeep==1.3.4 mkdocs==1.6.1 mkdocs-get-deps==0.2.2 -mkdocs-material==9.7.6 +mkdocs-material==9.7.7 mkdocs-material-extensions==1.3.1 mkdocs-static-i18n==1.3.1 packaging==26.1 @@ -25,7 +25,7 @@ paginate==0.5.7 pathspec==1.0.4 platformdirs==4.9.6 pygments==2.20.0 -pymdown-extensions==10.21.2 +pymdown-extensions==11.0.2 python-dateutil==2.9.0.post0 pyyaml==6.0.3 pyyaml-env-tag==1.1 diff --git a/src/fastapi_fastkit/__init__.py b/src/fastapi_fastkit/__init__.py index 3e8d9f9..bf25615 100644 --- a/src/fastapi_fastkit/__init__.py +++ b/src/fastapi_fastkit/__init__.py @@ -1 +1 @@ -__version__ = "1.4.0" +__version__ = "1.4.1" diff --git a/src/fastapi_fastkit/backend/inspection/context.py b/src/fastapi_fastkit/backend/inspection/context.py index dbc7331..1e956bc 100644 --- a/src/fastapi_fastkit/backend/inspection/context.py +++ b/src/fastapi_fastkit/backend/inspection/context.py @@ -43,6 +43,10 @@ class InspectionContext: warnings: List[str] = field(default_factory=list) #: Populated by a test strategy so later checks can reuse the environment. venv_path: Optional[str] = None + #: Populated when a test strategy already exercised the app's HTTP surface + #: (the Docker strategy probes the running container), so the Smoke Test + #: step reuses that verdict instead of booting a second server. + smoke_result: Optional[bool] = None def add_error(self, message: str) -> None: """Record a fatal finding.""" diff --git a/src/fastapi_fastkit/backend/inspection/core.py b/src/fastapi_fastkit/backend/inspection/core.py index 358ddd0..b66c7a3 100644 --- a/src/fastapi_fastkit/backend/inspection/core.py +++ b/src/fastapi_fastkit/backend/inspection/core.py @@ -252,8 +252,12 @@ def inspect_template(self) -> bool: ("Configuration Consistency", self._check_configuration_consistency), ("FastAPI Implementation", self._check_fastapi_implementation), ("Placeholder Substitution", self._check_no_placeholder_residue), - ("Template Tests", self._test_template), + # Compile Check is a pure static check: it runs before the (much + # heavier) Template Tests so a syntax error is reported without + # waiting for an environment, and so it never observes files a + # containerised test run left behind. ("Compile Check", self._check_compileall), + ("Template Tests", self._test_template), ("Type Check", self._check_mypy), ("Smoke Test", self._check_smoke_test), ("Dependency Freshness", self._check_dependency_freshness), diff --git a/src/fastapi_fastkit/backend/inspection/docker.py b/src/fastapi_fastkit/backend/inspection/docker.py index 9c6212d..c07ccd8 100644 --- a/src/fastapi_fastkit/backend/inspection/docker.py +++ b/src/fastapi_fastkit/backend/inspection/docker.py @@ -7,6 +7,7 @@ # @author bnbong # -------------------------------------------------------------------------- import json +import os import subprocess import time from typing import Any, Dict, List, Optional, Sequence @@ -14,6 +15,12 @@ from fastapi_fastkit.utils.logging import debug_log COMPOSE_COMMAND = "docker-compose" +#: Fallback for hosts that only ship the ``docker compose`` CLI plugin. +COMPOSE_PLUGIN_COMMAND = ["docker", "compose"] +#: Resolved compose invocation, switched by :meth:`DockerCompose.is_available`. +_compose_prefix: List[str] = [COMPOSE_COMMAND] +#: Throwaway image used to give bind-mounted files back to the host user. +RECLAIM_IMAGE = "alpine:3.20" SHORT_TIMEOUT = 10 STATUS_TIMEOUT = 30 CLEANUP_TIMEOUT = 60 @@ -43,27 +50,49 @@ def _run( debug_log(f"Command {' '.join(args)} failed: {e}", "warning") return None + @staticmethod + def compose_command() -> List[str]: + """Return the compose invocation resolved by :meth:`is_available`.""" + return list(_compose_prefix) + def _compose( self, args: Sequence[str], timeout: int ) -> Optional[subprocess.CompletedProcess[str]]: """Run a docker-compose subcommand against the configured compose file.""" return self._run( - [COMPOSE_COMMAND, "-f", self.compose_file, *args], timeout=timeout + [*self.compose_command(), "-f", self.compose_file, *args], timeout=timeout ) + @staticmethod + def _probe(command: Sequence[str]) -> bool: + """Return whether ``command`` exits successfully.""" + try: + result = subprocess.run( + list(command), capture_output=True, text=True, timeout=SHORT_TIMEOUT + ) + except (subprocess.TimeoutExpired, OSError): + return False + return result.returncode == 0 + @staticmethod def is_available() -> bool: - """Check that both docker and docker-compose respond.""" - for command in (["docker", "--version"], [COMPOSE_COMMAND, "--version"]): - try: - result = subprocess.run( - command, capture_output=True, text=True, timeout=SHORT_TIMEOUT - ) - except (subprocess.TimeoutExpired, OSError): - return False - if result.returncode != 0: - return False - return True + """Check that docker responds and a compose implementation exists. + + ``docker-compose`` stays the preferred command; hosts that only ship + the ``docker compose`` CLI plugin fall back to it. + """ + global _compose_prefix + + if not DockerCompose._probe(["docker", "--version"]): + return False + if DockerCompose._probe([COMPOSE_COMMAND, "--version"]): + _compose_prefix = [COMPOSE_COMMAND] + return True + if DockerCompose._probe([*COMPOSE_PLUGIN_COMMAND, "version"]): + debug_log("Falling back to the 'docker compose' CLI plugin", "info") + _compose_prefix = list(COMPOSE_PLUGIN_COMMAND) + return True + return False def _services(self, timeout: int) -> List[Dict[str, Any]]: """Return the parsed ``docker-compose ps --format json`` entries.""" @@ -71,8 +100,34 @@ def _services(self, timeout: int) -> List[Dict[str, Any]]: if result is None or result.returncode != 0: return [] + # Compose sometimes interleaves ``time="..." level=warning`` log lines + # with the JSON payload, so only JSON-looking lines are considered. + payload = "\n".join( + line + for line in result.stdout.splitlines() + if line.strip().startswith(("[", "{")) + ).strip() + if not payload: + return [] + + # Compose < v2.21 prints a single JSON array, newer versions print one + # JSON object per line (NDJSON). + try: + document = json.loads(payload) + except json.JSONDecodeError: + return DockerCompose._parse_json_lines(payload) + + if isinstance(document, dict): + return [document] + if isinstance(document, list): + return [entry for entry in document if isinstance(entry, dict)] + return [] + + @staticmethod + def _parse_json_lines(payload: str) -> List[Dict[str, Any]]: + """Parse NDJSON output, skipping malformed or non-object lines.""" services: List[Dict[str, Any]] = [] - for line in result.stdout.strip().split("\n"): + for line in payload.split("\n"): if not line.strip(): continue try: @@ -109,7 +164,7 @@ def up(self, timeout: int) -> subprocess.CompletedProcess[str]: """Build and start the compose services in the background.""" result = self._compose(["up", "-d", "--build"], timeout=timeout) if result is None: - raise subprocess.TimeoutExpired(COMPOSE_COMMAND, timeout) + raise subprocess.TimeoutExpired(" ".join(self.compose_command()), timeout) return result def wait_until_healthy(self, timeout: int) -> None: @@ -166,6 +221,50 @@ def verify_services_running(self) -> Optional[str]: debug_log("All required services are running", "info") return None + def published_port(self, service_hint: str = "app") -> Optional[int]: + """Return the host port the app container publishes, if any. + + ``docker-compose ps --format json`` reports a ``Publishers`` list per + service; an entry with a non-zero ``PublishedPort`` is a port bound on + the host, which is what a smoke test can reach. Services whose name + contains ``service_hint`` are preferred, so a database that happens to + publish a port is never mistaken for the application. + """ + services = self._services(timeout=STATUS_TIMEOUT) + if not services: + return None + + def _matches(service: Dict[str, Any]) -> bool: + name = f"{service.get('Service', '')} {service.get('Name', '')}" + return service_hint in name + + for candidate in ( + [entry for entry in services if _matches(entry)], + services, + ): + for service in candidate: + port = self._first_published_port(service) + if port is not None: + return port + return None + + @staticmethod + def _first_published_port(service: Dict[str, Any]) -> Optional[int]: + """Extract the first host-bound port from one ``ps`` entry.""" + publishers = service.get("Publishers") + if not isinstance(publishers, list): + return None + for publisher in publishers: + if not isinstance(publisher, dict): + continue + try: + port = int(publisher.get("PublishedPort", 0)) + except (TypeError, ValueError): + continue + if port > 0: + return port + return None + def exec_tests( self, use_test_script: bool ) -> Optional[subprocess.CompletedProcess[str]]: @@ -176,10 +275,46 @@ def exec_tests( command = ["exec", "-T", "app", "python", "-m", "pytest", "tests/", "-v"] return self._compose(command, timeout=TEST_TIMEOUT) + def reclaim_bind_mount_ownership(self) -> None: + """Give the bind-mounted project directory back to the host user. + + Containers run as root, so a test run inside the stack leaves + root-owned artefacts (``__pycache__``, ``.pytest_cache``, ...) in the + mounted project. Later host-side steps and the temp directory cleanup + cannot touch those and fail with a PermissionError, so the ownership + is reset from inside a throwaway container. Best effort: a failure + here is never worth failing an inspection over. + """ + if not hasattr(os, "getuid"): # pragma: no cover - Windows hosts + return + + uid, gid = os.getuid(), os.getgid() + if uid == 0: + return + + debug_log("Reclaiming ownership of bind-mounted project files", "info") + self._run( + [ + "docker", + "run", + "--rm", + "-v", + f"{self.project_dir}:/mnt", + RECLAIM_IMAGE, + "chown", + "-R", + f"{uid}:{gid}", + "/mnt", + ], + timeout=CLEANUP_TIMEOUT, + ) + def cleanup(self) -> None: """Tear down services and volumes, ignoring any failure.""" debug_log("Cleaning up Docker services", "info") + self.reclaim_bind_mount_ownership() self._run( - [COMPOSE_COMMAND, "down", "-v", "--remove-orphans"], timeout=CLEANUP_TIMEOUT + [*self.compose_command(), "down", "-v", "--remove-orphans"], + timeout=CLEANUP_TIMEOUT, ) self._run(["docker", "system", "prune", "-f"], timeout=STATUS_TIMEOUT) diff --git a/src/fastapi_fastkit/backend/inspection/lint.py b/src/fastapi_fastkit/backend/inspection/lint.py index 96093ea..e4acf57 100644 --- a/src/fastapi_fastkit/backend/inspection/lint.py +++ b/src/fastapi_fastkit/backend/inspection/lint.py @@ -1,12 +1,17 @@ # -------------------------------------------------------------------------- # Static analysis of the generated project. # -# ``compileall`` is mandatory - a template that cannot be byte-compiled is -# broken beyond argument. ``mypy`` is opt-in because it needs the template's -# own dependencies installed and is far slower. +# The compile check is mandatory - a template that cannot be compiled is +# broken beyond argument. It is performed in-process with ``compile()`` and +# deliberately writes nothing to disk: a Docker test run beforehand can leave +# root-owned ``__pycache__`` directories in the bind-mounted project, and +# ``compileall`` would then fail with a PermissionError that says nothing +# about the template. ``mypy`` is opt-in because it needs the template's own +# dependencies installed and is far slower. # # @author bnbong # -------------------------------------------------------------------------- +import os import subprocess import sys from typing import List @@ -15,43 +20,52 @@ from .context import InspectionContext -COMPILE_TIMEOUT = 120 MYPY_TIMEOUT = 300 +#: Directories never worth compiling - third party code and build artefacts. +EXCLUDED_DIRS = {".venv", "venv", "__pycache__", "node_modules"} + def _interpreter(ctx: InspectionContext) -> str: """Prefer the inspection venv interpreter, falling back to the host one.""" return ctx.python_executable() or sys.executable +def _iter_python_files(root: str) -> List[str]: + """Collect every ``.py`` file below ``root``, skipping excluded directories.""" + python_files: List[str] = [] + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [name for name in dirnames if name not in EXCLUDED_DIRS] + for file_name in sorted(filenames): + if file_name.endswith(".py"): + python_files.append(os.path.join(dirpath, file_name)) + return python_files + + def check_compileall(ctx: InspectionContext) -> bool: - """Byte-compile every Python file of the generated project.""" - command: List[str] = [ - _interpreter(ctx), - "-m", - "compileall", - "-q", - "-x", - r"(\.venv|venv|node_modules)", - ctx.temp_dir, - ] - try: - result = subprocess.run( - command, - cwd=ctx.temp_dir, - capture_output=True, - text=True, - timeout=COMPILE_TIMEOUT, - ) - except subprocess.TimeoutExpired: - ctx.add_error("compileall timed out") - return False - except OSError as e: - ctx.add_error(f"Failed to run compileall: {e}") - return False + """Compile every Python file of the generated project, in memory. - if result.returncode != 0: - detail = (result.stderr or result.stdout or "").strip() + Nothing is written to disk - no ``__pycache__`` is created - so the check + stays a pure syntax gate that cannot trip over file ownership left behind + by a containerised test run. + """ + failures: List[str] = [] + + for path in _iter_python_files(ctx.temp_dir): + try: + with open(path, "rb") as f: + source = f.read() + except OSError as e: + failures.append(f"{path}: could not be read ({e})") + continue + + try: + compile(source, path, "exec", dont_inherit=True) + except (SyntaxError, ValueError) as e: + failures.append(f"{path}: {e}") + + if failures: + detail = "\n".join(failures) ctx.add_error(f"Generated project failed to compile:\n{detail}") return False diff --git a/src/fastapi_fastkit/backend/inspection/smoke.py b/src/fastapi_fastkit/backend/inspection/smoke.py index 55011f2..653dada 100644 --- a/src/fastapi_fastkit/backend/inspection/smoke.py +++ b/src/fastapi_fastkit/backend/inspection/smoke.py @@ -115,12 +115,17 @@ def _probe(url: str, timeout: int = 5) -> Optional[int]: def _wait_for_server( - process: "subprocess.Popen[bytes]", base_url: str, timeout: int + process: Optional["subprocess.Popen[bytes]"], base_url: str, timeout: int ) -> Tuple[bool, str]: - """Poll ``/docs`` until the server answers, the process dies or time runs out.""" + """Poll ``/docs`` until the server answers, the process dies or time runs out. + + ``process`` is ``None`` when the server is not ours to watch - a container + started by the Docker strategy, for one - in which case only the timeout + bounds the wait. + """ deadline = time.time() + timeout while time.time() < deadline: - if process.poll() is not None: + if process is not None and process.poll() is not None: return False, "server process exited before becoming reachable" status = _probe(f"{base_url}/docs") if status is not None: @@ -228,14 +233,70 @@ def _read_log_tail(log_file: IO[bytes]) -> str: return "" +def _check_health_endpoint(ctx: InspectionContext, base_url: str) -> Optional[str]: + """Probe ``/health``; return a failure message, or ``None`` when acceptable. + + A template without a ``/health`` route answers 404, which is fine; an + unreachable endpoint is only worth a warning, but a route that exists and + answers with anything other than 200 is a real failure. + """ + health_status = _probe(f"{base_url}/health") + if health_status is None: + ctx.add_warning("Smoke test: /health did not respond") + elif health_status == 404: + debug_log("Template exposes no /health endpoint, skipping", "info") + elif health_status != 200: + return ( + f"Smoke test failed: /health returned HTTP {health_status} (expected 200)" + ) + return None + + +def run_http_smoke(ctx: InspectionContext, base_url: str) -> bool: + """Verify the HTTP surface of a server someone else already started. + + Used by the Docker strategy, which has the real application running in a + container: probing its published port is more honest than booting a second + copy on the host, and templates that require Docker never get a host venv + to boot one with in the first place. + """ + debug_log(f"Running smoke test against {base_url}", "info") + reachable, reason = _wait_for_server(None, base_url, ctx.options.smoke_timeout) + if not reachable: + ctx.add_error(f"Smoke test failed: {reason}") + return False + + failure = _check_health_endpoint(ctx, base_url) + if failure: + ctx.add_error(failure) + return False + + debug_log("Smoke test passed", "info") + return True + + +def _requires_docker(ctx: InspectionContext) -> bool: + """Whether the template declares that it can only run under Docker.""" + return bool((ctx.template_config or {}).get("requires_docker", False)) + + def check_smoke_test(ctx: InspectionContext) -> bool: """Boot the generated project and verify its HTTP surface.""" if not ctx.options.run_smoke_test: debug_log("Smoke test disabled", "info") return True + if ctx.smoke_result is not None: + debug_log("Reusing the smoke test result recorded while testing", "info") + return ctx.smoke_result + python_executable = ctx.python_executable() if not python_executable or not os.path.exists(python_executable): + if _requires_docker(ctx): + ctx.add_warning( + "smoke test skipped: Docker template without published port" + ) + return True ctx.add_error( "Smoke test requires an installed environment, but no virtual " "environment was prepared for the generated project" @@ -306,16 +367,9 @@ def check_smoke_test(ctx: InspectionContext) -> bool: ctx.add_error(last_failure) return False - health_status = _probe(f"{base_url}/health") - if health_status is None: - ctx.add_warning("Smoke test: /health did not respond") - elif health_status == 404: - debug_log("Template exposes no /health endpoint, skipping", "info") - elif health_status != 200: - ctx.add_error( - f"Smoke test failed: /health returned HTTP {health_status} " - "(expected 200)\n" + _read_log_tail(log_file) - ) + failure = _check_health_endpoint(ctx, base_url) + if failure: + ctx.add_error(f"{failure}\n{_read_log_tail(log_file)}".rstrip()) return False finally: _terminate(process) diff --git a/src/fastapi_fastkit/backend/inspection/strategies.py b/src/fastapi_fastkit/backend/inspection/strategies.py index b77aee5..814e372 100644 --- a/src/fastapi_fastkit/backend/inspection/strategies.py +++ b/src/fastapi_fastkit/backend/inspection/strategies.py @@ -16,6 +16,7 @@ from fastapi_fastkit.backend.main import create_venv, install_dependencies_with_manager from fastapi_fastkit.utils.logging import debug_log +from . import smoke from .context import InspectionContext from .docker import DockerCompose from .fsutils import fix_all_script_line_endings, fix_script_line_endings @@ -278,7 +279,15 @@ def run(self) -> bool: self.ctx.add_error(verification_error) return False - return self._run_tests(compose) + if not self._run_tests(compose): + return False + + # The application is running right here, in a container with a + # published port: probing that is the only way a Docker-only + # template gets a smoke test, since no host venv is ever built for + # it. Done before the ``finally`` below tears the stack down. + self._run_smoke_test(compose) + return True except subprocess.TimeoutExpired: self.ctx.add_error("Docker Compose setup timed out") return False @@ -307,6 +316,28 @@ def _run_tests(self, compose: DockerCompose) -> bool: debug_log("Docker tests passed successfully", "info") return True + def _run_smoke_test(self, compose: DockerCompose) -> None: + """Probe the running container's HTTP surface and record the verdict. + + The result lands on the context so the pipeline's Smoke Test step + reuses it instead of trying (and failing) to boot the project from a + virtual environment that a Docker run never creates. + """ + if not self.ctx.options.run_smoke_test: + return + + port = compose.published_port() + if port is None: + self.ctx.add_warning( + "smoke test skipped: Docker template without published port" + ) + self.ctx.smoke_result = True + return + + self.ctx.smoke_result = smoke.run_http_smoke( + self.ctx, f"http://127.0.0.1:{port}" + ) + def select_fallback_strategy(ctx: InspectionContext) -> TestStrategy: """Pick the fallback strategy, or the standard one when none is configured.""" diff --git a/src/fastapi_fastkit/backend/transducer.py b/src/fastapi_fastkit/backend/transducer.py index 0c9cafb..42ccce7 100644 --- a/src/fastapi_fastkit/backend/transducer.py +++ b/src/fastapi_fastkit/backend/transducer.py @@ -22,6 +22,104 @@ #: here are the *converted* names (the ``-tpl`` marker already stripped). TEMPLATE_ONLY_FILES = frozenset({"template-config.yml"}) +#: Extensions of files that are known to be text and therefore safe to rewrite +#: with Unix line endings. Anything outside this list is copied byte for byte, +#: so an image or an archive shipped inside a template survives untouched. +TEXT_FILE_EXTENSIONS = frozenset( + { + ".bash", + ".cfg", + ".css", + ".env", + ".html", + ".ini", + ".js", + ".json", + ".mako", + ".md", + ".py", + ".rst", + ".sh", + ".sql", + ".toml", + ".ts", + ".txt", + ".yaml", + ".yml", + } +) + +#: Extension-less file names that are text as well. +TEXT_FILE_NAMES = frozenset( + { + ".dockerignore", + ".env", + ".gitignore", + "CHANGELOG", + "Dockerfile", + "LICENSE", + "Makefile", + "Procfile", + "README", + } +) + +#: How much of a file is sampled when looking for a NUL byte. A NUL in the +#: first chunk is the same heuristic Git uses to call a blob binary. +BINARY_SNIFF_BYTES = 8192 + + +def _looks_like_text_file(file_path: str, file_name: str) -> bool: + """ + Decide whether a copied file may have its line endings normalised. + + The check is deliberately conservative: the name has to be on the text + whitelist *and* the content must carry no NUL byte, so a mislabelled + binary is left alone rather than corrupted. + + :param file_path: Path of the file to inspect + :param file_name: File name used for the extension/name whitelist + :return: True when the file is safe to rewrite as text + """ + _, extension = os.path.splitext(file_name) + if ( + extension.lower() not in TEXT_FILE_EXTENSIONS + and file_name not in TEXT_FILE_NAMES + ): + return False + + try: + with open(file_path, "rb") as f: + return b"\x00" not in f.read(BINARY_SNIFF_BYTES) + except OSError as e: + debug_log(f"Could not sniff {file_path} for binary content: {e}", "warning") + return False + + +def _normalize_line_endings(file_path: str, file_name: str) -> None: + """ + Rewrite a copied text file with Unix line endings. + + Templates checked out on Windows (or with ``core.autocrlf=true``) carry + CRLF, which makes a generated project's shell scripts unusable inside a + Linux container: ``env: 'bash\r': No such file or directory``. The rewrite + happens in place through :func:`fix_script_line_endings`, which truncates + rather than recreates the file and therefore keeps the executable bit + ``shutil.copy2`` just carried over. + + :param file_path: Path of the copied file + :param file_name: File name used for the text/binary decision + """ + if not _looks_like_text_file(file_path, file_name): + return + + # Imported lazily: ``fastapi_fastkit.backend.inspection`` pulls in the + # scaffolder, which imports this module, so a top-level import would be + # circular. + from fastapi_fastkit.backend.inspection.fsutils import fix_script_line_endings + + fix_script_line_endings(file_path) + def copy_and_convert_template( template_dir: str, target_dir: str, project_name: str = "" @@ -130,6 +228,7 @@ def _copy_template_file( try: shutil.copy2(src_file, dst_file) + _normalize_line_endings(dst_file, dst_file_name) debug_log(f"Copied {src_file} to {dst_file}", "debug") return dst_file @@ -223,8 +322,8 @@ def _write_target_file(target_file: str, content: str, source_file: str) -> bool target_dir = os.path.dirname(target_file) os.makedirs(target_dir, exist_ok=True) - with open(target_file, "w", encoding="utf-8") as f: - f.write(content) + with open(target_file, "w", encoding="utf-8", newline="\n") as f: + f.write(content.replace("\r\n", "\n").replace("\r", "\n")) debug_log( f"Successfully copied template file from {source_file} to {target_file}", diff --git a/src/fastapi_fastkit/core/settings.py b/src/fastapi_fastkit/core/settings.py index e5571b9..ab1716b 100644 --- a/src/fastapi_fastkit/core/settings.py +++ b/src/fastapi_fastkit/core/settings.py @@ -257,13 +257,13 @@ class FastkitConfig: DatabaseChoice.NONE: [], }, FeatureAxis.AUTHENTICATION: { - AuthChoice.JWT: ["python-jose[cryptography]", "passlib[bcrypt]"], + AuthChoice.JWT: ["pyjwt[crypto]", "pwdlib[argon2]"], # SessionMiddleware (used by the OAuth2 login flow) needs itsdangerous. AuthChoice.OAUTH2: ["authlib", "itsdangerous", "httpx"], AuthChoice.FASTAPI_USERS: [ "fastapi-users[sqlalchemy]", - "python-jose[cryptography]", - "passlib[bcrypt]", + "pyjwt[crypto]", + "pwdlib[argon2]", ], AuthChoice.SESSION: ["itsdangerous"], AuthChoice.NONE: [], diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/.env-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/.env-tpl index 4b3f4ed..d458312 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/.env-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/.env-tpl @@ -21,7 +21,7 @@ MCP_TITLE=FastAPI MCP Server MCP_DESCRIPTION=FastAPI endpoints exposed as MCP tools # Authentication Settings -SECRET_KEY=changethis +SECRET_KEY=changethis-please-use-openssl-rand-hex-32 ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=30 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/README.md-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/README.md-tpl index 818b302..c05a6fd 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/README.md-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/README.md-tpl @@ -75,7 +75,7 @@ MCP_TITLE=FastAPI MCP Server MCP_DESCRIPTION=FastAPI endpoints exposed as MCP tools # Authentication Settings -SECRET_KEY=changethis +SECRET_KEY=changethis-please-use-openssl-rand-hex-32 ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=30 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/pyproject.toml-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/pyproject.toml-tpl index e8c360f..1e3ab80 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/pyproject.toml-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/pyproject.toml-tpl @@ -17,9 +17,8 @@ dependencies = [ "python-dotenv>=1.2.3", "fastapi-mcp>=0.3.7,<0.4.0", "mcp>=1.29.1,<2.0.0", - "bcrypt>=4.0.1,<4.1.0", - "passlib>=1.7.4", - "python-jose>=3.5.0", + "pwdlib[argon2]>=0.3.1", + "PyJWT[crypto]>=2.13.0", "python-multipart>=0.0.32", ] diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/requirements.txt-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/requirements.txt-tpl index 03e85b6..20f26d8 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/requirements.txt-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/requirements.txt-tpl @@ -1,9 +1,10 @@ annotated-doc==0.0.5 annotated-types==0.8.0 anyio==4.15.0 +argon2-cffi==25.1.0 +argon2-cffi-bindings==26.1.0 ast-serialize==0.9.0 attrs==26.1.0 -bcrypt==4.0.1 black==26.5.1 certifi==2026.7.22 cffi==2.1.1 @@ -11,7 +12,6 @@ charset-normalizer==3.5.1 click==8.5.0 coverage==7.16.0 cryptography==50.0.1 -ecdsa==0.19.2 fastapi==0.141.1 fastapi-mcp==0.3.7 h11==0.16.0 @@ -31,21 +31,19 @@ mdurl==0.1.2 mypy==2.3.1 mypy-extensions==1.1.0 packaging==26.3 -passlib==1.7.4 pathspec==1.1.1 platformdirs==4.11.7 pluggy==1.6.0 -pyasn1==0.6.4 +pwdlib==0.3.1 pycparser==3.0 pydantic==2.13.5 pydantic-core==2.46.5 pydantic-settings==2.15.0 pygments==2.21.0 -pyjwt==2.13.0 +PyJWT==2.13.0 pytest==9.1.1 pytest-cov==7.1.0 python-dotenv==1.2.3 -python-jose==3.5.0 python-multipart==0.0.32 pytokens==0.4.1 PyYAML==6.0.3 @@ -53,9 +51,7 @@ referencing==0.37.0 requests==2.34.2 rich==15.0.0 rpds-py==2026.6.3 -rsa==4.9.1 shellingham==1.5.4 -six==1.17.0 SQLAlchemy==2.0.52 sse-starlette==3.4.10 starlette==1.6.0 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/setup.cfg-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/setup.cfg-tpl index 9d1058e..1bc7329 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/setup.cfg-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/setup.cfg-tpl @@ -29,9 +29,8 @@ install_requires = pydantic>=2.10.6 pydantic-settings>=2.7.1 python-dotenv>=1.0.1 - python-jose>=3.3.0 - passlib>=1.7.4 - bcrypt>=4.1.2 + PyJWT[crypto]>=2.10.0 + pwdlib[argon2]>=0.3.1 python-multipart>=0.0.17 [options.extras_require] diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/api/routes/auth.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/api/routes/auth.py-tpl index 81eb99b..3463dce 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/api/routes/auth.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/api/routes/auth.py-tpl @@ -3,10 +3,10 @@ Authentication-related API endpoints. """ from datetime import datetime, timedelta, timezone +import jwt from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from jose import jwt -from passlib.context import CryptContext +from pwdlib import PasswordHash from src.auth.dependencies import get_current_active_user from src.core.config import settings @@ -14,8 +14,8 @@ from src.schemas.items import AuthToken, UserInfo, UserLogin router = APIRouter() -# Password hashing -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +# Password hashing — argon2id, the recommended default for new applications. +password_hash = PasswordHash.recommended() # OAuth2 scheme oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") @@ -25,19 +25,19 @@ mock_users = { "user1": { "user_id": "user1", "username": "user1", - "hashed_password": pwd_context.hash("password123"), + "hashed_password": password_hash.hash("password123"), "active": True, }, "user2": { "user_id": "user2", "username": "user2", - "hashed_password": pwd_context.hash("password456"), + "hashed_password": password_hash.hash("password456"), "active": True, }, "admin": { "user_id": "admin", "username": "admin", - "hashed_password": pwd_context.hash("admin123"), + "hashed_password": password_hash.hash("admin123"), "active": True, }, } @@ -45,12 +45,12 @@ mock_users = { def verify_password(plain_password: str, hashed_password: str) -> bool: """Verify a password against its hash.""" - return pwd_context.verify(plain_password, hashed_password) + return password_hash.verify(plain_password, hashed_password) def get_password_hash(password: str) -> str: """Generate password hash.""" - return pwd_context.hash(password) + return password_hash.hash(password) def authenticate_user(username: str, password: str) -> dict | None: diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/auth/dependencies.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/auth/dependencies.py-tpl index d53062b..a9c904b 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/auth/dependencies.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/auth/dependencies.py-tpl @@ -2,9 +2,9 @@ Authentication dependencies for API and MCP endpoints. """ +import jwt from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from jose import JWTError, jwt from src.core.config import settings @@ -16,7 +16,7 @@ def verify_token(token: str) -> dict: try: payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) return payload - except JWTError: + except jwt.PyJWTError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/core/config.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/core/config.py-tpl index 0d736bd..cc2bd36 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/core/config.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/core/config.py-tpl @@ -28,7 +28,7 @@ class Settings(BaseSettings): MCP_DESCRIPTION: str = "FastAPI endpoints exposed as MCP tools" # Authentication settings - SECRET_KEY: str = "your-secret-key-here" + SECRET_KEY: str = "your-secret-key-here-change-in-production" ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/.env-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/.env-tpl index bade61e..f486540 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/.env-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/.env-tpl @@ -1,4 +1,6 @@ # Backend +# Must be one of the values accepted by src/core/config.py: development | production +ENVIRONMENT=development SECRET_KEY=changethis # Postgres diff --git a/src/fastapi_fastkit/fragments/auth/jwt.py.j2 b/src/fastapi_fastkit/fragments/auth/jwt.py.j2 index bea6b5b..a7c5385 100644 --- a/src/fastapi_fastkit/fragments/auth/jwt.py.j2 +++ b/src/fastapi_fastkit/fragments/auth/jwt.py.j2 @@ -1,8 +1,8 @@ {% include "header.j2" %} from datetime import UTC, datetime, timedelta -from jose import jwt -from passlib.context import CryptContext +import jwt +from pwdlib import PasswordHash from pydantic_settings import BaseSettings, SettingsConfigDict @@ -18,18 +18,18 @@ class AuthSettings(BaseSettings): settings = AuthSettings() -# Password hashing -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +# Password hashing — argon2id, the recommended default for new applications. +password_hash = PasswordHash.recommended() def verify_password(plain_password: str, hashed_password: str) -> bool: """Verify a password against a hash.""" - return pwd_context.verify(plain_password, hashed_password) + return password_hash.verify(plain_password, hashed_password) def get_password_hash(password: str) -> str: """Hash a password.""" - return pwd_context.hash(password) + return password_hash.hash(password) def create_access_token( diff --git a/tests/test_backends/test_inspection_docker.py b/tests/test_backends/test_inspection_docker.py index 32be010..7dab492 100644 --- a/tests/test_backends/test_inspection_docker.py +++ b/tests/test_backends/test_inspection_docker.py @@ -11,7 +11,21 @@ import pytest -from fastapi_fastkit.backend.inspection.docker import COMPOSE_COMMAND, DockerCompose +from fastapi_fastkit.backend.inspection import docker as docker_module +from fastapi_fastkit.backend.inspection.docker import ( + COMPOSE_COMMAND, + COMPOSE_PLUGIN_COMMAND, + RECLAIM_IMAGE, + DockerCompose, +) + + +@pytest.fixture(autouse=True) +def reset_compose_prefix() -> Any: + """Keep the module-level compose prefix from leaking between testcases.""" + original = docker_module._compose_prefix + yield + docker_module._compose_prefix = original def completed( @@ -85,6 +99,53 @@ def test_false_on_timeout(self) -> None: ): assert DockerCompose.is_available() is False + def test_keeps_docker_compose_command_by_default(self) -> None: + with patch("subprocess.run", return_value=completed(returncode=0)): + assert DockerCompose.is_available() is True + assert DockerCompose.compose_command() == [COMPOSE_COMMAND] + + def test_falls_back_to_compose_plugin(self) -> None: + # given - only ``docker --version`` and ``docker compose version`` work + def fake_run( + command: List[str], **kwargs: Any + ) -> subprocess.CompletedProcess[str]: + if command[0] == COMPOSE_COMMAND: + raise OSError("not found") + return completed(returncode=0) + + # when + with patch("subprocess.run", side_effect=fake_run): + available = DockerCompose.is_available() + + # then + assert available is True + assert DockerCompose.compose_command() == list(COMPOSE_PLUGIN_COMMAND) + + def test_false_when_no_compose_implementation(self) -> None: + # given + def fake_run( + command: List[str], **kwargs: Any + ) -> subprocess.CompletedProcess[str]: + if command[:1] == ["docker"] and command[1:] == ["--version"]: + return completed(returncode=0) + return completed(returncode=1) + + # when / then + with patch("subprocess.run", side_effect=fake_run): + assert DockerCompose.is_available() is False + + def test_plugin_fallback_is_used_by_compose_invocations(self) -> None: + # given + compose = DockerCompose("/tmp/project") + docker_module._compose_prefix = list(COMPOSE_PLUGIN_COMMAND) + + # when + with patch.object(compose, "_run", return_value=completed()) as mock_run: + compose._compose(["ps"], timeout=5) + + # then + assert mock_run.call_args.args[0][:2] == ["docker", "compose"] + class TestServices: """``_services`` parses docker-compose ps --format json output.""" @@ -99,6 +160,69 @@ def test_returns_empty_list_on_nonzero_returncode(self) -> None: with patch.object(compose, "_compose", return_value=completed(returncode=1)): assert compose._services(timeout=5) == [] + def test_returns_empty_list_on_empty_output(self) -> None: + compose = DockerCompose("/tmp/project") + with patch.object(compose, "_compose", return_value=completed(stdout=" \n")): + assert compose._services(timeout=5) == [] + + def test_parses_single_json_array(self) -> None: + # given - compose v2.18 and older print one JSON array + compose = DockerCompose("/tmp/project") + stdout = ( + '[{"Name": "app", "State": "running"}, ' + '{"Name": "db", "State": "running"}]' + ) + + # when + with patch.object(compose, "_compose", return_value=completed(stdout=stdout)): + services = compose._services(timeout=5) + + # then + assert services == [ + {"Name": "app", "State": "running"}, + {"Name": "db", "State": "running"}, + ] + + def test_parses_single_json_object(self) -> None: + compose = DockerCompose("/tmp/project") + stdout = '{"Name": "app", "State": "running"}' + with patch.object(compose, "_compose", return_value=completed(stdout=stdout)): + assert compose._services(timeout=5) == [{"Name": "app", "State": "running"}] + + def test_skips_non_dict_entries_of_a_json_array(self) -> None: + compose = DockerCompose("/tmp/project") + stdout = '["not-a-dict", {"Name": "db", "State": "running"}]' + with patch.object(compose, "_compose", return_value=completed(stdout=stdout)): + assert compose._services(timeout=5) == [{"Name": "db", "State": "running"}] + + def test_ignores_interleaved_warning_lines(self) -> None: + # given - compose may mix its own logs into stdout + compose = DockerCompose("/tmp/project") + stdout = ( + 'time="2024-01-01T00:00:00Z" level=warning msg="deprecated"\n' + '{"Name": "app", "State": "running"}\n' + '{"Name": "db", "State": "running"}\n' + ) + + # when + with patch.object(compose, "_compose", return_value=completed(stdout=stdout)): + services = compose._services(timeout=5) + + # then + assert services == [ + {"Name": "app", "State": "running"}, + {"Name": "db", "State": "running"}, + ] + + def test_ignores_warning_lines_around_a_json_array(self) -> None: + compose = DockerCompose("/tmp/project") + stdout = ( + 'time="2024-01-01T00:00:00Z" level=warning msg="deprecated"\n' + '[{"Name": "app", "State": "running"}]\n' + ) + with patch.object(compose, "_compose", return_value=completed(stdout=stdout)): + assert compose._services(timeout=5) == [{"Name": "app", "State": "running"}] + def test_parses_json_lines_and_skips_malformed(self) -> None: compose = DockerCompose("/tmp/project") stdout = ( @@ -260,9 +384,149 @@ class TestCleanup: def test_runs_down_and_prune(self) -> None: compose = DockerCompose("/tmp/project") with patch.object(compose, "_run", return_value=completed()) as mock_run: - compose.cleanup() - assert mock_run.call_count == 2 - first_args = mock_run.call_args_list[0].args[0] - assert first_args[0] == COMPOSE_COMMAND + with ( + patch("os.getuid", return_value=1000, create=True), + patch("os.getgid", return_value=1000, create=True), + ): + compose.cleanup() + assert mock_run.call_count == 3 + reclaim_args = mock_run.call_args_list[0].args[0] + assert reclaim_args[:3] == ["docker", "run", "--rm"] + assert RECLAIM_IMAGE in reclaim_args + assert reclaim_args[-4:] == ["chown", "-R", "1000:1000", "/mnt"] second_args = mock_run.call_args_list[1].args[0] - assert second_args == ["docker", "system", "prune", "-f"] + assert second_args[0] == COMPOSE_COMMAND + third_args = mock_run.call_args_list[2].args[0] + assert third_args == ["docker", "system", "prune", "-f"] + + def test_ownership_reclaim_is_skipped_for_root(self) -> None: + compose = DockerCompose("/tmp/project") + with patch.object(compose, "_run", return_value=completed()) as mock_run: + with patch("os.getuid", return_value=0, create=True): + compose.reclaim_bind_mount_ownership() + mock_run.assert_not_called() + + def test_ownership_reclaim_mounts_the_project_directory(self) -> None: + compose = DockerCompose("/tmp/project") + with patch.object(compose, "_run", return_value=completed()) as mock_run: + with ( + patch("os.getuid", return_value=501, create=True), + patch("os.getgid", return_value=20, create=True), + ): + compose.reclaim_bind_mount_ownership() + args = mock_run.call_args.args[0] + assert "/tmp/project:/mnt" in args + assert "501:20" in args + + +class TestPublishedPort: + """``published_port`` reads host-bound ports out of ``ps --format json``.""" + + def _services(self, entries: List[Any]) -> Any: + return patch.object(DockerCompose, "_services", return_value=entries) + + def test_prefers_the_app_service(self) -> None: + # given + compose = DockerCompose("/tmp/project") + entries = [ + { + "Service": "db", + "Name": "proj-db-1", + "Publishers": [{"PublishedPort": 5432, "TargetPort": 5432}], + }, + { + "Service": "app", + "Name": "proj-app-1", + "Publishers": [{"PublishedPort": 8000, "TargetPort": 8000}], + }, + ] + + # when + with self._services(entries): + port = compose.published_port() + + # then + assert port == 8000 + + def test_ignores_unpublished_entries(self) -> None: + # given + compose = DockerCompose("/tmp/project") + entries = [ + { + "Service": "app", + "Name": "proj-app-1", + "Publishers": [ + {"PublishedPort": 0, "TargetPort": 8000}, + {"PublishedPort": "32770", "TargetPort": 8000}, + ], + } + ] + + # when + with self._services(entries): + port = compose.published_port() + + # then + assert port == 32770 + + def test_falls_back_to_any_service_with_a_published_port(self) -> None: + # given: no service name matches the hint + compose = DockerCompose("/tmp/project") + entries = [ + { + "Service": "web", + "Name": "proj-web-1", + "Publishers": [{"PublishedPort": 9000}], + } + ] + + # when + with self._services(entries): + port = compose.published_port() + + # then + assert port == 9000 + + def test_returns_none_without_publishers(self) -> None: + # given + compose = DockerCompose("/tmp/project") + entries = [ + {"Service": "app", "Name": "proj-app-1", "Publishers": []}, + {"Service": "db", "Name": "proj-db-1"}, + ] + + # when + with self._services(entries): + port = compose.published_port() + + # then + assert port is None + + def test_returns_none_when_ps_reports_nothing(self) -> None: + # given + compose = DockerCompose("/tmp/project") + + # when + with self._services([]): + port = compose.published_port() + + # then + assert port is None + + def test_malformed_publisher_entries_are_skipped(self) -> None: + # given + compose = DockerCompose("/tmp/project") + entries = [ + { + "Service": "app", + "Name": "proj-app-1", + "Publishers": ["nonsense", {"PublishedPort": "not-a-number"}], + } + ] + + # when + with self._services(entries): + port = compose.published_port() + + # then + assert port is None diff --git a/tests/test_backends/test_inspector.py b/tests/test_backends/test_inspector.py index 48720f0..f1a0d02 100644 --- a/tests/test_backends/test_inspector.py +++ b/tests/test_backends/test_inspector.py @@ -11,7 +11,7 @@ import subprocess import tempfile from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from unittest.mock import MagicMock, patch import pytest @@ -679,16 +679,22 @@ def test_successful_docker_run(self, tmp_path: Path) -> None: compose.exec_tests.return_value = subprocess.CompletedProcess( ["pytest"], 0, "", "" ) + compose.published_port.return_value = 32768 # when with ( patch.object(DockerCompose, "is_available", return_value=True), patch.object(strategies_module, "DockerCompose", return_value=compose), + patch.object( + strategies_module.smoke, "run_http_smoke", return_value=True + ) as mock_smoke, ): result = strategies_module.DockerStrategy(ctx).run() # then assert result is True + assert ctx.smoke_result is True + mock_smoke.assert_called_once_with(ctx, "http://127.0.0.1:32768") compose.cleanup.assert_called_once() # test_env_defaults are materialised into a .env file assert "POSTGRES_USER=test_user" in (tmp_path / ".env").read_text() @@ -807,6 +813,118 @@ def test_run_fails_when_environment_preparation_fails(self, tmp_path: Path) -> N assert result is False +class TestDockerStrategySmokeTest: + """The Docker strategy probes the container before tearing it down.""" + + def _context(self, tmp_path: Path, **option_kwargs: Any) -> Any: + return InspectionContext( + template_path=tmp_path, + temp_dir=str(tmp_path), + options=InspectionOptions(**option_kwargs), + template_config={"requires_docker": True}, + ) + + def _compose(self, port: Optional[int]) -> MagicMock: + compose = MagicMock() + compose.containers_running.return_value = True + compose.verify_services_running.return_value = None + compose.exec_tests.return_value = subprocess.CompletedProcess( + ["pytest"], 0, "", "" + ) + compose.published_port.return_value = port + return compose + + def _run(self, ctx: Any, compose: MagicMock) -> bool: + with ( + patch.object(DockerCompose, "is_available", return_value=True), + patch.object(strategies_module, "DockerCompose", return_value=compose), + ): + result: bool = strategies_module.DockerStrategy(ctx).run() + return result + + def test_failed_probe_is_recorded_but_tests_still_pass( + self, tmp_path: Path + ) -> None: + # given: the container answers nothing on its published port + ctx = self._context(tmp_path, smoke_timeout=0) + compose = self._compose(32768) + + # when + with patch.object(strategies_module.smoke, "_probe", return_value=None): + result = self._run(ctx, compose) + + # then: the strategy itself succeeded, the smoke step will fail + assert result is True + assert ctx.smoke_result is False + assert any("Smoke test failed" in e for e in ctx.errors) + + def test_probe_runs_before_the_stack_is_torn_down(self, tmp_path: Path) -> None: + # given + ctx = self._context(tmp_path) + compose = self._compose(32768) + order: List[str] = [] + compose.cleanup.side_effect = lambda: order.append("cleanup") + + # when + with patch.object( + strategies_module.smoke, + "run_http_smoke", + side_effect=lambda *_: order.append("smoke") or True, + ): + result = self._run(ctx, compose) + + # then + assert result is True + assert order == ["smoke", "cleanup"] + + def test_missing_published_port_is_a_warning(self, tmp_path: Path) -> None: + # given + ctx = self._context(tmp_path) + compose = self._compose(None) + + # when + with patch.object(strategies_module.smoke, "run_http_smoke") as mock_smoke: + result = self._run(ctx, compose) + + # then + assert result is True + assert ctx.smoke_result is True + assert ctx.errors == [] + assert any("smoke test skipped" in w for w in ctx.warnings) + mock_smoke.assert_not_called() + + def test_disabled_smoke_test_is_not_probed(self, tmp_path: Path) -> None: + # given + ctx = self._context(tmp_path, run_smoke_test=False) + compose = self._compose(32768) + + # when + with patch.object(strategies_module.smoke, "run_http_smoke") as mock_smoke: + result = self._run(ctx, compose) + + # then + assert result is True + assert ctx.smoke_result is None + mock_smoke.assert_not_called() + + def test_failed_tests_skip_the_probe(self, tmp_path: Path) -> None: + # given + ctx = self._context(tmp_path) + compose = self._compose(32768) + compose.exec_tests.return_value = subprocess.CompletedProcess( + ["pytest"], 1, "out", "err" + ) + + # when + with patch.object(strategies_module.smoke, "run_http_smoke") as mock_smoke: + result = self._run(ctx, compose) + + # then + assert result is False + assert ctx.smoke_result is None + mock_smoke.assert_not_called() + + class TestDockerStrategyEnvFileAndTimeout: """The .env materialisation and the outer timeout/OSError handling.""" @@ -834,6 +952,7 @@ def test_existing_env_file_values_are_preserved(self, tmp_path: Path) -> None: compose.exec_tests.return_value = subprocess.CompletedProcess( ["pytest"], 0, "", "" ) + compose.published_port.return_value = None # when with ( @@ -1027,6 +1146,35 @@ def test_inspect_template_stops_at_first_failure(self, temp_dir: str) -> None: assert result is False mock_tests.assert_not_called() + def test_compile_check_runs_before_template_tests(self, temp_dir: str) -> None: + # given: the compile check is static, so it must gate the expensive + # (and, for docker templates, root-owned) test run + self.create_valid_template_structure() + options = InspectionOptions( + offline=True, run_smoke_test=False, run_template_tests=False + ) + calls: List[str] = [] + + # when + with self.make_inspector(temp_dir, options) as inspector: + with ( + patch.object( + TemplateInspector, + "_check_compileall", + side_effect=lambda: (calls.append("compile"), True)[1], + ), + patch.object( + TemplateInspector, + "_test_template", + side_effect=lambda: (calls.append("tests"), True)[1], + ), + ): + result = inspector.inspect_template() + + # then + assert result is True, inspector.errors + assert calls == ["compile", "tests"] + def test_inspect_template_runs_every_check(self, temp_dir: str) -> None: # given: all expensive steps stubbed out self.create_valid_template_structure() diff --git a/tests/test_backends/test_inspector_checks.py b/tests/test_backends/test_inspector_checks.py index ec2add6..98abc13 100644 --- a/tests/test_backends/test_inspector_checks.py +++ b/tests/test_backends/test_inspector_checks.py @@ -291,32 +291,65 @@ def test_mypy_oserror_is_a_warning_not_a_failure(self, tmp_path: Path) -> None: assert result is True assert any("Could not run mypy" in warning for warning in ctx.warnings) - def test_compileall_timeout_is_reported_as_error(self, tmp_path: Path) -> None: + def test_compileall_writes_no_bytecode(self, tmp_path: Path) -> None: # given ctx = make_context(tmp_path) + Path(ctx.temp_path("main.py")).write_text("value = 1\n") # when - with patch( - "subprocess.run", - side_effect=subprocess.TimeoutExpired(cmd="compileall", timeout=1), - ): + result = lint.check_compileall(ctx) + + # then + assert result is True + assert not list(Path(ctx.temp_dir).rglob("__pycache__")) + assert not list(Path(ctx.temp_dir).rglob("*.pyc")) + + def test_compileall_runs_no_subprocess(self, tmp_path: Path) -> None: + # given + ctx = make_context(tmp_path) + Path(ctx.temp_path("main.py")).write_text("value = 1\n") + + # when + with patch("subprocess.run") as mock_run: result = lint.check_compileall(ctx) # then - assert result is False - assert any("compileall timed out" in error for error in ctx.errors) + assert result is True + mock_run.assert_not_called() + + def test_compileall_skips_excluded_directories(self, tmp_path: Path) -> None: + # given + ctx = make_context(tmp_path) + vendored = Path(ctx.temp_path(".venv", "lib")) + vendored.mkdir(parents=True) + (vendored / "broken.py").write_text("def broken(:\n") + + # when / then + assert lint.check_compileall(ctx) is True - def test_compileall_oserror_is_reported_as_error(self, tmp_path: Path) -> None: + def test_compileall_reports_the_offending_file(self, tmp_path: Path) -> None: # given ctx = make_context(tmp_path) + Path(ctx.temp_path("broken.py")).write_text("def broken(:\n") + + # when / then + assert lint.check_compileall(ctx) is False + assert any("broken.py" in error for error in ctx.errors) + + def test_compileall_unreadable_file_is_reported_as_error( + self, tmp_path: Path + ) -> None: + # given + ctx = make_context(tmp_path) + Path(ctx.temp_path("main.py")).write_text("value = 1\n") # when - with patch("subprocess.run", side_effect=OSError("no interpreter")): + with patch("builtins.open", side_effect=OSError("permission denied")): result = lint.check_compileall(ctx) # then assert result is False - assert any("Failed to run compileall" in error for error in ctx.errors) + assert any("could not be read" in error for error in ctx.errors) class TestDependencyFreshnessCheck: @@ -1101,3 +1134,128 @@ def test_options_defaults(self) -> None: assert options["offline"] is False assert options["run_smoke_test"] is True assert options["run_mypy"] is False + + +class TestSmokeTestReuseAndDockerTemplates: + """A Docker run probes the container, so the smoke step reuses its verdict.""" + + def test_recorded_result_is_reused_without_booting_a_server( + self, tmp_path: Path + ) -> None: + # given: a strategy already probed the running container + ctx = make_context(tmp_path) + ctx.smoke_result = True + + # when + with patch.object(smoke_module.subprocess, "Popen") as popen: + result = smoke_module.check_smoke_test(ctx) + + # then + assert result is True + popen.assert_not_called() + + def test_recorded_failure_is_reused(self, tmp_path: Path) -> None: + # given + ctx = make_context(tmp_path) + ctx.smoke_result = False + + # when + with patch.object(smoke_module.subprocess, "Popen") as popen: + result = smoke_module.check_smoke_test(ctx) + + # then + assert result is False + popen.assert_not_called() + + def test_docker_template_without_venv_is_skipped(self, tmp_path: Path) -> None: + # given: requires_docker templates never get a host virtual environment + ctx = make_context(tmp_path) + ctx.template_config = {"requires_docker": True} + + # when + result = smoke_module.check_smoke_test(ctx) + + # then + assert result is True + assert ctx.errors == [] + assert any("smoke test skipped" in w for w in ctx.warnings) + + +class TestRunHttpSmoke: + """Probing an already-running server (a container's published port).""" + + def test_passes_when_docs_and_health_answer(self, tmp_path: Path) -> None: + # given + ctx = make_context(tmp_path) + statuses = {"docs": 200, "health": 200} + + # when + with patch.object( + smoke_module, + "_probe", + side_effect=lambda url, timeout=5: statuses[url.rsplit("/", 1)[-1]], + ): + result = smoke_module.run_http_smoke(ctx, "http://127.0.0.1:8000") + + # then + assert result is True + assert ctx.errors == [] + + def test_missing_health_endpoint_is_not_an_error(self, tmp_path: Path) -> None: + # given + ctx = make_context(tmp_path) + statuses = {"docs": 200, "health": 404} + + # when + with patch.object( + smoke_module, + "_probe", + side_effect=lambda url, timeout=5: statuses[url.rsplit("/", 1)[-1]], + ): + result = smoke_module.run_http_smoke(ctx, "http://127.0.0.1:8000") + + # then + assert result is True + assert ctx.errors == [] + + def test_broken_health_endpoint_fails(self, tmp_path: Path) -> None: + # given + ctx = make_context(tmp_path) + statuses = {"docs": 200, "health": 503} + + # when + with patch.object( + smoke_module, + "_probe", + side_effect=lambda url, timeout=5: statuses[url.rsplit("/", 1)[-1]], + ): + result = smoke_module.run_http_smoke(ctx, "http://127.0.0.1:8000") + + # then + assert result is False + assert any("/health returned HTTP 503" in e for e in ctx.errors) + + def test_unreachable_container_fails(self, tmp_path: Path) -> None: + # given + ctx = make_context(tmp_path) + ctx.options.smoke_timeout = 0 + + # when + with patch.object(smoke_module, "_probe", return_value=None): + result = smoke_module.run_http_smoke(ctx, "http://127.0.0.1:8000") + + # then + assert result is False + assert any("did not become reachable" in e for e in ctx.errors) + + def test_bad_docs_status_fails(self, tmp_path: Path) -> None: + # given + ctx = make_context(tmp_path) + + # when + with patch.object(smoke_module, "_probe", return_value=500): + result = smoke_module.run_http_smoke(ctx, "http://127.0.0.1:8000") + + # then + assert result is False + assert any("/docs returned HTTP 500" in e for e in ctx.errors) diff --git a/tests/test_backends/test_interactive_config_builder.py b/tests/test_backends/test_interactive_config_builder.py index a435368..c966ff2 100644 --- a/tests/test_backends/test_interactive_config_builder.py +++ b/tests/test_backends/test_interactive_config_builder.py @@ -108,8 +108,8 @@ def test_collect_dependencies_with_authentication(self) -> None: dependencies = builder._collect_all_dependencies() # then - assert "python-jose[cryptography]" in dependencies - assert "passlib[bcrypt]" in dependencies + assert "pyjwt[crypto]" in dependencies + assert "pwdlib[argon2]" in dependencies def test_collect_dependencies_deduplication(self) -> None: """Test that dependencies are deduplicated.""" diff --git a/tests/test_backends/test_interactive_validators.py b/tests/test_backends/test_interactive_validators.py index 921df20..250c6c8 100644 --- a/tests/test_backends/test_interactive_validators.py +++ b/tests/test_backends/test_interactive_validators.py @@ -74,7 +74,7 @@ def test_valid_package_name_with_extras(self) -> None: def test_valid_package_name_with_version_and_extras(self) -> None: """Test validation of package name with both version and extras.""" # given - package_name = "python-jose[cryptography]>=3.0.0" + package_name = "pyjwt[crypto]>=2.10.0" # when result = validate_package_name(package_name) diff --git a/tests/test_backends/test_project_builder_config_generator.py b/tests/test_backends/test_project_builder_config_generator.py index e7aed23..f24ca90 100644 --- a/tests/test_backends/test_project_builder_config_generator.py +++ b/tests/test_backends/test_project_builder_config_generator.py @@ -286,8 +286,8 @@ def test_generate_auth_config_jwt(self) -> None: # then assert content is not None assert "JWT Authentication Configuration" in content - assert "jose" in content.lower() - assert "passlib" in content or "password" in content.lower() + assert "import jwt" in content + assert "pwdlib" in content or "password" in content.lower() assert "SECRET_KEY" in content def test_generate_auth_config_fastapi_users(self) -> None: diff --git a/tests/test_backends/test_project_builder_dependency_collector.py b/tests/test_backends/test_project_builder_dependency_collector.py index f1a003f..5e958a2 100644 --- a/tests/test_backends/test_project_builder_dependency_collector.py +++ b/tests/test_backends/test_project_builder_dependency_collector.py @@ -134,8 +134,8 @@ def test_collect_with_jwt_authentication(self) -> None: dependencies = collector.collect_from_config(config) # then - assert "python-jose[cryptography]" in dependencies - assert "passlib[bcrypt]" in dependencies + assert "pyjwt[crypto]" in dependencies + assert "pwdlib[argon2]" in dependencies def test_collect_with_celery(self) -> None: """Test dependency collection with Celery.""" @@ -280,7 +280,7 @@ def test_collect_full_stack_config(self) -> None: assert "sqlalchemy" in dependencies assert "asyncpg" in dependencies # Authentication - assert "python-jose[cryptography]" in dependencies + assert "pyjwt[crypto]" in dependencies # Tasks assert any("celery" in dep for dep in dependencies) assert any("redis" in dep for dep in dependencies) @@ -586,7 +586,7 @@ def test_every_catalog_axis_is_walked(self) -> None: # then — one representative package per axis that ships any for expected in ( "asyncpg", - "python-jose[cryptography]", + "pyjwt[crypto]", "celery[redis]", "fastapi-cache2", "loguru", diff --git a/tests/test_backends/test_transducer.py b/tests/test_backends/test_transducer.py index 43fcf52..6f39d1c 100644 --- a/tests/test_backends/test_transducer.py +++ b/tests/test_backends/test_transducer.py @@ -513,3 +513,103 @@ def test_copy_and_convert_template_target_creation_error(self) -> None: copy_and_convert_template( str(self.source_path), "/invalid/path", "project" ) + + +class TestLineEndingNormalization: + """Copying a template must never carry CRLF into a generated project.""" + + def setup_method(self) -> None: + """Setup method for each test.""" + self.temp_source_dir = tempfile.mkdtemp() + self.temp_dest_dir = tempfile.mkdtemp() + self.source_path = Path(self.temp_source_dir) + self.dest_path = Path(self.temp_dest_dir) + + def teardown_method(self) -> None: + """Cleanup method for each test.""" + import shutil + + if os.path.exists(self.temp_source_dir): + shutil.rmtree(self.temp_source_dir) + if os.path.exists(self.temp_dest_dir): + shutil.rmtree(self.temp_dest_dir) + + def test_crlf_text_templates_are_converted_to_lf(self) -> None: + """Every whitelisted text file loses its CRLF during the copy.""" + # given + crlf_files = { + "pre-start.sh-tpl": b"#!/usr/bin/env bash\r\nset -e\r\n", + "main.py-tpl": b"import os\r\nprint(os)\r\n", + "docker-compose.yml-tpl": b"services:\r\n app: {}\r\n", + "pyproject.toml-tpl": b'[project]\r\nname = "x"\r\n', + "setup.cfg-tpl": b"[flake8]\r\nmax-line-length = 88\r\n", + "alembic.ini-tpl": b"[alembic]\r\nscript_location = m\r\n", + ".env-tpl": b"ENVIRONMENT=development\r\nSECRET_KEY=changethis\r\n", + "Dockerfile-tpl": b"FROM python:3.12\r\nWORKDIR /app\r\n", + "Makefile-tpl": b"all:\r\n\techo hi\r\n", + "script.py.mako-tpl": b"# ${message}\r\n", + "README.md-tpl": b"# Title\r\n\r\nBody\r\n", + "requirements.txt-tpl": b"fastapi\r\nuvicorn\r\n", + # Extension-less text files (alembic ships a bare ``README``). + "README-tpl": b"Generic single-database configuration.\r\n", + } + for name, payload in crlf_files.items(): + (self.source_path / name).write_bytes(payload) + + # when + copy_and_convert_template(str(self.source_path), str(self.dest_path)) + + # then + for name, payload in crlf_files.items(): + converted = self.dest_path / name.removesuffix("-tpl") + content = converted.read_bytes() + assert b"\r" not in content, f"{converted} still contains CR" + assert content == payload.replace(b"\r\n", b"\n") + + def test_executable_bit_survives_normalization(self) -> None: + """A CRLF shell script stays executable after being rewritten.""" + # given + script = self.source_path / "pre-start.sh-tpl" + script.write_bytes(b"#!/usr/bin/env bash\r\nexit 0\r\n") + script.chmod(0o755) + + # when + copy_and_convert_template(str(self.source_path), str(self.dest_path)) + + # then + converted = self.dest_path / "pre-start.sh" + assert converted.read_bytes() == b"#!/usr/bin/env bash\nexit 0\n" + assert os.access(str(converted), os.X_OK) + assert converted.stat().st_mode & 0o111 == 0o111 + + def test_binary_files_are_copied_untouched(self) -> None: + """A PNG carrying a CRLF byte pair is copied byte for byte.""" + # given + png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00\x01\r\n\x02" * 8 + (self.source_path / "logo.png-tpl").write_bytes(png_bytes) + # A NUL byte inside a whitelisted extension must also be left alone. + mislabelled = b"header\x00\r\npayload\r\n" + (self.source_path / "data.json-tpl").write_bytes(mislabelled) + + # when + copy_and_convert_template(str(self.source_path), str(self.dest_path)) + + # then + assert (self.dest_path / "logo.png").read_bytes() == png_bytes + assert (self.dest_path / "data.json").read_bytes() == mislabelled + + def test_single_file_copy_normalizes_line_endings(self) -> None: + """``copy_and_convert_template_file`` writes LF regardless of input.""" + # given + source = self.source_path / "config.py-tpl" + source.write_bytes(b"NAME = '{{project_name}}'\r\nDEBUG = True\r\n") + target = self.dest_path / "config.py" + + # when + result = copy_and_convert_template_file( + str(source), str(target), {"{{project_name}}": "demo"} + ) + + # then + assert result is True + assert target.read_bytes() == b"NAME = 'demo'\nDEBUG = True\n" diff --git a/tests/test_cli_operations/test_cli_config_options.py b/tests/test_cli_operations/test_cli_config_options.py index 1d64cf9..e9603d1 100644 --- a/tests/test_cli_operations/test_cli_config_options.py +++ b/tests/test_cli_operations/test_cli_config_options.py @@ -464,7 +464,7 @@ def test_dependency_collector_sees_every_normalized_axis(self) -> None: dependencies = config["all_dependencies"] for package in ( "asyncpg", - "python-jose[cryptography]", + "pyjwt[crypto]", "celery[redis]", "fastapi-cache2", "alembic", diff --git a/uv.lock b/uv.lock index 5ce7c73..48d04a8 100644 --- a/uv.lock +++ b/uv.lock @@ -328,6 +328,7 @@ dev = [ [package.dev-dependencies] docs = [ + { name = "idna" }, { name = "mdx-include" }, { name = "mkdocs" }, { name = "mkdocs-material" }, @@ -361,12 +362,13 @@ provides-extras = ["dev"] [package.metadata.requires-dev] docs = [ + { name = "idna", specifier = ">=3.15" }, { name = "mdx-include", specifier = ">=1.4.2" }, { name = "mkdocs", specifier = ">=1.6.1" }, - { name = "mkdocs-material", specifier = ">=9.6.15" }, + { name = "mkdocs-material", specifier = ">=9.7.7" }, { name = "mkdocs-static-i18n", specifier = ">=1.3.0" }, { name = "pygments", specifier = ">=2.20.0" }, - { name = "pymdown-extensions", specifier = ">=10.17.2" }, + { name = "pymdown-extensions", specifier = ">=11.0.1" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "requests", specifier = ">=2.33.0" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, @@ -378,11 +380,11 @@ translation = [ [[package]] name = "filelock" -version = "3.20.0" +version = "3.32.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/46/126b1831dca12060d4a8296bf9c4fe5c93c4f22197fa239cb0cc82042bba/filelock-3.32.6.tar.gz", hash = "sha256:a3f55a18af3652a94d8f47d6055df434f254ca1d02ef2524850c6d249ca2512c", size = 225172, upload-time = "2026-09-08T22:57:11.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/4f138f618dbea66803291274f228f01daf29f306fe8b96bc30dab765df75/filelock-3.32.6-py3-none-any.whl", hash = "sha256:3f16ecd0117feae0dfc147e8c62eb5daeccd8bd800378c3ddf416de9b4feb6b1", size = 100189, upload-time = "2026-09-08T22:57:10.182Z" }, ] [[package]] @@ -445,11 +447,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.19" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] [[package]] @@ -706,7 +708,7 @@ wheels = [ [[package]] name = "mkdocs-material" -version = "9.6.22" +version = "9.7.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, @@ -721,9 +723,9 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/5d/317e37b6c43325cb376a1d6439df9cc743b8ee41c84603c2faf7286afc82/mkdocs_material-9.6.22.tar.gz", hash = "sha256:87c158b0642e1ada6da0cbd798a3389b0bc5516b90e5ece4a0fb939f00bacd1c", size = 4044968, upload-time = "2025-10-15T09:21:15.409Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923, upload-time = "2026-07-17T16:21:33.156Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/82/6fdb9a7a04fb222f4849ffec1006f891a0280825a20314d11f3ccdee14eb/mkdocs_material-9.6.22-py3-none-any.whl", hash = "sha256:14ac5f72d38898b2f98ac75a5531aaca9366eaa427b0f49fc2ecf04d99b7ad84", size = 9206252, upload-time = "2025-10-15T09:21:12.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438, upload-time = "2026-07-17T16:21:30.017Z" }, ] [[package]] @@ -974,15 +976,15 @@ wheels = [ [[package]] name = "pymdown-extensions" -version = "10.21.2" +version = "11.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/17/2db4b414de89659144488e0d9c6c0bf0c8395841dc12d81d0532cc6ef310/pymdown_extensions-11.0.2.tar.gz", hash = "sha256:9506fcbe66fa355a775b768084334238dd6805020ac4b92bea0c0dda6f8f223d", size = 855419, upload-time = "2026-08-22T19:28:47.236Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/a6/43/9f45ec4d14e596efc32c925a78104934790438b0c0628b70d741016734ad/pymdown_extensions-11.0.2-py3-none-any.whl", hash = "sha256:259910762019732caa1dfd76f3faa62c59f191d46573e80bcb1d13c0f675bbe5", size = 269929, upload-time = "2026-08-22T19:28:45.389Z" }, ] [[package]] @@ -1027,6 +1029,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-discovery" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/96/0f93e27c9f60a650838f2118159aa115fd5732c0716247917b7ba7ede665/python_discovery-1.6.0.tar.gz", hash = "sha256:6393b4eae1be8b2182670635e7baff89ac21cb9f8e86fd1ff40c7b1144febb4c", size = 82849, upload-time = "2026-08-28T17:30:02.366Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/5e/21abf578182fb15006a57faf3711a1e659e29d600d19b6e557eae908c81d/python_discovery-1.6.0-py3-none-any.whl", hash = "sha256:d4e244cf17b8b29819ed78003d55fbacf86eda23425b075454fff9271b79377a", size = 38451, upload-time = "2026-08-28T17:30:01.236Z" }, +] + [[package]] name = "pytokens" version = "0.4.1" @@ -1213,25 +1227,26 @@ wheels = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] name = "virtualenv" -version = "20.35.3" +version = "21.7.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, + { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/d5/b0ccd381d55c8f45d46f77df6ae59fbc23d19e901e2d523395598e5f4c93/virtualenv-20.35.3.tar.gz", hash = "sha256:4f1a845d131133bdff10590489610c98c168ff99dc75d6c96853801f7f67af44", size = 6002907, upload-time = "2025-10-10T21:23:33.178Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/5c/ba82fdd0da13ade01453fc08277a70abc79128101319549f55f2e13f8f83/virtualenv-21.7.9.tar.gz", hash = "sha256:a7e42d81d779dec8afd7dc4be71640fb959ea861bccfa5980cb4ad9f92e30675", size = 5348882, upload-time = "2026-09-09T01:03:27.752Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/73/d9a94da0e9d470a543c1b9d3ccbceb0f59455983088e727b8a1824ed90fb/virtualenv-20.35.3-py3-none-any.whl", hash = "sha256:63d106565078d8c8d0b206d48080f938a8b25361e19432d2c9db40d2899c810a", size = 5981061, upload-time = "2025-10-10T21:23:30.433Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/888c80b8da917bfc48a41887eca244f995d190bd3c01175727089fcaf27e/virtualenv-21.7.9-py3-none-any.whl", hash = "sha256:ba3b0bb41063c848d84d76a9fe3fb7711aaa0f2fe78708e8f3cd9714770e4eac", size = 5324667, upload-time = "2026-09-09T01:03:26.023Z" }, ] [[package]] From f5cb38a5b74772e9ee68a0e907dfc30c413dcc22 Mon Sep 17 00:00:00 2001 From: bnbong Date: Thu, 10 Sep 2026 11:57:19 +0900 Subject: [PATCH 2/2] [TEST] add coverage testcases --- tests/test_backends/test_inspection_docker.py | 47 +++++++++++++ tests/test_backends/test_transducer.py | 69 +++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/tests/test_backends/test_inspection_docker.py b/tests/test_backends/test_inspection_docker.py index 7dab492..4e6742e 100644 --- a/tests/test_backends/test_inspection_docker.py +++ b/tests/test_backends/test_inspection_docker.py @@ -240,6 +240,37 @@ def test_parses_json_lines_and_skips_malformed(self) -> None: {"Name": "db", "State": "exited"}, ] + def test_returns_empty_list_when_document_is_neither_dict_nor_list(self) -> None: + # given - a payload that parses successfully but into a scalar value + compose = DockerCompose("/tmp/project") + stdout = '{"ignored": true}' + + # when + with patch.object(compose, "_compose", return_value=completed(stdout=stdout)): + with patch.object(docker_module.json, "loads", return_value=42): + services = compose._services(timeout=5) + + # then + assert services == [] + + def test_parse_json_lines_skips_blank_and_malformed_lines(self) -> None: + # given - blank lines and syntactically invalid JSON lines mixed in + payload = ( + '{"Name": "app", "State": "running"}\n' + "\n" + "{not valid json}\n" + '{"Name": "db", "State": "exited"}' + ) + + # when + services = DockerCompose._parse_json_lines(payload) + + # then + assert services == [ + {"Name": "app", "State": "running"}, + {"Name": "db", "State": "exited"}, + ] + class TestContainersRunning: """Every container in the project must report ``true`` for Running.""" @@ -314,6 +345,22 @@ def test_times_out_when_never_healthy(self) -> None: with patch("time.time", side_effect=lambda: next(times, 100)): compose.wait_until_healthy(timeout=1) + def test_logs_and_sleeps_while_waiting_for_services_to_become_ready(self) -> None: + # given - the deadline check passes once before finally timing out + compose = DockerCompose("/tmp/project") + times = iter([0, 0, 100]) + with patch.object(compose, "_services", return_value=[]): + with patch("time.sleep") as mock_sleep: + with patch( + "fastapi_fastkit.backend.inspection.docker.debug_log" + ) as mock_debug_log: + with patch("time.time", side_effect=lambda: next(times, 100)): + compose.wait_until_healthy(timeout=10) + + # then + mock_sleep.assert_any_call(5) + mock_debug_log.assert_any_call("Services not ready yet, waiting...", "info") + class TestVerifyServicesRunning: """DB and app services must both be reported as running.""" diff --git a/tests/test_backends/test_transducer.py b/tests/test_backends/test_transducer.py index 6f39d1c..974fd01 100644 --- a/tests/test_backends/test_transducer.py +++ b/tests/test_backends/test_transducer.py @@ -15,6 +15,7 @@ _apply_replacements, _copy_template_file, _ensure_directory_exists, + _looks_like_text_file, _process_directory_tree, _read_template_content, _write_target_file, @@ -193,6 +194,45 @@ def test_copy_and_convert_template_file_source_not_found(self) -> None: assert result is False assert not target_file.exists() + def test_copy_and_convert_template_file_returns_false_when_content_is_none( + self, + ) -> None: + """Test copy_and_convert_template_file returns False when content read fails.""" + # given + source_file = self.source_path / "test.py-tpl" + target_file = self.dest_path / "test.py" + source_file.write_text("# Test content") + + # when + with patch( + "fastapi_fastkit.backend.transducer._read_template_content", + return_value=None, + ): + result = copy_and_convert_template_file(str(source_file), str(target_file)) + + # then + assert result is False + assert not target_file.exists() + + def test_copy_and_convert_template_file_returns_false_on_unexpected_error( + self, + ) -> None: + """Test copy_and_convert_template_file catches unexpected exceptions.""" + # given + source_file = self.source_path / "test.py-tpl" + target_file = self.dest_path / "test.py" + source_file.write_text("# Test content") + + # when + with patch( + "fastapi_fastkit.backend.transducer._write_target_file", + side_effect=RuntimeError("boom"), + ): + result = copy_and_convert_template_file(str(source_file), str(target_file)) + + # then + assert result is False + def test_copy_and_convert_template_file_target_dir_creation(self) -> None: """Test copy_and_convert_template_file creates target directory.""" # given @@ -515,6 +555,35 @@ def test_copy_and_convert_template_target_creation_error(self) -> None: ) +class TestLooksLikeTextFile: + """``_looks_like_text_file`` sniffs whitelisted files for binary content.""" + + def setup_method(self) -> None: + """Setup method for each test.""" + self.temp_dir = tempfile.mkdtemp() + + def teardown_method(self) -> None: + """Cleanup method for each test.""" + import shutil + + if os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + + def test_returns_false_when_open_raises_os_error(self) -> None: + """An OSError while sniffing the file is treated as non-text.""" + # given + file_path = os.path.join(self.temp_dir, "main.py") + with open(file_path, "w") as f: + f.write("print('hi')") + + # when + with patch("builtins.open", side_effect=OSError("boom")): + result = _looks_like_text_file(file_path, "main.py") + + # then + assert result is False + + class TestLineEndingNormalization: """Copying a template must never carry CRLF into a generated project."""