Compare commits

...

3 Commits

Author SHA1 Message Date
Gud Boi 0a3c48fc57 Fix informal-RST in docstrings for clean autodoc
The new `api/` reference pages surfaced 22 docutils warnings from
informal reST in public docstrings; fix the markup so the docs
build is warning-free (24 -> 0), clearing the path to a future
`-W`/`nitpicky` flip in CI.

Deats (docstring content only; no code/signature changes),
- give bullet lists a blank line + base-column indent (`Context`,
  `Context.cancel_called`/`.cancelled_caught`/ `.outcome`,
  `ActorNursery.cancel_called`, `query_actor`,
  `open_crash_handler`),
- demote the under-short `Behaviour:` underline in `Context.cancel`
  to a `**bold**` label,
- close an unbalanced backtick in the `wait_for_actor` summary and
  use the `` `role`\ s `` escaped-plural idiom where a role was
  pluralized (`gather_contexts`, `mk_pdb`, `MsgCodec`, msg `Error`,
  `open_context_from_portal`),
- make the `|_` method-tree in `ContextCancelled.canceller` a
  literal block (the bare `|` was read as a substitution ref),
- same blank-line fix for the `#318` entry in `NEWS.rst`.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-06-25 19:20:30 -04:00
Gud Boi 3a5cfde023 Harden the `.. d2::` directive render path
Three robustness fixes surfaced by the PR #460 self-review,

- a render that's *attempted and fails* (a `d2` bin is present but
  errors on the source) now raises a docutils error instead of
  silently serving the stale committed SVG — so `sphinx-build -W`
  fails on a broken `.d2` edit. The no-binary case stays a quiet
  committed-SVG fallback.
- render into a sibling temp-file then `os.replace()` so a
  failed/torn render can never clobber a good committed SVG.
- guard against 2 distinct `.d2` sources colliding on a single
  `_diagrams/<stem>.svg` within a build.

`render_svg()` now returns a `RenderResult` tristate that `run()`
maps to error / raw-source-literal / image.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-06-25 19:20:30 -04:00
Gud Boi 8661336cf6 Add opt-in `docs` dev-shell with `d2`
Factor the shared dev-shell toolchain into `basePkgs` + `baseHook`
so extra-tooling shells can extend it, then add a `docs` shell =
base + `pkgs.d2` (the diagram renderer our `.. d2::` sphinx
directive shells out to). Keeps `d2` OUT of the `default` shell so
casual dev envs stay lean.

Enter with `nix develop .#docs`, then build via `uv run --group
docs make -C docs html`.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-06-25 19:20:30 -04:00
12 changed files with 208 additions and 78 deletions

View File

@ -78,6 +78,7 @@ Bug Fixes
for ``asyncio``-side errors to not propagate due to a race condition.
The implementation fix summary is:
- add state to signal the end of the ``trio`` side task to be
read by the ``asyncio`` side and always cancel any ongoing
task in such cases.

View File

@ -16,6 +16,14 @@ Rendering policy,
emitted as a literal block so no content is ever
silently dropped.
A render that is *attempted and fails* (a ``d2`` bin is
present but errors on the source) is NOT silently
degraded to the stale committed SVG: it raises a
docutils error (so ``sphinx-build -W`` fails the
build). The render is also atomic a failed/torn
render can never clobber a good committed SVG so the
last-good diagram survives a bad edit.
Binary discovery order,
- the ``D2_BIN`` env var; may contain args which are
@ -37,11 +45,13 @@ Usage,
'''
from __future__ import annotations
import enum
import os
from pathlib import Path
import shlex
import shutil
import subprocess as sp
import tempfile
from docutils import nodes
from docutils.parsers.rst import directives
@ -55,6 +65,22 @@ log = logging.getLogger(__name__)
# git-committed, fallback SVG outputs.
_outdir: str = '_diagrams'
# per-build map of {output-svg-name: source-relpath} used
# to detect 2 distinct `.d2` sources colliding on a single
# output stem; reset on each `builder-inited` (see `setup`).
_seen_outputs: dict[str, str] = {}
class RenderResult(enum.Enum):
'''
Outcome of a `render_svg()` call.
'''
OK = 'ok' # fresh SVG exists on disk
FELL_BACK = 'fell_back' # no bin; serving committed SVG
NO_OUTPUT = 'no_output' # no bin AND no committed SVG
FAILED = 'failed' # bin present, render errored
def find_d2(
app: Sphinx,
@ -77,37 +103,54 @@ def render_svg(
app: Sphinx,
src: Path,
out: Path,
) -> bool:
) -> RenderResult:
'''
Maybe (re)render `src` -> `out`, returning
`True` when an up-to-date SVG exists after the
call.
Maybe (re)render `src` -> `out` and report the
outcome as a `RenderResult`.
A render is performed only when `out` is missing or
older than `src` AND a `d2` binary is available. The
write is atomic (temp-file + `os.replace`) so a
failed or torn render never clobbers an existing
(committed) SVG.
'''
stale: bool = (
not out.exists()
or
src.stat().st_mtime > out.stat().st_mtime
fresh: bool = (
out.exists()
and
src.stat().st_mtime <= out.stat().st_mtime
)
if not stale:
return True
if fresh:
return RenderResult.OK
d2cmd: list[str]|None = find_d2(app)
if d2cmd is None:
# no binary: fall back to whatever is committed,
# else signal the caller to emit the raw source.
if out.exists():
log.info(
f'no d2 binary; using committed svg '
f'for {src.name}'
)
return True
return False
return RenderResult.FELL_BACK
return RenderResult.NO_OUTPUT
out.parent.mkdir(
parents=True,
exist_ok=True,
)
# render into a sibling temp-file first so a bad
# `d2` exit (or a crash mid-write) leaves any prior
# committed SVG fully intact.
fd, tmpname = tempfile.mkstemp(
dir=str(out.parent),
prefix=f'.{out.stem}.',
suffix='.svg.tmp',
)
os.close(fd)
tmp = Path(tmpname)
argv: list[str] = (
d2cmd
+ list(app.config.d2_args)
+ [str(src), str(out)]
+ [str(src), str(tmp)]
)
try:
proc = sp.run(
@ -120,18 +163,22 @@ def render_svg(
OSError,
sp.TimeoutExpired,
) as err:
tmp.unlink(missing_ok=True)
log.warning(
f'd2 invocation failed for {src.name}: '
f'{err}'
)
return out.exists()
return RenderResult.FAILED
if proc.returncode != 0:
tmp.unlink(missing_ok=True)
log.warning(
f'd2 render error for {src.name}:\n'
f'{proc.stderr}'
)
return out.exists()
return True
return RenderResult.FAILED
# atomic swap-in of the freshly rendered SVG.
os.replace(tmp, out)
return RenderResult.OK
class D2Diagram(SphinxDirective):
@ -169,18 +216,54 @@ class D2Diagram(SphinxDirective):
/ _outdir
/ f'{src.stem}.svg'
)
if not render_svg(self.env.app, src, out):
# last resort: emit the raw d2 source.
# collision guard: two distinct sources must not
# map onto the same output stem within a build.
prior: str|None = _seen_outputs.get(out.name)
if (
prior is not None
and
prior != relsrc
):
err = self.state_machine.reporter.error(
f'd2 output collision: {relsrc!r} and '
f'{prior!r} both render to '
f'{_outdir}/{out.name}; rename one '
f'`.d2` stem',
line=self.lineno,
)
return [err]
_seen_outputs[out.name] = relsrc
result: RenderResult = render_svg(
self.env.app,
src,
out,
)
if result is RenderResult.FAILED:
# loud, build-failing (under `-W`) signal; the
# prior committed SVG, if any, is untouched.
err = self.state_machine.reporter.error(
f'd2 render failed for {relsrc} (see '
f'build log); the committed svg, if any, '
f'was left untouched',
line=self.lineno,
)
return [err]
if result is RenderResult.NO_OUTPUT:
# last resort: emit the raw d2 source so no
# content is ever silently dropped.
log.warning(
f'no svg available for {relsrc}; '
f'emitting d2 source as literal block'
)
src_text: str = src.read_text()
literal = nodes.literal_block(
src.read_text(),
src.read_text(),
src_text,
src_text,
)
literal['language'] = 'text'
return [literal]
# OK | FELL_BACK -> embed the SVG figure.
img = nodes.image(
uri=f'/{_outdir}/{out.name}',
alt=self.options.get(
@ -224,12 +307,29 @@ class D2Diagram(SphinxDirective):
return [fig]
def _reset_seen(
app: Sphinx,
) -> None:
'''
Clear the per-build output-collision map at the
start of each build.
'''
_seen_outputs.clear()
def setup(app: Sphinx) -> dict:
app.add_config_value('d2_bin', None, 'env')
app.add_config_value('d2_args', [], 'env')
app.add_directive('d2', D2Diagram)
app.connect('builder-inited', _reset_seen)
return {
'version': '0.1.0',
# NB: run() writes the rendered SVG during the
# READ phase; the temp-file + `os.replace` swap
# keeps that atomic so concurrent renders of the
# same diagram (under `sphinx-build -j`) can't
# tear the output file.
'parallel_read_safe': True,
'parallel_write_safe': True,
}

View File

@ -25,11 +25,11 @@
cpython = "python313";
venv_dir = "py313";
pypkgs = pkgs."${cpython}Packages";
in
{
default = pkgs.mkShell {
packages = [
# shared base toolchain for every dev-shell variant;
# extra-tooling shells (eg. `docs`, below) extend it
# so heavy/niche deps stay OUT of the `default` shell.
basePkgs = [
# XXX, ensure sh completions activate!
pkgs.bashInteractive
pkgs.bash-completion
@ -42,7 +42,7 @@
pkgs.${cpython}# ?TODO^ how to set from `cpython` above?
];
shellHook = ''
baseHook = ''
# unmask to debug **this** dev-shell-hook
# set -e
@ -63,6 +63,26 @@
# run the `nix develop` cmd with,
# >> nix develop -c uv run xonsh
'';
in
{
default = pkgs.mkShell {
packages = basePkgs;
shellHook = baseHook;
};
# OPT-IN docs shell: `default` + the `d2` diagram
# renderer, kept OUT of `default` so casual dev shells
# don't pull it in. Enter with,
# >> nix develop .#docs
# then build (the `.. d2::` directive auto-detects the
# `d2` bin now on PATH; see `docs/_ext/d2diagrams.py`),
# >> uv run --group docs make -C docs html
docs = pkgs.mkShell {
packages = basePkgs ++ [ pkgs.d2 ];
shellHook = baseHook + ''
echo "[docs] d2 $(d2 --version) on PATH \
build via: uv run --group docs make -C docs html"
'';
};
}
);

View File

@ -135,6 +135,7 @@ class Context:
communication context.
(We've also considered other names and ideas:
- "communicating tasks scope": cts
- "distributed task scope": dts
- "communicating tasks context": ctc
@ -144,6 +145,7 @@ class Context:
NB: This class should **never be instatiated directly**, it is
allocated by the runtime in 2 ways:
- by entering `Portal.open_context()` which is the primary
public API for any "parent" task or,
- by the RPC machinery's `._rpc._invoke()` as a `ctx` arg
@ -443,6 +445,7 @@ class Context:
'''
Records whether cancellation has been requested for this context
by a call to `.cancel()` either due to,
- an explicit call by some local task,
- or an implicit call due to an error caught inside
the `Portal.open_context()` block.
@ -554,6 +557,7 @@ class Context:
Only as an FYI, in the "child" side case it can also be
set but never is readable by any task outside the RPC
machinery in `._invoke()` since,:
- when a child side calls `.cancel()`, `._scope.cancel()`
is called immediately and handled specially inside
`._invoke()` to raise a `ContextCancelled` which is then
@ -1033,8 +1037,8 @@ class Context:
`._scope.cancel()` and delivering an `ContextCancelled`
ack msg in reponse.
Behaviour:
---------
**Behaviour:**
- after the far end cancels, the `.cancel()` calling side
should receive a `ContextCancelled` with the
`.canceller: tuple` uid set to the current `Actor.aid.uid`.
@ -1505,6 +1509,7 @@ class Context:
TODO->( this is doc-driven-dev content not yet actual ;P )
The final "outcome" from an IPC context which can be any of:
- some `outcome.Value` which boxes the returned output from the peer task's
`@context`-decorated remote task-as-func, or
- an `outcome.Error` wrapping an exception raised that same RPC task
@ -2066,7 +2071,7 @@ async def open_context_from_portal(
allows for deterministic setup and teardown of a remotely
scheduled task in another remote actor. Once opened, the 2 now
"linked" tasks run completely in parallel in each actor's
runtime with their enclosing `trio.CancelScope`s kept in
runtime with their enclosing `trio.CancelScope`\\ s kept in
a synced state wherein if either side errors or cancels an
equivalent error is relayed to the other side via an SC-compat
IPC protocol.

View File

@ -824,7 +824,8 @@ class ContextCancelled(RemoteActorError):
- (simulating) an IPC transport network outage
- a (malicious) pkt sent specifically to cancel an actor's
runtime non-gracefully without ensuring ongoing RPC tasks are
incrementally cancelled as is done with:
incrementally cancelled as is done with::
`Actor`
|_`.cancel()`
|_`.cancel_soon()`

View File

@ -321,6 +321,7 @@ def open_crash_handler(
`typer` users so they can quickly wrap cmd endpoints which get
automatically wrapped to use the runtime's `debug_mode: bool`
AND `pdbp.pm()` around any code that is PRE-runtime entry
- any sync code which runs BEFORE the main call to
`trio.run()`.

View File

@ -187,7 +187,7 @@ def mk_pdb() -> PdbREPL:
FURTHER, the `pdbp.Pdb` instance is configured to be `trio`
"compatible" from a SIGINT handling perspective; we mask out
the default `pdb` handler and instead apply `trio`s default
the default `pdb` handler and instead apply `trio`\\ s default
which mostly addresses all issues described in:
- https://github.com/python-trio/trio/issues/1155

View File

@ -253,6 +253,7 @@ async def query_actor(
listening @ `regaddr`.
Yields a `tuple` of `(addr, reg_portal)` where,
- `addr` is the transport protocol (socket) address or `None` if
no entry under that name exists,
- `reg_portal` is the `Portal` (or `LocalPortal` when the
@ -439,7 +440,7 @@ async def wait_for_actor(
) -> AsyncGenerator[Portal, None]:
'''
Wait on at least one peer actor to register `name` with the
registrar, yield a `Portal to the first registree.
registrar, yield a `Portal` to the first registree.
'''
actor: Actor = current_actor()

View File

@ -371,7 +371,7 @@ class MsgCodec(Struct):
A IPC msg interchange format lib's encoder + decoder pair.
Pretty much nothing more then delegation to underlying
`msgspec.<interchange-protocol>.Encoder/Decoder`s for now.
`msgspec.<interchange-protocol>.Encoder/Decoder`\\ s for now.
'''
_enc: msgpack.Encoder

View File

@ -453,7 +453,7 @@ class Error(
# omit_defaults=True,
):
'''
A pkt that wraps `RemoteActorError`s for relay and raising.
A pkt that wraps `RemoteActorError`\\ s for relay and raising.
Fields are 1-to-1 meta-data as needed originally by
`RemoteActorError.msgdata: dict` but now are defined here.

View File

@ -252,6 +252,7 @@ class ActorNursery:
'''
Records whether cancellation has been requested for this
actor-nursery by a call to `.cancel()` either due to,
- an explicit call by some actor-local-task,
- an implicit call due to an error/cancel emited inside
the `tractor.open_nursery()` block.

View File

@ -120,12 +120,12 @@ async def gather_contexts(
None,
]:
'''
Concurrently enter a sequence of async context managers (`acm`s),
Concurrently enter a sequence of async context managers (`acm`\\ s),
each scheduled in a separate `trio.Task` and deliver their
unwrapped `yield`-ed values in the same order once all `@acm`s
unwrapped `yield`-ed values in the same order once all `@acm`\\ s
in every task have entered.
On exit, all `acm`s are subsequently and concurrently exited with
On exit, all `acm`\\ s are subsequently and concurrently exited with
**no order guarantees**.
This function is somewhat similar to a batch of non-blocking