diff --git a/examples/bytes_encoding_example.py b/examples/bytes_encoding_example.py new file mode 100644 index 0000000000..f40fce267c --- /dev/null +++ b/examples/bytes_encoding_example.py @@ -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() diff --git a/examples/bytes_type_example.py b/examples/bytes_type_example.py new file mode 100644 index 0000000000..0d23ed49dc --- /dev/null +++ b/examples/bytes_type_example.py @@ -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() diff --git a/tests/test_bytes_encoding.py b/tests/test_bytes_encoding.py new file mode 100644 index 0000000000..4432e9157a --- /dev/null +++ b/tests/test_bytes_encoding.py @@ -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!") diff --git a/tests/test_bytes_type.py b/tests/test_bytes_type.py new file mode 100644 index 0000000000..51b74bd27c --- /dev/null +++ b/tests/test_bytes_type.py @@ -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!") diff --git a/typer/main.py b/typer/main.py index 508d96617e..60689bef49 100644 --- a/typer/main.py +++ b/typer/main.py @@ -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: @@ -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