|
4 | 4 | parent directory |
5 | 5 | """ |
6 | 6 |
|
7 | | -import unittest |
8 | 7 | import os |
| 8 | +import glob |
9 | 9 | import argparse |
10 | 10 | import logging |
| 11 | +import io |
| 12 | +import contextlib |
| 13 | +import re |
| 14 | + |
| 15 | +import pytest |
| 16 | + |
11 | 17 | from ctsm import unit_testing |
12 | 18 |
|
13 | 19 | logger = logging.getLogger(__name__) |
14 | 20 |
|
| 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 | + |
15 | 32 |
|
16 | 33 | def main(description): |
17 | 34 | """Main function called when run_tests is run from the command-line |
18 | 35 |
|
19 | 36 | Args: |
20 | 37 | description (str): description printed to usage message |
21 | 38 | """ |
22 | | - args = _commandline_args(description) |
23 | | - verbosity = _get_verbosity_level(args) |
| 39 | + args, pytest_args = _commandline_args(description) |
24 | 40 |
|
| 41 | + # Get list of test files to process, if any requested |
| 42 | + file_list = [] |
25 | 43 | 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 |
33 | 50 |
|
34 | 51 | # 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' |
36 | 53 | unit_testing.setup_for_tests(enable_critical_logs=args.debug) |
37 | 54 |
|
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) |
45 | 57 |
|
46 | 58 |
|
47 | 59 | def _commandline_args(description): |
48 | 60 | """Parse and return command-line arguments |
49 | 61 | Note that run_ctsm_py_tests is not intended to be |
50 | 62 | used without argument specifications |
51 | 63 | """ |
| 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 | + |
52 | 68 | parser = argparse.ArgumentParser( |
53 | 69 | description=description, formatter_class=argparse.RawTextHelpFormatter |
54 | 70 | ) |
55 | 71 |
|
56 | | - output_level = parser.add_mutually_exclusive_group() |
57 | | - |
58 | | - output_level.add_argument( |
| 72 | + parser.add_argument( |
59 | 73 | "-v", "--verbose", action="store_true", help="Run tests with more verbosity" |
60 | 74 | ) |
61 | 75 |
|
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" |
64 | 92 | ) |
65 | 93 |
|
66 | 94 | test_subset = parser.add_mutually_exclusive_group() |
67 | 95 |
|
68 | 96 | test_subset.add_argument("-u", "--unit", action="store_true", help="Only run unit tests") |
69 | 97 |
|
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 | + ) |
71 | 104 |
|
72 | 105 | test_subset.add_argument( |
73 | 106 | "-p", "--pattern", help="File name pattern to match\n" "Default is test*.py" |
74 | 107 | ) |
75 | 108 |
|
76 | | - args = parser.parse_args() |
| 109 | + args, unknown = parser.parse_known_args() |
77 | 110 |
|
78 | | - return args |
| 111 | + pytest_args = [] |
79 | 112 |
|
| 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"] |
80 | 116 |
|
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}") |
0 commit comments