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 = "gpt-4o"
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,
messages=[
{"role": "system", "content": system or self._default_system},
{"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,
messages=[
{"role": "system", "content": system or self._default_system},
*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
Not every method needs an instance. A @classmethod receives the class itself (cls) rather than an instance — the idiomatic way to write alternative constructors, the same role a C# static factory method plays. A @staticmethod belongs to the class purely for namespacing and takes no self or cls at all. The registry below uses both to look up and construct model clients by name.
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("gpt-4o")) # 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…