diff --git a/LICENSE b/LICENSE index 13d78d7..88d49c4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Masen Furer +Copyright (c) 2023 Masen Furer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index dbeb9c8..29afd06 100644 --- a/README.md +++ b/README.md @@ -9,22 +9,45 @@ Reuse virtualenvs with multiple `tox` test environments. If two environments have compatible specifications (basically, same `deps`) and use the same `env_dir`, installing this plugin and setting -`ignore_env_name_mismatch = true` will allow tox to use the same underlying +`runner = ignore_env_name_mismatch` will allow tox to use the same underlying virtualenv for each test environment. ## Usage 1. Install `tox-ignore-env-name-mismatch` in the same environment as `tox`. -2. Set `ignore_env_name_mismatch = true` to opt-out of recreating the virtualenv when the cached name differs from the current env name. -* To always use this plugin, specify `requires = tox-ignore-env-name-mismatch` in the `[tox]` section - of `tox.ini` +2. Set `runner = ignore_env_name_mismatch` in a testenv to opt-out of recreating the virtualenv when the env name changes. + +### To always use this plugin: + +#### Install/provision + +* Specify `requires = tox-ignore-env-name-mismatch~=0.2.0` in the `[tox]` + section of `tox.ini` + +This will cause `tox` to provision a new virtualenv for `tox` itself and other +dependencies named in the +[`requires`](https://tox.wiki/en/latest/config.html#requires) key if the current +environment does not meet the specification. + +Pinning the plugin to a minor version is _highly recommended_ to avoid breaking +changes. + +#### Vendor + +* copy `src/tox_ignore_env_name_mismatch.py` to the root of you project + directory as `toxfile.py` + +This uses the tox4's new ["inline +plugin"](https://tox.wiki/en/latest/plugins.html#module-tox.plugin) approach +instead of relying on the provisioning system (which [can be disabled via +CLI](https://tox.wiki/en/latest/cli_interface.html#tox---no-provision)). ## Example ``` [tox] envlist = py39,py310,py311,lint,format,types -requires = tox-ignore-env-name-mismatch +requires = tox-ignore-env-name-mismatch~=0.2.0 [testenv] deps = pytest @@ -32,7 +55,7 @@ commands = pytest {posargs} [testenv:{lint,format,types}] env_dir = {toxworkdir}{/}static -ignore_env_name_mismatch = true +runner = ignore_env_name_mismatch deps = black flake8 @@ -74,3 +97,15 @@ _fine_, that's what plugins are for). * [tox multiple tests, re-using tox environment](https://stackoverflow.com/questions/57222212/tox-multiple-tests-re-using-tox-environment) [2019, StackOverflow] * [[tox-dev/tox#425] Ability to share tox environments within a project](https://github.com/tox-dev/tox/issues/425) [2016, Github] * [Tox tricks and patterns#Environment reuse](https://blog.ionelmc.ro/2015/04/14/tox-tricks-and-patterns/#environment-reuse) [2015, Blog] + +## Changelog + +### v0.2 - 2023-01-15 + +**[BREAKING]** [#3](https://github.com/masenf/tox-ignore-env-name-mismatch/issues/3) Rewrite plugin to use Public API + +To upgrade to v0.2, change `ignore_env_name_mismatch = true` to `runner = ignore_env_name_mismatch`. + +### v0.1 - 2023-01-14 + +Initial Release \ No newline at end of file diff --git a/examples/tox.ini b/examples/tox.ini index 6c00765..c225707 100644 --- a/examples/tox.ini +++ b/examples/tox.ini @@ -8,7 +8,7 @@ commands = pytest {posargs} [testenv:{lint,format,types}] env_dir = {toxworkdir}{/}static -ignore_env_name_mismatch = true +runner = ignore_env_name_mismatch deps = black flake8 diff --git a/src/tox_ignore_env_name_mismatch.py b/src/tox_ignore_env_name_mismatch.py index 12f042e..ae19a7b 100644 --- a/src/tox_ignore_env_name_mismatch.py +++ b/src/tox_ignore_env_name_mismatch.py @@ -1,4 +1,12 @@ -from tox.config.sets import EnvConfigSet +""" +https://github.com/masenf/tox-ignore-env-name-mismatch + +MIT License +Copyright (c) 2023 Masen Furer +""" +from contextlib import contextmanager +from typing import Any, Iterator, Optional, Sequence, Tuple + from tox.plugin import impl from tox.tox_env.api import ToxEnv from tox.tox_env.info import Info @@ -6,40 +14,64 @@ from tox.tox_env.register import ToxEnvRegister -IGNORE_ENV_NAME_MISMATCH_KEY = "ignore_env_name_mismatch" -IGNORE_ENV_NAME_MISMATCH_KEY_ALT = "ignore_envname_mismatch" +class FilteredInfo(Info): + """Subclass of Info that optionally filters specific keys during compare().""" + + def __init__( + self, + *args: Any, + filter_keys: Optional[Sequence[str]] = None, + filter_section: Optional[str] = None, + **kwargs: Any, + ): + """ + :param filter_keys: key names to pop from value + :param filter_section: if specified, only pop filter_keys when the compared section matches + + All other args and kwargs are passed to super().__init__ + """ + self.filter_keys = filter_keys + self.filter_section = filter_section + super().__init__(*args, **kwargs) + @contextmanager + def compare( + self, + value: Any, + section: str, + sub_section: Optional[str] = None, + ) -> Iterator[Tuple[bool, Optional[Any]]]: + """Perform comparison and update cached info after filtering `value`.""" + if self.filter_section is None or section == self.filter_section: + try: + value = value.copy() + except AttributeError: # pragma: no cover + pass + else: + for fkey in self.filter_keys or []: + value.pop(fkey, None) + with super().compare(value, section, sub_section) as rv: + yield rv -class ReusableVirtualEnvRunner(VirtualEnvRunner): - """EnvRunner that optionall ignores name mismatch.""" + +class IgnoreEnvNameMismatchVirtualEnvRunner(VirtualEnvRunner): + """EnvRunner that does NOT save the env name as part of the cached info.""" @staticmethod def id() -> str: - return "virtualenv-reusable" + return "ignore_env_name_mismatch" @property def cache(self) -> Info: - """Ignore changes in the "name" if env has `ignore_env_name_mismatch = true`.""" - info = super().cache - toxenv_info = info._content.get(ToxEnv.__name__, {}) - if self.conf[IGNORE_ENV_NAME_MISMATCH_KEY] and toxenv_info: - toxenv_info["name"] = self.conf.name - return info - - -@impl -def tox_add_env_config(env_conf: EnvConfigSet) -> None: - """tox4 entry point: add ignore_env_name_config env config.""" - env_conf.add_config( - keys=[IGNORE_ENV_NAME_MISMATCH_KEY, IGNORE_ENV_NAME_MISMATCH_KEY_ALT], - default=False, - of_type=bool, - desc="Do not recreate venv when the testenv name differs.", - ) + """Return a modified Info class that does NOT pass "name" key to `Info.compare`.""" + return FilteredInfo( + self.env_dir, + filter_keys=["name"], + filter_section=ToxEnv.__name__, + ) @impl def tox_register_tox_env(register: ToxEnvRegister) -> None: - """tox4 entry point: set ReuseVirtualEnvRunner as default_env_runner.""" - register.add_run_env(ReusableVirtualEnvRunner) - register.default_env_runner = ReusableVirtualEnvRunner.id() + """tox4 entry point: add IgnoreEnvNameMismatchVirtualEnvRunner to registry.""" + register.add_run_env(IgnoreEnvNameMismatchVirtualEnvRunner) diff --git a/tests/integration/test_end_to_end.py b/tests/integration/test_end_to_end.py index 073fab2..b1f1b44 100644 --- a/tests/integration/test_end_to_end.py +++ b/tests/integration/test_end_to_end.py @@ -3,8 +3,6 @@ import pytest -import tox_ignore_env_name_mismatch - pytest_plugins = ["pytester"] @@ -18,20 +16,12 @@ @pytest.mark.parametrize( "ignore_env_name_mismatch_spec, exp_reuse", [ - [f"{tox_ignore_env_name_mismatch.IGNORE_ENV_NAME_MISMATCH_KEY} = true", True], - [ - f"{tox_ignore_env_name_mismatch.IGNORE_ENV_NAME_MISMATCH_KEY_ALT} = true", - True, - ], - [f"{tox_ignore_env_name_mismatch.IGNORE_ENV_NAME_MISMATCH_KEY} = false", False], - [ - f"{tox_ignore_env_name_mismatch.IGNORE_ENV_NAME_MISMATCH_KEY_ALT} = false", - False, - ], + ["runner = ignore_env_name_mismatch", True], + ["", False], ], ) def test_testenv_reuse(pytester, monkeypatch, ignore_env_name_mismatch_spec, exp_reuse): - """Environment should not be recreated if ignore_env_name_mismatch is true.""" + """Environment should not be recreated if runner is ignore_env_name_mismatch.""" envlist = ["foo", "bar", "baz"] monkeypatch.delenv( "TOX_WORK_DIR", raising=False @@ -69,9 +59,10 @@ def test_testenv_no_reuse(pytester, monkeypatch): """ [tox] envlist = foo, bar + [testenv] env_dir = {toxworkdir}{/}shared - ignore_env_name_mismatch = true + runner = ignore_env_name_mismatch deps = wheel commands = %s diff --git a/tests/unit/test_ignore_env_name_mismatch.py b/tests/unit/test_ignore_env_name_mismatch.py index 3d43693..dfb25e3 100644 --- a/tests/unit/test_ignore_env_name_mismatch.py +++ b/tests/unit/test_ignore_env_name_mismatch.py @@ -1,8 +1,7 @@ +import json from unittest import mock from tox.tox_env.api import ToxEnv, ToxEnvCreateArgs -from tox.tox_env.info import Info -from tox.tox_env.python.virtual_env.runner import VirtualEnvRunner import pytest import tox_ignore_env_name_mismatch @@ -14,18 +13,7 @@ def test_tox_register_tox_env_mock(): register_mock.add_run_env.assert_called_once() assert ( register_mock.default_env_runner - == tox_ignore_env_name_mismatch.ReusableVirtualEnvRunner.id() - ) - - -def test_tox_add_env_config_mock(): - env_conf_mock = mock.Mock() - tox_ignore_env_name_mismatch.tox_add_env_config(env_conf_mock) - env_conf_mock.add_config.assert_called_once_with( - keys=["ignore_env_name_mismatch", "ignore_envname_mismatch"], - default=False, - of_type=bool, - desc="Do not recreate venv when the testenv name differs.", + != tox_ignore_env_name_mismatch.IgnoreEnvNameMismatchVirtualEnvRunner.id() ) @@ -34,60 +22,112 @@ def env_name(request): return request.param -@pytest.fixture(params=["foo", "bar"]) -def cached_env_name(request): - return request.param - - -@pytest.fixture(params=[True, False], ids=["ToxEnv-cache", "missing-cache"]) -def env_is_cached(request, tmp_path, monkeypatch, cached_env_name): - def mock_cache(*args, **kwargs): - info = Info(tmp_path) - info._content = ( - {ToxEnv.__name__: {"name": cached_env_name}} if request.param else {} - ) - return info - - monkeypatch.setattr(VirtualEnvRunner, "cache", property(mock_cache)) - return request.param - - -@pytest.fixture(params=[True, False], ids=["ignore_env_name_mismatch", "default"]) -def ignore_env_name_mismatch(request): - return request.param - - @pytest.fixture -def tox_env(env_name, ignore_env_name_mismatch, env_is_cached): +def tox_env(env_name, tmp_path): class ConfigMock(dict): name = env_name add_config = mock.Mock() add_constant = mock.Mock() cargs = ToxEnvCreateArgs( - conf=ConfigMock( - { - tox_ignore_env_name_mismatch.IGNORE_ENV_NAME_MISMATCH_KEY: ignore_env_name_mismatch - } - ), + conf=ConfigMock({"env_dir": tmp_path}), core=mock.MagicMock(), options=mock.Mock(), journal=None, log_handler=None, ) - return tox_ignore_env_name_mismatch.ReusableVirtualEnvRunner(cargs) + return tox_ignore_env_name_mismatch.IgnoreEnvNameMismatchVirtualEnvRunner(cargs) -def test_reusable_virtualenv_runner_cache( - env_name, tox_env, cached_env_name, env_is_cached, ignore_env_name_mismatch -): +def test_reusable_virtualenv_runner_cache(env_name, tox_env): info = tox_env.cache - env_name_from_info = info._content.get(ToxEnv.__name__, {}).get("name", None) - - if env_is_cached: - if ignore_env_name_mismatch: - assert env_name_from_info == env_name - else: - assert env_name_from_info == cached_env_name - else: - assert env_name_from_info is None + assert not info._content + with info.compare({"name": env_name}, ToxEnv.__name__) as (eq, old): + assert not eq + + assert info._content + assert not info._content[ToxEnv.__name__] + + +SECTION_BLANK = "" +SECTION_FOO = "foo" +TOP_LEVEL_KEY_BLANK = {"empty": None} +TOP_LEVEL_KEY_FOO = {"bar": None} + + +@pytest.fixture +def cached_info(tmp_path): + content = { + SECTION_BLANK: TOP_LEVEL_KEY_BLANK.copy(), + SECTION_FOO: TOP_LEVEL_KEY_FOO.copy(), + } + (tmp_path / ".tox-info.json").write_text(json.dumps(content)) + return content + + +@pytest.mark.usefixtures("cached_info") +@pytest.mark.parametrize( + ("filter_keys", "filter_section", "args", "exp_eq", "exp_old"), + [ + (None, None, ({}, SECTION_BLANK), False, TOP_LEVEL_KEY_BLANK), + (None, None, (TOP_LEVEL_KEY_BLANK, SECTION_BLANK), True, TOP_LEVEL_KEY_BLANK), + ( + None, + None, + ({"foo": "bar", **TOP_LEVEL_KEY_BLANK}, SECTION_BLANK), + False, + TOP_LEVEL_KEY_BLANK, + ), + ( + ["foo"], + None, + ({"foo": "bar", **TOP_LEVEL_KEY_BLANK}, SECTION_BLANK), + True, + TOP_LEVEL_KEY_BLANK, + ), + ( + ["foo"], + None, + ({"foo": "bar", **TOP_LEVEL_KEY_FOO}, SECTION_FOO), + True, + TOP_LEVEL_KEY_FOO, + ), + ( + ["foo"], + SECTION_FOO, + ({"foo": "bar", **TOP_LEVEL_KEY_BLANK}, SECTION_BLANK), + False, + TOP_LEVEL_KEY_BLANK, + ), + ( + ["foo"], + SECTION_FOO, + ({"foo": "bar", **TOP_LEVEL_KEY_FOO}, SECTION_FOO), + True, + TOP_LEVEL_KEY_FOO, + ), + ( + ["foo"], + SECTION_FOO, + (TOP_LEVEL_KEY_FOO, SECTION_FOO), + True, + TOP_LEVEL_KEY_FOO, + ), + ( + ["bar"], + SECTION_FOO, + (TOP_LEVEL_KEY_FOO, SECTION_FOO), + False, + TOP_LEVEL_KEY_FOO, + ), + ], +) +def test_filtered_info(tmp_path, filter_keys, filter_section, args, exp_eq, exp_old): + info = tox_ignore_env_name_mismatch.FilteredInfo( + tmp_path, + filter_keys=filter_keys, + filter_section=filter_section, + ) + with info.compare(*args) as (eq, old): + assert eq == exp_eq + assert old == exp_old