oc_chat
Loading...
Searching...
No Matches
oc_chat.tools._proc Namespace Reference

Functions

None _terminate ("subprocess.Popen" proc)
"Callable[[str], None] | None" _observer_for_one_pipe ("Callable[[str], None] | None" on_line, "Callable[[], Callable[[str], None]] | None" on_line_factory)
None _read_pipe (pipe, "list[str]" sink, "Callable[[str], None] | None" on_line)
"subprocess.CompletedProcess" run_cancellable ("str | list[str]" cmd, *, bool shell=False, "float | None" timeout=None, "dict | None" env=None, "str | None" cwd=None, float poll_interval=0.1, "Callable[[str], None] | None" on_line=None, "Callable[[], Callable[[str], None]] | None" on_line_factory=None, "Callable[[], int | None] | None" on_budget_expired=None)

Detailed Description

Cancellable subprocess execution for tools.

Purpose
-------
:func:`run_cancellable` is a drop-in for ``subprocess.run(capture_output=True,
text=True, timeout=...)`` that additionally polls the process-wide cooperative
tool-cancel flag (:func:`oc_chat.core.cancellation.tool_cancel_requested`) and
**terminates the child** when a Ctrl+C is signalled. Tools that shell out
(``run_command``, the ``oct`` wrappers) route through this so a Ctrl+C actually
stops the underlying process instead of orphaning it — a subprocess, unlike a
pure-Python thread, *can* be killed.

Plan 279 (P1) replaced the single ``communicate()`` drain with one reader thread
per pipe, so a caller can watch the child's output WHILE it runs instead of only
after it exits. The optional ``on_line`` observer is what the status bar's tail
is built from; it is strictly an observer — the capture, the return code, the
cancel path and the timeout path are all exactly what ``communicate()`` produced.

Plan 283 (P1) added the optional ``on_budget_expired`` hook. A timeout here is
two different events wearing one name: the MODEL's clamped ceiling running out,
which must kill the child exactly as it always has, and a HUMAN's budget running
out, which the human may want to extend because they are sitting in front of it.
The hook is how the caller tells the two apart — with no hook this function
behaves byte for byte as it did before the parameter existed.

Responsibilities
----------------
- :func:`run_cancellable` — Popen + one reader thread per pipe + a poll loop that
  terminates the child on cancel (returncode ``-2``, ``[cancelled by user]``
  appended to stderr) or on timeout (raises ``subprocess.TimeoutExpired``, like
  ``subprocess.run``) — unless an ``on_budget_expired`` hook buys more time.
- :func:`_observer_for_one_pipe` — pick the observer one reader will use: a
  fresh one from ``on_line_factory`` when the caller keeps per-stream state, the
  shared ``on_line`` otherwise.
- :func:`_read_pipe` — drain one text pipe line by line into a list, offering
  each line to the optional observer.
- :func:`_terminate` — best-effort terminate-then-kill of a child.

Diagnostics
-----------
Domain: OC_CHAT.TOOLS
Levels:
    L2 — cancel / terminate lifecycle
    L3 — semantic details
    L4 — module load

Contracts
---------
- Returns a :class:`subprocess.CompletedProcess` so callers use ``.stdout`` /
  ``.stderr`` / ``.returncode`` exactly as with ``subprocess.run``.
- The captured text is byte-identical to what ``communicate()`` returned: the
  pipes are opened in text mode with universal newlines, and joining the lines a
  text-mode iterator yields reproduces the stream exactly.
- One reader thread PER PIPE (not one shared drain) is what prevents the
  large-output deadlock that a bare ``poll()`` loop would risk — a full stderr
  buffer can no longer block the stdout reader or vice versa.
- ``on_line`` is an observer and nothing more: it never sees a line the capture
  does not contain, it cannot change the result or the return code, and an
  exception it raises is swallowed so the child is never affected by it.
- ``on_line_factory`` (plan 287) is the same observer contract, bound PER PIPE:
  it is called once for each reader, so an observer that carries state from one
  line to the next — the status bar's private-key armor filter — never has
  stdout's state mixed into stderr's. The binding lives here, in the one place
  that knows how many pipes there are, rather than in a caller guessing at
  thread identity.
- ``on_budget_expired`` is called AT MOST ONCE per run, only when a *timeout*
  was given and actually elapsed. It returns whole seconds to add to the wait,
  or ``None`` to decline; declining terminates the child on exactly the path a
  hookless run takes. An exception from the hook is the conservative answer —
  it is caught and read as a decline, never as an extension, because the hook's
  job is to ask a human and a failed question is not a yes.
- Best-effort kill: the direct child is terminated; on ``shell=True`` a
  grandchild may briefly outlive it (documented limitation).

Dependencies
------------
- oc_diagnostics (structured debug logging via _dbg)
- oc_chat.core.cancellation (lazy; the cooperative tool-cancel flag)

Function Documentation

◆ _observer_for_one_pipe()

"Callable[[str], None] | None" oc_chat.tools._proc._observer_for_one_pipe ( "Callable[[str], None] | None" on_line,
"Callable[[], Callable[[str], None]] | None" on_line_factory )
protected
Return the observer ONE pipe's reader will use (plan 287 P2).

With a factory the caller gets a fresh observer per pipe, which is how an
observer that carries state across lines — the status bar's private-key armor
filter — keeps stdout's state out of stderr's. Without one, every reader
shares the single *on_line*, exactly as before the factory existed. A factory
that raises yields no observer at all rather than a half-built one: the bar
showing nothing is the safe failure, and the child is never affected either
way.

Definition at line 110 of file _proc.py.

Here is the caller graph for this function:

◆ _read_pipe()

None oc_chat.tools._proc._read_pipe ( pipe,
"list[str]" sink,
"Callable[[str], None] | None" on_line )
protected
Drain one text *pipe* line by line into *sink*, offering each to *on_line*.

Iterating a text-mode pipe yields each line as the child flushes it, so a
long-running command is observable while it runs; joining *sink* afterwards
reproduces the stream byte for byte, exactly as ``communicate()`` returned it.

Never raises. A pipe closed underneath the reader (the terminate path) ends
the drain quietly, and an observer that raises is swallowed — a tool's
progress display must never be able to disturb the child or the capture.

Definition at line 136 of file _proc.py.

◆ _terminate()

None oc_chat.tools._proc._terminate ( "subprocess.Popen" proc)
protected
Best-effort terminate-then-kill of *proc* (never raises).

Definition at line 97 of file _proc.py.

Here is the caller graph for this function:

◆ run_cancellable()

"subprocess.CompletedProcess" oc_chat.tools._proc.run_cancellable ( "str | list[str]" cmd,
* ,
bool shell = False,
"float | None" timeout = None,
"dict | None" env = None,
"str | None" cwd = None,
float poll_interval = 0.1,
"Callable[[str], None] | None" on_line = None,
"Callable[[], Callable[[str], None]] | None" on_line_factory = None,
"Callable[[], int | None] | None" on_budget_expired = None )
Run *cmd* like ``subprocess.run(capture_output=True, text=True)`` but
cancellable.

Polls the cooperative tool-cancel flag every *poll_interval* seconds. On
cancel, the child is terminated and a :class:`subprocess.CompletedProcess`
with ``returncode == -2`` and a ``[cancelled by user]`` stderr marker is
returned. On *timeout*, the child is terminated and
:class:`subprocess.TimeoutExpired` is raised (matching ``subprocess.run`` so
existing handlers keep working).

*on_line* (plan 279) is called with each stdout and stderr line as the child
writes it, trailing newline stripped. It is a pure observer: it sees only
what the capture also contains, it cannot change the result, and an exception
it raises is caught and ignored. Callers that pass nothing get exactly the
behaviour this function had before the parameter existed. The caller is
responsible for any filtering the line needs — ``run_command`` redacts each
line with the same filter it applies to the result before the line reaches a
sink, because the terminal must not show what the result would have hidden.

*on_line_factory* (plan 287) is how a caller whose filter carries state gets
ONE observer per pipe instead of one shared by both readers: it is called
once per reader and its return value is that reader's *on_line*. Supplying it
ignores *on_line*; supplying neither is exactly the behaviour this function
had before either parameter existed.

*on_budget_expired* (plan 283) turns the *timeout* from a verdict into a
question, ONCE. When it is supplied and the timeout elapses, the child is
left running while the hook is called; the hook returns whole seconds to
continue for, or ``None`` to decline. A decline — and a hook that raises,
which is read as a decline — terminates the child and raises
:class:`subprocess.TimeoutExpired` exactly as a hookless run does. Callers
that pass nothing get precisely the behaviour this function had before the
parameter existed, which is what keeps the MODEL's clamped ceiling a hard
ceiling: only a human-set budget ever arrives here with a hook attached.

Definition at line 171 of file _proc.py.

Here is the call graph for this function: