Object-Oriented Python for AI Components
You know OOP. You've written C# classes, interfaces, abstract base classes, and inheritance hierarchies. Python's OOP covers the same ground, but with important syntactic and philosophical differences. The biggest one: Python makes encapsulation a convention rather than an enforcement — there's no private keyword that the runtime enforces. The second biggest: Python's "duck typing" means you often don't need explicit interfaces.
This lesson builds a realistic AI client abstraction — the kind of wrapper class you'd write in your first week on a real AI project — to show how Python OOP maps to C# patterns.
Defining a Class
public class AIClient
{
private readonly string _apiKey;
private readonly string _model;
private int _requestCount;
public AIClient(string apiKey, string model)
{
_apiKey = apiKey;
_model = model;
_requestCount = 0;
}
public int RequestCount => _requestCount;
}
class AIClient:
def __init__(self, api_key: str, model: str):
# _prefix = convention for "private"
self._api_key = api_key
self._model = model
self._request_count = 0
@property
def request_count(self) -> int:
return self._request_count
# All methods take 'self' as first arg
# No access modifiers — convention only
Key OOP Differences from C#
- self is explicit — every instance method takes
selfas its first parameter. Python doesn't hide it the way C# hidesthis. - No access modifiers — prefix with
_for "internal" convention,__for name-mangled (hard to access externally but not truly private). - @property instead of getters/setters — Python uses decorators to make methods look like attributes.
- __init__ not a constructor keyword — it's just the initializer method that runs after the object is created.
Building a Full AI Client Abstraction
Here's the kind of wrapper class you'd build to centralize all your AI API interactions, add observability, and make testing easier:
from __future__ import annotations
import time
import logging
from typing import Optional
from dataclasses import dataclass, field
from openai import OpenAI
logger = logging.getLogger(__name__)
@dataclass
class RequestRecord:
"""Tracks metadata for a single API request."""
prompt_length: int
response_length: int
input_tokens: int
output_tokens: int
latency_ms: float
model: str
success: bool
class OpenAIClient:
"""
A production-ready wrapper around the OpenAI SDK.
Adds:
- Request tracking and metrics
- Configurable defaults
- Consistent error handling
- Usage reporting
"""
DEFAULT_MODEL = "claude-3-5-sonnet-20241022"
def __init__(
self,
api_key: str,
model: str = DEFAULT_MODEL,
max_tokens: int = 1024,
default_system: str = "You are a helpful AI assistant.",
) -> None:
self._client = OpenAI(api_key=api_key)
self._model = model
self._max_tokens = max_tokens
self._default_system = default_system
self._history: list[RequestRecord] = []
# --- Properties (read-only computed attributes) ---
@property
def model(self) -> str:
return self._model
@property
def total_requests(self) -> int:
return len(self._history)
@property
def total_tokens_used(self) -> int:
return sum(r.input_tokens + r.output_tokens for r in self._history)
@property
def success_rate(self) -> float:
if not self._history:
return 0.0
return sum(1 for r in self._history if r.success) / len(self._history)
# --- Public methods ---
def complete(
self,
prompt: str,
system: Optional[str] = None,
max_tokens: Optional[int] = None,
) -> str:
"""
Send a single-turn completion request.
Returns the text content of the response.
"""
start = time.monotonic()
success = False
response = None
try:
response = self._client.chat.completions.create(
model=self._model,
max_tokens=max_tokens or self._max_tokens,
system=system or self._default_system,
messages=[{"role": "user", "content": prompt}],
)
success = True
return response.choices[0].message.content
except openai.APIError as e:
logger.error(f"API error in complete(): {e}")
raise
finally:
latency = (time.monotonic() - start) * 1000
self._record_request(
prompt=prompt,
response=response,
latency_ms=latency,
success=success,
)
def chat(
self,
messages: list[dict],
system: Optional[str] = None,
) -> str:
"""Send a multi-turn messages request."""
start = time.monotonic()
success = False
response = None
try:
response = self._client.chat.completions.create(
model=self._model,
max_tokens=self._max_tokens,
system=system or self._default_system,
messages=messages,
)
success = True
return response.choices[0].message.content
except openai.APIError as e:
logger.error(f"API error in chat(): {e}")
raise
finally:
latency = (time.monotonic() - start) * 1000
prompt_text = messages[-1].get("content", "") if messages else ""
self._record_request(
prompt=str(prompt_text),
response=response,
latency_ms=latency,
success=success,
)
def usage_report(self) -> dict:
"""Return a summary of API usage for this client instance."""
return {
"total_requests": self.total_requests,
"total_tokens": self.total_tokens_used,
"success_rate": f"{self.success_rate:.1%}",
"avg_latency_ms": (
sum(r.latency_ms for r in self._history) / len(self._history)
if self._history else 0.0
),
}
# --- Private methods ---
def _record_request(
self,
prompt: str,
response: Optional[openai.types.chat.ChatCompletion],
latency_ms: float,
success: bool,
) -> None:
"""Internal method — records a request in the history."""
record = RequestRecord(
prompt_length=len(prompt),
response_length=len(response.choices[0].message.content) if response else 0,
input_tokens=response.usage.prompt_tokens if response else 0,
output_tokens=response.usage.completion_tokens if response else 0,
latency_ms=latency_ms,
model=self._model,
success=success,
)
self._history.append(record)
logger.debug(f"Request completed: {latency_ms:.0f}ms, {record.input_tokens + record.output_tokens} tokens")
Inheritance and Abstract Base Classes
Python inheritance uses parentheses instead of colons. Abstract base classes use the abc module — the equivalent of a C# interface or abstract class:
from abc import ABC, abstractmethod
class BaseLLMClient(ABC):
"""Abstract interface for LLM clients — swap providers without changing app code."""
@abstractmethod
def complete(self, prompt: str, **kwargs) -> str:
"""Send a completion request and return the response text."""
...
@abstractmethod
def usage_report(self) -> dict:
"""Return a usage summary."""
...
def test_connection(self) -> bool:
"""Concrete method shared by all subclasses."""
try:
result = self.complete("Say 'ok'.", max_tokens=10)
return bool(result)
except Exception:
return False
class OpenAIClient(BaseLLMClient):
"""OpenAI implementation of BaseLLMClient."""
def __init__(self, api_key: str, model: str) -> None:
from openai import OpenAI
self._client = OpenAI(api_key=api_key)
self._model = model
self._calls = 0
def complete(self, prompt: str, **kwargs) -> str:
self._calls += 1
response = self._client.chat.completions.create(
model=self._model,
max_tokens=kwargs.get("max_tokens", 1024),
messages=[{"role": "user", "content": prompt}],
)
return response.choices[0].message.content
def usage_report(self) -> dict:
return {"provider": "openai", "total_calls": self._calls}
class OpenAIClient(BaseLLMClient):
"""OpenAI implementation — same interface, different provider."""
def complete(self, prompt: str, **kwargs) -> str:
... # OpenAI implementation
def usage_report(self) -> dict:
return {"provider": "openai", "total_calls": 0}
# The application code doesn't need to know which provider it's using:
def generate_summary(client: BaseLLMClient, text: str) -> str:
return client.complete(f"Summarize in 3 sentences:\n\n{text}")
Python's duck typing means you often don't need ABC for basic polymorphism. If two objects both have a complete() method, they can be used interchangeably regardless of inheritance. Use ABC when you want to enforce that all subclasses implement certain methods — similar to a C# interface but at runtime rather than compile time.
Class Methods and Static Methods
class ModelRegistry:
"""Manages known model configurations."""
_registry: dict[str, dict] = {} # class-level attribute (like C# static field)
@classmethod
def register(cls, name: str, config: dict) -> None:
"""Class method — receives the class, not the instance."""
cls._registry[name] = config
@staticmethod
def validate_model_name(name: str) -> bool:
"""Static method — no class or instance reference needed."""
return name.startswith("claude-") or name.startswith("gpt-")
@classmethod
def from_config_file(cls, path: str) -> "ModelRegistry":
"""Factory constructor — common pattern for multiple constructors."""
import json
with open(path) as f:
configs = json.load(f)
registry = cls()
for name, config in configs.items():
registry.register(name, config)
return registry
# Usage
ModelRegistry.register("gpt-4o", {"max_tokens": 8192, "context_window": 200_000})
print(ModelRegistry.validate_model_name("claude-3-5-sonnet-20241022")) # True
Key Takeaways
- All Python instance methods take
selfexplicitly — it's how Python connects a method call to its object - Use single underscore (
_attr) to signal "private by convention" — Python has no runtime privacy enforcement @propertyturns a method into an attribute-style accessor — the equivalent of a C# getter property- Use
ABC+@abstractmethodto define interfaces — subclasses that don't implement abstract methods raiseTypeErrorat instantiation @classmethodreceives the class as first arg (useful for factory constructors);@staticmethodreceives nothing (pure utility functions on the class namespace)- Python's duck typing means you often don't need inheritance — any object with the right methods works, regardless of its class hierarchy

Comments
Loading comments…