Python Coding Standards That Make Collaboration Easier
Coding standards documents often feel like bureaucracy — a list of rules that exists to exist, written by someone who felt strongly about snake_case and wanted everyone to know it.
The standards we codified for smplkit’s Python codebase are different in intent. They’re a response to specific friction we experienced working across multiple microservices with AI-assisted development. When three different agents and one human are all writing Python code for the same platform, the inconsistencies that creep in between repos aren’t trivial formatting differences — they’re maintenance hazards that compound over time.
This post describes what we standardized and why.
Type Hints Everywhere, But Write Them Right
Python’s type hint syntax has evolved and there are multiple ways to express the same type. We standardize on the modern syntax:
str | NonenotOptional[str]list[str]notList[str]dict[str, int]notDict[str, int]tuple[str, ...]notTuple[str, ...]
The union syntax (|) became available in Python 3.10. The Optional type alias from typing is still valid but is idiomatic for an older era of Python. We’ve standardized on 3.10+ syntax because that’s our minimum supported version.
We also standardize on from __future__ import annotations at the top of every module. This enables PEP 563 postponed evaluation of annotations, which allows forward references without string quotes and slightly improves import performance. Every file starts with it. It’s become muscle memory.
Functions that can return None explicitly declare | None in their return type. Functions that never return None don’t. def get_flag(key: str) -> Flag | None: communicates the contract exactly: you might not get a flag back, handle accordingly.
Google Docstrings
We chose Google-style docstrings over NumPy-style or Sphinx/reStructuredText style. Google-style is readable without being rendered:
def get_flag(key: str, environment: str | None = None) -> Flag | None:
"""Retrieve a flag by key.
Args:
key: The flag's unique identifier within the account.
environment: The environment to retrieve the flag for.
Defaults to the client's configured environment.
Returns:
The flag object, or None if no flag with this key exists.
Raises:
AuthenticationError: If the API key is invalid.
"""
Docstrings are required for all public functions, classes, and modules. Private functions (those with an _ prefix) benefit from docstrings too but it’s not enforced.
The requirement for Raises: sections is intentional. Functions that raise exceptions that callers need to handle should document those exceptions explicitly. If a caller doesn’t know a function can raise AuthenticationError, they can’t write correct error-handling code.
Exception Hierarchy
All exceptions raised by smplkit code inherit from a base SmplkitError. The hierarchy:
SmplkitError
├── ValidationError # Bad input from the caller
├── NotFoundError # Resource doesn't exist
├── ConflictError # Resource state conflict (e.g., duplicate key)
├── AuthenticationError # Invalid or missing credentials
├── AuthorizationError # Authenticated but not permitted
└── ServiceError # Internal service failures
This hierarchy lives in smplkit-core and is used consistently across all services. The smplcore.exceptions module is the single source of truth for exception types.
Why a hierarchy? Several reasons.
Catchability. except SmplkitError catches all domain exceptions, which is the right behavior for top-level error handlers that want to return a JSON:API error response for any domain failure. except ValidationError catches only validation failures, which is the right behavior for a caller that wants to handle input errors differently from authentication errors.
HTTP mapping. The FastAPI exception handler maps the hierarchy to HTTP status codes: ValidationError → 400, NotFoundError → 404, ConflictError → 409, AuthenticationError → 401, AuthorizationError → 403, ServiceError → 500. The mapping is defined once, in smplkit-core, not in each service.
Searchability. grep -r "raise NotFoundError" src/ gives you every place in the codebase that returns a 404. That’s a useful audit.
Naming Conventions
SQLAlchemy models get a Model suffix: FlagModel, ConfigModel, LoggingModel. Pydantic schemas (API request/response types) use clean names: Flag, Config, Logger.
The Model suffix distinguishes ORM objects from API objects. A FlagModel is a database row. A Flag is the API representation. They often have the same fields but different semantics: FlagModel has database-specific fields (id, created_at, version), uses SQLAlchemy column types, and participates in ORM sessions. Flag is a pure Pydantic model used for serialization.
When a function takes a Flag it’s working with API data. When it takes a FlagModel it’s working with database state. The naming distinction makes this visible in function signatures without reading the type definition.
The from __future__ import annotations Rule
This is worth its own section because it’s the one that causes the most initial confusion.
PEP 563 postponed evaluation means annotation strings are not evaluated at function definition time — they’re stored as strings and evaluated lazily. This breaks code that uses annotations at runtime via get_type_hints() without passing include_extras=True or globalns. Pydantic v1 had this problem; Pydantic v2 handles it correctly.
We require this import because it enables forward references cleanly. Without it:
class Config:
def parent(self) -> Config: # NameError: Config isn't defined yet
...
With from __future__ import annotations:
from __future__ import annotations
class Config:
def parent(self) -> Config: # Fine — annotation is a string at this point
...
The practical result: all type hints in smplkit code are strings at runtime, which is fine for static analysis tools and Pydantic v2, and which allows clean forward references throughout.
What We Don’t Standardize
We don’t have an opinion on code formatting beyond what Ruff enforces. Line length 100 (Ruff default). Import ordering (Ruff’s isort-compatible rules). Everything else is up to the developer.
We don’t require a specific async style. Some handlers are async def; some are synchronous. FastAPI handles both. We use async for handlers that make I/O calls (database queries, HTTP calls to other services) and sync for handlers that are purely computational. This is pragmatic, not dogmatic.
We don’t require a specific test structure beyond “tests live in tests/ and use pytest.” We’ve found that over-specified test structure requirements lead to tests that conform to the structure but don’t actually test the right things. The requirement is coverage, not structure.
Enforcement
These standards are enforced at two levels. First, Ruff handles formatting and some style rules automatically. The CI pipeline runs Ruff and rejects non-conforming code.
Second, code review. The type hint and docstring standards aren’t enforced by a linter; they’re enforced by the review process. A PR that adds a public function without a docstring or without type hints gets a comment asking for them before merge.
smplkit’s Python codebase uses modern type hint syntax (str | None, not Optional[str]), Google docstrings, a shared exception hierarchy in smplkit-core, and the Model/clean-name convention for SQLAlchemy/Pydantic distinction.