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. for ``asyncio``-side errors to not propagate due to a race condition.
The implementation fix summary is: The implementation fix summary is:
- add state to signal the end of the ``trio`` side task to be - add state to signal the end of the ``trio`` side task to be
read by the ``asyncio`` side and always cancel any ongoing read by the ``asyncio`` side and always cancel any ongoing
task in such cases. task in such cases.

View File

@ -16,6 +16,14 @@ Rendering policy,
emitted as a literal block so no content is ever emitted as a literal block so no content is ever
silently dropped. 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, Binary discovery order,
- the ``D2_BIN`` env var; may contain args which are - the ``D2_BIN`` env var; may contain args which are
@ -37,11 +45,13 @@ Usage,
''' '''
from __future__ import annotations from __future__ import annotations
import enum
import os import os
from pathlib import Path from pathlib import Path
import shlex import shlex
import shutil import shutil
import subprocess as sp import subprocess as sp
import tempfile
from docutils import nodes from docutils import nodes
from docutils.parsers.rst import directives from docutils.parsers.rst import directives
@ -55,6 +65,22 @@ log = logging.getLogger(__name__)
# git-committed, fallback SVG outputs. # git-committed, fallback SVG outputs.
_outdir: str = '_diagrams' _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( def find_d2(
app: Sphinx, app: Sphinx,
@ -77,37 +103,54 @@ def render_svg(
app: Sphinx, app: Sphinx,
src: Path, src: Path,
out: Path, out: Path,
) -> bool: ) -> RenderResult:
''' '''
Maybe (re)render `src` -> `out`, returning Maybe (re)render `src` -> `out` and report the
`True` when an up-to-date SVG exists after the outcome as a `RenderResult`.
call.
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 = ( fresh: bool = (
not out.exists() out.exists()
or and
src.stat().st_mtime > out.stat().st_mtime src.stat().st_mtime <= out.stat().st_mtime
) )
if not stale: if fresh:
return True return RenderResult.OK
d2cmd: list[str]|None = find_d2(app) d2cmd: list[str]|None = find_d2(app)
if d2cmd is None: if d2cmd is None:
# no binary: fall back to whatever is committed,
# else signal the caller to emit the raw source.
if out.exists(): if out.exists():
log.info( log.info(
f'no d2 binary; using committed svg ' f'no d2 binary; using committed svg '
f'for {src.name}' f'for {src.name}'
) )
return True return RenderResult.FELL_BACK
return False return RenderResult.NO_OUTPUT
out.parent.mkdir( out.parent.mkdir(
parents=True, parents=True,
exist_ok=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] = ( argv: list[str] = (
d2cmd d2cmd
+ list(app.config.d2_args) + list(app.config.d2_args)
+ [str(src), str(out)] + [str(src), str(tmp)]
) )
try: try:
proc = sp.run( proc = sp.run(
@ -120,18 +163,22 @@ def render_svg(
OSError, OSError,
sp.TimeoutExpired, sp.TimeoutExpired,
) as err: ) as err:
tmp.unlink(missing_ok=True)
log.warning( log.warning(
f'd2 invocation failed for {src.name}: ' f'd2 invocation failed for {src.name}: '
f'{err}' f'{err}'
) )
return out.exists() return RenderResult.FAILED
if proc.returncode != 0: if proc.returncode != 0:
tmp.unlink(missing_ok=True)
log.warning( log.warning(
f'd2 render error for {src.name}:\n' f'd2 render error for {src.name}:\n'
f'{proc.stderr}' f'{proc.stderr}'
) )
return out.exists() return RenderResult.FAILED
return True # atomic swap-in of the freshly rendered SVG.
os.replace(tmp, out)
return RenderResult.OK
class D2Diagram(SphinxDirective): class D2Diagram(SphinxDirective):
@ -169,18 +216,54 @@ class D2Diagram(SphinxDirective):
/ _outdir / _outdir
/ f'{src.stem}.svg' / f'{src.stem}.svg'
) )
if not render_svg(self.env.app, src, out): # collision guard: two distinct sources must not
# last resort: emit the raw d2 source. # 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( log.warning(
f'no svg available for {relsrc}; ' f'no svg available for {relsrc}; '
f'emitting d2 source as literal block' f'emitting d2 source as literal block'
) )
src_text: str = src.read_text()
literal = nodes.literal_block( literal = nodes.literal_block(
src.read_text(), src_text,
src.read_text(), src_text,
) )
literal['language'] = 'text' literal['language'] = 'text'
return [literal] return [literal]
# OK | FELL_BACK -> embed the SVG figure.
img = nodes.image( img = nodes.image(
uri=f'/{_outdir}/{out.name}', uri=f'/{_outdir}/{out.name}',
alt=self.options.get( alt=self.options.get(
@ -224,12 +307,29 @@ class D2Diagram(SphinxDirective):
return [fig] 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: def setup(app: Sphinx) -> dict:
app.add_config_value('d2_bin', None, 'env') app.add_config_value('d2_bin', None, 'env')
app.add_config_value('d2_args', [], 'env') app.add_config_value('d2_args', [], 'env')
app.add_directive('d2', D2Diagram) app.add_directive('d2', D2Diagram)
app.connect('builder-inited', _reset_seen)
return { return {
'version': '0.1.0', '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_read_safe': True,
'parallel_write_safe': True, 'parallel_write_safe': True,
} }

View File

@ -25,43 +25,63 @@
cpython = "python313"; cpython = "python313";
venv_dir = "py313"; venv_dir = "py313";
pypkgs = pkgs."${cpython}Packages"; pypkgs = pkgs."${cpython}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
# XXX, on nix(os), use pkgs version to avoid
# build/sys-sh-integration issues
pkgs.ruff
pkgs.uv
pkgs.${cpython}# ?TODO^ how to set from `cpython` above?
];
baseHook = ''
# unmask to debug **this** dev-shell-hook
# set -e
# link-in c++ stdlib for various AOT-ext-pkgs (numpy, etc.)
LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH"
export LD_LIBRARY_PATH
# RUNTIME-SETTINGS
# ------ uv ------
# - always use the ./py313/ venv-subdir
# - sync env with all extras
export UV_PROJECT_ENVIRONMENT=${venv_dir}
uv sync --dev --all-extras
# ------ TIPS ------
# NOTE, to launch the py-venv installed `xonsh` (like @goodboy)
# run the `nix develop` cmd with,
# >> nix develop -c uv run xonsh
'';
in in
{ {
default = pkgs.mkShell { default = pkgs.mkShell {
packages = basePkgs;
shellHook = baseHook;
};
packages = [ # OPT-IN docs shell: `default` + the `d2` diagram
# XXX, ensure sh completions activate! # renderer, kept OUT of `default` so casual dev shells
pkgs.bashInteractive # don't pull it in. Enter with,
pkgs.bash-completion # >> nix develop .#docs
# then build (the `.. d2::` directive auto-detects the
# XXX, on nix(os), use pkgs version to avoid # `d2` bin now on PATH; see `docs/_ext/d2diagrams.py`),
# build/sys-sh-integration issues # >> uv run --group docs make -C docs html
pkgs.ruff docs = pkgs.mkShell {
packages = basePkgs ++ [ pkgs.d2 ];
pkgs.uv shellHook = baseHook + ''
pkgs.${cpython}# ?TODO^ how to set from `cpython` above? echo "[docs] d2 $(d2 --version) on PATH \
]; build via: uv run --group docs make -C docs html"
shellHook = ''
# unmask to debug **this** dev-shell-hook
# set -e
# link-in c++ stdlib for various AOT-ext-pkgs (numpy, etc.)
LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH"
export LD_LIBRARY_PATH
# RUNTIME-SETTINGS
# ------ uv ------
# - always use the ./py313/ venv-subdir
# - sync env with all extras
export UV_PROJECT_ENVIRONMENT=${venv_dir}
uv sync --dev --all-extras
# ------ TIPS ------
# NOTE, to launch the py-venv installed `xonsh` (like @goodboy)
# run the `nix develop` cmd with,
# >> nix develop -c uv run xonsh
''; '';
}; };
} }

View File

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

View File

@ -823,12 +823,13 @@ class ContextCancelled(RemoteActorError):
- (simulating) an IPC transport network outage - (simulating) an IPC transport network outage
- a (malicious) pkt sent specifically to cancel an actor's - a (malicious) pkt sent specifically to cancel an actor's
runtime non-gracefully without ensuring ongoing RPC tasks are runtime non-gracefully without ensuring ongoing RPC tasks are
incrementally cancelled as is done with: incrementally cancelled as is done with::
`Actor`
|_`.cancel()` `Actor`
|_`.cancel_soon()` |_`.cancel()`
|_`._cancel_task()` |_`.cancel_soon()`
|_`._cancel_task()`
''' '''
value: tuple[str, str]|None = self._ipc_msg.canceller value: tuple[str, str]|None = self._ipc_msg.canceller

View File

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

View File

@ -187,7 +187,7 @@ def mk_pdb() -> PdbREPL:
FURTHER, the `pdbp.Pdb` instance is configured to be `trio` FURTHER, the `pdbp.Pdb` instance is configured to be `trio`
"compatible" from a SIGINT handling perspective; we mask out "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: which mostly addresses all issues described in:
- https://github.com/python-trio/trio/issues/1155 - https://github.com/python-trio/trio/issues/1155

View File

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

View File

@ -371,7 +371,7 @@ class MsgCodec(Struct):
A IPC msg interchange format lib's encoder + decoder pair. A IPC msg interchange format lib's encoder + decoder pair.
Pretty much nothing more then delegation to underlying 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 _enc: msgpack.Encoder

View File

@ -453,7 +453,7 @@ class Error(
# omit_defaults=True, # 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 Fields are 1-to-1 meta-data as needed originally by
`RemoteActorError.msgdata: dict` but now are defined here. `RemoteActorError.msgdata: dict` but now are defined here.

View File

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

View File

@ -120,12 +120,12 @@ async def gather_contexts(
None, 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 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. 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**. **no order guarantees**.
This function is somewhat similar to a batch of non-blocking This function is somewhat similar to a batch of non-blocking