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
4 changes: 0 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,6 @@ source = ["tailscale"]
[tool.coverage.report]
show_missing = true
fail_under = 95
omit = [
"src/tailscale/_cli.py",
"src/tailscale/cli/*",
]

[tool.ty.environment]
python-version = "3.11"
Expand Down
29 changes: 10 additions & 19 deletions src/tailscale/cli/async_typer.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,17 @@
from typing import (
TYPE_CHECKING,
Any,
ParamSpec,
TypeVar,
)

from typer import Exit
from typer import Typer as SyncTyper

if TYPE_CHECKING:
from collections.abc import Coroutine

from typer.core import TyperCommand, TyperGroup

_P = ParamSpec("_P")
_R = TypeVar("_R")
# A callback or command, which can be a coroutine function or a plain one.
_F = TypeVar("_F", bound=Callable[..., Any])

HandleErrorFunc = Callable[[Any], None]

Expand Down Expand Up @@ -71,10 +68,7 @@ def callback( # type: ignore[override] # ty: ignore[invalid-method-override]
hidden: bool = False,
deprecated: bool = False,
rich_help_panel: str | None = None,
) -> Callable[
[Callable[_P, Coroutine[Any, Any, _R]]],
Callable[_P, Coroutine[Any, Any, _R]],
]:
) -> Callable[[_F], _F]:
"""Create a new typer callback."""
super_callback = super().callback(
cls=cls,
Expand All @@ -95,12 +89,12 @@ def callback( # type: ignore[override] # ty: ignore[invalid-method-override]
)

def decorator(
func: Callable[_P, Coroutine[Any, Any, _R]],
) -> Callable[_P, Coroutine[Any, Any, _R]]:
func: _F,
) -> _F:
if inspect.iscoroutinefunction(func):

@wraps(func)
def sync_func(*_args: _P.args, **_kwargs: _P.kwargs) -> _R:
def sync_func(*_args: Any, **_kwargs: Any) -> Any:
return asyncio.run(func(*_args, **_kwargs))

super_callback(sync_func)
Expand Down Expand Up @@ -129,10 +123,7 @@ def command( # type: ignore[override] # ty: ignore[invalid-method-override]
deprecated: bool = False,
# Rich settings
rich_help_panel: str | None = None,
) -> Callable[
[Callable[_P, Coroutine[Any, Any, _R]]],
Callable[_P, Coroutine[Any, Any, _R]],
]:
) -> Callable[[_F], _F]:
"""Create a new typer command."""
super_command = super().command(
name,
Expand All @@ -150,12 +141,12 @@ def command( # type: ignore[override] # ty: ignore[invalid-method-override]
)

def decorator(
func: Callable[_P, Coroutine[Any, Any, _R]],
) -> Callable[_P, Coroutine[Any, Any, _R]]:
func: _F,
) -> _F:
if inspect.iscoroutinefunction(func):

@wraps(func)
def sync_func(*_args: _P.args, **_kwargs: _P.kwargs) -> _R:
def sync_func(*_args: Any, **_kwargs: Any) -> Any:
return asyncio.run(func(*_args, **_kwargs))

super_command(sync_func)
Expand Down
2 changes: 1 addition & 1 deletion src/tailscale/tailscale.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ class Tailscale:
oauth_client_id: str | None = None
oauth_client_secret: str | None = field(default=None, repr=False)

request_timeout: int = 8
request_timeout: float = 8
session: ClientSession | None = None
token_storage: TokenStorage | None = field(default=None, repr=False)

Expand Down
102 changes: 102 additions & 0 deletions tests/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from typer import Exit
from typer.core import TyperGroup
from typer.main import get_command
from typer.testing import CliRunner

from tailscale._cli import main
from tailscale.cli import cli
from tailscale.cli.async_typer import AsyncTyper
from tailscale.exceptions import (
TailscaleAuthenticationError,
TailscaleConnectionError,
Expand Down Expand Up @@ -1165,6 +1168,105 @@ def test_missing_auth(
# --- error handlers ---


@pytest.mark.parametrize(
("error", "title"),
[
(TailscalePermissionError(403, "not on this plan"), "Permission denied"),
(TailscaleAuthenticationError("bad key"), "Authentication error"),
(TailscaleConnectionError("unreachable"), "Connection error"),
(TailscaleError("something broke"), "Tailscale API error"),
],
)
def test_errors_through_the_entry_point(
capsys: pytest.CaptureFixture[str],
error: TailscaleError,
title: str,
) -> None:
"""Errors of a command end up at their handler, like when run for real."""
mock_client = _mock_tailscale()
mock_client._request.side_effect = error # pylint: disable=protected-access
with (
patch("tailscale.cli._build_client", return_value=mock_client),
pytest.raises(SystemExit) as exc_info,
):
cli(["dump", "settings", "--api-key", "tskey-api-test"])

assert exc_info.value.code == 1
assert title in capsys.readouterr().out


def test_unhandled_errors_through_the_entry_point() -> None:
"""Errors without a handler are raised as they are."""
mock_client = _mock_tailscale()
mock_client._request.side_effect = RuntimeError("boom") # pylint: disable=protected-access
with (
patch("tailscale.cli._build_client", return_value=mock_client),
pytest.raises(RuntimeError, match="boom"),
):
cli(["dump", "settings", "--api-key", "tskey-api-test"])


def test_console_script_runs_the_cli() -> None:
"""The console script runs the CLI."""
with patch("tailscale.cli.cli") as mock_cli:
main()
mock_cli.assert_called_once_with()


def test_async_callback(runner: CliRunner) -> None:
"""An async callback of an AsyncTyper app runs, like an async command."""
app = AsyncTyper()
called: list[str] = []

@app.callback(invoke_without_command=True)
async def callback() -> None:
called.append("callback")

@app.command()
async def hello() -> None:
called.append("hello")

result = runner.invoke(app, ["hello"])
assert result.exit_code == 0, result.output
assert called == ["callback", "hello"]


def test_sync_callback_and_command(runner: CliRunner) -> None:
"""Plain functions keep working as callback and command."""
app = AsyncTyper()
called: list[str] = []

@app.callback()
def callback() -> None:
called.append("callback")

@app.command()
def hello() -> None:
called.append("hello")

result = runner.invoke(app, ["hello"])
assert result.exit_code == 0, result.output
assert called == ["callback", "hello"]


def test_exit_through_the_entry_point() -> None:
"""A typer.Exit is not turned into an error."""
app = AsyncTyper()
app.error_handler(Exception)(lambda _: pytest.fail("handler called"))

@app.command()
def stop() -> None:
raise Exit(code=3)

@app.command()
def other() -> None:
"""Make this a group, so commands are called by name."""

with pytest.raises(SystemExit) as exc_info:
app(["stop"])
assert exc_info.value.code == 3


def test_authentication_error_handler(
capsys: pytest.CaptureFixture[str],
snapshot: SnapshotAssertion,
Expand Down
Loading
Loading