From c59eb26f9e34b99965122a663326a691a2e2664a Mon Sep 17 00:00:00 2001 From: eleanorjboyd Date: Fri, 13 Oct 2023 19:35:24 -0700 Subject: [PATCH 1/3] implement django tests with custom runner --- pythonFiles/unittestadapter/django_runner.py | 107 ++++++++++++++++++ .../unittestadapter/django_test_runner.py | 17 +++ pythonFiles/unittestadapter/execution.py | 31 +++-- .../testing/testController/common/server.ts | 12 +- 4 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 pythonFiles/unittestadapter/django_runner.py create mode 100644 pythonFiles/unittestadapter/django_test_runner.py diff --git a/pythonFiles/unittestadapter/django_runner.py b/pythonFiles/unittestadapter/django_runner.py new file mode 100644 index 000000000000..5cb1a546e998 --- /dev/null +++ b/pythonFiles/unittestadapter/django_runner.py @@ -0,0 +1,107 @@ +import subprocess +import os +import pathlib +import re +import sys + + +def find_settings_module(path_to_manage_py): + dj_settings_module = None + with open(path_to_manage_py, "r") as manage_py: + pattern = r"^os\.environ\.setdefault\((['\"])(DJANGO_SETTINGS_MODULE)\1, (['\"])(?P[\w.]+)\3\)$" + for line in manage_py.readlines(): + match_result = re.match(pattern, line.strip()) + if match_result is not None: + dj_settings_module = match_result.groupdict().get("settings_path", None) + break + return dj_settings_module + + +def configure_test_runner(path_to_manage_py): + # Getting the DJANGO_SETTINGS_MODULE from manage.py + dj_settings_module = find_settings_module(path_to_manage_py) + if dj_settings_module is None: + raise Exception("DJANGO_SETTINGS_MODULE not found in manage.py") + + # Construct the path to the settings.py file + settings_file = os.path.join( + os.path.dirname(dj_settings_module.replace(".", os.sep)), "settings.py" + ) + # Check if the settings.py file exists + if not os.path.exists(settings_file): + raise Exception(f"settings.py file not found at {settings_file}") + # Read the content of the existing settings.py file + with open(settings_file, "r") as f: + original_settings_content = f.read() + + # Check if TEST_RUNNER is already defined in the settings + if "TEST_RUNNER" in original_settings_content: + print("TEST_RUNNER is already defined in settings.py. but continuing") + print("settings_content: ", original_settings_content) + else: + # Add the custom test runner to the settings.py file + + # Get path to the custom_test_runner.py parent folder, add to sys.path + custom_test_runner_dir = pathlib.Path(__file__).parent + sys.path.insert(0, custom_test_runner_dir) + + # Import your custom test runner class + # from execution import UnittestTestResult + + # Set the TEST_RUNNER setting + setting_content = original_settings_content + ( + "\n\n" + + "# Use custom test runner\n" + + "import sys\n" + + f"sys.path.insert(0, '{custom_test_runner_dir}')\n" + + f"TEST_RUNNER = 'django_test_runner.CustomTestRunner'\n" + ) + + # Write the updated content back to the settings.py file + with open(settings_file, "w") as f: + f.write(setting_content) + + print("TEST_RUNNER setting added to settings.py.") + return settings_file, original_settings_content + + +# Define a cleanup method +def cleanup(settings_file, original_settings_content): + # Restore the original content of settings.py + with open(settings_file, "w") as f: + f.write(original_settings_content) + print("Settings.py has been restored to its original state.") + + return True + + +def runner(): + # Define the path to your manage.py file + # could get path to manage.py from environment variable + # get Django test boolean + django_test_enabled = os.environ.get("DJANGO_TEST_ENABLED") + manage_py_path = os.environ.get("MANAGE_PY_PATH") + + if ( + django_test_enabled is not None + and django_test_enabled.lower() == "true" + and manage_py_path is not None + ): + # attempt to configure and run tests as django tests + try: + settings_file, original_settings_content = configure_test_runner( + manage_py_path + ) + # Command to run 'python manage.py test' + python_executable = sys.executable + command = [python_executable, "manage.py", "test"] + print("running test command: ", command) + # Run the command + try: + subprocess.run(" ".join(command), shell=True, check=True) + # Cleanup + cleanup(settings_file, original_settings_content) + except subprocess.CalledProcessError as e: + print(f"Error running 'manage.py test': {e}") + except Exception as e: + print(f"Error configuring Django test runner: {e}") diff --git a/pythonFiles/unittestadapter/django_test_runner.py b/pythonFiles/unittestadapter/django_test_runner.py new file mode 100644 index 000000000000..317751fe9acb --- /dev/null +++ b/pythonFiles/unittestadapter/django_test_runner.py @@ -0,0 +1,17 @@ +from django.test.runner import DiscoverRunner +import sys +import os +import pathlib + +script_dir = pathlib.Path(__file__).parent +sys.path.append(os.fspath(script_dir)) + +from execution import UnittestTestResult + + +class CustomTestRunner(DiscoverRunner): + def get_test_runner_kwargs(self): + print("get_test_runner_kwargs") + kwargs = super().get_test_runner_kwargs() + kwargs["resultclass"] = UnittestTestResult + return kwargs diff --git a/pythonFiles/unittestadapter/execution.py b/pythonFiles/unittestadapter/execution.py index 2a22bfff3486..9a86aa0d17c9 100644 --- a/pythonFiles/unittestadapter/execution.py +++ b/pythonFiles/unittestadapter/execution.py @@ -20,6 +20,7 @@ from testing_tools import process_json_util, socket_manager from typing_extensions import Literal, NotRequired, TypeAlias, TypedDict from unittestadapter.utils import parse_unittest_args +from django_runner import runner ErrorType = Union[ Tuple[Type[BaseException], BaseException, TracebackType], Tuple[None, None, None] @@ -103,7 +104,6 @@ def formatResult( subtest: Union[unittest.TestCase, None] = None, ): tb = None - message = "" # error is a tuple of the form returned by sys.exc_info(): (type, value, traceback). if error is not None: @@ -128,9 +128,16 @@ def formatResult( "subtest": subtest.id() if subtest else None, } self.formatted[test_id] = result - if testPort == 0 or testUuid == 0: - print("Error sending response, port or uuid unknown to python server.") - send_run_data(result, testPort, testUuid) + testPort2 = int(os.environ.get("TEST_PORT", DEFAULT_PORT)) + testUuid2 = os.environ.get("TEST_UUID") + if testPort2 == 0 or testUuid2 == 0: + print( + "Error sending response, port or uuid unknown to python server.", + testPort, + testUuid, + ) + + send_run_data(result, testPort2, testUuid2) class TestExecutionStatus(str, enum.Enum): @@ -318,9 +325,19 @@ def post_response( testUuid = "unknown" if test_ids_from_buffer: # Perform test execution. - payload = run_tests( - start_dir, test_ids_from_buffer, pattern, top_level_dir, testUuid - ) + + # get django test boolean + django_test_enabled = os.environ.get("DJANGO_TEST_ENABLED") + print("DJANGO_TEST_ENABLED = ", django_test_enabled) + if django_test_enabled: + # run django runner + print("running django runner") + runner() + else: + print("running unittest runner") + payload = run_tests( + start_dir, test_ids_from_buffer, pattern, top_level_dir, testUuid + ) else: cwd = os.path.abspath(start_dir) status = TestExecutionStatus.error diff --git a/src/client/testing/testController/common/server.ts b/src/client/testing/testController/common/server.ts index e496860526e4..db346c5de818 100644 --- a/src/client/testing/testController/common/server.ts +++ b/src/client/testing/testController/common/server.ts @@ -184,6 +184,16 @@ export class PythonTestServer implements ITestServer, Disposable { mutableEnv.TEST_PORT = this.getPort().toString(); mutableEnv.RUN_TEST_IDS_PORT = runTestIdPort; + const isRun = runTestIdPort !== undefined; + + // NEEDS TO BE UNCOMMENTED TO GET DJANGO WORKING + // if (isRun) { + // mutableEnv.DJANGO_TEST_ENABLED = 'true'; + // mutableEnv.MANAGE_PY_PATH = [options.cwd, 'manage.py'].join('/'); + // console.log('DJANGO_TEST_ENABLED', mutableEnv.DJANGO_TEST_ENABLED); + // console.log('MANAGE_PY_PATH', mutableEnv.MANAGE_PY_PATH); + // } + const spawnOptions: SpawnOptions = { token: options.token, cwd: options.cwd, @@ -191,7 +201,7 @@ export class PythonTestServer implements ITestServer, Disposable { outputChannel: options.outChannel, env: mutableEnv, }; - const isRun = runTestIdPort !== undefined; + // Create the Python environment in which to execute the command. const creationOptions: ExecutionFactoryCreateWithEnvironmentOptions = { allowEnvironmentFetchExceptions: false, From 31305e16e275ab5db33ab9862f2dc1e444cd7ecb Mon Sep 17 00:00:00 2001 From: eleanorjboyd Date: Wed, 18 Oct 2023 13:09:03 -0700 Subject: [PATCH 2/3] switch to using --testrunner --- pythonFiles/unittestadapter/django_runner.py | 128 +++++------------- pythonFiles/unittestadapter/execution.py | 83 ++++++------ .../testing/testController/common/server.ts | 12 +- 3 files changed, 84 insertions(+), 139 deletions(-) diff --git a/pythonFiles/unittestadapter/django_runner.py b/pythonFiles/unittestadapter/django_runner.py index 5cb1a546e998..9aa306f5527e 100644 --- a/pythonFiles/unittestadapter/django_runner.py +++ b/pythonFiles/unittestadapter/django_runner.py @@ -1,107 +1,47 @@ import subprocess import os import pathlib -import re import sys +from typing import Union +from pythonFiles.unittestadapter.execution import VSCodeUnittestError -def find_settings_module(path_to_manage_py): - dj_settings_module = None - with open(path_to_manage_py, "r") as manage_py: - pattern = r"^os\.environ\.setdefault\((['\"])(DJANGO_SETTINGS_MODULE)\1, (['\"])(?P[\w.]+)\3\)$" - for line in manage_py.readlines(): - match_result = re.match(pattern, line.strip()) - if match_result is not None: - dj_settings_module = match_result.groupdict().get("settings_path", None) - break - return dj_settings_module +def django_execution_runner(start_dir: Union[str, None]): + # Get path to manage.py if set as an env var, otherwise use the default + manage_py_path = os.environ.get("MANAGE_PY_PATH") -def configure_test_runner(path_to_manage_py): - # Getting the DJANGO_SETTINGS_MODULE from manage.py - dj_settings_module = find_settings_module(path_to_manage_py) - if dj_settings_module is None: - raise Exception("DJANGO_SETTINGS_MODULE not found in manage.py") - - # Construct the path to the settings.py file - settings_file = os.path.join( - os.path.dirname(dj_settings_module.replace(".", os.sep)), "settings.py" - ) - # Check if the settings.py file exists - if not os.path.exists(settings_file): - raise Exception(f"settings.py file not found at {settings_file}") - # Read the content of the existing settings.py file - with open(settings_file, "r") as f: - original_settings_content = f.read() + if manage_py_path is None: + # Search for default manage.py path at the root of the workspace + if not start_dir: + print( + "Error running Django, no start_dir provided or value for MANAGE_PY_PATH" + ) - # Check if TEST_RUNNER is already defined in the settings - if "TEST_RUNNER" in original_settings_content: - print("TEST_RUNNER is already defined in settings.py. but continuing") - print("settings_content: ", original_settings_content) - else: - # Add the custom test runner to the settings.py file + cwd = os.path.abspath(start_dir) + manage_py_path = os.path.join(cwd, "manage.py") - # Get path to the custom_test_runner.py parent folder, add to sys.path + try: + # Get path to the custom_test_runner.py parent folder, add to sys.path. custom_test_runner_dir = pathlib.Path(__file__).parent sys.path.insert(0, custom_test_runner_dir) - - # Import your custom test runner class - # from execution import UnittestTestResult - - # Set the TEST_RUNNER setting - setting_content = original_settings_content + ( - "\n\n" - + "# Use custom test runner\n" - + "import sys\n" - + f"sys.path.insert(0, '{custom_test_runner_dir}')\n" - + f"TEST_RUNNER = 'django_test_runner.CustomTestRunner'\n" - ) - - # Write the updated content back to the settings.py file - with open(settings_file, "w") as f: - f.write(setting_content) - - print("TEST_RUNNER setting added to settings.py.") - return settings_file, original_settings_content - - -# Define a cleanup method -def cleanup(settings_file, original_settings_content): - # Restore the original content of settings.py - with open(settings_file, "w") as f: - f.write(original_settings_content) - print("Settings.py has been restored to its original state.") - - return True - - -def runner(): - # Define the path to your manage.py file - # could get path to manage.py from environment variable - # get Django test boolean - django_test_enabled = os.environ.get("DJANGO_TEST_ENABLED") - manage_py_path = os.environ.get("MANAGE_PY_PATH") - - if ( - django_test_enabled is not None - and django_test_enabled.lower() == "true" - and manage_py_path is not None - ): - # attempt to configure and run tests as django tests + custom_test_runner = "django_test_runner.CustomTestRunner" + + # Build command to run 'python manage.py test'. + python_executable = sys.executable + command = [ + python_executable, + "manage.py", + "test", + "--testrunner", + custom_test_runner, + ] + print("Running Django run tests with command: ", command) try: - settings_file, original_settings_content = configure_test_runner( - manage_py_path - ) - # Command to run 'python manage.py test' - python_executable = sys.executable - command = [python_executable, "manage.py", "test"] - print("running test command: ", command) - # Run the command - try: - subprocess.run(" ".join(command), shell=True, check=True) - # Cleanup - cleanup(settings_file, original_settings_content) - except subprocess.CalledProcessError as e: - print(f"Error running 'manage.py test': {e}") - except Exception as e: - print(f"Error configuring Django test runner: {e}") + subprocess.run(" ".join(command), shell=True, check=True) + except subprocess.CalledProcessError as e: + print(f"Error running 'manage.py test': {e}") + raise VSCodeUnittestError(f"Error running 'manage.py test': {e}") + except Exception as e: + print(f"Error configuring Django test runner: {e}") + raise VSCodeUnittestError(f"Error configuring Django test runner: {e}") diff --git a/pythonFiles/unittestadapter/execution.py b/pythonFiles/unittestadapter/execution.py index 9a86aa0d17c9..4829d8905433 100644 --- a/pythonFiles/unittestadapter/execution.py +++ b/pythonFiles/unittestadapter/execution.py @@ -20,7 +20,7 @@ from testing_tools import process_json_util, socket_manager from typing_extensions import Literal, NotRequired, TypeAlias, TypedDict from unittestadapter.utils import parse_unittest_args -from django_runner import runner +from django_runner import django_execution_runner ErrorType = Union[ Tuple[Type[BaseException], BaseException, TracebackType], Tuple[None, None, None] @@ -31,6 +31,13 @@ DEFAULT_PORT = 45454 +class VSCodeUnittestError(Exception): + """A custom exception class for pytest errors.""" + + def __init__(self, message): + super().__init__(message) + + class TestOutcomeEnum(str, enum.Enum): error = "error" failure = "failure" @@ -310,46 +317,44 @@ def post_response( testPort = int(os.environ.get("TEST_PORT", DEFAULT_PORT)) testUuid = os.environ.get("TEST_UUID") - if testPort is DEFAULT_PORT: - print( - "Error[vscode-unittest]: TEST_PORT is not set.", - " TEST_UUID = ", - testUuid, - ) - if testUuid is None: - print( - "Error[vscode-unittest]: TEST_UUID is not set.", - " TEST_PORT = ", - testPort, - ) - testUuid = "unknown" - if test_ids_from_buffer: - # Perform test execution. - - # get django test boolean - django_test_enabled = os.environ.get("DJANGO_TEST_ENABLED") - print("DJANGO_TEST_ENABLED = ", django_test_enabled) - if django_test_enabled: - # run django runner - print("running django runner") - runner() - else: - print("running unittest runner") - payload = run_tests( - start_dir, test_ids_from_buffer, pattern, top_level_dir, testUuid + try: + if testPort is DEFAULT_PORT: + raise VSCodeUnittestError( + "Error[vscode-unittest]: TEST_PORT is not set.", + " TEST_UUID = ", + testUuid, ) - else: - cwd = os.path.abspath(start_dir) - status = TestExecutionStatus.error + if testUuid is None: + raise VSCodeUnittestError( + "Error[vscode-unittest]: TEST_UUID is not set.", + " TEST_PORT = ", + testPort, + ) + if test_ids_from_buffer: + # Perform test execution. + + # Check to see if we are running django tests. + django_test_enabled = os.environ.get("DJANGO_TEST_ENABLED") + print("DJANGO_TEST_ENABLED = ", django_test_enabled) + if django_test_enabled and django_test_enabled.lower() == "true": + # run django runner + print("running django runner") + django_execution_runner(start_dir) + else: + print("running unittest runner") + payload = run_tests( + start_dir, test_ids_from_buffer, pattern, top_level_dir, testUuid + ) + else: + raise VSCodeUnittestError("No test ids received from buffer") + except Exception as exception: payload: PayloadDict = { - "cwd": cwd, - "status": status, - "error": "No test ids received from buffer", + "cwd": os.path.abspath(start_dir) if start_dir else None, + "status": TestExecutionStatus.error, + "error": exception, "result": None, } + post_response(payload, testPort, "unknown") + eot_payload: EOTPayloadDict = {"command_type": "execution", "eot": True} - if testUuid is None: - print("Error sending response, uuid unknown to python server.") - post_response(eot_payload, testPort, "unknown") - else: - post_response(eot_payload, testPort, testUuid) + post_response(eot_payload, testPort, testUuid) diff --git a/src/client/testing/testController/common/server.ts b/src/client/testing/testController/common/server.ts index db346c5de818..82a062881728 100644 --- a/src/client/testing/testController/common/server.ts +++ b/src/client/testing/testController/common/server.ts @@ -187,12 +187,12 @@ export class PythonTestServer implements ITestServer, Disposable { const isRun = runTestIdPort !== undefined; // NEEDS TO BE UNCOMMENTED TO GET DJANGO WORKING - // if (isRun) { - // mutableEnv.DJANGO_TEST_ENABLED = 'true'; - // mutableEnv.MANAGE_PY_PATH = [options.cwd, 'manage.py'].join('/'); - // console.log('DJANGO_TEST_ENABLED', mutableEnv.DJANGO_TEST_ENABLED); - // console.log('MANAGE_PY_PATH', mutableEnv.MANAGE_PY_PATH); - // } + if (isRun) { + mutableEnv.DJANGO_TEST_ENABLED = 'true'; + mutableEnv.MANAGE_PY_PATH = [options.cwd, 'manage.py'].join('/'); + console.log('DJANGO_TEST_ENABLED', mutableEnv.DJANGO_TEST_ENABLED); + console.log('MANAGE_PY_PATH', mutableEnv.MANAGE_PY_PATH); + } const spawnOptions: SpawnOptions = { token: options.token, From 395e36613abacf5de67b340e23eb546b6232ed9c Mon Sep 17 00:00:00 2001 From: eleanorjboyd Date: Wed, 18 Oct 2023 13:12:24 -0700 Subject: [PATCH 3/3] add result class error msg --- pythonFiles/unittestadapter/django_test_runner.py | 4 ++++ src/client/testing/testController/common/server.ts | 12 ++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/pythonFiles/unittestadapter/django_test_runner.py b/pythonFiles/unittestadapter/django_test_runner.py index 317751fe9acb..9ff73b16e94b 100644 --- a/pythonFiles/unittestadapter/django_test_runner.py +++ b/pythonFiles/unittestadapter/django_test_runner.py @@ -13,5 +13,9 @@ class CustomTestRunner(DiscoverRunner): def get_test_runner_kwargs(self): print("get_test_runner_kwargs") kwargs = super().get_test_runner_kwargs() + if kwargs["resultclass"] is not None: + raise ValueError( + "Resultclass already set, cannot use custom test runner design for VS Code compatibility." + ) kwargs["resultclass"] = UnittestTestResult return kwargs diff --git a/src/client/testing/testController/common/server.ts b/src/client/testing/testController/common/server.ts index 82a062881728..db346c5de818 100644 --- a/src/client/testing/testController/common/server.ts +++ b/src/client/testing/testController/common/server.ts @@ -187,12 +187,12 @@ export class PythonTestServer implements ITestServer, Disposable { const isRun = runTestIdPort !== undefined; // NEEDS TO BE UNCOMMENTED TO GET DJANGO WORKING - if (isRun) { - mutableEnv.DJANGO_TEST_ENABLED = 'true'; - mutableEnv.MANAGE_PY_PATH = [options.cwd, 'manage.py'].join('/'); - console.log('DJANGO_TEST_ENABLED', mutableEnv.DJANGO_TEST_ENABLED); - console.log('MANAGE_PY_PATH', mutableEnv.MANAGE_PY_PATH); - } + // if (isRun) { + // mutableEnv.DJANGO_TEST_ENABLED = 'true'; + // mutableEnv.MANAGE_PY_PATH = [options.cwd, 'manage.py'].join('/'); + // console.log('DJANGO_TEST_ENABLED', mutableEnv.DJANGO_TEST_ENABLED); + // console.log('MANAGE_PY_PATH', mutableEnv.MANAGE_PY_PATH); + // } const spawnOptions: SpawnOptions = { token: options.token,