Skip to content

✨ Support bytes in Options and Arguments #1190

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
96 changes: 96 additions & 0 deletions examples/bytes_encoding_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import base64
import binascii

import typer

app = typer.Typer()


@app.command()
def base64_encode(text: bytes):
"""Encode text to base64."""
encoded = base64.b64encode(text)
typer.echo(f"Original: {text!r}")
typer.echo(f"Base64 encoded: {encoded.decode()}")


@app.command()
def base64_decode(encoded: str):
"""Decode base64 to bytes."""
try:
decoded = base64.b64decode(encoded)
typer.echo(f"Base64 encoded: {encoded}")
typer.echo(f"Decoded: {decoded!r}")
typer.echo(f"As string: {decoded.decode(errors='replace')}")
except Exception as e:
typer.echo(f"Error decoding base64: {e}", err=True)
raise typer.Exit(code=1) from e


@app.command()
def hex_encode(data: bytes):
"""Convert bytes to hex string."""
hex_str = binascii.hexlify(data).decode()
typer.echo(f"Original: {data!r}")
typer.echo(f"Hex encoded: {hex_str}")


@app.command()
def hex_decode(hex_str: str):
"""Convert hex string to bytes."""
try:
data = binascii.unhexlify(hex_str)
typer.echo(f"Hex encoded: {hex_str}")
typer.echo(f"Decoded: {data!r}")
typer.echo(f"As string: {data.decode(errors='replace')}")
except Exception as e:
typer.echo(f"Error decoding hex: {e}", err=True)
raise typer.Exit(code=1) from e


@app.command()
def convert(
data: bytes = typer.Argument(..., help="Data to convert"),
from_format: str = typer.Option(
"raw", "--from", "-f", help="Source format: raw, base64, or hex"
),
to_format: str = typer.Option(
"base64", "--to", "-t", help="Target format: raw, base64, or hex"
),
):
"""Convert between different encodings."""
# First decode from source format to raw bytes
raw_bytes = data
if from_format == "base64":
try:
raw_bytes = base64.b64decode(data)
except Exception as e:
typer.echo(f"Error decoding base64: {e}", err=True)
raise typer.Exit(code=1) from e
elif from_format == "hex":
try:
raw_bytes = binascii.unhexlify(data)
except Exception as e:
typer.echo(f"Error decoding hex: {e}", err=True)
raise typer.Exit(code=1) from e
elif from_format != "raw":
typer.echo(f"Unknown source format: {from_format}", err=True)
raise typer.Exit(code=1)

# Then encode to target format
if to_format == "raw":
typer.echo(f"Raw bytes: {raw_bytes!r}")
typer.echo(f"As string: {raw_bytes.decode(errors='replace')}")
elif to_format == "base64":
encoded = base64.b64encode(raw_bytes).decode()
typer.echo(f"Base64 encoded: {encoded}")
elif to_format == "hex":
encoded = binascii.hexlify(raw_bytes).decode()
typer.echo(f"Hex encoded: {encoded}")
else:
typer.echo(f"Unknown target format: {to_format}", err=True)
raise typer.Exit(code=1)


if __name__ == "__main__":
app()
25 changes: 25 additions & 0 deletions examples/bytes_type_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import base64

import typer

app = typer.Typer()


@app.command()
def encode(text: bytes):
"""Encode text to base64."""
encoded = base64.b64encode(text)
typer.echo(f"Original: {text!r}")
typer.echo(f"Encoded: {encoded.decode()}")


@app.command()
def decode(encoded: str):
"""Decode base64 to bytes."""
decoded = base64.b64decode(encoded)
typer.echo(f"Encoded: {encoded}")
typer.echo(f"Decoded: {decoded!r}")


if __name__ == "__main__":
app()
98 changes: 98 additions & 0 deletions tests/test_bytes_encoding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import base64
import binascii

import typer
from typer.testing import CliRunner

runner = CliRunner()


def test_base64_encode_decode():
"""Test base64 encoding and decoding with bytes type."""
app = typer.Typer()

@app.command()
def encode(text: bytes):
"""Encode text to base64."""
encoded = base64.b64encode(text)
typer.echo(encoded.decode())

@app.command()
def decode(encoded: str):
"""Decode base64 to bytes."""
decoded = base64.b64decode(encoded)
typer.echo(repr(decoded))

# Test encoding
result = runner.invoke(app, ["encode", "Hello, world!"])
assert result.exit_code == 0
assert result.stdout.strip() == "SGVsbG8sIHdvcmxkIQ=="

# Test decoding
result = runner.invoke(app, ["decode", "SGVsbG8sIHdvcmxkIQ=="])
assert result.exit_code == 0
assert result.stdout.strip() == repr(b"Hello, world!")


def test_hex_encode_decode():
"""Test hex encoding and decoding with bytes type."""
app = typer.Typer()

@app.command()
def to_hex(data: bytes):
"""Convert bytes to hex string."""
hex_str = binascii.hexlify(data).decode()
typer.echo(hex_str)

@app.command()
def from_hex(hex_str: str):
"""Convert hex string to bytes."""
data = binascii.unhexlify(hex_str)
typer.echo(repr(data))

# Test to_hex
result = runner.invoke(app, ["to-hex", "ABC123"])
assert result.exit_code == 0
assert result.stdout.strip() == "414243313233" # Hex for "ABC123"

# Test from_hex
result = runner.invoke(app, ["from-hex", "414243313233"])
assert result.exit_code == 0
assert result.stdout.strip() == repr(b"ABC123")


def test_complex_bytes_operations():
"""Test more complex operations with bytes type."""
app = typer.Typer()

@app.command()
def main(
data: bytes = typer.Argument(..., help="Data to process"),
encoding: str = typer.Option("utf-8", help="Encoding to use for output"),
prefix: bytes = typer.Option(b"PREFIX:", help="Prefix to add to the data"),
):
"""Process bytes data with options."""
result = prefix + data
typer.echo(result.decode(encoding))

# Test with default encoding
result = runner.invoke(app, ["Hello"])
assert result.exit_code == 0
assert result.stdout.strip() == "PREFIX:Hello"

# Test with custom encoding
result = runner.invoke(app, ["Hello", "--encoding", "ascii"])
assert result.exit_code == 0
assert result.stdout.strip() == "PREFIX:Hello"

# Test with custom prefix
result = runner.invoke(app, ["Hello", "--prefix", "CUSTOM:"])
assert result.exit_code == 0
assert result.stdout.strip() == "CUSTOM:Hello"


if __name__ == "__main__":
test_base64_encode_decode()
test_hex_encode_decode()
test_complex_bytes_operations()
print("All tests passed!")
98 changes: 98 additions & 0 deletions tests/test_bytes_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import typer
from typer.testing import CliRunner

runner = CliRunner()


def test_bytes_type():
"""Test that bytes type works correctly."""
app = typer.Typer()

@app.command()
def main(name: bytes):
typer.echo(f"Bytes: {name!r}")

result = runner.invoke(app, ["hello"])
assert result.exit_code == 0
assert "Bytes: b'hello'" in result.stdout


def test_bytes_option():
"""Test that bytes type works correctly as an option."""
app = typer.Typer()

@app.command()
def main(name: bytes = typer.Option(b"default")):
typer.echo(f"Bytes: {name!r}")

result = runner.invoke(app)
assert result.exit_code == 0
assert "Bytes: b'default'" in result.stdout

result = runner.invoke(app, ["--name", "custom"])
assert result.exit_code == 0
assert "Bytes: b'custom'" in result.stdout


def test_bytes_argument():
"""Test that bytes type works correctly as an argument."""
app = typer.Typer()

@app.command()
def main(name: bytes = typer.Argument(b"default")):
typer.echo(f"Bytes: {name!r}")

result = runner.invoke(app)
assert result.exit_code == 0
assert "Bytes: b'default'" in result.stdout

result = runner.invoke(app, ["custom"])
assert result.exit_code == 0
assert "Bytes: b'custom'" in result.stdout


def test_bytes_non_string_input():
"""Test that bytes type works correctly with non-string input."""
app = typer.Typer()

@app.command()
def main(value: bytes):
typer.echo(f"Bytes: {value!r}")

# Test with a number (will be converted to string then bytes)
result = runner.invoke(app, ["123"])
assert result.exit_code == 0
assert "Bytes: b'123'" in result.stdout


def test_bytes_conversion_error():
"""Test error handling when bytes conversion fails."""
import click
from typer.main import BytesParamType

bytes_type = BytesParamType()

# Create a mock object that will raise UnicodeDecodeError when str() is called
class MockObj:
def __str__(self):
# This will trigger the UnicodeDecodeError in the except block
raise UnicodeDecodeError("utf-8", b"\x80abc", 0, 1, "invalid start byte")

# Create a mock context for testing
ctx = click.Context(click.Command("test"))

# This should raise a click.BadParameter exception
try:
bytes_type.convert(MockObj(), None, ctx)
raise AssertionError(
"Should have raised click.BadParameter"
) # pragma: no cover
except click.BadParameter:
pass # Test passes if we get here


if __name__ == "__main__":
test_bytes_type()
test_bytes_option()
test_bytes_argument()
print("All tests passed!")
25 changes: 25 additions & 0 deletions typer/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,29 @@ def wrapper(**kwargs: Any) -> Any:
return wrapper


class BytesParamType(click.ParamType):
name = "bytes"

def convert(
self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context]
) -> bytes:
if isinstance(value, bytes):
return value
try:
if isinstance(value, str):
return value.encode()
return str(value).encode()
except (UnicodeDecodeError, AttributeError):
self.fail(
f"{value!r} is not a valid string that can be encoded to bytes",
param,
ctx,
)


BYTES = BytesParamType()


def get_click_type(
*, annotation: Any, parameter_info: ParameterInfo
) -> click.ParamType:
Expand All @@ -712,6 +735,8 @@ def get_click_type(

elif annotation is str:
return click.STRING
elif annotation is bytes:
return BYTES
elif annotation is int:
if parameter_info.min is not None or parameter_info.max is not None:
min_ = None
Expand Down
Loading