toolforge-python · git:20260627.84c1774 · 2026-06-27 · sha256 ab3e723f6348479a

toolforge-python git:20260627.84c1774B

Immutable. This exact content never changes and is served at /api/v1/blob/ab3e723f6348479a.

---
name: toolforge-python
description: Deploy Python web services on Wikimedia Toolforge — Flask (WSGI) and FastAPI (ASGI), gunicorn/uvicorn, Build Service and traditional Kubernetes backends, virtual environments, pip caching, PORT configuration, static files, logging, and common pitfalls
license: MIT
compatibility: opencode
depends_on: [wikimedia-toolforge, wikimedia-api-access]
skill_discovery_hints:
  - keywords: ["Toolforge Python", "Flask", "gunicorn", "Python webservice", "python3.11", "WSGI"]
  - keywords: ["Toolforge FastAPI", "ASGI", "uvicorn", "Python ASGI", "FastAPI toolforge"]
  - keywords: ["Toolforge build service", "Procfile", "buildpack", "toolforge build start"]
  - keywords: ["Toolforge venv", "virtual environment", "pip install", "Toolforge pip", "requirements.txt", "runtime.txt"]
  - keywords: ["Toolforge PORT", "gunicorn bind", "Toolforge static", "Python http server"]
  - keywords: ["Toolforge flask", "flask app", "Toolforge deploy python", "toolforge python"]
  - keywords: ["Toolforge cron python", "python job", "scheduled python", "Toolforge batch"]
last_verified: 2026-06-22
---

> ⚠️ **Prerequisites:** This skill assumes you have a Toolforge account and can SSH in. See **[wikimedia-toolforge](../wikimedia-toolforge/SKILL.md)** for account setup, tool creation, and basic SSH configuration.

> 💡 **Python is the dominant language on Toolforge.** Most existing tools use Python, and the platform has first-class support for Python runtimes. This skill covers the standard Flask + gunicorn stack and FastAPI + uvicorn, which power the vast majority of Toolforge Python web services.

> 📖 **Official documentation is spread across 7 pages on Wikitech** with no clear
> "start here" signal. The table below maps each official page to this skill's
> SOPs so you know which to follow:
>
> | Wikitech Page | Path | Maps To |
> |--------------|------|----------|
> | [Help:Toolforge/Python](https://wikitech.wikimedia.org/wiki/Help:Toolforge/Python) | Landing page | All SOPs — this skill is the curated replacement |
> | [Web/Python](https://wikitech.wikimedia.org/wiki/Help:Toolforge/Web/Python) | Traditional uWSGI | SOP 1 (Flask) + SOP 1-D (conventions) |
> | [My first Flask OAuth tool](https://wikitech.wikimedia.org/wiki/Help:Toolforge/My_first_Flask_OAuth_tool) | Traditional | SOP 1 + **[wikimedia-auth-oauth](../wikimedia-auth-oauth/SKILL.md)** |
> | [Buildpack Python (Flask)](https://wikitech.wikimedia.org/wiki/Help:Toolforge/Building_container_images/My_first_Buildpack_Python_tool) | Build Service | SOP 1-C |
> | [My first Python ASGI tool](https://wikitech.wikimedia.org/wiki/Help:Toolforge/My_first_Python_ASGI_tool) | Build Service | SOP 1-B (FastAPI) + SOP 1-C |
> | [Buildpack Django](https://wikitech.wikimedia.org/wiki/Help:Toolforge/Building_container_images/My_first_Buildpack_Django_tool) | Build Service | SOP 1-D (Django notes) + SOP 1-C |
>
> **Recommendation for new tools:** Use Build Service (SOP 1-C). It's the modern
> path, supports all Python versions, and avoids the fixed file layout of the
> traditional backend.

---

## Quick Reference

| Goal | Command / Pattern |
|------|-------------------|
| Deploy a new Python service | See SOP 1 |
| Choose a runtime | `python3.11` (default), `python3.9` (legacy) — see SOP 1.1 |
| Start/restart a webservice | `become <tool> webservice --backend=kubernetes python3.11 start` |
| See logs | `become <tool> kubectl logs -f deployment/<tool>` |
| Set environment variables | `become <tool> toolforge env set NAME value` |
| Serve static files | Use Flask's `send_from_directory()` or `static_url_path` — see SOP 3 |
| Install dependencies | Use a virtual environment and `requirements.txt` — see SOP 4 |
| Run a cron job | `become <tool> job schedule python3.11 --command 'python3 script.py' "0 * * * *"` |

---

## SOP 1: Bootstrap a Python Web Service

The standard Toolforge Python stack: **Flask + gunicorn**, deployed in a virtual environment.

### 1.1 Choose a Runtime

Toolforge offers these Python runtime versions:

| Runtime | Kubernetes Image | Status |
|---------|-----------------|--------|
| `python3.11` | `docker-registry.tools.wmflabs.org/toolforge-python311-sssd-web:latest` | ✅ Current default (Python 3.11) |
| `python3.9` | `docker-registry.tools.wmflabs.org/toolforge-python39-sssd-web:latest` | ⚠️ Legacy (Python 3.9, available but not recommended for new tools) |

The runtime image includes Python, pip, and system libraries. Your code and
dependencies live on NFS at `/data/project/<tool>/`.

### 1.2 Create the Tool and Virtual Environment

```bash
# From your local machine
ssh <user>@login.toolforge.org

# Create the tool (one time)
toolforge tools create my-python-tool

# Enter the tool's home
become my-python-tool

# Create a virtual environment
python3 -m venv venv
source venv/bin/activate

# Install Flask and gunicorn
pip install flask gunicorn
```

### 1.3 Write Your App

Create `app.py` — a minimal Flask application:

```python
# app.py — Flask web service for Toolforge
import os
from flask import Flask, jsonify, send_from_directory

app = Flask(__name__)

# Toolforge sets the PORT environment variable — your server must bind to it
PORT = int(os.environ.get('PORT', 8765))


@app.route('/')
def home():
    return '<h1>Hello from Toolforge!</h1>'


@app.route('/api/hello')
def hello():
    return jsonify(message='Hello from Toolforge!', status='ok')


@app.route('/api/status')
def status():
    return jsonify(status='running', port=PORT)


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=PORT)
```

**Key points:**
- Bind to `0.0.0.0:PORT` (not `127.0.0.1`) — Kubernetes needs the external interface
- Always read `PORT` from the environment; the fallback (`8765`) is for local dev only
- The `if __name__ == '__main__'` guard lets you test locally with `python3 app.py`

### 1.4 Deploy with gunicorn

gunicorn is the production WSGI server. Toolforge expects gunicorn to be
installed in your virtual environment.

```bash
# From your local machine — upload files
scp app.py <user>@login.toolforge.org:/data/project/my-python-tool/
scp requirements.txt <user>@login.toolforge.org:/data/project/my-python-tool/

# SSH in and set up
ssh <user>@login.toolforge.org
become my-python-tool

# Create venv and install dependencies
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

# Start the webservice
webservice --backend=kubernetes python3.11 start
```

### 1.5 Verify

```bash
# Check status
become my-python-tool webservice --backend=kubernetes python3.11 status

# View logs
become my-python-tool kubectl logs -f deployment/my-python-tool

# Test
curl https://my-python-tool.toolforge.org/
curl https://my-python-tool.toolforge.org/api/hello
```

---

## SOP 1-B: FastAPI / ASGI (Alternative Framework)

[FastAPI](https://fastapi.tiangolo.com/) is a modern ASGI framework with automatic
OpenAPI docs, async support, and built-in data validation. It runs on uvicorn —
an ASGI server — rather than gunicorn directly.

### Key Differences from Flask

| | Flask (WSGI) | FastAPI (ASGI) |
|---|-------------|----------------|
| Server | gunicorn directly | gunicorn **with uvicorn workers** |
| Async | Limited | Native `async/await` |
| Docs | Manual | Auto-generated at `/docs` |
| Validation | Manual or extensions | Built-in via Pydantic |

### Minimal FastAPI App

```python
# app.py — FastAPI ASGI application for Toolforge
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def home():
    return {"Hello": "World"}


@app.get("/api/hello")
async def hello():
    return {"message": "Hello from Toolforge!", "status": "ok"}
```

### Installing Dependencies

```bash
pip install fastapi httpx "uvicorn[standard]" gunicorn
```

### Procfile (Required for Build Service)

```
web: gunicorn app:app -k uvicorn.workers.UvicornWorker --workers=4 \
     --timeout 60 --bind 0.0.0.0 --forwarded-allow-ips=*
```

**How this works:** Gunicorn manages multiple uvicorn worker processes.
Each worker runs an instance of the uvicorn ASGI server. Gunicorn handles
process management and scaling; uvicorn handles async request processing.

### Local Testing

```bash
# FastAPI dev server (auto-reload, great for development)
uvicorn app:app --reload

# Open http://127.0.0.1:8000 to see the JSON response
# Open http://127.0.0.1:8000/docs for auto-generated API docs
```

### Production Deploy (With Build Service)

FastAPI apps typically use the Build Service deployment path (see SOP 1-C).
The key differences from Flask:
- Prodfile uses `-k uvicorn.workers.UvicornWorker`
- gunicorn wraps uvicorn, not the app directly
- The app must be ASGI-compatible (FastAPI, Starlette, Quart, etc.)

---

## SOP 1-C: Build Service Deployment (Container-Based)

The **Build Service** is the recommended path for new tools. Instead of
`scp`-ing files and running `webservice start`, you push code to a Git
repository and Toolforge builds a container image from it.

### Benefits Over Traditional Deployment

| Traditional (Kubernetes) | Build Service (Container) |
|--------------------------|---------------------------|
| Manual `scp` uploads | `git push` triggers deploy |
| Venv lives on NFS | Dependencies baked into image |
| Runtime tied to Toolforge images | Any Python version via `runtime.txt` |
| ASGI/WSGI both work but manual | Native support for both |

### Step-by-Step

**1. Create a Git repo for your tool**

Go to [toolsadmin.wikimedia.org](https://toolsadmin.wikimedia.org/tools/) →
select your tool → `Git repositories` → `create repository`. Clone the
resulting URL (use the public HTTPS URL for Build Service):

```bash
git clone https://gitlab.wikimedia.org/toolforge-repos/my-python-tool.git
cd my-python-tool
```

**2. Set up your project**

```bash
# Create a virtual environment for local dev
python3 -m venv venv
source venv/bin/activate

# Install dependencies
pip install fastapi httpx "uvicorn[standard]" gunicorn
# Or for Flask:
pip install flask gunicorn

# Freeze dependencies
pip freeze > requirements.txt
```

> ⚠️ **License requirement:** All code on Toolforge must be under an
> [OSI-approved open-source license](https://opensource.org/licenses).
> Include a license header in your source files and a `LICENSE` file in your
> repository. See the [Right to fork policy](https://wikitech.wikimedia.org/wiki/Help:Toolforge/Right_to_fork_policy).

**3. Specify Python version (optional)**

Create `runtime.txt` to pin a specific Python version:

```bash
echo "python-3.12.1" > runtime.txt
```

Format: `python-<major>.<minor>.<patch>` — case-sensitive, no spaces.
If omitted, the latest available Python is used.

**4. Create a Procfile**

The `Procfile` tells Toolforge how to start your server:

```
web: gunicorn app:app -k uvicorn.workers.UvicornWorker --workers=4 --timeout 60 --bind 0.0.0.0 --forwarded-allow-ips=*
```

For Flask (WSGI, no uvicorn worker):

```
web: gunicorn app:app --workers=4 --timeout 60 --bind 0.0.0.0 --forwarded-allow-ips=*
```

The PORT environment variable is handled automatically — gunicorn reads it.

**5. Commit and push**

```bash
git add .
git commit -m "Initial commit"
git push origin main
```

**6. Build and start on Toolforge**

```bash
ssh <user>@login.toolforge.org
become my-python-tool

# Start the build (use the PUBLIC HTTPS URL)
toolforge build start https://gitlab.wikimedia.org/toolforge-repos/my-python-tool.git

# Check build status — wait for "ok (Succeeded)"
toolforge build show

# Start the webservice
toolforge webservice buildservice start --mount=none
```

**7. Verify**

```bash
curl https://my-python-tool.toolforge.org/
# FastAPI docs: https://my-python-tool.toolforge.org/docs
```

### Updating Your App

```bash
# Make changes locally, push, then rebuild
git add .
git commit -m "Update"
git push origin main

# SSH in and rebuild
ssh <user>@login.toolforge.org
become my-python-tool
toolforge build start https://gitlab.wikimedia.org/toolforge-repos/my-python-tool.git
```

### Which Path to Choose?

| Scenario | Use |
|----------|-----|
| New Python tool (2024+) | Build Service (SOP 1-C) |
| FastAPI / ASGI app | Build Service required |
| Existing Flask app already on NFS | Traditional (SOP 1) |
| Quick prototype, no Git setup | Traditional (SOP 1) |
| Need to pin exact Python version | Build Service with `runtime.txt` |

---

## SOP 1-D: Traditional Backend Conventions (uWSGI)

The **traditional Kubernetes backend** (not Build Service) uses uWSGI under the
hood and expects specific file locations. If you're deploying via
`webservice --backend=kubernetes python3.11 start`, these conventions apply.

### Required File Layout

```
$HOME/www/python/
├── src/
│   └── app.py          # Entry point — must be named app.py
├── venv/                # Virtual environment — must be here
└── uwsgi.ini            # Optional uWSGI configuration
```

> ⚠️ **These paths are non-negotiable for the traditional backend.** Build Service
> (SOP 1-C) has no such restrictions — use whatever layout you prefer.

### Entry Point Convention

The entry point **must** be `$HOME/www/python/src/app.py` and must expose a
variable named `app`:

```python
# $HOME/www/python/src/app.py
from flask import Flask
app = Flask(__name__)
```

For Django, create a wrapper that imports the WSGI application:

```python
import os
from django.core.wsgi import get_wsgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', '<tool-name>.settings')
app = get_wsgi_application()
```

> 💡 **ModuleNotFoundError?** Try importing `os` and setting the env var *before*
> importing Django — the settings module must be set before Django loads.

### Virtual Environment

```bash
# Open a shell in the target Python runtime
become my-tool
webservice --backend=kubernetes python3.11 shell

# Create the venv at the expected location
mkdir -p $HOME/www/python
python3 -m venv $HOME/www/python/venv
source $HOME/www/python/venv/bin/activate

# Upgrade pip (required for wheel support)
pip install --upgrade pip wheel

# Install dependencies
pip install -r $HOME/www/python/src/requirements.txt

# Exit and start
exit
toolforge webservice --backend=kubernetes python3.11 start
```

> 💡 **Shortcut:** Use `webservice-python-bootstrap` inside the shell to automate
> steps 2-6. For a fresh venv (e.g., switching Python versions):
> `webservice-python-bootstrap --fresh`

### uWSGI Configuration (Optional)

Create `$HOME/www/python/uwsgi.ini` for additional configuration:

```ini
[uwsgi]
# CORS headers
route = .* addheader:Access-Control-Allow-Origin: *

# Log rotation (10 MiB)
log-maxsize = 10485760

# Static file mapping (for Django)
static-map = /static=/data/project/<tool>/www/python/src/static
```

### Django Production Setup

Building on the uWSGI wrapper above, a production Django deployment needs
several additional configurations:

**Static files:** Django doesn't serve static files in production. Use
[WhiteNoise](https://whitenoise.readthedocs.io/en/stable/django.html):

```bash
pip install whitenoise
```

```python
# settings.py
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'static'
MIDDLEWARE = [
    'whitenoise.middleware.WhiteNoiseMiddleware',
    # ... other middleware
]
```

**CSRF protection:** Add your tool's domain to trusted origins:

```python
# settings.py
CSRF_TRUSTED_ORIGINS = ['https://your-tool.toolforge.org']
```

**Database (ToolsDB):** For production, use ToolsDB instead of SQLite:

```bash
pip install mysqlclient
```

```bash
# Set these env vars via `toolforge env set`
DB_NAME=<name-of-db-in-toolsdb>
DJANGO_CONFIGURATION=Production
DJANGO_SETTINGS_MODULE=mysite.settings
```

Use [django-configurations](https://django-configurations.readthedocs.io/) to
manage separate dev/prod settings:

```bash
pip install django-configurations
pip freeze > requirements.txt
```

**Database migrations:** Run as one-off jobs on Toolforge:

```bash
# Build Service path (after toolforge build start)
toolforge jobs run \
    --image tool-<tool>/tool-<tool>:latest \
    --command "python manage.py migrate" \
    --wait migrate
```

**Procfile for Django:**

```
web: gunicorn mysite.wsgi --bind 0.0.0.0 --forwarded-allow-ips=*
migrate: python manage.py migrate
```

### Logs

Traditional backend logs go to `$HOME/uwsgi.log` (not stdout/kubectl).

### Troubleshooting: python_plugin.so Error

If you see this in logs:

```
open("/usr/lib/uwsgi/plugins/python_plugin.so"): No such file or directory
!!! UNABLE to load uWSGI plugin
```

It means your app is crashing on startup and the container is falling back to
a Python 2 uWSGI plugin. Debug by opening a shell and running the app directly:

```bash
webservice --backend=kubernetes python3.11 shell
source $HOME/www/python/venv/bin/activate
python $HOME/www/python/src/app.py  # See the actual error
```

---

## SOP 2: The `PORT` Environment Variable

Toolforge's Kubernetes proxy routes incoming requests to your container. It
sets the `PORT` environment variable — your server **must** bind to this port.

### Do This

```python
import os
PORT = int(os.environ.get('PORT', 8765))

# Flask dev server (for testing only — use gunicorn in production)
app.run(host='0.0.0.0', port=PORT)
```

gunicorn automatically reads `PORT` from the environment, so no extra
configuration is needed:

```bash
# gunicorn binds to PORT automatically
venv/bin/gunicorn --bind=0.0.0.0 app:app
```

### Don't Do This

```python
app.run(port=5000)       # ❌ Toolforge proxy won't find it
app.run(port=80)         # ❌ Not running as root
app.run(port='8080')     # ❌ PORT may be different
```

### How It Works

```
Browser ─→ toolforge.org ─→ Kubernetes Ingress ─→ Pod:PORT
                                                      │
                                                process.env.PORT
                                               (e.g., "31245")
```

The `PORT` value is assigned per-pod and is **not predictable** — always read
it from the environment.

---

## SOP 3: Static File Serving

### Using Flask

Flask serves static files from the `static/` folder by default:

```python
from flask import Flask, send_from_directory
import os

app = Flask(__name__)

# Option 1: Serve from Flask's built-in static route
# Place files in /data/project/my-tool/static/ — automatically served at /static/

# Option 2: Custom route for large files or specific directories
@app.route('/images/<path:filename>')
def serve_image(filename):
    return send_from_directory(
        os.path.join(app.root_path, 'static', 'images'),
        filename
    )
```

### Cache-Control Headers

Toolforge uses Varnish as an HTTP accelerator. Set proper cache headers for
performance:

| Asset Type | Recommendation | Effect |
|------------|---------------|--------|
| Images, thumbnails | `public, max-age=604800, immutable` | 1 week, never revalidates |
| CSS, JS | `public, max-age=3600` | 1 hour |
| API responses | `no-store` | Never cached |

```python
@app.after_request
def add_cache_headers(response):
    if response.mimetype.startswith('image/'):
        response.headers['Cache-Control'] = 'public, max-age=604800, immutable'
    elif request.path.startswith('/static/'):
        response.headers['Cache-Control'] = 'public, max-age=3600'
    return response
```

---

## SOP 4: Virtual Environments and Dependencies

### The NFS Problem

Toolforge's NFS filesystem has **slow metadata operations**. Creating a virtual
environment and running `pip install` can take minutes. Strategies to cope:

1. **Minimize dependencies** — stick to Flask + stdlib when possible
2. **Use a single venv** — create it once, reuse across deployments
3. **Pin exact versions** — prevents pip from resolving dependency trees on
   every install

### requirements.txt

```txt
flask==3.1.0
gunicorn==23.0.0
requests==2.32.3
```

### Full Setup

```bash
# SSH into Toolforge
ssh <user>@login.toolforge.org
become my-python-tool

# Create venv (one time)
python3 -m venv venv

# Activate and install
source venv/bin/activate
pip install -r requirements.txt

# Freeze for reproducibility
pip freeze > requirements-frozen.txt
```

### pip Cache Warning

pip's cache directory defaults to `~/.cache/pip`, which is on NFS. For tools
with many dependencies, this can slow things down. You can override it:

```bash
# Use /tmp for pip cache (in-memory tmpfs, faster but ephemeral)
export PIP_CACHE_DIR=/tmp/pip-cache
pip install -r requirements.txt
```

> ⚠️ **Do not run `pip install` in a cron job** — it will be slow and unreliable.
> Install dependencies once in your venv and reference that in cron commands.

---

## SOP 5: Environment Variables & Secrets

Store configuration and secrets via Toolforge's environment variable system:

```bash
# Set (from a become shell)
become my-python-tool
toolforge env set SECRET_KEY your-secret-key-here
toolforge env set OAUTH_CLIENT_ID your-oauth-client-id
toolforge env set FLASK_ENV production

# List
toolforge env list

# Remove
toolforge env unset OLD_VAR
```

### Accessing in Python

```python
import os

SECRET_KEY = os.environ.get('SECRET_KEY')
OAUTH_CLIENT_ID = os.environ.get('OAUTH_CLIENT_ID')
FLASK_ENV = os.environ.get('FLASK_ENV', 'production')
```

> ⚠️ **Never hardcode secrets** in source files. Always use `toolforge env set`
> and read from the environment. Toolforge's env system stores values securely
> and they survive pod restarts.

---

## SOP 6: Logging & Debugging

### View Logs

```bash
# Live tail
become my-python-tool kubectl logs -f deployment/my-python-tool

# Last 100 lines
become my-python-tool kubectl logs --tail=100 deployment/my-python-tool
```

### Python Logging

Use the standard `logging` module with stdout (Toolforge captures it):

```python
import logging
import sys

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s',
    stream=sys.stdout,
)
logger = logging.getLogger(__name__)

logger.info(f"Server starting on port {PORT}")
```

### Debugging a Crash Loop

If your pod keeps restarting:
1. Check logs for tracebacks (`kubectl logs` shows the last crash)
2. Test locally with the same Python version
3. Verify `requirements.txt` is complete
4. Check PORT binding — the most common crash cause

---

## SOP 7: Cron Jobs

For batch processing, periodic syncs, or scheduled maintenance:

```bash
# Run a script every hour
become my-python-tool job schedule python3.11 \
    --command '/data/project/my-python-tool/venv/bin/python3 /data/project/my-python-tool/cron/cleanup.py' \
    "0 * * * *"

# Run once
become my-python-tool job submit python3.11 \
    --command '/data/project/my-python-tool/venv/bin/python3 /data/project/my-python-tool/cron/backfill.py'
```

### Cron Script Template

```python
#!/usr/bin/env python3
# cron/cleanup.py — Scheduled script (no HTTP server needed)
import os
import time
from pathlib import Path

CACHE_DIR = Path('/data/project/my-python-tool/cache')
MAX_AGE_DAYS = 7


def cleanup():
    now = time.time()
    removed = 0
    for file in CACHE_DIR.glob('*'):
        if file.is_file():
            age_days = (now - file.stat().st_atime) / (60 * 60 * 24)
            if age_days > MAX_AGE_DAYS:
                file.unlink()
                removed += 1
    print(f'Cleanup: removed {removed} files (threshold: {MAX_AGE_DAYS} days)')


if __name__ == '__main__':
    cleanup()
```

> ⚠️ **Always use full paths** for the Python interpreter and scripts in cron
> commands. Cron runs in a minimal environment — no shell profile, no `$PATH`
> setup, no activated venv.

---

## SOP 8: Restarting and Updating

### Restart (zero-downtime)

```bash
become my-python-tool webservice --backend=kubernetes python3.11 restart
```

The Kubernetes deployment performs a rolling restart — no downtime as long as
your server starts quickly.

### Update Code

```bash
# Upload new version
scp app.py <user>@login.toolforge.org:/data/project/my-python-tool/

# If dependencies changed, reinstall
ssh <user>@login.toolforge.org "become my-python-tool bash -c 'source venv/bin/activate && pip install -r /data/project/my-python-tool/requirements.txt'"

# Restart
ssh <user>@login.toolforge.org "become my-python-tool webservice --backend=kubernetes python3.11 restart"
```

### Stop

```bash
become my-python-tool webservice --backend=kubernetes python3.11 stop
```

---

## Reference: Quick Command Cheat Sheet

```bash
# === Lifecycle ===
become my-python-tool webservice --backend=kubernetes python3.11 start
become my-python-tool webservice --backend=kubernetes python3.11 stop
become my-python-tool webservice --backend=kubernetes python3.11 restart
become my-python-tool webservice --backend=kubernetes python3.11 status

# === Logs ===
become my-python-tool kubectl logs -f deployment/my-python-tool
become my-python-tool kubectl logs --tail=100 deployment/my-python-tool

# === File Transfer (from local machine) ===
scp app.py <user>@login.toolforge.org:/data/project/my-python-tool/
scp -r static/ <user>@login.toolforge.org:/data/project/my-python-tool/

# === Virtual Environment ===
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

# === Environment ===
become my-python-tool toolforge env set KEY value
become my-python-tool toolforge env list
become my-python-tool toolforge env unset KEY

# === Scheduled Jobs ===
become my-python-tool job schedule python3.11 \
    --command '/data/project/my-python-tool/venv/bin/python3 /data/project/my-python-tool/cron/script.py' \
    "0 * * * *"
```

---

## Reference: Common Pitfalls

| Pitfall | Why It Happens | Prevention |
|---------|---------------|------------|
| **Missing PORT** | Hardcoded port in code | Always use `int(os.environ.get('PORT', 8765))` |
| **Wrong bind address** | `app.run()` defaults to `127.0.0.1` | Always bind `0.0.0.0` |
| **Forgot venv** | pip installs globally, gunicorn not found | Always `source venv/bin/activate` before pip install |
| **Slow pip install** | NFS metadata operations | Minimize dependencies, pin versions, use `PIP_CACHE_DIR=/tmp` |
| **No Cache-Control** | Missing headers = re-download on every page load | Always set cache headers for static assets |
| **Cached 404 pages** | 404 served with `Cache-Control: public` | Use `no-store` for error responses |
| **Crash on startup** | Import error or missing dependency | Test locally with `python3 app.py` before deploying |
| **Cron uses wrong Python** | No venv activation in cron | Always use full path to venv Python: `/data/project/<tool>/venv/bin/python3` |
| **Stale venv** | Dependencies outdated after code change | Re-run `pip install -r requirements.txt` after code updates |
| **TLS/SSL errors from API calls** | Missing CA certificates in container | Install `certifi` and use `requests` (handles this automatically) |

---

## Cross-References

| Related Skill | Why |
|--------------|-----|
| **[wikimedia-toolforge](../wikimedia-toolforge/SKILL.md)** | Account setup, tool creation, SSH, Kubernetes pods, cron jobs, database access |
| **[toolforge-nodejs](../toolforge-nodejs/SKILL.md)** | Node.js equivalent — same platform, different language |
| **[wikimedia-api-access](../wikimedia-api-access/SKILL.md)** | User-Agent, rate limiting, error handling |
| **[wikimedia-database](../wikimedia-database/SKILL.md)** | SQL replicas — database access from Python tools |
| **[pywikibot](../pywikibot/SKILL.md)** | Bot framework — often deployed on Toolforge with Python |
| **[wikimedia-auth-oauth](../wikimedia-auth-oauth/SKILL.md)** | OAuth 1.0a/2.0 — authenticate users of your Python web app via Wikimedia accounts |

---

## Tooling

### 🔧 Deploy Script (`scripts/deploy-python.sh`)

One-command deployment for Python tools:

```bash
./scripts/deploy-python.sh my-python-tool
```

Uploads `app.py`, `requirements.txt`, and static files, reinstalls dependencies,
and restarts the webservice.

### 🐍 Flask App Template (`assets/flask-app-template.py`)

Ready-to-deploy Flask app with:
- Home page with status
- Health check endpoint (`/api/status`)
- Wikipedia API proxy (`/api/summary/<title>`, `/api/search?q=...`)
- Static file serving with cache headers
- Proper User-Agent for Wikimedia API requests
- gunicorn compatibility

```bash
cp assets/flask-app-template.py app.py
# Edit, test locally with `python3 app.py`, then deploy
```

### 🐍 Cron Script Template (`assets/cron-template.py`)

Template for scheduled Python scripts with:
- Standard logging setup
- Error handling and exit codes
- NFS-safe file operations
- Example: cache cleanup, data sync, batch processing

### 📚 requirements.txt Template (`assets/requirements-template.txt`)

Pre-configured dependency file with commonly used packages:
```
flask==3.1.0
gunicorn==23.0.0
requests==2.32.3
```

---

## See Also

- **[wikimedia-toolforge](../wikimedia-toolforge/SKILL.md)** — Account setup, tool creation, SSH, Kubernetes pods, cron jobs, database access
  - **SOP 9: Frontend Assets (Privacy-Preserving CDN)** — Load JS/CSS/fonts from `tools-static.wmflabs.org/cdnjs/` instead of external CDNs
- **[toolforge-nodejs](../toolforge-nodejs/SKILL.md)** — Node.js-specific deployment patterns
- **[wikimedia-api-access](../wikimedia-api-access/SKILL.md)** — User-Agent, rate limiting, error handling
- **[wikimedia-security-and-privacy](../wikimedia-security-and-privacy/SKILL.md)** — CDN privacy risks and third-party script policies