diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index ec8c44a041..54ce6fdd84 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -17,4 +17,6 @@ It will be helpful if you run the following command and paste the results: textual diagnose ``` +If you don't have the `textual` command on your path, you may have forgotten to install the `textual-dev` package. + Feel free to add screenshots and / or videos. These can be very helpful! diff --git a/src/textual/_binary_encode.py b/src/textual/_binary_encode.py new file mode 100644 index 0000000000..0e42b57484 --- /dev/null +++ b/src/textual/_binary_encode.py @@ -0,0 +1,324 @@ +""" +An encoding / decoding format suitable for serializing data structures to binary. + +This is based on https://en.wikipedia.org/wiki/Bencode with some extensions. + +The following data types may be encoded: + +- None +- int +- bool +- bytes +- str +- list +- tuple +- dict + +""" + +from __future__ import annotations + +from typing import Any, Callable + + +class DecodeError(Exception): + """A problem decoding data.""" + + +def dump(data: object) -> bytes: + """Encodes a data structure in to bytes. + + Args: + data: Data structure + + Returns: + A byte string encoding the data. + """ + + def encode_none(_datum: None) -> bytes: + """ + Encodes a None value. + + Args: + datum: Always None. + + Returns: + None encoded. + """ + return b"N" + + def encode_bool(datum: bool) -> bytes: + """ + Encode a boolean value. + + Args: + datum: The boolean value to encode. + + Returns: + The encoded bytes. + """ + return b"T" if datum else b"F" + + def encode_int(datum: int) -> bytes: + """ + Encode an integer value. + + Args: + datum: The integer value to encode. + + Returns: + The encoded bytes. + """ + return b"i%ie" % datum + + def encode_bytes(datum: bytes) -> bytes: + """ + Encode a bytes value. + + Args: + datum: The bytes value to encode. + + Returns: + The encoded bytes. + """ + return b"%i:%s" % (len(datum), datum) + + def encode_string(datum: str) -> bytes: + """ + Encode a string value. + + Args: + datum: The string value to encode. + + Returns: + The encoded bytes. + """ + return b"s%i:%s" % (len(datum), datum.encode("utf-8")) + + def encode_list(datum: list) -> bytes: + """ + Encode a list value. + + Args: + datum: The list value to encode. + + Returns: + The encoded bytes. + """ + return b"l%se" % b"".join(encode(element) for element in datum) + + def encode_tuple(datum: tuple) -> bytes: + """ + Encode a tuple value. + + Args: + datum: The tuple value to encode. + + Returns: + The encoded bytes. + """ + return b"t%se" % b"".join(encode(element) for element in datum) + + def encode_dict(datum: dict) -> bytes: + """ + Encode a dictionary value. + + Args: + datum: The dictionary value to encode. + + Returns: + The encoded bytes. + """ + return b"d%se" % b"".join( + b"%s%s" % (encode(key), encode(value)) for key, value in datum.items() + ) + + ENCODERS: dict[type, Callable[[Any], Any]] = { + type(None): encode_none, + bool: encode_bool, + int: encode_int, + bytes: encode_bytes, + str: encode_string, + list: encode_list, + tuple: encode_tuple, + dict: encode_dict, + } + + def encode(datum: object) -> bytes: + """Recursively encode data. + + Args: + datum: Data suitable for encoding. + + Raises: + TypeError: If `datum` is not one of the supported types. + + Returns: + Encoded data bytes. + """ + try: + decoder = ENCODERS[type(datum)] + except KeyError: + raise TypeError("Can't encode {datum!r}") from None + return decoder(datum) + + return encode(data) + + +def load(encoded: bytes) -> object: + """Load an encoded data structure from bytes. + + Args: + encoded: Encoded data in bytes. + + Raises: + DecodeError: If an error was encountered decoding the string. + + Returns: + Decoded data. + """ + if not isinstance(encoded, bytes): + raise TypeError("must be bytes") + max_position = len(encoded) + position = 0 + + def get_byte() -> bytes: + """Get an encoded byte and advance position. + + Raises: + DecodeError: If the end of the data was reached + + Returns: + A bytes object with a single byte. + """ + nonlocal position + if position >= max_position: + raise DecodeError("More data expected") + character = encoded[position : position + 1] + position += 1 + return character + + def peek_byte() -> bytes: + """Get the byte at the current position, but don't advance position. + + Returns: + A bytes object with a single byte. + """ + return encoded[position : position + 1] + + def get_bytes(size: int) -> bytes: + """Get a number of bytes of encode data. + + Args: + size: Number of bytes to retrieve. + + Raises: + DecodeError: If there aren't enough bytes. + + Returns: + A bytes object. + """ + nonlocal position + bytes_data = encoded[position : position + size] + if len(bytes_data) != size: + raise DecodeError(b"Missing bytes in {bytes_data!r}") + position += size + return bytes_data + + def decode_int() -> int: + """Decode an int from the encoded data. + + Returns: + An integer. + """ + int_bytes = b"" + while (byte := get_byte()) != b"e": + int_bytes += byte + return int(int_bytes) + + def decode_bytes(size_bytes: bytes) -> bytes: + """Decode a bytes string from the encoded data. + + Returns: + A bytes object. + """ + while (byte := get_byte()) != b":": + size_bytes += byte + bytes_string = get_bytes(int(size_bytes)) + return bytes_string + + def decode_string() -> str: + """Decode a (utf-8 encoded) string from the encoded data. + + Returns: + A string. + """ + size_bytes = b"" + while (byte := get_byte()) != b":": + size_bytes += byte + bytes_string = get_bytes(int(size_bytes)) + decoded_string = bytes_string.decode("utf-8", errors="replace") + return decoded_string + + def decode_list() -> list[object]: + """Decode a list. + + Returns: + A list of data. + """ + elements: list[object] = [] + add_element = elements.append + while peek_byte() != b"e": + add_element(decode()) + get_byte() + return elements + + def decode_tuple() -> tuple[object, ...]: + """Decode a tuple. + + Returns: + A tuple of decoded data. + """ + elements: list[object] = [] + add_element = elements.append + while peek_byte() != b"e": + add_element(decode()) + get_byte() + return tuple(elements) + + def decode_dict() -> dict[object, object]: + """Decode a dict. + + Returns: + A dict of decoded data. + """ + elements: dict[object, object] = {} + add_element = elements.__setitem__ + while peek_byte() != b"e": + add_element(decode(), decode()) + get_byte() + return elements + + DECODERS = { + b"i": decode_int, + b"s": decode_string, + b"l": decode_list, + b"t": decode_tuple, + b"d": decode_dict, + b"T": lambda: True, + b"F": lambda: False, + b"N": lambda: None, + } + + def decode() -> object: + """Recursively decode data. + + Returns: + Decoded data. + """ + decoder = DECODERS.get(initial := get_byte(), None) + if decoder is None: + return decode_bytes(initial) + return decoder() + + return decode() diff --git a/src/textual/dom.py b/src/textual/dom.py index 1d1c8b5698..e5dceebe79 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1166,13 +1166,10 @@ def watch( """Watches for modifications to reactive attributes on another object. Example: - - Here's how you could detect when the app changes from dark to light mode (and vice versa). - ```python - def on_dark_change(old_value:bool, new_value:bool): + def on_dark_change(old_value:bool, new_value:bool) -> None: # Called when app.dark changes. - print("App.dark when from {old_value} to {new_value}") + print("App.dark went from {old_value} to {new_value}") self.watch(self.app, "dark", self.on_dark_change, init=False) ``` diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 2c32c31b77..317fd0fcd3 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -346,7 +346,15 @@ def set_timer( name: str | None = None, pause: bool = False, ) -> Timer: - """Make a function call after a delay. + """call a function after a delay. + + Example: + ```python + def ready(): + self.notify("Your soft boiled egg is ready!") + # Call ready() after 3 minutes + self.set_timer(3 * 60, ready) + ``` Args: delay: Time (in seconds) to wait before invoking callback. diff --git a/src/textual/screen.py b/src/textual/screen.py index f1591c0da9..5ee75c05d2 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -284,7 +284,6 @@ def _watch_stack_updates(self): def refresh_bindings(self) -> None: """Call to request a refresh of bindings.""" - self.log.debug("Bindings updated") self._bindings_updated = True self.check_idle() diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 81efbcb6c3..f30c84f727 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -231,7 +231,6 @@ def compose(self) -> ComposeResult: Footer.ctrl_to_caret, Footer.compact, ) - self.log(bindings) if self.show_command_palette and self.app.ENABLE_COMMAND_PALETTE: for key, binding in self.app._bindings: if binding.action in ( diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 22940f7644..a818ab788e 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -3,11 +3,10 @@ import pytest from tests.snapshot_tests.language_snippets import SNIPPETS -from textual.pilot import Pilot from textual.app import App -from textual.widgets.text_area import Selection, BUILTIN_LANGUAGES -from textual.widgets import RichLog, TextArea, Input, Button -from textual.widgets.text_area import TextAreaTheme +from textual.pilot import Pilot +from textual.widgets import Button, Input, RichLog, TextArea +from textual.widgets.text_area import BUILTIN_LANGUAGES, Selection, TextAreaTheme # These paths should be relative to THIS directory. WIDGET_EXAMPLES_DIR = Path("../../docs/examples/widgets") @@ -1433,8 +1432,7 @@ async def run_before(pilot: Pilot): await pilot.press(App.COMMAND_PALETTE_BINDING) await pilot.pause() await pilot.press(*"foo") - await pilot.pause() - await pilot.pause() + await pilot.app.workers.wait_for_complete() await pilot.press("escape") assert snap_compare( diff --git a/tests/test_binary_encode.py b/tests/test_binary_encode.py new file mode 100644 index 0000000000..2f9aaf98d6 --- /dev/null +++ b/tests/test_binary_encode.py @@ -0,0 +1,81 @@ +import pytest + +from textual._binary_encode import DecodeError, dump, load + + +@pytest.mark.parametrize( + "data", + [ + None, + False, + True, + -10, + -1, + 0, + 1, + 100, + "", + "Hello", + b"World", + b"", + [], + (), + [None], + [1, 2, 3], + ["hello", "world"], + ["hello", b"world"], + ("hello", "world"), + ("hello", b"world"), + {}, + {"foo": "bar"}, + {"foo": "bar", b"egg": b"baz"}, + {"foo": "bar", b"egg": b"baz", "list_of_things": [1, 2, 3, "Paul", "Jessica"]}, + [{}], + [[1]], + [(1, 2), (3, 4)], + ], +) +def test_round_trip(data: object) -> None: + """Test the data may be encoded then decoded""" + encoded = dump(data) + assert isinstance(encoded, bytes) + decoded = load(encoded) + assert data == decoded + + +@pytest.mark.parametrize( + "data", + [ + b"", + b"100:hello", + b"i", + b"i1", + b"i10", + b"li1e", + b"x100", + ], +) +def test_bad_encoding(data: bytes) -> None: + with pytest.raises(DecodeError): + load(data) + + +@pytest.mark.parametrize( + "data", + [ + set(), + float, + ..., + [float], + ], +) +def test_dump_invalid_type(data): + with pytest.raises(TypeError): + dump(data) + + +def test_load_wrong_type(): + with pytest.raises(TypeError): + load(None) + with pytest.raises(TypeError): + load("foo")