Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Note: This is a quick-reference Python guide focused on AI/ML workflows. For a comprehensive Python course, see our Complete Python Crash Course.

Getting Started

1. Install Python

Download Python 3.11+ from python.org. Verify installation:
python --version  # Should show 3.11 or higher

2. Set Up Virtual Environment

Virtual environments are like separate toolboxes for each project. Without them, installing a package for Project A might break Project B because they need different versions of the same library. This is not a theoretical concern — it will happen to you within your first week of AI development, because LLM libraries update frequently and often have conflicting dependencies.
# Create a virtual environment
python -m venv venv

# Activate (Windows)
venv\Scripts\activate

# Activate (macOS/Linux)
source venv/bin/activate

3. Install AI Packages

pip install openai anthropic langchain chromadb pydantic python-dotenv

4. Manage Dependencies

# Save current packages
pip freeze > requirements.txt

# Install from requirements
pip install -r requirements.txt

Python Core Syntax (AI Context)

These are the Python fundamentals you will use daily in AI engineering. We focus on what matters for working with LLM APIs, data processing, and async workflows — not the full breadth of Python.

Variables & Types

# Basic types -- you will use all four of these in every LLM API call
name = "Claude"  # str: model names, prompts, responses
temperature = 0.7  # float: model parameters, scores, costs
max_tokens = 1000  # int: token limits, counts, retry attempts
is_streaming = True  # bool: feature flags, configuration

# Lists (ordered, mutable)
messages = ["Hello", "How are you?"]
messages.append("Great!")

# Dictionaries -- the most important data structure for AI work.
# Every API request and response is essentially a dict.
config = {
    "model": "gpt-4",
    "temperature": 0.7,
    "max_tokens": 1000
}

# Tuples (immutable)
coordinates = (40.7128, -74.0060)

Functions

def generate_prompt(context: str, question: str) -> str:
    """Generate a prompt with context and question."""
    return f"Context: {context}\n\nQuestion: {question}"

# With default args
def call_api(prompt: str, temperature: float = 0.7, max_tokens: int = 1000):
    # API call logic here
    pass

# Lambda (anonymous functions)
multiply = lambda x, y: x * y
result = multiply(5, 3)  # 15

Control Flow

# If-else
if temperature < 0.3:
    style = "deterministic"
elif temperature < 0.7:
    style = "balanced"
else:
    style = "creative"

# For loops
for message in messages:
    print(message)

# List comprehension (faster, more Pythonic)
lengths = [len(msg) for msg in messages]
filtered = [msg for msg in messages if len(msg) > 10]

# While loop
retry_count = 0
while retry_count < 3:
    try:
        # Try API call
        break
    except Exception:
        retry_count += 1

Error Handling

Error handling is not optional in AI engineering — LLM APIs fail regularly due to rate limits, network issues, and content policy violations. Every API call should be wrapped in try/except. The pattern below catches errors from most specific to most general, which is important because Python matches the first except block that fits.
try:
    response = api_call(prompt)
except APIError as e:
    # Catch specific API errors first (rate limits, invalid requests, etc.)
    print(f"API error: {e}")
except TimeoutError:
    # Network timeouts are common with LLM APIs (long generation times)
    print("Request timed out")
finally:
    # Always runs, even if an exception was raised -- use for cleanup
    close_connection()

Data Structures for AI

Working with JSON

JSON is the lingua franca of LLM APIs. Every request you send is JSON, every response you receive is JSON, and structured outputs are JSON. Mastering json.loads() and json.dumps() is as fundamental to AI engineering as knowing how to read and write.
import json

# Parse JSON string -- turns a string into a Python dict
data = json.loads('{"model": "gpt-4", "temp": 0.7}')

# Convert to JSON string
json_str = json.dumps({"result": "success"})

# Read from file
with open("config.json") as f:
    config = json.load(f)

# Write to file
with open("output.json", "w") as f:
    json.dump(results, f, indent=2)

List Operations

# Slicing
messages[0]      # First item
messages[-1]     # Last item
messages[1:3]    # Items 1-2
messages[:2]     # First 2 items
messages[2:]     # From item 2 to end

# Common operations
len(messages)           # Length
messages.extend([...])  # Add multiple items
messages.remove(item)   # Remove specific item
messages.pop()          # Remove & return last item

# Sorting
sorted_list = sorted(numbers)
messages.sort(key=lambda x: len(x))  # Sort by length

Dictionary Operations

# Access
value = config["model"]
value = config.get("model", "default")  # Safe access

# Check existence
if "model" in config:
    print(config["model"])

# Iteration
for key, value in config.items():
    print(f"{key}: {value}")

# Merge dictionaries
combined = {**config1, **config2}

# Dictionary comprehension
squares = {x: x**2 for x in range(5)}

Object-Oriented Python for AI

Classes & Dataclasses

from dataclasses import dataclass
from typing import List, Optional

@dataclass
class Message:
    role: str
    content: str
    tokens: Optional[int] = None

class ChatBot:
    def __init__(self, model: str = "gpt-4"):
        self.model = model
        self.messages: List[Message] = []
    
    def add_message(self, role: str, content: str) -> None:
        msg = Message(role=role, content=content)
        self.messages.append(msg)
    
    def get_history(self) -> list[dict]:
        return [
            {"role": m.role, "content": m.content}
            for m in self.messages
        ]
    
    def clear(self) -> None:
        self.messages = []

# Usage
bot = ChatBot(model="gpt-4o")
bot.add_message("user", "Hello!")
bot.add_message("assistant", "Hi there!")
print(bot.get_history())
Why dataclasses? Reduces boilerplate for data objects. Perfect for API responses, configuration objects, and structured data.

Type Hints (Modern Python)

from typing import List, Dict, Optional, Union

def process_batch(
    items: List[str],
    config: Dict[str, any],
    timeout: Optional[float] = None
) -> List[Dict[str, Union[str, int]]]:
    """Process a batch of items with config."""
    results = []
    for item in items:
        result = {"text": item, "length": len(item)}
        results.append(result)
    return results
Why type hints? Better IDE support, catch bugs early, and self-documenting code.

Dependency Management: pip vs. Poetry vs. uv

Choosing the right tool for managing Python packages will save you hours of debugging dependency conflicts — a common occurrence in AI projects because libraries like langchain, transformers, and torch have deep and sometimes conflicting dependency trees.
ToolSpeedLock FileBest ForWatch Out For
pip + requirements.txtSlowManual (pip freeze)Simple projects, tutorialsNo true dependency resolution; pip freeze captures everything including transitive deps
pip + pip-toolsModeraterequirements.in -> requirements.txtProduction projects needing reproducibilityExtra step to compile lock file
PoetryModeratepoetry.lock (automatic)Libraries, projects needing publishingSlow resolver, can conflict with conda
uvVery fast (10-100x pip)uv.lock (automatic)New projects in 2025+, fast iterationNewer tool, some edge cases with exotic packages
condaSlowenvironment.ymlData science, GPU/CUDA deps, cross-platform binariesHeavy, dependency resolution can be painfully slow
AI-specific recommendation: For AI projects that need PyTorch or CUDA, start with uv (fast, modern) and fall back to conda only if you need binary packages that pip cannot install (e.g., specific CUDA toolkit versions). For everything else, uv or pip-tools gives you speed and reproducibility.

Advanced Patterns for AI Engineering

Decorators (Reusable Logic)

Decorators are functions that wrap other functions to add behavior — think of them as “middleware for functions.” They are everywhere in AI engineering: @retry for handling flaky API calls, @timer for profiling, @cache for avoiding redundant LLM calls, and @observe for tracing. If you understand decorators, you can read (and write) production AI code. If you do not, they will look like magic.
import functools
import time
from typing import Callable

def timer(func: Callable) -> Callable:
    """Measure execution time"""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        duration = time.time() - start
        print(f"{func.__name__} took {duration:.2f}s")
        return result
    return wrapper

def retry(max_attempts: int = 3, delay: float = 1.0):
    """Retry decorator with exponential backoff"""
    def decorator(func: Callable) -> Callable:
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts - 1:
                        raise
                    wait_time = delay * (2 ** attempt)
                    print(f"Retry {attempt + 1}/{max_attempts} in {wait_time}s")
                    time.sleep(wait_time)
        return wrapper
    return decorator

def cache_result(func: Callable) -> Callable:
    """Simple caching decorator"""
    cache = {}
    @functools.wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

# Usage
@retry(max_attempts=3, delay=2.0)
@timer
def call_llm(prompt: str) -> str:
    # Simulated API call
    return "response"
Use cases:
  • @timer - Profile slow functions
  • @retry - Handle flaky API calls
  • @cache_result - Avoid redundant LLM calls

Context Managers (Resource Management)

Context managers ensure resources are properly managed—files closed, connections released, timers stopped.
from contextlib import contextmanager
import time

@contextmanager
def timer_context(label: str):
    """Time a block of code"""
    start = time.time()
    try:
        yield
    finally:
        duration = time.time() - start
        print(f"{label}: {duration:.2f}s")

@contextmanager
def temporary_config(new_config: dict):
    """Temporarily change config, then restore"""
    old_config = config.copy()
    config.update(new_config)
    try:
        yield
    finally:
        config.clear()
        config.update(old_config)

# Usage
with timer_context("Embedding generation"):
    embeddings = generate_embeddings(texts)

with open("data.txt") as f:
    content = f.read()
Use cases:
  • File I/O
  • Database connections
  • Timing code blocks
  • Temporary state changes

Async/Await (Concurrency)

Async is the single most important advanced Python pattern for AI engineering. Here is why: a typical LLM API call takes 1-5 seconds, and during that time your program is just waiting for a network response. Without async, processing 10 prompts takes 10-50 seconds. With async, all 10 run concurrently and you get results in 1-5 seconds total. That is a 10x speedup for free. The mental model: async def declares a function that can pause (at await points) and let other tasks run while it waits. asyncio.gather runs multiple async tasks concurrently.
import asyncio
from typing import List

async def fetch_completion(prompt: str) -> str:
    """Simulated async API call -- the await lets other tasks run while waiting"""
    await asyncio.sleep(1)  # In real code, this is the LLM API call
    return f"Response to: {prompt}"

async def process_batch(prompts: List[str]) -> List[str]:
    """Process multiple prompts concurrently.
    
    asyncio.gather runs all tasks at once and waits for all to complete.
    Order is preserved: results[0] corresponds to prompts[0].
    """
    tasks = [fetch_completion(p) for p in prompts]
    results = await asyncio.gather(*tasks)
    return results

# asyncio.run() is the entry point -- call it once from synchronous code
prompts = ["Question 1", "Question 2", "Question 3"]
results = asyncio.run(process_batch(prompts))
Why async? Process multiple API calls concurrently. 10 sequential 1-second calls = 10 seconds. 10 concurrent = ~1 second. For batch processing, this is not a nice-to-have — it is the difference between a feature that ships and one that times out.

File Operations

# Read entire file
with open("data.txt") as f:
    content = f.read()

# Read line by line (memory efficient)
with open("large_file.txt") as f:
    for line in f:
        process(line.strip())

# Write to file
with open("output.txt", "w") as f:
    f.write("Hello, world!\n")

# Append to file
with open("log.txt", "a") as f:
    f.write(f"{timestamp}: Event\n")

# Check if file exists
from pathlib import Path
if Path("config.json").exists():
    # Load config
    pass

Environment Variables (.env)

API keys are the crown jewels of your AI application. A leaked OpenAI key can rack up thousands of dollars in charges before you notice. The .env pattern keeps secrets out of your code and out of git history. This is not a suggestion — it is a hard requirement for any project that will ever be shared, deployed, or committed to a repository.
from dotenv import load_dotenv
import os

# Load from .env file -- call this once at startup
load_dotenv()

# Access variables -- os.getenv returns None if not found (no crash)
api_key = os.getenv("OPENAI_API_KEY")
db_url = os.getenv("DATABASE_URL", "default_url")  # Second arg is fallback
.env file:
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
DATABASE_URL=postgresql://...
Never commit .env files! Add .env to your .gitignore immediately when you create a new project — before you make your first commit. If you accidentally commit a key, rotate it immediately; removing it from git history is difficult and unreliable.

Essential Libraries for AI

HTTP Requests

import requests

# GET request
response = requests.get("https://api.example.com/data")
data = response.json()

# POST request
response = requests.post(
    "https://api.example.com/generate",
    json={"prompt": "Hello", "max_tokens": 100},
    headers={"Authorization": f"Bearer {api_key}"}
)

Data Manipulation (Pandas)

import pandas as pd

# Read CSV
df = pd.read_csv("data.csv")

# Basic operations
df.head()           # First 5 rows
df.describe()       # Statistics
df["column"].mean() # Column average

# Filter
filtered = df[df["score"] > 0.8]

# Group by
grouped = df.groupby("category")["score"].mean()

Date & Time

from datetime import datetime, timedelta

now = datetime.now()
timestamp = now.isoformat()

# Add time
tomorrow = now + timedelta(days=1)
hour_ago = now - timedelta(hours=1)

# Parse string
dt = datetime.fromisoformat("2024-01-15T10:30:00")

Common AI Patterns

These patterns appear in virtually every AI application. They are worth memorizing because you will use them dozens of times.

Loading Environment Variables

from dotenv import load_dotenv
import os

load_dotenv()
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")

if not OPENAI_API_KEY:
    raise ValueError("OPENAI_API_KEY not set")

Building Prompts

def build_rag_prompt(context: str, question: str) -> str:
    """Build RAG prompt with context."""
    return f"""Use the following context to answer the question.

Context:
{context}

Question: {question}

Answer:"""

# Template with f-strings
system_prompt = f"""You are an AI assistant with expertise in {domain}.
Your responses should be {tone} and {length}."""

Batching Requests

Batching is essential when you have hundreds or thousands of items to process. Sending them all at once will hit rate limits; sending them one at a time is painfully slow. Batching gives you the best of both worlds: controlled throughput that stays within API limits while processing efficiently. The yield keyword makes this a generator, which means it processes one batch at a time and does not load all results into memory.
def batch_process(items: List[str], batch_size: int = 10):
    """Process items in batches to stay within rate limits."""
    for i in range(0, len(items), batch_size):
        batch = items[i:i + batch_size]
        results = process_batch(batch)
        yield results  # yield returns results one batch at a time (memory-efficient)

Rate Limiting

OpenAI, Anthropic, and other providers enforce rate limits (typically measured in requests per minute and tokens per minute). Exceeding them results in 429 errors and temporary bans. This simple rate limiter adds a fixed delay between calls to keep you under the limit. For production use, consider the tenacity library for more sophisticated retry-with-backoff patterns.
import time

def rate_limited_call(func, calls_per_minute: int = 60):
    """Rate limit function calls.
    
    Simple but effective: adds a fixed delay between calls.
    For 60 calls/min, that is 1 second between each call.
    """
    delay = 60.0 / calls_per_minute
    
    def wrapper(*args, **kwargs):
        time.sleep(delay)  # Pause before each call to stay under the limit
        return func(*args, **kwargs)
    
    return wrapper

Sync vs. Async: When to Use What

ScenarioUse SyncUse AsyncWhy
Single LLM callYesNoNo concurrency benefit; simpler code
Batch of 10+ LLM callsNoYes10x speedup from concurrent I/O
FastAPI endpointEitherPreferredFastAPI is async-native; mixing sync blocks the event loop
Jupyter notebookYesTrickyNotebooks already run an event loop; asyncio.run() will fail — use await directly or nest_asyncio
CLI scriptYesYes (for batch)Sync is simpler; use async only if you have batch processing
Streaming responseEitherPreferredAsync generators (async for) integrate cleanly with streaming APIs
Edge case — mixing sync and async: If you call a sync function (like requests.get()) from inside an async function, it blocks the entire event loop. Use httpx (async HTTP) instead of requests, or wrap sync calls in asyncio.to_thread() to run them in a thread pool without blocking.

Next Steps

Next Steps


Quick Reference

Common Commands

# Python version
python --version

# Install package
pip install package-name

# Install from requirements
pip install -r requirements.txt

# Create requirements file
pip freeze > requirements.txt

# Create virtual environment
python -m venv venv

# Activate venv (Windows)
venv\Scripts\activate

# Activate venv (macOS/Linux)
source venv/bin/activate

# Deactivate venv
deactivate

# Run Python file
python script.py

# Interactive Python shell
python

# Install specific version
pip install package-name==1.2.3

Style Guidelines (PEP 8)

# Naming conventions
class MyClass:          # PascalCase for classes
    pass

def my_function():      # snake_case for functions/variables
    pass

CONSTANT_VALUE = 100    # UPPER_CASE for constants

# Line length: max 79-88 characters
# Imports at top, grouped: standard lib, third-party, local
# Use 4 spaces for indentation (not tabs)
# Two blank lines between top-level functions/classes
# One blank line between methods

Type Hints Quick Reference

from typing import List, Dict, Optional, Union, Callable, Any

def func(
    text: str,                          # String
    count: int,                         # Integer
    ratio: float,                       # Float
    is_valid: bool,                     # Boolean
    items: List[str],                   # List of strings
    config: Dict[str, int],             # Dict with str keys, int values
    callback: Callable[[str], int],     # Function: str -> int
    optional: Optional[str] = None,     # Can be str or None
    either: Union[str, int],            # Can be str OR int
    anything: Any                       # Any type
) -> tuple[str, int]:                   # Returns tuple
    return ("result", 42)

Common Python Gotchas in AI Work

These are the mistakes that burn hours of debugging time specifically in AI engineering contexts:
GotchaWhat HappensFix
Mutable default argumentsdef f(items=[]) shares the same list across all calls — appended items persistUse def f(items=None): items = items or []
Shallow copy of dictsconfig2 = config1 means both point to the same dict; changing one changes bothUse config2 = config1.copy() or copy.deepcopy() for nested dicts
f-string with dictsf"value: {d['key']}" fails with single quotes inside f-string bracesUse double quotes: f"value: {d[\"key\"]}" or assign to variable first
JSON dumps vs dumpjson.dumps() returns a string; json.dump() writes to a file. Mixing them up produces cryptic errorsRemember: the s stands for “string”
asyncio.run() in JupyterRaises RuntimeError: cannot run nested event loopUse await directly in cells, or pip install nest_asyncio; nest_asyncio.apply()
Float precision in cost calculations0.1 + 0.2 == 0.30000000000000004Use round() or Decimal for financial calculations
Forgetting awaitresult = async_func() returns a coroutine object, not the resultAlways result = await async_func() — linters catch this if you use type hints

Troubleshooting

”Module not found” error

# Make sure venv is activated
# Then reinstall
pip install -r requirements.txt

”pip: command not found”

# Use python -m pip instead
python -m pip install package-name

Import errors in VS Code

  1. Select correct Python interpreter: Ctrl+Shift+P → “Python: Select Interpreter”
  2. Choose the one in your venv folder

Slow pip installs

# Use a faster mirror
pip install --index-url https://pypi.org/simple package-name

Pro Tips:
  • Use virtual environments for EVERY project
  • Pin package versions in production (package==1.2.3)
  • Use type hints—they catch bugs before runtime
  • Learn list/dict comprehensions—they’re faster and more Pythonic
  • Use python-dotenv for API keys and secrets