Cyclopts CLI with Annotated types and rich help: working versions (tested 2026-10-15)
A typed Cyclopts command using Annotated Parameter help text and enum/int/list coercion, invoked offline in tests with captured stdout, stderr and return values.
passed6 passed · 0 failed2026-10-15 15:58 UTC- Status
- passed
- Last passed
- 2026-10-15 15:58 UTC
- Tests
- 6 / 6
- Language
- Python
- Runtime
- Python 3.13.16
pinned versions2
cyclopts@5.2.0pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
test environment
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38764699834
The code
Exactly the files that passed. Your agent can pull them with get_recipe("cyclopts-annotated-cli") (install), or see them on GitHub.
requirements.txt
cyclopts==5.2.0 pytest==9.1.1
cli.py
from __future__ import annotations
from enum import Enum
from typing import Annotated
from cyclopts import App, Parameter
app = App(name="tasks", help="Manage a small todo list.")
class Priority(str, Enum):
low = "low"
medium = "medium"
high = "high"
@app.command
def add(
title: Annotated[str, Parameter(help="Short task description.")],
*,
priority: Annotated[Priority, Parameter(help="Task priority.")] = Priority.medium,
copies: Annotated[int, Parameter(help="How many copies to create.")] = 1,
tags: Annotated[list[str], Parameter(name="--tag", help="Attach a tag; repeat for many.")] = [],
verbose: Annotated[bool, Parameter(help="Print extra detail.")] = False,
) -> dict:
"""Add a task to the list."""
return {
"title": title,
"priority": priority.value,
"copies": copies,
"tags": tags,
"verbose": verbose,
}
tests/test_cli.py
import contextlib
import io
from dataclasses import dataclass
import pytest
from cyclopts import App
from cyclopts.exceptions import CoercionError, MissingArgumentError
from cli import app
@dataclass
class Result:
return_value: object = None
stdout: str = ""
stderr: str = ""
error: Exception | None = None
@property
def exit_code(self) -> int:
return 2 if self.error is not None else 0
def invoke(cli: App, tokens: list[str]) -> Result:
"""Run a Cyclopts app offline and capture its streams and return value."""
out, err = io.StringIO(), io.StringIO()
result = Result()
try:
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
result.return_value = cli(tokens, exit_on_error=False, result_action="return_value")
except Exception as exc: # CycloptsError subclasses
result.error = exc
result.stdout, result.stderr = out.getvalue(), err.getvalue()
return result
def test_annotated_types_are_coerced():
result = invoke(
app,
["add", "buy milk", "--priority", "high", "--copies", "3", "--tag", "home", "--verbose"],
)
assert result.error is None
assert result.return_value == {
"title": "buy milk",
"priority": "high",
"copies": 3,
"tags": ["home"],
"verbose": True,
}
assert isinstance(result.return_value["copies"], int)
def test_repeated_option_accumulates_but_commas_do_not_split():
result = invoke(app, ["add", "x", "--tag", "a,b", "--tag", "c"])
assert result.return_value["tags"] == ["a,b", "c"]
def test_help_renders_annotated_help_and_choices():
result = invoke(app, ["add", "--help"])
assert result.error is None and result.exit_code == 0
assert "Short task description." in result.stdout
assert "[choices: low, medium, high]" in result.stdout
assert "--no-verbose" in result.stdout
def test_missing_required_argument_is_reported_on_stderr():
result = invoke(app, ["add"])
assert isinstance(result.error, MissingArgumentError)
assert result.stdout == ""
assert "--title" in result.stderr
def test_coercion_errors_are_cyclopts_errors():
result = invoke(app, ["add", "x", "--priority", "urgent"])
assert isinstance(result.error, CoercionError)
assert "low" in result.stderr and "high" in result.stderr
def test_default_app_call_exits_for_production_scripts():
with pytest.raises(SystemExit) as exc_info:
app(["add"]) # exit_on_error defaults to True
assert exc_info.value.code == 2
pytest.ini
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.