Skip to content

Fix Bug with Pytest when using symlinked workspaces #22885

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

Merged
merged 33 commits into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
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
42 changes: 41 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,45 @@ 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,
):
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
53 changes: 51 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,13 @@ def pytest_sessionfinish(session, exitstatus):
Exit code 5: No tests were collected
"""
cwd = pathlib.Path.cwd()
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 +415,11 @@ 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
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 +677,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}"
)

# 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 = path.relative_to(pathlib.Path.cwd())

# 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 +737,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
80 changes: 80 additions & 0 deletions src/client/testing/testController/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,3 +349,83 @@ export function splitTestNameWithRegex(testName: string): [string, string] {
}
return [testName, testName];
}

/**
* Converts an array of strings (with or without '=') into a map.
* If a string contains '=', it is split into a key-value pair, with the portion
* before the '=' as the key and the portion after the '=' as the value.
* If no '=' is found in the string, the entire string becomes a key with a value of null.
*
* @param args - Readonly array of strings to be converted to a map.
* @returns A map representation of the input strings.
*/
export const argsToMap = (args: ReadonlyArray<string>): { [key: string]: string | null | undefined } => {
const map: { [key: string]: string | null } = {};
for (const arg of args) {
const delimiter = arg.indexOf('=');
if (delimiter === -1) {
map[arg] = null;
} else {
map[arg.slice(0, delimiter)] = arg.slice(delimiter + 1);
}
}

return map;
};

/**
* Converts a map into an array of strings.
* Each key-value pair in the map is transformed into a string.
* If the value is null, only the key is represented in the string.
* If the value is defined (and not null), the string is in the format "key=value".
* If a value is undefined, the key-value pair is skipped.
*
* @param map - The map to be converted to an array of strings.
* @returns An array of strings representation of the input map.
*/
export const mapToArgs = (map: { [key: string]: string | null | undefined }): string[] => {
const out: string[] = [];
for (const key of Object.keys(map)) {
const value = map[key];
if (value === undefined) {
// eslint-disable-next-line no-continue
continue;
}

out.push(value === null ? key : `${key}=${value}`);
}

return out;
};

/**
* Adds an argument to the map only if it doesn't already exist.
*
* @param map - The map of arguments.
* @param argKey - The argument key to be checked and added.
* @param argValue - The value to set for the argument if it's not already in the map.
* @returns The updated map.
*/
export function addArgIfNotExist(
map: { [key: string]: string | null | undefined },
argKey: string,
argValue: string | null,
): { [key: string]: string | null | undefined } {
// Only add the argument if it doesn't exist in the map.
if (map[argKey] === undefined) {
map[argKey] = argValue;
}

return map;
}

/**
* Checks if an argument key exists in the map.
*
* @param map - The map of arguments.
* @param argKey - The argument key to be checked.
* @returns True if the argument key exists in the map, false otherwise.
*/
export function argKeyExists(map: { [key: string]: string | null | undefined }, argKey: string): boolean {
return map[argKey] !== undefined;
}
Loading