diff --git a/.devcontainer/postStartCommand.sh b/.devcontainer/postStartCommand.sh index 42074cf..6125e38 100755 --- a/.devcontainer/postStartCommand.sh +++ b/.devcontainer/postStartCommand.sh @@ -4,3 +4,5 @@ nohup bash -c '.devcontainer/postStartBackground.sh &' > .dev_container_logs/postStartBackground.out docker compose -f tests/workarounds/pytest_django/docker-compose.yml up -d postgres + +poetry run dmypy start diff --git a/.github/actions/test/action.yaml b/.github/actions/test/action.yaml index 37f4302..1dfa435 100644 --- a/.github/actions/test/action.yaml +++ b/.github/actions/test/action.yaml @@ -17,9 +17,13 @@ runs: shell: bash - name: Test with pytest run: | - poetry run pytest --daemon-start-if-needed + poetry run pytest --daemon-start-if-needed tests shell: bash - name: Rerun workaround tests to check for incompatibilities run: | poetry run pytest tests/workarounds shell: bash + - name: Run metatests + run: | + poetry run python metatests/metatest_runner.py --retry 2 + shell: bash diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 0371e7e..68984c0 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -30,7 +30,5 @@ jobs: uses: Gr1N/setup-poetry@12c727a3dcf8c1a548d8d041c9d5ef5cebb3ba2e - name: test uses: ./.github/actions/test - - name: lint using ruff - run: poetry run ruff pytest_hot_reloading tests - name: lint using mypy run: poetry run mypy pytest_hot_reloading tests diff --git a/.vscode/launch.json b/.vscode/launch.json index be0a206..fc9a3a7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -32,6 +32,16 @@ "program": "/workspaces/pytest-hot-reloading/.venv/bin/pytest", "justMyCode": false }, + { + "name": "Run metatests", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/metatests/metatest_runner.py", + "justMyCode": false, + // "args": [ + // "--do-not-reset-daemon" + // ] + }, { "name": "Python: Debug Unit Tests", "type": "python", @@ -41,7 +51,7 @@ "debug-in-terminal" ], "console": "integratedTerminal", - "justMyCode": false, + "justMyCode": false } ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 77cd0a7..08d7cbf 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,7 +4,7 @@ { "label": "Check", "type": "shell", - "command": "poetry run dmypy check pytest_hot_reloading tests && poetry run ruff pytest_hot_reloading tests", + "command": "poetry run dmypy check pytest_hot_reloading tests metatests && poetry run ruff pytest_hot_reloading tests metatests", "problemMatcher": [] }, { diff --git a/README.md b/README.md index f2bb71f..0cc06d3 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,10 @@ Then enable automatically starting the daemon in your settings: - Disable the pytest plugin for this test run. - Default: `False` - Command line: `--daemon-disable` +- `PYTEST_DAEMON_DO_NOT_AUTOWATCH_FIXTURES` + - Disable automatically autowatching files containing fixtures + - Default: `False` + - Command line: `--daemon-do-not-autowatch-fixtures` ## Workarounds Libraries that use mutated globals may need a workaround to work with this plugin. The preferred diff --git a/metatests/metatest_runner.py b/metatests/metatest_runner.py new file mode 100644 index 0000000..455a3c6 --- /dev/null +++ b/metatests/metatest_runner.py @@ -0,0 +1,318 @@ +import argparse +import shutil +import tempfile +import time +from os import system +from pathlib import Path +from typing import Callable + +METATESTS_DIR = Path(__file__).parent +TEMPLATE_DIR = METATESTS_DIR / "template" + + +class MetaTestRunner: + def __init__( + self, + do_not_reset_daemon: bool, + use_watchman: bool, + change_delay: float, + retries: int, + temp_dir: Path, + ) -> None: + self.do_not_reset_daemon = do_not_reset_daemon + self.use_watchman = use_watchman + self.change_delay = change_delay + self.retries = retries + self.temp_dir = temp_dir + self.modified_conftest_file = self.temp_dir / "conftest.py" + self.modified_test_file = self.temp_dir / "test_fixture_changes.py" + self.modified_used_by_conftest_file = self.temp_dir / "used_by_conftest.py" + + def make_fresh_copy(self): + # delete the directory contents if it is not empty + if self.temp_dir.exists(): + shutil.rmtree(self.temp_dir) + shutil.copytree(TEMPLATE_DIR, self.temp_dir) + with (self.temp_dir / ".gitignore").open("w") as gitignore: + gitignore.write("*") + + def run_test( + self, + test_name: str, + *file_mod_funcs: Callable, + expect_fail: bool = False, + use_watchman: bool = False, + change_delay: float = 0.01, + retries: int = 0, + ): + for retry_num in range(retries + 1): + self.make_fresh_copy() + if system( + f"pytest -p pytest_hot_reloading.plugin --daemon-start-if-needed {'--daemon-use-watchman' if use_watchman else ''} " + f"{self.temp_dir}/test_fixture_changes.py::test_always_ran" + ): + raise Exception("Failed to prep daemon") + for func in file_mod_funcs: + func() + time.sleep(change_delay + retry_num * 0.25) + try: + if system(f"pytest {self.temp_dir}/test_fixture_changes.py::{test_name}"): + if not expect_fail: + raise Exception(f"Failed to run test {test_name}") + elif expect_fail: + raise Exception(f"Expected test {test_name} to fail but it passed") + except Exception: + if retry_num >= retries: + raise + else: + print("Retrying failed metatest") + else: + break + + def add_fixture(self) -> None: + with self.modified_conftest_file.open("a") as f: + f.write( + """ +@pytest.fixture() +def added_fixture(): + pass""" + ) + + def add_async_fixture(self) -> None: + with self.modified_conftest_file.open("a") as f: + f.write( + """ +@pytest.fixture() +async def async_added_fixture(): + pass""" + ) + + def remove_fixture(self, trigger_comment="# start of removed fixture") -> None: + # remove the fixture from conftest.py + with self.modified_conftest_file.open() as f: + lines = f.readlines() + + new_lines = [] + is_removed_fixture = False + for line in lines: + stripped_line = line.strip() + if stripped_line == f"@pytest.fixture() {trigger_comment}": + is_removed_fixture = True + elif not stripped_line: + is_removed_fixture = False + if not is_removed_fixture: + new_lines.append(line) + + # write new version of conftest.py + with self.modified_conftest_file.open("w") as f: + f.writelines(new_lines) + + def remove_use_of_fixture(self, fixture_name="removed_fixture") -> None: + # remove the fixture from test_fixture_changes.py + with self.modified_test_file.open() as f: + lines = f.readlines() + + with self.modified_test_file.open("w") as f: + for line in lines: + f.write(line.replace(f"({fixture_name})", "()")) + + def rename_fixture(self) -> None: + # rename the fixture in conftest.py + with self.modified_conftest_file.open() as f: + lines = f.readlines() + + # write new version of conftest.py + with self.modified_conftest_file.open("w") as f: + for line in lines: + f.write(line.replace("renamed_fixture", "renamed_fixture2")) + + def rename_use_of_fixture(self) -> None: + # rename the fixture in test_fixture_changes.py + with self.modified_test_file.open() as f: + lines = f.readlines() + + with self.modified_test_file.open("w") as f: + for line in lines: + f.write(line.replace("renamed_fixture", "renamed_fixture2")) + + def modify_dependency_fixture_return(self) -> None: + # modify the dependency fixture in conftest.py + with self.modified_conftest_file.open() as f: + lines = f.readlines() + + # write new version of conftest.py + with self.modified_conftest_file.open("w") as f: + for line in lines: + f.write( + line.replace( + "return 1 # dependency value", "return 2222 # dependency value" + ) + ) + + def modify_dependency_fixture_name(self) -> None: + # modify the dependency fixture in conftest.py + with self.modified_conftest_file.open() as f: + lines = f.readlines() + + # write new version of conftest.py + with self.modified_conftest_file.open("w") as f: + for line in lines: + f.write(line.replace("dependency_fixture", "dependency_fixture2")) + + def remove_dependency_fixture(self) -> None: + # remove the dependency fixture from conftest.py + with self.modified_conftest_file.open() as f: + lines = f.readlines() + + new_lines = [] + is_dependency_fixture = False + for line in lines: + stripped_line = line.strip() + if stripped_line == "@pytest.fixture() # start of dependency fixture": + is_dependency_fixture = True + elif not stripped_line: + is_dependency_fixture = False + if not is_dependency_fixture: + new_lines.append(line) + + # write new version of conftest.py + with self.modified_conftest_file.open("w") as f: + f.writelines(new_lines) + + def remove_dependency_fixture_usage(self) -> None: + # remove the dependency fixture from conftest.py + with self.modified_conftest_file.open() as f: + lines = f.readlines() + + # write new version of conftest.py + with self.modified_conftest_file.open("w") as f: + for line in lines: + f.write( + line.replace( + "dependency_change_fixture(dependency_fixture)", + "dependency_change_fixture()", + ).replace( + "dependency_removed_fixture(dependency_fixture)", + "dependency_removed_fixture()", + ) + ) + + def modify_fixture_outside_of_conftest(self) -> None: + # modify the dependency fixture in conftest.py + with self.modified_used_by_conftest_file.open() as f: + lines = f.readlines() + + # write new version of used_by_conftest.py + with self.modified_used_by_conftest_file.open("w") as f: + for line in lines: + f.write( + line.replace( + "return value_modified_by_autouse_fixture", "return 'modified value'" + ) + ) + + def remove_autouse_fixture_outside_of_conftest(self) -> None: + # remove the dependency fixture from conftest.py + with self.modified_used_by_conftest_file.open() as f: + lines = f.readlines() + + new_lines = [] + is_autouse_fixture = False + for line in lines: + stripped_line = line.strip() + if stripped_line == "@pytest.fixture(autouse=True)": + is_autouse_fixture = True + elif not stripped_line: + is_autouse_fixture = False + if not is_autouse_fixture: + new_lines.append(line) + + # write new version of conftest.py + with self.modified_used_by_conftest_file.open("w") as f: + f.writelines(new_lines) + + def main(self) -> None: + if not self.do_not_reset_daemon: + system("pytest --stop-daemon") + if self.use_watchman: + self.run_test("test_always_ran", use_watchman=True) + self.run_test( + "test_adding_fixture", + self.add_fixture, + ) + self.run_test( + "test_adding_fixture_async", + self.add_async_fixture, + ) + self.run_test("test_removing_fixture") # needed to trigger caching of fixture info + self.run_test( + "test_removing_fixture", + self.remove_fixture, + self.remove_use_of_fixture, + ) + self.run_test( + "test_removing_fixture_async", + lambda: self.remove_fixture("# start of async removed fixture"), + lambda: self.remove_use_of_fixture("async_removed_fixture"), + ) + self.run_test( + "test_removing_should_fail", + self.remove_fixture, + expect_fail=True, + ) + self.run_test( + "test_renaming_fixture", + self.rename_fixture, + self.rename_use_of_fixture, + ) + self.run_test( + "test_renaming_should_fail", + self.rename_fixture, + expect_fail=True, + ) + self.run_test( + "test_fixture_changes_dependency", + self.modify_dependency_fixture_return, + ) + self.run_test( + "test_fixture_has_dependency_renamed", + self.modify_dependency_fixture_name, + ) + self.run_test( + "test_fixture_has_dependency_removed", + self.remove_dependency_fixture, + expect_fail=True, + ) + self.run_test( + "test_fixture_removes_dependency", + self.remove_dependency_fixture, + self.remove_dependency_fixture_usage, + ) + self.run_test("test_fixture_outside_of_conftest", expect_fail=True) + self.run_test( + "test_fixture_outside_of_conftest", + self.modify_fixture_outside_of_conftest, + ) + self.run_test( + "test_autouse_fixture_outside_of_conftest_is_removed", + self.remove_autouse_fixture_outside_of_conftest, + ) + + +if __name__ == "__main__": + argparser = argparse.ArgumentParser() + argparser.add_argument("--do-not-reset-daemon", action="store_true") + argparser.add_argument("--use-watchman", action="store_true") + argparser.add_argument("--change-delay", default=0.01, type=float) + argparser.add_argument("--retry", default=0, type=int) + args = argparser.parse_args() + + with tempfile.TemporaryDirectory() as temp_dir: + runner = MetaTestRunner( + args.do_not_reset_daemon, + args.use_watchman, + args.change_delay, + args.retry, + Path(temp_dir), + ) + runner.main() diff --git a/metatests/template/__init__.py b/metatests/template/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/metatests/template/conftest.py b/metatests/template/conftest.py new file mode 100644 index 0000000..53ce61d --- /dev/null +++ b/metatests/template/conftest.py @@ -0,0 +1,48 @@ +import pytest + +from .used_by_conftest import * # noqa + + +@pytest.fixture() # start of removed fixture +def removed_fixture(): + """ + This fixture is removed + """ + + +@pytest.fixture() # start of async removed fixture +async def async_removed_fixture(): + """ + This fixture is removed + """ + + +@pytest.fixture() +def renamed_fixture(): + """ + This fixture is renamed + """ + + +@pytest.fixture() +def dependency_change_fixture(dependency_fixture): + """ + This fixture changes its dependencies + """ + return dependency_fixture + + +@pytest.fixture() # start of dependency fixture +def dependency_fixture(): + """ + This fixture is a dependency + """ + return 1 # dependency value + + +@pytest.fixture() +def dependency_removed_fixture(dependency_fixture): + """ + This fixture removes a dependency + """ + return 1 diff --git a/metatests/template/test_fixture_changes.py b/metatests/template/test_fixture_changes.py new file mode 100644 index 0000000..c071e55 --- /dev/null +++ b/metatests/template/test_fixture_changes.py @@ -0,0 +1,110 @@ +def test_always_ran(): + pass + + +def test_adding_fixture(added_fixture): + """ + This test uses a fixture that is added + + Should pass + """ + + +async def test_adding_fixture_async(async_added_fixture): + """ + This test uses an async fixture that is added + + Should pass + """ + + +def test_removing_fixture(removed_fixture): + """ + This test uses a fixture that is removed and no longer uses it + + Should pass + """ + + +async def test_removing_fixture_async(async_removed_fixture): + """ + This test uses an async fixture that is removed and no longer uses it + + Should pass + """ + + +def test_removing_should_fail(removed_fixture): + """ + This test uses a removed fixture + + Should error + """ + + +def test_renaming_fixture(renamed_fixture): + """ + This test uses a fixture that is renamed + + Should pass + """ + + +def test_renaming_should_fail(renamed_fixture): + """ + This test uses a fixture that is renamed without updating it here + + Should fail + """ + + +def test_fixture_changes_dependency(dependency_change_fixture): + """ + This test uses a fixture that changes its dependencies + + Should pass + """ + assert dependency_change_fixture == 2222 + + +def test_fixture_has_dependency_renamed(dependency_change_fixture): + """ + This test uses a fixture that renamed a dependency + + Should pass + """ + assert dependency_change_fixture == 1 + + +def test_fixture_removes_dependency(dependency_removed_fixture): + """ + This test uses a fixture that removes a dependency + + Should pass + """ + + +def test_fixture_has_dependency_removed(dependency_removed_fixture): + """ + This test uses a fixture that removes a dependency without updating it here + + Should fail + """ + + +def test_fixture_outside_of_conftest(fixture_outside_of_conftest): + """ + This test uses a fixture that is modified outside of conftest.py + + Should pass + """ + assert fixture_outside_of_conftest == "modified value" + + +def test_autouse_fixture_outside_of_conftest_is_removed(fixture_outside_of_conftest): + """ + This test uses a fixture that is altered by an autouse fixture outside of context + + Should pass + """ + assert fixture_outside_of_conftest == "modified by autouse value" diff --git a/metatests/template/used_by_conftest.py b/metatests/template/used_by_conftest.py new file mode 100644 index 0000000..b2f729f --- /dev/null +++ b/metatests/template/used_by_conftest.py @@ -0,0 +1,14 @@ +from pytest import fixture + +value_modified_by_autouse_fixture = "original value" + + +@fixture() +def fixture_outside_of_conftest() -> str: + return value_modified_by_autouse_fixture + + +@fixture(autouse=True) +def autouse_fixture_outside_of_conftest() -> None: + global value_modified_by_autouse_fixture + value_modified_by_autouse_fixture = "modified by autouse value" diff --git a/poetry.lock b/poetry.lock index ae22657..c591a5c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -404,6 +404,24 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.23.2" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-asyncio-0.23.2.tar.gz", hash = "sha256:c16052382554c7b22d48782ab3438d5b10f8cf7a4bdcae7f0f67f097d95beecc"}, + {file = "pytest_asyncio-0.23.2-py3-none-any.whl", hash = "sha256:ea9021364e32d58f0be43b91c6233fb8d2224ccef2398d6837559e587682808f"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + [[package]] name = "pytest-django" version = "4.5.2" @@ -626,4 +644,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "9bc19b8e502ae47b7f756158a8edc81ae518c8b5ef7d82131f708b96931d9c13" +content-hash = "426934a3b9e869e4d8f8b523c44eb2a1a3396e7b85261487072020acaf918f9d" diff --git a/pyproject.toml b/pyproject.toml index 0459df9..2d2fb37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,12 +31,14 @@ pytest-env = "^0.8.1" pytest-xdist = "^3.3.1" megamock = "^0.1.0b9" types-cachetools = "^5.3.0.5" +pytest-asyncio = "^0.23.2" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] +asyncio_mode = "auto" DJANGO_SETTINGS_MODULE = "tests.workarounds.pytest_django.settings" # xdist is not supported. Enable it to check that workaround works addopts = "-n 1 -p pytest_hot_reloading.plugin -p megamock.plugins.pytest" diff --git a/pytest_hot_reloading/client.py b/pytest_hot_reloading/client.py index 05aca08..f0ede9e 100644 --- a/pytest_hot_reloading/client.py +++ b/pytest_hot_reloading/client.py @@ -14,6 +14,8 @@ class PytestClient: _daemon_port: int _pytest_name: str _will_start_daemon_if_needed: bool + _do_not_autowatch_fixtures: bool + _use_watchman: bool def __init__( self, @@ -21,12 +23,16 @@ def __init__( daemon_port: int = 4852, pytest_name: str = "pytest", start_daemon_if_needed: bool = False, + do_not_autowatch_fixtures: bool = False, + use_watchman: bool = False, ) -> None: self._socket = None self._daemon_host = daemon_host self._daemon_port = daemon_port self._pytest_name = pytest_name self._will_start_daemon_if_needed = start_daemon_if_needed + self._do_not_autowatch_fixtures = do_not_autowatch_fixtures + self._use_watchman = use_watchman def _get_server(self) -> xmlrpc.client.ServerProxy: server_url = f"http://{self._daemon_host}:{self._daemon_port}" @@ -101,5 +107,9 @@ def _start_daemon(self) -> None: # start the daemon PytestDaemon.start( - host=self._daemon_host, port=self._daemon_port, pytest_name=self._pytest_name + host=self._daemon_host, + port=self._daemon_port, + pytest_name=self._pytest_name, + do_not_autowatch_fixtures=self._do_not_autowatch_fixtures, + use_watchman=self._use_watchman, ) diff --git a/pytest_hot_reloading/daemon.py b/pytest_hot_reloading/daemon.py index 3e9b2b1..c0315dc 100644 --- a/pytest_hot_reloading/daemon.py +++ b/pytest_hot_reloading/daemon.py @@ -15,6 +15,7 @@ import pytest from cachetools import TTLCache +from pytest_hot_reloading.jurigged_daemon_signalers import JuriggedDaemonSignaler from pytest_hot_reloading.workarounds import ( run_workarounds_post, run_workarounds_pre, @@ -22,10 +23,16 @@ class PytestDaemon: - def __init__(self, daemon_host: str = "localhost", daemon_port: int = 4852) -> None: + def __init__( + self, + signaler: JuriggedDaemonSignaler, + daemon_host: str = "localhost", + daemon_port: int = 4852, + ) -> None: self._daemon_host = daemon_host self._daemon_port = daemon_port self._server: SimpleXMLRPCServer | None = None + self._signaler = signaler @property def pid_file(self) -> Path: @@ -38,6 +45,8 @@ def start( pytest_name: str = "pytest", watch_globs: str | None = None, ignore_watch_globs: str | None = None, + do_not_autowatch_fixtures: bool | None = None, + use_watchman: bool | None = None, ) -> None: # start the daemon such that it will not close when the parent process closes if host == "localhost": @@ -53,6 +62,10 @@ def start( args += ["--daemon-watch-globs", watch_globs] if ignore_watch_globs: args += ["--daemon-ignore-watch-globs", ignore_watch_globs] + if do_not_autowatch_fixtures: + args += ["--daemon-do-not-autowatch-fixtures"] + if use_watchman: + args += ["--daemon-use-watchman"] subprocess.Popen( args, env=os.environ, @@ -145,6 +158,9 @@ def run_pytest(self, cwd: str, env_json: str, sys_path: list[str], args: list[st sys.stdout = stdout sys.stderr = stderr + if self._signaler.receive_clear_cache_signal(): + session_item_cache.clear() + import _pytest.main # monkeypatch in the main that does test collection caching @@ -325,6 +341,8 @@ def best_effort_copy(item, depth_remaining=2, force_best_effort=False): item_copy.__dict__[k] = best_effort_copy(v, depth_remaining - 1) return item_copy + num_tests_collected: int + # here config.args becomes basically the tests to run. Other arguments are omitted # not 100% sure this is always the case session_key = tuple(config.args) @@ -336,10 +354,12 @@ def best_effort_copy(item, depth_remaining=2, force_best_effort=False): config.hook.pytest_collection(session=session) print(f"Pytest Daemon: Collection took {(time.time() - start):0.3f} seconds") session_item_cache[session_key] = tuple(best_effort_copy(x) for x in session.items) + num_tests_collected = session.testscollected else: print("Pytest Daemon: Using cached collection") # Assign the prior test items (tests to run) and config to the current session session.items = items # type: ignore + num_tests_collected = len(items) session.config = config for i in items: # Items have references to the config and the session @@ -352,6 +372,6 @@ def best_effort_copy(item, depth_remaining=2, force_best_effort=False): if session.testsfailed: return pytest.ExitCode.TESTS_FAILED - elif session.testscollected == 0: + elif num_tests_collected == 0: return pytest.ExitCode.NO_TESTS_COLLECTED return None diff --git a/pytest_hot_reloading/jurigged_daemon_signalers.py b/pytest_hot_reloading/jurigged_daemon_signalers.py new file mode 100644 index 0000000..f5b6b39 --- /dev/null +++ b/pytest_hot_reloading/jurigged_daemon_signalers.py @@ -0,0 +1,22 @@ +import time + + +class JuriggedDaemonSignaler: + def __init__(self) -> None: + self._do_cache_clear = False + self._deleted_fixtures: set[str] = set() + self._block_until: float | None = None + + def signal_clear_cache(self) -> None: + self._do_cache_clear = True + self._block_until = time.time() + 1 + + def receive_clear_cache_signal(self) -> bool: + ret = self._do_cache_clear + cur_time = time.time() + while self._block_until is not None and cur_time < self._block_until: + time.sleep(self._block_until - cur_time) + cur_time = time.time() + self._block_until = None + self._do_cache_clear = False + return ret diff --git a/pytest_hot_reloading/plugin.py b/pytest_hot_reloading/plugin.py index b249503..58905f3 100644 --- a/pytest_hot_reloading/plugin.py +++ b/pytest_hot_reloading/plugin.py @@ -3,6 +3,7 @@ """ from __future__ import annotations +import inspect import os import sys from enum import Enum @@ -10,11 +11,13 @@ from typing import TYPE_CHECKING, Callable, Optional from pytest_hot_reloading.client import PytestClient +from pytest_hot_reloading.jurigged_daemon_signalers import JuriggedDaemonSignaler # this is modified by the daemon so that the pytest_collection hooks does not run i_am_server = False seen_paths: set[Path] = set() +signaler = JuriggedDaemonSignaler() if TYPE_CHECKING: from pytest import Config, Item, Parser, Session @@ -28,6 +31,8 @@ class EnvVariables(str, Enum): PYTEST_DAEMON_IGNORE_WATCH_GLOBS = "PYTEST_DAEMON_IGNORE_WATCH_GLOBS" PYTEST_DAEMON_START_IF_NEEDED = "PYTEST_DAEMON_START_IF_NEEDED" PYTEST_DAEMON_DISABLE = "PYTEST_DAEMON_DISABLE" + PYTEST_DAEMON_DO_NOT_AUTOWATCH_FIXTURES = "PYTEST_DAEMON_DO_NOT_AUTOWATCH_FIXTURES" + PYTEST_DAEMON_USE_WATCHMAN = "PYTEST_DAEMON_USE_WATCHMAN" def pytest_addoption(parser) -> None: @@ -92,6 +97,30 @@ def pytest_addoption(parser) -> None: 'you need add "python.experiments.optOutFrom": ["pythonTestAdapter"] to your config.' ), ) + group.addoption( + "--daemon-do-not-autowatch-fixtures", + action="store_true", + default=( + os.getenv(EnvVariables.PYTEST_DAEMON_DO_NOT_AUTOWATCH_FIXTURES, "False").lower() + in ("true", "1") + ), + help=( + "Do not automatically watch fixtures. " + "Typically this would be used if there's too many fixtures and the watch glob is used instead." + ), + ) + group.addoption( + "--daemon-use-watchman", + action="store_true", + default=( + os.getenv(EnvVariables.PYTEST_DAEMON_USE_WATCHMAN, "False").lower() in ("true", "1") + ), + help=( + "Use watchman instead of polling. " + "This reduces CPU usage, takes up open file handles, and improves responsiveness. " + "Some systems cannot reliably use this." + ), + ) # list of pytest hooks @@ -127,20 +156,62 @@ def pytest_cmdline_main(config: Config) -> Optional[int]: return status_code # status code 0 +fixture_names: set[str] = set() + + def monkey_patch_jurigged_function_definition(): import jurigged.codetools as jurigged_codetools # type: ignore import jurigged.utils as jurigged_utils # type: ignore OrigFunctionDefinition = jurigged_codetools.FunctionDefinition + OrigDeleteOperation = jurigged_codetools.DeleteOperation import ast + class NewDeleteOperation(OrigDeleteOperation): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self._signal_clear_cache_if_fixture() + + def _signal_clear_cache_if_fixture(self) -> None: + """ + Clear the cache if a fixture is deleted. + + If this isn't here, then deleted fixtures may still exist. + """ + if self.defn.name in fixture_names: + signaler.signal_clear_cache() + class NewFunctionDefinition(OrigFunctionDefinition): def reevaluate(self, new_node, glb): + func = glb[new_node.name] + is_test = new_node.name.startswith("test_") + if is_test: + old_sig = inspect.signature(func) + else: + if new_node.name in fixture_names: + # if a fixture is updated, then clear the session cache to avoid stale responses + signaler.signal_clear_cache() # monkeypatch: The assertion rewrite is from pytest. Jurigged doesn't # seem to have a way to add rewrite hooks new_node = self.apply_assertion_rewrite(new_node, glb) obj = super().reevaluate(new_node, glb) + + if is_test: + new_sig = inspect.signature(func) + + # if the signature changes, clear the session cache + # otherwise pytest will use the old signature. + # This is a more of a band-aid because the session cache + # could be more intelligently updated based on what was changed. + # This band-aid fixes tests using stale fixture info, which + # can result in unpredictable behavior that requires + # restarting the daemon. + if is_test: + if old_sig != new_sig: + signaler.signal_clear_cache() + return obj def apply_assertion_rewrite(self, ast_func, glb): @@ -187,6 +258,7 @@ def stash(self, lineno=1, col_offset=0): # monkey patch in new definition jurigged_codetools.FunctionDefinition = NewFunctionDefinition + jurigged_codetools.DeleteOperation = NewDeleteOperation def monkeypatch_group_definition(): @@ -221,24 +293,75 @@ def append(self, *children, ensure_separation=False): jurigged_codetools.GroupDefinition.append = append -def setup_jurigged(config: Config): - def _jurigged_logger(x: str) -> None: - """ - Jurigged behavior is to both print and log. +def _jurigged_logger(x: str) -> None: + """ + Jurigged behavior is to both print and log. - By default this creates duplicated output. + By default this creates duplicated output. + + Pass in a no-op logger to prevent this. + """ + print(x) - Pass in a no-op logger to prevent this. - """ +def setup_jurigged(config: Config): import jurigged monkey_patch_jurigged_function_definition() monkeypatch_group_definition() + if not config.option.daemon_do_not_autowatch_fixtures: + monkeypatch_fixture_marker(config.option.daemon_use_watchman) + else: + print("Not autowatching fixtures") pattern = _get_pattern_filters(config) # TODO: intelligently use poll versus watchman (https://github.com/JamesHutchison/pytest-hot-reloading/issues/16) - jurigged.watch(pattern=pattern, logger=_jurigged_logger, poll=True) + jurigged.watch( + pattern=pattern, logger=_jurigged_logger, poll=(not config.option.daemon_use_watchman) + ) + + +seen_files: set[str] = set() + + +def monkeypatch_fixture_marker(use_watchman: bool): + import jurigged + import pytest + from _pytest import fixtures + + FixtureFunctionMarkerOrig = fixtures.FixtureFunctionMarker + + # FixtureFunctionMarker is marked as final + class FixtureFunctionMarkerNew(FixtureFunctionMarkerOrig): # type: ignore # noqa + def __call__(self, func, *args, **kwargs): + fixture_names.add(func.__name__) + + return super().__call__(func, *args, **kwargs) + + fixture_original = pytest.fixture + + # doing a pure class only monkeypatch was breaking event_loop fixture + # so patching out fixture function to use new class + def _new_fixture(*args, **kwargs): + # get current file where this is called + frame = inspect.stack()[1] + module = inspect.getmodule(frame[0]) + fixture_file = module.__file__ + + # add fixture file to watched files + if fixture_file not in seen_files: + seen_files.add(fixture_file) + jurigged.watch(pattern=fixture_file, logger=_jurigged_logger, poll=(not use_watchman)) + + fixtures.FixtureFunctionMarker = FixtureFunctionMarkerNew + try: + ret = fixture_original(*args, **kwargs) + finally: + fixtures.FixtureFunctionMarker = FixtureFunctionMarkerOrig + + return ret + + pytest.fixture = _new_fixture def _plugin_logic(config: Config) -> int: @@ -257,7 +380,7 @@ def _plugin_logic(config: Config) -> int: from pytest_hot_reloading.daemon import PytestDaemon - daemon = PytestDaemon(daemon_port=daemon_port) + daemon = PytestDaemon(daemon_port=daemon_port, signaler=signaler) daemon.run_forever() sys.exit(0) @@ -267,6 +390,7 @@ def _plugin_logic(config: Config) -> int: daemon_port=daemon_port, pytest_name=pytest_name, start_daemon_if_needed=config.option.daemon_start_if_needed, + do_not_autowatch_fixtures=config.option.daemon_do_not_autowatch_fixtures, ) if config.option.stop_daemon: @@ -335,9 +459,9 @@ def matcher(filename: str) -> bool: def pytest_collection_modifyitems(session: Session, config: Config, items: list[Item]) -> None: """ - This hooks is called by pytest after the collection phase. + This hook is called by pytest after the collection phase. - This adds tests to the watch list automatically. + This adds tests and conftests to the watch list automatically. The client should never get this far. This should only be used by the daemon. @@ -347,5 +471,9 @@ def pytest_collection_modifyitems(session: Session, config: Config, items: list[ for item in items: if item.path and item.path not in seen_paths: - jurigged.watch(pattern=str(item.path)) + jurigged.watch( + pattern=str(item.path), + logger=_jurigged_logger, + poll=(not config.option.daemon_use_watchman), + ) seen_paths.add(item.path)