What’s one thing you learned? What’s still confusing?
Python Mini-Projects, Part 1
Three hands-on projects: calculator with history, persistent contact book, and a word frequency analyzer — the foundation of NLP preprocessing.
Python Mini-Projects, Part 2
Two larger projects: a pandas-driven student-performance dashboard and an OOP-driven CLI quiz game with decorators, random shuffling, and a JSON-persisted leaderboard.
Capstone: Build Your First AI Script
Build a text analyser that calls an LLM API, processes the response, and saves results.
Interactive Labs for This Track
Loop Visualizer
You're a factory robot repeating the same task on an assembly line — watch how loops automate repetitive work
List Slicing
You have a playlist of 50 songs — grab just tracks 10 through 20 with a single slice expression
Sorting Algorithms
You're organizing a library of 10,000 books — which sorting method is fastest?
Ask questions, share insights
A script is something you run once. Production code is something that runs for months, is maintained by a team, and must be reliable at 3 AM when nobody is watching.
| Aspect | Script | Production Code |
|---|---|---|
| Error handling | print("Error!") and crash | Structured logging, graceful degradation, retry logic |
| Configuration | Hardcoded paths and values | Environment variables, config files, secrets management |
| Output | print() everywhere | Logging with levels (DEBUG, INFO, WARNING, ERROR) |
| Dependencies | pip install globally | Virtual environments, pinned versions, lockfiles |
| Testing | "It worked when I ran it" | Automated tests, CI/CD, coverage checks |
# This is what most beginners write
print("Loading model...")
print(f"Model loaded, accuracy: {accuracy}")
print("ERROR: prediction failed!")
print("DEBUG: input features:", features)print():import logging
# Create a logger for this module
logger = logging.getLogger(__name__)
# Set the minimum level to display
logging.basicConfig(level=logging.INFO)
# Use different levels for different importance
logger.debug("Feature vector: %s", features) # development only
logger.info("Model loaded successfully") # normal operation
logger.warning("GPU not available, using CPU") # something unexpected
logger.error("Prediction failed: %s", str(e)) # something broke
logger.critical("Database connection lost") # system is downDEBUG (10) -- Detailed diagnostic information. Only in development.
INFO (20) -- Confirmation that things are working as expected.
WARNING (30) -- Something unexpected but not broken. Default level.
ERROR (40) -- Something failed. The operation could not complete.
CRITICAL (50) -- The system itself is failing. Immediate attention needed.
WARNING, only WARNING, ERROR, and CRITICAL messages appear. DEBUG and INFO are silently discarded. This is how you control verbosity without editing code.import logging
import sys
def setup_logging(level: str = "INFO") -> logging.Logger:
"""Configure logging with console and file handlers."""
logger = logging.getLogger("ml_service")
logger.setLevel(getattr(logging, level.upper()))
# Console handler -- human-readable format
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
console_fmt = logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
console_handler.setFormatter(console_fmt)
# File handler -- captures everything including DEBUG
file_handler = logging.FileHandler("app.log")
file_handler.setLevel(logging.DEBUG)
file_fmt = logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d | %(message)s",
)
file_handler.setFormatter(file_fmt)
logger.addHandler(console_handler)
logger.addHandler(file_handler)
return logger
# Usage
logger = setup_logging("DEBUG")
logger.info("Service started")
logger.debug("Config: %s", {"model": "v2", "batch_size": 32})
logger.error("Failed to load model: FileNotFoundError")Console output:
2026-04-14 10:30:15 | INFO | ml_service | Service started
2026-04-14 10:30:15 | ERROR | ml_service | Failed to load model: FileNotFoundError
File output (includes DEBUG):
2026-04-14 10:30:15 | INFO | ml_service:28 | Service started
2026-04-14 10:30:15 | DEBUG | ml_service:29 | Config: {'model': 'v2', 'batch_size': 32}
2026-04-14 10:30:15 | ERROR | ml_service:30 | Failed to load model: FileNotFoundError
For production systems that feed into log aggregation tools, JSON logging is essential:
import logging
import json
from datetime import datetime, timezone
class JSONFormatter(logging.Formatter):
"""Format log records as JSON for machine parsing."""
def format(self, record: logging.LogRecord) -> str:
log_data = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"module": record.module,
"line": record.lineno,
}
# Include exception info if present
if record.exc_info and record.exc_info[0]:
log_data["exception"] = self.formatException(record.exc_info)
return json.dumps(log_data)
# Apply the JSON formatter
handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())
logger = logging.getLogger("ml_service")
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.info("Prediction completed", extra={"model": "v2", "latency_ms": 42})Output:
{"timestamp": "2026-04-14T17:30:15.123456+00:00", "level": "INFO", "logger": "ml_service", "message": "Prediction completed", "module": "main", "line": 12}
You set logging.basicConfig(level=logging.WARNING). Then you call logger.info('Model loaded'). What happens?
# DO NOT DO THIS
DATABASE_URL = "postgresql://admin:secretpassword@prod-db.example.com/ml_data"
API_KEY = "sk-abc123xyz789"
MODEL_PATH = "/home/alice/models/classifier_v3.pkl"
BATCH_SIZE = 32Problems:
# .env file (NEVER commit this to git)
DATABASE_URL=postgresql://admin:secretpassword@prod-db.example.com/ml_data
API_KEY=sk-abc123xyz789
MODEL_PATH=/opt/models/classifier_v3.pkl
BATCH_SIZE=32
LOG_LEVEL=INFO
DEBUG=false
# Load .env with python-dotenv
from dotenv import load_dotenv
import os
load_dotenv() # reads .env file into os.environ
database_url = os.getenv("DATABASE_URL")
api_key = os.getenv("API_KEY")
batch_size = int(os.getenv("BATCH_SIZE", "32")) # default to 32
debug = os.getenv("DEBUG", "false").lower() == "true"pydantic-settings gives you validated, typed configuration with automatic .env loading:# config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Required (no default = must be set)
database_url: str
api_key: str
# Optional with defaults
model_path: str = "/opt/models/classifier.pkl"
batch_size: int = Field(default=32, ge=1, le=1024)
log_level: str = "INFO"
debug: bool = False
# Nested prefix: ML_MODEL_NAME, ML_VERSION, etc.
model_name: str = "classifier"
model_version: str = "1.0.0"
# Pydantic v2 style. The old `class Config:` form still works but
# raises PydanticDeprecatedSince20 and goes away in v3.
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False, # DATABASE_URL and database_url both work
)
# Usage
settings = Settings()
print(settings.database_url) # from .env or environment
print(settings.batch_size) # validated as int, 1-1024| Factor | Principle | Python Implementation |
|---|---|---|
| III. Config | Store config in environment | pydantic-settings, .env files |
| IV. Backing services | Treat databases, queues as attached resources | Connection URLs in env vars |
| V. Build, release, run | Strictly separate build and run stages | Docker build vs docker run |
| XI. Logs | Treat logs as event streams | Log to stdout, let the platform collect |
| XII. Admin processes | Run admin tasks as one-off processes |
# .gitignore -- CRITICAL: never commit secrets
.env
.env.local
.env.production
*.pem
credentials.json.env files:boto3.client("secretsmanager").get_secret_value()hvac.Client().secrets.kv.v2.read_secret_version()ML workflows are inherently command-line driven:
python train.py --model resnet50 --epochs 100 --lr 0.001 --data ./data/train
python evaluate.py --model-path ./checkpoints/best.pt --test-data ./data/test
python predict.py --input image.jpg --model-path ./models/classifier.pkl
python export.py --model-path ./models/best.pt --format onnx --output ./exported/
# train.py
import argparse
def main():
parser = argparse.ArgumentParser(
description="Train an ML model",
)
parser.add_argument(
"--model", type=str, required=True,
choices=["linear", "tree", "forest", "neural"],
help="Model architecture to train",
)
parser.add_argument(
"--epochs", type=int, default=10,
help="Number of training epochs (default: 10)",
)
parser.add_argument(
"--lr", "--learning-rate", type=float, default=0.001,
help="Learning rate (default: 0.001)",
)
parser.add_argument(
"--data", type=str, required=True,
help="Path to training data directory",
)
parser.add_argument(
"--verbose", "-v", action="store_true",
help="Enable verbose output",
)
args = parser.parse_args()
print(f"Training {args.model} for {args.epochs} epochs")
print(f"Learning rate: {args.lr}")
print(f"Data: {args.data}")
if args.verbose:
print("Verbose mode enabled")
if __name__ == "__main__":
main()$ python train.py --model neural --epochs 50 --lr 0.01 --data ./data -v
Training neural for 50 epochs
Learning rate: 0.01
Data: ./data
Verbose mode enabled
$ python train.py --help
usage: train.py [-h] --model {linear,tree,forest,neural} [--epochs EPOCHS]
[--lr LR] --data DATA [--verbose]
Train an ML model
...
# train.py
import click
@click.command()
@click.option("--model", type=click.Choice(["linear", "tree", "forest", "neural"]),
required=True, help="Model architecture")
@click.option("--epochs", default=10, show_default=True, help="Training epochs")
@click.option("--lr", default=0.001, show_default=True, help="Learning rate")
@click.argument("data_path", type=click.Path(exists=True))
@click.option("-v", "--verbose", is_flag=True, help="Verbose output")
def train(model: str, epochs: int, lr: float, data_path: str, verbose: bool):
"""Train an ML model on the given dataset."""
click.echo(f"Training {model} for {epochs} epochs (lr={lr})")
click.echo(f"Data: {data_path}")
if verbose:
click.echo("Verbose mode enabled")
# Simulate training with a progress bar
import time
with click.progressbar(range(epochs), label="Training") as bar:
for epoch in bar:
time.sleep(0.1) # simulate work
click.secho("Training complete!", fg="green", bold=True)
if __name__ == "__main__":
train()Typer is built on click but uses type hints instead of decorators -- the same philosophy as FastAPI:
# train.py
import typer
from pathlib import Path
from enum import Enum
app = typer.Typer(help="ML Training CLI")
class ModelType(str, Enum):
linear = "linear"
tree = "tree"
forest = "forest"
neural = "neural"
@app.command()
def train(
model: ModelType = typer.Option(..., help="Model architecture"),
epochs: int = typer.Option(10, help="Number of training epochs"),
lr: float = typer.Option(0.001, help="Learning rate"),
data_path: Path = typer.Argument(..., exists=True, help="Training data directory"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Train an ML model on the given dataset."""
typer.echo(f"Training {model.value} for {epochs} epochs (lr={lr})")
typer.echo(f"Data: {data_path}")
@app.command()
def evaluate(
model_path: Path = typer.Argument(..., exists=True, help="Path to trained model"),
test_data: Path = typer.Argument(..., exists=True, help="Path to test data"),
):
"""Evaluate a trained model on test data."""
typer.echo(f"Evaluating {model_path} on {test_data}")
@app.command()
def export(
model_path: Path = typer.Argument(..., exists=True),
output_format: str = typer.Option("onnx", help="Export format"),
):
"""Export a model to a portable format."""
typer.echo(f"Exporting {model_path} to {output_format}")
if __name__ == "__main__":
app()$ python train.py --help
Usage: train.py [OPTIONS] COMMAND [ARGS]...
ML Training CLI
Commands:
train Train an ML model on the given dataset.
evaluate Evaluate a trained model on test data.
export Export a model to a portable format.
$ python train.py train --model neural --epochs 50 ./data
Training neural for 50 epochs (lr=0.001)
Data: ./data
| Feature | argparse | click | typer |
|---|---|---|---|
| Built-in | Yes | No (pip install) | No (pip install) |
| Syntax | Procedural | Decorators | Type hints |
| Progress bars | No | Yes | Yes |
| Colored output | No | Yes | Yes |
| Subcommands | Manual | @group | Automatic |
| Auto-completion | No | Yes |
Professional Python Project Structure
src/ layout prevents a subtle and common bug: accidentally importing your project from the working directory instead of the installed package. Without src/, running python from the project root imports from the local directory, which might differ from what is installed. With src/, you must install the package (pip install -e .) to import it, guaranteeing consistency.[project]
name = "my-ml-project"
version = "1.0.0"
description = "An ML prediction service"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.110",
"uvicorn>=0.29",
"pydantic>=2.0",
"pydantic-settings>=2.0",
"scikit-learn>=1.4",
"numpy>=1.26",
"typer>=0.12",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"httpx>=0.27",
"ruff>=0.4",
"mypy>=1.10",
"pre-commit>=3.7",
]
[project.scripts]
ml-train = "my_ml_project.cli:app"
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --cov=src --cov-report=term-missing"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
[tool.mypy]
python_version = "3.11"
strict = true
git commit, catching issues before they enter the codebase:# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.4
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
args: ['--maxkb=500']
- id: detect-private-key
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
additional_dependencies: [pydantic>=2.0]
Install and run:
pip install pre-commit
pre-commit install # installs hooks into .git/hooks/
pre-commit run --all-files # run on all files (first time)
# Now hooks run automatically on every git commit
Ruff replaces black, isort, flake8, and pyflakes in a single tool that runs 10-100x faster:
pip install ruff
# Lint (find issues)
ruff check src/ tests/
# Lint and auto-fix
ruff check --fix src/ tests/
# Format (like black)
ruff format src/ tests/
pyproject.toml, you can build distributable packages:# Install build tools
pip install build
# Build the package (creates dist/ directory)
python -m build
dist/:my_ml_project-1.0.0.tar.gz -- source distribution (sdist)my_ml_project-1.0.0-py3-none-any.whl -- wheel (binary distribution).whl) is a pre-built package format that installs without needing to compile anything. It is faster than installing from source. Modern Python packaging strongly favors wheels.# Install from a local wheel
pip install dist/my_ml_project-1.0.0-py3-none-any.whl
# Install in editable mode for development
pip install -e ".[dev]"
# Install twine (the upload tool)
pip install twine
# Upload to TestPyPI first (for testing)
twine upload --repository testpypi dist/*
# Upload to real PyPI
twine upload dist/*
# Now anyone in the world can install your package
# pip install my-ml-project
[project.scripts] section in pyproject.toml creates CLI commands that are available after installation:[project.scripts]
ml-train = "my_ml_project.cli:app"
ml-predict = "my_ml_project.predict_cli:app"
pip install my-ml-project, users can run:ml-train --model neural --epochs 50 ./data
ml-predict --input image.jpg --model-path ./models/best.pkl
python prefix needed. The commands are installed as executables in the virtual environment's bin/ directory.# src/my_ml_project/__init__.py
__version__ = "1.0.0"
# Access programmatically
import my_ml_project
print(my_ml_project.__version__)setuptools-scm:# pyproject.toml
[tool.setuptools_scm]
git tag v1.0.0
python -m build # version is automatically 1.0.0
Tests · Configure logging with different levels, parse CLI arguments, and validate configuration!
logging.getLogger(__name__) with appropriate levels (DEBUG, INFO, WARNING, ERROR). Configure handlers for console vs file output, and use JSON formatting for production log aggregation.env files for local development, pydantic-settings for type-safe validation, and secrets managers for production credentials. The same code should run in all environments with zero changesargparse for zero-dependency scripts, click for decorator-style CLIs, or typer for type-hint-based CLIs (recommended). Entry points in pyproject.toml make your tools installable as system commandssrc/my_project/ prevents accidental local imports and forces you to install your package properly. pyproject.toml is the single source of truth for project metadata, dependencies, and tool configurationYou set the logging level to WARNING. Which of these messages will appear in the output?
| Deployment |
| "Run this notebook" |
| Docker containers, systemd services, cloud functions |
| Secrets | API_KEY = "sk-abc123" in code | .env files, environment variables, secret managers |
| CLI tools with click/typer |
| Yes |
| Learning curve | Medium | Medium | Low (if you know type hints) |
python -m buildtwine upload