Python code style#
This reference governs Python code throughout the repository:
where the public API ends, how failures are represented, and how modules are
laid out. It covers the SDK, utilities, services, samples, and tests.
Application-owned projects under apps/ are outside repository tooling and
outside this reference. Package surfaces and their documented behavior live in
each package’s __all__ and docstrings; this reference covers only the
conventions that keep them consistent.
These rules describe the target state of all Python code. Code that does not yet follow them is brought into line by cleanup changes and tracked outside this reference. Making a supported public name conform to a rule is a breaking change: keep the code working in every merged state, update every use in the repository, and add a migration entry. Until then the name keeps working as it is, and an unrelated change is not asked to rename it.
A package that other projects import, such as everything under agent-sdk/ and
utils/ and any library a service or sample publishes, follows every section.
A runnable service, orchestrator, or sample worker publishes no API: keep its
modules private to the project and do not import them from elsewhere, except
from its own tests. The exception rules apply to all of them, and each entry
point is a sanctioned broad-catch site.
The rules apply established Python guidance, cited rule by rule. This reference does not adopt any external style guide wholesale; for example, the repository’s worker packages use relative imports, which the Google guide discourages. Each section ends with its Basis, which names the standard it comes from. A rule marked Repository convention goes beyond a published standard to settle a pattern that the repository handled inconsistently, and it has no external source.
Review feedback on a change cites a rule from this reference. A preference that is not written here is raised as a change to this reference, in its own pull request, so that every contributor works from the same rules.
Public and private code#
The public surface of a package is the names in __all__ of its top-level
__init__.py, plus the names in __all__ of each module enrolled in the public
API inventory (PUBLIC_API_MODULES in docs/source/_api_contract.py). Imports
from an enrolled module, such as xr_ai_tools.current_frame,
xr_ai_tools.vision, xr_ai_tools.rpc, and xr_ai_models.presets, remain
supported. Nothing else is supported.
Document every public name with a docstring, because the generated Python reference is rendered from it. Give a private name a docstring only when its invariant is not obvious.
Start each private module with a short module docstring that states what it owns and which packages can import it.
Use only public types in public signatures. Keep internal state in
_-prefixed attributes that no other package reads. If a consumer needs that state, publish a deliberate accessor.Production code never imports a private name or module of another package. When a neighbouring package needs something, the owning package publishes it first. Implementation tests of the owning package can import private names.
Keep transport and vendor details out of public names and documentation. Agent-facing text never mentions LiveKit or StreamKit.
Basis:
PEP 8, Public and Internal Interfaces
says “Documented interfaces are considered public” and that modules “should
explicitly declare the names in their public API using the __all__
attribute”. It requires a leading underscore on internal interfaces and treats
an interface as internal when any containing namespace is internal. The public
API inventory and the contributing page’s statement that public references are
generated from “each enrolled module’s literal __all__” define which modules
are enrolled. The repository’s
AGENTS.md
requires that LiveKit not appear in agent APIs and that workers use public SDK
packages, and its change contract places public API documentation in
__all__ and co-located docstrings, and it keeps private imports inside their
owning packages or implementation tests. The private-module docstring is a
repository convention.
Exports#
Each public name has one canonical home, and these rules decide where it is.
Every module is public or private. A public module is the package root or a module enrolled in
PUBLIC_API_MODULES. Every other module has a leading underscore. Split a module that mixes public and internal code so the public names live in a public module.Export from the package root by default: define the name in a private module, import it explicitly in
__init__.py, and list it in the root__all__.Add a public submodule only for one of two reasons, and state which in its module docstring. Either importing it requires an optional dependency declared as an extra, so the root could not import it without making the extra mandatory, as
xr_ai_tools.marker_trackingdoes for OpenCV; or it namespaces a family of related names that would crowd the root, asxr_ai_models.presetsdoes.A public module declares its names in a literal
__all__and documents each one. The list holds names the module defines, and no underscore names. Only the package root re-exports names defined elsewhere. Do not use wildcard imports.Do not add a module to
PUBLIC_API_EXCLUSIONS. A module with a public-looking name is either enrolled in the public API or given a leading underscore; the list only tracks modules awaiting that change. Check for importers before renaming one, and add a migration entry if a supported path moves.Import a name from its canonical home: the package root when the root exports it, otherwise the enrolled module that documents it.
Basis: The contract in docs/source/_api_contract.py already requires every
importable module to be enrolled or excluded and requires a literal __all__
in each public package and module. PEP 8
says wildcard imports “should be avoided”, because they make it unclear which
names are present. It also says that imported names “should always be
considered an implementation detail” unless they are an explicitly documented
part of the module’s API, “such as … a package’s __init__ module that
exposes functionality from submodules”, which is why only the root re-exports.
The xr-ai-tools pyproject.toml declares the optional extras that justify
its public submodules. Exporting from the root by default, the two reasons for
a public submodule, and the rule against PUBLIC_API_EXCLUSIONS entries are
repository conventions.
Module boundaries#
The unit of encapsulation is a package, not a file. Hierarchy is fine: a package can contain sub-packages, and each one is a boundary for the code around it.
Import from a package, not from one of its files. Code outside a package imports from its root or from an enrolled public module, and never from a private file such as
xr_ai_voice._runtime.A sub-package is a boundary for its parent. The parent imports from the sub-package’s
__init__.py, whose__all__lists what the parent may use, and not from the sub-package’s files: writefrom ._processors import VadSttProcessor, notfrom ._processors.vad_stt import VadSttProcessor. Everything else in the sub-package is private to it.Modules in the same package form one unit and import each other directly with relative imports.
Dependencies point one way. A sub-package does not import its parent’s modules. Put a definition that both need in a lower-level module or sub-package that both import, or pass it in as an argument.
An
__init__.pyassembles a boundary and holds no logic: a docstring, explicit imports, and__all__. Put logic in a private module and re-export what the boundary exposes.
Basis: PEP 8
treats an interface as internal when any containing namespace is internal, so
everything inside a private sub-package is internal, and it names “a package’s
__init__ module that exposes functionality from submodules” as the documented
way to expose names. PEP 8 also calls explicit relative imports “an acceptable
alternative to absolute imports, especially when dealing with complex package
layouts”, and AGENTS.md requires relative internal imports in worker
packages. Treating the sub-package __init__.py as the parent’s boundary, the
one-way dependency rule, and logic-free __init__.py files are repository
conventions.
Exceptions#
Every exception is either expected or unexpected, and the code makes the kind obvious.
Expected failures are ones a caller can anticipate from the contract: a peer is unreachable, a remote service returns an error status, external or configuration input is malformed, a timeout fires, or a peer closes.
Root a package’s failures at a single package base class, such as
ModelServiceError, with a few meaningful subclasses, and define each one next to the code that raises it. End every exception name inError. Export the public ones through the package root or the enrolled module that raises them.List the SDK errors a public function raises in its docstring
Raises:section. Do not list theValueErrororTypeErrorraised for a caller mistake, because that would make behavior under misuse part of the API.Raise where the failure is detected, and catch at the one place that can make a decision: retry, degrade, or report. Catch the narrowest type there, and do not catch in between.
When a failure depends on state outside the program, such as a file, a socket, or an entry another task can change, attempt the operation and handle the specific exception instead of checking first, because the check can go stale before the operation runs. Validate caller-supplied arguments up front, and return a value for an ordinary outcome the caller branches on, so that an exception always marks a failure.
Translate a foreign error (
httpx,zmq,grpc,OSError,json,yaml,pydantic) into an SDK error exactly once, at the adapter boundary, withraise SdkError(...) from exc. Foreign types do not appear in public signatures.Raise
ValueErrororTypeErroreagerly for caller mistakes. These are programming errors, not expected failures. Do not useassertto validate arguments or object state; anassertcan be removed without changing the program, so nothing may depend on it.When replacing a builtin that callers already catch, make the new class also inherit that builtin so the change is not breaking.
Unexpected failures are bugs or violated invariants: a KeyError on an
internal mapping, an AttributeError, an AssertionError. Do not catch them
to hide them. They propagate to the process top level, which logs once with a
traceback and exits non-zero. A visible crash is easier to diagnose than a
swallowed error.
These rules follow:
Do not write a bare
except:, and do not catchExceptionorBaseExceptionoutside the sanctioned sites below. A broad handler that only re-raises does nothing: delete it. Put work that must run whether or not the block fails infinally. Catch broadly and re-raise only to do work that applies to failure alone, such as rolling back partial state, and say why in a comment.Do not silence an error with
pass,continue, or a barereturnin anexceptclause. Where ignoring a failure is correct, catch the specific type and explain why in a# ignored:comment.The sanctioned broad-catch sites each carry a
# isolation boundary:comment and consume the failure: the site logs it once with its traceback through the package’s logger, then ends the unit of work or continues the loop it supervises. A handler that re-raises does not log; the owner that finally consumes the failure logs it. The sites are:calling a user-supplied callback from a loop the SDK owns, so one handler cannot stop the loop;
a background-task supervisor or done callback that records the failure and surfaces it to the task’s owner;
the process entry point.
Propagate a cancellation directed at the current task. Code that catches
asyncio.CancelledErrorto clean up propagates it afterward, and preferstry/finallyorasync with. An owner that cancels a child task and awaits it can consume the child’sCancelledErroras the child’s completion and continue its own cleanup or callback. Outside a block that is already propagating the owner’s own cancellation, the owner first checks thatasyncio.current_task().cancelling()is zero and otherwise re-raises, because the error can then come from the owner’s own cancellation.Log or raise at one layer, not both. A re-raised error is logged once, by the owner that consumes it.
Always preserve the cause with
raise ... from exc. Make the message match the actual error condition, and say what the caller can do.Cleanup that must not mask the primary error uses
contextlib.suppress(<specific type>)with a comment.Keep each
trybody to the statements that can raise the handled error.Release resources with
withorasync with, usingcontextlib.closingfor an object that lacks one. Usetry/finallyfor other cleanup.
Basis:
Rules 1 and 2, and the sanctioned sites in rule 3, follow the Google Python Style Guide, section 2.4.4, which forbids catch-all
except:and catchingExceptionunless re-raising or “creating an isolation point in the program where exceptions are not propagated but are recorded and suppressed instead, such as protecting a thread from crashing by guarding its outermost block”. Rule 1 narrows the re-raising allowance to handlers that do failure-only work, because a broad handler that merely re-raises has no effect; that narrowing is a repository convention. The same section says a library’s own exceptions “must inherit from an existing exception class” and that names end inError; calls forfinallyfor cleanup; advises raisingValueErrorfor a violated precondition; and says not to useassertto validate preconditions. Its function docstring rules say not to document exceptions raised when the API is violated, and its error message rules require that a message “precisely match the actual error condition”. The PEP 8 programming recommendations warn that a bareexcept:“can disguise other problems”, direct the author to name specific exceptions, limit eachtryclause to the minimum code (rule 8), and useraise X from Yto replace an exception without losing the traceback (rule 6). PEP 20 states the principle: “Errors should never pass silently. Unless explicitly silenced.”Ruff has matching rules:
BLE001(blind except),S110(try-except-pass), andB904(missingfrom).Rule 4 follows the asyncio documentation on task cancellation: it recommends
try/finallyfor cleanup, says a caughtCancelledError“should generally be propagated when clean-up is complete”, and warns thatasyncio.TaskGroupandasyncio.timeout()“might misbehave if a coroutine swallows” it. The documentation definesTask.cancelling()as “the number of pending cancellation requests to this Task”, which is what the owner check reads. Letting an owner consume a child’s completion is a repository convention; the existing shutdown paths inxr_ai_voiceandxr_ai_toolscancel and await child tasks this way.The attempt-first bullet follows the Python glossary definition of EAFP, a style that “assumes the existence of valid keys or attributes and catches exceptions if the assumption proves false”. The
os.accessdocumentation says that checking before opening “creates a security hole, because the user might exploit the short time interval between checking and opening the file” and that “It’s preferable to use EAFP techniques”. Validating arguments up front is the Google guide’s advice to raiseValueErrorfor a violated precondition.Rule 9 follows the Google guide’s section 3.11: “The preferred way to manage files and similar resources is using the
withstatement”, withcontextlib.closing()for objects that do not support it.Rule 7 follows the
contextlib.suppressdocumentation, which limits it to “very specific errors where silently continuing with program execution is known to be the right thing to do”.Repository conventions: raising where the failure is detected and catching only where a decision can be made, returning a value for an ordinary outcome so exceptions mark failures only, the per-package base class (the Google guide requires only that an exception inherit from an existing one), inheriting the replaced builtin to avoid a breaking change, translating foreign errors exactly once, the log-once rule (rule 5), and the greppable
# isolation boundary:and# ignored:comment tags.
API design#
Do not model a behavior with a boolean flag plus an optional argument. Use one typed choice. Use an
EnumorLiteralinstead of a string for a kind or state.Make lifecycle explicit and symmetric:
async with, orstart()andstop().stop()is idempotent, wakes the loop it stops, and survives repeated cancellation. A constructor does not perform I/O or start tasks.Return an unsubscribe callable from every
on_*registration. State in the docstring which loop runs the callback and what happens if it raises.Do not return
Noneto mean a failed operation; a lookup that can find nothing can returnNone. Raise a typed error, or offer an explicittry_*variant that returns abool.Expose attributes read-only unless mutation is the documented purpose.
Do not add a second way to do something, such as a synchronous twin or a parallel constructor, until two in-tree callers need it.
Pass policy to a constructor. Do not assign private attributes on another object after constructing it.
Basis: The Google guide supports two of the rules. Section
2.13 lists
making an attribute read-only as a use of @property, and section
3.11
prefers the with statement for resources and requires that code documentation
“explain clearly how resource lifetime is managed” when that is infeasible,
which is the reason for explicit lifecycle. The flag rule applies Martin
Fowler’s Flag Argument,
which prefers separate, explicit calls because they “communicate more clearly
what my intention is”. The second-way rule restates the repository’s own
AGENTS.md
change contract:
“Do not add an abstraction until two concrete use cases need it.” The callback,
try_*, idempotent stop(), and constructor-policy rules, and the preference
for Enum or Literal, are repository conventions.
Structuring a change#
Keep one concern per pull request, ideally under 300 changed lines.
Do not mix a file move with an edit. Put the move in its own commit so
git diff -Mshows it as a pure rename.When splitting a module, keep the old import path working through a re-export until every in-repository importer, including tests, has moved. Remove the re-export in a follow-up change. Removing an import path that is supported, such as an enrolled module, is a breaking change.
State in the pull request whether the change alters behavior. A refactor that must not change behavior passes the existing tests unmodified.
Add a migration entry for a breaking change.
Basis: Google’s engineering practices on
small changes
say the right size is “one self-contained change” and that refactorings
belong in a separate change from bug fixes, with “moving and renaming a class”
as the example. The migration entry is required by the AGENTS.md change
contract. The 300-line guideline and the re-export step are repository
conventions.
Comments#
Comments explain a non-obvious invariant or failure mode. Use the fixed tags
# isolation boundary:and# ignored:so policy sites are easy to find with a search.Basis:
AGENTS.md, Comments and documentation limits comments to non-obvious invariants and failure modes. The tags are a repository convention.