diff --git a/pythonFiles/testing_tools/unittest_discovery.py b/pythonFiles/testing_tools/unittest_discovery.py index 2988092c387c..4a36e17a7466 100644 --- a/pythonFiles/testing_tools/unittest_discovery.py +++ b/pythonFiles/testing_tools/unittest_discovery.py @@ -9,6 +9,24 @@ top_level_dir = sys.argv[3] if len(sys.argv) >= 4 else None sys.path.insert(0, os.getcwd()) +import os.path + +sys.path.insert( + 1, + os.path.dirname( # pythonFiles + os.path.dirname( # pythonFiles/testing_tools + os.path.abspath(__file__) # this file + ) + ), +) + +from django_runner import setup_django_env + +django_test_enabled = os.environ.get("DJANGO_TEST_ENABLED", "False") +if django_test_enabled.lower() == "true": + print(f"DJANGO TEST DECLEARED = {django_test_enabled}") + django_env_enabled = setup_django_env(start_dir) + print(f"DJANGO ENV ENABLED = {django_env_enabled}") def get_sourceline(obj): try: diff --git a/pythonFiles/unittestadapter/discovery.py b/pythonFiles/unittestadapter/discovery.py index 7525f33cda61..f14058112666 100644 --- a/pythonFiles/unittestadapter/discovery.py +++ b/pythonFiles/unittestadapter/discovery.py @@ -3,7 +3,7 @@ import json import os -import pathlib +import pathlib # TODO: pathlib added in python v3.4 - this file used to used os.path.dirname | commit/0b6fc5b44c70fed32294b460e3ea45854ae220e4 import sys import traceback import unittest @@ -19,6 +19,8 @@ # If I use from utils then there will be an import error in test_discovery.py. from unittestadapter.utils import TestNode, build_test_tree, parse_unittest_args +from unittestadapter.django_runner import setup_django_env + DEFAULT_PORT = 45454 @@ -46,7 +48,7 @@ def discover_tests( - cwd: Absolute path to the test start directory; - uuid: UUID sent by the caller of the Python script, that needs to be sent back as an integrity check; - status: Test discovery status, can be "success" or "error"; - - tests: Discoverered tests if any, not present otherwise. Note that the status can be "error" but the payload can still contain tests; + - tests: Discovered tests if any, not present otherwise. Note that the status can be "error" but the payload can still contain tests; - error: Discovery error if any, not present otherwise. Payload format for a successful discovery: @@ -78,7 +80,7 @@ def discover_tests( loader = unittest.TestLoader() suite = loader.discover(start_dir, pattern, top_level_dir) - tests, error = build_test_tree(suite, cwd) # test tree built succesfully here. + tests, error = build_test_tree(suite, cwd) # test tree built successfully here. except Exception: error.append(traceback.format_exc()) @@ -121,6 +123,12 @@ def post_response( start_dir, pattern, top_level_dir = parse_unittest_args(argv[index + 1 :]) + django_test_enabled = os.environ.get("DJANGO_TEST_ENABLED", "False") + if django_test_enabled.lower() == "true": + print(f"DJANGO TEST DECLEARED = {django_test_enabled}") + django_env_enabled = setup_django_env(start_dir) + print(f"DJANGO ENV ENABLED = {django_env_enabled}") + testPort = int(os.environ.get("TEST_PORT", DEFAULT_PORT)) testUuid = os.environ.get("TEST_UUID") if testPort is DEFAULT_PORT: diff --git a/pythonFiles/unittestadapter/django_runner.py b/pythonFiles/unittestadapter/django_runner.py new file mode 100644 index 000000000000..7378d50f50c8 --- /dev/null +++ b/pythonFiles/unittestadapter/django_runner.py @@ -0,0 +1,112 @@ +import subprocess +import os +import sys +from typing import Union + +from pythonFiles.unittestadapter.execution import VSCodeUnittestError + +def setup_django_env(start_dir: Union[str, None]): + """Configures the Django environment to run Django tests. + + If Django is not installed or if manage.py can not be found, the function fails quietly. + + Args: + start_dir (str): The root directory of the Django project. + + Returns: + boolean: either succeeded or failed. + """ + + # To avoid false positive ModuleNotFoundError from django.setup() due to missing current workspace in sys.path + sys.path.insert(0, os.getcwd()) + + try: + import django + except ImportError: + return False + + # Get path to manage.py if set as an env var, otherwise use the default + manage_py_path = os.environ.get("MANAGE_PY_PATH") + + 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" + ) + + cwd = os.path.abspath(start_dir) + manage_py_path = os.path.join(cwd, "manage.py") + + django_settings_module = os.environ.get("DJANGO_SETTINGS_MODULE", None) + + if django_settings_module is None: + print("Warning running Django, missing django settings module in environment, reading from manage.py") + + import re + try: + with open(manage_py_path, "r") as f: + manage_py_module = f.readlines() + except FileNotFoundError: + print("Error running Django, manage.py not found") + return False + + pattern = r"^os\.environ\.setdefault\((\'|\")DJANGO_SETTINGS_MODULE(\'|\"), (\'|\")(?P[\w.]+)(\'|\")\)$" + for line in manage_py_module: + matched = re.match(pattern, line.strip()) + if matched is not None: + django_settings_module = matched.groupdict().get("settings_path", None) + break + + if django_settings_module is None: + print("Error running Django, django settings module not found") + return False + + os.environ.setdefault("DJANGO_SETTINGS_MODULE", django_settings_module) + + try: + django.setup() + except ModuleNotFoundError: + print("Error running Django, Drat!") + return False + + return True + +def django_execution_runner(start_dir: Union[str, None]): + + _ = setup_django_env(start_dir) + + try: + # Get path to the custom_test_runner.py parent folder, add to sys.path. + + # TODO: Check backward compatibility https://docs.python.org/3/library/pathlib.html -> New in version 3.4. + # import pathlib + # custom_test_runner_dir = pathlib.Path(__file__).parent + # sys.path.insert(0, custom_test_runner_dir) + + sys.path.insert( + 0, + os.path.dirname( # pythonFiles/unittestadapter + os.path.abspath(__file__) # this file + ) + ) + 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: + 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/django_test_runner.py b/pythonFiles/unittestadapter/django_test_runner.py new file mode 100644 index 000000000000..9ff73b16e94b --- /dev/null +++ b/pythonFiles/unittestadapter/django_test_runner.py @@ -0,0 +1,21 @@ +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() + 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/pythonFiles/unittestadapter/execution.py b/pythonFiles/unittestadapter/execution.py index 2a22bfff3486..4829d8905433 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 django_execution_runner ErrorType = Union[ Tuple[Type[BaseException], BaseException, TracebackType], Tuple[None, None, None] @@ -30,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" @@ -103,7 +111,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 +135,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): @@ -303,36 +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. - payload = run_tests( - start_dir, test_ids_from_buffer, pattern, top_level_dir, testUuid - ) - else: - cwd = os.path.abspath(start_dir) - status = TestExecutionStatus.error + try: + if testPort is DEFAULT_PORT: + raise VSCodeUnittestError( + "Error[vscode-unittest]: TEST_PORT is not set.", + " TEST_UUID = ", + testUuid, + ) + 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 4e7a617a3ffd..91e6348c81df 100644 --- a/src/client/testing/testController/common/server.ts +++ b/src/client/testing/testController/common/server.ts @@ -190,6 +190,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, @@ -197,7 +207,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,