From bfb093a95773bd44e954485de54ac88631ccd420 Mon Sep 17 00:00:00 2001 From: doubledare704 Date: Thu, 3 Apr 2025 18:20:12 +0300 Subject: [PATCH 01/15] Remove debug_process.py file Add comprehensive tests for bytes type with base64 and hex encoding Add support for bytes type in Options and Arguments --- examples/bytes_encoding_example.py | 95 +++++++++++++++++++++++++++++ examples/bytes_type_example.py | 24 ++++++++ tests/test_bytes_encoding.py | 97 ++++++++++++++++++++++++++++++ tests/test_bytes_type.py | 58 ++++++++++++++++++ typer/main.py | 17 ++++++ 5 files changed, 291 insertions(+) create mode 100644 examples/bytes_encoding_example.py create mode 100644 examples/bytes_type_example.py create mode 100644 tests/test_bytes_encoding.py create mode 100644 tests/test_bytes_type.py diff --git a/examples/bytes_encoding_example.py b/examples/bytes_encoding_example.py new file mode 100644 index 0000000000..021c6bdfda --- /dev/null +++ b/examples/bytes_encoding_example.py @@ -0,0 +1,95 @@ +import typer +import base64 +import binascii + +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) + + +@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) + + +@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) + 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) + 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..a044229b9a --- /dev/null +++ b/examples/bytes_type_example.py @@ -0,0 +1,24 @@ +import typer +import base64 + +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..b6b1f71e9a --- /dev/null +++ b/tests/test_bytes_encoding.py @@ -0,0 +1,97 @@ +import typer +import base64 +import binascii +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..9ddcdd34a6 --- /dev/null +++ b/tests/test_bytes_type.py @@ -0,0 +1,58 @@ +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 + + +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..c60438ab8e 100644 --- a/typer/main.py +++ b/typer/main.py @@ -701,6 +701,21 @@ def wrapper(**kwargs: Any) -> Any: return wrapper +class BytesParamType(click.ParamType): + name = "bytes" + + def convert(self, value, param, ctx): + if isinstance(value, bytes): + return value + try: + return 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 +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 From 87bcd4b930e682d5d8e079eb6e4edaf9135eec30 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 3 Apr 2025 15:36:45 +0000 Subject: [PATCH 02/15] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/bytes_encoding_example.py | 3 ++- examples/bytes_type_example.py | 3 ++- tests/test_bytes_encoding.py | 7 ++++--- typer/main.py | 6 +++++- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/examples/bytes_encoding_example.py b/examples/bytes_encoding_example.py index 021c6bdfda..4c09350d44 100644 --- a/examples/bytes_encoding_example.py +++ b/examples/bytes_encoding_example.py @@ -1,7 +1,8 @@ -import typer import base64 import binascii +import typer + app = typer.Typer() diff --git a/examples/bytes_type_example.py b/examples/bytes_type_example.py index a044229b9a..0d23ed49dc 100644 --- a/examples/bytes_type_example.py +++ b/examples/bytes_type_example.py @@ -1,6 +1,7 @@ -import typer import base64 +import typer + app = typer.Typer() diff --git a/tests/test_bytes_encoding.py b/tests/test_bytes_encoding.py index b6b1f71e9a..4432e9157a 100644 --- a/tests/test_bytes_encoding.py +++ b/tests/test_bytes_encoding.py @@ -1,6 +1,7 @@ -import typer import base64 import binascii + +import typer from typer.testing import CliRunner runner = CliRunner() @@ -30,7 +31,7 @@ def decode(encoded: str): # Test decoding result = runner.invoke(app, ["decode", "SGVsbG8sIHdvcmxkIQ=="]) assert result.exit_code == 0 - assert result.stdout.strip() == repr(b'Hello, world!') + assert result.stdout.strip() == repr(b"Hello, world!") def test_hex_encode_decode(): @@ -57,7 +58,7 @@ def from_hex(hex_str: str): # Test from_hex result = runner.invoke(app, ["from-hex", "414243313233"]) assert result.exit_code == 0 - assert result.stdout.strip() == repr(b'ABC123') + assert result.stdout.strip() == repr(b"ABC123") def test_complex_bytes_operations(): diff --git a/typer/main.py b/typer/main.py index c60438ab8e..430cb0f46a 100644 --- a/typer/main.py +++ b/typer/main.py @@ -710,7 +710,11 @@ def convert(self, value, param, ctx): try: return value.encode() except (UnicodeDecodeError, AttributeError): - self.fail(f"{value!r} is not a valid string that can be encoded to bytes", param, ctx) + self.fail( + f"{value!r} is not a valid string that can be encoded to bytes", + param, + ctx, + ) BYTES = BytesParamType() From 73bd728e485b71ca89fe3c453252bd6cd32fdf99 Mon Sep 17 00:00:00 2001 From: doubledare704 Date: Thu, 3 Apr 2025 18:42:30 +0300 Subject: [PATCH 03/15] Fix linting issues and add type annotations --- examples/bytes_encoding_example.py | 8 ++++---- typer/main.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/bytes_encoding_example.py b/examples/bytes_encoding_example.py index 4c09350d44..f40fce267c 100644 --- a/examples/bytes_encoding_example.py +++ b/examples/bytes_encoding_example.py @@ -24,7 +24,7 @@ def base64_decode(encoded: str): 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) + raise typer.Exit(code=1) from e @app.command() @@ -45,7 +45,7 @@ def hex_decode(hex_str: str): 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) + raise typer.Exit(code=1) from e @app.command() @@ -66,13 +66,13 @@ def convert( raw_bytes = base64.b64decode(data) except Exception as e: typer.echo(f"Error decoding base64: {e}", err=True) - raise typer.Exit(code=1) + 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) + 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) diff --git a/typer/main.py b/typer/main.py index 430cb0f46a..5246001b4d 100644 --- a/typer/main.py +++ b/typer/main.py @@ -704,7 +704,7 @@ def wrapper(**kwargs: Any) -> Any: class BytesParamType(click.ParamType): name = "bytes" - def convert(self, value, param, ctx): + def convert(self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context]) -> bytes: if isinstance(value, bytes): return value try: From 2766fe7326e5d6dc1a951698691faa901c2e21bf Mon Sep 17 00:00:00 2001 From: doubledare704 Date: Thu, 3 Apr 2025 18:43:44 +0300 Subject: [PATCH 04/15] Fix mypy type error in BytesParamType.convert --- typer/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/typer/main.py b/typer/main.py index 5246001b4d..ea3df423e9 100644 --- a/typer/main.py +++ b/typer/main.py @@ -708,7 +708,9 @@ def convert(self, value: Any, param: Optional[click.Parameter], ctx: Optional[cl if isinstance(value, bytes): return value try: - return value.encode() + 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", From 38145f51705161da5b3995ce971c8006770767f9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 3 Apr 2025 15:45:05 +0000 Subject: [PATCH 05/15] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/typer/main.py b/typer/main.py index ea3df423e9..60689bef49 100644 --- a/typer/main.py +++ b/typer/main.py @@ -704,7 +704,9 @@ def wrapper(**kwargs: Any) -> Any: class BytesParamType(click.ParamType): name = "bytes" - def convert(self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context]) -> bytes: + def convert( + self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context] + ) -> bytes: if isinstance(value, bytes): return value try: From c0ce2ce88b7d6d68740b385ff1885d1e5bf8bb0a Mon Sep 17 00:00:00 2001 From: doubledare704 Date: Thu, 3 Apr 2025 18:55:50 +0300 Subject: [PATCH 06/15] make full coverage of tests --- tests/test_bytes_type.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_bytes_type.py b/tests/test_bytes_type.py index 9ddcdd34a6..1bf69f26b1 100644 --- a/tests/test_bytes_type.py +++ b/tests/test_bytes_type.py @@ -51,6 +51,44 @@ def main(name: bytes = typer.Argument(b"default")): 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.""" + from typer.main import BytesParamType + import click + + 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) + assert False, "Should have raised an exception" + except click.BadParameter: + assert True + + if __name__ == "__main__": test_bytes_type() test_bytes_option() From 8a52f9464f716150fc0c0de9c85b212651092e1e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 3 Apr 2025 15:56:06 +0000 Subject: [PATCH 07/15] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_bytes_type.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_bytes_type.py b/tests/test_bytes_type.py index 1bf69f26b1..e237fa09ae 100644 --- a/tests/test_bytes_type.py +++ b/tests/test_bytes_type.py @@ -67,8 +67,8 @@ def main(value: bytes): def test_bytes_conversion_error(): """Test error handling when bytes conversion fails.""" - from typer.main import BytesParamType import click + from typer.main import BytesParamType bytes_type = BytesParamType() @@ -76,7 +76,7 @@ def test_bytes_conversion_error(): 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') + raise UnicodeDecodeError("utf-8", b"\x80abc", 0, 1, "invalid start byte") # Create a mock context for testing ctx = click.Context(click.Command("test")) From de03a327946f9e0c30ccd0989fdbac5d97b4fed9 Mon Sep 17 00:00:00 2001 From: doubledare704 Date: Thu, 3 Apr 2025 19:14:02 +0300 Subject: [PATCH 08/15] avoid assert false --- tests/test_bytes_type.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_bytes_type.py b/tests/test_bytes_type.py index e237fa09ae..98ed1fc29d 100644 --- a/tests/test_bytes_type.py +++ b/tests/test_bytes_type.py @@ -84,9 +84,9 @@ def __str__(self): # This should raise a click.BadParameter exception try: bytes_type.convert(MockObj(), None, ctx) - assert False, "Should have raised an exception" + raise AssertionError("Should have raised an exception") except click.BadParameter: - assert True + pass # Test passes if we get here if __name__ == "__main__": From dc98576b2ff962c6c87c486df56734b8b1153ada Mon Sep 17 00:00:00 2001 From: doubledare704 Date: Thu, 3 Apr 2025 20:56:44 +0300 Subject: [PATCH 09/15] avoid coverage in 1 line of tests --- tests/test_bytes_type.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_bytes_type.py b/tests/test_bytes_type.py index 98ed1fc29d..51b74bd27c 100644 --- a/tests/test_bytes_type.py +++ b/tests/test_bytes_type.py @@ -84,7 +84,9 @@ def __str__(self): # This should raise a click.BadParameter exception try: bytes_type.convert(MockObj(), None, ctx) - raise AssertionError("Should have raised an exception") + raise AssertionError( + "Should have raised click.BadParameter" + ) # pragma: no cover except click.BadParameter: pass # Test passes if we get here From 963b0d19354414e83b21ed0c2e4d756787cfd36e Mon Sep 17 00:00:00 2001 From: doubledare704 Date: Sat, 27 Sep 2025 18:11:06 +0300 Subject: [PATCH 10/15] feat(bytes): support encoding/errors for bytes params\n\n- Add encoding-aware BytesParamType with helpers\n- Wire ParameterInfo.encoding/errors through Argument/Option\n- Add edge-case tests (non-ASCII, latin-1, ascii+errors, invalid encodings)\n- Expand examples to demonstrate encoding and errors\n- Add docs page: Tutorial -> CLI Parameter Types -> Bytes; wire into nav\n\nBackwards compatible: defaults to UTF-8 + 'strict' when not specified. --- docs/tutorial/parameter-types/bytes.md | 38 ++++++++++++ docs_src/parameter_types/bytes/__init__.py | 0 docs_src/parameter_types/bytes/tutorial001.py | 10 ++++ docs_src/parameter_types/bytes/tutorial002.py | 10 ++++ docs_src/parameter_types/bytes/tutorial003.py | 10 ++++ examples/bytes_encoding_example.py | 59 ++++++++++++++++++ examples/bytes_type_example.py | 32 ++++++++++ mkdocs.yml | 2 + tests/test_bytes_encoding.py | 60 +++++++++++++++++++ typer/main.py | 32 ++++++++-- 10 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 docs/tutorial/parameter-types/bytes.md create mode 100644 docs_src/parameter_types/bytes/__init__.py create mode 100644 docs_src/parameter_types/bytes/tutorial001.py create mode 100644 docs_src/parameter_types/bytes/tutorial002.py create mode 100644 docs_src/parameter_types/bytes/tutorial003.py 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 index f40fce267c..dcddd20f7c 100644 --- a/examples/bytes_encoding_example.py +++ b/examples/bytes_encoding_example.py @@ -92,5 +92,64 @@ def convert( 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 index 0d23ed49dc..906d5ecbbb 100644 --- a/examples/bytes_type_example.py +++ b/examples/bytes_type_example.py @@ -21,5 +21,37 @@ def decode(encoded: str): 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 index 4432e9157a..9cb4adf0ce 100644 --- a/tests/test_bytes_encoding.py +++ b/tests/test_bytes_encoding.py @@ -91,6 +91,66 @@ def main( 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")): + typer.echo(repr(name)) + + result = runner.invoke(app, ["--name", "x"]) + assert result.exit_code != 0 + assert "Could not encode" in result.stdout + + if __name__ == "__main__": test_base64_encode_decode() test_hex_encode_decode() diff --git a/typer/main.py b/typer/main.py index 0d8c31b1ce..6021ab15ee 100644 --- a/typer/main.py +++ b/typer/main.py @@ -691,18 +691,36 @@ def wrapper(**kwargs: Any) -> Any: 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: - if isinstance(value, str): - return value.encode() - return str(value).encode() - except (UnicodeDecodeError, AttributeError): + s = self._to_str(value) + return self._encode(s) + except Exception as e: self.fail( - f"{value!r} is not a valid string that can be encoded to bytes", + f"Could not encode {value!r} to bytes" + + (f" with encoding={self.encoding!r}" if self.encoding else "") + + f": {e}", param, ctx, ) @@ -723,7 +741,9 @@ def get_click_type( elif annotation is str: return click.STRING elif annotation is bytes: - return 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 From b5111fccc89eb78eacdc56bc5b313cfa5eacb9a9 Mon Sep 17 00:00:00 2001 From: doubledare704 Date: Sat, 27 Sep 2025 18:55:53 +0300 Subject: [PATCH 11/15] fix(tests): correct stderr assertion in bytes encoding test - Update test to assert error message in `stderr` instead of `stdout`. - Adjust indentation for function parameters to improve readability. --- tests/test_bytes_encoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_bytes_encoding.py b/tests/test_bytes_encoding.py index 9cb4adf0ce..0dfd1791fa 100644 --- a/tests/test_bytes_encoding.py +++ b/tests/test_bytes_encoding.py @@ -148,7 +148,7 @@ def main(name: bytes = typer.Option(..., encoding="no-such-enc")): result = runner.invoke(app, ["--name", "x"]) assert result.exit_code != 0 - assert "Could not encode" in result.stdout + assert "Could not encode" in result.stderr if __name__ == "__main__": From a59129ff5f9a8e8252f67fbbf72a2bd977df176f Mon Sep 17 00:00:00 2001 From: doubledare704 Date: Sat, 27 Sep 2025 19:04:29 +0300 Subject: [PATCH 12/15] fix(tests): correct stderr assertion in bytes encoding test - Update test to assert error message in `stderr` instead of `stdout`. - Adjust indentation for function parameters to improve readability. --- tests/test_bytes_encoding.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_bytes_encoding.py b/tests/test_bytes_encoding.py index 0dfd1791fa..3ea862fb8c 100644 --- a/tests/test_bytes_encoding.py +++ b/tests/test_bytes_encoding.py @@ -148,7 +148,9 @@ def main(name: bytes = typer.Option(..., encoding="no-such-enc")): result = runner.invoke(app, ["--name", "x"]) assert result.exit_code != 0 - assert "Could not encode" in result.stderr + # Check both stderr and stdout for the error message + error_text = (result.stderr or "") + (result.stdout or "") + assert "Could not encode" in error_text if __name__ == "__main__": From 2e8b8ec4329ff673299d182c60f21bec1044ab6e Mon Sep 17 00:00:00 2001 From: doubledare704 Date: Sat, 27 Sep 2025 19:15:13 +0300 Subject: [PATCH 13/15] fix(tests): correct stderr assertion in bytes encoding test - Update test to assert error message in `stderr` instead of `stdout`. - Adjust indentation for function parameters to improve readability. --- tests/test_bytes_encoding.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/test_bytes_encoding.py b/tests/test_bytes_encoding.py index 3ea862fb8c..82c23446b4 100644 --- a/tests/test_bytes_encoding.py +++ b/tests/test_bytes_encoding.py @@ -148,9 +148,16 @@ def main(name: bytes = typer.Option(..., encoding="no-such-enc")): result = runner.invoke(app, ["--name", "x"]) assert result.exit_code != 0 - # Check both stderr and stdout for the error message - error_text = (result.stderr or "") + (result.stdout or "") - assert "Could not encode" in error_text + + # 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 "") if __name__ == "__main__": From 996a5d7f712b0940c5d2228c0279f440eff2966d Mon Sep 17 00:00:00 2001 From: doubledare704 Date: Sat, 27 Sep 2025 19:25:56 +0300 Subject: [PATCH 14/15] fix(tests): correct stderr assertion in bytes encoding test - Update test to assert error message in `stderr` instead of `stdout`. - Adjust indentation for function parameters to improve readability. --- tests/test_bytes_encoding.py | 9 +-------- tests/test_bytes_type.py | 7 ------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/tests/test_bytes_encoding.py b/tests/test_bytes_encoding.py index 82c23446b4..b5df628eba 100644 --- a/tests/test_bytes_encoding.py +++ b/tests/test_bytes_encoding.py @@ -144,7 +144,7 @@ def test_bytes_invalid_encoding_name(): @app.command() def main(name: bytes = typer.Option(..., encoding="no-such-enc")): - typer.echo(repr(name)) + print(name) result = runner.invoke(app, ["--name", "x"]) assert result.exit_code != 0 @@ -158,10 +158,3 @@ def main(name: bytes = typer.Option(..., encoding="no-such-enc")): except (ValueError, AttributeError): # Fallback to checking combined output for older versions assert error_message in (result.output or "") - - -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 index 51b74bd27c..d4f878bdfe 100644 --- a/tests/test_bytes_type.py +++ b/tests/test_bytes_type.py @@ -89,10 +89,3 @@ def __str__(self): ) # 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!") From 77c284670ecbe87c11387ebe76e4967c77fc6b4a Mon Sep 17 00:00:00 2001 From: doubledare704 Date: Sat, 27 Sep 2025 19:45:46 +0300 Subject: [PATCH 15/15] set no cover for uncoverable line --- tests/test_bytes_encoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_bytes_encoding.py b/tests/test_bytes_encoding.py index b5df628eba..6181ef9420 100644 --- a/tests/test_bytes_encoding.py +++ b/tests/test_bytes_encoding.py @@ -144,7 +144,7 @@ def test_bytes_invalid_encoding_name(): @app.command() def main(name: bytes = typer.Option(..., encoding="no-such-enc")): - print(name) + print(name) # pragma: no cover result = runner.invoke(app, ["--name", "x"]) assert result.exit_code != 0