Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
65c5811
Refactor configuration handling to ensure config file path is correct…
javidahmed64592 Dec 30, 2025
d0eda2d
Remove configuration directory references from Dockerfile and build p…
javidahmed64592 Dec 30, 2025
df50ebc
Update documentation and CI workflows; remove unused mock fixture
javidahmed64592 Dec 30, 2025
168e0de
Ensure configuration directory is created if missing when saving conf…
javidahmed64592 Dec 30, 2025
8be3bf1
Refactor documentation and scripts: remove certificate generation ins…
javidahmed64592 Dec 30, 2025
cfd0568
Refactor certificate handling: remove command-line argument parsing a…
javidahmed64592 Dec 30, 2025
4925984
Update default server port to 443 in ServerConfigModel
javidahmed64592 Dec 30, 2025
035247e
Refactor startup script instructions and remove unused certificate di…
javidahmed64592 Dec 30, 2025
e5f56e8
Update coverage source to include tests directory
javidahmed64592 Dec 30, 2025
c2880de
Update default server hostname to 0.0.0.0 in ServerConfigModel
javidahmed64592 Dec 30, 2025
c1979ae
Refactor configuration handling: allow dynamic server port configurat…
javidahmed64592 Dec 30, 2025
690bf52
Enhance configuration handling: add configuration copy to Dockerfile,…
javidahmed64592 Dec 30, 2025
93fcdf9
Refactor TemplateServerRun tests: add mock for uvicorn.run, improve e…
javidahmed64592 Dec 30, 2025
92b4050
Enhance Docker deployment documentation: add PORT environment variabl…
javidahmed64592 Dec 30, 2025
8dda18d
Enhance request logging middleware: include client port in log messag…
javidahmed64592 Dec 30, 2025
10460aa
Fix logging format: adjust parentheses in log format string for consi…
javidahmed64592 Dec 30, 2025
4eadbca
Refactor startup script creation: remove specific mention of Ollama m…
javidahmed64592 Dec 30, 2025
2f940c0
Add assertion to ensure client is set in request logging test
javidahmed64592 Dec 30, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ Developers extend `TemplateServer` to create application-specific servers (see `
```powershell
# Setup (first time)
uv sync # Install dependencies
uv run generate-certificate # Create self-signed SSL certs (certs/ dir)
uv run generate-new-token # Generate API key, save hash to .env

# Development
Expand All @@ -76,9 +75,9 @@ docker compose down # Stop and remove containers

### Docker Multi-Stage Build

- **Stage 1 (builder)**: Uses `uv` to build wheel, copies `configuration/` directory and other required files
- **Stage 1 (builder)**: Uses `uv` to build wheel, copies required files
- **Stage 2 (runtime)**: Installs wheel, copies runtime files (.here, configs, LICENSE, README.md) from wheel to /app
- **Startup Script**: `/app/start.sh` generates token/certs if missing, starts server
- **Startup Script**: `/app/start.sh` generates token if missing, starts server
- **Config Selection**: Uses `config.json` for all environments
- **Build Args**: `PORT=443` (exposes port)
- **Health Check**: Curls `/api/health` with unverified SSL context (no auth required)
Expand Down Expand Up @@ -117,15 +116,13 @@ docker compose down # Stop and remove containers

### What's NOT Implemented Yet

- Custom domain-specific endpoints (template provides base functionality only)
- Database/metadata storage (users implement as needed in subclasses)
- CORS configuration (can be added by subclasses)
- API key rotation/expiry
- Multi-user auth (JWT/OAuth2)

### Testing Requirements

- Mock `pyhere.here()` for all file path tests (see `conftest.py`)
- Use fixtures for TemplateServer/ExampleServer instantiation
- Test async endpoints with `@pytest.mark.asyncio`
- Mock `uvicorn.run` when testing server `.run()` methods
Expand All @@ -134,18 +131,24 @@ docker compose down # Stop and remove containers

All PRs must pass:

**CI Workflow:**
**Build Workflow (build.yml):**

1. `build_wheel` - Create and upload Python wheel package
2. `verify_structure` - Verify installed package structure and required files

**CI Workflow (ci.yml):**

1. `validate-pyproject` - pyproject.toml schema validation
2. `ruff` - linting (120 char line length, strict rules in pyproject.toml)
3. `mypy` - 100% type coverage (strict mode)
4. `pytest` - 99% code coverage, HTML report uploaded
5. `version-check` - pyproject.toml vs uv.lock version consistency
5. `bandit` - security check for Python code
6. `pip-audit` - audit dependencies for known vulnerabilities
7. `version-check` - pyproject.toml vs uv.lock version consistency

**Docker Workflow:**
**Docker Workflow (docker.yml):**

1. `docker-development` - Build and test dev image with docker compose
2. `docker-production` - Build and test prod image with ENV=prod, PORT=443
1. `build` - Build and test development image with docker compose

## Quick Reference

Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ jobs:
echo "Checking required directories in site-packages..."
REQUIRED_DIRS=(
"${SITE_PACKAGES}/${PACKAGE_NAME}"
"${SITE_PACKAGES}/configuration"
)

for dir in "${REQUIRED_DIRS[@]}"; do
Expand All @@ -77,7 +76,6 @@ jobs:
# Check for required files in site-packages
echo "Checking required files in site-packages..."
REQUIRED_FILES=(
"${SITE_PACKAGES}/configuration/config.json"
"${SITE_PACKAGES}/README.md"
"${SITE_PACKAGES}/LICENSE"
"${SITE_PACKAGES}/.here"
Expand Down
23 changes: 9 additions & 14 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

# Copy backend source files
COPY python_template_server/ ./python_template_server/
COPY configuration/ ./configuration/
COPY pyproject.toml .here LICENSE README.md ./

# Build the wheel
Expand All @@ -26,21 +25,23 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# Copy the built wheel from backend builder
COPY --from=backend-builder /build/dist/*.whl /tmp/

# Copy configuration
COPY configuration /app/configuration/

# Install the wheel
RUN uv pip install --system --no-cache /tmp/*.whl && \
rm /tmp/*.whl

# Create required directories
RUN mkdir -p /app/logs /app/certs
RUN mkdir -p /app/logs

# Copy included files from installed wheel to app directory
RUN SITE_PACKAGES_DIR=$(find /usr/local/lib -name "site-packages" -type d | head -1) && \
cp -r "${SITE_PACKAGES_DIR}/configuration" /app/ && \
cp "${SITE_PACKAGES_DIR}/.here" /app/.here && \
cp "${SITE_PACKAGES_DIR}/LICENSE" /app/LICENSE && \
cp "${SITE_PACKAGES_DIR}/README.md" /app/README.md

# Create startup script with Ollama model checking
# Create startup script
RUN echo '#!/bin/sh\n\
set -e\n\
\n\
Expand All @@ -51,20 +52,14 @@ RUN echo '#!/bin/sh\n\
export $(grep -v "^#" .env | xargs)\n\
fi\n\
\n\
# Generate certificates if needed\n\
if [ ! -f certs/cert.pem ] || [ ! -f certs/key.pem ]; then\n\
echo "Generating self-signed certificates..."\n\
generate-certificate\n\
fi\n\
\n\
exec python-template-server' > /app/start.sh && \
exec python-template-server --port $PORT' > /app/start.sh && \
chmod +x /app/start.sh

# Expose HTTPS port
EXPOSE 443
# Expose server port
EXPOSE $PORT

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('https://localhost:443/api/health', context=__import__('ssl')._create_unverified_context()).read()" || exit 1
CMD python -c "import urllib.request; urllib.request.urlopen('https://localhost:'\"$PORT\"'/api/health', context=__import__('ssl')._create_unverified_context()).read()" || exit 1

CMD ["/app/start.sh"]
8 changes: 2 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ This repository provides a solid foundation for building secure, observable Fast
- [Quick Start](#quick-start)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Generate Certificates and API Token](#generate-certificates-and-api-token)
- [Generate API Token](#generate-api-token)
- [Run the Server](#run-the-server)
- [Using as a Template](#using-as-a-template)
- [Docker Deployment](#docker-deployment)
Expand Down Expand Up @@ -73,13 +73,9 @@ cd python-template-server
uv sync --extra dev
```

### Generate Certificates and API Token
### Generate API Token

```sh
# Generate self-signed SSL certificate (saves to certs/ directory)
uv run generate-certificate

# Generate API authentication token (saves hash to .env)
uv run generate-new-token
# ⚠️ Save the displayed token - you'll need it for API requests!
```
Expand Down
5 changes: 3 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ services:
dockerfile: Dockerfile
container_name: python-template-server
ports:
- "443:443"
- "${PORT:-443}:${PORT:-443}"
environment:
# Load API token hash from .env file (optional - will be generated if missing)
- API_TOKEN_HASH=${API_TOKEN_HASH:-}
- PORT=${PORT:-443}
volumes:
- certs:/app/certs
- logs:/app/logs
Expand All @@ -20,7 +21,7 @@ services:
"CMD",
"sh",
"-c",
'python -c "import urllib.request; urllib.request.urlopen(''https://localhost:443/api/health'', context=__import__(''ssl'')._create_unverified_context()).read()"',
'python -c "import urllib.request; urllib.request.urlopen(''https://localhost:$PORT/api/health'', context=__import__(''ssl'')._create_unverified_context()).read()"',
]
interval: 30s
timeout: 10s
Expand Down
3 changes: 3 additions & 0 deletions docs/DOCKER_DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,15 @@ Configure the FastAPI server using environment variables in `docker-compose.yml`
```yaml
environment:
- API_TOKEN_HASH=${API_TOKEN_HASH}
- PORT=${PORT:-443}
```

The `API_TOKEN_HASH` is loaded from your local `.env` file.
If the `.env` file exists when you run `docker compose up`, the container will use that token hash.
Otherwise, the container startup script will generate a new token and create the `.env` file.

The `PORT` environment variable sets the server port (default 443).

### Server Configuration

Modify `config.json` to customize:
Expand Down
25 changes: 3 additions & 22 deletions docs/SMG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@ This document outlines how to configure and setup a development environment to w
- [Directory Structure](#directory-structure)
- [Architecture Overview](#architecture-overview)
- [Installing Dependencies](#installing-dependencies)
- [Setting Up Certificates and Authentication](#setting-up-certificates-and-authentication)
- [Generating SSL Certificates](#generating-ssl-certificates)
- [Generating API Authentication Tokens](#generating-api-authentication-tokens)
- [Setting Up Authentication](#setting-up-authentication)
- [Running the Backend](#running-the-backend)
- [Testing, Linting, and Type Checking](#testing-linting-and-type-checking)

Expand Down Expand Up @@ -83,26 +81,9 @@ After installing dev dependencies, set up pre-commit hooks:
uv run pre-commit install
```

### Setting Up Certificates and Authentication
### Setting Up Authentication

Before running the server, you need to generate SSL certificates and an API authentication token.

#### Generating SSL Certificates

The server requires self-signed SSL certificates for HTTPS support:

```sh
uv run generate-certificate
```

This command:
- Creates a self-signed certificate valid for 365 days
- Generates RSA-4096 key pairs
- Saves certificates to the `certs/` directory (`cert.pem` and `key.pem`)

#### Generating API Authentication Tokens

Generate a secure API token for authenticating requests:
Before running the server, you need to generate an API authentication token.

```sh
uv run generate-new-token
Expand Down
4 changes: 1 addition & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ repository = "https://github.com/javidahmed64592/python-template-server"

[project.scripts]
python-template-server = "python_template_server.main:run"
generate-certificate = "python_template_server.certificate_handler:generate_self_signed_certificate"
generate-new-token = "python_template_server.authentication_handler:generate_new_token"

[tool.hatch.metadata]
Expand All @@ -55,7 +54,6 @@ allow-direct-references = true
[tool.hatch.build]
include = [
"python_template_server/**",
"configuration/**",
".here",
"LICENSE",
"README.md",
Expand All @@ -71,7 +69,7 @@ addopts = [

[tool.coverage.run]
branch = true
source = ["python_template_server"]
source = ["python_template_server", "tests"]

[tool.coverage.report]
fail_under = 80
Expand Down
38 changes: 0 additions & 38 deletions python_template_server/certificate_handler.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Generate self-signed SSL certificate for local development."""

import argparse
import ipaddress
import logging
import sys
Expand All @@ -12,9 +11,7 @@
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID

from python_template_server.constants import CONFIG_FILE_PATH
from python_template_server.logging_setup import setup_logging
from python_template_server.main import ExampleServer
from python_template_server.models import CertificateConfigModel

setup_logging()
Expand Down Expand Up @@ -132,38 +129,3 @@ def generate_self_signed_cert(self) -> None:
except OSError:
logger.exception("Failed to generate certificate files!")
raise


def parse_args() -> argparse.Namespace:
"""Parse command-line arguments for certificate generation.

:return argparse.Namespace: Parsed arguments
"""
parser = argparse.ArgumentParser(description="Generate self-signed certificates for local development.")
parser.add_argument(
"--config",
type=str,
default=str(CONFIG_FILE_PATH),
help="Path to the configuration file (default: configuration/config.json)",
)
return parser.parse_args()


def generate_self_signed_certificate() -> None:
"""Generate self-signed certificates for local development.

:raise SystemExit: If certificate generation fails
"""
args = parse_args()
config_filepath = Path(args.config)

try:
server = ExampleServer(config_filepath=config_filepath)
handler = CertificateHandler(server.config.certificate)
handler.generate_self_signed_cert()
except (OSError, PermissionError):
logger.exception("Failed to generate certificates!")
sys.exit(1)
except Exception:
logger.exception("Unexpected error during certificate generation!")
sys.exit(1)
2 changes: 1 addition & 1 deletion python_template_server/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@
# Logging constants
LOG_MAX_BYTES = 10 * BYTES_TO_MB # 10 MB
LOG_BACKUP_COUNT = 5
LOG_FORMAT = "[%(asctime)s] (%(levelname)s) %(module)s: %(message)s"
LOG_FORMAT = "[%(asctime)s] %(levelname)s [%(module)s]: %(message)s"
LOG_DATE_FORMAT = "%d/%m/%Y | %H:%M:%S"
LOG_LEVEL = "INFO"
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ def __init__(self, app: ASGIApp) -> None:
async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
"""Log request and response details."""
client_ip = request.client.host if request.client else "unknown"
client_port = request.client.port if request.client else 0

self.logger.info(
"Request: %s %s from %s",
"Request: %s %s from %s:%d",
request.method,
request.url.path,
client_ip,
client_port,
)

response = await call_next(request)
Expand Down
13 changes: 10 additions & 3 deletions python_template_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
class ServerConfigModel(BaseModel):
"""Server configuration model."""

host: str = Field(default="localhost", description="Server hostname or IP address")
port: int = Field(default=8000, ge=1, le=65535, description="Server port number")
host: str = Field(default="0.0.0.0", description="Server hostname or IP address") # noqa: S104
port: int = Field(default=443, ge=1, le=65535, description="Server port number")

@property
def address(self) -> str:
Expand All @@ -33,7 +33,13 @@ class SecurityConfigModel(BaseModel):

hsts_max_age: int = Field(default=31536000, ge=0, description="HSTS max-age in seconds (1 year default)")
content_security_policy: str = Field(
default="default-src 'self'", description="Content Security Policy header value"
default=(
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"img-src 'self' data: https://cdn.jsdelivr.net https://fastapi.tiangolo.com"
),
description="Content Security Policy header value",
)


Expand Down Expand Up @@ -87,6 +93,7 @@ def save_to_file(self, filepath: Path) -> None:

:param Path filepath: Path to the configuration file
"""
filepath.parent.mkdir(parents=True, exist_ok=True)
with filepath.open("w", encoding="utf-8") as config_file:
config_file.write(self.model_dump_json(indent=2))
config_file.write("\n")
Expand Down
Loading