From 6033323d48090055bdd9669599904d2a5c61eaa6 Mon Sep 17 00:00:00 2001 From: Tian Gao Date: Sun, 21 Apr 2024 21:24:27 -0700 Subject: [PATCH] Add test and coverage (#5) --- .coveragerc | 3 ++ .github/workflows/build_test.yaml | 52 ++++++++++++++++++++++++++++ .github/workflows/lint.yaml | 26 ++++++++++++++ pyproject.toml | 3 +- src/coredumpy/coredumpy.py | 8 ++++- src/coredumpy/py_object_proxy.py | 12 +++++-- tests/__init__.py | 0 tests/base.py | 45 +++++++++++++++++++++++++ tests/test_basic.py | 56 +++++++++++++++++++++++++++++++ tests/test_patch.py | 26 ++++++++++++++ tests/test_py_object_proxy.py | 40 ++++++++++++++++++++++ tests/util.py | 14 ++++++++ 12 files changed, 280 insertions(+), 5 deletions(-) create mode 100644 .coveragerc create mode 100644 .github/workflows/build_test.yaml create mode 100644 .github/workflows/lint.yaml create mode 100644 tests/__init__.py create mode 100644 tests/base.py create mode 100644 tests/test_basic.py create mode 100644 tests/test_patch.py create mode 100644 tests/test_py_object_proxy.py create mode 100644 tests/util.py 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/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/.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 @@ def dump(cls, path, frame=None): 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 @@ def load(cls, path): 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 @@ def clear(cls): @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 @@ def load_objects(cls, data): @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): 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)", 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('