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
6 changes: 6 additions & 0 deletions docs/tutorial/printing.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@ If you have some `bytes` objects, you would probably want to decode them intenti

And if you want to print data with colors and other features, you are much better off with the more advanced tools in **Rich**.

### Untrusted Text

`typer.echo()` preserves terminal control sequences, including those used for colors. When displaying text from an untrusted source, escape control characters first. For a diagnostic representation of a string, you can use `typer.echo(repr(value))`.

Setting `color=False` disables styling, but does not sanitize all terminal control sequences.

### Color

/// note | Technical Details
Expand Down
118 changes: 118 additions & 0 deletions tests/test_terminal_control_characters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import re
from typing import Annotated

import pytest
import typer
from typer._click.exceptions import NoSuchOption
from typer.testing import CliRunner

runner = CliRunner()


@pytest.fixture(params=["rich", None], ids=["rich", "plain"])
def app(request: pytest.FixtureRequest) -> typer.Typer:
app = typer.Typer(add_completion=False, rich_markup_mode=request.param)

@app.command()
def no_args() -> None:
pass # pragma: no cover

@app.command()
def read_file(value: typer.FileText) -> None:
pass # pragma: no cover

@app.command()
def parse(
value: Annotated[int, typer.Argument(parser=lambda x: int(x, 0))],
) -> None:
pass # pragma: no cover

return app


@pytest.mark.parametrize("color", [False, True])
@pytest.mark.parametrize(
("command", "prefix", "message"),
[
("no-args", "", "Got unexpected extra argument(s)"),
("no-args", "--", "No such option"),
("read-file", "", "Invalid value"),
("parse", "", "Invalid value"),
],
ids=["extra-argument", "unknown-option", "file-open", "custom-parser"],
)
@pytest.mark.parametrize(
("value", "escaped"),
[
("\x1b[2J\x1b[H", r"\x1b[2J\x1b[H"),
("\x1b]52;c;YWJj\x07", r"\x1b]52;c;YWJj\x07"),
("\x1b]52;c;YWJj\x1b\\", "\\x1b]52;c;YWJj\\x1b\\"),
("\x9b2J\x9d52;c;YWJj\x9c", r"\x9b2J\x9d52;c;YWJj\x9c"),
("\r\n\t\b\x7f", r"\x0d\x0a\x09\x08\x7f"),
(
"text-\u00e9-\u65e5\u672c-\U0001f469\u200d\U0001f4bb-'quoted'-\\text",
"text-\u00e9-\u65e5\u672c-\U0001f469\u200d\U0001f4bb-'quoted'-\\text",
),
],
ids=["screen", "osc-bel", "osc-st", "c1", "whitespace-controls", "unicode"],
)
def test_error_values(
app: typer.Typer,
command: str,
prefix: str,
message: str,
value: str,
escaped: str,
color: bool,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from typer import rich_utils

monkeypatch.delenv("NO_COLOR", raising=False)
monkeypatch.setenv("TERM", "xterm-256color")
monkeypatch.setattr(rich_utils, "FORCE_TERMINAL", color)
monkeypatch.setattr(rich_utils, "MAX_WIDTH", 3000)
result = runner.invoke(app, [command, prefix + "input-" + value], color=color)

assert result.exit_code == 2
# Remove only generated color styles so other terminal controls cannot hide.
output = re.sub(r"\x1b\[[0-9;]*m", "", result.stderr)
assert message in output
assert prefix + "input-" + escaped in output
assert re.search(r"[\x00-\x09\x0b-\x1f\x7f-\x9f]", output) is None


def test_unknown_option_preserves_original_value() -> None:
value = "--input-\x00\x1f\x7f\x80\x9f"
error = NoSuchOption(value)

assert error.option_name == value
assert error.format_message() == r"No such option: --input-\x00\x1f\x7f\x80\x9f"


def test_argument_preserves_original_value() -> None:
app = typer.Typer(add_completion=False)
value = "input-\x1b[2J\x1b]52;c;YWJj\x07"

@app.command()
def main(value: str) -> None:
typer.echo(repr(value))

result = runner.invoke(app, [value])

assert result.exit_code == 0
assert result.stdout == repr(value) + "\n"


def test_echo_preserves_terminal_sequences() -> None:
app = typer.Typer(add_completion=False)
value = "\x1b[2J\x1b]52;c;YWJj\x07"

@app.command()
def main() -> None:
typer.echo(value, nl=False)

result = runner.invoke(app, color=True)

assert result.exit_code == 0
assert result.stdout_bytes == value.encode()
5 changes: 3 additions & 2 deletions typer/_click/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from .globals import pop_context, push_context
from .parser import _OptionParser
from .termui import style
from .utils import echo, make_default_short_help
from .utils import _escape_control_characters, echo, make_default_short_help

if TYPE_CHECKING:
from ..core import TyperOption
Expand Down Expand Up @@ -719,7 +719,8 @@ def parse_args(self, ctx: Context, args: list[str]) -> list[str]:
_, args = param.handle_parse_result(ctx, opts, args)

if args and not ctx.allow_extra_args and not ctx.resilient_parsing:
ctx.fail(f"Got unexpected extra argument(s) ({' '.join(map(str, args))})")
extra_args = " ".join(_escape_control_characters(str(arg)) for arg in args)
ctx.fail(f"Got unexpected extra argument(s) ({extra_args})")

ctx.args = args
ctx._opt_prefixes.update(parser._opt_prefixes)
Expand Down
4 changes: 2 additions & 2 deletions typer/_click/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from ..exceptions import TyperException
from ._compat import get_text_stderr
from .globals import resolve_color_default
from .utils import echo, format_filename
from .utils import _escape_control_characters, echo, format_filename

if TYPE_CHECKING:
from .core import Command, Context, Parameter
Expand Down Expand Up @@ -178,7 +178,7 @@ def __init__(
ctx: Union["Context", None] = None,
) -> None:
if message is None:
message = f"No such option: {option_name}"
message = f"No such option: {_escape_control_characters(option_name)}"

super().__init__(message, ctx)
self.option_name = option_name
Expand Down
9 changes: 5 additions & 4 deletions typer/_click/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from ._compat import _get_argv_encoding, open_stream
from .exceptions import BadParameter
from .utils import LazyFile, format_filename, safecall
from .utils import LazyFile, _escape_control_characters, format_filename, safecall

if TYPE_CHECKING:
from .core import Context, Parameter
Expand Down Expand Up @@ -138,7 +138,7 @@ def convert(
assert isinstance(value, bytes)
value = value.decode("utf-8", "replace")

self.fail(value, param, ctx)
self.fail(_escape_control_characters(value), param, ctx)


class StringParamType(ParamType):
Expand Down Expand Up @@ -559,8 +559,9 @@ def convert(
ctx.call_on_close(safecall(f.flush))

return f
except OSError as e: # pragma: no cover
self.fail(f"'{format_filename(value)}': {e.strerror}", param, ctx)
except OSError as e:
filename = _escape_control_characters(format_filename(value))
self.fail(f"'{filename}': {e.strerror}", param, ctx)

def shell_complete(
self, ctx: "Context", param: "Parameter", incomplete: str
Expand Down
7 changes: 7 additions & 0 deletions typer/_click/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@
R = TypeVar("R")


def _escape_control_characters(value: str) -> str:
"""Display untrusted diagnostic values without terminal control characters."""
return re.sub(
r"[\x00-\x1f\x7f-\x9f]", lambda match: f"\\x{ord(match[0]):02x}", value
)


def _posixify(name: str) -> str:
return "-".join(name.split()).lower()

Expand Down
Loading