Skip to content

Commit b139f6b

Browse files
authored
Merge pull request #3761 from samsrabin/run_ctsm_py_tests-in-pytest
Use pytest instead of unittest in run_ctsm_py_tests
2 parents dbee6df + 948cbd6 commit b139f6b

File tree

8 files changed

+489
-208
lines changed

8 files changed

+489
-208
lines changed

python/ctsm/run_ctsm_py_tests.py

Lines changed: 193 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,83 +4,244 @@
44
parent directory
55
"""
66

7-
import unittest
87
import os
8+
import glob
99
import argparse
1010
import logging
11+
import io
12+
import contextlib
13+
import re
14+
15+
import pytest
16+
1117
from ctsm import unit_testing
1218

1319
logger = logging.getLogger(__name__)
1420

21+
# Helpful message explaining the fact that our -s differs from pytest -s
22+
SYS_TESTS_DISAMBIGUATION = "If you want to use pytest's -s option, use --capture=no instead."
23+
24+
25+
def _get_files_matching_pattern(pattern):
26+
pattern = os.path.join("**", pattern)
27+
result = glob.glob(pattern, recursive=True)
28+
result.sort()
29+
result = [f for f in result if f.endswith(".py")]
30+
return result
31+
1532

1633
def main(description):
1734
"""Main function called when run_tests is run from the command-line
1835
1936
Args:
2037
description (str): description printed to usage message
2138
"""
22-
args = _commandline_args(description)
23-
verbosity = _get_verbosity_level(args)
39+
args, pytest_args = _commandline_args(description)
2440

41+
# Get list of test files to process, if any requested
42+
file_list = []
2543
if args.pattern is not None:
26-
pattern = args.pattern
27-
elif args.unit:
28-
pattern = "test_unit*.py"
29-
elif args.sys:
30-
pattern = "test_sys*.py"
31-
else:
32-
pattern = "test*.py"
44+
file_list += _get_files_matching_pattern(args.pattern)
45+
if args.unit:
46+
file_list += _get_files_matching_pattern("test_unit*.py")
47+
if args.sys:
48+
file_list += _get_files_matching_pattern("test_sys*.py")
49+
pytest_args += file_list
3350

3451
# This setup_for_tests call is the main motivation for having this wrapper script to
35-
# run the tests rather than just using 'python -m unittest discover'
52+
# run the tests rather than just using 'python -m pytest'
3653
unit_testing.setup_for_tests(enable_critical_logs=args.debug)
3754

38-
mydir = os.path.dirname(os.path.abspath(__file__))
39-
testsuite = unittest.defaultTestLoader.discover(start_dir=mydir, pattern=pattern)
40-
# NOTE(wjs, 2018-08-29) We may want to change the meaning of '--debug'
41-
# vs. '--verbose': I could imagine having --verbose set buffer=False, and --debug
42-
# additionally sets the logging level to much higher - e.g., debug level.
43-
testrunner = unittest.TextTestRunner(buffer=(not args.debug), verbosity=verbosity)
44-
testrunner.run(testsuite)
55+
# Run the tests
56+
pytest.main(pytest_args)
4557

4658

4759
def _commandline_args(description):
4860
"""Parse and return command-line arguments
4961
Note that run_ctsm_py_tests is not intended to be
5062
used without argument specifications
5163
"""
64+
65+
# Get help for pytest options we're using to overload existing run_ctsm_py_test options
66+
debug_help = _get_pytest_help()
67+
5268
parser = argparse.ArgumentParser(
5369
description=description, formatter_class=argparse.RawTextHelpFormatter
5470
)
5571

56-
output_level = parser.add_mutually_exclusive_group()
57-
58-
output_level.add_argument(
72+
parser.add_argument(
5973
"-v", "--verbose", action="store_true", help="Run tests with more verbosity"
6074
)
6175

62-
output_level.add_argument(
63-
"-d", "--debug", action="store_true", help="Run tests with even more verbosity"
76+
parser.add_argument(
77+
"-d",
78+
"--debug",
79+
nargs="?",
80+
type=str,
81+
const="",
82+
metavar="DEBUG_FILE_NAME", # Same as pytest
83+
help=(
84+
"If given with no argument, uses old unittest-based run_ctsm_py_tests behavior: Run"
85+
" tests with maximum verbosity, equivalent to ``pylint -v --capture=no``. If given"
86+
f" with argument, uses pytest behavior: {debug_help}"
87+
),
88+
)
89+
90+
parser.add_argument(
91+
"--no-disable-warnings", action="store_true", help="Show pytest's warnings summary"
6492
)
6593

6694
test_subset = parser.add_mutually_exclusive_group()
6795

6896
test_subset.add_argument("-u", "--unit", action="store_true", help="Only run unit tests")
6997

70-
test_subset.add_argument("-s", "--sys", action="store_true", help="Only run system tests")
98+
test_subset.add_argument(
99+
"-s",
100+
"--sys",
101+
action="store_true",
102+
help=f"Only run system tests. {SYS_TESTS_DISAMBIGUATION}",
103+
)
71104

72105
test_subset.add_argument(
73106
"-p", "--pattern", help="File name pattern to match\n" "Default is test*.py"
74107
)
75108

76-
args = parser.parse_args()
109+
args, unknown = parser.parse_known_args()
77110

78-
return args
111+
pytest_args = []
79112

113+
# Pre-pytest version of run_ctsm_py_tests suppressed warnings by default
114+
if not args.no_disable_warnings:
115+
pytest_args += ["--disable-warnings"]
80116

81-
def _get_verbosity_level(args):
82-
if args.debug or args.verbose:
83-
verbosity = 2
84-
else:
85-
verbosity = 1
86-
return verbosity
117+
# Handle -v/--verbose
118+
if args.verbose:
119+
pytest_args += ["--verbose"]
120+
121+
# Handle --debug with vs. without arg. Note that --debug is no longer mutually exclusive with
122+
# --verbose.
123+
if args.debug is not None:
124+
# Old run_ctsm_py_tests behavior: Run tests with maximum verbosity
125+
if args.debug == "":
126+
pytest_args += ["--verbose", "--capture=no"]
127+
# pylint's --debug
128+
else:
129+
pytest_args += ["--debug", args.debug]
130+
131+
# Warn user about ambiguous -s
132+
if args.sys:
133+
logger.info("Running system tests only. %s", SYS_TESTS_DISAMBIGUATION)
134+
135+
# Pass any unknown args to pytest directly
136+
pytest_args += unknown
137+
138+
return args, pytest_args
139+
140+
141+
def _get_pytest_help():
142+
# Get pytest help text
143+
buf = io.StringIO()
144+
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
145+
try:
146+
pytest.main(["--help"])
147+
except SystemExit:
148+
# pytest may call sys.exit(); ignore it
149+
pass
150+
pytest_help_text = buf.getvalue()
151+
152+
# Extract help for options we care about
153+
debug_help = get_pytest_help_item(pytest_help_text, "--debug")
154+
return debug_help
155+
156+
157+
def get_pytest_help_item(pytest_help_text: str, option: str) -> str:
158+
"""
159+
Extract a single pytest CLI option (e.g. "--debug") from the full ``pytest --help`` output and
160+
collapse its multi-line description into a single line.
161+
162+
The function locates the first line starting with ``option`` and then collects all immediately
163+
following lines that are more indented than that header line. These indented lines are treated
164+
as the option's description. The first line break is replaced with a colon and the description
165+
lines are joined with single spaces.
166+
167+
Parameters
168+
----------
169+
pytest_help_text : str
170+
The full text output of ``pytest --help``.
171+
option : str
172+
A substring identifying the option to extract
173+
(e.g. "--debug" or "--override-ini").
174+
175+
Returns
176+
-------
177+
str
178+
A single-line string of the form:
179+
"<option header>: <collapsed description>"
180+
181+
Raises
182+
------
183+
RuntimeError
184+
If no line starting with ``option`` is found in the help text.
185+
186+
Notes
187+
-----
188+
This function relies on pytest's help formatting convention that description lines are indented
189+
more deeply than their corresponding option header line.
190+
"""
191+
lines = pytest_help_text.splitlines()
192+
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+
197+
for i, line in enumerate(lines):
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.
216+
header_indent = len(line) - len(line.lstrip())
217+
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
224+
for next_line in lines[i + 1 :]:
225+
# Stop if we hit a blank line
226+
if not next_line.strip():
227+
break
228+
229+
# Calculate indentation of this line
230+
indent = len(next_line) - len(next_line.lstrip())
231+
232+
# Stop if indentation is not deeper than header (indicates we've reached the next
233+
# option or section)
234+
if indent <= header_indent:
235+
break
236+
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)
242+
243+
# Return formatted result: "option_header: description"
244+
return f"{option_header}: {description}"
245+
246+
# If we get here, the option was not found
247+
raise RuntimeError(f"Failed to get pytest help for {option}")

python/ctsm/test/test_advanced_unit_mesh_plotter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def test_default_outfile_as_expected(self):
4444
"""
4545
Test that the default outfile is as expected
4646
"""
47-
infile = "ctsm/test/testinputs/default_data.cfg"
47+
infile = os.path.join(unit_testing.get_test_input_data_dir(), "default_data.cfg")
4848
sys.argv = [
4949
"mesh_plotter",
5050
"--input",

0 commit comments

Comments
 (0)