Compare commits
No commits in common. "0a3c48fc57989a12fb17baf1072c67834f7287c8" and "37e7470f265dfbaed996ebe45404bfefb7f6478b" have entirely different histories.
0a3c48fc57
...
37e7470f26
1
NEWS.rst
1
NEWS.rst
|
|
@ -78,7 +78,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -16,14 +16,6 @@ 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
|
||||
|
|
@ -45,13 +37,11 @@ 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
|
||||
|
|
@ -65,22 +55,6 @@ 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,
|
||||
|
|
@ -103,54 +77,37 @@ def render_svg(
|
|||
app: Sphinx,
|
||||
src: Path,
|
||||
out: Path,
|
||||
) -> RenderResult:
|
||||
) -> bool:
|
||||
'''
|
||||
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.
|
||||
Maybe (re)render `src` -> `out`, returning
|
||||
`True` when an up-to-date SVG exists after the
|
||||
call.
|
||||
|
||||
'''
|
||||
fresh: bool = (
|
||||
out.exists()
|
||||
and
|
||||
src.stat().st_mtime <= out.stat().st_mtime
|
||||
stale: bool = (
|
||||
not out.exists()
|
||||
or
|
||||
src.stat().st_mtime > out.stat().st_mtime
|
||||
)
|
||||
if fresh:
|
||||
return RenderResult.OK
|
||||
if not stale:
|
||||
return True
|
||||
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 RenderResult.FELL_BACK
|
||||
return RenderResult.NO_OUTPUT
|
||||
return True
|
||||
return False
|
||||
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(tmp)]
|
||||
+ [str(src), str(out)]
|
||||
)
|
||||
try:
|
||||
proc = sp.run(
|
||||
|
|
@ -163,22 +120,18 @@ 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 RenderResult.FAILED
|
||||
return out.exists()
|
||||
if proc.returncode != 0:
|
||||
tmp.unlink(missing_ok=True)
|
||||
log.warning(
|
||||
f'd2 render error for {src.name}:\n'
|
||||
f'{proc.stderr}'
|
||||
)
|
||||
return RenderResult.FAILED
|
||||
# atomic swap-in of the freshly rendered SVG.
|
||||
os.replace(tmp, out)
|
||||
return RenderResult.OK
|
||||
return out.exists()
|
||||
return True
|
||||
|
||||
|
||||
class D2Diagram(SphinxDirective):
|
||||
|
|
@ -216,54 +169,18 @@ class D2Diagram(SphinxDirective):
|
|||
/ _outdir
|
||||
/ f'{src.stem}.svg'
|
||||
)
|
||||
# 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.
|
||||
if not render_svg(self.env.app, src, out):
|
||||
# last resort: emit the raw d2 source.
|
||||
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_text,
|
||||
src_text,
|
||||
src.read_text(),
|
||||
src.read_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(
|
||||
|
|
@ -307,29 +224,12 @@ 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,
|
||||
}
|
||||
|
|
|
|||
30
flake.nix
30
flake.nix
|
|
@ -25,11 +25,11 @@
|
|||
cpython = "python313";
|
||||
venv_dir = "py313";
|
||||
pypkgs = pkgs."${cpython}Packages";
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
|
||||
# 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 = [
|
||||
packages = [
|
||||
# XXX, ensure sh completions activate!
|
||||
pkgs.bashInteractive
|
||||
pkgs.bash-completion
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
pkgs.${cpython}# ?TODO^ how to set from `cpython` above?
|
||||
];
|
||||
|
||||
baseHook = ''
|
||||
shellHook = ''
|
||||
# unmask to debug **this** dev-shell-hook
|
||||
# set -e
|
||||
|
||||
|
|
@ -63,26 +63,6 @@
|
|||
# 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"
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -135,7 +135,6 @@ class Context:
|
|||
communication context.
|
||||
|
||||
(We've also considered other names and ideas:
|
||||
|
||||
- "communicating tasks scope": cts
|
||||
- "distributed task scope": dts
|
||||
- "communicating tasks context": ctc
|
||||
|
|
@ -145,7 +144,6 @@ 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
|
||||
|
|
@ -445,7 +443,6 @@ 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.
|
||||
|
|
@ -557,7 +554,6 @@ 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
|
||||
|
|
@ -1037,8 +1033,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`.
|
||||
|
|
@ -1509,7 +1505,6 @@ 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
|
||||
|
|
@ -2071,7 +2066,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.
|
||||
|
|
|
|||
|
|
@ -824,8 +824,7 @@ 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()`
|
||||
|
|
|
|||
|
|
@ -321,7 +321,6 @@ 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()`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -253,7 +253,6 @@ 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
|
||||
|
|
@ -440,7 +439,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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -252,7 +252,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue