Skip to content

Commit 065461c

Browse files
committed
Refactor test processing and folder construction in vscode_pytest module
1 parent f3675b0 commit 065461c

File tree

1 file changed

+144
-81
lines changed

1 file changed

+144
-81
lines changed

python_files/vscode_pytest/__init__.py

Lines changed: 144 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import pathlib
1111
import sys
1212
import traceback
13-
from typing import TYPE_CHECKING, Any, Dict, Generator, Literal, TypedDict
13+
from typing import TYPE_CHECKING, Any, Dict, Generator, Literal, Protocol, TypedDict
1414

1515
import pytest
1616

@@ -25,6 +25,13 @@
2525
USES_PYTEST_DESCRIBE = True
2626

2727

28+
class HasPathOrFspath(Protocol):
29+
"""Protocol defining objects that have either a path or fspath attribute."""
30+
31+
path: pathlib.Path | None = None
32+
fspath: Any | None = None
33+
34+
2835
class TestData(TypedDict):
2936
"""A general class that all test objects inherit from."""
3037

@@ -522,11 +529,131 @@ def pytest_sessionfinish(session, exitstatus):
522529
send_message(payload)
523530

524531

532+
def construct_nested_folders(
533+
file_nodes_dict: dict[str, TestNode],
534+
session_node: TestNode, # session_node['path'] is a pathlib.Path object
535+
session_children_dict: dict[str, TestNode]
536+
) -> dict[str, TestNode]:
537+
"""Iterate through all files and construct them into nested folders.
538+
539+
Keyword arguments:
540+
file_nodes_dict -- Dictionary of all file nodes
541+
session_node -- The session node that will be parent to the folder structure
542+
session_children_dict -- Dictionary of session's children nodes indexed by ID
543+
544+
Returns:
545+
dict[str, TestNode] -- Updated session_children_dict with folder nodes added
546+
"""
547+
created_files_folders_dict: dict[str, TestNode] = {}
548+
for file_node in file_nodes_dict.values():
549+
# Iterate through all the files that exist and construct them into nested folders.
550+
root_folder_node: TestNode
551+
try:
552+
root_folder_node: TestNode = build_nested_folders(
553+
file_node, created_files_folders_dict, session_node
554+
)
555+
except ValueError:
556+
# This exception is raised when the session node is not a parent of the file node.
557+
print(
558+
"[vscode-pytest]: Session path not a parent of test paths, adjusting session node to common parent."
559+
)
560+
# IMPORTANT: Use session_node["path"] directly as it's already a pathlib.Path object
561+
# Do NOT use get_node_path(session_node["path"]) as get_node_path expects pytest objects,
562+
# not Path objects directly.
563+
common_parent = os.path.commonpath([file_node["path"], session_node["path"]])
564+
common_parent_path = pathlib.Path(common_parent)
565+
print("[vscode-pytest]: Session node now set to: ", common_parent)
566+
session_node["path"] = common_parent_path # pathlib.Path
567+
session_node["id_"] = common_parent # str
568+
session_node["name"] = common_parent_path.name # str
569+
root_folder_node = build_nested_folders(
570+
file_node, created_files_folders_dict, session_node
571+
)
572+
# The final folder we get to is the highest folder in the path
573+
# and therefore we add this as a child to the session.
574+
root_id = root_folder_node.get("id_")
575+
if root_id and root_id not in session_children_dict:
576+
session_children_dict[root_id] = root_folder_node
577+
578+
return session_children_dict
579+
580+
581+
def process_parameterized_test(
582+
test_case: pytest.Item, # Must have callspec attribute (parameterized test)
583+
test_node: TestItem,
584+
function_nodes_dict: dict[str, TestNode],
585+
file_nodes_dict: dict[str, TestNode]
586+
) -> TestNode:
587+
"""Process a parameterized test case and create appropriate function nodes.
588+
589+
Keyword arguments:
590+
test_case -- the parameterized pytest test case
591+
test_node -- the test node created from the test case
592+
function_nodes_dict -- dictionary of function nodes indexed by ID
593+
file_nodes_dict -- dictionary of file nodes indexed by path
594+
595+
Returns:
596+
TestNode -- the node to use for further processing (function node or original test node)
597+
"""
598+
function_name: str = ""
599+
# parameterized test cases cut the repetitive part of the name off.
600+
parent_part, parameterized_section = test_node["name"].split("[", 1)
601+
test_node["name"] = "[" + parameterized_section
602+
603+
first_split = test_case.nodeid.rsplit(
604+
"::", 1
605+
) # splits the parameterized test name from the rest of the nodeid
606+
second_split = first_split[0].rsplit(
607+
".py", 1
608+
) # splits the file path from the rest of the nodeid
609+
610+
class_and_method = second_split[1] + "::" # This has "::" separator at both ends
611+
# construct the parent id, so it is absolute path :: any class and method :: parent_part
612+
parent_id = os.fspath(get_node_path(test_case)) + class_and_method + parent_part
613+
614+
try:
615+
function_name = test_case.originalname # type: ignore
616+
function_test_node = function_nodes_dict[parent_id]
617+
except AttributeError: # actual error has occurred
618+
ERRORS.append(
619+
f"unable to find original name for {test_case.name} with parameterization detected."
620+
)
621+
raise VSCodePytestError(
622+
"Unable to find original name for parameterized test case"
623+
) from None
624+
except KeyError:
625+
function_test_node: TestNode = create_parameterized_function_node(
626+
function_name, get_node_path(test_case), parent_id
627+
)
628+
function_nodes_dict[parent_id] = function_test_node
629+
630+
if test_node not in function_test_node["children"]:
631+
function_test_node["children"].append(test_node)
632+
633+
# Check if the parent node of the function is file, if so create/add to this file node.
634+
if isinstance(test_case.parent, pytest.File):
635+
# calculate the parent path of the test case
636+
parent_path = get_node_path(test_case.parent)
637+
try:
638+
parent_test_case = file_nodes_dict[os.fspath(parent_path)]
639+
except KeyError:
640+
parent_test_case = create_file_node(parent_path)
641+
file_nodes_dict[os.fspath(parent_path)] = parent_test_case
642+
if function_test_node not in parent_test_case["children"]:
643+
parent_test_case["children"].append(function_test_node)
644+
645+
# Return the function node as the test node to handle subsequent nesting
646+
return function_test_node
647+
648+
525649
def build_test_tree(session: pytest.Session) -> TestNode:
526650
"""Builds a tree made up of testing nodes from the pytest session.
527651
528652
Keyword arguments:
529-
session -- the pytest session object.
653+
session -- the pytest session object that contains test items.
654+
655+
Returns:
656+
TestNode -- The root node of the constructed test tree.
530657
"""
531658
session_node = create_session_node(session)
532659
session_children_dict: dict[str, TestNode] = {}
@@ -542,54 +669,8 @@ def build_test_tree(session: pytest.Session) -> TestNode:
542669
for test_case in session.items:
543670
test_node = create_test_node(test_case)
544671
if hasattr(test_case, "callspec"): # This means it is a parameterized test.
545-
function_name: str = ""
546-
# parameterized test cases cut the repetitive part of the name off.
547-
parent_part, parameterized_section = test_node["name"].split("[", 1)
548-
test_node["name"] = "[" + parameterized_section
549-
550-
first_split = test_case.nodeid.rsplit(
551-
"::", 1
552-
) # splits the parameterized test name from the rest of the nodeid
553-
second_split = first_split[0].rsplit(
554-
".py", 1
555-
) # splits the file path from the rest of the nodeid
556-
557-
class_and_method = second_split[1] + "::" # This has "::" separator at both ends
558-
# construct the parent id, so it is absolute path :: any class and method :: parent_part
559-
parent_id = os.fspath(get_node_path(test_case)) + class_and_method + parent_part
560-
# file, middle, param = test_case.nodeid.rsplit("::", 2)
561-
# parent_id = test_case.nodeid.rsplit("::", 1)[0] + "::" + parent_part
562-
# parent_path = os.fspath(get_node_path(test_case)) + "::" + parent_part
563-
try:
564-
function_name = test_case.originalname # type: ignore
565-
function_test_node = function_nodes_dict[parent_id]
566-
except AttributeError: # actual error has occurred
567-
ERRORS.append(
568-
f"unable to find original name for {test_case.name} with parameterization detected."
569-
)
570-
raise VSCodePytestError(
571-
"Unable to find original name for parameterized test case"
572-
) from None
573-
except KeyError:
574-
function_test_node: TestNode = create_parameterized_function_node(
575-
function_name, get_node_path(test_case), parent_id
576-
)
577-
function_nodes_dict[parent_id] = function_test_node
578-
if test_node not in function_test_node["children"]:
579-
function_test_node["children"].append(test_node)
580-
# Check if the parent node of the function is file, if so create/add to this file node.
581-
if isinstance(test_case.parent, pytest.File):
582-
# calculate the parent path of the test case
583-
parent_path = get_node_path(test_case.parent)
584-
try:
585-
parent_test_case = file_nodes_dict[os.fspath(parent_path)]
586-
except KeyError:
587-
parent_test_case = create_file_node(parent_path)
588-
file_nodes_dict[os.fspath(parent_path)] = parent_test_case
589-
if function_test_node not in parent_test_case["children"]:
590-
parent_test_case["children"].append(function_test_node)
591-
# If the parent is not a file, it is a class, add the function node as the test node to handle subsequent nesting.
592-
test_node = function_test_node
672+
# Process parameterized test and get the function node to use for further processing
673+
test_node = process_parameterized_test(test_case, test_node, function_nodes_dict, file_nodes_dict)
593674
if isinstance(test_case.parent, pytest.Class) or (
594675
USES_PYTEST_DESCRIBE and isinstance(test_case.parent, DescribeBlock)
595676
):
@@ -636,41 +717,16 @@ def build_test_tree(session: pytest.Session) -> TestNode:
636717
parent_test_case = create_file_node(parent_path)
637718
file_nodes_dict[os.fspath(parent_path)] = parent_test_case
638719
parent_test_case["children"].append(test_node)
639-
created_files_folders_dict: dict[str, TestNode] = {}
640-
for file_node in file_nodes_dict.values():
641-
# Iterate through all the files that exist and construct them into nested folders.
642-
root_folder_node: TestNode
643-
try:
644-
root_folder_node: TestNode = build_nested_folders(
645-
file_node, created_files_folders_dict, session_node
646-
)
647-
except ValueError:
648-
# This exception is raised when the session node is not a parent of the file node.
649-
print(
650-
"[vscode-pytest]: Session path not a parent of test paths, adjusting session node to common parent."
651-
)
652-
common_parent = os.path.commonpath([file_node["path"], get_node_path(session)])
653-
common_parent_path = pathlib.Path(common_parent)
654-
print("[vscode-pytest]: Session node now set to: ", common_parent)
655-
session_node["path"] = common_parent_path # pathlib.Path
656-
session_node["id_"] = common_parent # str
657-
session_node["name"] = common_parent_path.name # str
658-
root_folder_node = build_nested_folders(
659-
file_node, created_files_folders_dict, session_node
660-
)
661-
# The final folder we get to is the highest folder in the path
662-
# and therefore we add this as a child to the session.
663-
root_id = root_folder_node.get("id_")
664-
if root_id and root_id not in session_children_dict:
665-
session_children_dict[root_id] = root_folder_node
720+
# Process all files and construct them into nested folders
721+
session_children_dict = construct_nested_folders(file_nodes_dict, session_node, session_children_dict)
666722
session_node["children"] = list(session_children_dict.values())
667723
return session_node
668724

669725

670726
def build_nested_folders(
671-
file_node: TestNode,
672-
created_files_folders_dict: dict[str, TestNode],
673-
session_node: TestNode,
727+
file_node: TestNode, # A file node to build folders for
728+
created_files_folders_dict: dict[str, TestNode], # Cache of created folders indexed by path
729+
session_node: TestNode, # The session node containing path information
674730
) -> TestNode:
675731
"""Takes a file or folder and builds the nested folder structure for it.
676732
@@ -851,10 +907,17 @@ class CoveragePayloadDict(Dict):
851907
error: str | None # Currently unused need to check
852908

853909

854-
def get_node_path(node: Any) -> pathlib.Path:
910+
def get_node_path(node: pytest.Session | pytest.Item | pytest.File | pytest.Class | pytest.Module | HasPathOrFspath) -> pathlib.Path:
855911
"""A function that returns the path of a node given the switch to pathlib.Path.
856912
857913
It also evaluates if the node is a symlink and returns the equivalent path.
914+
915+
Parameters:
916+
node: A pytest object or any object that has a path or fspath attribute.
917+
Do NOT pass a pathlib.Path object directly; use it directly instead.
918+
919+
Returns:
920+
pathlib.Path: The resolved path for the node.
858921
"""
859922
node_path = getattr(node, "path", None) or pathlib.Path(node.fspath)
860923

0 commit comments

Comments
 (0)