From cde915e7fedb54edb9e1ccc03411a2632a1ed1e8 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 29 Mar 2024 17:33:18 +0100 Subject: [PATCH 1/6] feat: add debug tests --- frappe/tests/utils.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/frappe/tests/utils.py b/frappe/tests/utils.py index 2b90b7fc08..ff7de8ae02 100644 --- a/frappe/tests/utils.py +++ b/frappe/tests/utils.py @@ -1,7 +1,11 @@ import copy import datetime +import functools import os +import pdb import signal +import sys +import traceback import unittest from collections.abc import Sequence from contextlib import contextmanager @@ -17,6 +21,26 @@ from frappe.utils.data import convert_utc_to_timezone, get_datetime, get_system_ datetime_like_types = (datetime.datetime, datetime.date, datetime.time, datetime.timedelta) +def debug_on(*exceptions): + if not exceptions: + exceptions = (AssertionError,) + + def decorator(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + try: + return f(*args, **kwargs) + except exceptions as e: + info = sys.exc_info() + traceback.print_exception(*info) + pdb.post_mortem(info[2]) + raise e + + return wrapper + + return decorator + + class FrappeTestCase(unittest.TestCase): """Base test class for Frappe tests. From 73e253fde2cbb75a4a12c0ef7a1a6b91826e3d25 Mon Sep 17 00:00:00 2001 From: "David (aider)" Date: Wed, 4 Sep 2024 17:00:45 +0200 Subject: [PATCH 2/6] docs: add extensive docstring to debug_on --- frappe/tests/utils.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/frappe/tests/utils.py b/frappe/tests/utils.py index ff7de8ae02..f5196a3b5c 100644 --- a/frappe/tests/utils.py +++ b/frappe/tests/utils.py @@ -22,6 +22,42 @@ datetime_like_types = (datetime.datetime, datetime.date, datetime.time, datetime def debug_on(*exceptions): + """ + A decorator to automatically start the debugger when specified exceptions occur. + + This decorator allows you to automatically invoke the debugger (pdb) when certain + exceptions are raised in the decorated function. If no exceptions are specified, + it defaults to catching AssertionError. + + Args: + *exceptions: Variable length argument list of exception classes to catch. + If none provided, defaults to (AssertionError,). + + Returns: + function: A decorator function. + + Usage: + 1. Basic usage (catches AssertionError): + @debug_on() + def test_assertion_error(): + assert False, "This will start the debugger" + + 2. Catching specific exceptions: + @debug_on(ValueError, TypeError) + def test_specific_exceptions(): + raise ValueError("This will start the debugger") + + 3. Using on a method in a test class: + class TestMyFunctionality(unittest.TestCase): + @debug_on(ZeroDivisionError) + def test_division_by_zero(self): + result = 1 / 0 + + Note: + When an exception is caught, this decorator will print the exception traceback + and then start the post-mortem debugger, allowing you to inspect the state of + the program at the point where the exception was raised. + """ if not exceptions: exceptions = (AssertionError,) From 349e9c67f304b7dc95c33b559435f795cd12b710 Mon Sep 17 00:00:00 2001 From: "David (aider)" Date: Wed, 4 Sep 2024 17:04:58 +0200 Subject: [PATCH 3/6] feat: Add --pdb and --pdb-on flags to FrappeTestCase --- frappe/tests/utils.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/frappe/tests/utils.py b/frappe/tests/utils.py index f5196a3b5c..d62474ec4f 100644 --- a/frappe/tests/utils.py +++ b/frappe/tests/utils.py @@ -105,8 +105,25 @@ class FrappeTestCase(unittest.TestCase): cls.addClassCleanup(_restore_thread_locals, copy.deepcopy(frappe.local.flags)) cls.addClassCleanup(_rollback_db) + cls._apply_debug_decorator() + return super().setUpClass() + @classmethod + def _apply_debug_decorator(cls): + import sys + + pdb_flag = next((arg for arg in sys.argv if arg.startswith('--pdb')), None) + if pdb_flag: + exceptions = (AssertionError,) + if pdb_flag.startswith('--pdb-on='): + exception_names = pdb_flag.split('=')[1].split(',') + exceptions = tuple(getattr(__builtins__, name.strip()) for name in exception_names) + + for attr in dir(cls): + if attr.startswith('test_'): + setattr(cls, attr, debug_on(*exceptions)(getattr(cls, attr))) + def assertSequenceSubset(self, larger: Sequence, smaller: Sequence, msg=None): """Assert that `expected` is a subset of `actual`.""" self.assertTrue(set(smaller).issubset(set(larger)), msg=msg) From 97a9b604a30d6105cca14d7355e8faee8fa50fd0 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 4 Sep 2024 19:43:05 +0200 Subject: [PATCH 4/6] fix: test module + test case + test method name --- frappe/test_runner.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/frappe/test_runner.py b/frappe/test_runner.py index 6f20de3d87..ccbec643eb 100644 --- a/frappe/test_runner.py +++ b/frappe/test_runner.py @@ -266,23 +266,29 @@ def _run_unittest( ): frappe.db.begin() - test_suite = unittest.TestSuite() + final_test_suite = unittest.TestSuite() if not isinstance(modules, list | tuple): modules = [modules] + def iterate_suite(suite): + for test in suite: + if isinstance(test, unittest.TestSuite): + yield from iterate_suite(test) + elif isinstance(test, unittest.TestCase): + yield test + for module in modules: if case: - module_test_cases = unittest.TestLoader().loadTestsFromTestCase(getattr(module, case)) + test_suite = unittest.TestLoader().loadTestsFromTestCase(getattr(module, case)) else: - module_test_cases = unittest.TestLoader().loadTestsFromModule(module) + test_suite = unittest.TestLoader().loadTestsFromModule(module) if tests: - for each in module_test_cases: - for test_case in each.__dict__["_tests"]: - if test_case.__dict__["_testMethodName"] in tests: - test_suite.addTest(test_case) + for test_case in iterate_suite(test_suite): + if test_case._testMethodName in tests: + final_test_suite.addTest(test_case) else: - test_suite.addTest(module_test_cases) + final_test_suite.addTest(test_suite) if junit_xml_output: runner = unittest_runner(verbosity=1 + cint(verbose), failfast=failfast) @@ -300,7 +306,7 @@ def _run_unittest( frappe.flags.tests_verbose = verbose - out = runner.run(test_suite) + out = runner.run(final_test_suite) if profile: pr.disable() From f194eeee92cddf07dc77a50185eb06bae6617065 Mon Sep 17 00:00:00 2001 From: "David (aider)" Date: Wed, 4 Sep 2024 17:13:44 +0200 Subject: [PATCH 5/6] feat: add --pdb --- frappe/commands/utils.py | 7 +++++++ frappe/test_runner.py | 7 +++++++ frappe/tests/utils.py | 18 ++---------------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 6f783bb7ef..26d10988d9 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -752,6 +752,7 @@ def transform_database(context, table, engine, row_format, failfast): ) @click.option("--test", multiple=True, help="Specific test") @click.option("--module", help="Run tests in a module") +@click.option("--pdb", is_flag=True, default=False, help="Open pdb on AssertionError") @click.option("--profile", is_flag=True, default=False) @click.option("--coverage", is_flag=True, default=False) @click.option("--skip-test-records", is_flag=True, default=False, help="Don't create test records") @@ -776,9 +777,14 @@ def run_tests( skip_before_tests=False, failfast=False, case=None, + pdb=False, ): """Run python unit-tests""" + pdb_on_exceptions = None + if pdb: + pdb_on_exceptions = (AssertionError,) + with CodeCoverage(coverage, app): import frappe import frappe.test_runner @@ -810,6 +816,7 @@ def run_tests( case=case, skip_test_records=skip_test_records, skip_before_tests=skip_before_tests, + pdb_on_exceptions=pdb_on_exceptions, ) if len(ret.failures) == 0 and len(ret.errors) == 0: diff --git a/frappe/test_runner.py b/frappe/test_runner.py index ccbec643eb..1c40d48232 100644 --- a/frappe/test_runner.py +++ b/frappe/test_runner.py @@ -53,6 +53,7 @@ def main( case=None, skip_test_records=False, skip_before_tests=False, + pdb_on_exceptions=False, ): global unittest_runner @@ -78,6 +79,7 @@ def main( try: frappe.flags.print_messages = verbose frappe.flags.in_test = True + frappe.flags.pdb_on_exceptions = pdb_on_exceptions # workaround! since there is no separate test db frappe.clear_cache() @@ -290,6 +292,11 @@ def _run_unittest( else: final_test_suite.addTest(test_suite) + if frappe.flags.pdb_on_exceptions: + for test_case in iterate_suite(final_test_suite): + if hasattr(test_case, "_apply_debug_decorator"): + test_case._apply_debug_decorator(frappe.flags.pdb_on_exceptions) + if junit_xml_output: runner = unittest_runner(verbosity=1 + cint(verbose), failfast=failfast) else: diff --git a/frappe/tests/utils.py b/frappe/tests/utils.py index d62474ec4f..7294498d33 100644 --- a/frappe/tests/utils.py +++ b/frappe/tests/utils.py @@ -105,24 +105,10 @@ class FrappeTestCase(unittest.TestCase): cls.addClassCleanup(_restore_thread_locals, copy.deepcopy(frappe.local.flags)) cls.addClassCleanup(_rollback_db) - cls._apply_debug_decorator() - return super().setUpClass() - @classmethod - def _apply_debug_decorator(cls): - import sys - - pdb_flag = next((arg for arg in sys.argv if arg.startswith('--pdb')), None) - if pdb_flag: - exceptions = (AssertionError,) - if pdb_flag.startswith('--pdb-on='): - exception_names = pdb_flag.split('=')[1].split(',') - exceptions = tuple(getattr(__builtins__, name.strip()) for name in exception_names) - - for attr in dir(cls): - if attr.startswith('test_'): - setattr(cls, attr, debug_on(*exceptions)(getattr(cls, attr))) + def _apply_debug_decorator(self, exceptions=()): + setattr(self, self._testMethodName, debug_on(*exceptions)(getattr(self, self._testMethodName))) def assertSequenceSubset(self, larger: Sequence, smaller: Sequence, msg=None): """Assert that `expected` is a subset of `actual`.""" From 789475e05f935defdc3377e5560681dd8433b61b Mon Sep 17 00:00:00 2001 From: David Date: Wed, 4 Sep 2024 19:58:10 +0200 Subject: [PATCH 6/6] chore: lint --- .pre-commit-config.yaml | 1 + frappe/tests/utils.py | 38 +++++++++++++++++++------------------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bf4dcdd27d..e1bea7cc91 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,6 +19,7 @@ repos: - id: check-toml - id: check-yaml - id: debug-statements + exclude: ^frappe/tests/utils\.py$ - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.2.0 diff --git a/frappe/tests/utils.py b/frappe/tests/utils.py index 7294498d33..853399c841 100644 --- a/frappe/tests/utils.py +++ b/frappe/tests/utils.py @@ -30,33 +30,33 @@ def debug_on(*exceptions): it defaults to catching AssertionError. Args: - *exceptions: Variable length argument list of exception classes to catch. - If none provided, defaults to (AssertionError,). + *exceptions: Variable length argument list of exception classes to catch. + If none provided, defaults to (AssertionError,). Returns: - function: A decorator function. + function: A decorator function. Usage: - 1. Basic usage (catches AssertionError): - @debug_on() - def test_assertion_error(): - assert False, "This will start the debugger" + 1. Basic usage (catches AssertionError): + @debug_on() + def test_assertion_error(): + assert False, "This will start the debugger" - 2. Catching specific exceptions: - @debug_on(ValueError, TypeError) - def test_specific_exceptions(): - raise ValueError("This will start the debugger") + 2. Catching specific exceptions: + @debug_on(ValueError, TypeError) + def test_specific_exceptions(): + raise ValueError("This will start the debugger") - 3. Using on a method in a test class: - class TestMyFunctionality(unittest.TestCase): - @debug_on(ZeroDivisionError) - def test_division_by_zero(self): - result = 1 / 0 + 3. Using on a method in a test class: + class TestMyFunctionality(unittest.TestCase): + @debug_on(ZeroDivisionError) + def test_division_by_zero(self): + result = 1 / 0 Note: - When an exception is caught, this decorator will print the exception traceback - and then start the post-mortem debugger, allowing you to inspect the state of - the program at the point where the exception was raised. + When an exception is caught, this decorator will print the exception traceback + and then start the post-mortem debugger, allowing you to inspect the state of + the program at the point where the exception was raised. """ if not exceptions: exceptions = (AssertionError,)