Instruction file imported from f2calv/.github (
.github/instructions/python.instructions.md). Copyright stays with the author.
Python
Style (enforced by ruff and mypy)
ruff formatis authoritative. Never hand-format Python source; runruff format .before committing. CI fails onruff format --check ..ruff checkmust pass. Select broadly (select = ["ALL"]) and justify each entry inignore. Prefer fixing the code over a suppression; when one is genuinely warranted, scope it to the narrowest line with# noqa: RULEand say why.mypy --strictmust pass. Every function, parameter and return value is annotated, including-> None. An untyped function body is a defect, not a style preference.- Indentation: 4 spaces, LF line endings, final newline. Line length is set once in
pyproject.tomland honoured by both formatter and linter. - Naming:
snake_casefor modules, functions, methods and variables;PascalCasefor classes and enums;SCREAMING_SNAKE_CASEfor module-level constants. - Module-per-concern: one focused responsibility per module (
config.py,telemetry.py,worker.py). Modules are the Python analogue of the one-type-per-file rule used in the .NET conventions - do not accumulate unrelated responsibilities in one file. __main__.pyis wiring only: load configuration, install logging, register signal handling, hand off to a worker, map terminal failures to exit codes. Business logic belongs in a module.- Visibility is deliberate: prefix helper functions, classes and attributes that are not imported elsewhere with a single underscore. Module-level constants are exempt. Do not rely on
__all__as a substitute for naming intent. - Modern syntax only: built-in generics (
list[str],dict[str, int]),X | Nonerather thanOptional[X],StrEnumfor closed string sets, and structural pattern matching where it reads more clearly than a chain ofelif. from __future__ import annotationsat the top of every module, with type-only imports insideif TYPE_CHECKING:so runtime import cost and cycles are avoided.- Import ordering is ruff's (
std/ third-party / first-party, each group separated by a blank line). Import names, not whole modules, unless the module qualifier aids readability. - Dataclasses over ad-hoc dicts: use
@dataclass(frozen=True, slots=True)for immutable value and configuration records.frozenprevents accidental mutation;slotsremoves the per-instance__dict__. pathlib.Pathfor filesystem paths, never string concatenation oros.path.- Context managers for owned resources: files, sockets, locks and clients are acquired with
with/async with, never opened and closed by hand. - Prefer comprehensions and generator expressions over
map/filterchains or index loops, and stop when the comprehension becomes harder to read than an explicit loop. - Early return over nesting. Handle the failure and
return; keep the happy path at the left margin. - Never use a mutable default argument. Default to
Noneand construct inside the function.
Error Handling
- Catch only what you can handle, translate or enrich. A bare
except:is forbidden, andexcept Exceptionneeds a comment justifying the breadth. - Never silently discard an exception.
except ...: passrequires an explicit comment explaining why the failure is genuinely uninteresting. - Preserve the cause: always
raise DomainError(message) from error. A bareraise X(...)inside anexceptblock discards the original traceback. - Define domain exceptions at boundaries (
ConfigurationError,TelemetryError), deriving from the closest built-in (ValueError,RuntimeError) so existing handlers still behave sensibly. - Assign the message to a local before raising so the exception constructor call stays short and the message is greppable.
- Keep messages actionable and safe: name the setting, file or operation that failed, never credentials, tokens, connection strings or personally identifying values.
sys.exitbelongs tomainalone. Reusable modules raise; only the entry point maps an exception to an exit code.- Do not use exceptions for expected absence. Return
None, a sentinel or a result object where that makes the contract clearer.
Logging
- The standard library
loggingmodule is the logging framework. Never useprintfor application diagnostics. - One logger per module:
logger = logging.getLogger(__name__)at module scope. Application code never configures handlers. - Structured fields, not interpolated strings. Pass varying values through
extra=, keeping the message itself a constant so events group correctly. Never build a message with an f-string or%formatting. - Field names are
snake_caseand match sibling repositories' names where the concept is shared (git_repository,interval_seconds,process_architecture). - Field names must avoid the reserved
LogRecordattributes (message,args,name,module,levelnameand friends).loggingrefuses to overwrite them, and the failure is a confusing runtime error rather than a type error. - Handler setup lives in one place (
telemetry.py) and is installed exactly once from the entry point. - Use
logger.exception(...)inside anexceptblock so the traceback is captured;logger.error(...)discards it. - Verbosity via a
LOG_LEVELenvironment variable, defaulting toinfo. - Never log secrets or personal data: tokens, authorisation headers, connection strings, signed URLs, full local paths and personally identifying values must never reach a sink.
Configuration
- Layered, defaults-then-file-then-environment. Environment variables win, so a container can be reconfigured without rebuilding the image.
- Every setting has a default, so the application runs with no configuration file and no environment variables present.
- Parse into a typed record. Bind once into a frozen dataclass; never scatter
os.environreads through the code base. - Accept an injected environment mapping (
environ: Mapping[str, str] | None = None, defaulting toos.environ) so configuration loading is testable without mutating process state. - Keys are
snake_casein both the file and the environment, and the section separator is__(APP__INTERVAL_SECONDS), matching the sibling .NET, Go and Rust repositories. - Validate at startup. Check types, ranges, required values and closed sets as configuration is loaded, and abort the process with a clear message. A configuration error must never surface later as a runtime surprise.
- Coerce deliberately. Environment variables arrive as strings; convert and validate explicitly rather than relying on truthiness, and reject
boolwhere anintis expected.
Concurrency and Shutdown
- Register SIGINT and SIGTERM in the entry point, which is the only place Python permits signal handler installation on the main thread.
- Cancellation is explicit. Pass a
threading.Event(or anasyncio.Event/CancellationTokenequivalent) into long-running work, and loop on it so shutdown is prompt. - Use
event.wait(timeout)for interruptible delays, nevertime.sleepinside a cancellable loop - a sleeping worker cannot observe a shutdown request. - Every thread and task has a defined exit. If you cannot say what stops it, do not start it, and never leave a non-daemon thread without a join.
- Do not hold a lock across I/O or a cancellation wait. Keep critical sections small and prefer queues over shared mutable state.
- Flush and shut down owned providers (telemetry exporters, clients, pools) in a
finallyblock so buffered data is not lost on exit. - Remember the GIL. Threads suit I/O-bound work; use
multiprocessingor a native extension for CPU-bound work rather than expecting threads to scale.
Testing
pytestis the test framework. Tests live intests/, mirroring the package layout.- Plain
assert, notunittestassertion methods - pytest rewrites them to produce useful failure output. - Test names describe the behaviour, not the function:
load_falls_back_to_defaults_when_file_missing, nottest_load. - Arrange/Act/Assert, separated by blank lines. One behaviour per test.
@pytest.mark.parametrizefor anything with more than one interesting input, rather than a loop inside a single test.- Use the built-in fixtures (
tmp_path,monkeypatch,caplog,capsys) instead of hand-rolled setup and teardown; they restore state automatically. - Never mutate
os.environdirectly in a test. Usemonkeypatch.setenvor pass an explicit environment mapping. - No shared mutable module-level state between tests. Each test must be independently repeatable and order-independent.
- Assert on public behaviour, not private helpers, unless the helper carries genuinely tricky logic.
assertis stripped underpython -O. Never use it for runtime validation in application code - only in tests.
Documentation
- Docstrings on every public module, class and function. A module docstring is the first statement in the file.
- First line is a single-sentence summary in the imperative mood, ending with a full stop. Further detail goes in following paragraphs after a blank line.
- Document the contract, not the implementation: constraints, raised exceptions, units and defaults. Restating the code in prose is noise.
- Do not repeat type information already carried by annotations.
- Do not delete hyperlinks to blog posts, issues or answers when refactoring a comment - move them into the docstring.
Performance
- Measure before optimising. Profile with
cProfileor a benchmark before any micro-optimisation; prefer clear code until measurement says otherwise. - Avoid work in hot loops: hoist attribute lookups, reuse buffers, and build strings with
str.joinrather than repeated concatenation. - Generators over lists when the whole sequence is not needed at once, particularly when streaming records.
- Prefer built-ins and the standard library, which are implemented in C, over hand-written equivalents.
- Avoid unbounded cardinality in log fields, metric labels and caches. Serialisation costs are paid even when the surrounding logic is trivial.
Dependencies and Packaging
- Prefer the standard library. Every dependency is install time, image size and supply-chain surface; add one only when it removes meaningful complexity.
pyproject.tomlis the single manifest. Declare runtime dependencies under[project]and tooling under[dependency-groups]so development-only tools stay out of the production image.- Pin the interpreter in
.python-versionand inrequires-python, and target the same version in the linter and type checker so all three agree. - The lock file is committed, and CI installs with a locked, no-update sync so the dependency graph cannot silently drift.
- Runtime dependency versions are pinned exactly; the lock file records the full resolution and Dependabot proposes the updates.
- Run tooling inside the development container. Do not install interpreters or packages onto the host.