Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/deepctl-cmd-billing/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ classifiers = [
keywords = ["deepgram", "cli", "billing", "usage"]
requires-python = ">=3.10"
dependencies = [
"deepctl-core>=0.1.10",
"deepctl-core>=0.2.16",
"click>=8.0.0",
"rich>=13.0.0",
"pydantic>=2.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/deepctl-cmd-keys/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ classifiers = [
keywords = ["deepgram", "cli", "keys", "api-keys"]
requires-python = ">=3.10"
dependencies = [
"deepctl-core>=0.1.10",
"deepctl-core>=0.2.16",
"click>=8.0.0",
"rich>=13.0.0",
"pydantic>=2.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/deepctl-cmd-members/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ classifiers = [
keywords = ["deepgram", "cli", "members", "team"]
requires-python = ">=3.10"
dependencies = [
"deepctl-core>=0.1.10",
"deepctl-core>=0.2.16",
"click>=8.0.0",
"rich>=13.0.0",
"pydantic>=2.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/deepctl-cmd-models/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ classifiers = [
keywords = ["deepgram", "cli", "models"]
requires-python = ">=3.10"
dependencies = [
"deepctl-core>=0.1.10",
"deepctl-core>=0.2.16",
"click>=8.0.0",
"rich>=13.0.0",
"pydantic>=2.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/deepctl-cmd-projects/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ classifiers = [
keywords = ["deepgram", "cli", "projects", "management"]
requires-python = ">=3.10"
dependencies = [
"deepctl-core>=0.1.10",
"deepctl-core>=0.2.16",
"click>=8.0.0",
"rich>=13.0.0",
"deepgram-sdk>=7.7.0,<8",
Expand Down
2 changes: 1 addition & 1 deletion packages/deepctl-cmd-read/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ classifiers = [
keywords = ["deepgram", "cli", "text-intelligence", "nlp", "read"]
requires-python = ">=3.10"
dependencies = [
"deepctl-core>=0.1.10",
"deepctl-core>=0.2.16",
"click>=8.0.0",
"rich>=13.0.0",
"pydantic>=2.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/deepctl-cmd-requests/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ classifiers = [
keywords = ["deepgram", "cli", "requests", "history"]
requires-python = ">=3.10"
dependencies = [
"deepctl-core>=0.1.10",
"deepctl-core>=0.2.16",
"click>=8.0.0",
"rich>=13.0.0",
"pydantic>=2.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/deepctl-cmd-usage/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ classifiers = [
keywords = ["deepgram", "cli", "usage", "statistics"]
requires-python = ">=3.10"
dependencies = [
"deepctl-core>=0.1.10",
"deepctl-core>=0.2.16",
"deepctl-shared-utils>=0.1.10",
"click>=8.0.0",
"rich>=13.0.0",
Expand Down
28 changes: 14 additions & 14 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ requires-python = ">=3.10"
dependencies = [
"click>=8.0.0",
"deepgram-sdk>=7.7.0,<8",
"deepctl-core>=0.2.15",
"deepctl-cmd-login>=0.1.10",
"deepctl-cmd-projects>=0.1.10",
"deepctl-core>=0.2.16",
"deepctl-cmd-login>=0.1.17",
"deepctl-cmd-projects>=0.1.14",
"deepctl-cmd-transcribe>=0.1.10",
"deepctl-cmd-usage>=0.1.10",
"deepctl-cmd-mcp>=0.1.10",
"deepctl-cmd-usage>=0.1.14",
"deepctl-cmd-mcp>=0.1.15",
"deepctl-cmd-api>=0.0.1",
"deepctl-cmd-debug>=0.1.10",
"deepctl-cmd-debug-browser>=0.1.10",
Expand All @@ -49,21 +49,21 @@ dependencies = [
"deepctl-cmd-debug-probe>=0.0.1",
"deepctl-cmd-debug-toolkit>=0.0.1",
"deepctl-cmd-ffprobe>=0.0.1",
"deepctl-cmd-update>=0.1.10",
"deepctl-cmd-update>=0.2.6",
"deepctl-cmd-plugin>=0.1.10",
"deepctl-cmd-skills>=0.0.1",
"deepctl-cmd-skills>=0.0.7",
"deepctl-cmd-init>=0.0.1",
"deepctl-cmd-models>=0.0.1",
"deepctl-cmd-models>=0.0.3",
"deepctl-cmd-speak>=0.0.4",
"deepctl-cmd-keys>=0.0.1",
"deepctl-cmd-read>=0.0.1",
"deepctl-cmd-keys>=0.0.4",
"deepctl-cmd-read>=0.0.3",
"deepctl-cmd-listen>=0.0.14",
"deepctl-cmd-requests>=0.0.1",
"deepctl-cmd-billing>=0.0.1",
"deepctl-cmd-members>=0.0.1",
"deepctl-cmd-requests>=0.0.3",
"deepctl-cmd-billing>=0.0.3",
"deepctl-cmd-members>=0.0.4",
"deepctl-cmd-completion>=0.0.1",
"deepctl-shared-utils>=0.1.10",
"deepctl-telemetry>=0.0.1",
"deepctl-telemetry>=0.0.6",
"pydantic>=2.0.0",
"rich>=13.0.0",
"httpx>=0.24.0",
Expand Down
19 changes: 14 additions & 5 deletions src/deepctl/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,18 @@
print_timing_summary,
setup_output,
)
from rich.console import Console
from deepctl_core.output import stderr_console
from rich.traceback import install

# Install rich traceback for better error messages
install(show_locals=True)
console = Console()

# Diagnostics go to stderr so stdout carries only the requested payload -- a
# crash or usage error must not corrupt `dg -o json ...` for a script piping
# stdout into jq. Same console the command layer uses via print_error(), so
# root-level and command-level diagnostics format identically (including the
# no-color handling for agentic/CI callers).
console = stderr_console


def _record_install_method_cb(
Expand Down Expand Up @@ -282,7 +288,7 @@ def _telemetry_transaction() -> Iterator[None]:


def _safe_console_print(message: str) -> None:
"""Print to the console, tolerating a closed/broken output stream.
"""Print a diagnostic to stderr, tolerating a closed/broken stream.

When `dg` runs as an MCP server, the host can close stdio before the
process finishes. A write to the closed stream raises ``BrokenPipeError``
Expand Down Expand Up @@ -389,12 +395,15 @@ def main() -> None:
if exit_code:
sys.exit(exit_code)

except KeyboardInterrupt:
except (KeyboardInterrupt, click.exceptions.Abort):
# Click wraps a Ctrl-C/Ctrl-D raised inside cli() in Abort when
# standalone_mode=False, so a mid-command interrupt arrives here as
# Abort, not KeyboardInterrupt. Both are user cancellation: exit 2.
_safe_console_print("\n[yellow]Operation cancelled by user[/yellow]")
sys.exit(2)
except Exception as e:
_safe_console_print(f"[red]Error: {e}[/red]")
sys.exit(2)
sys.exit(1) # 1 = error; 2 is reserved for user interrupt


if __name__ == "__main__":
Expand Down
110 changes: 98 additions & 12 deletions tests/unit/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,29 +117,115 @@ def test_main_keyboard_interrupt(self):
"""Test main() handles KeyboardInterrupt."""
from deepctl.main import main

# Mock sys.argv and the cli call to raise KeyboardInterrupt
# Patch the module-global `cli` that main() calls, going through
# sys.modules because deepctl/__init__ re-exports the `main` function
# as `deepctl.main`, shadowing the submodule attribute. (Patching the
# instance's __call__ is inert: dunder lookup bypasses instance
# attributes, so the old patch.object(cli, "__call__", ...) form
# exercised the bare-`dg` help path instead.)
main_mod = sys.modules["deepctl.main"]
with patch("sys.argv", ["deepctl"]):
# Patch the cli function that's already imported at module level
with patch.object(cli, "__call__", side_effect=KeyboardInterrupt()):
with patch.object(main_mod, "cli", side_effect=KeyboardInterrupt()):
with pytest.raises(SystemExit) as exc_info:
main()

# main()'s own KeyboardInterrupt handler: 2 = user interrupt
assert exc_info.value.code == 2

def test_main_click_abort(self):
"""A click Abort (Ctrl-C/Ctrl-D during a command) exits 2.

With standalone_mode=False, Click catches a KeyboardInterrupt raised
inside command execution and re-raises it as Abort (a RuntimeError,
not a KeyboardInterrupt) -- so the mid-command interrupt, the common
case, reaches main() as Abort. It is user cancellation: 2, not 1.
"""
import click

from deepctl.main import main

main_mod = sys.modules["deepctl.main"]
with patch("sys.argv", ["deepctl"]):
with patch.object(
main_mod, "cli", side_effect=click.exceptions.Abort()
):
with pytest.raises(SystemExit) as exc_info:
main()

# Click exits with code 2 when there's an error in standalone mode
assert exc_info.value.code == 2

def test_main_general_exception(self):
"""Test main() handles general exceptions."""
from deepctl.main import main

# Mock sys.argv and the cli call to raise an exception
main_mod = sys.modules["deepctl.main"]
with patch("sys.argv", ["deepctl"]):
# Patch the cli function that's already imported at module level
with patch.object(cli, "__call__", side_effect=Exception("Test error")):
with patch.object(main_mod, "cli", side_effect=Exception("Test error")):
with pytest.raises(SystemExit) as exc_info:
main()

# Click exits with code 2 when there's an error in standalone mode
assert exc_info.value.code == 2
# main()'s own handler: 1 = error (2 is reserved for interrupt)
assert exc_info.value.code == 1

def test_main_usage_error(self):
"""A Click usage error (bad flag, unknown command) exits 1.

standalone_mode=False means Click's UsageError propagates to main()'s
generic handler rather than Click's own standalone exit(2) -- per the
published contract, 1 = error and 2 is reserved for user interrupt.
"""
from deepctl.main import main

with patch("sys.argv", ["deepctl", "--definitely-not-a-flag"]):
with pytest.raises(SystemExit) as exc_info:
main()

assert exc_info.value.code == 1

@pytest.mark.parametrize(
("argv", "label"),
[
(["deepctl", "-o", "json", "not-a-command"], "unknown command"),
(["deepctl", "-o", "json", "--definitely-not-a-flag"], "bad flag"),
],
)
def test_failure_keeps_stdout_clean(self, capsys, argv, label):
"""A failing `dg -o json ...` writes nothing to stdout.

The whole point of `-o json` is that stdout is machine-readable, so a
script can pipe it into jq. Diagnostics therefore belong on stderr:
printing `Error: ...` to stdout leaves the caller parsing prose. This
is the root-handler half of the #97 sweep, which moved command-level
status chrome to stderr but left main()'s own handlers on stdout.
"""
from deepctl.main import main

with patch("sys.argv", argv):
with pytest.raises(SystemExit) as exc_info:
main()

assert exc_info.value.code == 1
captured = capsys.readouterr()
assert captured.out == "", f"{label} polluted stdout: {captured.out!r}"
assert "Error" in captured.err

def test_interrupt_message_goes_to_stderr(self, capsys):
"""The cancellation notice is a diagnostic, so it also stays off stdout."""
import click

from deepctl.main import main

main_mod = sys.modules["deepctl.main"]
with patch("sys.argv", ["deepctl"]):
with patch.object(
main_mod, "cli", side_effect=click.exceptions.Abort()
):
with pytest.raises(SystemExit):
main()

captured = capsys.readouterr()
assert captured.out == ""
assert "cancelled" in captured.err.lower()


class TestSafeConsolePrint:
Expand Down Expand Up @@ -172,14 +258,14 @@ def test_swallows_closed_stream(self, error):
def test_main_survives_broken_console_on_error(self):
"""The full DX-CLI-P cascade: cli raises AND the console is closed.

main() must still exit(2) cleanly rather than let rich's ValueError
main() must still exit(1) cleanly rather than let rich's ValueError
escape to the excepthook.
"""
main_mod = sys.modules["deepctl.main"]

with (
patch("sys.argv", ["deepctl"]),
patch.object(cli, "__call__", side_effect=Exception("boom")),
patch.object(main_mod, "cli", side_effect=Exception("boom")),
patch.object(
main_mod.console,
"print",
Expand All @@ -189,4 +275,4 @@ def test_main_survives_broken_console_on_error(self):
):
main_mod.main()

assert exc_info.value.code == 2
assert exc_info.value.code == 1
Loading