Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,5 @@ local_settings.py
# Keep asset directories
!assets/icons/.gitkeep
!assets/images/.gitkeep

posts/*
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@
<a href="https://github.com/MrDebugger/deproto#testing">Testing</a>
</p>

<p align="center">
<a href="https://github.com/MrDebugger/deproto/blob/main/assets/videos/deproto-demo.mp4">
<img src="https://raw.githubusercontent.com/MrDebugger/deproto/main/assets/videos/deproto-demo.gif" alt="deproto demo: a Google Maps protobuf string decoded into a tree, a value edited, and the string encoded back" width="720"/>
</a>
<br/>
<sub>▶ <a href="https://github.com/MrDebugger/deproto/blob/main/assets/videos/deproto-demo.mp4">Watch the demo with sound (MP4)</a></sub>
</p>

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
Expand Down
Binary file added assets/videos/deproto-demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/videos/deproto-demo.mp4
Binary file not shown.
100 changes: 87 additions & 13 deletions deproto/protobuf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
6 changes: 3 additions & 3 deletions examples/tree_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
87 changes: 87 additions & 0 deletions tests/test_tree_operations.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import io
import unittest
from unittest import mock

from deproto import Cluster, Node, Protobuf
from deproto.types import IntType, StringType
Expand Down Expand Up @@ -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()
Loading