pw_snapshot: Add snapshot dump tooling

Adds python tooling to create text dumps of snapshots.

Change-Id: Ic284001079bf4b9fd03c22e3f1dd93c3b8205f07
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/53000
Reviewed-by: Ewout van Bekkum <ewout@google.com>
diff --git a/pw_env_setup/BUILD.gn b/pw_env_setup/BUILD.gn
index 6220f48..d78567b 100644
--- a/pw_env_setup/BUILD.gn
+++ b/pw_env_setup/BUILD.gn
@@ -44,6 +44,7 @@
     "$dir_pw_protobuf/py",
     "$dir_pw_protobuf_compiler/py",
     "$dir_pw_rpc/py",
+    "$dir_pw_snapshot/py",
     "$dir_pw_status/py",
     "$dir_pw_stm32cube_build/py",
     "$dir_pw_third_party/boringssl/py",
diff --git a/pw_snapshot/BUILD.gn b/pw_snapshot/BUILD.gn
index 06a7136..f2be8a7 100644
--- a/pw_snapshot/BUILD.gn
+++ b/pw_snapshot/BUILD.gn
@@ -31,6 +31,7 @@
   sources = [ "pw_snapshot_protos/snapshot_metadata.proto" ]
   strip_prefix = "pw_snapshot_protos"
   prefix = "pw_snapshot_metadata_proto"
+  deps = [ "$dir_pw_tokenizer:proto" ]
 }
 
 # This proto provides the complete "Snapshot" proto, which depends on various
diff --git a/pw_snapshot/module_usage.rst b/pw_snapshot/module_usage.rst
index 7674eee..a3b2c42 100644
--- a/pw_snapshot/module_usage.rst
+++ b/pw_snapshot/module_usage.rst
@@ -89,3 +89,42 @@
 the proto data can be decoded a second time using a project-specific proto. At
 that point, any handling logic of the project-specific data would have to be
 done as part of project-specific tooling.
+
+-------------------
+Analyzing Snapshots
+-------------------
+Snapshots can be processed for analysis using the ``pw_snapshot.process`` python
+tool. This tool turns a binary snapshot proto into human readable, actionable
+information. As some snapshot fields may optionally be tokenized, a
+pw_tokenizer database or ELF file with embedded pw_tokenizer tokens may
+optionally be passed to the tool to detokenize applicable fields.
+
+.. code-block:: sh
+
+  # Example invocation, which dumps to stdout by default.
+  $ python -m pw_snapshot.processor path/to/serialized_snapshot.bin
+
+
+          ____ _       __    _____ _   _____    ____  _____ __  ______  ______
+         / __ \ |     / /   / ___// | / /   |  / __ \/ ___// / / / __ \/_  __/
+        / /_/ / | /| / /    \__ \/  |/ / /| | / /_/ /\__ \/ /_/ / / / / / /
+       / ____/| |/ |/ /    ___/ / /|  / ___ |/ ____/___/ / __  / /_/ / / /
+      /_/     |__/|__/____/____/_/ |_/_/  |_/_/    /____/_/ /_/\____/ /_/
+                    /_____/
+
+
+                              ▪▄▄▄ ▄▄▄· ▄▄▄▄▄ ▄▄▄· ▄ ·
+                              █▄▄▄▐█ ▀█ • █▌ ▐█ ▀█ █
+                              █ ▪ ▄█▀▀█   █. ▄█▀▀█ █
+                              ▐▌ .▐█ ▪▐▌ ▪▐▌·▐█ ▪▐▌▐▌
+                              ▀    ▀  ▀ ·  ▀  ▀  ▀ .▀▀
+
+  Device crash cause:
+      Assert failed: 1+1 == 42
+
+  Project name:      gShoe
+  Device:            GSHOE-QUANTUM_CORE-REV_0.1
+  Device FW version: QUANTUM_CORE-0.1.325-e4a84b1a
+  FW build UUID:     ad2d39258c1bc487f07ca7e04991a836fdf7d0a0
+  Snapshot UUID:     8481bb12a162164f5c74855f6d94ea1a
+
diff --git a/pw_snapshot/pw_snapshot_protos/snapshot_metadata.proto b/pw_snapshot/pw_snapshot_protos/snapshot_metadata.proto
index 5a2612b..dabf49b 100644
--- a/pw_snapshot/pw_snapshot_protos/snapshot_metadata.proto
+++ b/pw_snapshot/pw_snapshot_protos/snapshot_metadata.proto
@@ -15,6 +15,8 @@
 
 package pw.snapshot;
 
+import "pw_tokenizer/proto/options.proto";
+
 option java_package = "pw.snapshot.proto";
 option java_outer_classname = "Snapshot";
 
@@ -36,14 +38,14 @@
   //   Null-pointer dereference
   //   [main.cc:22] True is not false!
   //   STACK_OVERFLOW
-  bytes reason = 1;
+  bytes reason = 1 [(tokenizer.format) = TOKENIZATION_OPTIONAL];
 
   // Whether or not the snapshot was captured due to a crash of some kind.
   bool fatal = 2;
 
   // Project name to assist in identifying where to redirect this snapshot. A
   // single project might have multiple devices that can produce snapshots.
-  bytes project_name = 3;
+  bytes project_name = 3 [(tokenizer.format) = TOKENIZATION_OPTIONAL];
 
   // Version characters must be alphanumeric, punctuation, and space. This
   // string is case-sensitive. This should either be human readable text, or
@@ -52,7 +54,7 @@
   // Examples:
   //   "codename-local-[build_id]"
   //   "codename-release-193"
-  bytes software_version = 4;
+  string software_version = 4;
 
   // UUID associated with the build for the software version.
   bytes software_build_uuid = 5;
@@ -67,7 +69,7 @@
   //   "propellerhat-evk"
   //   "gshoe-sensor-core-pvt"
   //   "alarm-clock-dsp-p1"
-  bytes device_name = 6;
+  bytes device_name = 6 [(tokenizer.format) = TOKENIZATION_OPTIONAL];
 
   // 128-bit UUID for this snapshot, used to help with de-duplication.
   bytes snapshot_uuid = 7;
diff --git a/pw_snapshot/py/BUILD.gn b/pw_snapshot/py/BUILD.gn
new file mode 100644
index 0000000..8bd0d81
--- /dev/null
+++ b/pw_snapshot/py/BUILD.gn
@@ -0,0 +1,66 @@
+# Copyright 2021 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+
+import("//build_overrides/pigweed.gni")
+
+import("$dir_pw_build/python.gni")
+import("$dir_pw_docgen/docs.gni")
+
+pw_python_package("pw_snapshot_metadata") {
+  generate_setup = {
+    name = "pw_snapshot_metadata"
+    version = "0.0.1"
+  }
+
+  sources = [
+    "pw_snapshot_metadata/__init__.py",
+    "pw_snapshot_metadata/metadata.py",
+  ]
+  python_deps = [
+    "$dir_pw_tokenizer/py",
+    "..:metadata_proto.python",
+  ]
+  pylintrc = "$dir_pigweed/.pylintrc"
+}
+
+pw_python_package("pw_snapshot") {
+  generate_setup = {
+    name = "pw_snapshot"
+    version = "0.0.1"
+  }
+  sources = [
+    "generate_example_snapshot.py",
+    "pw_snapshot/__init__.py",
+    "pw_snapshot/processor.py",
+  ]
+  tests = [ "metadata_test.py" ]
+  python_deps = [
+    ":pw_snapshot_metadata",
+    "$dir_pw_tokenizer/py",
+    "..:snapshot_proto.python",
+  ]
+  pylintrc = "$dir_pigweed/.pylintrc"
+}
+
+pw_python_group("py") {
+  python_deps = [
+    ":pw_snapshot",
+    ":pw_snapshot_metadata",
+  ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+  other_deps = [ ":py" ]
+}
diff --git a/pw_snapshot/py/generate_example_snapshot.py b/pw_snapshot/py/generate_example_snapshot.py
new file mode 100644
index 0000000..d9fad17
--- /dev/null
+++ b/pw_snapshot/py/generate_example_snapshot.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+# Copyright 2020 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Generates an example snapshot useful for updating documentation."""
+
+import argparse
+import sys
+from typing import TextIO
+from pw_snapshot_protos import snapshot_pb2
+from pw_snapshot import processor
+
+
+def _main(out_file: TextIO):
+    snapshot = snapshot_pb2.Snapshot()
+
+    snapshot.metadata.reason = 'Assert failed: 1+1 == 42'.encode('utf-8')
+    snapshot.metadata.fatal = True
+    snapshot.metadata.project_name = 'gShoe'.encode('utf-8')
+    snapshot.metadata.software_version = 'QUANTUM_CORE-0.1.325-e4a84b1a'
+    snapshot.metadata.software_build_uuid = (
+        b'\xAD\x2D\x39\x25\x8C\x1B\xC4\x87'
+        b'\xF0\x7C\xA7\xE0\x49\x91\xA8\x36'
+        b'\xFD\xF7\xD0\xA0')
+    snapshot.metadata.device_name = 'GSHOE-QUANTUM_CORE-REV_0.1'.encode(
+        'utf-8')
+    snapshot.metadata.snapshot_uuid = (b'\x84\x81\xBB\x12\xA1\x62\x16\x4F'
+                                       b'\x5C\x74\x85\x5F\x6D\x94\xEA\x1A')
+
+    serialized_snapshot = snapshot.SerializeToString()
+    out_file.write(processor.process_snapshots(serialized_snapshot))
+
+
+def _parse_args():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('--out-file',
+                        '-o',
+                        type=argparse.FileType('w'),
+                        help='File to output serialized snapshot to.')
+
+    return parser.parse_args()
+
+
+if __name__ == '__main__':
+    _main(**vars(_parse_args()))
+    sys.exit(0)
diff --git a/pw_snapshot/py/metadata_test.py b/pw_snapshot/py/metadata_test.py
new file mode 100644
index 0000000..7d131b8
--- /dev/null
+++ b/pw_snapshot/py/metadata_test.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+# Copyright 2021 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Tests for snapshot metadata processing."""
+
+import base64
+import unittest
+import pw_tokenizer
+from pw_snapshot_metadata.metadata import MetadataProcessor, process_snapshot
+from pw_snapshot_protos import snapshot_pb2
+from pw_tokenizer import tokens
+
+
+class MetadataProcessorTest(unittest.TestCase):
+    """Tests that the metadata processor produces expected results."""
+    def setUp(self):
+        super().setUp()
+        self.detok = pw_tokenizer.Detokenizer(
+            tokens.Database([
+                tokens.TokenizedStringEntry(0x3A9BC4C3,
+                                            'Assert failed: 1+1 == 42'),
+                tokens.TokenizedStringEntry(0x01170923, 'gShoe'),
+            ]))
+
+        snapshot = snapshot_pb2.Snapshot()
+        snapshot.metadata.reason = b'\xc3\xc4\x9b\x3a'
+        snapshot.metadata.project_name = b'$' + base64.b64encode(
+            b'\x23\x09\x17\x01')
+        snapshot.metadata.device_name = b'hyper-fast-gshoe'
+        snapshot.metadata.software_version = 'gShoe-debug-1.2.1-6f23412b+'
+        snapshot.metadata.snapshot_uuid = b'\x00\x00\x00\x01'
+
+        self.snapshot = snapshot
+
+    def test_reason_tokenized(self):
+        meta = MetadataProcessor(self.snapshot.metadata, self.detok)
+        self.assertEqual(meta.reason(), 'Assert failed: 1+1 == 42')
+
+    def test_project_name_tokenized(self):
+        meta = MetadataProcessor(self.snapshot.metadata, self.detok)
+        self.assertEqual(meta.project_name(), 'gShoe')
+
+    def test_device_name_not_tokenized(self):
+        meta = MetadataProcessor(self.snapshot.metadata, self.detok)
+        self.assertEqual(meta.device_name(), 'hyper-fast-gshoe')
+
+    def test_default_non_fatal(self):
+        meta = MetadataProcessor(self.snapshot.metadata, self.detok)
+        self.assertFalse(meta.is_fatal())
+
+    def test_fw_version(self):
+        meta = MetadataProcessor(self.snapshot.metadata, self.detok)
+        self.assertEqual(meta.device_fw_version(),
+                         'gShoe-debug-1.2.1-6f23412b+')
+
+    def test_snapshot_uuid(self):
+        meta = MetadataProcessor(self.snapshot.metadata, self.detok)
+        self.assertEqual(meta.snapshot_uuid(), '00000001')
+
+    def test_fw_uuid_default(self):
+        meta = MetadataProcessor(self.snapshot.metadata, self.detok)
+        self.assertEqual(meta.fw_build_uuid(), '')
+
+    def test_as_str(self):
+        meta = MetadataProcessor(self.snapshot.metadata, self.detok)
+        expected = '\n'.join((
+            'Snapshot capture reason:',
+            '    Assert failed: 1+1 == 42',
+            '',
+            'Project name:      gShoe',
+            'Device:            hyper-fast-gshoe',
+            'Device FW version: gShoe-debug-1.2.1-6f23412b+',
+            'Snapshot UUID:     00000001',
+        ))
+        self.assertEqual(expected, str(meta))
+
+    def test_as_str_fatal(self):
+        self.snapshot.metadata.fatal = True
+        meta = MetadataProcessor(self.snapshot.metadata, self.detok)
+        expected = '\n'.join((
+            '                            ▪▄▄▄ ▄▄▄· ▄▄▄▄▄ ▄▄▄· ▄ ·',
+            '                            █▄▄▄▐█ ▀█ • █▌ ▐█ ▀█ █',
+            '                            █ ▪ ▄█▀▀█   █. ▄█▀▀█ █',
+            '                            ▐▌ .▐█ ▪▐▌ ▪▐▌·▐█ ▪▐▌▐▌',
+            '                            ▀    ▀  ▀ ·  ▀  ▀  ▀ .▀▀',
+            '',
+            'Device crash cause:',
+            '    Assert failed: 1+1 == 42',
+            '',
+            'Project name:      gShoe',
+            'Device:            hyper-fast-gshoe',
+            'Device FW version: gShoe-debug-1.2.1-6f23412b+',
+            'Snapshot UUID:     00000001',
+        ))
+        self.assertEqual(expected, str(meta))
+
+    def test_no_reason(self):
+        snapshot = snapshot_pb2.Snapshot()
+        snapshot.metadata.fatal = True
+        meta = MetadataProcessor(snapshot.metadata, self.detok)
+        meta.set_pretty_format_width(40)
+        expected = '\n'.join((
+            '        ▪▄▄▄ ▄▄▄· ▄▄▄▄▄ ▄▄▄· ▄ ·',
+            '        █▄▄▄▐█ ▀█ • █▌ ▐█ ▀█ █',
+            '        █ ▪ ▄█▀▀█   █. ▄█▀▀█ █',
+            '        ▐▌ .▐█ ▪▐▌ ▪▐▌·▐█ ▪▐▌▐▌',
+            '        ▀    ▀  ▀ ·  ▀  ▀  ▀ .▀▀',
+            '',
+            'Device crash cause:',
+            '    UNKNOWN (field missing)',
+            '',
+        ))
+        self.assertEqual(expected, str(meta))
+
+    def test_serialized_snapshot(self):
+        self.snapshot.tags['type'] = 'obviously a crash'
+        expected = '\n'.join((
+            'Snapshot capture reason:',
+            '    Assert failed: 1+1 == 42',
+            '',
+            'Project name:      gShoe',
+            'Device:            hyper-fast-gshoe',
+            'Device FW version: gShoe-debug-1.2.1-6f23412b+',
+            'Snapshot UUID:     00000001',
+            '',
+            'Tags:',
+            '  type: obviously a crash',
+            '',
+        ))
+        self.assertEqual(
+            expected,
+            process_snapshot(self.snapshot.SerializeToString(), self.detok))
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/pw_snapshot/py/pw_snapshot/__init__.py b/pw_snapshot/py/pw_snapshot/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/pw_snapshot/py/pw_snapshot/__init__.py
diff --git a/pw_snapshot/py/pw_snapshot/processor.py b/pw_snapshot/py/pw_snapshot/processor.py
new file mode 100644
index 0000000..3f3357b
--- /dev/null
+++ b/pw_snapshot/py/pw_snapshot/processor.py
@@ -0,0 +1,116 @@
+# Copyright 2021 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Tool for processing and outputting Snapshot protos as text"""
+
+import argparse
+import sys
+from typing import Optional, BinaryIO, TextIO, Callable
+import pw_tokenizer
+from pw_snapshot_metadata import metadata
+from pw_snapshot_protos import snapshot_pb2
+
+_BRANDING = """
+        ____ _       __    _____ _   _____    ____  _____ __  ______  ______
+       / __ \\ |     / /   / ___// | / /   |  / __ \\/ ___// / / / __ \\/_  __/
+      / /_/ / | /| / /    \\__ \\/  |/ / /| | / /_/ /\\__ \\/ /_/ / / / / / /
+     / ____/| |/ |/ /    ___/ / /|  / ___ |/ ____/___/ / __  / /_/ / / /
+    /_/     |__/|__/____/____/_/ |_/_/  |_/_/    /____/_/ /_/\\____/ /_/
+                  /_____/
+
+"""
+
+
+def process_snapshot(
+        serialized_snapshot: bytes,
+        detokenizer: Optional[pw_tokenizer.Detokenizer] = None) -> str:
+    """Processes a single snapshot."""
+
+    output = [_BRANDING]
+
+    captured_metadata = metadata.process_snapshot(serialized_snapshot,
+                                                  detokenizer)
+    if captured_metadata:
+        output.append(captured_metadata)
+
+    # Check and emit the number of related snapshots embedded in this snapshot.
+    snapshot = snapshot_pb2.Snapshot()
+    snapshot.ParseFromString(serialized_snapshot)
+    if snapshot.related_snapshots:
+        snapshot_count = len(snapshot.related_snapshots)
+        plural = 's' if snapshot_count > 1 else ''
+        output.extend((
+            f'This snapshot contains {snapshot_count} related snapshot{plural}',
+            '',
+        ))
+
+    return '\n'.join(output)
+
+
+def process_snapshots(
+        serialized_snapshot: bytes,
+        detokenizer: Optional[pw_tokenizer.Detokenizer] = None,
+        user_processing_callback: Optional[Callable[[bytes],
+                                                    str]] = None) -> str:
+    """Processes a snapshot that may have multiple embedded snapshots."""
+    output = []
+    # Process the top-level snapshot.
+    output.append(process_snapshot(serialized_snapshot, detokenizer))
+
+    # If the user provided a custom processing callback, call it on each
+    # snapshot.
+    if user_processing_callback is not None:
+        output.append(user_processing_callback(serialized_snapshot))
+
+    # Process any related snapshots that were embedded in this one.
+    snapshot = snapshot_pb2.Snapshot()
+    snapshot.ParseFromString(serialized_snapshot)
+    for nested_snapshot in snapshot.related_snapshots:
+        output.append('\n[' + '=' * 78 + ']\n')
+        output.append(
+            str(
+                process_snapshots(nested_snapshot.SerializeToString(),
+                                  detokenizer)))
+
+    return '\n'.join(output)
+
+
+def _load_and_dump_snapshots(in_file: BinaryIO, out_file: TextIO,
+                             token_db: Optional[TextIO]):
+    detokenizer = None
+    if token_db:
+        detokenizer = pw_tokenizer.Detokenizer(token_db)
+    out_file.write(process_snapshots(in_file.read(), detokenizer))
+
+
+def _parse_args():
+    parser = argparse.ArgumentParser(description='Decode Pigweed snapshots')
+    parser.add_argument('in_file',
+                        type=argparse.FileType('rb'),
+                        help='Binary snapshot file')
+    parser.add_argument(
+        '--out-file',
+        '-o',
+        default='-',
+        type=argparse.FileType('wb'),
+        help='File to output decoded snapshots to. Defaults to stdout.')
+    parser.add_argument(
+        '--token-db',
+        type=argparse.FileType('r'),
+        help='Token database or ELF file to use for detokenization.')
+    return parser.parse_args()
+
+
+if __name__ == '__main__':
+    _load_and_dump_snapshots(**vars(_parse_args()))
+    sys.exit(0)
diff --git a/pw_snapshot/py/pw_snapshot/py.typed b/pw_snapshot/py/pw_snapshot/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/pw_snapshot/py/pw_snapshot/py.typed
diff --git a/pw_snapshot/py/pw_snapshot_metadata/__init__.py b/pw_snapshot/py/pw_snapshot_metadata/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/pw_snapshot/py/pw_snapshot_metadata/__init__.py
diff --git a/pw_snapshot/py/pw_snapshot_metadata/metadata.py b/pw_snapshot/py/pw_snapshot_metadata/metadata.py
new file mode 100644
index 0000000..15927f0
--- /dev/null
+++ b/pw_snapshot/py/pw_snapshot_metadata/metadata.py
@@ -0,0 +1,132 @@
+# Copyright 2021 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Library to assist processing Snapshot Metadata protos into text"""
+
+from typing import Optional, List, Mapping
+import pw_tokenizer
+from pw_tokenizer import proto as proto_detokenizer
+from pw_snapshot_metadata_proto import snapshot_metadata_pb2
+
+_PRETTY_FORMAT_DEFAULT_WIDTH = 80
+
+
+def _process_tags(tags: Mapping[str, str]) -> Optional[str]:
+    """Outputs snapshot tags as a multi-line string."""
+    if not tags:
+        return None
+
+    output: List[str] = ['Tags:']
+    for key, value in tags.items():
+        output.append(f'  {key}: {value}')
+
+    return '\n'.join(output)
+
+
+def process_snapshot(serialized_snapshot: bytes,
+                     tokenizer_db: Optional[pw_tokenizer.Detokenizer]) -> str:
+    """Processes snapshot metadata and tags, producing a multi-line string."""
+    snapshot = snapshot_metadata_pb2.SnapshotBasicInfo()
+    snapshot.ParseFromString(serialized_snapshot)
+
+    output: List[str] = []
+
+    if snapshot.HasField('metadata'):
+        output.extend((
+            str(MetadataProcessor(snapshot.metadata, tokenizer_db)),
+            '',
+        ))
+
+    if snapshot.tags:
+        tags = _process_tags(snapshot.tags)
+        if tags:
+            output.append(tags)
+        # Trailing blank line for spacing.
+        output.append('')
+
+    return '\n'.join(output)
+
+
+class MetadataProcessor:
+    """This class simplifies dumping contents of a snapshot Metadata message."""
+    def __init__(self, metadata: snapshot_metadata_pb2.Metadata,
+                 tokenizer_db: Optional[pw_tokenizer.Detokenizer]):
+        self._metadata = metadata
+        self._tokenizer_db = (tokenizer_db if tokenizer_db is not None else
+                              pw_tokenizer.Detokenizer(None))
+        self._format_width = _PRETTY_FORMAT_DEFAULT_WIDTH
+        proto_detokenizer.detokenize_fields(self._tokenizer_db, self._metadata)
+
+    def is_fatal(self) -> bool:
+        return self._metadata.fatal
+
+    def reason(self) -> str:
+        return self._metadata.reason.decode(
+        ) if self._metadata.reason else 'UNKNOWN (field missing)'
+
+    def project_name(self) -> str:
+        return self._metadata.project_name.decode()
+
+    def device_name(self) -> str:
+        return self._metadata.device_name.decode()
+
+    def device_fw_version(self) -> str:
+        return self._metadata.software_version
+
+    def snapshot_uuid(self) -> str:
+        return self._metadata.snapshot_uuid.hex()
+
+    def fw_build_uuid(self) -> str:
+        return self._metadata.software_build_uuid.hex()
+
+    def set_pretty_format_width(self, width: int):
+        """Sets the centered width of the FATAL text for a formatted output."""
+        self._format_width = width
+
+    def __str__(self) -> str:
+        """outputs a pw.snapshot.Metadata proto as a multi-line string."""
+        output: List[str] = []
+        if self._metadata.fatal:
+            output.extend((
+                '▪▄▄▄ ▄▄▄· ▄▄▄▄▄ ▄▄▄· ▄ ·'.center(self._format_width).rstrip(),
+                '█▄▄▄▐█ ▀█ • █▌ ▐█ ▀█ █  '.center(self._format_width).rstrip(),
+                '█ ▪ ▄█▀▀█   █. ▄█▀▀█ █  '.center(self._format_width).rstrip(),
+                '▐▌ .▐█ ▪▐▌ ▪▐▌·▐█ ▪▐▌▐▌ '.center(self._format_width).rstrip(),
+                '▀    ▀  ▀ ·  ▀  ▀  ▀ .▀▀'.center(self._format_width).rstrip(),
+                '',
+                'Device crash cause:',
+            ))
+        else:
+            output.append('Snapshot capture reason:')
+
+        output.extend((
+            '    ' + self.reason(),
+            '',
+        ))
+
+        if self._metadata.project_name:
+            output.append(f'Project name:      {self.project_name()}')
+
+        if self._metadata.device_name:
+            output.append(f'Device:            {self.device_name()}')
+
+        if self._metadata.software_version:
+            output.append(f'Device FW version: {self.device_fw_version()}')
+
+        if self._metadata.software_build_uuid:
+            output.append(f'FW build UUID:     {self.fw_build_uuid()}')
+
+        if self._metadata.snapshot_uuid:
+            output.append(f'Snapshot UUID:     {self.snapshot_uuid()}')
+
+        return '\n'.join(output)
diff --git a/pw_snapshot/py/pw_snapshot_metadata/py.typed b/pw_snapshot/py/pw_snapshot_metadata/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/pw_snapshot/py/pw_snapshot_metadata/py.typed
diff --git a/pw_snapshot/setup.rst b/pw_snapshot/setup.rst
index f8968f1..7c39444 100644
--- a/pw_snapshot/setup.rst
+++ b/pw_snapshot/setup.rst
@@ -296,5 +296,33 @@
 ----------------------
 Snapshot Tooling Setup
 ----------------------
-Pigweed will provide Python tooling to dump snapshot protos as human-readable
-text dumps. This section will be updated as this functionality is introduced.
+When using the upstream ``Snapshot`` proto, you can directly use
+``pw_snapshot.process`` to process snapshots into human-readable dumps. If
+you've opted to extend Pigweed's snapshot proto, you'll likely want to extend
+the processing tooling to handle custom project data as well. This can be done
+by creating a light wrapper around
+``pw_snapshot.processor.process_snapshots()``.
+
+.. code-block:: python
+
+  def _process_hw_failures(serialized_snapshot: bytes) -> str:
+      """Custom handler that checks wheel state."""
+      wheel_state = wheel_state_pb2.WheelStateSnapshot()
+      output = []
+      wheel_state.ParseFromString(serialized_snapshot)
+
+      if len(wheel_state.wheels) != 2:
+          output.append(f'Expected 2 wheels, found {len(wheel_state.wheels)}')
+
+      if len(wheel_state.wheels) < 2:
+          output.append('Wheels fell off!')
+
+      # And more...
+
+      return '\n'.join(output)
+
+
+  def process_my_snapshots(serialized_snapshot: bytes) -> str:
+      """Runs the snapshot processor with a custom callback."""
+      return pw_snaphsot.processor.process_snapshots(
+          serialized_snapshot, user_processing_callback=_process_hw_failures)