Contributing¶
Getting set up¶
uv sync --all-packages --all-groups
That installs all four packages editable plus every tool. VS Code will pick up
.venv automatically and format, fix and sort imports on save.
The full gate, identical to CI:
uv run ruff format --check .
uv run ruff check .
uv run pyright
PYTHONDEVMODE=1 PYTHONWARNDEFAULTENCODING=1 uv run pytest
uv run properdocs build --strict
The two environment variables are not decoration: CI sets them for the test
job, filterwarnings = ["error"] turns what they emit into failures, and
PYTHONWARNDEFAULTENCODING catches nothing at all without them. A run without
them passes tests that CI fails.
Layout¶
pb-cli ──> pb-client ──> pb-core
└───> pb-service ──> pb-core
pb-cli depends on pb-service so that installing the CLI gives a working service
autostart, but it must never import it — importing is what costs startup time.
There is a test asserting bleak is not in sys.modules after pb --help.
Async rules¶
All async code in this repository is structured, in the sense of Notes on structured concurrency. These are not style preferences; several are enforced by ruff and by tests.
- The black-box rule. When a function returns, every task it started has
finished. Control flow is a tree.
asyncio.create_taskis thegostatement that article argues against and is banned. - Ongoing events are
@asynccontextmanageryielding anAsyncIterator— neveron_event(callback), never a returned task. This is what makes rule 1 unbreakable from outside the library. - No
timeout=parameters in public APIs. Cancellation belongs to a scope. Callers useanyio.fail_after/move_on_after. Where the CLI exposes--timeout, the command body opens the cancel scope. - Never swallow cancellation. It is level-triggered, so it keeps arriving
until the scope exits. Use
except anyio.get_cancelled_exc_class(): ... raise— never a bareexcept Exceptionaround an await. Enforced by ruffBLE001. - Teardown that must not be cancelled is a sync context manager where
possible: a sync
__exit__cannot be cancelled at all, which is strictly stronger thanshield=True, which merely defers. Restoring the terminal from raw mode works this way. Where async teardown is unavoidable, useanyio.CancelScope(shield=True)with a boundedmove_on_after. - Async functions all the way down. No futures, no
call_soon, no Protocol/Transport callback layering in our own code. - Wait for something; do not ask repeatedly whether it has happened. A loop
whose body is "look, find nothing, sleep, look again" is a timer standing in
for an event, and it costs a wakeup every period plus half a period of
latency on everything it waits for. Where the platform can say "there is
input now" — a file descriptor, an
anyio.Event, a memory object stream — take that. Polling is for the places where nothing will ever say so: retrying after a device has refused a write, or waiting for a process that has not started listening yet. Each of those owes a comment naming what cannot be waited on, so a forced poll can be told from a lazy one. - Close an async generator in the task that used it. Wrap it in
contextlib.aclosingwhenever the loop over it might not run to the end. Stopping early — abreak, areturn, an exception, an enclosing cancel scope firing — leaves the generator suspended at itsyieldrather than closing it there and then. It is finalised later, by the event loop's async-generator hooks or by the garbage collector, and in whatever task happens to be running then. So everything it was holding is released from the wrong task:
# Wrong: returning from inside the loop abandons the generator, and the
# lock inside it is released by whichever task finalises it.
async for device in manager.discover(ScanFilter()):
if device.id == wanted:
return device
# Right: closed here, in this task, on every path out.
async with aclosing(manager.discover(ScanFilter())) as stream:
async for device in stream:
if device.id == wanted:
return device
The symptom is a failure a long way from the cause. anyio says
RuntimeError: The current task is not holding this lock, or a cancel scope
complains that it is being exited in a different task from the one that
entered it, and neither names the generator. Assume any generator with an
async with or a try/finally in it needs this, which in practice means
all of ours: it is also the reason rule 2 hands out subscriptions through an
@asynccontextmanager, since that gives the caller a close point they cannot
forget.
- An async generator that owns a task group must catch
GeneratorExit. Closing a generator throwsGeneratorExitin at theyield. It is aBaseExceptionand it is not a cancellation, so a task group's__aexit__collects it and re-raises it wrapped in anExceptionGroup— which reaches the caller as an unrelated crash a long way from thebreakthat caused it. This is not hypothetical: it took the service down mid-scan, because every caller ofdiscoverstops early.
async with anyio.create_task_group() as tg, receive:
tg.start_soon(pump, ...)
try:
async for item in receive:
yield item
except GeneratorExit:
tg.cancel_scope.cancel()
return # so __aexit__ sees no exception at all
Measured, because the obvious alternatives look right and are not: cancelling
the scope in a finally does not help, since the exception still arrives at
__aexit__; nor does entering and exiting the group by hand. The cancel also
has to come before the receiving end closes, or the tasks are woken by a
broken stream rather than by a cancellation and raise on the way out.
Better still, do not put a task group in a generator. Rule 2's
@asynccontextmanager yielding an AsyncIterator has no such problem, and
is the shape to use for anything new that needs both.
-
A cancel scope that bounds a generator goes outside the block that closes it. Same family, opposite end. To put a deadline on how long a scan runs, the scope wraps the
async with aclosing(...):with anyio.move_on_after(seconds) as looking: # outside async with aclosing(manager.discover(f)) as stream: async for device in stream: ... looking.deadline = ... # fine to adjustOpen the scope inside that block instead and cancelling it unwinds into the generator's own scopes, which anyio reports as
Attempted to exit a cancel scope that isn't the current tasks's current cancel scope— from a frame that names neither the generator nor the scope. Adjusting the outer scope'sdeadlinefrom inside the loop is the way to shorten a wait part-way through, and needs no scope of its own.
The two sanctioned exceptions¶
Both are confined to a single documented module, and nowhere else may take a callback from a third-party library:
- bleak's notification and disconnect callbacks are invoked outside our task
tree. They may only
send_nowaitinto a memory object stream drained by an owned pump task. They never spawn tasks and never block — blocking there stalls the BLE stack. - asyncio's proactor named-pipe API on Windows is exactly the
Protocol/Transport style rule 6 rejects, but is the only way to get named
pipes. It is wrapped into an
anyio.abc.ByteStream.
Separately, and by design, hub connections outlive the client session that
created them — that is the entire point of the service. This is contained by
having the HubManager own the hub's task group, so connecting is handle
acquisition against a running supervisor rather than a spawn.
Why anyio rather than asyncio.TaskGroup¶
Portability is not the reason; bleak pins us to the asyncio backend regardless.
anyio is used for shielded cancel scopes (no stdlib equivalent —
asyncio.shield() still cancels the awaiter, which breaks clean hub disconnect
and terminal restore), level-triggered cancellation, and memory object streams
with real close/EOF semantics.
Layout on the page¶
A block statement gets a blank line before and after it. An if, for,
while, with, try or match is separated from what comes before it and
what comes after it by an empty line, because a block is a shape on the page and
air around it makes where it begins and ends visible without counting
indentation. The else, elif, except and finally clauses belong to the
statement and stay with it. A block that is the first thing in its body, or the
last, has nothing to be separated from.
A docstring counts as something before it, so a function whose body opens with a
block gets a blank line between the two. That is why D202 is switched off in
the ruff configuration — pydocstyle forbids exactly that line.
Except an if that tests what the line above assigned, which stays attached
to it. Getting a value and checking it is one thought:
wake = _CreateEventW(None, True, False, None)
if not wake:
raise OSError(ctypes.get_last_error(), "cannot create a wake-up event")
No linter checks this — ruff's E301–E306 are about functions and classes —
so it is held to by hand.
Source files stay near 500 lines. Up to 1000 where it genuinely reads better as one thing, and not past that. A file over the line is usually doing several jobs, so the fix is to split along a seam that means something rather than to cut it at a convenient point, and a group of related modules is a subpackage rather than several modules sharing a name prefix.
A control byte is written as an escape sequence, never as itself. b"\x1b[A"
and not an arrow key's actual Escape byte, "\r\n" and not a real carriage
return inside a string. This repository is full of terminal sequences and
keystrokes, so the temptation is constant and the mistake is invisible: an
editor draws a literal Escape as a neat little badge, while git diff, a review
in a browser, grep output and any terminal that acts on the byte rather than
printing it all show something else, and the file survives copying and
line-ending normalisation only by luck.
tests/test_source_text.py checks every tracked .py file for this, because
nothing else would: the bytes are identical at runtime, so the tests pass either
way.
The way they get in is not typing them. Writing a file through a shell heredoc
eats a level of backslash escaping, so a payload containing \\x1b reaches
Python as \x1b and is written out as the byte. The same trap turned \\n into
a real newline once before, producing a file with an unterminated string
literal. Write files that contain backslash escapes with an editor rather than
through a heredoc.
Talking to C libraries¶
ctypes signatures go on a function of our own, never on the library's. A
ctypes library object hands the same function object to everybody who asks for
it, so assigning argtypes or restype to it changes how every other caller in
the process — including other packages — marshals that call. Build a binding
instead, with ctypes.WINFUNCTYPE or CFUNCTYPE, and call the prototype with a
(name, library) pair; pb_cli.console.windows does this for every kernel32
entry it uses.
Declaring the types is still necessary. Left to ctypes' defaults everything is a
C int, so a handle or a pointer is passed as four bytes — which works right up
until a value is large enough that it does not.
Use paramflags, and an errcheck with them. A prototype takes a flag per
parameter, and they earn their keep: an output parameter comes back as the
return value instead of the caller allocating one and passing byref, and an
argument that never varies becomes a default instead of being repeated at every
call site. The catch is that an output parameter replaces the function's own
result, which for a Win32 BOOL is the only thing that reported failure — so
anything with one needs an errcheck that raises ctypes.WinError and returns
args, which is ctypes' signal to hand the output parameters back. Without it,
a failed call returns a plausible zero and says nothing.
ctypes, not pywin32. Importing pythoncom initialises COM as an apartment,
which makes bleak hang; bleak.backends.winrt.util.assert_mta names it.
Text has an encoding, and we say which one¶
Every call that turns bytes into text names its encoding. That means the
encoding= argument to open, to Path.read_text and write_text, and to
subprocess.run and Popen — and it is "utf-8" unless there is a reason for
something else. Left out, Python uses the platform default, which is UTF-8 on
Linux and macOS and the ANSI code page on Windows, so the same code reads the
same file two different ways depending on where it runs.
Prefer encoding="utf-8" to text=True when running a subprocess. text=True
selects text mode and then falls back to the default encoding for it; passing
an encoding selects text mode as well, so it says the same thing and one more
useful thing.
PYTHONWARNDEFAULTENCODING is what finds the omissions, which is why the local
gate above sets it. The warning points at the stdlib frame that consulted the
default rather than at the call that failed to specify one, so read up the
traceback for our own file.
Values, not things that get edited¶
A leaning rather than a law, in the same spirit as the async rules.
Anything that is a value gets an immutable type — a frozen dataclass, a
NamedTuple, a tuple, a frozenset — and its methods return the next value
rather than editing this one. Reserve mutable objects for things with a
lifetime: something owning a socket, a task or a terminal. A session's four
tasks all read the same Running, and none of them can change what another is
looking at, which is the property to aim for.
Constants are Final, and their type is an immutable one. Final on its
own is a promise about the name: the typing specification says it
"only guarantees that the name will not be re-bound to another value", and it
recommends what to do about the rest — "immutable ABCs and containers may be
used in combination with Final to prevent mutating such values". So a
constant table is Final[Mapping[...]] rather than Final[dict[...]], and
then editing it is a type error wherever it is imported. Nothing needs wrapping
in MappingProxyType, and nothing needs copying defensively.
One gap worth knowing rather than working around: in the module that declares
the constant, pyright narrows the annotation back to the type of the value
assigned, so WARNINGS["x"] = "y" is accepted there and refused everywhere
else. Measured, not assumed.
Prefer composition over inheritance. Where behaviour or state needs sharing,
the first thing to reach for is an object that holds another object, a
Protocol describing what is needed, or a function that takes what it works on
— not a base class. CombinedTransport is the shape to copy: it behaves like
several transports by holding them in a tuple and delegating, so nothing above
it knows there is more than one. Multiple inheritance in particular appears
nowhere in this repository and should not start.
An ABC is still fine where an ABC fits — pb_core.ipc has two,
deliberately, next to the Protocols used elsewhere — so this is not a licence
to refactor working code. And naming a Protocol as a base class is a statement
of intent rather than reuse, which is why _BleLink(HubLink) reads the way it
does; a Protocol is checked structurally wherever the value is used either
way.
What a function did is best said by returning it. A callback that reaches
into a shared object to record what it has done is harder to follow than one
that says so: pb run and pb repl hand the session a start function, and it
returns whether it started something rather than setting a flag on the session.
Errors the user sees¶
Two kinds of failure, presented differently.
An expected failure -- the hub is switched off, nothing is connected, the
program is too big -- is something the user can act on. It is reported as one
sentence saying what happened plus, where there is one, a suggestion of how to
fix it. No traceback: it would only bury the sentence that matters. The wording
follows Pybricks Code, which pairs every alert with a suggestion for the same
reason.
An unexpected failure is a bug in the tools. Its traceback is printed in full and unaltered, because that is the most useful thing that can be shown to whoever ends up reading the report.
Both live in pb/errors.py. Commands do not catch their own failures: a task
group wraps whatever its body raised, so except JsonRpcError around an
async with pb_client.connect() block silently stops matching. errors.run
flattens the group, and anything it cannot explain it re-raises. The only
except clauses left in pb/cli.py are for failures that are a normal answer
to the question asked -- listing hubs when none are connected, stopping a
service that is not running -- and they use errors.find for the same reason.
Adding a JSON-RPC error code means adding its wording to _RULES; a test walks
ErrorCode and fails if one is missing. Dispatch is keyed on the code, never on
message text, so service-side wording can be changed freely. Keep service messages
factual and let the CLI give the advice: the service does not know whether the
user was trying to connect to a hub or to use one.
Docs¶
Reference documentation is generated from the code — docstrings for the API, the cyclopts app for the CLI, the typed method registry for the JSON-RPC protocol. Do not hand-maintain reference pages; they drift. Narrative pages are written by hand. Docstrings are Google style.
A command's docstring is written for whoever types the command. It is what
pb scan --help prints and what the Commands page shows, so it says what the
command does and how to use it, and nothing about how it is built. Notes for
whoever maintains it — why a lint rule is silenced, which library call is being
worked around — are comments. That is not a style preference: one such note sat
in pb scan's docstring and was read by users as part of the help.
The pb-core and pb-client docstrings are the opposite case: their readers
are people calling the code, so implementation notes belong there.