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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from __future__ import annotations

import pytest

from deepctl_cmd_completion.command import CompletionCommand


Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
"""Tests for audio debug command."""

import pytest
from unittest.mock import Mock, patch, MagicMock
import json
from unittest.mock import MagicMock, Mock, patch

import pytest
from deepctl_cmd_debug_audio.command import AudioCommand
from deepctl_cmd_debug_audio.models import (
AudioDebugResult,
AudioInfo,
AudioFormat,
AudioInfo,
AudioStream,
)
from deepctl_core import Config, AuthManager, DeepgramClient
from deepctl_core import AuthManager, Config, DeepgramClient


class TestAudioCommand:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Unit tests for browser debug command."""

from unittest.mock import AsyncMock, MagicMock, Mock, patch

import pytest
from unittest.mock import Mock, patch, AsyncMock, MagicMock
from aiohttp import WSMsgType
from deepctl_cmd_debug_browser import BrowserCommand, BrowserDebugResult

Expand Down
15 changes: 8 additions & 7 deletions packages/deepctl-cmd-debug-network/tests/unit/test_models.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
"""Tests for network debug command models."""

import pytest
from datetime import datetime

import pytest
from deepctl_cmd_debug_network.models import (
EndpointTestResult,
DNSResult,
CertificateInfo,
RevocationEndpointTest,
TLSTestResult,
PythonRequestsTest,
CommandExecutionResult,
NetworkDebugResult,
DeepgramEndpoint,
DNSResult,
EndpointTestResult,
NetworkDebugResult,
PythonRequestsTest,
RevocationEndpointTest,
TLSTestResult,
)


Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
"""Tests for the network debug command."""

import pytest
from unittest.mock import Mock, MagicMock, patch, call
import subprocess
import socket
import requests
import json
import socket
import subprocess
from datetime import datetime
from unittest.mock import MagicMock, Mock, call, patch

import pytest
import requests
from deepctl_cmd_debug_network import NetworkCommand
from deepctl_cmd_debug_network.models import (
NetworkDebugResult,
DNSResult,
TLSTestResult,
CertificateInfo,
RevocationEndpointTest,
PythonRequestsTest,
CommandExecutionResult,
DNSResult,
EndpointTestResult,
NetworkDebugResult,
PythonRequestsTest,
RevocationEndpointTest,
TLSTestResult,
)
from deepctl_core import Config, AuthManager, DeepgramClient
from deepctl_core import AuthManager, Config, DeepgramClient


@pytest.fixture
Expand Down
1 change: 0 additions & 1 deletion packages/deepctl-cmd-listen/tests/unit/test_captions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from __future__ import annotations

import pytest

from deepctl_cmd_listen.captions import (
StreamingCaptionWriter,
_fmt_srt,
Expand Down
4 changes: 2 additions & 2 deletions packages/deepctl-cmd-login/tests/unit/test_login_command.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Tests for the login command."""

import pytest
from unittest.mock import Mock, patch, MagicMock, call
from unittest.mock import MagicMock, Mock, call, patch

import pytest
from deepctl_cmd_login.command import (
LoginCommand,
LogoutCommand,
Expand Down
143 changes: 68 additions & 75 deletions packages/deepctl-cmd-plugin/tests/unit/test_plugin_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,11 @@ def test_get_plugin_state_existing(self) -> None:
"""Test getting plugin state from existing file."""
test_state = {"plugins": {"test-plugin": {"version": "1.0.0"}}}

with patch.object(Path, "exists", return_value=True):
with patch.object(
Path, "read_text", return_value=json.dumps(test_state)
):
state = self.command._get_plugin_state()
assert state == test_state
with patch.object(Path, "exists", return_value=True), patch.object(
Path, "read_text", return_value=json.dumps(test_state)
):
state = self.command._get_plugin_state()
assert state == test_state

def test_save_plugin_state(self) -> None:
"""Test saving plugin state."""
Expand Down Expand Up @@ -140,34 +139,32 @@ def test_install_plugin_system_environment(
# Mock state and version operations
with patch.object(
self.command, "_get_plugin_state"
) as mock_get_state:
with patch.object(
self.command, "_save_plugin_state"
) as mock_save_state:
with patch.object(
self.command,
"_get_package_version",
return_value="1.0.0",
):
mock_get_state.return_value = {"plugins": {}}

options = PluginInstallOptions(
package="test-plugin"
)
result = self.command.install_plugin(
self.config,
self.auth_manager,
self.client,
options,
)

assert result.success is True
assert "Successfully installed" in result.message
# Strategy should use the plugin env python
cmd = mock_strategy_run.call_args[0][0]
assert cmd[0] == "/path/to/plugin/python"
# Should save plugin state
mock_save_state.assert_called_once()
) as mock_get_state, patch.object(
self.command, "_save_plugin_state"
) as mock_save_state, patch.object(
self.command,
"_get_package_version",
return_value="1.0.0",
):
mock_get_state.return_value = {"plugins": {}}

options = PluginInstallOptions(
package="test-plugin"
)
result = self.command.install_plugin(
self.config,
self.auth_manager,
self.client,
options,
)

assert result.success is True
assert "Successfully installed" in result.message
# Strategy should use the plugin env python
cmd = mock_strategy_run.call_args[0][0]
assert cmd[0] == "/path/to/plugin/python"
# Should save plugin state
mock_save_state.assert_called_once()

def test_install_plugin_git_url(self) -> None:
"""Test handling git URL in install options."""
Expand Down Expand Up @@ -317,32 +314,29 @@ def test_list_plugins_verbose(self) -> None:

with patch.object(
self.command, "_discover_plugins", return_value=test_plugins
):
with patch(
"deepctl_cmd_plugin.command.console.print"
) as mock_print:
with patch(
"deepctl_cmd_plugin.command.print_info"
) as mock_print_info:
with patch.object(
self.command.detector, "detect"
) as mock_detect:
mock_detect.return_value.method = InstallMethod.SYSTEM

self.command.list_plugins(
self.config,
self.auth_manager,
self.client,
verbose=True,
)

# Should print a table
mock_print.assert_called_once()
# Should show system installation info via print_info
assert any(
"system" in str(call).lower()
for call in mock_print_info.call_args_list
)
), patch(
"deepctl_cmd_plugin.command.console.print"
) as mock_print, patch(
"deepctl_cmd_plugin.command.print_info"
) as mock_print_info, patch.object(
self.command.detector, "detect"
) as mock_detect:
mock_detect.return_value.method = InstallMethod.SYSTEM

self.command.list_plugins(
self.config,
self.auth_manager,
self.client,
verbose=True,
)

# Should print a table
mock_print.assert_called_once()
# Should show system installation info via print_info
assert any(
"system" in str(call).lower()
for call in mock_print_info.call_args_list
)

def test_setup_commands(self) -> None:
"""Test that all subcommands are properly set up."""
Expand Down Expand Up @@ -396,21 +390,20 @@ def test_handle_search(self) -> None:
# Test search all
with patch(
"deepctl_cmd_plugin.command.console.print"
) as mock_print:
with patch(
"deepctl_cmd_plugin.command.print_info"
) as mock_info:
self.command._handle_search(
self.config, self.auth_manager, self.client
)

# Should print a table
mock_print.assert_called_once()
# Should show install hint
assert any(
"install" in str(call)
for call in mock_info.call_args_list
)
) as mock_print, patch(
"deepctl_cmd_plugin.command.print_info"
) as mock_info:
self.command._handle_search(
self.config, self.auth_manager, self.client
)

# Should print a table
mock_print.assert_called_once()
# Should show install hint
assert any(
"install" in str(call)
for call in mock_info.call_args_list
)

# Test search with query
with patch(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
print_pending_plugin_notifications,
)


# ---------------------------------------------------------------------------
# _is_excluded
# ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
import httpx
import pytest
from deepctl_cmd_update.version_check import (
_FREQUENCY_DURATIONS,
VersionChecker,
VersionInfo,
_FREQUENCY_DURATIONS,
format_version_message,
)

Expand Down
55 changes: 55 additions & 0 deletions packages/deepctl-core/src/deepctl_core/base_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,26 +104,81 @@ def execute(self, ctx: click.Context, **kwargs: Any) -> None:
)
raise click.ClickException("Project ID required")

self._tag_telemetry_start(ctx)

# Execute the command
try:
with TimingContext(f"command_{self.name}_handler"):
result = self.handle(config, auth_manager, client, **kwargs)

status = "ok"
if result is not None and hasattr(result, "status"):
status = str(result.status)
self._tag_telemetry_status(status)

# Handle command result
if result is not None:
with TimingContext("output_processing"):
self.output_result(result, config)

except KeyboardInterrupt:
self._tag_telemetry_status("cancelled")
stderr_console.print("\n[yellow]Command cancelled by user[/yellow]")
raise click.Abort()

except Exception as e:
self._tag_telemetry_status("error")
print_error(f"Command failed: {e}")
if config.get("output.verbose", False):
stderr_console.print_exception()
raise click.ClickException(str(e))

def _tag_telemetry_start(self, ctx: click.Context) -> None:
"""Annotate the active Sentry transaction with command-level usage signal.

Sets the transaction name to the full Click command path
(e.g. 'deepctl debug audio' instead of just 'debug') and tags it with
the user-provided flag NAMES (never values), the requested output
format, and the auth method. All values are bounded enums or
already-public flag identifiers — no PII risk.

Wrapped in a bare except so a Sentry hiccup, missing scope, or unknown
Click parameter-source enum value can never crash the user's command.
"""
try:
import sentry_sdk

scope = sentry_sdk.get_current_scope()
transaction = getattr(scope, "transaction", None)
if transaction is not None and ctx.command_path:
transaction.name = ctx.command_path

used: list[str] = []
for param in ctx.command.params:
if not param.name:
continue
src = ctx.get_parameter_source(param.name)
if src is not None and src.name == "COMMANDLINE":
used.append(param.name)
scope.set_tag("cmd.flags", ",".join(sorted(used)) or "(none)")
scope.set_tag("cmd.output_format", ctx.params.get("output") or "default")
except Exception:
pass

def _tag_telemetry_status(self, status: str) -> None:
"""Tag the active Sentry transaction with the command outcome.

Status is a bounded enum: 'ok', 'cancelled', 'error', or whatever the
command's BaseResult.status returned (also bounded by the result
model). Bare except so telemetry can't crash the command teardown.
"""
try:
import sentry_sdk

sentry_sdk.get_current_scope().set_tag("cmd.status", status)
except Exception:
pass

@abstractmethod
def handle(
self,
Expand Down
Loading
Loading