diff --git a/docs/tutorial/parameter-types/bytes.md b/docs/tutorial/parameter-types/bytes.md new file mode 100644 index 0000000000..9df45148f5 --- /dev/null +++ b/docs/tutorial/parameter-types/bytes.md @@ -0,0 +1,38 @@ +# Bytes + +You can declare `bytes` for CLI arguments and options. + +By default, `bytes` are created by encoding the input string with UTF-8 (the same as Python's default for `str.encode()`), but you can configure the encoding and error handling. + +## Default UTF-8 encoding + +This example declares a `bytes` argument using the default UTF-8 encoding: + +{* docs_src/parameter_types/bytes/tutorial001.py *} + +Try it with non-ASCII characters and you will get UTF-8 encoded bytes. + +## Custom encoding on Argument + +You can set a specific encoding for a `bytes` argument: + +{* docs_src/parameter_types/bytes/tutorial002.py hl[4] *} + +Here the argument is configured with `encoding="latin-1"`, so the command line input will be encoded accordingly. + +## Custom encoding and errors on Option + +You can also configure a `bytes` option with a specific encoding and error handling mode: + +{* docs_src/parameter_types/bytes/tutorial003.py hl[4] *} + +The `errors` parameter supports the same values as Python's `str.encode()` (e.g. `"strict"`, `"ignore"`, `"replace"`). + +## Primary use case + +The goal of supporting `bytes` is to let you write a single function that works both: + +- Inside Typer: when called as a CLI, Typer parses command line input and converts it to `bytes` using the configured `encoding`/`errors`. +- Outside Typer: when called as regular Python code, you can pass `bytes` directly, without any CLI parsing involved. + +This keeps your function reusable in both contexts while giving you control over how CLI text inputs are converted to `bytes`. diff --git a/docs_src/parameter_types/bytes/__init__.py b/docs_src/parameter_types/bytes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/parameter_types/bytes/tutorial001.py b/docs_src/parameter_types/bytes/tutorial001.py new file mode 100644 index 0000000000..330844629e --- /dev/null +++ b/docs_src/parameter_types/bytes/tutorial001.py @@ -0,0 +1,10 @@ +import typer + + +def main(data: bytes): + # Default encoding is UTF-8 + print(f"Bytes: {data!r}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs_src/parameter_types/bytes/tutorial002.py b/docs_src/parameter_types/bytes/tutorial002.py new file mode 100644 index 0000000000..ebe4b8f499 --- /dev/null +++ b/docs_src/parameter_types/bytes/tutorial002.py @@ -0,0 +1,10 @@ +import typer + + +def main(data: bytes = typer.Argument(..., encoding="latin-1")): + # Argument configured to use latin-1 + print(f"Bytes (latin-1): {data!r}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs_src/parameter_types/bytes/tutorial003.py b/docs_src/parameter_types/bytes/tutorial003.py new file mode 100644 index 0000000000..2790d439cf --- /dev/null +++ b/docs_src/parameter_types/bytes/tutorial003.py @@ -0,0 +1,10 @@ +import typer + + +def main(token: bytes = typer.Option(..., encoding="ascii", errors="replace")): + # Option configured with ascii encoding and errors=replace + print(f"Token: {token!r}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/examples/bytes_encoding_example.py b/examples/bytes_encoding_example.py new file mode 100644 index 0000000000..dcddd20f7c --- /dev/null +++ b/examples/bytes_encoding_example.py @@ -0,0 +1,155 @@ +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) + + +@app.command() +def convert_latin1( + data: bytes = typer.Argument( + ..., help="Data to convert (latin-1)", encoding="latin-1" + ), + 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 using latin-1 input decoding for the bytes argument.""" + # Reuse the same logic as convert() + 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) + + 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) + + +@app.command() +def option_ascii_replace( + payload: bytes = typer.Option( + ..., + "--payload", + help="Bytes option encoded with ascii and errors=replace", + encoding="ascii", + errors="replace", + ), +): + """Demonstrate bytes option with ascii encoding and errors=replace.""" + typer.echo(f"Option bytes: {payload!r}") + + +if __name__ == "__main__": + app() diff --git a/examples/bytes_type_example.py b/examples/bytes_type_example.py new file mode 100644 index 0000000000..906d5ecbbb --- /dev/null +++ b/examples/bytes_type_example.py @@ -0,0 +1,57 @@ +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}") + + +@app.command() +def echo_default( + name: bytes = typer.Argument(..., help="Name as bytes (default UTF-8)"), +): + """Echo bytes with default UTF-8 encoding.""" + typer.echo(f"Default UTF-8 bytes: {name!r}") + + +@app.command() +def echo_latin1( + name: bytes = typer.Argument( + ..., encoding="latin-1", help="Name as bytes (latin-1)" + ), +): + """Echo bytes with latin-1 encoding for the argument.""" + typer.echo(f"Latin-1 bytes: {name!r}") + + +@app.command() +def option_ascii_replace( + token: bytes = typer.Option( + ..., + "--token", + encoding="ascii", + errors="replace", + help="Token as bytes (ascii, errors=replace)", + ), +): + """Option demonstrating ascii encoding with errors=replace.""" + typer.echo(f"Option bytes: {token!r}") + + +if __name__ == "__main__": + app() diff --git a/mkdocs.yml b/mkdocs.yml index a9a661098b..f21cdbf7c0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -113,6 +113,8 @@ nav: - tutorial/parameter-types/enum.md - tutorial/parameter-types/path.md - tutorial/parameter-types/file.md + - tutorial/parameter-types/bytes.md + - tutorial/parameter-types/custom-types.md - SubCommands - Command Groups: - tutorial/subcommands/index.md diff --git a/tests/test_bytes_encoding.py b/tests/test_bytes_encoding.py new file mode 100644 index 0000000000..6181ef9420 --- /dev/null +++ b/tests/test_bytes_encoding.py @@ -0,0 +1,160 @@ +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" + + +def test_bytes_default_utf8(): + app = typer.Typer() + + @app.command() + def main(name: bytes): + typer.echo(repr(name)) + + result = runner.invoke(app, ["héllö"]) + assert result.exit_code == 0 + assert result.stdout.strip() == repr("héllö".encode()) + + +def test_bytes_custom_encoding_option(): + app = typer.Typer() + + @app.command() + def main(name: bytes = typer.Option(..., encoding="latin-1")): + typer.echo(repr(name)) + + result = runner.invoke(app, ["--name", "ñ"]) + assert result.exit_code == 0 + assert result.stdout.strip() == repr("ñ".encode("latin-1")) + + +def test_bytes_custom_encoding_argument(): + app = typer.Typer() + + @app.command() + def main(name: bytes = typer.Argument(..., encoding="latin-1")): + typer.echo(repr(name)) + + result = runner.invoke(app, ["ñ"]) + assert result.exit_code == 0 + assert result.stdout.strip() == repr("ñ".encode("latin-1")) + + +def test_bytes_errors_replace_ascii(): + app = typer.Typer() + + @app.command() + def main(name: bytes = typer.Option(..., encoding="ascii", errors="replace")): + typer.echo(repr(name)) + + result = runner.invoke(app, ["--name", "é"]) + assert result.exit_code == 0 + assert result.stdout.strip() == repr("é".encode("ascii", "replace")) + + +def test_bytes_invalid_encoding_name(): + app = typer.Typer() + + @app.command() + def main(name: bytes = typer.Option(..., encoding="no-such-enc")): + print(name) # pragma: no cover + + result = runner.invoke(app, ["--name", "x"]) + assert result.exit_code != 0 + + # Check for the error message in either stderr or combined output + # to handle different Click testing behavior across Python versions + error_message = "Could not encode" + try: + # Try stderr first (works in newer versions) + assert error_message in result.stderr + except (ValueError, AttributeError): + # Fallback to checking combined output for older versions + assert error_message in (result.output or "") diff --git a/tests/test_bytes_type.py b/tests/test_bytes_type.py new file mode 100644 index 0000000000..d4f878bdfe --- /dev/null +++ b/tests/test_bytes_type.py @@ -0,0 +1,91 @@ +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 diff --git a/typer/main.py b/typer/main.py index 2fdc09e1ac..6021ab15ee 100644 --- a/typer/main.py +++ b/typer/main.py @@ -688,6 +688,47 @@ def wrapper(**kwargs: Any) -> Any: return wrapper +class BytesParamType(click.ParamType): + name = "bytes" + + def __init__( + self, encoding: Optional[str] = None, errors: Optional[str] = None + ) -> None: + super().__init__() + self.encoding = encoding + self.errors = errors + + def _to_str(self, value: Any) -> str: + if isinstance(value, str): + return value + return str(value) + + def _encode(self, s: str) -> bytes: + enc = self.encoding or "utf-8" + errs = self.errors or "strict" + return s.encode(enc, errs) + + def convert( + self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context] + ) -> bytes: + if isinstance(value, bytes): + return value + try: + s = self._to_str(value) + return self._encode(s) + except Exception as e: + self.fail( + f"Could not encode {value!r} to bytes" + + (f" with encoding={self.encoding!r}" if self.encoding else "") + + f": {e}", + param, + ctx, + ) + + +BYTES = BytesParamType() + + def get_click_type( *, annotation: Any, parameter_info: ParameterInfo ) -> click.ParamType: @@ -699,6 +740,10 @@ def get_click_type( elif annotation is str: return click.STRING + elif annotation is bytes: + return BytesParamType( + encoding=parameter_info.encoding, errors=parameter_info.errors + ) elif annotation is int: if parameter_info.min is not None or parameter_info.max is not None: min_ = None