Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bfb093a
Remove debug_process.py file
doubledare704 Apr 3, 2025
87bcd4b
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Apr 3, 2025
73bd728
Fix linting issues and add type annotations
doubledare704 Apr 3, 2025
2766fe7
Fix mypy type error in BytesParamType.convert
doubledare704 Apr 3, 2025
38145f5
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Apr 3, 2025
c0ce2ce
make full coverage of tests
doubledare704 Apr 3, 2025
8a52f94
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Apr 3, 2025
de03a32
avoid assert false
doubledare704 Apr 3, 2025
dc98576
avoid coverage in 1 line of tests
doubledare704 Apr 3, 2025
3f94475
Merge branch 'master' into feature/support-bytes-type
doubledare704 Sep 6, 2025
32520a7
Merge branch 'master' into feature/support-bytes-type
doubledare704 Sep 12, 2025
8eb5fcc
Merge branch 'fastapi:master' into feature/support-bytes-type
doubledare704 Sep 27, 2025
963b0d1
feat(bytes): support encoding/errors for bytes params\n\n- Add encodi…
doubledare704 Sep 27, 2025
b5111fc
fix(tests): correct stderr assertion in bytes encoding test
doubledare704 Sep 27, 2025
a59129f
fix(tests): correct stderr assertion in bytes encoding test
doubledare704 Sep 27, 2025
2e8b8ec
fix(tests): correct stderr assertion in bytes encoding test
doubledare704 Sep 27, 2025
996a5d7
fix(tests): correct stderr assertion in bytes encoding test
doubledare704 Sep 27, 2025
77c2846
set no cover for uncoverable line
doubledare704 Sep 27, 2025
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
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