Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Bug with Pytest when using symlinked workspaces #22885

Merged
merged 33 commits into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
638eb39
symlink solution for pytest
eleanorjboyd Feb 2, 2024
f52e093
add arg mapping to pytest adapters
eleanorjboyd Feb 5, 2024
99349e6
testing for util functions
eleanorjboyd Feb 5, 2024
76973c7
fix existing tests
eleanorjboyd Feb 5, 2024
e648fda
minor fixes
eleanorjboyd Feb 6, 2024
8d8d4fd
symlink end to end test
eleanorjboyd Feb 6, 2024
939b4b7
touchups
eleanorjboyd Feb 8, 2024
220c821
fix failing test
eleanorjboyd Feb 8, 2024
58ea94d
fix import
eleanorjboyd Feb 8, 2024
f06f8d4
Merge branch 'main' into symlink-solution-pytest
eleanorjboyd Feb 8, 2024
53347f9
switch to using tmpdir
eleanorjboyd Feb 9, 2024
f71ab3a
fix paths
eleanorjboyd Feb 9, 2024
35098ce
add 3rd party notice
eleanorjboyd Feb 9, 2024
c5d5e6b
add better assertion error handling
eleanorjboyd Feb 9, 2024
978ff58
logging
eleanorjboyd Feb 9, 2024
1a6c662
logging
eleanorjboyd Feb 9, 2024
5e49484
printing for permissions error
eleanorjboyd Feb 9, 2024
9fac030
make symlink destination
eleanorjboyd Feb 9, 2024
717e269
revert mkdir
eleanorjboyd Feb 12, 2024
12555b2
fix to actual symlink folder
eleanorjboyd Feb 14, 2024
03cd94f
Update ThirdPartyNotices-Repository.txt
eleanorjboyd Feb 14, 2024
21858bc
add license
eleanorjboyd Feb 14, 2024
82341dc
fix pyright
eleanorjboyd Feb 14, 2024
add10d9
switching to setup and cleanup for symlink tests
eleanorjboyd Feb 14, 2024
76f292d
fix import
eleanorjboyd Feb 14, 2024
89a4713
add error msg
eleanorjboyd Feb 14, 2024
6b55a2c
fixing issue with path creation for expected output constant
eleanorjboyd Feb 14, 2024
477c41a
remove test printing
eleanorjboyd Feb 14, 2024
1348d36
windows specific test
eleanorjboyd Feb 14, 2024
e670907
add trace messaging
eleanorjboyd Feb 14, 2024
ce21dac
fix other assert to also work with the lowercase check
eleanorjboyd Feb 14, 2024
bc95b7a
Merge branch 'main' into symlink-solution-pytest
eleanorjboyd Feb 14, 2024
8609910
small edits- karthik review
eleanorjboyd Feb 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions ThirdPartyNotices-Repository.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Microsoft Python extension for Visual Studio Code incorporates third party mater
11. vscode-cpptools (https://github.com/microsoft/vscode-cpptools)
12. mocha (https://github.com/mochajs/mocha)
13. get-pip (https://github.com/pypa/get-pip)
14. vscode-js-debug (https://github.com/microsoft/vscode-js-debug)

%%
Go for Visual Studio Code NOTICES, INFORMATION, AND LICENSE BEGIN HERE
eleanorjboyd marked this conversation as resolved.
Show resolved Hide resolved
Expand Down Expand Up @@ -1032,3 +1033,31 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

=========================================
END OF get-pip NOTICES, INFORMATION, AND LICENSE


%% vscode-js-debug NOTICES, INFORMATION, AND LICENSE BEGIN HERE
=========================================

MIT License

Copyright (c) Microsoft Corporation. All rights reserved.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
=========================================
END OF vscode-js-debug NOTICES, INFORMATION, AND LICENSE
75 changes: 75 additions & 0 deletions pythonFiles/tests/pytestadapter/expected_discovery_test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -994,3 +994,78 @@
],
"id_": str(TEST_DATA_PATH),
}
SYMLINK_FOLDER_PATH = TEST_DATA_PATH / "symlink_folder"
SYMLINK_FOLDER_PATH_TESTS = TEST_DATA_PATH / "symlink_folder" / "tests"
SYMLINK_FOLDER_PATH_TESTS_TEST_A = (
TEST_DATA_PATH / "symlink_folder" / "tests" / "test_a.py"
)
SYMLINK_FOLDER_PATH_TESTS_TEST_B = (
TEST_DATA_PATH / "symlink_folder" / "tests" / "test_b.py"
)

symlink_expected_discovery_output = {
"name": "symlink_folder",
"path": str(SYMLINK_FOLDER_PATH),
"type_": "folder",
"children": [
{
"name": "tests",
"path": str(SYMLINK_FOLDER_PATH_TESTS),
"type_": "folder",
"id_": str(SYMLINK_FOLDER_PATH_TESTS),
"children": [
{
"name": "test_a.py",
"path": str(SYMLINK_FOLDER_PATH_TESTS_TEST_A),
"type_": "file",
"id_": str(SYMLINK_FOLDER_PATH_TESTS_TEST_A),
"children": [
{
"name": "test_a_function",
"path": str(SYMLINK_FOLDER_PATH_TESTS_TEST_A),
"lineno": find_test_line_number(
"test_a_function",
os.path.join(tests_path, "test_a.py"),
),
"type_": "test",
"id_": get_absolute_test_id(
"tests/test_a.py::test_a_function",
SYMLINK_FOLDER_PATH_TESTS_TEST_A,
),
"runID": get_absolute_test_id(
"tests/test_a.py::test_a_function",
SYMLINK_FOLDER_PATH_TESTS_TEST_A,
),
}
],
},
{
"name": "test_b.py",
"path": str(SYMLINK_FOLDER_PATH_TESTS_TEST_B),
"type_": "file",
"id_": str(SYMLINK_FOLDER_PATH_TESTS_TEST_B),
"children": [
{
"name": "test_b_function",
"path": str(SYMLINK_FOLDER_PATH_TESTS_TEST_B),
"lineno": find_test_line_number(
"test_b_function",
os.path.join(tests_path, "test_b.py"),
),
"type_": "test",
"id_": get_absolute_test_id(
"tests/test_b.py::test_b_function",
SYMLINK_FOLDER_PATH_TESTS_TEST_B,
),
"runID": get_absolute_test_id(
"tests/test_b.py::test_b_function",
SYMLINK_FOLDER_PATH_TESTS_TEST_B,
),
}
],
},
],
}
],
"id_": str(SYMLINK_FOLDER_PATH),
}
18 changes: 18 additions & 0 deletions pythonFiles/tests/pytestadapter/helpers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import contextlib
import io
import json
import os
Expand All @@ -27,6 +28,23 @@ def get_absolute_test_id(test_id: str, testPath: pathlib.Path) -> str:
return absolute_test_id


@contextlib.contextmanager
def create_symlink(root: pathlib.Path, target_ext: str, destination_ext: str):
try:
destination = root / destination_ext
target = root / target_ext
if destination.exists():
print("destination already exists", destination)
try:
destination.symlink_to(target)
except Exception as e:
print("error occurred when attempting to create a symlink", e)
yield target, destination
finally:
destination.unlink()
print("destination unlinked", destination)


def create_server(
host: str = "127.0.0.1",
port: int = 0,
Expand Down
44 changes: 43 additions & 1 deletion pythonFiles/tests/pytestadapter/test_discovery.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
import json
import os
import pathlib
import shutil
Expand All @@ -14,7 +15,7 @@
from tests.tree_comparison_helper import is_same_tree

from . import expected_discovery_test_output
from .helpers import TEST_DATA_PATH, runner, runner_with_cwd
from .helpers import TEST_DATA_PATH, runner, runner_with_cwd, create_symlink


def test_import_error(tmp_path):
Expand Down Expand Up @@ -205,6 +206,47 @@ def test_pytest_collect(file, expected_const):
assert is_same_tree(actual_item.get("tests"), expected_const)


def test_symlink_root_dir():
"""
Test to test pytest discovery with the command line arg --rootdir specified as a symlink path.
Discovery should succeed and testids should be relative to the symlinked root directory.
"""
with create_symlink(TEST_DATA_PATH, "root", "symlink_folder") as (
source,
destination,
):
print(f"symlink destination: {destination}")
eleanorjboyd marked this conversation as resolved.
Show resolved Hide resolved
print(f"symlink target: {source}")
assert destination.is_symlink()

# Run pytest with the cwd being the resolved symlink path (as it will be when we run the subprocess from node).
actual = runner_with_cwd(
["--collect-only", f"--rootdir={os.fspath(destination)}"], source
)
expected = expected_discovery_test_output.symlink_expected_discovery_output
assert actual
actual_list: List[Dict[str, Any]] = actual
if actual_list is not None:
assert actual_list.pop(-1).get("eot")
actual_item = actual_list.pop(0)
try:
# Check if all requirements
assert all(
item in actual_item.keys() for item in ("status", "cwd", "error")
), "Required keys are missing"
assert actual_item.get("status") == "success", "Status is not 'success'"
assert actual_item.get("cwd") == os.fspath(
destination
), f"CWD does not match: {os.fspath(destination)}"
assert (
actual_item.get("tests") == expected
), "Tests do not match expected value"
except AssertionError as e:
# Print the actual_item in JSON format if an assertion fails
print(json.dumps(actual_item, indent=4))
pytest.fail(str(e))


def test_pytest_root_dir():
"""
Test to test pytest discovery with the command line arg --rootdir specified to be a subfolder
Expand Down
55 changes: 53 additions & 2 deletions pythonFiles/vscode_pytest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def __init__(self, message):
collected_tests_so_far = list()
TEST_PORT = os.getenv("TEST_PORT")
TEST_UUID = os.getenv("TEST_UUID")
SYMLINK_PATH = None


def pytest_load_initial_conftests(early_config, parser, args):
Expand All @@ -75,6 +76,25 @@ def pytest_load_initial_conftests(early_config, parser, args):
global IS_DISCOVERY
IS_DISCOVERY = True

# check if --rootdir is in the args
for arg in args:
if "--rootdir=" in arg:
rootdir = arg.split("--rootdir=")[1]
if not os.path.exists(rootdir):
raise VSCodePytestError(
f"The path set in the argument --rootdir={rootdir} does not exist."
)
if (
os.path.islink(rootdir)
and pathlib.Path(os.path.realpath(rootdir)) == pathlib.Path.cwd()
):
print(
f"Plugin info[vscode-pytest]: rootdir argument, {rootdir}, is identified as a symlink to the cwd, {pathlib.Path.cwd()}.",
"Therefore setting symlink path to rootdir argument.",
)
global SYMLINK_PATH
SYMLINK_PATH = pathlib.Path(rootdir)


def pytest_internalerror(excrepr, excinfo):
"""A pytest hook that is called when an internal error occurs.
Expand Down Expand Up @@ -326,6 +346,14 @@ def pytest_sessionfinish(session, exitstatus):
Exit code 5: No tests were collected
"""
cwd = pathlib.Path.cwd()
global SYMLINK_PATH
eleanorjboyd marked this conversation as resolved.
Show resolved Hide resolved
if SYMLINK_PATH:
print("Plugin warning[vscode-pytest]: SYMLINK set, adjusting cwd.")
# Get relative between the cwd (resolved path) and the node path.
rel_path = os.path.relpath(cwd, pathlib.Path.cwd())
# Calculate the new node path by making it relative to the symlink path.
cwd = pathlib.Path(os.path.join(SYMLINK_PATH, rel_path))

if IS_DISCOVERY:
if not (exitstatus == 0 or exitstatus == 1 or exitstatus == 5):
errorNode: TestNode = {
Expand Down Expand Up @@ -388,6 +416,12 @@ def build_test_tree(session: pytest.Session) -> TestNode:
class_nodes_dict: Dict[str, TestNode] = {}
function_nodes_dict: Dict[str, TestNode] = {}

# Check to see if the global variable for symlink path is set
global SYMLINK_PATH
eleanorjboyd marked this conversation as resolved.
Show resolved Hide resolved
if SYMLINK_PATH:
session_node["path"] = SYMLINK_PATH
session_node["id_"] = os.fspath(SYMLINK_PATH)

for test_case in session.items:
test_node = create_test_node(test_case)
if isinstance(test_case.parent, pytest.Class):
Expand Down Expand Up @@ -645,13 +679,31 @@ class EOTPayloadDict(TypedDict):


def get_node_path(node: Any) -> pathlib.Path:
"""A function that returns the path of a node given the switch to pathlib.Path."""
"""
A function that returns the path of a node given the switch to pathlib.Path.
It also evaluates if the node is a symlink and returns the equivalent path.
"""
path = getattr(node, "path", None) or pathlib.Path(node.fspath)

if not path:
raise VSCodePytestError(
f"Unable to find path for node: {node}, node.path: {node.path}, node.fspath: {node.fspath}"
)

global SYMLINK_PATH
eleanorjboyd marked this conversation as resolved.
Show resolved Hide resolved
# Check for the session node since it has the symlink already.
if SYMLINK_PATH and not isinstance(node, pytest.Session):
# Get relative between the cwd (resolved path) and the node path.
try:
rel_path = os.path.relpath(path, pathlib.Path.cwd())
eleanorjboyd marked this conversation as resolved.
Show resolved Hide resolved
# Calculate the new node path by making it relative to the symlink path.
sym_path = pathlib.Path(os.path.join(SYMLINK_PATH, rel_path))
return sym_path
except Exception as e:
raise VSCodePytestError(
f"Error occurred while calculating symlink equivalent from node path: {e}"
"\n SYMLINK_PATH: {SYMLINK_PATH}, \n node path: {path}, \n cwd: {{pathlib.Path.cwd()}}"
)
return path


Expand Down Expand Up @@ -687,7 +739,6 @@ def post_response(cwd: str, session_node: TestNode) -> None:
cwd (str): Current working directory.
session_node (TestNode): Node information of the test session.
"""

payload: DiscoveryPayloadDict = {
"cwd": cwd,
"status": "success" if not ERRORS else "error",
Expand Down
Loading
Loading