mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-28 04:24:51 +02:00
* [eric] ci: gitleaks-ignore the known historical secrets so our branch stops failing on leaks it didnt add * [eric] workflows: restore scheduled-tasks on the workflow line (revert removal, keep windows fixes + 1.1.69) * [eric] workflows: re-apply uncommitted scheduling wip (schedule pill, calendar view, slice) * [eric] ops: gitignore dev-team local state files * [eric] ops: backlog item for download-tracking visibility * [eric] ci: allowlist the cdp-routes redaction-test token in gitleaks * [aidan] feat/scheduled-tasks: keep step labels in sync on edit and show chevron on every step * [aidan] fix: schedule time in chat * [aidan] ux/workflows: add workflow step removal (#91) * [aidan] feat/scheduled-tasks: remember workflow tool permissions across runs * [aidan] feat/task-scheduling: add hourly and minute (15-min minimum) schedule intervals (#93) * [aidan] feat/scheduled-tasks: calendar, rename, and edit workflows (#94) * [aidan] bug: fix schedule button * [aidan] fix/agent-errors: surface provider rate limits * [aidan] ux/cards: click-to-rename for chat and workflow titles Single-click a card's title to enter edit mode inline. Commit on Enter/blur, cancel on Escape. Rename persists via PATCH for workflows and sessions. * [aidan] feat/workflows: seed build prompt for zero-step workflows When a new workflow has no steps, seed the agent with a prompt asking the user to describe what the workflow should do, rather than starting blank. * [aidan] feat/workflows: add-to-schedule popover for unscheduled workflows Clicking the "+" on an unscheduled workflow row opens a popover with two options: - Keep this schedule: enables the workflow's existing cadence and moves it to Scheduled - Change schedule: opens the scheduling editor to pick a different time * [aidan] ux/workflows: wire add-to-schedule popover and simplify New button - Made the "+" icon on unscheduled workflow rows clickable, opening a popover to keep or change the schedule - Removed AddIcon from toolbar "New" button (now reads "New" instead of "+ New") * [aidan] fix/scheduled-tasks: open schedule calendar when Schedule pill clicked Fixed the Schedule pill click being swallowed by the toolbar's dismiss handler. Exempted the toolbar pills via data-toolbar-pills so their click handlers fire. * [aidan] ux/workflows: open New workflow in agent build chat instead of empty card When creating a new workflow from the hub, open it in edit_agent view (with the agent builder chat) instead of a preview card. The workflow is created on the backend first so the embedded session has a real ID. * [aidan] feat/workflow-edit: add draft testing save flow * [aidan] ux/chat: remove continue chat button * [aidan] ux/workflows: polish workflow card interactions * [aidan] fix/workflow-scheduling: save unscheduled workflows as drafts * aidan ui: schedule naming changes * [aidan] ui: tool calling desc/naming * [aidan] ui: calendar sidebar naming * [aidan] ui: fix stop viewing closing chat * [aidan] feat/workflows: auto-name workflows and polish the build flow (#95) * [aidan] feat/workflow-auto-naming: auto-generate workflow titles from steps Generate a title + description from a workflow's steps (one aux call, reused for step labels) whenever it is still auto_named, so a workflow built in the Edit Agent names itself on commit instead of staying "New workflow". A manual rename sets auto_named=False and is never overwritten. Stream the aux call (non-streaming drops content on some 9router lanes) and fall back to a step-derived title when the model is unavailable. * [aidan] feat/workflows: hide unsaved new workflows until first save A brand-new "+ New" workflow is created with unsaved=true and kept out of the hub's scheduled/unscheduled lists while the user is still building it in the Edit Agent. The first commit (Save) clears the flag and the workflow appears. Every other create path stays visible immediately. * [aidan] ux/workflows: remove redundant save workflow button The Edit Agent already has Discard/Save controls in its strip, so the header "Save Workflow" button was a duplicate save path. Remove it and its pulse/edit-session-id wiring; the model/time subtitle stays. * [aidan] ux/workflows: animate title on auto-rename Wrap the workflow card title in the same Typewriter the chat card uses, so when the auto-generated name replaces the placeholder after Save it retypes letter-by-letter. Gated on a real (non-placeholder) title so it never animates on mount or for already-named workflows. * [aidan] ux/workflows: animate sidebar title on auto-rename Wrap the calendar hub's sidebar row title in the same Typewriter the workflow card uses, so a title that auto-renames retypes letter-by-letter in the sidebar too. Extract the placeholder/isRealTitle guard into the shared workflowVisuals so the card and sidebar stay in sync. * [aidan] fix/workflows: connect watch tether, keep watched chat open, wire draft run/history * [aidan] ui: grey out chat pill when not selected * [aidan] ui: fix running agent display * [aidan] feat/history-popover: add chat history and scheduled tasks run log tabs (#96) * [aidan] ux/schedule: toast when calendar view already open on expand * [aidan] ui: fix popover descs * [aidan] feat/workflow-runs: add pause, resume, and stop controls for live runs * [aidan] feat/schedule-calendar: add calendar occurrences endpoint and concrete timezones * [aidan] feat/workflows: require at least one step to save a workflow * [aidan] ux/edit-agent: hide Discard for an unsaved new workflow * [aidan] ux/edit-agent: move fix-prefix card below the step list * [aidan] feat/workflows: toast when an unattended scheduled run starts * [aidan] fix/dashboard-tethers: anchor workflow-sidecar tethers to measured card rects * [aidan] feat/workflows: validate steps before scheduling and keep chat tool memory * [aidan] feat/mcp-suggestions: dismissable integration banner with per-session cooldown * [aidan] feat/workflows: add scheduled-run "running now" toast with click-to-view (#97) * [aidan] ux/workflows: surface paused state on card, sidebar, and calendar; tidy run history * [aidan] feat/mcp-suggestions: suggest both Google and Microsoft when provider is ambiguous * [aidan] fix/agent-tokens: friendly out-of-tokens card across all agent surfaces * [aidan] feat/workflow-model: persist edit-agent model on save with switch notice and fresh drafts * [aidan] fix/workflow-chat: force stop on watched run mirrors workflow card stop * [aidan] fix/workflow-cards: keep watched run tethered on finish to avoid duplicate chat * [aidan] feat/schedule-calendar: mark current time with a now line in week view * [aidan] refactor/private-names: rename error and schedule classifiers from _ to p_ * [aidan] feat/scheduled-tasks: agent workflow scheduling and in-chat convert (#98) * [aidan] feat/workflow-suggest: nudge user to convert repeatable chat to workflow Add SuggestConvertToWorkflow MCP tool that agents call at the end of a task when they've completed something worth repeating (daily report, weekly check, recurring data pull). Frontend detects the tool call and glows the "Convert to workflow" button 3 times to draw the eye. When user clicks it, the suggested cadence (e.g. "every weekday at 9am") is stored in the draft and seeded into the scheduling agent's first prompt, so the agent can act on the suggestion rather than asking the user again. Tool is never auto-called — agents decide when a task is genuinely repeatable (not debugging, creative work, one-off lookup). Tool description emphasizes sparse, high-confidence use only (once per session max). Files changed: - backend/apps/agents/schedule_mcp_server.py: add SuggestConvertToWorkflow tool - frontend/src/shared/mcpToolMeta.ts: add label for new tool - frontend/src/app/pages/Dashboard/cards/AgentCard.tsx: detect suggestion in session messages, show+glow "Convert to workflow" button, pass cadence to draft - frontend/src/shared/state/workflowsSlice.ts: add suggested_cadence field to Workflow interface - frontend/src/app/pages/Workflows/SchedulingView.tsx: seed scheduling agent prompt with suggested cadence hint * [aidan] feat/agent-scheduling: route recurring asks through native workflows, deny claude cron skill * [aidan] feat/workflow-convert: in-chat convert popup and auto-open scheduled workflow card * [aidan] ux/calendar-page: schedule calendar restyle + popover fixes (#99) * [aidan] fix/dashboard-delete: remove workflows calendar panel on delete key * [aidan] ux/workflows-calendar: restyle hub, fix today highlight, add toolbar toggle * [aidan] ux/schedule-popover: compact density, fix sticky header bleed, add header spacing * [aidan] ux/schedule-calendar: hollow ring dot for past fires in month view * [aidan] ux/schedule-calendar: clickable +N more opens day's full run list * [aidan] feat/run-log-filters: add success and skipped pills to scheduled task history * [aidan] fix/convert-button: stop drag capture so convert-to-workflow click fires * [aidan] ux/calendar-card: match border color and radius to chat and workflow cards * [aidan] fix/minimap: render missed-runs card on the minimap * [aidan] ux/run-sparkline: simplify tooltip to plain run tally * [aidan] ux/run-history: collapse expanded run view to one clickable line * [aidan] ux/calendar-card: match corner radius to browser cards * [aidan] feat/workflows: launch-time scheduling UX and workflow-card polish (#101) * [aidan] feat/schedule-list: lazy-load list view via scroll sentinel * [aidan] feat/missed-runs: launch toast with per-workflow counts and pan-to-card * [aidan] fix/dashboard-tethers: keep watching line anchored on canvas zoom * [aidan] feat/scheduled-tasks: review missed runs at launch instead of auto-firing on_missed * [aidan] refactor/workflow-cards: use radius and status design tokens, polish card chrome * [aidan] ux/agent-card: keep convert-to-workflow visible during runs with mid-turn toast * [aidan] ux/mcp-bubble: drop redundant verb label when a workflow label is shown * [aidan] chore/backend: remove stale explanatory comments * [aidan] fix/workflows-hub: load workflows on hub mount so calendar fills at launch * [aidan] feat/workflows: generate title, description, step labels at convert time * [aidan] fix/tidy-layout: include workflows hub in tidy and fit-to-view * [aidan] feat/schedule-list: window long list via measured-height virtualizer * [aidan] ux/workflows-hub: remove time-saved badge from calendar header * [aidan] fix/types: add missing semantic-type labels and drop stray fade arg * [aidan] feat/schedule: pin monthly day-of-month and honor repeat-every intervals * [aidan] feat/schedule: inherit source-session tool surface for scheduled runs * [aidan] ux/calendar: restack hour-cell events as bars with overflow affordance * [aidan] feat/calendar: open the run card when clicking a scheduled occurrence * [aidan] ux/missed-runs: add per-group select-all toggle and rename skip action * [aidan] feat: new scheduled task design ported * [aidan] ui: sidebar reorder, repeat controls on schedule card * [aidan] ui: sidebar, scheduling time * [aidan] feat/schedule: pin monthly last-day-of-month * [aidan] feat/steps: per-step enable toggle * [aidan] feat/workflows: per-workflow color swatch * [aidan] feat/trash: soft-delete workflows with restore and purge * [aidan] feat/run-monitor: live run monitor card on the canvas * [aidan] feat/run-context: attach a run as removable chat context * [aidan] feat/compose: new-workflow landing page and auto-commit build flow * [aidan] ui/workflows: dark mode and design-system cohesion * [aidan] ui/calendar: overflow popover, condensed week view, scroll fix * [aidan] feat/home: ongoing runs, missed review, and accurate Coming-up counts * [aidan] fix/run-status: sync ongoing runs and heal stuck/interrupted runs * [aidan] ux/schedule: last-day-of-month UI, Run-at time typing, interval input * [aidan] ux/workflows: default window size and toolbar icon * [aidan] fix/schedule: measure ran_late from start and anchor recurrences to created_at * [aidan] feat/calendar: render fire times from backend, drop JS recurrence reimpl * [aidan] chore/dashboard: drop dead configure/missed-run cards, refetch on reconnect * [aidan] chore/agent-card: remove unreachable convert-to-workflow action * [aidan] fix/workflows: don't bump updated_at on a no-op draft commit so viewing a workflow doesn't reorder the sidebar * [aidan] feat/schedule: warn when scheduling a workflow that has no steps * [aidan] fix/selection-tool: never select the workflows app, and exit the tool on Escape without dropping selections * [aidan] ux/compose: diversify new-workflow starter prompts across personas * [aidan] ux/run-monitor: spawn the run card a bit farther right of the workflows app * [aidan] fix/schedule: harden run recovery and storage writes against crashes * [aidan] ux/compose: restyle new-workflow starters as a clean pill cluster with rich prompts * [aidan] fix/workflows: optimistically apply edits so the schedule banner updates instantly * [aidan] ui/workflows: three-tone surface depth so the window lifts off the canvas in both themes * [aidan] ui/workflows: close buttons turn red on hover, matching the chat card * [aidan] test/schedule: cover executor pipeline, storage durability, and recurrence gaps * [aidan] fix: remove package-lock json * [aidan] fix/workflows-compose: keep compose view until edit agent replies * [aidan] feat/workflows: auto-generate workflow + step titles with typewriter animation * [eric] deps: restore frontend/package-lock.json (PR #105 deletion broke npm ci) --------- Co-authored-by: Eric <ciregenz@berkeley.edu> Co-authored-by: cire <134991075+ciregenz@users.noreply.github.com>
773 lines
26 KiB
Python
773 lines
26 KiB
Python
from __future__ import annotations
|
|
|
|
import collections.abc as cabc
|
|
import contextlib
|
|
import io
|
|
import os
|
|
import pdb
|
|
import shlex
|
|
import sys
|
|
import tempfile
|
|
import typing as t
|
|
from types import TracebackType
|
|
|
|
from . import _compat
|
|
from . import formatting
|
|
from . import termui
|
|
from . import utils
|
|
from ._compat import _find_binary_reader
|
|
|
|
if t.TYPE_CHECKING:
|
|
from _typeshed import ReadableBuffer
|
|
|
|
from .core import Command
|
|
|
|
if sys.platform == "win32":
|
|
CaptureMode: t.TypeAlias = t.Literal["sys"] # pyright: ignore[reportRedeclaration]
|
|
else:
|
|
CaptureMode: t.TypeAlias = t.Literal["sys", "fd"] # pyright: ignore[reportRedeclaration]
|
|
ExceptionInfo: t.TypeAlias = tuple[type[BaseException], BaseException, TracebackType]
|
|
|
|
|
|
class EchoingStdin:
|
|
_input: t.BinaryIO
|
|
_output: t.BinaryIO
|
|
_paused: bool
|
|
|
|
def __init__(self, input: t.BinaryIO, output: t.BinaryIO) -> None:
|
|
self._input = input
|
|
self._output = output
|
|
self._paused = False
|
|
|
|
def __getattr__(self, x: str) -> t.Any:
|
|
return getattr(self._input, x)
|
|
|
|
def _echo(self, rv: bytes) -> bytes:
|
|
if not self._paused:
|
|
self._output.write(rv)
|
|
|
|
return rv
|
|
|
|
def read(self, n: int = -1) -> bytes:
|
|
return self._echo(self._input.read(n))
|
|
|
|
def read1(self, n: int = -1) -> bytes:
|
|
return self._echo(self._input.read1(n)) # type: ignore
|
|
|
|
def readline(self, n: int = -1) -> bytes:
|
|
return self._echo(self._input.readline(n))
|
|
|
|
def readlines(self) -> list[bytes]:
|
|
return [self._echo(x) for x in self._input.readlines()]
|
|
|
|
def __iter__(self) -> cabc.Iterator[bytes]:
|
|
return iter(self._echo(x) for x in self._input)
|
|
|
|
def __repr__(self) -> str:
|
|
return repr(self._input)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _pause_echo(stream: EchoingStdin | None) -> cabc.Generator[None]:
|
|
if stream is None:
|
|
yield
|
|
else:
|
|
stream._paused = True
|
|
yield
|
|
stream._paused = False
|
|
|
|
|
|
class _FDCapture:
|
|
"""Redirect a file descriptor to a temporary file for capture.
|
|
|
|
Saves the current target of *targetfd* via :func:`os.dup`, then
|
|
redirects it to a temporary file via :func:`os.dup2`. On
|
|
:meth:`stop`, restores the original ``fd`` and returns the captured
|
|
bytes. Inspired by Pytest's ``FDCapture``.
|
|
|
|
.. versionadded:: 8.4.0
|
|
"""
|
|
|
|
_targetfd: int
|
|
saved_fd: int
|
|
_tmpfile: t.BinaryIO | None
|
|
|
|
def __init__(self, targetfd: int) -> None:
|
|
self._targetfd = targetfd
|
|
self.saved_fd = -1
|
|
self._tmpfile = None
|
|
|
|
def start(self) -> None:
|
|
self.saved_fd = os.dup(self._targetfd)
|
|
self._tmpfile = tempfile.TemporaryFile(buffering=0)
|
|
os.dup2(self._tmpfile.fileno(), self._targetfd)
|
|
|
|
def stop(self) -> bytes:
|
|
assert self._tmpfile is not None, "_FDCapture.start() was not called"
|
|
os.dup2(self.saved_fd, self._targetfd)
|
|
os.close(self.saved_fd)
|
|
self.saved_fd = -1
|
|
self._tmpfile.seek(0)
|
|
data = self._tmpfile.read()
|
|
self._tmpfile.close()
|
|
self._tmpfile = None
|
|
return data
|
|
|
|
|
|
class BytesIOCopy(io.BytesIO):
|
|
"""Patch ``io.BytesIO`` to let the written stream be copied to another.
|
|
|
|
.. versionadded:: 8.2
|
|
"""
|
|
|
|
copy_to: io.BytesIO
|
|
|
|
def __init__(self, copy_to: io.BytesIO) -> None:
|
|
super().__init__()
|
|
self.copy_to = copy_to
|
|
|
|
def flush(self) -> None:
|
|
super().flush()
|
|
self.copy_to.flush()
|
|
|
|
def write(self, b: ReadableBuffer) -> int:
|
|
self.copy_to.write(b)
|
|
return super().write(b)
|
|
|
|
|
|
class StreamMixer:
|
|
"""Mixes `<stdout>` and `<stderr>` streams.
|
|
|
|
The result is available in the ``output`` attribute.
|
|
|
|
.. versionadded:: 8.2
|
|
"""
|
|
|
|
output: io.BytesIO
|
|
stdout: BytesIOCopy
|
|
stderr: BytesIOCopy
|
|
|
|
def __init__(self) -> None:
|
|
self.output = io.BytesIO()
|
|
self.stdout = BytesIOCopy(copy_to=self.output)
|
|
self.stderr = BytesIOCopy(copy_to=self.output)
|
|
|
|
|
|
class _NamedTextIOWrapper(io.TextIOWrapper):
|
|
"""A :class:`~io.TextIOWrapper` with custom ``name`` and ``mode``
|
|
that does not close its underlying buffer.
|
|
|
|
When ``CliRunner`` runs in ``fd`` mode, ``_original_fd`` is patched to
|
|
point at the saved (pre-redirection) ``fd``, so C-level consumers that call
|
|
:meth:`fileno` (like ``faulthandler`` or ``subprocess``) keep working. In
|
|
the default ``sys`` mode ``_original_fd`` stays at ``-1`` and
|
|
:meth:`fileno` raises :exc:`io.UnsupportedOperation`, matching the
|
|
pre-``8.3.3`` behavior.
|
|
"""
|
|
|
|
_name: str
|
|
_mode: str
|
|
_original_fd: int
|
|
|
|
def __init__(
|
|
self,
|
|
buffer: t.BinaryIO,
|
|
name: str,
|
|
mode: str,
|
|
**kwargs: t.Any,
|
|
) -> None:
|
|
super().__init__(buffer, **kwargs)
|
|
self._name = name
|
|
self._mode = mode
|
|
self._original_fd = -1
|
|
|
|
def close(self) -> None:
|
|
"""The buffer this object contains belongs to some other object,
|
|
so prevent the default ``__del__`` implementation from closing
|
|
that buffer.
|
|
|
|
.. versionadded:: 8.3.2
|
|
"""
|
|
|
|
def fileno(self) -> int:
|
|
"""Return the file descriptor of the saved original stream when
|
|
``CliRunner`` runs in ``fd`` mode. Otherwise delegate to
|
|
:class:`~io.TextIOWrapper`, which raises
|
|
:exc:`io.UnsupportedOperation` for a ``BytesIO``-backed buffer.
|
|
"""
|
|
if self._original_fd >= 0:
|
|
return self._original_fd
|
|
return super().fileno()
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return self._name
|
|
|
|
@property
|
|
def mode(self) -> str:
|
|
return self._mode
|
|
|
|
|
|
def make_input_stream(
|
|
input: str | bytes | t.IO[t.Any] | None, charset: str
|
|
) -> t.BinaryIO:
|
|
# Is already an input stream.
|
|
if hasattr(input, "read"):
|
|
rv = _find_binary_reader(t.cast("t.IO[t.Any]", input))
|
|
|
|
if rv is not None:
|
|
return rv
|
|
|
|
raise TypeError("Could not find binary reader for input stream.")
|
|
|
|
if input is None:
|
|
input = b""
|
|
elif isinstance(input, str):
|
|
input = input.encode(charset)
|
|
|
|
return io.BytesIO(input)
|
|
|
|
|
|
class Result:
|
|
"""Holds the captured result of an invoked CLI script.
|
|
|
|
:param runner: The runner that created the result
|
|
:param stdout_bytes: The standard output as bytes.
|
|
:param stderr_bytes: The standard error as bytes.
|
|
:param output_bytes: A mix of ``stdout_bytes`` and ``stderr_bytes``, as the
|
|
user would see it in its terminal.
|
|
:param return_value: The value returned from the invoked command.
|
|
:param exit_code: The exit code as integer.
|
|
:param exception: The exception that happened if one did.
|
|
:param exc_info: Exception information (exception type, exception instance,
|
|
traceback type).
|
|
|
|
.. versionchanged:: 8.2
|
|
``stderr_bytes`` no longer optional, ``output_bytes`` introduced and
|
|
``mix_stderr`` has been removed.
|
|
|
|
.. versionadded:: 8.0
|
|
Added ``return_value``.
|
|
"""
|
|
|
|
runner: CliRunner
|
|
stdout_bytes: bytes
|
|
stderr_bytes: bytes
|
|
output_bytes: bytes
|
|
return_value: t.Any
|
|
exit_code: int
|
|
exception: BaseException | None
|
|
exc_info: ExceptionInfo | None
|
|
|
|
def __init__(
|
|
self,
|
|
runner: CliRunner,
|
|
stdout_bytes: bytes,
|
|
stderr_bytes: bytes,
|
|
output_bytes: bytes,
|
|
return_value: t.Any,
|
|
exit_code: int,
|
|
exception: BaseException | None,
|
|
exc_info: ExceptionInfo | None = None,
|
|
) -> None:
|
|
self.runner = runner
|
|
self.stdout_bytes = stdout_bytes
|
|
self.stderr_bytes = stderr_bytes
|
|
self.output_bytes = output_bytes
|
|
self.return_value = return_value
|
|
self.exit_code = exit_code
|
|
self.exception = exception
|
|
self.exc_info = exc_info
|
|
|
|
@property
|
|
def output(self) -> str:
|
|
"""The terminal output as unicode string, as the user would see it.
|
|
|
|
.. versionchanged:: 8.2
|
|
No longer a proxy for ``self.stdout``. Now has its own independent stream
|
|
that is mixing `<stdout>` and `<stderr>`, in the order they were written.
|
|
"""
|
|
return self.output_bytes.decode(self.runner.charset, "replace").replace(
|
|
"\r\n", "\n"
|
|
)
|
|
|
|
@property
|
|
def stdout(self) -> str:
|
|
"""The standard output as unicode string."""
|
|
return self.stdout_bytes.decode(self.runner.charset, "replace").replace(
|
|
"\r\n", "\n"
|
|
)
|
|
|
|
@property
|
|
def stderr(self) -> str:
|
|
"""The standard error as unicode string.
|
|
|
|
.. versionchanged:: 8.2
|
|
No longer raise an exception, always returns the `<stderr>` string.
|
|
"""
|
|
return self.stderr_bytes.decode(self.runner.charset, "replace").replace(
|
|
"\r\n", "\n"
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
exc_str = repr(self.exception) if self.exception else "okay"
|
|
return f"<{type(self).__name__} {exc_str}>"
|
|
|
|
|
|
class CliRunner:
|
|
"""The CLI runner provides functionality to invoke a Click command line
|
|
script for unittesting purposes in a isolated environment. This only
|
|
works in single-threaded systems without any concurrency as it changes the
|
|
global interpreter state.
|
|
|
|
:param charset: the character set for the input and output data.
|
|
:param env: a dictionary with environment variables for overriding.
|
|
:param echo_stdin: if this is set to `True`, then reading from `<stdin>` writes
|
|
to `<stdout>`. This is useful for showing examples in
|
|
some circumstances. Note that regular prompts
|
|
will automatically echo the input.
|
|
:param catch_exceptions: Whether to catch any exceptions other than
|
|
``SystemExit`` when running :meth:`~CliRunner.invoke`.
|
|
:param capture: Selects the output capture strategy. ``sys`` (default)
|
|
captures Python-level writes only and leaves
|
|
:meth:`sys.stdout.fileno` raising :exc:`io.UnsupportedOperation`, so
|
|
user code that calls :func:`os.dup2` on ``sys.stdout.fileno()`` cannot
|
|
clobber the host runner's stdout. ``fd`` redirects file descriptors
|
|
``1`` and ``2`` via :func:`os.dup2` to a temporary file, also catching
|
|
output from stale stream references, C extensions, and subprocesses.
|
|
``fd`` is not supported on Windows.
|
|
|
|
.. versionchanged:: 8.4.0
|
|
Added the ``capture`` parameter. The default ``sys`` mode no longer
|
|
exposes the original fd through :meth:`fileno`, reverting the change
|
|
introduced in ``8.3.3`` that broke Pytest's ``fd``-level capture
|
|
teardown. Use ``capture="fd"`` to restore that behavior with proper
|
|
isolation. :issue:`3384`
|
|
|
|
.. versionchanged:: 8.2
|
|
Added the ``catch_exceptions`` parameter.
|
|
|
|
.. versionchanged:: 8.2
|
|
``mix_stderr`` parameter has been removed.
|
|
"""
|
|
|
|
charset: str
|
|
env: cabc.Mapping[str, str | None]
|
|
echo_stdin: bool
|
|
catch_exceptions: bool
|
|
capture: CaptureMode
|
|
|
|
def __init__(
|
|
self,
|
|
charset: str = "utf-8",
|
|
env: cabc.Mapping[str, str | None] | None = None,
|
|
echo_stdin: bool = False,
|
|
catch_exceptions: bool = True,
|
|
capture: CaptureMode = "sys",
|
|
) -> None:
|
|
if capture not in {"sys", "fd"}:
|
|
raise ValueError(
|
|
f"capture={capture!r} is not valid. Choose from 'sys' or 'fd'."
|
|
)
|
|
if capture == "fd" and sys.platform == "win32":
|
|
raise ValueError(
|
|
f"capture={capture!r} is not supported on Windows. Use 'sys'."
|
|
)
|
|
self.charset = charset
|
|
self.env = env or {}
|
|
self.echo_stdin = echo_stdin
|
|
self.catch_exceptions = catch_exceptions
|
|
self.capture = capture
|
|
|
|
def get_default_prog_name(self, cli: Command) -> str:
|
|
"""Given a command object it will return the default program name
|
|
for it. The default is the `name` attribute or ``"root"`` if not
|
|
set.
|
|
"""
|
|
return cli.name or "root"
|
|
|
|
def make_env(
|
|
self, overrides: cabc.Mapping[str, str | None] | None = None
|
|
) -> cabc.Mapping[str, str | None]:
|
|
"""Returns the environment overrides for invoking a script."""
|
|
rv = dict(self.env)
|
|
if overrides:
|
|
rv.update(overrides)
|
|
return rv
|
|
|
|
@contextlib.contextmanager
|
|
def isolation(
|
|
self,
|
|
input: str | bytes | t.IO[t.Any] | None = None,
|
|
env: cabc.Mapping[str, str | None] | None = None,
|
|
color: bool = False,
|
|
) -> cabc.Generator[tuple[io.BytesIO, io.BytesIO, io.BytesIO]]:
|
|
"""A context manager that sets up the isolation for invoking of a
|
|
command line tool. This sets up `<stdin>` with the given input data
|
|
and `os.environ` with the overrides from the given dictionary.
|
|
This also rebinds some internals in Click to be mocked (like the
|
|
prompt functionality).
|
|
|
|
This is automatically done in the :meth:`invoke` method.
|
|
|
|
:param input: the input stream to put into `sys.stdin`.
|
|
:param env: the environment overrides as dictionary.
|
|
:param color: whether the output should contain color codes. The
|
|
application can still override this explicitly.
|
|
|
|
.. versionadded:: 8.2
|
|
An additional output stream is returned, which is a mix of
|
|
`<stdout>` and `<stderr>` streams.
|
|
|
|
.. versionchanged:: 8.2
|
|
Always returns the `<stderr>` stream.
|
|
|
|
.. versionchanged:: 8.0
|
|
`<stderr>` is opened with ``errors="backslashreplace"``
|
|
instead of the default ``"strict"``.
|
|
|
|
.. versionchanged:: 4.0
|
|
Added the ``color`` parameter.
|
|
"""
|
|
bytes_input = make_input_stream(input, self.charset)
|
|
echo_input = None
|
|
|
|
old_stdin = sys.stdin
|
|
old_stdout = sys.stdout
|
|
old_stderr = sys.stderr
|
|
old_forced_width = formatting.FORCED_WIDTH
|
|
formatting.FORCED_WIDTH = 80
|
|
|
|
env = self.make_env(env)
|
|
|
|
stream_mixer = StreamMixer()
|
|
|
|
if self.echo_stdin:
|
|
bytes_input = echo_input = t.cast(
|
|
t.BinaryIO, EchoingStdin(bytes_input, stream_mixer.stdout)
|
|
)
|
|
|
|
sys.stdin = text_input = _NamedTextIOWrapper(
|
|
bytes_input, encoding=self.charset, name="<stdin>", mode="r"
|
|
)
|
|
|
|
if self.echo_stdin:
|
|
# Force unbuffered reads, otherwise TextIOWrapper reads a
|
|
# large chunk which is echoed early.
|
|
text_input._CHUNK_SIZE = 1 # type: ignore
|
|
|
|
sys.stdout = _NamedTextIOWrapper(
|
|
stream_mixer.stdout,
|
|
encoding=self.charset,
|
|
name="<stdout>",
|
|
mode="w",
|
|
)
|
|
|
|
sys.stderr = _NamedTextIOWrapper(
|
|
stream_mixer.stderr,
|
|
encoding=self.charset,
|
|
name="<stderr>",
|
|
mode="w",
|
|
errors="backslashreplace",
|
|
)
|
|
|
|
@_pause_echo(echo_input) # type: ignore
|
|
def visible_input(prompt: str | None = None) -> str:
|
|
sys.stdout.write(prompt or "")
|
|
try:
|
|
val = next(text_input).rstrip("\r\n")
|
|
except StopIteration as e:
|
|
raise EOFError() from e
|
|
sys.stdout.write(f"{val}\n")
|
|
sys.stdout.flush()
|
|
return val
|
|
|
|
@_pause_echo(echo_input) # type: ignore
|
|
def hidden_input(prompt: str | None = None) -> str:
|
|
sys.stdout.write(f"{prompt or ''}\n")
|
|
sys.stdout.flush()
|
|
try:
|
|
return next(text_input).rstrip("\r\n")
|
|
except StopIteration as e:
|
|
raise EOFError() from e
|
|
|
|
@_pause_echo(echo_input) # type: ignore
|
|
def _getchar(echo: bool) -> str:
|
|
char = sys.stdin.read(1)
|
|
|
|
if echo:
|
|
sys.stdout.write(char)
|
|
|
|
sys.stdout.flush()
|
|
return char
|
|
|
|
default_color = color
|
|
|
|
def should_strip_ansi(
|
|
stream: t.IO[t.Any] | None = None, color: bool | None = None
|
|
) -> bool:
|
|
if color is None:
|
|
return not default_color
|
|
return not color
|
|
|
|
old_visible_prompt_func = termui.visible_prompt_func
|
|
old_hidden_prompt_func = termui.hidden_prompt_func
|
|
old__getchar_func = termui._getchar
|
|
old_should_strip_ansi = utils.should_strip_ansi # type: ignore
|
|
old__compat_should_strip_ansi = _compat.should_strip_ansi
|
|
old_pdb_init = pdb.Pdb.__init__
|
|
termui.visible_prompt_func = visible_input
|
|
termui.hidden_prompt_func = hidden_input
|
|
termui._getchar = _getchar
|
|
utils.should_strip_ansi = should_strip_ansi # type: ignore
|
|
_compat.should_strip_ansi = should_strip_ansi
|
|
|
|
def _patched_pdb_init(
|
|
self: pdb.Pdb,
|
|
completekey: str = "tab",
|
|
stdin: t.IO[str] | None = None,
|
|
stdout: t.IO[str] | None = None,
|
|
**kwargs: t.Any,
|
|
) -> None:
|
|
"""Default ``pdb.Pdb`` to real terminal streams during
|
|
``CliRunner`` isolation.
|
|
|
|
Without this patch, ``pdb.Pdb.__init__`` inherits from
|
|
``cmd.Cmd`` which falls back to ``sys.stdin``/``sys.stdout``
|
|
when no explicit streams are provided. During isolation
|
|
those are ``BytesIO``-backed wrappers, so the debugger
|
|
reads from an empty buffer and writes to captured output,
|
|
making interactive debugging impossible.
|
|
|
|
By defaulting to ``sys.__stdin__``/``sys.__stdout__`` (the
|
|
original terminal streams Python preserves regardless of
|
|
redirection), debuggers can interact with the user while
|
|
``click.echo`` output is still captured normally.
|
|
|
|
This covers ``pdb.set_trace()``, ``breakpoint()``,
|
|
``pdb.post_mortem()``, and debuggers that subclass
|
|
``pdb.Pdb`` (ipdb, pdbpp). Explicit ``stdin``/``stdout``
|
|
arguments are honored and not overridden. Debuggers that
|
|
do not subclass ``pdb.Pdb`` (pudb, debugpy) are not
|
|
covered.
|
|
"""
|
|
if stdin is None:
|
|
stdin = sys.__stdin__
|
|
if stdout is None:
|
|
stdout = sys.__stdout__
|
|
old_pdb_init(
|
|
self, completekey=completekey, stdin=stdin, stdout=stdout, **kwargs
|
|
)
|
|
|
|
pdb.Pdb.__init__ = _patched_pdb_init # type: ignore[assignment]
|
|
|
|
old_env = {}
|
|
try:
|
|
for key, value in env.items():
|
|
old_env[key] = os.environ.get(key)
|
|
if value is None:
|
|
try:
|
|
del os.environ[key]
|
|
except Exception:
|
|
pass
|
|
else:
|
|
os.environ[key] = value
|
|
yield (stream_mixer.stdout, stream_mixer.stderr, stream_mixer.output)
|
|
finally:
|
|
for key, value in old_env.items():
|
|
if value is None:
|
|
try:
|
|
del os.environ[key]
|
|
except Exception:
|
|
pass
|
|
else:
|
|
os.environ[key] = value
|
|
sys.stdout = old_stdout
|
|
sys.stderr = old_stderr
|
|
sys.stdin = old_stdin
|
|
termui.visible_prompt_func = old_visible_prompt_func
|
|
termui.hidden_prompt_func = old_hidden_prompt_func
|
|
termui._getchar = old__getchar_func
|
|
utils.should_strip_ansi = old_should_strip_ansi # type: ignore
|
|
_compat.should_strip_ansi = old__compat_should_strip_ansi
|
|
formatting.FORCED_WIDTH = old_forced_width
|
|
pdb.Pdb.__init__ = old_pdb_init # type: ignore[method-assign]
|
|
|
|
def invoke(
|
|
self,
|
|
cli: Command,
|
|
args: str | cabc.Sequence[str] | None = None,
|
|
input: str | bytes | t.IO[t.Any] | None = None,
|
|
env: cabc.Mapping[str, str | None] | None = None,
|
|
catch_exceptions: bool | None = None,
|
|
color: bool = False,
|
|
**extra: t.Any,
|
|
) -> Result:
|
|
"""Invokes a command in an isolated environment. The arguments are
|
|
forwarded directly to the command line script, the `extra` keyword
|
|
arguments are passed to the :meth:`~clickpkg.Command.main` function of
|
|
the command.
|
|
|
|
This returns a :class:`Result` object.
|
|
|
|
:param cli: the command to invoke
|
|
:param args: the arguments to invoke. It may be given as an iterable
|
|
or a string. When given as string it will be interpreted
|
|
as a Unix shell command. More details at
|
|
:func:`shlex.split`.
|
|
:param input: the input data for `sys.stdin`.
|
|
:param env: the environment overrides.
|
|
:param catch_exceptions: Whether to catch any other exceptions than
|
|
``SystemExit``. If :data:`None`, the value
|
|
from :class:`CliRunner` is used.
|
|
:param extra: the keyword arguments to pass to :meth:`main`.
|
|
:param color: whether the output should contain color codes. The
|
|
application can still override this explicitly.
|
|
|
|
.. versionadded:: 8.2
|
|
The result object has the ``output_bytes`` attribute with
|
|
the mix of ``stdout_bytes`` and ``stderr_bytes``, as the user would
|
|
see it in its terminal.
|
|
|
|
.. versionchanged:: 8.2
|
|
The result object always returns the ``stderr_bytes`` stream.
|
|
|
|
.. versionchanged:: 8.0
|
|
The result object has the ``return_value`` attribute with
|
|
the value returned from the invoked command.
|
|
|
|
.. versionchanged:: 4.0
|
|
Added the ``color`` parameter.
|
|
|
|
.. versionchanged:: 3.0
|
|
Added the ``catch_exceptions`` parameter.
|
|
|
|
.. versionchanged:: 3.0
|
|
The result object has the ``exc_info`` attribute with the
|
|
traceback if available.
|
|
"""
|
|
exc_info = None
|
|
if catch_exceptions is None:
|
|
catch_exceptions = self.catch_exceptions
|
|
|
|
# Set up fd capture before isolation replaces sys.stdout and sys.stderr.
|
|
cap_out: _FDCapture | None = None
|
|
cap_err: _FDCapture | None = None
|
|
|
|
if self.capture == "fd":
|
|
cap_out = _FDCapture(1)
|
|
cap_err = _FDCapture(2)
|
|
try:
|
|
cap_out.start()
|
|
cap_err.start()
|
|
except OSError:
|
|
cap_out = cap_err = None
|
|
|
|
with self.isolation(input=input, env=env, color=color) as outstreams:
|
|
# Point the captured streams' fileno() at the saved (original)
|
|
# fd so that C-level consumers like faulthandler keep working
|
|
# while fd 1/2 are redirected to the capture tmpfile.
|
|
if cap_out is not None and cap_err is not None:
|
|
sys.stdout._original_fd = cap_out.saved_fd # type: ignore[union-attr]
|
|
sys.stderr._original_fd = cap_err.saved_fd # type: ignore[union-attr]
|
|
|
|
return_value = None
|
|
exception: BaseException | None = None
|
|
exit_code = 0
|
|
|
|
if isinstance(args, str):
|
|
args = shlex.split(args)
|
|
|
|
try:
|
|
prog_name = extra.pop("prog_name")
|
|
except KeyError:
|
|
prog_name = self.get_default_prog_name(cli)
|
|
|
|
try:
|
|
return_value = cli.main(args=args or (), prog_name=prog_name, **extra)
|
|
except SystemExit as e:
|
|
exc_info = sys.exc_info()
|
|
e_code = t.cast("int | t.Any | None", e.code)
|
|
|
|
if e_code is None:
|
|
e_code = 0
|
|
|
|
if e_code != 0:
|
|
exception = e
|
|
|
|
if not isinstance(e_code, int):
|
|
sys.stdout.write(str(e_code))
|
|
sys.stdout.write("\n")
|
|
e_code = 1
|
|
|
|
exit_code = e_code
|
|
|
|
except Exception as e:
|
|
if not catch_exceptions:
|
|
raise
|
|
exception = e
|
|
exit_code = 1
|
|
exc_info = sys.exc_info()
|
|
finally:
|
|
sys.stdout.flush()
|
|
sys.stderr.flush()
|
|
|
|
# Stop fd capture and merge the captured bytes into
|
|
# the stdout/stderr BytesIO streams. BytesIOCopy mirrors
|
|
# those writes into outstreams[2] automatically.
|
|
if cap_out is not None and cap_err is not None:
|
|
fd_out = cap_out.stop()
|
|
fd_err = cap_err.stop()
|
|
if fd_out:
|
|
outstreams[0].write(fd_out)
|
|
if fd_err:
|
|
outstreams[1].write(fd_err)
|
|
|
|
stdout = outstreams[0].getvalue()
|
|
stderr = outstreams[1].getvalue()
|
|
output = outstreams[2].getvalue()
|
|
|
|
return Result(
|
|
runner=self,
|
|
stdout_bytes=stdout,
|
|
stderr_bytes=stderr,
|
|
output_bytes=output,
|
|
return_value=return_value,
|
|
exit_code=exit_code,
|
|
exception=exception,
|
|
exc_info=exc_info, # type: ignore
|
|
)
|
|
|
|
@contextlib.contextmanager
|
|
def isolated_filesystem(
|
|
self, temp_dir: str | os.PathLike[str] | None = None
|
|
) -> cabc.Generator[str]:
|
|
"""A context manager that creates a temporary directory and
|
|
changes the current working directory to it. This isolates tests
|
|
that affect the contents of the CWD to prevent them from
|
|
interfering with each other.
|
|
|
|
:param temp_dir: Create the temporary directory under this
|
|
directory. If given, the created directory is not removed
|
|
when exiting.
|
|
|
|
.. versionchanged:: 8.0
|
|
Added the ``temp_dir`` parameter.
|
|
"""
|
|
cwd = os.getcwd()
|
|
dt = tempfile.mkdtemp(dir=temp_dir)
|
|
os.chdir(dt)
|
|
|
|
try:
|
|
yield dt
|
|
finally:
|
|
os.chdir(cwd)
|
|
|
|
if temp_dir is None:
|
|
import shutil
|
|
|
|
try:
|
|
shutil.rmtree(dt)
|
|
except OSError:
|
|
pass
|