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)