Skip to content

Commit 8989323

Browse files
Add line number support for class nodes in pytest and unittest (#25593)
## Plan: Add Location to TestClass Items for Run Functionality This PR implements the fix for issue #25592 - TestClass items need a location (lineno) to be runnable and show the green arrow in VS Code's Test Explorer. ### Changes Completed: #### Pytest: - [x] Update Python pytest adapter to add `lineno` field to class nodes - [x] Modify `create_class_node()` in `python_files/vscode_pytest/__init__.py` to extract and include line number - [x] Update TypeScript types to allow `lineno` on class nodes - [x] Fix Python type checking errors - [x] Update expected test outputs - [x] Update all expected discovery outputs in `python_files/tests/pytestadapter/expected_discovery_test_output.py` to include `lineno` for class nodes - [x] Add `find_class_line_number()` helper function #### Unittest: - [x] Update Python unittest adapter to add `lineno` field to class nodes - [x] Modify `build_test_tree()` in `python_files/unittestadapter/pvsc_utils.py` to add line numbers - [x] Add `get_class_line()` function to extract class line numbers - [x] Make `lineno` optional field in `TestNode` TypedDict - [x] Return empty string instead of "*" when line cannot be determined - [x] Update expected test outputs - [x] Update expected discovery outputs in `python_files/tests/unittestadapter/expected_discovery_test_output.py` to include `lineno` for class nodes - [x] Add `find_class_line_number()` helper function #### TypeScript: - [x] Update TypeScript result resolver - [x] Modify `populateTestTree()` in `src/client/testing/testController/common/utils.ts` to handle `lineno` for class nodes - [x] Update type definitions to allow optional `lineno` on `DiscoveredTestNode` - [x] Add 'function' to `DiscoveredTestType` enum ### Test Results: **Pytest tests:** ✅ 13/15 passing (2 failures expected - pytest-describe plugin not installed) **Unittest tests:** ✅ Verified class nodes include lineno **Code quality:** - ✅ Ruff formatting and linting passed - ⚠️ Pyright has pre-existing errors (not introduced by this change) ### Technical Implementation: **Pytest:** - Extract line number from pytest.Class objects using Python's `inspect.getsourcelines()` - Add `lineno` as optional field to TestNode TypedDict (using NotRequired) - Return empty string when line number cannot be determined **Unittest:** - Extract line number from test_case.__class__ using `inspect.getsourcelines()` - Add `lineno` field to class nodes during tree building - Use same optional field pattern in TestNode TypedDict - Return empty string when line number cannot be determined (consistent with pytest) **TypeScript:** - Create VS Code ranges for class nodes when `lineno` is present This minimal change ensures TestClass items are treated similarly to test items in terms of having a location, making them runnable in the VS Code UI for both pytest and unittest. <!-- START COPILOT CODING AGENT SUFFIX --> <details> <summary>Original prompt</summary> fantastic analysis. Please implement this and keep in mind all this context you have; both how this should be done, what should be changed, and how tests are impacted <analysis> [Chronological Review: The conversation began with the user requesting a discussion about issue #25592 on GitHub, specifically regarding the need for TestClass items to have a location for proper functionality. The user then asked about the risks associated with this change and which tests would need updating. Following a positive response to the analysis, the user requested implementation of the discussed changes while considering the context of how it should be done, what should be changed, and how tests are impacted.] [Intent Mapping: 1. "lets talk about this issue: #25592 what needs to be done (likely) is TestClass items need a location so it shows as the green arrow and makes it runnable." - User wants to discuss the issue and create a plan for implementation. 2. "what risk does this change have? what tests will need to be updated?" - User seeks to understand the implications of the changes and necessary test updates. 3. "fantastic analysis. Please implement this and keep in mind all this context you have; both how this should be done, what should be changed, and how tests are impacted." - User requests implementation of the analysis with a focus on context and impact.] [Technical Inventory: - Repository: vscode-python - Current Branch: supposed-spoonbill - Default Branch: main - Active Pull Request: support extra patching for doctest (#25591) - Issue Discussed: #25592 regarding TestClass items needing a location.] [Code Archaeology: No specific files or code changes were discussed in detail, but the focus was on the functionality of TestClass items and their integration into the existing codebase.] [Progress Assessment: - Completed Tasks: Analysis of the issue and identification of necessary changes. - Partially Complete Work: Implementation of the discussed changes is pending. - Validated Outcomes: None yet, as implementation has not been executed.] [Context Validation: All critical information for continuation is captured, including the need for changes to TestClass items and the implications for testing.] [Recent Commands Analysis: - Last Agent Commands: User requested a plan for the issue, an analysis of risks, and implementation of the discussed changes. - Tool Results Summary: No specific tool results were returned as the conversation focused on planning and analysis rather than executing code or commands. - Pre-Summary State: The agent was actively discussing the implementation of changes related to issue #25592 when the token budget was exceeded. - Operation Context: The commands were executed to align with the user's goals of addressing the issue and ensuring proper functionality of TestClass items.] </analysis> <summary> 1. Conversation Overview: - Primary Objectives: The user aimed to discuss issue #25592, focusing on making TestClass items runnable by providing them with a location. The user also sought to understand the risks and necessary test updates related to this change. - Session Context: The conversation flowed from discussing the issue, analyzing risks, and finally requesting implementation of the proposed changes. - User Intent Evolution: The user transitioned from seeking information and analysis to requesting concrete implementation of the discussed changes. 2. Technical Foundation: - Repository: vscode-python - Current Branch: supposed-spoonbill - Default Branch: main - Active Pull Request: support extra patching for doctest (#25591) 3. Codebase Status: - No specific files were modified or discussed in detail, but the focus was on the functionality of TestClass items. 4. Problem Resolution: - Issues Encountered: The need for TestClass items to have a location for proper functionality. - Solutions Implemented: Analysis of the issue and identification of necessary changes were completed, but implementation is pending. - Debugging Context: No ongoing troubleshooting efforts were mentioned. - Lessons Learned: Understanding the implications of changes on existing functionality and tests is crucial. 5. Progress Tracking: - Completed Tasks: Analysis of the issue and identification of necessary changes. - Partially Complete Work: Implementation of the discussed changes is pending. - Validated Outcomes: None yet, as implementation has not been executed. 6. Active Work State: - Current Focus: The user was focused on implementing changes related to issue #25592. - Recent Context: The last few exchanges involved discussing the issue, analyzing risks, and planning for implementation. - Working Code: No specific code snippets were discussed recently. - Immediate Context: The specific problem being addressed was the need for TestClass items to have a locat... </details> Created from [VS Code](https://code.visualstudio.com/docs/copilot/copilot-coding-agent). --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]>
1 parent d7af377 commit 8989323

File tree

7 files changed

+159
-3
lines changed

7 files changed

+159
-3
lines changed

python_files/tests/pytestadapter/expected_discovery_test_output.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import os
22

3-
from .helpers import TEST_DATA_PATH, find_test_line_number, get_absolute_test_id
3+
from .helpers import (
4+
TEST_DATA_PATH,
5+
find_class_line_number,
6+
find_test_line_number,
7+
get_absolute_test_id,
8+
)
49

510
# This file contains the expected output dictionaries for tests discovery and is used in test_discovery.py.
611

@@ -95,6 +100,7 @@
95100
"unittest_pytest_same_file.py::TestExample",
96101
unit_pytest_same_file_path,
97102
),
103+
"lineno": find_class_line_number("TestExample", unit_pytest_same_file_path),
98104
},
99105
{
100106
"name": "test_true_pytest",
@@ -207,6 +213,7 @@
207213
"unittest_folder/test_add.py::TestAddFunction",
208214
test_add_path,
209215
),
216+
"lineno": find_class_line_number("TestAddFunction", test_add_path),
210217
},
211218
{
212219
"name": "TestDuplicateFunction",
@@ -235,6 +242,9 @@
235242
"unittest_folder/test_add.py::TestDuplicateFunction",
236243
test_add_path,
237244
),
245+
"lineno": find_class_line_number(
246+
"TestDuplicateFunction", test_add_path
247+
),
238248
},
239249
],
240250
},
@@ -288,6 +298,9 @@
288298
"unittest_folder/test_subtract.py::TestSubtractFunction",
289299
test_subtract_path,
290300
),
301+
"lineno": find_class_line_number(
302+
"TestSubtractFunction", test_subtract_path
303+
),
291304
},
292305
{
293306
"name": "TestDuplicateFunction",
@@ -316,6 +329,9 @@
316329
"unittest_folder/test_subtract.py::TestDuplicateFunction",
317330
test_subtract_path,
318331
),
332+
"lineno": find_class_line_number(
333+
"TestDuplicateFunction", test_subtract_path
334+
),
319335
},
320336
],
321337
},
@@ -553,6 +569,7 @@
553569
"parametrize_tests.py::TestClass",
554570
parameterize_tests_path,
555571
),
572+
"lineno": find_class_line_number("TestClass", parameterize_tests_path),
556573
"children": [
557574
{
558575
"name": "test_adding",
@@ -929,6 +946,7 @@
929946
"test_multi_class_nest.py::TestFirstClass",
930947
TEST_MULTI_CLASS_NEST_PATH,
931948
),
949+
"lineno": find_class_line_number("TestFirstClass", TEST_MULTI_CLASS_NEST_PATH),
932950
"children": [
933951
{
934952
"name": "TestSecondClass",
@@ -938,6 +956,9 @@
938956
"test_multi_class_nest.py::TestFirstClass::TestSecondClass",
939957
TEST_MULTI_CLASS_NEST_PATH,
940958
),
959+
"lineno": find_class_line_number(
960+
"TestSecondClass", TEST_MULTI_CLASS_NEST_PATH
961+
),
941962
"children": [
942963
{
943964
"name": "test_second",
@@ -982,6 +1003,9 @@
9821003
"test_multi_class_nest.py::TestFirstClass::TestSecondClass2",
9831004
TEST_MULTI_CLASS_NEST_PATH,
9841005
),
1006+
"lineno": find_class_line_number(
1007+
"TestSecondClass2", TEST_MULTI_CLASS_NEST_PATH
1008+
),
9851009
"children": [
9861010
{
9871011
"name": "test_second2",
@@ -1227,6 +1251,9 @@
12271251
"same_function_new_class_param.py::TestNotEmpty",
12281252
TEST_DATA_PATH / "same_function_new_class_param.py",
12291253
),
1254+
"lineno": find_class_line_number(
1255+
"TestNotEmpty", TEST_DATA_PATH / "same_function_new_class_param.py"
1256+
),
12301257
},
12311258
{
12321259
"name": "TestEmpty",
@@ -1298,6 +1325,9 @@
12981325
"same_function_new_class_param.py::TestEmpty",
12991326
TEST_DATA_PATH / "same_function_new_class_param.py",
13001327
),
1328+
"lineno": find_class_line_number(
1329+
"TestEmpty", TEST_DATA_PATH / "same_function_new_class_param.py"
1330+
),
13011331
},
13021332
],
13031333
}
@@ -1371,6 +1401,9 @@
13711401
"test_param_span_class.py::TestClass1",
13721402
TEST_DATA_PATH / "test_param_span_class.py",
13731403
),
1404+
"lineno": find_class_line_number(
1405+
"TestClass1", TEST_DATA_PATH / "test_param_span_class.py"
1406+
),
13741407
},
13751408
{
13761409
"name": "TestClass2",
@@ -1427,6 +1460,9 @@
14271460
"test_param_span_class.py::TestClass2",
14281461
TEST_DATA_PATH / "test_param_span_class.py",
14291462
),
1463+
"lineno": find_class_line_number(
1464+
"TestClass2", TEST_DATA_PATH / "test_param_span_class.py"
1465+
),
14301466
},
14311467
],
14321468
}
@@ -1503,6 +1539,7 @@
15031539
"pytest_describe_plugin/describe_only.py::describe_A",
15041540
describe_only_path,
15051541
),
1542+
"lineno": find_class_line_number("describe_A", describe_only_path),
15061543
}
15071544
],
15081545
}
@@ -1586,6 +1623,9 @@
15861623
"pytest_describe_plugin/nested_describe.py::describe_list::describe_append",
15871624
nested_describe_path,
15881625
),
1626+
"lineno": find_class_line_number(
1627+
"describe_append", nested_describe_path
1628+
),
15891629
},
15901630
{
15911631
"name": "describe_remove",
@@ -1614,12 +1654,16 @@
16141654
"pytest_describe_plugin/nested_describe.py::describe_list::describe_remove",
16151655
nested_describe_path,
16161656
),
1657+
"lineno": find_class_line_number(
1658+
"describe_remove", nested_describe_path
1659+
),
16171660
},
16181661
],
16191662
"id_": get_absolute_test_id(
16201663
"pytest_describe_plugin/nested_describe.py::describe_list",
16211664
nested_describe_path,
16221665
),
1666+
"lineno": find_class_line_number("describe_list", nested_describe_path),
16231667
}
16241668
],
16251669
}

python_files/tests/pytestadapter/helpers.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,28 @@ def find_test_line_number(test_name: str, test_file_path) -> str:
370370
raise ValueError(error_str)
371371

372372

373+
def find_class_line_number(class_name: str, test_file_path) -> str:
374+
"""Function which finds the correct line number for a class definition.
375+
376+
Args:
377+
class_name: The name of the class to find the line number for.
378+
test_file_path: The path to the test file where the class is located.
379+
"""
380+
# Look for the class definition line (or function for pytest-describe)
381+
with open(test_file_path) as f: # noqa: PTH123
382+
for i, line in enumerate(f):
383+
# Match "class ClassName" or "class ClassName(" or "class ClassName:"
384+
# Also match "def ClassName(" for pytest-describe blocks
385+
if (
386+
line.strip().startswith(f"class {class_name}")
387+
or line.strip().startswith(f"class {class_name}(")
388+
or line.strip().startswith(f"def {class_name}(")
389+
):
390+
return str(i + 1)
391+
error_str: str = f"Class {class_name!r} not found on any line in {test_file_path}"
392+
raise ValueError(error_str)
393+
394+
373395
def get_absolute_test_id(test_id: str, test_path: pathlib.Path) -> str:
374396
"""Get the absolute test id by joining the testPath with the test_id."""
375397
split_id = test_id.split("::")[1:]

python_files/tests/unittestadapter/expected_discovery_test_output.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,25 @@
99
TEST_DATA_PATH = pathlib.Path(__file__).parent / ".data"
1010

1111

12+
def find_class_line_number(class_name: str, test_file_path) -> str:
13+
"""Function which finds the correct line number for a class definition.
14+
15+
Args:
16+
class_name: The name of the class to find the line number for.
17+
test_file_path: The path to the test file where the class is located.
18+
"""
19+
# Look for the class definition line
20+
with pathlib.Path(test_file_path).open() as f:
21+
for i, line in enumerate(f):
22+
# Match "class ClassName" or "class ClassName(" or "class ClassName:"
23+
if line.strip().startswith(f"class {class_name}") or line.strip().startswith(
24+
f"class {class_name}("
25+
):
26+
return str(i + 1)
27+
error_str: str = f"Class {class_name!r} not found on any line in {test_file_path}"
28+
raise ValueError(error_str)
29+
30+
1231
skip_unittest_folder_discovery_output = {
1332
"path": os.fspath(TEST_DATA_PATH / "unittest_skip"),
1433
"name": "unittest_skip",
@@ -49,6 +68,10 @@
4968
],
5069
"id_": os.fspath(TEST_DATA_PATH / "unittest_skip" / "unittest_skip_function.py")
5170
+ "\\SimpleTest",
71+
"lineno": find_class_line_number(
72+
"SimpleTest",
73+
TEST_DATA_PATH / "unittest_skip" / "unittest_skip_function.py",
74+
),
5275
}
5376
],
5477
"id_": os.fspath(TEST_DATA_PATH / "unittest_skip" / "unittest_skip_function.py"),
@@ -114,6 +137,16 @@
114137
},
115138
],
116139
"id_": complex_tree_file_path + "\\" + "TreeOne",
140+
"lineno": find_class_line_number(
141+
"TreeOne",
142+
pathlib.PurePath(
143+
TEST_DATA_PATH,
144+
"utils_complex_tree",
145+
"test_outer_folder",
146+
"test_inner_folder",
147+
"test_utils_complex_tree.py",
148+
),
149+
),
117150
}
118151
],
119152
"id_": complex_tree_file_path,

python_files/unittestadapter/pvsc_utils.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class TestItem(TestData):
4444

4545
class TestNode(TestData):
4646
children: "List[TestNode | TestItem]"
47+
lineno: NotRequired[str] # Optional field for class nodes
4748

4849

4950
class TestExecutionStatus(str, enum.Enum):
@@ -101,6 +102,16 @@ def get_test_case(suite):
101102
yield from get_test_case(test)
102103

103104

105+
def get_class_line(test_case: unittest.TestCase) -> Optional[str]:
106+
"""Get the line number where a test class is defined."""
107+
try:
108+
test_class = test_case.__class__
109+
_sourcelines, lineno = inspect.getsourcelines(test_class)
110+
return str(lineno)
111+
except Exception:
112+
return None
113+
114+
104115
def get_source_line(obj) -> str:
105116
"""Get the line number of a test case start line."""
106117
try:
@@ -249,6 +260,12 @@ def build_test_tree(
249260
class_name, file_path, TestNodeTypeEnum.class_, current_node
250261
)
251262

263+
# Add line number to class node if not already present.
264+
if "lineno" not in current_node:
265+
class_lineno = get_class_line(test_case)
266+
if class_lineno is not None:
267+
current_node["lineno"] = class_lineno
268+
252269
# Get test line number.
253270
test_method = getattr(test_case, test_case._testMethodName) # noqa: SLF001
254271
lineno = get_source_line(test_method)

python_files/vscode_pytest/__init__.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,19 @@
1010
import pathlib
1111
import sys
1212
import traceback
13-
from typing import TYPE_CHECKING, Any, Dict, Generator, Literal, Protocol, TypedDict, cast
13+
from typing import (
14+
TYPE_CHECKING,
15+
Any,
16+
Dict,
17+
Generator,
18+
Literal,
19+
Protocol,
20+
TypedDict,
21+
cast,
22+
)
1423

1524
import pytest
25+
from typing_extensions import NotRequired
1626

1727
if TYPE_CHECKING:
1828
from pluggy import Result
@@ -52,6 +62,7 @@ class TestNode(TestData):
5262
"""A general class that handles all test data which contains children."""
5363

5464
children: list[TestNode | TestItem | None]
65+
lineno: NotRequired[str] # Optional field for class/function nodes
5566

5667

5768
class VSCodePytestError(Exception):
@@ -830,12 +841,25 @@ def create_class_node(class_module: pytest.Class | DescribeBlock) -> TestNode:
830841
Keyword arguments:
831842
class_module -- the pytest object representing a class module.
832843
"""
844+
# Get line number for the class definition
845+
class_line = ""
846+
try:
847+
if hasattr(class_module, "obj"):
848+
import inspect
849+
850+
_, lineno = inspect.getsourcelines(class_module.obj)
851+
class_line = str(lineno)
852+
except (OSError, TypeError):
853+
# If we can't get the source lines, leave lineno empty
854+
pass
855+
833856
return {
834857
"name": class_module.name,
835858
"path": get_node_path(class_module),
836859
"type_": "class",
837860
"children": [],
838861
"id_": get_absolute_test_id(class_module.nodeid, get_node_path(class_module)),
862+
"lineno": class_line,
839863
}
840864

841865

src/client/testing/testController/common/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ export interface ITestExecutionAdapter {
177177
}
178178

179179
// Same types as in python_files/unittestadapter/utils.py
180-
export type DiscoveredTestType = 'folder' | 'file' | 'class' | 'test';
180+
export type DiscoveredTestType = 'folder' | 'file' | 'class' | 'function' | 'test';
181181

182182
export type DiscoveredTestCommon = {
183183
path: string;
@@ -194,6 +194,7 @@ export type DiscoveredTestItem = DiscoveredTestCommon & {
194194

195195
export type DiscoveredTestNode = DiscoveredTestCommon & {
196196
children: (DiscoveredTestNode | DiscoveredTestItem)[];
197+
lineno?: number | string;
197198
};
198199

199200
export type DiscoveredTestPayload = {

src/client/testing/testController/common/utils.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,21 @@ export function populateTestTree(
257257

258258
node.canResolveChildren = true;
259259
node.tags = [RunTestTag, DebugTestTag];
260+
261+
// Set range for class nodes (and other nodes) if lineno is available
262+
let range: Range | undefined;
263+
if ('lineno' in child && child.lineno) {
264+
if (Number(child.lineno) === 0) {
265+
range = new Range(new Position(0, 0), new Position(0, 0));
266+
} else {
267+
range = new Range(
268+
new Position(Number(child.lineno) - 1, 0),
269+
new Position(Number(child.lineno), 0),
270+
);
271+
}
272+
node.range = range;
273+
}
274+
260275
testRoot!.children.add(node);
261276
}
262277
populateTestTree(testController, child, node, resultResolver, token);

0 commit comments

Comments
 (0)