Chapter 2 — Coding Standards as a Tool, Not a Style Guide
A style guide tells you how to format your code. A coding standard tells you how to engineer it. This chapter is about the second.
The previous chapter named ten engineering principles. This chapter takes those principles and turns them into the specific rules every package in the foundation tier follows. Each rule has a reason. The reason is the chapter's central concern; the rules without the reasons are arbitrary, and arbitrary rules get worked around.
The canonical source of these rules is the
CLAUDE.md file kept at the root of the engineering
workspace, outside any one repository. The file's job is to tell automated coding
assistants what the project's conventions are; the same file
serves equally well as the human-readable reference for the
team. This chapter unpacks each rule with the rationale and a
worked example.
Formatting rules with engineering reasons
The first family of rules looks like style preferences but isn't. Each one buys something specific.
No semicolons to terminate or join lines
Python permits semicolons; the convention forbids them. The
reason is debuggability: a line that uses semicolons to chain
statements (x = 1; y = 2; z = x + y) is one source-line that
hits multiple bytecode instructions. When the debugger breaks at
that line, it stops at the first instruction; stepping doesn't
take you to the next statement on the same line — it takes you to
the next line.
Splitting x = 1; y = 2; z = x + y into three separate lines
gives the debugger three breakpoint anchors. This is a small
example of debuggability-paramount; the cost is one newline per
statement, the benefit is one breakpoint per statement.
snake_case for functions and methods
The convention is consistent with PEP 8. The reason is machine
readability — when the codebase is grepped for symbol names
across packages, the convention tells the reader whether a name is
a function (run_command), a class (RunCommand), or a module
(run_command.py). No string can be ambiguous about its kind.
Type hints on every parameter and return
# Required
def parse_workflow(workflow_stream: TextIOWrapper) -> Workflow:
...
# Forbidden
def parse_workflow(workflow_stream):
...
The discipline is universal: every parameter and every return, including for private helpers. The reason is twofold:
- Static analysis catches type mismatches before they reach runtime. The IDE flags the call site that passes the wrong type; the linter flags the function that returns the wrong shape.
- Documentation comes for free. A reader of an unfamiliar function knows what it expects and what it produces from the signature alone, without having to read the body.
self and cls discipline
Instance methods take self as the first argument; class methods
take cls. The convention is consistent with Python's standard
naming. The reason is the constraint satisfier role from
chapter 1 — when the codebase is uniform on this point, a method's
binding is unambiguous from the first parameter.
Return statement rules
The return discipline is one of the most distinctive parts of
the convention. It has four specific rules.
Every function and method ends with return
def configure_logger(logger: Logger) -> None:
logger.setLevel(logging.INFO)
logger.addHandler(StreamHandler())
return
The return is required even when the function returns None.
Three reasons (the chapter-1 preview develops these in detail):
- Debugger breakpoint anchor. The Python interpreter
generates a return-bytecode whether or not the source has the
keyword. An explicit
returnlets the debugger attach a breakpoint at a named line, which inspects every local variable in the function before exit. Without the keyword, the breakpoint lands on the closing brace's invisible bytecode location, which most debuggers handle awkwardly. - Refactor safety. When a function is deleted (or a
defline is removed during refactoring), its trailingreturnbecomes unreachable code in the next function down. Linters flag unreachable code; the bug is caught. Without the trailingreturn, the orphaned function body silently merges with the next function's body, and the bug ships. - Function-end visibility.
returnat column zero (after dedent) is a clear delimiter — the reader knows the function ends here.
Assign return values to a local variable before returning
# Discouraged
def get_user_count(query: Query) -> int:
return query.execute().scalar() + 1
# Preferred
def get_user_count(query: Query) -> int:
raw_count = query.execute().scalar()
user_count = raw_count + 1
return user_count
The local variable serves two purposes. At debug time, the
debugger can break on the return and inspect user_count to
see what's about to be returned. At maintenance time, the
intermediate name documents the calculation — raw_count + 1 is
opaque without the variable name; user_count = raw_count + 1
explains the offset's meaning.
No compound return statements
# Forbidden
def find_or_default(key: str, registry: Dict[str, Any]) -> Any:
return registry.get(key, default_for_key(key))
# Required
def find_or_default(key: str, registry: Dict[str, Any]) -> Any:
if key in registry:
return registry[key]
default_value = default_for_key(key)
return default_value
The forbidden form has two function calls and a conditional all
on one line. Setting a breakpoint on it gives no fine-grained
control. The required form gives the engineer three breakpoint
anchors — the if, the first return, and the second return —
and makes the conditional logic explicit.
Standalone return for void functions
def install_logger(logger: Logger) -> None:
logger.addHandler(StreamHandler())
return # not `return None`; just `return`
When the function returns nothing meaningful, the return
statement is bare. This is a convention rather than a
correctness issue (the two forms are equivalent), but the bare
form distinguishes void functions visually from functions that
happen to return None as a sentinel.
No mutable defaults
# Forbidden — every call shares the same list
def add_item(item: str, items: List[str] = []) -> List[str]:
items.append(item)
return items
# Required
def add_item(item: str, items: Optional[List[str]] = None) -> List[str]:
if items is None:
items = []
items.append(item)
return items
The forbidden form has a default argument that is evaluated
once at function definition time. Every call that doesn't
override items shares the same list — an action-at-a-distance
bug that's easy to introduce and hard to catch.
The required form takes None as the default and creates the
mutable inside the body. Every call gets a fresh list when no
list is passed. The verbosity is two extra lines; the safety is
permanent.
Class and file naming
The portfolio's class-and-file-naming convention is opinionated:
# File: someclass.py
class SomeClass:
pass
The class name is PascalCase with no underscores. The file
name is the class name lowercased, also without underscores. One
class per file, where the class is the file's reason for existing.
The reason: navigation. When an engineer reads from ccai.taskplus.tasks.runcommand import RunCommand, the path
ccai/taskplus/tasks/runcommand.py is the file's location
mechanically. No mental translation, no searching, no
case-folding rules to remember.
The convention has consequences. When a module needs five small
classes that belong together, they don't all live in one file —
they live in five files in the same package, with __init__.py
re-exporting them as needed. This is more files than a less
disciplined codebase, by design; the navigation gain is worth the
file count.
Class-variable initialisation in __init__
class Workflow:
def __init__(self, environment: Dict[str, str], parameters: Dict[str, Any], flow: List[FlowNode]):
self._environment = environment
self._parameters = parameters
self._flow = flow
return
Every instance variable is initialised in __init__, and only
in __init__. Adding a self.foo = bar in any other method
violates the rule.
The reason: when the constructor lists every instance variable in one place, a reader can see the class's full state in one view. When variables are introduced in random methods, the reader has to walk the whole class to know what state exists. The first pattern is predictable; the second is archaeology.
Decision expressions
Two specific rules govern conditional expressions.
No shortcut truthy/falsy checks
# Forbidden
if some_list:
# ...
# Required
if some_list is not None and len(some_list) > 0:
# ...
The forbidden form mashes two conditions ("not None" and "not
empty") into one. When the variable is None, the test is
False; when the variable is [], the test is also False; when
the variable is [0], the test is True. These three cases have
different semantics and probably want different handling — but
the shortcut hides them.
The required form makes the two checks explicit. When the
variable is None, the first clause short-circuits. When the
variable is empty, the second clause does. When the variable has
content, both pass. Each branch can have its own error message,
its own logging, its own recovery path.
Separate error conditions, separate handling
# Forbidden
if name is None or len(name) == 0 or name in BLACKLIST:
raise ConfigurationError("Name is invalid.")
# Required
if name is None:
raise ConfigurationError("The 'name' parameter is required.")
if len(name) == 0:
raise ConfigurationError("The 'name' parameter cannot be empty.")
if name in BLACKLIST:
raise ConfigurationError("The name '%s' is blacklisted." % name)
The forbidden form gives the same error message regardless of which condition triggered. When the user receives "Name is invalid," they don't know whether to fix a missing parameter, an empty string, or a blacklist violation.
The required form gives a specific error per condition. The caller's mental model snaps to the right fix immediately. This is the errors-never-silent principle from chapter 1 made operational at the validation layer.
Decorator usage
Decorators are only allowed for two purposes:
- Adding metadata.
@priority(1),@categorize("integration"),@deprecated(since="2.0"). The decorator attaches a value or tag to the function; the function's behaviour is unchanged. - Registering with a mapping.
@app.route("/"),@click.command(),@register_handler("user_login"). The decorator adds the function to a registry; the function's body is otherwise untouched.
Decorators are not allowed for:
- Behaviour modification. No
@retry, no@timeout, no@cache, no@log_calls. Behaviour modification belongs in parameters or in wrapping objects (see Volume 5's Aspects pattern), not in decorators. - Cross-cutting concerns. No
@requires_auth, no@validate_input, no@audit_log. These belong in explicit middleware or in the function's body.
The reason: behaviour-modifying decorators hide control flow.
A function decorated with @retry(3) doesn't run once when the
caller invokes it; it runs up to three times, with delays in
between, with exception swallowing built in. The caller reading
the call site has no visual cue that any of this is happening.
The same behaviour expressed as an explicit aspects=Aspects(retry=3)
parameter is visible at the call site; the reader knows what
they're getting.
This is the explicit-is-better-than-implicit principle from chapter 1 made operational at the decorator boundary. Volume 5 develops the Aspects pattern as the affirmative answer for behaviour-knob parameterisation.
Documentation comments
The convention is Sphinx-style triple-quoted docstrings:
def some_function(a: int, b: str) -> str:
"""
This is a test function.
:param a: The value for 'a'.
:param b: The value for 'b'.
:returns: The word 'blah' in a string.
:raises: ValueError
"""
if a <= 0:
raise ValueError("'a' must be greater than 0")
rtnval = "blah"
return rtnval
The shape:
- Multi-line triple-quote, even for single-line descriptions.
- Description starts on the second line. The first line is whitespace.
- Body indented four spaces inside the opening triple quote.
:param name:for parameters. No type in the docstring (the type hint carries it).:returns:for the return. No type in the docstring.:raises:for the exception types the function can raise.
The shape produces parseable docstrings — Sphinx's autodoc and
the IDE's hover-help both render these correctly. The
type-information-in-hints discipline avoids duplicating the type
in the docstring (where it would drift from the signature).
Exception handling
The exception-handling discipline has six rules covered briefly in chapter 1; chapter 3 develops them in full. As a preview:
- Don't handle exceptions you can't recover from.
- Catch and reclassify to add specific contextual information.
- Catch and reclassify to improve code-context in failure reports.
- Catch and add extra contextual information about unique circumstances.
SemanticErrormust NEVER be caught. Same forConfigurationErrorin most contexts.- In all other cases, let exceptions bubble.
The discipline is not "exceptions are good" or "exceptions are bad." It is errors that fail loudly are easier to debug than errors that fail silently, applied at the catch boundary.
Multi-line error messages
Long error messages are constructed as a list of lines, then joined:
err_msg_lines = ["Failed to find expected devices."]
err_msg_lines.append("EXPECTED: ({})".format(len(expected)))
for key in expected:
err_msg_lines.append(" {}:".format(key))
err_msg = os.linesep.join(err_msg_lines)
raise TimeoutError(err_msg) from None
Three reasons:
- Cross-platform line endings.
os.linesepis\r\non Windows and\non Unix. Joining with the constant produces correct line endings on every platform without the author having to think about it. - Composability. Building the list with conditionals —
appending different sections based on what context is
available — is straightforward. Building the same string with
+=and embedded newlines is brittle. - Readability. The list-of-lines form makes the error message's structure visible in code: a header line, a count, an indented per-item block, a footer. The string- concatenation form hides the structure.
InterOp API design — the Aspects pattern
For APIs that interact with remote systems (SSH, REST,
filesystem operations), the discipline forbids behaviour-modifying
decorators (rule above) and forbids hardcoded retry/timeout
behaviour. Instead, the API takes an Aspects object as a
parameter:
def run_command(command: str, *, aspects: Aspects = None) -> CommandResult:
if aspects is None:
aspects = DEFAULT_ASPECTS
# ... use aspects.timeout, aspects.retry_count, aspects.retry_backoff_ms ...
The Aspects object packages every behaviour parameter the API
might want — timeout, retry count, retry backoff, logging level,
sync vs async — into one scalable parameter. Adding a new
behaviour knob is one field in the Aspects class; existing
callers don't need to know about it.
Volume 5 chapter 1 develops the Aspects pattern in full, including the read-only-vs-state-modifying API design heuristic (read-only APIs typically don't need Aspects; state-modifying APIs typically do). Chapter 1 of this volume previewed the pattern; chapter 23 of Volume 3 used it for the Retry wrapper task. The discipline is the same across volumes.
Properties as simple data accessors
# Required
@property
def host(self) -> str:
return self._host
# Forbidden
@property
def host(self) -> str:
if self._host is None:
self._host = self._connection.lookup_host()
return self._host
A property is a typed read of a stored value. It is not a hook for lazy initialisation, side-effecting computation, or any operation that might block.
The reason: properties look like attribute access at the call
site. When obj.host triggers a 30-second SSH connection, the
caller has no visual cue that any work is happening. Worse,
when obj.host is read inside an __init__ of a related class,
the lazy initialisation can produce circular dependencies that
are nearly impossible to debug.
When lazy initialisation is genuinely needed, the right shape is
an explicit method (get_host()) with the work named. When
caching is needed, a method called lookup_host() that caches
internally is fine — the call site sees a method invocation and
knows that work might be happening.
Coding standards across volumes
The standards in this chapter apply to every volume in the series. Specific applications:
- Volume 1 (Testing) uses the
assert_*family of verification functions instead of bare Pythonassertstatements; the explicit-is-better discipline shows up in test assertions. - Volume 3 (Orchestration) uses the no-mutable-defaults rule
in workflow YAML loading; the
parseclassmethod doesn't haveWorkflow=Workflow()defaults. - Volume 5 (Interop) is the affirmative-Aspects volume; the decorator discipline of this chapter justifies the Aspects pattern's existence.
When you read code in any volume of the series and find yourself asking "why does it look like this?" — chapter 1 is the philosophy; this chapter is the rules; the next chapter is the error vocabulary that makes the errors-never-silent principle operational.
Summary
- The standards in this chapter are rules with reasons. Each rule has a debuggability, refactor-safety, or explicit-is-better justification. The reasons matter; rules without reasons get worked around.
- Formatting rules: no semicolons, snake_case functions, type
hints everywhere,
self/clsdiscipline. Each one buys something specific. - Return statement rules: every function ends with
return, return values assigned to local variables before returning, no compound returns, barereturnfor void functions. The rationale is debuggability + refactor safety + function-end visibility. - No mutable defaults: prevents action-at-a-distance bugs from shared mutable state across calls. Two extra lines per function with a default; permanent safety.
- Class-and-file naming: PascalCase classes; lowercase-no- underscores file names matching the class. One class per file. More files than a less-disciplined codebase, by design.
- No shortcut truthy/falsy checks.
if some_list is not None and len(some_list) > 0makes both conditions explicit; each can have its own error message and recovery path. - Decorators only for metadata or registration. Behaviour-
modifying decorators are forbidden; behaviour parameters live
in
Aspectsobjects. - Sphinx-style docstrings on every public function, with
:param:/:returns:/:raises:and no type duplication. - Properties as simple data accessors only. Lazy init, side-effecting computation, and blocking operations belong in explicit methods, not properties.
- Multi-line error messages built from a list joined with
os.linesep. Cross-platform, composable, readable.
The next chapter develops the error vocabulary that makes the
errors-never-silent principle operational. Three special
exception types — SemanticError, ConfigurationError,
AssertionError — have specific must-bubble or must-catch
semantics that the rest of the portfolio depends on.