Coding standards
The canonical coding-standards doc — assembled from the rules already in force across
.github/copilot-instructions.md,CLAUDE.md,CONTEXT.md, the ADRs, and the actual code; previously-open points (docstrings, formatting/linting, the commands-layer type-checking split) have their decision record in Decide: coding standards for the repo.This doc is the single source reviewers (the
code-reviewskill, CodeRabbit's path instructions,.github/copilot-instructions.md) derive from — they should shrink to a pointer here rather than repeat these rules.
Architecture & layering
crm/core/*— Web API logic, one module per domain (entity,connection,metadata,async_ops,batch,charts,dashboard,dup, …).crm/commands/*— thin Click wrappers, one file percrm <group>, one command group per domain, mirroring thecoremodule it wraps.- Credentials come only from a saved profile (
crm profile add) or a per-run--password; never environment variables.
Type checking
pyrightconfig.jsonsetsstrictglobally;crm/commands/*and most test files opt back out to basic via a# pyright: basicheader comment. This split is permanent by design, not debt: the commands layer is thin, decorator-heavy Click glue where strict mode fights the framework for near-zero bug yield. Don't flag it and don't tighten files opportunistically — when a command file accumulates real logic, the fix is moving that logic intocrm/core/*(the strict surface, together withcrm/utils/d365_backend.py), not tightening the wrapper.- Boundary rule for D365 payloads:
dict[str, Any]at the raw OData seam (aTypedDictcast over external JSON is a false contract — the server can add/omit keys the type doesn't know about). ReserveTypedDictfor structures the CLI itself constructs and owns (e.g.Referenceincrm/core/references.py). - Run pyright locally with
--pythonpath .venv/bin/python --pythonversion 3.9— omitting either flag masks import errors or lets 3.10+-only symbols pass that fail at the pinned runtime.
Docstrings
- Intent rule, not a coverage quota: a public function in
crm/core/*gets a docstring when its name and signature don't already say what it does and why. Self-explanatory helpers stay bare — forced docstrings are narrative noise. - Sectioned docstrings use Google style (
Args:/Returns:/Raises:) — the only style in use today; don't introduce Sphinx/NumPy variants. - Lint enforces format only (ruff
D2xx/D4xx: quoting, blank lines, imperative mood) on docstrings that exist; there is deliberately no missing-docstring (D1xx) rule.
Formatting & linting
Decision record: Decide: coding standards for the repo;
gates landed via Task: adopt ruff — CI enforces
ruff check + ruff format --check, and pre-commit install (once per clone) runs both on
staged files so the format round-trip never reaches CI.
- ruff is the single formatter and linter.
pyproject.tomlis the source of truth for the exact rule codes; the agreed shape:- line length 100 (matches the codebase's natural style; not the ruff default 88),
- rule families
E,W,F,I(import order),B(bugbear),UP(pyupgrade,target-version = py39), plus the format-onlyDsubset above, - deliberately excluded:
S(bandit — rejected in the tooling sweep),C90complexity,ANN(pyright owns typing),D1xxdocstring coverage, - temporarily ignored:
D205(blank line between docstring summary and description) — adoption debt, not a rule rejection: ~560 pre-existing docstrings violate it; tracked as a follow-up issue.
ruff formatcovers the whole tree —crm/including tests, plusscripts/— no carve-outs.- Noisy rules in tests get a scoped
per-file-ignoresentry, never a global rule removal.
Click command pattern
Every command follows the same shape: @pass_ctx decorator, ctx: CLIContext as the first
parameter, ctx.backend() to reach the backend, ctx.json_mode / ctx.dry_run to branch
behavior, ctx.emit(...) as the single result seam.
@org_group.command("brief")
@pass_ctx
def org_brief(ctx: CLIContext):
with d365_errors(ctx):
brief = org_mod.org_brief(ctx.backend())
ctx.emit(True, data=brief)
House rules on top of the shape:
- Wrap file read/write in
try/except OSErrorand return a clean error envelope — never let a rawOSErrortraceback reach the user. - Mutually exclusive flags raise
click.UsageError(exit2); never fake it withctx.emit(False)(exit1) — those are different failure classes (see Exit codes below). Validate untrusted input before callingctx.backend(), not after. CLIContext.emit(meta=...)renders in human mode too, not just--json— gate any JSON-only field onctx.json_modebefore adding it tometa.- When serializing a Click option for introspection (
crm describe, docs generation), emitopt.secondary_opts(the--no-*forms) alongsideopt.opts, notoptsalone.
Error handling & exit codes
- All D365/backend failures are a
D365Error(crm/utils/d365_backend.py) carryingstatus,code,response_body, and (for multi-stage writes)completed_steps/stage. Commands funnel it through thed365_errors(ctx)context manager (crm/commands/_helpers/errors.py), never a baretry/except D365Errorthat hand-builds the envelope. - Exit-code contract (ADR 0001):
0success,1operational failure (server error, in-command validation, declined confirmation),2Click usage error. Granular per-class codes are rejected — failure-class detail lives in the envelope (error,meta.status/code/category/retryable), not the exit code. - The envelope's
{status, code, category, retryable}is reserved and always derived from the caughtD365Erroritself; anenrich(exc)callback'sextra_metais strictly additive and raises if it names a reserved key. - Never
assertfor a runtime invariant in shipped code —assertis stripped underpython -O, and the frozen PyInstaller build can run optimized. RaiseD365Error(or the appropriate domain error) even for a logically-unreachable check.
Output contract
- The
datapayload is curated and CLI-owned, never a passthrough of the raw D365 Web API response (ADR 0008). List verbs put a bare array indata; OData paging (@odata.nextLink,@odata.count) relocates tometa.next_link/meta.count; per-row@odata.*protocol keys are stripped. _entity_id(+_entity_id_url) is the one normalized key for an affected record's GUID acrosscreate/update/delete/entity get— the leading underscore marks it as CLI-synthesized, distinct from the real PK attribute.- Client-side output shaping (
--fields,--jq, ADR 0023) is a post-curation, envelope-preserving transform applied once at theCLIContext.emitseam — never re-implemented per command. It only touchesdata; error envelopes bypass shaping entirely.
Dry-run contract
- Reads-execute rule: under
--dry-run, only mutations are previewed — every GET still runs for real, which is what lets a preview report live facts (_exists,would_skip) instead of guesses. - A dry-run mutator returns
{_dry_run: true, would_*}(plusmeta.dry_run: true), never the bare success key a real write would return (deleted: true, etc.).
Encoding
Full policy in docs/contributing/encoding.md; the load-bearing rules:
- Every file read uses an explicit encoding — never the OS-locale default.
utf-8-sigfor anything a user or external tool may author (CSV, JSONL, YAML/JSON specs, FetchXML); plainutf-8(no BOM) for writes. - Subprocess captures pin
encoding="utf-8", errors="replace"—text=Truealone decodes with the locale default and can crash on a stray non-UTF-8 byte. - Unix-only imports (
fcntl,pwd,termios, …) needtry/except ImportErrorat the import line so the module still loads on Windows. - Consult metadata to type a value crossing the file boundary; don't infer type from the value's shape (a string-typed alternate-key column keeps its verbatim text, leading zeros and all).
D365 API conventions
- Logic enforcing a header invariant ("never emit both X and Y") uses
requests'CaseInsensitiveDict, never a plaindict— headers are case-insensitive on the wire. @odata.bindnavigation-property names must match$metadatacasing exactly; system entities (sdkmessage*,solution, …) use lowercase logical names — flag a guessed PascalCase name.
Docs-in-sync obligation
Every new/changed command, flag, or behavior ships its docs in the same change:
docs/how-to/<group>.md, docs/reference/cli.md, README.md if user-facing, and crm/skills/
if the change is workflow-visible. Never hand-edit CHANGELOG.md — python-semantic-release
generates it from commit subjects. A new lazily-loaded command group is added to crm.spec
hiddenimports (PyInstaller) in the same change.
Public-repo hygiene
The repo is public and must stay generic: flag real org names, internal hostnames, or
real-looking GUIDs in tests/docs. Placeholders are Contoso / internalcrm.contoso.local.
Known non-issues — do not flag
D365Backendretries only502/503/504(+429) for idempotent methods, not all 5xx; a test using a bare500to exercise the no-retry path is intentional.- Click is pinned
>=8.2:CliRunnerhas nomix_stderrparameter — stdout/stderr are always separate streams. # pyright: basicheaders in test files are intentional (see Type checking above).- OAuth token acquisition raises
D365Errorduringsession.get(), not arequestsexception — a caller catchingD365Erroraround a raw session call is correct, not dead code.