Skip to content

Commit 948cbd6

Browse files
committed
Fix, improve, and test get_pytest_help_item().
1 parent b105037 commit 948cbd6

File tree

2 files changed

+217
-13
lines changed

2 files changed

+217
-13
lines changed

python/ctsm/run_ctsm_py_tests.py

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import logging
1111
import io
1212
import contextlib
13+
import re
1314

1415
import pytest
1516

@@ -158,7 +159,7 @@ def get_pytest_help_item(pytest_help_text: str, option: str) -> str:
158159
Extract a single pytest CLI option (e.g. "--debug") from the full ``pytest --help`` output and
159160
collapse its multi-line description into a single line.
160161
161-
The function locates the first line containing ``option`` and then collects all immediately
162+
The function locates the first line starting with ``option`` and then collects all immediately
162163
following lines that are more indented than that header line. These indented lines are treated
163164
as the option's description. The first line break is replaced with a colon and the description
164165
lines are joined with single spaces.
@@ -180,36 +181,67 @@ def get_pytest_help_item(pytest_help_text: str, option: str) -> str:
180181
Raises
181182
------
182183
RuntimeError
183-
If no line containing ``option`` is found in the help text.
184+
If no line starting with ``option`` is found in the help text.
184185
185186
Notes
186187
-----
187-
This function relies on pytest's help formatting convention that
188-
description lines are indented more deeply than their corresponding
189-
option header line.
188+
This function relies on pytest's help formatting convention that description lines are indented
189+
more deeply than their corresponding option header line.
190190
"""
191191
lines = pytest_help_text.splitlines()
192192

193+
# Create regex pattern to match the option at the start of a line (after whitespace).
194+
# Use word boundary \b to ensure we match exact options (e.g., "--pdb" won't match "--pdbcls").
195+
option_pattern = re.compile(r"^\s*" + re.escape(option) + r"\b")
196+
193197
for i, line in enumerate(lines):
194-
if option in line:
195-
header = line.rstrip()
198+
if option_pattern.match(line):
199+
# Found line starting with the option
200+
201+
# Split the line into option header and description.
202+
# Description typically starts after 2+ consecutive spaces.
203+
match = re.match(r"^(\s*\S+(?:\s+\S+)*?)\s{2,}(.*)$", line)
204+
205+
if match:
206+
# Line has both option header and description
207+
option_header = match.group(1).strip()
208+
first_desc = match.group(2)
209+
else:
210+
# No description on this line (or only single spaces)
211+
option_header = line.strip()
212+
first_desc = ""
213+
214+
# Calculate the indentation level of the first matched ("header") line.
215+
# This will be used to identify continuation lines.
196216
header_indent = len(line) - len(line.lstrip())
197217

198-
body_lines = []
218+
# Collect all description text, starting with any on the header line
219+
description_lines = []
220+
if first_desc:
221+
description_lines.append(first_desc)
222+
223+
# Collect all continuation lines that are more indented than the header
199224
for next_line in lines[i + 1 :]:
200-
# Stop if blank
225+
# Stop if we hit a blank line
201226
if not next_line.strip():
202227
break
203228

229+
# Calculate indentation of this line
204230
indent = len(next_line) - len(next_line.lstrip())
205231

206-
# Stop if indentation is not deeper than header
232+
# Stop if indentation is not deeper than header (indicates we've reached the next
233+
# option or section)
207234
if indent <= header_indent:
208235
break
209236

210-
body_lines.append(next_line.strip())
237+
# This is a continuation line - add it to the description
238+
description_lines.append(next_line.strip())
239+
240+
# Join all description lines into a single string with spaces
241+
description = " ".join(description_lines)
211242

212-
body = " ".join(body_lines)
213-
return f"{header.strip()}: {body}"
243+
# Return formatted result: "option_header: description"
244+
return f"{option_header}: {description}"
214245

246+
# If we get here, the option was not found
215247
raise RuntimeError(f"Failed to get pytest help for {option}")
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Unit tests for run_ctsm_py_tests module"""
2+
3+
import pytest
4+
from ctsm.run_ctsm_py_tests import get_pytest_help_item
5+
6+
7+
@pytest.fixture(name="sample_pytest_help")
8+
def fixture_sample_pytest_help():
9+
"""Sample pytest help text for testing"""
10+
return """usage: pytest [options] [file_or_dir] [file_or_dir] [...]
11+
12+
positional arguments:
13+
file_or_dir
14+
15+
general:
16+
-k EXPRESSION Only run tests which match the given substring expression. An expression is a Python evaluable expression where all names are substring-matched against test names and their
17+
parent classes. Example: -k 'test_method or test_other' matches all test functions and classes whose name contains 'test_method' or 'test_other', while -k 'not test_method'
18+
matches those that don't contain 'test_method' in their names. -k 'not test_method and not test_other' will eliminate the matches. Additionally keywords are matched to classes
19+
and functions containing extra names in their 'extra_keyword_matches' set, as well as functions which have names assigned directly to them. The matching is case-insensitive.
20+
-m MARKEXPR Only run tests matching given mark expression. For example: -m 'mark1 and not mark2'.
21+
--markers show markers (builtin, plugin and per-project ones).
22+
-x, --exitfirst Exit instantly on first error or failed test
23+
--fixtures, --funcargs
24+
Show available fixtures, sorted by plugin appearance (fixtures with leading '_' are only shown with '-v')
25+
--fixtures-per-test Show fixtures per test
26+
--pdbcls=modulename:classname
27+
Specify a custom interactive Python debugger for use with --pdb.For example: --pdbcls=IPython.terminal.debugger:TerminalPdb
28+
--pdb Start the interactive Python debugger on errors or KeyboardInterrupt
29+
--trace Immediately break when running each test
30+
--capture=method Per-test capturing method: one of fd|sys|no|tee-sys
31+
-s Shortcut for --capture=no
32+
--runxfail Report the results of xfail tests as if they were not marked
33+
--lf, --last-failed Rerun only the tests that failed at the last run (or all if none failed)
34+
--ff, --failed-first Run all tests, but run the last failures first. This may re-order tests and thus lead to repeated fixture setup/teardown.
35+
--nf, --new-first Run tests from new files first, then the rest of the tests sorted by file mtime
36+
--cache-show=[CACHESHOW]
37+
Show cache contents, don't perform collection or tests. Optional argument: glob (default: '*').
38+
--cache-clear Remove all cache contents at start of test run
39+
--lfnf, --last-failed-no-failures={all,none}
40+
With ``--lf``, determines whether to execute tests when there are no previously (known) failures or when no cached ``lastfailed`` data was found. ``all`` (the default) runs the
41+
full test suite again. ``none`` just emits a message about no known failures and exits successfully.
42+
--sw, --stepwise Exit on test failure and continue from last failing test next time
43+
--sw-skip, --stepwise-skip
44+
Ignore the first failing test but stop on the next failing test. Implicitly enables --stepwise.
45+
"""
46+
47+
48+
class TestGetPytestHelpItem:
49+
"""Tests for get_pytest_help_item function"""
50+
51+
def test_extract_short_option(self, sample_pytest_help):
52+
"""Test extracting a short option with description"""
53+
result = get_pytest_help_item(sample_pytest_help, "-k")
54+
assert result.startswith("-k EXPRESSION")
55+
assert "Only run tests which match the given substring expression" in result
56+
assert "case-insensitive" in result
57+
58+
def test_extract_long_option(self, sample_pytest_help):
59+
"""Test extracting a long option with description"""
60+
result = get_pytest_help_item(sample_pytest_help, "--markers")
61+
assert result.startswith("--markers")
62+
assert "show markers (builtin, plugin and per-project ones)" in result
63+
64+
def test_extract_option_with_short_and_long(self, sample_pytest_help):
65+
"""Test extracting an option that has both short and long forms"""
66+
result = get_pytest_help_item(sample_pytest_help, "-x")
67+
assert result.startswith("-x, --exitfirst")
68+
assert "Exit instantly on first error or failed test" in result
69+
print(result)
70+
71+
def test_extract_option_with_argument(self, sample_pytest_help):
72+
"""Test extracting an option that takes an argument"""
73+
result = get_pytest_help_item(sample_pytest_help, "--pdbcls")
74+
assert result.startswith("--pdbcls=modulename:classname")
75+
assert "custom interactive Python debugger" in result
76+
77+
def test_extract_multiline_description(self, sample_pytest_help):
78+
"""Test that multiline descriptions are collapsed into single line"""
79+
result = get_pytest_help_item(sample_pytest_help, "--fixtures")
80+
assert "\n" not in result
81+
assert "Show available fixtures" in result
82+
assert "sorted by plugin appearance" in result
83+
84+
def test_extract_option_with_optional_argument(self, sample_pytest_help):
85+
"""Test extracting an option with optional argument"""
86+
result = get_pytest_help_item(sample_pytest_help, "--cache-show")
87+
assert result.startswith("--cache-show=[CACHESHOW]")
88+
assert "Show cache contents" in result
89+
assert "glob (default: '*')" in result
90+
91+
def test_extract_option_with_choices(self, sample_pytest_help):
92+
"""Test extracting an option with choices"""
93+
result = get_pytest_help_item(sample_pytest_help, "--lfnf")
94+
assert result.startswith("--lfnf, --last-failed-no-failures={all,none}")
95+
assert "With ``--lf``" in result
96+
97+
def test_option_not_found_raises_error(self, sample_pytest_help):
98+
"""Test that RuntimeError is raised when option is not found"""
99+
with pytest.raises(RuntimeError, match="Failed to get pytest help for --nonexistent"):
100+
get_pytest_help_item(sample_pytest_help, "--nonexistent")
101+
102+
def test_only_matches_line_start(self, sample_pytest_help):
103+
"""Test that option must be at start of line (after whitespace)"""
104+
# "pdb" appears in both "--pdb" and "--pdbcls", but searching for "pdb"
105+
# should not match "--pdbcls" since "pdb" is not at the start
106+
result = get_pytest_help_item(sample_pytest_help, "--pdb")
107+
assert result.startswith("--pdb")
108+
assert "Start the interactive Python debugger" in result
109+
# Should not contain pdbcls content
110+
assert "modulename:classname" not in result
111+
112+
def test_handles_indented_options(self):
113+
"""Test that function handles options with leading whitespace"""
114+
help_text = """
115+
general:
116+
--foo This is foo option
117+
with multiple lines
118+
--bar This is bar option
119+
"""
120+
result = get_pytest_help_item(help_text, "--foo")
121+
assert result.startswith("--foo")
122+
assert "This is foo option" in result
123+
assert "with multiple lines" in result
124+
125+
def test_stops_at_blank_line(self):
126+
"""Test that description extraction stops at blank line"""
127+
help_text = """
128+
--option First line
129+
Second line
130+
131+
--next Next option
132+
"""
133+
result = get_pytest_help_item(help_text, "--option")
134+
assert "First line" in result
135+
assert "Second line" in result
136+
assert "Next option" not in result
137+
138+
def test_stops_at_less_indented_line(self):
139+
"""Test that description extraction stops when indentation decreases"""
140+
help_text = """
141+
--option First line
142+
Second line
143+
--next Next option
144+
"""
145+
result = get_pytest_help_item(help_text, "--option")
146+
assert "First line" in result
147+
assert "Second line" in result
148+
assert "Next option" not in result
149+
150+
def test_colon_separator_in_output(self, sample_pytest_help):
151+
"""Test that output contains colon separator between header and description"""
152+
result = get_pytest_help_item(sample_pytest_help, "-s")
153+
assert ":" in result
154+
# The format includes the full header line, then colon, then description
155+
assert result.startswith("-s")
156+
assert "Shortcut for --capture=no" in result
157+
158+
def test_empty_help_text(self):
159+
"""Test behavior with empty help text"""
160+
with pytest.raises(RuntimeError, match="Failed to get pytest help for --option"):
161+
get_pytest_help_item("", "--option")
162+
163+
def test_option_with_no_description(self):
164+
"""Test option that has no description lines"""
165+
help_text = """
166+
--option
167+
168+
--next Next option
169+
"""
170+
result = get_pytest_help_item(help_text, "--option")
171+
# Should return just the header with empty description
172+
assert result.startswith("--option:")

0 commit comments

Comments
 (0)