Last active
February 9, 2023 13:08
-
-
Save szobov/e5ee966e3bdae711c27b304d688aa05e to your computer and use it in GitHub Desktop.
Add a fixture parameters to pytest's test function definitions
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
Copyright © 2023 Sergei Zobov | |
Permission is hereby granted, free of charge, to any person obtaining a copy | |
of this software and associated documentation files (the “Software”), to deal | |
in the Software without restriction, including without limitation the rights | |
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
copies of the Software, and to permit persons to whom the Software is | |
furnished to do so, subject to the following conditions: | |
The above copyright notice and this permission notice shall be included in | |
all copies or substantial portions of the Software. | |
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, | |
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. | |
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM | |
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR | |
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE | |
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
""" | |
import argparse | |
import logging | |
import os | |
import pathlib | |
import shutil | |
import subprocess | |
import typing as _t | |
logger = logging.getLogger(__name__) | |
PACKAGE_LINE_PREFIX = "<Package '" | |
MODULE_LINE_PREFIX = "<Module '" | |
FUNCTION_LINE_PREFIX = "<Function '" | |
LINE_TERMINATOR = "'>" | |
TEMPORTAL_TEST_FILE_PREFIX = "test_temporal_fixed_auto_test_" | |
def extract_test_info(input_data: str) -> _t.Generator[_t.Tuple[str, str, str], None, None]: | |
""" | |
Process the format of the output from `pytest --collect-only`. | |
The example of the output: | |
``` | |
collected 42 items / 1 skipped | |
<Module 'tests/test_file.py'> | |
<Function 'test_ok'> | |
<Function 'test_error'> | |
... | |
``` | |
DISCLAIMER: it improperly works with test classes since it doesn't parse it. | |
""" | |
current_package = "" | |
current_module: _t.Optional[str] = None | |
current_function: _t.Optional[str] = None | |
for line in map(str.strip, input_data.split("\n")): | |
if line.startswith(PACKAGE_LINE_PREFIX): | |
current_package = line[len(PACKAGE_LINE_PREFIX) : -len(LINE_TERMINATOR)] | |
current_module = None | |
if line.startswith(MODULE_LINE_PREFIX): | |
current_module = line[len(MODULE_LINE_PREFIX) : -len(LINE_TERMINATOR)] | |
package_module = pathlib.Path(current_package) / pathlib.Path(current_module) | |
if package_module.exists(): | |
continue | |
if pathlib.Path(current_module).exists(): | |
current_package = "" | |
if line.startswith(FUNCTION_LINE_PREFIX): | |
current_function = line[len(FUNCTION_LINE_PREFIX) : -len(LINE_TERMINATOR)] | |
if current_function.endswith("]"): | |
current_function = current_function.split("[")[0] | |
assert current_module | |
assert current_function | |
yield current_package, current_module, current_function | |
def create_fixed_file( | |
*, | |
package_module: pathlib.Path, | |
function: str, | |
fixture_parameters: _t.Tuple[str, ...], | |
dry_run: bool = True, | |
suppress_output: bool = False, | |
): | |
""" | |
Process a test file in a following algorithm: | |
1. Read an original test file by lines | |
2. Find a line with function name in it | |
3. If line containes `self` keyword igonres it | |
4. Parse a line and put a string with fixtures paramers between | |
a fuction definition and the first parameter | |
5. Creates a temporal test file with fixed line | |
6. Run fixed the test file | |
7. If test passes, replace the original file with the fixed copy | |
8. If any error occured, remove a copy and exit from a this function | |
""" | |
file_content_with_fixed_line: _t.List[str] = [] | |
for line in package_module.read_text().split("\n"): | |
function_def = f"def {function}(" | |
if function_def not in line or "self" in line: | |
file_content_with_fixed_line.append(line) | |
continue | |
splitted_line = line.split(function_def) | |
assert len(splitted_line) == 2, splitted_line | |
new_line = ( | |
f"{splitted_line[0]}{function_def}{', '.join(fixture_parameters)}, {splitted_line[1]}" | |
) | |
file_content_with_fixed_line.append(new_line) | |
fixed_test_content = "\n".join(file_content_with_fixed_line) | |
fixed_test_file = package_module.parent / f"{TEMPORTAL_TEST_FILE_PREFIX}{package_module.name}" | |
quite_mode = [] | |
if suppress_output: | |
quite_mode = ["-qq"] | |
try: | |
fixed_test_file.write_text(fixed_test_content) | |
result = subprocess.run( | |
["pytest", f"{str(fixed_test_file)}::{function}"] + quite_mode, | |
) | |
assert result.returncode == 0 | |
logger.info( | |
msg={"comment": "Test was fixed", "test_file": package_module, "function": function} | |
) | |
except: | |
logger.exception( | |
msg={"comment": "Got an error on execution test", "test_file": fixed_test_file} | |
) | |
os.remove(fixed_test_file) | |
return | |
logger.info( | |
msg={"comment": "Replacing test files", "test_file": package_module, "dry_run": dry_run} | |
) | |
if dry_run: | |
if fixed_test_file.exists: | |
os.remove(fixed_test_file) | |
else: | |
shutil.move(str(fixed_test_file), str(package_module)) | |
def main( | |
fixture_parameters: _t.Tuple[str, ...], | |
dry_run: bool, | |
suppress_output: bool, | |
fix_only_test_substring: _t.Optional[str], | |
): | |
logger.info( | |
msg={ | |
"comment": "Start fixing tests function definitions", | |
"fixture_parameters": fixture_parameters, | |
"dry_run": dry_run, | |
"suppress_output": suppress_output, | |
"fix_only_test_substring": fix_only_test_substring, | |
} | |
) | |
input_from_pytest_collect: str = subprocess.check_output(["pytest", "--collect-only"]).decode() | |
processed_tests: _t.Set[_t.Tuple[str, str, str]] = set() | |
for (package, module, function) in extract_test_info(input_from_pytest_collect): | |
if fix_only_test_substring is not None: | |
if fix_only_test_substring not in module: | |
continue | |
test_key = tuple([package, module, function]) | |
if test_key in processed_tests: | |
continue | |
processed_tests.add(test_key) | |
package_module = pathlib.Path(package) / pathlib.Path(module) | |
quite_mode = [] | |
if suppress_output: | |
quite_mode = ["-qq"] | |
result = subprocess.run(["pytest", f"{str(package_module)}::{function}"] + quite_mode) | |
if result.returncode in (0, 4): | |
continue | |
create_fixed_file( | |
package_module=package_module, | |
function=function, | |
fixture_parameters=fixture_parameters, | |
dry_run=dry_run, | |
suppress_output=suppress_output, | |
) | |
def parse_args() -> argparse.Namespace: | |
parser = argparse.ArgumentParser(""" | |
Add a fixture parameters to pytest's test function definitions. | |
E.g. you have: | |
``` | |
def test_ok(bar, baz): | |
assert True | |
``` | |
and you want to add a new fixture `fizz` to all the test function that are | |
requires this fixture. | |
Then you can run this script in the way: | |
``` | |
$ python add_fixture_to_tests.py -f fizz | |
``` | |
And it will replace a test file with a new one, if it was failing before but | |
was fixed after adding a new fixture. | |
``` | |
def test_ok(fiz, bar, baz): | |
assert True | |
``` | |
Please check `--help` to learn more about available parameters. | |
Intended to be used together with automatical code formating tool. | |
""") | |
parser.add_argument( | |
"-f", | |
"--fixture-parameters", | |
type=str, | |
help="List of the names of the fixtures that will be added to the tests. E.g. " | |
"--fixture-parameters=fixture_name1,fixture_name2", | |
required=True, | |
) | |
parser.add_argument( | |
"-dr", | |
"--dry-run", | |
action="store_true", | |
help="Do not replace an original test file.", | |
required=False, | |
default=False, | |
) | |
parser.add_argument( | |
"-q", | |
"--suppress-output", | |
action="store_true", | |
help="Pass -qq argument to pytest", | |
required=False, | |
default=False, | |
) | |
parser.add_argument( | |
"-s", | |
"--test-name-substring", | |
type=str, | |
help="Ingnore all test with expect containing this substring.", | |
required=False, | |
default=None, | |
) | |
return parser.parse_args() | |
if __name__ == "__main__": | |
logging.basicConfig() | |
logging.getLogger().setLevel(logging.INFO) | |
args = parse_args() | |
test_name_substring = None | |
if not args.test_name_substring: | |
test_name_substring = None | |
fixture_parameters = tuple(args.fixture_parameters.split(",")) | |
assert all(map(lambda p: isinstance(p, str), fixture_parameters)) | |
assert len(fixture_parameters) != 0 | |
main( | |
fixture_parameters=fixture_parameters, | |
dry_run=args.dry_run, | |
suppress_output=args.suppress_output, | |
fix_only_test_substring=args.test_name_substring, | |
) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment