diff --git a/.gitignore b/.gitignore index df2661b..9c2204a 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,5 @@ local_settings.py # Keep asset directories !assets/icons/.gitkeep !assets/images/.gitkeep + +posts/* \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index da0be7a..0971a80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- `print_tree()` no longer raises `UnicodeEncodeError` on consoles that can't encode box-drawing characters (e.g. the default cp1252 Windows console); it prints ASCII connectors (`|-- `, `` `-- ``, `| `) instead. The returned string is unchanged. + ## [0.2.5] - 2025-01-02 ### Added diff --git a/README.md b/README.md index ae1ab8d..07ecf4d 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,14 @@ Testing

+

+ + deproto demo: a Google Maps protobuf string decoded into a tree, a value edited, and the string encoded back + +
+ ▶ Watch the demo with sound (MP4) +

+ A Python package for decoding, manipulating, and encoding Google Maps protobuf format strings. This library provides an intuitive way to work with protobuf structures commonly found in Google Maps URLs and data. ## Features diff --git a/assets/videos/deproto-demo.gif b/assets/videos/deproto-demo.gif new file mode 100644 index 0000000..0badfa4 Binary files /dev/null and b/assets/videos/deproto-demo.gif differ diff --git a/assets/videos/deproto-demo.mp4 b/assets/videos/deproto-demo.mp4 new file mode 100644 index 0000000..be855dd Binary files /dev/null and b/assets/videos/deproto-demo.mp4 differ diff --git a/deproto/protobuf.py b/deproto/protobuf.py index d3dbd1b..dc6c21a 100644 --- a/deproto/protobuf.py +++ b/deproto/protobuf.py @@ -21,12 +21,47 @@ """ import re +import sys from typing import List, Optional, Tuple from deproto.cluster import Cluster from deproto.node import Node from deproto.types import DataTypeFactory +# Tree connectors as (branch, last branch, vertical continuation). +UNICODE_CONNECTORS: Tuple[str, str, str] = ("├── ", "└── ", "│ ") +ASCII_CONNECTORS: Tuple[str, str, str] = ("|-- ", "`-- ", "| ") + + +def _can_encode(text: str, encoding: Optional[str]) -> bool: + """Check whether ``text`` can be encoded with ``encoding``. + + :param text: Text to check + :param encoding: Target encoding; ``None`` means the stream accepts + any ``str`` (e.g. ``io.StringIO``) + :return: True if the text can be encoded, False otherwise + """ + if not encoding: + return True + try: + text.encode(encoding) + except (UnicodeEncodeError, LookupError): + return False + return True + + +def _replace_unencodable(text: str, encoding: str) -> str: + """Replace characters that ``encoding`` cannot represent with '?'. + + :param text: Text to sanitize + :param encoding: Target encoding; unknown encodings fall back to ASCII + :return: Text that can be safely encoded with ``encoding`` + """ + try: + return text.encode(encoding, "replace").decode(encoding) + except LookupError: + return text.encode("ascii", "replace").decode("ascii") + class Protobuf: """Decoder for Google Maps protobuf format.""" @@ -103,47 +138,86 @@ def encode(self) -> str: return "" return "".join(node.encode() for node in self.root.nodes) - def _tree_lines(self, node: Cluster, prefix: str) -> List[str]: + def _tree_lines( + self, + node: Cluster, + prefix: str, + connectors: Tuple[str, str, str] = UNICODE_CONNECTORS, + ) -> List[str]: """Generate tree lines recursively. :param node: Cluster node to process :param prefix: Current indentation prefix + :param connectors: (branch, last branch, vertical) connector strings :return: List of formatted tree lines """ + branch, last_branch, vertical = connectors result = [] for i, child in enumerate(node.nodes): is_last = i == len(node.nodes) - 1 - connector = "└── " if is_last else "├── " + connector = last_branch if is_last else branch line = f"{prefix}{connector}{child.index + 1}" if isinstance(child, Cluster): line += f"m{child.total}" result.append(line) - new_prefix = prefix + (" " if is_last else "│ ") - result.extend(self._tree_lines(child, new_prefix)) + new_prefix = prefix + (" " if is_last else vertical) + result.extend(self._tree_lines(child, new_prefix, connectors)) else: line += f"{child.type}{child.value_raw}" result.append(line) return result - def print_tree( + def _format_tree( self, - cluster: Optional[Cluster] = None, - prefix: str = "", - stdout: bool = True, - ) -> Optional[str]: - """Print or return a visual representation of the tree.""" + cluster: Optional[Cluster], + prefix: str, + connectors: Tuple[str, str, str], + ) -> str: + """Build the tree string using the given connectors. + + :param cluster: Cluster to render, or None for the root + :param prefix: Initial indentation prefix + :param connectors: (branch, last branch, vertical) connector strings + :return: Tree representation as a string + """ result = [] if cluster is None: cluster = self.root result.append(f"{cluster.index + 1}m{cluster.total}") if isinstance(cluster, Cluster): - result.extend(self._tree_lines(cluster, prefix)) + result.extend(self._tree_lines(cluster, prefix, connectors)) + + return "\n".join(result) + + def print_tree( + self, + cluster: Optional[Cluster] = None, + prefix: str = "", + stdout: bool = True, + ) -> Optional[str]: + """Print or return a visual representation of the tree. + + The returned string always uses Unicode box-drawing connectors. When + printing, if ``sys.stdout`` cannot encode them (e.g. a cp1252 Windows + console), ASCII connectors are printed instead, and any remaining + unencodable characters are replaced rather than raising. - tree_str = "\n".join(result) + :param cluster: Cluster to render, or None for the root + :param prefix: Initial indentation prefix + :param stdout: Whether to print the tree to ``sys.stdout`` + :return: Tree representation with Unicode connectors + """ + tree_str = self._format_tree(cluster, prefix, UNICODE_CONNECTORS) if stdout: - print(tree_str) + encoding = getattr(sys.stdout, "encoding", None) + output = tree_str + if not _can_encode(output, encoding): + output = self._format_tree(cluster, prefix, ASCII_CONNECTORS) + if not _can_encode(output, encoding): + output = _replace_unencodable(output, encoding) + print(output) return tree_str diff --git a/examples/tree_operations.py b/examples/tree_operations.py index 8d473b2..6670240 100644 --- a/examples/tree_operations.py +++ b/examples/tree_operations.py @@ -59,11 +59,11 @@ def demonstrate_tree_serialization(): print("\nJSON representation:") print(json_data) - # Show tree visualization as string + # Show tree visualization; print_tree() handles console encoding + # (falls back to ASCII connectors when box-drawing chars can't print) pb = Protobuf("") - tree_str = pb.print_tree(root, stdout=False) print("\nTree visualization:") - print(tree_str) + pb.print_tree(root) def main(): diff --git a/tests/test_tree_operations.py b/tests/test_tree_operations.py index b093e02..407be4c 100644 --- a/tests/test_tree_operations.py +++ b/tests/test_tree_operations.py @@ -1,4 +1,6 @@ +import io import unittest +from unittest import mock from deproto import Cluster, Node, Protobuf from deproto.types import IntType, StringType @@ -79,5 +81,90 @@ def test_tree_visualization(self): self.assertIn("i42", tree_str) +class TestPrintTreeConsoleEncoding(unittest.TestCase): + """print_tree() must not crash on consoles that lack box-drawing glyphs. + + Default Windows consoles (PowerShell/cmd) use cp1252 for stdout, which + cannot encode the Unicode connectors used in the tree. + """ + + PB_STRING = "!1m4!1stest!2m1!1i42!3send" + EXPECTED_TREE = ( + "1m5\n" + "└── 1m4\n" + " ├── 1stest\n" + " ├── 2m1\n" + " │ └── 1i42\n" + " └── 3send" + ) + + def setUp(self): + self.pb = Protobuf(self.PB_STRING) + self.pb.decode() + + @staticmethod + def _make_stdout(encoding): + buffer = io.BytesIO() + stream = io.TextIOWrapper(buffer, encoding=encoding, newline="\n") + return buffer, stream + + def _print_to(self, encoding, pb=None): + pb = pb or self.pb + buffer, stream = self._make_stdout(encoding) + with mock.patch("sys.stdout", stream): + returned = pb.print_tree() + stream.flush() + return returned, buffer.getvalue().decode(encoding) + + def test_cp1252_stdout_does_not_crash(self): + returned, printed = self._print_to("cp1252") + + # Returned value keeps the Unicode box-drawing characters. + self.assertEqual(returned, self.EXPECTED_TREE) + # Printed output falls back to ASCII connectors. + self.assertEqual( + printed, + "1m5\n" + "`-- 1m4\n" + " |-- 1stest\n" + " |-- 2m1\n" + " | `-- 1i42\n" + " `-- 3send\n", + ) + + def test_utf8_stdout_prints_unicode_tree(self): + returned, printed = self._print_to("utf-8") + + self.assertEqual(returned, self.EXPECTED_TREE) + self.assertEqual(printed, self.EXPECTED_TREE + "\n") + + def test_stdout_without_encoding_prints_unicode_tree(self): + stream = io.StringIO() # ``encoding`` is None + self.assertIsNone(stream.encoding) + with mock.patch("sys.stdout", stream): + returned = self.pb.print_tree() + + self.assertEqual(returned, self.EXPECTED_TREE) + self.assertEqual(stream.getvalue(), self.EXPECTED_TREE + "\n") + + def test_unencodable_values_are_replaced_not_raised(self): + pb = Protobuf("!1m1!1s日本") + pb.decode() + + returned, printed = self._print_to("cp1252", pb) + + self.assertEqual(returned, "1m2\n└── 1m1\n └── 1s日本") + self.assertEqual(printed, "1m2\n`-- 1m1\n `-- 1s??\n") + + def test_stdout_false_does_not_print(self): + buffer, stream = self._make_stdout("cp1252") + with mock.patch("sys.stdout", stream): + returned = self.pb.print_tree(stdout=False) + stream.flush() + + self.assertEqual(returned, self.EXPECTED_TREE) + self.assertEqual(buffer.getvalue(), b"") + + if __name__ == "__main__": unittest.main()