Repository: gaogaotiantian/coredumpy

Issue:
Implement peek feature
To quickly get the gist of a dump or even dumps.

Discussion:
codecov-commenter: ## [Codecov](https://app.codecov.io/gh/gaogaotiantian/coredumpy/pull/21?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tian+Gao) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`869a0de`)](https://app.codecov.io/gh/gaogaotiantian/coredumpy/commit/869a0de830be63f0059d0a1ae88a238662eb5028?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tian+Gao) to head [(`d0e71a8`)](https://app.codecov.io/gh/gaogaotiantian/coredumpy/pull/21?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tian+Gao).


:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##            master       #21   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           11        11           
  Lines          305       334   +29     
=========================================
+ Hits           305       334   +29     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/gaogaotiantian/coredumpy/pull/21?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tian+Gao).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tian+Gao).


Patch:
diff --git a/README.md b/README.md
index 3d5867a..cb33477 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,8 @@ coredumpy.dump(path='coredumpy.dump')
 coredumpy.dump(path=lambda: f"coredumpy_{time.time()}.dump")
 # Specify a directory to keep the dump
 coredumpy.dump(directory='./dumps')
+# Specify the description of the dump for peek
+coredumpy.dump(description="a random dump")
 ```
 
 ### load
@@ -66,6 +68,16 @@ open an unknown dump (not recommended though). You will be in an "observer"
 mode where you can access certain types of value of the variables and attributes,
 but none of the user-created objects will have the actual functionality.
 
+### peek
+
+If you only need some very basic information of the dump (to figure out which dump
+you actually need), you can use `peek` command.
+
+```
+coredumpy peek <your_dump_directory>
+coredumpy peek <your_dump_file1> <your_dump_file2>
+```
+
 ## Disclaimer
 
 This library is still in development phase and is not recommended for production use.
diff --git a/src/coredumpy/coredumpy.py b/src/coredumpy/coredumpy.py
index d58afcd..f0e75de 100644
--- a/src/coredumpy/coredumpy.py
+++ b/src/coredumpy/coredumpy.py
@@ -11,6 +11,7 @@ import os
 import pdb
 import platform
 import tokenize
+import textwrap
 import types
 import warnings
 from typing import Callable, Optional, Union
@@ -25,6 +26,7 @@ class Coredumpy:
     def dump(cls,
              frame: Optional[types.FrameType] = None,
              *,
+             description: Optional[str] = None,
              path: Optional[Union[str, Callable[[], str]]] = None,
              directory: Optional[str] = None):
         """
@@ -73,6 +75,7 @@ class Coredumpy:
                 "objects": PyObjectProxy._objects,
                 "frame": str(id(curr_frame)),
                 "files": file_lines,
+                "description": description,
                 "metadata": cls.get_metadata()
             }, f)
 
@@ -100,6 +103,24 @@ class Coredumpy:
         pdb_instance.interaction(frame, None)
         PyObjectProxy.clear()  # pragma: no cover
 
+    @classmethod
+    def peek(cls, path):
+        with gzip.open(path, "rt") as f:
+            data = json.load(f)
+
+        from coredumpy import __version__
+        if data["metadata"]["version"] != __version__:  # pragma: no cover
+            print(f"Warning! the dump file is created by {data['metadata']['version']}\n"
+                  f"but the current coredumpy version is {__version__}")
+        patch_all()
+        metadata = data["metadata"]
+        system = metadata["system"]
+        print(f"{os.path.abspath(path)}")
+        print(f"    Python v{metadata['python_version']} on {system['system']} {system['node']} {system['release']}")
+        print(f"    {metadata['dump_time']}")
+        if data["description"]:
+            print(textwrap.indent(data["description"], "    "))
+
     @classmethod
     def get_metadata(cls):
         from coredumpy import __version__
@@ -118,3 +139,4 @@ class Coredumpy:
 
 dump = Coredumpy.dump
 load = Coredumpy.load
+peek = Coredumpy.peek
diff --git a/src/coredumpy/except_hook.py b/src/coredumpy/except_hook.py
index 4628d16..e6fbbd9 100644
--- a/src/coredumpy/except_hook.py
+++ b/src/coredumpy/except_hook.py
@@ -3,6 +3,7 @@
 
 
 import sys
+import traceback
 from typing import Callable, Optional, Union
 
 from .coredumpy import dump
@@ -22,12 +23,19 @@ def patch_except(path: Optional[Union[str, Callable[[], str]]] = None,
             The directory to save the dump file, only works when path is not specified.
     """
 
-    def _excepthook(type, value, traceback):
-        while traceback.tb_next:
-            traceback = traceback.tb_next
+    def _get_description(type, value, tb):
+        side_count = (70 - len(type.__qualname__) - 2) // 2
+        headline = f"{'=' * side_count} {type.__qualname__} {'=' * side_count}"
+        return '\n'.join([headline,
+                          ''.join(traceback.format_exception(type, value, tb)).strip()])
 
-        filename = dump(traceback.tb_frame, path=path, directory=directory)
-        _original_excepthook(type, value, traceback)
+    def _excepthook(type, value, tb):
+        while tb.tb_next:
+            tb = tb.tb_next
+
+        filename = dump(tb.tb_frame, description=_get_description(type, value, tb),
+                        path=path, directory=directory)
+        _original_excepthook(type, value, tb)
         print(f'Your frame stack is dumped, open it with\n'
               f'coredumpy load {filename}')
 
diff --git a/src/coredumpy/main.py b/src/coredumpy/main.py
index 94d7548..69e63aa 100644
--- a/src/coredumpy/main.py
+++ b/src/coredumpy/main.py
@@ -5,7 +5,7 @@
 import argparse
 import os
 
-from .coredumpy import load
+from .coredumpy import load, peek
 
 
 def main():
@@ -15,6 +15,9 @@ def main():
     subparsers_load = subparsers.add_parser("load", help="Load a dump file.")
     subparsers_load.add_argument("file", type=str, help="The dump file to load.")
 
+    subparsers_load = subparsers.add_parser("peek", help="Peek a dump file.")
+    subparsers_load.add_argument("files", help="The dump file to load.", nargs="+")
+
     args = parser.parse_args()
 
     if args.command == "load":
@@ -22,3 +25,20 @@ def main():
             load(args.file)
         else:
             print(f"File {args.file} not found.")
+    elif args.command == "peek":
+        for file in args.files:
+            if os.path.exists(file):
+                if os.path.isdir(file):
+                    for f in os.listdir(file):
+                        try:
+                            path = os.path.join(file, f)
+                            peek(path)
+                        except Exception:
+                            pass
+                else:
+                    try:
+                        peek(file)
+                    except Exception:
+                        pass
+            else:
+                print(f"File {file} not found.")


Test patch:
diff --git a/src/coredumpy/pytest_hook.py b/src/coredumpy/pytest_hook.py
index 3baffe0..5c5d08c 100644
--- a/src/coredumpy/pytest_hook.py
+++ b/src/coredumpy/pytest_hook.py
@@ -16,6 +16,12 @@ def pytest_addoption(parser):
     )
 
 
+def _get_description(report):
+    underscore_count = (70 - len(report.head_line) - 2) // 2
+    headline = f"{'_' * underscore_count} {report.head_line} {'_' * underscore_count}"
+    return '\n'.join([headline, report.longreprtext])
+
+
 def pytest_exception_interact(node, call, report):
     if not node.config.getoption("--enable-coredumpy"):
         return
@@ -26,7 +32,9 @@ def pytest_exception_interact(node, call, report):
             tb = call.excinfo.tb
             while tb.tb_next:
                 tb = tb.tb_next
-            filename = coredumpy.dump(tb.tb_frame, directory=node.config.getoption("--coredumpy-dir"))
+            filename = coredumpy.dump(tb.tb_frame,
+                                      description=_get_description(report),
+                                      directory=node.config.getoption("--coredumpy-dir"))
             print(f'Your frame stack is dumped, open it with\n'
                   f'coredumpy load {filename}')
         except Exception:  # pragma: no cover
diff --git a/src/coredumpy/unittest_hook.py b/src/coredumpy/unittest_hook.py
index 59c1560..155974d 100644
--- a/src/coredumpy/unittest_hook.py
+++ b/src/coredumpy/unittest_hook.py
@@ -19,6 +19,13 @@ def patch_unittest(path: Optional[Union[str, Callable[[], str]]] = None,
             The directory to save the dump file, only works when path is not specified.
     """
 
+    def _get_description(self, test, err):
+        class_name = f"{test.__class__.__module__}.{test.__class__.__qualname__}"
+        return '\n'.join(['=' * 70,
+                          f"FAIL: {test._testMethodName} ({class_name})",
+                          '-' * 70,
+                          self._exc_info_to_string(err, test).strip()])
+
     _original_addError = unittest.TestResult.addError
     _original_addFailure = unittest.TestResult.addFailure
 
@@ -27,7 +34,8 @@ def patch_unittest(path: Optional[Union[str, Callable[[], str]]] = None,
         while tb.tb_next:
             tb = tb.tb_next
         try:
-            filename = dump(tb.tb_frame, path=path, directory=directory)
+            filename = dump(tb.tb_frame, description=_get_description(self, test, err),
+                            path=path, directory=directory)
             print(f'Your frame stack is dumped, open it with\n'
                   f'coredumpy load {filename}')
         except Exception:  # pragma: no cover
@@ -39,7 +47,8 @@ def patch_unittest(path: Optional[Union[str, Callable[[], str]]] = None,
         while tb.tb_next:
             tb = tb.tb_next
         try:
-            filename = dump(tb.tb_frame, path=path, directory=directory)
+            filename = dump(tb.tb_frame, description=_get_description(self, test, err),
+                            path=path, directory=directory)
             print(f'Your frame stack is dumped, open it with\n'
                   f'coredumpy load {filename}')
         except Exception:  # pragma: no cover
diff --git a/tests/base.py b/tests/base.py
index fe3a06c..ade24ed 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -46,3 +46,11 @@ class TestBase(unittest.TestCase):
             self.assertEqual(process.returncode, expected_returncode,
                              f"script failed with return code {process.returncode}\n{stderr}")
         return stdout, stderr
+
+    def run_peek(self, paths):
+        process = subprocess.Popen(normalize_commands(["coredumpy", "peek"] + paths),
+                                   stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+        stdout, stderr = process.communicate()
+        stdout = stdout.decode(errors='backslashreplace')
+        stderr = stderr.decode(errors='backslashreplace')
+        return stdout, stderr
diff --git a/tests/test_basic.py b/tests/test_basic.py
index 88ec4fa..5d545c8 100644
--- a/tests/test_basic.py
+++ b/tests/test_basic.py
@@ -67,6 +67,26 @@ class TestBasic(TestBase):
         self.assertIn("return 1 / arg", stdout)
         self.assertIn("0", stdout)
 
+    def test_peek(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            script = f"""
+                import coredumpy
+                coredumpy.dump(description="test", directory={repr(tmpdir)})
+            """
+            self.run_script(script)
+            self.run_script(script)
+            with open(os.path.join(tmpdir, "invalid"), "w") as f:
+                f.write("{invalid}")
+
+            self.assertEqual(len(os.listdir(tmpdir)), 3)
+            stdout, _ = self.run_peek([tmpdir])
+            stdout2, _ = self.run_peek([os.path.join(tmpdir, file) for file in os.listdir(tmpdir)])
+
+            self.assertEqual(stdout, stdout2)
+
+            stdout, _ = self.run_peek([os.path.join(tmpdir, "nosuchfile")])
+            self.assertIn("not found", stdout)
+
     def test_nonexist_file(self):
         stdout, stderr = self.run_test("", "nonexist_dump", [])
         self.assertIn("File nonexist_dump not found", stdout)