Repository: gaogaotiantian/coredumpy

Issue:
Create a test framework and write tests

Discussion:
codecov-commenter: ## Welcome to [Codecov](https://codecov.io?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tian+Gao) :tada:

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered :open_umbrella:

Patch:
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 0000000..fe37262
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,3 @@
+[run]
+cover_pylib = True
+source = coredumpy
diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml
new file mode 100644
index 0000000..e852340
--- /dev/null
+++ b/.github/workflows/lint.yaml
@@ -0,0 +1,26 @@
+name: lint
+
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+
+jobs:
+  lint:
+    strategy:
+      matrix:
+        python-version: ['3.9', '3.10', '3.11', '3.12']
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set up Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependency
+        run: pip install flake8 mypy
+      - name: Run flake8
+        run: flake8 src/ tests/ --count --ignore=W503 --max-line-length=127 --statistics
+      - name: Run mypy 
+        run: mypy src/
diff --git a/pyproject.toml b/pyproject.toml
index af7f9ae..ac569c4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,12 +7,11 @@ name = "coredumpy"
 authors = [{name = "Tian Gao", email = "gaogaotiantian@hotmail.com"}]
 description = "A utility tool to dump python stacks"
 readme = "README.md"
-requires-python = ">=3.8"
+requires-python = ">=3.9"
 license = {file = "LICENSE"}
 dynamic = ["version"]
 classifiers = [
     "Development Status :: 4 - Beta",
-    "Programming Language :: Python :: 3.8",
     "Programming Language :: Python :: 3.9",
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.11",
diff --git a/src/coredumpy/coredumpy.py b/src/coredumpy/coredumpy.py
index 5aa1b79..8fcf8d5 100644
--- a/src/coredumpy/coredumpy.py
+++ b/src/coredumpy/coredumpy.py
@@ -5,6 +5,7 @@
 import inspect
 import json
 import linecache
+import os
 import tokenize
 import pdb
 
@@ -32,9 +33,13 @@ class Coredumpy:
             json.dump({
                 "objects": PyObjectProxy._objects,
                 "frame": str(id(curr_frame)),
-                "files": {filename: tokenize.open(filename).readlines() for filename in files}
+                "files": {filename: tokenize.open(filename).readlines()
+                          for filename in files
+                          if os.path.exists(filename)}
             }, f)
 
+        PyObjectProxy.clear()
+
     @classmethod
     def load(cls, path):
         with open(path, "r") as f:
@@ -48,6 +53,7 @@ class Coredumpy:
         pdb_instance = pdb.Pdb()
         pdb_instance.reset()
         pdb_instance.interaction(frame, None)
+        PyObjectProxy.clear()
 
 
 dump = Coredumpy.dump
diff --git a/src/coredumpy/py_object_proxy.py b/src/coredumpy/py_object_proxy.py
index e0538ef..0bbb9e0 100644
--- a/src/coredumpy/py_object_proxy.py
+++ b/src/coredumpy/py_object_proxy.py
@@ -23,8 +23,11 @@ class PyObjectProxy:
 
     @classmethod
     def add_object(cls, obj):
-        if id(obj) not in cls._objects:
+        if str(id(obj)) not in cls._objects:
+            # label the id
+            cls._objects[str(id(obj))] = {}
             cls._objects[str(id(obj))] = cls.dump_object(obj)
+        return cls._objects[str(id(obj))]
 
     @classmethod
     def dump_object(cls, obj):
@@ -77,7 +80,12 @@ class PyObjectProxy:
     @classmethod
     def default_encode(cls, obj):
         data = {"type": type(obj).__name__, "attrs": {}}
-        if isinstance(obj, (types.ModuleType, types.FunctionType, types.BuiltinFunctionType)):
+        if isinstance(obj, (types.ModuleType,
+                            types.FunctionType,
+                            types.BuiltinFunctionType,
+                            types.LambdaType,
+                            types.MethodType,
+                            )):
             return data
         for attr, value in inspect.getmembers(obj):
             if not attr.startswith("__") and not callable(value):


Test patch:
diff --git a/.github/workflows/build_test.yaml b/.github/workflows/build_test.yaml
new file mode 100644
index 0000000..77b5f59
--- /dev/null
+++ b/.github/workflows/build_test.yaml
@@ -0,0 +1,52 @@
+name: build and test
+
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+    branches:
+      - master
+
+jobs:
+  build_test:
+    strategy:
+      matrix:
+        os: [ubuntu-latest, windows-latest, macos-latest, macos-14]
+        python-version: ['3.9', '3.10', '3.11', '3.12']
+        exclude:
+          - python-version: '3.9'
+            os: macos-14
+    runs-on: ${{ matrix.os }}
+    timeout-minutes: 30
+    steps:
+    - uses: actions/checkout@v3
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v4
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install dependencies
+      run: pip install -r requirements-dev.txt
+    - name: Build
+      run: python -m build
+    - name: Install on Unix
+      if: matrix.os != 'windows-latest'
+      run: pip install dist/*.whl
+    - name: Install on Windows
+      if: matrix.os == 'windows-latest'
+      run: pip install (Get-ChildItem dist/*.whl)
+    - name: Test
+      run: python -m unittest
+    - name: Generate Coverage Report
+      run: |
+        coverage run --source coredumpy --parallel-mode -m unittest
+        coverage combine
+        coverage xml -i
+      env:
+        COVERAGE_RUN: True
+    - name: Upload coverage reports to Codecov
+      uses: codecov/codecov-action@v4.0.1
+      with:
+        token: ${{ secrets.CODECOV_TOKEN }}
+        slug: gaogaotiantian/coredumpy
+        file: ./coverage.xml
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/base.py b/tests/base.py
new file mode 100644
index 0000000..fb0a817
--- /dev/null
+++ b/tests/base.py
@@ -0,0 +1,45 @@
+# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
+# For details: https://github.com/gaogaotiantian/coredumpy/blob/master/NOTICE.txt
+
+
+import os
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+from .util import normalize_commands
+
+
+class TestBase(unittest.TestCase):
+    def run_test(self, script, dumppath, commands):
+        script = textwrap.dedent(script)
+        with tempfile.TemporaryDirectory() as tmpdir:
+            with open(f"{tmpdir}/script.py", "w") as f:
+                f.write(script)
+            subprocess.run(normalize_commands(["python", f"{tmpdir}/script.py"]),
+                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+
+            process = subprocess.Popen(normalize_commands(["coredumpy", "load", dumppath]),
+                                       stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+
+            stdout, stderr = process.communicate("\n".join(commands).encode())
+            stdout = stdout.decode()
+            stderr = stderr.decode()
+        try:
+            os.remove(dumppath)
+        except FileNotFoundError:
+            pass
+        return stdout, stderr
+
+    def run_script(self, script):
+        script = textwrap.dedent(script)
+        with tempfile.TemporaryDirectory() as tmpdir:
+            with open(f"{tmpdir}/script.py", "w") as f:
+                f.write(script)
+            process = subprocess.Popen(normalize_commands(["python", f"{tmpdir}/script.py"]),
+                                       stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+            stdout, stderr = process.communicate()
+            stdout = stdout.decode()
+            stderr = stderr.decode()
+        return stdout, stderr
diff --git a/tests/test_basic.py b/tests/test_basic.py
new file mode 100644
index 0000000..230c4bb
--- /dev/null
+++ b/tests/test_basic.py
@@ -0,0 +1,56 @@
+# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
+# For details: https://github.com/gaogaotiantian/coredumpy/blob/master/NOTICE.txt
+
+
+from .base import TestBase
+
+
+class TestBasic(TestBase):
+    def test_simple(self):
+        script = """
+            import coredumpy
+            def g(arg):
+                coredumpy.dump("coredumpy_dump")
+                return arg
+            def f():
+                x = 142857
+                y = [3, {'a': (4, None)}]
+                g(y)
+            f()
+        """
+        stdout, _ = self.run_test(script, "coredumpy_dump", [
+            "w",
+            "p arg",
+            "u",
+            "p x",
+            "q"
+        ])
+
+        self.assertIn("-> f()", stdout)
+        self.assertIn("script.py(10)<module>", stdout)
+        self.assertIn("-> g(y)", stdout)
+        self.assertIn("script.py(4)g()", stdout)
+        self.assertIn("[3, {'a': [4, None]}]", stdout)
+        self.assertIn("142857", stdout)
+
+    def test_except(self):
+        script = """
+            import coredumpy
+            coredumpy.patch_excepthook()
+            def g(arg):
+                return 1 / arg
+            g(0)
+        """
+        stdout, _ = self.run_test(script, "coredumpy_dump", [
+            "w",
+            "p arg",
+            "u",
+            "p x",
+            "q"
+        ])
+        self.assertIn("return 1 / arg", stdout)
+        self.assertIn("0", stdout)
+
+    def test_nonexist_file(self):
+        stdout, stderr = self.run_test("", "nonexist_dump", [])
+        self.assertIn("File nonexist_dump not found", stdout)
diff --git a/tests/test_patch.py b/tests/test_patch.py
new file mode 100644
index 0000000..e134ed0
--- /dev/null
+++ b/tests/test_patch.py
@@ -0,0 +1,26 @@
+# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
+# For details: https://github.com/gaogaotiantian/coredumpy/blob/master/NOTICE.txt
+
+
+from .base import TestBase
+
+
+class TestPatch(TestBase):
+    def test_inspect(self):
+        script = """
+            import inspect
+            from coredumpy.patch import patch_all
+            patch_all()
+            class FakeFrame:
+                def __init__(self):
+                    self._coredumpy_type = "frame"
+            class FakeCode:
+                def __init__(self):
+                    self._coredumpy_type = "code"
+            assert inspect.isframe(FakeFrame()), "isframe not patched"
+            assert inspect.iscode(FakeCode()), "iscode not patched"
+            print("patch inspect success")
+        """
+
+        stdout, stderr = self.run_script(script)
+        self.assertIn("patch inspect success", stdout, stderr)
diff --git a/tests/test_py_object_proxy.py b/tests/test_py_object_proxy.py
new file mode 100644
index 0000000..b7708fa
--- /dev/null
+++ b/tests/test_py_object_proxy.py
@@ -0,0 +1,40 @@
+# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
+# For details: https://github.com/gaogaotiantian/coredumpy/blob/master/NOTICE.txt
+
+
+from coredumpy.py_object_proxy import PyObjectProxy
+
+from .base import TestBase
+
+
+class TestPyObjectProxy(TestBase):
+    def tearDown(self):
+        PyObjectProxy.clear()
+        return super().tearDown()
+
+    def test_basic(self):
+        class A:
+            def __init__(self, x):
+                self.x = x
+        obj = A(142857)
+        data = PyObjectProxy.add_object(obj)
+        for i, o in PyObjectProxy._objects.items():
+            PyObjectProxy.load_object(i, o)
+        proxy = PyObjectProxy.load_object(str(id(obj)), data)
+        self.assertEqual(proxy.x, 142857)
+        self.assertEqual(dir(proxy), ['x'])
+        self.assertIn('<A object at 0x', repr(proxy))
+
+    def test_nonexist_attr(self):
+        class A:
+            def __init__(self, x):
+                self.x = x
+        o = A(142857)
+        obj = PyObjectProxy.add_object(o)
+        proxy = PyObjectProxy.load_object(str(id(o)), obj)
+        with self.assertRaises(AttributeError):
+            proxy.y
+
+    def test_invalid(self):
+        with self.assertRaises(ValueError):
+            PyObjectProxy.load_object("1", None)
diff --git a/tests/util.py b/tests/util.py
new file mode 100644
index 0000000..9ce13c1
--- /dev/null
+++ b/tests/util.py
@@ -0,0 +1,14 @@
+# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
+# For details: https://github.com/gaogaotiantian/coredumpy/blob/master/NOTICE.txt
+
+
+import os
+
+
+def normalize_commands(commands):
+    if os.getenv("COVERAGE_RUN"):
+        if commands[0] == "python":
+            commands = ["coverage", "run", "--parallel-mode"] + commands[1:]
+        elif commands[0] == "coredumpy":
+            commands = ["coverage", "run", "--parallel-mode", "-m", "coredumpy"] + commands[1:]
+    return commands