Skip to content
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 @@ -693,6 +693,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 @@ -704,6 +727,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