In C#, code is always inside a class. A standalone function doesn't exist — you'd write a static method on a utility class, or a record for data. Python takes the opposite approach: functions are first-class citizens that live freely in modules. This difference has a significant impact on how you structure AI applications, where a lot of your code is transformation logic that doesn't naturally belong to an object.
This lesson covers how to define functions, organize them into modules, and structure a Python AI project in a way that's readable, testable, and easy to extend.
Defining Functions in Python
Python functions use the def keyword. Unlike C# methods, they don't require a class wrapper, an access modifier, or a return type declaration (though you should add type hints — covered in Lesson 1.1).
public static string BuildPrompt(
string userInput,
string systemContext)
{
return $"Context: {systemContext}\n" +
$"User: {userInput}";
}
// Must live inside a class:
// public static class PromptUtils { ... }
def build_prompt(
user_input: str,
system_context: str
) -> str:
return (
f"Context: {system_context}\n"
f"User: {user_input}"
)
# No class required — just call build_prompt()
Default Arguments and Keyword Arguments
Python functions can have default parameter values, and callers can pass arguments by name (keyword arguments). This is especially useful for AI functions that have many optional configuration parameters.
def call_llm(
prompt: str,
model: str = "claude-3-5-sonnet-20241022",
max_tokens: int = 1024,
temperature: float = 0.7,
system: str = "You are a helpful assistant.",
) -> str:
"""
Call an LLM with configurable parameters.
Docstrings are the Python equivalent of XML doc comments.
"""
# implementation here...
pass
# Calling with keyword args — self-documenting and order-independent
response = call_llm(
prompt="Explain embeddings in one paragraph",
max_tokens=256,
temperature=0.2,
)
# Only override what you need
quick_response = call_llm("What is RAG?") # uses all defaults
Python uses snake_case for function and variable names, versus C#'s PascalCase for methods and camelCase for parameters. This isn't optional — it's enforced by community style guides (PEP 8) and expected by every linter and code reviewer you'll work with.
Return Multiple Values
Python functions can return multiple values as a tuple — no wrapper class needed:
def analyze_response(text: str) -> tuple[str, float, int]:
"""Returns (cleaned_text, confidence_score, token_count)."""
cleaned = text.strip()
confidence = 0.95 # hypothetical scoring
tokens = len(cleaned.split())
return cleaned, confidence, tokens # Python auto-wraps in tuple
# Unpack at the call site
text, score, count = analyze_response(raw_llm_output)
print(f"Got {count} tokens with {score:.0%} confidence")
Modules: Python's Namespace System
A Python module is simply a .py file. Every file you create is automatically a module. A package is a directory containing an __init__.py file, which makes it importable as a unit.
This maps roughly to C# namespaces, except the file structure IS the namespace — no separate namespace declarations needed.
// File: src/Prompts/PromptBuilder.cs
namespace MyAiApp.Prompts
{
public static class PromptBuilder
{
public static string Build(string input)
=> $"User: {input}";
}
}
// Usage:
using MyAiApp.Prompts;
var p = PromptBuilder.Build("hi");
# File: src/prompts/builder.py
# (directory must have __init__.py)
def build(input: str) -> str:
return f"User: {input}"
# Usage in another file:
from src.prompts.builder import build
# or
from src.prompts import builder
result = builder.build("hi")
The __init__.py File
An __init__.py file marks a directory as a Python package and can control what's exported when someone imports the package:
# src/prompts/__init__.py
# Re-export commonly used functions so callers can do:
# from src.prompts import build_chat_prompt
from .builder import build_chat_prompt
from .templates import SYSTEM_PROMPTS
__all__ = ["build_chat_prompt", "SYSTEM_PROMPTS"] # explicit public API
Practical AI Module Structure
Here's how you'd organize a real prompt utility module for an AI application. This is the kind of code you'd write in your first week of an AI engineering project:
# src/prompts/builder.py
from __future__ import annotations
from typing import Optional
# Module-level constant (like a C# static readonly field)
DEFAULT_SYSTEM_PROMPT = """You are an expert AI assistant specializing in
code analysis. Be precise, concise, and cite specific line numbers
when referencing code."""
def build_chat_prompt(
user_message: str,
context: Optional[str] = None,
system_override: Optional[str] = None,
) -> dict[str, str | list]:
"""
Build a structured prompt dict for the Anthropic Messages API.
Args:
user_message: The user's request.
context: Optional background context to prepend.
system_override: Replace the default system prompt.
Returns:
A dict with 'system' and 'messages' keys ready for the API.
"""
system = system_override or DEFAULT_SYSTEM_PROMPT
# Build the user content
if context:
user_content = f"\n{context}\n \n\n{user_message}"
else:
user_content = user_message
return {
"system": system,
"messages": [
{"role": "user", "content": user_content}
],
}
def build_few_shot_prompt(
user_message: str,
examples: list[tuple[str, str]],
) -> list[dict]:
"""
Build a messages list with few-shot examples.
Args:
user_message: The actual request.
examples: List of (user_input, assistant_output) tuples.
"""
messages = []
for user_ex, assistant_ex in examples:
messages.append({"role": "user", "content": user_ex})
messages.append({"role": "assistant", "content": assistant_ex})
messages.append({"role": "user", "content": user_message})
return messages
Using the Module
# main.py (or any other file)
import anthropic
from src.prompts.builder import build_chat_prompt, build_few_shot_prompt
client = anthropic.Anthropic()
# Build a structured prompt
prompt_data = build_chat_prompt(
user_message="Review this Python function for potential bugs.",
context="This code runs in a production AI pipeline with 10k req/min.",
)
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
system=prompt_data["system"],
messages=prompt_data["messages"],
)
print(response.content[0].text)
Import Patterns and Best Practices
Python has several import styles. Knowing which to use keeps your code readable and avoids namespace pollution — a common source of subtle bugs in AI codebases with large dependency trees.
# 1. Absolute import (preferred — explicit and unambiguous)
from src.prompts.builder import build_chat_prompt
# 2. Relative import (use inside a package)
from .builder import build_chat_prompt # same directory
from ..models import ChatRequest # parent directory
# 3. Import module, not function (good when using multiple items)
from src import prompts
prompts.build_chat_prompt(...)
# 4. Wildcard import — AVOID in production AI code
from src.prompts.builder import * # opaque, pollutes namespace
# 5. Aliased import — good for long names or conventions
import anthropic as ant
import numpy as np # community convention
If module_a.py imports from module_b.py and vice versa, Python will raise an ImportError. This is the Python equivalent of a circular assembly reference. Fix it by extracting shared types into a third module (often called types.py or models.py), or using lazy imports inside functions.
*args and **kwargs: Flexible Function Signatures
AI wrapper functions often need to pass arbitrary arguments through to underlying SDKs. Python's *args and **kwargs handle this elegantly — similar to C#'s params keyword, but more powerful.
def call_with_retry(
func, # callable to wrap
*args, # positional args forwarded to func
retries: int = 3,
**kwargs, # keyword args forwarded to func
):
"""Generic retry wrapper for any callable."""
for attempt in range(retries):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == retries - 1:
raise
print(f"Attempt {attempt + 1} failed: {e}. Retrying...")
# The caller doesn't need to know about retry logic:
result = call_with_retry(
client.messages.create, # the actual SDK function
model="claude-3-5-sonnet-20241022",
max_tokens=512,
messages=[{"role": "user", "content": "Hello"}],
retries=5, # our custom arg
)
Module-Level Code and the if __name__ == "__main__" Guard
Python executes module-level code when the file is imported. This is different from C#, where only explicitly called code runs. The if __name__ == "__main__" guard ensures code only runs when the file is executed directly — not when it's imported as a module.
# src/prompts/builder.py
def build_chat_prompt(user_message: str) -> dict:
# ... implementation ...
pass
# This block runs ONLY when you do: python builder.py
# It does NOT run when another file does: import builder
if __name__ == "__main__":
# Quick smoke test / development runner
test_prompt = build_chat_prompt("Test message")
print(test_prompt)
print("Module works correctly!")
Use the __main__ guard to add quick development runners to your modules. This lets you test a single module without running your full application — useful when iterating on prompt templates or parsing logic.
Key Takeaways
- Python functions are first-class and don't need a class — use standalone functions for pure transformation logic
- A Python file is a module; a directory with
__init__.pyis a package — your folder structure IS your namespace - Use keyword arguments and default values to build self-documenting AI function signatures
- *args / **kwargs let you write flexible wrapper functions that pass through SDK parameters without knowing them upfront
- Use
__all__in__init__.pyto define a clean public API for each package - The
if __name__ == "__main__"guard is essential for modules that double as standalone scripts