From 198612d2629562bd4aceff9d00591ae66a6cec9c Mon Sep 17 00:00:00 2001 From: Yusuke Miyazaki Date: Mon, 6 Sep 2021 00:51:17 +0900 Subject: [PATCH] Initial tox v4 support The first commit to support tox v4. Implemented: - Basic functionality to filter out envlist based on Python version and environment variables - Basic testing Not implemented: - Integration tests - Grouping log lines on GitHub Actions - Documentation Limitation: - Environment variables must be uppercase --- setup.cfg | 11 ++- src/tox_gh_actions/plugin.py | 125 ++++++++++++++++++++--------------- tests/integration/tox.ini | 16 ----- tests/test_integration.py | 72 -------------------- tests/test_plugin.py | 85 ------------------------ 5 files changed, 74 insertions(+), 235 deletions(-) delete mode 100644 tests/integration/tox.ini delete mode 100644 tests/test_integration.py diff --git a/setup.cfg b/setup.cfg index d77fe25..b673bb9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -40,7 +40,7 @@ package_dir = zip_safe = True python_requires = >=3.6 install_requires = - tox >=3.12, <4 + tox >= 4.0.0a8, <5 setup_requires = setuptools_scm[toml] >=6, <7 @@ -76,7 +76,7 @@ envlist = black flake8 mypy - {py36,py37,py38,py39,pypy2,pypy3}-tox{312,315,latest} + {py36,py37,py38,py39,pypy2,pypy3}-toxlatest [gh-actions] python = @@ -89,11 +89,8 @@ python = [testenv] description = run test suite under {basepython} -deps = - tox312: tox>=3.12,<3.13 - tox315: tox>=3.15,<3.16 extras = testing -commands = pytest --cov=tox_gh_actions --cov-branch --cov-report=term --cov-report=xml {posargs} +commands = pytest --cov=tox_gh_actions --cov-branch --cov-report=term --cov-report=xml tests/ {posargs} [testenv:black] description = run black with check-only under {basepython} @@ -107,7 +104,7 @@ extras = testing [testenv:mypy] description = run mypy under {basepython} -commands = flake8 src/ tests/ setup.py +commands = mypy src/ tests/ extras = testing [flake8] diff --git a/src/tox_gh_actions/plugin.py b/src/tox_gh_actions/plugin.py index db6712f..eaf5863 100644 --- a/src/tox_gh_actions/plugin.py +++ b/src/tox_gh_actions/plugin.py @@ -1,79 +1,79 @@ from itertools import product + +from logging import getLogger import os import sys from typing import Any, Dict, Iterable, List -import pluggy -from tox.config import Config, TestenvConfig, _split_env as split_env -from tox.reporter import verbosity1, verbosity2 -from tox.venv import VirtualEnv - +from tox.config.loader.memory import MemoryLoader +from tox.config.loader.str_convert import StrConvert +from tox.config.main import Config +from tox.config.of_type import _PLACE_HOLDER +from tox.config.sets import ConfigSet +from tox.config.types import EnvList +from tox.plugin import impl -hookimpl = pluggy.HookimplMarker("tox") +logger = getLogger(__name__) -@hookimpl +@impl def tox_configure(config: Config) -> None: - verbosity1("running tox-gh-actions") + logger.info("running tox-gh-actions") if not is_running_on_actions(): - verbosity1( - "tox-gh-actions won't override envlist " - "because tox is not running in GitHub Actions" + logger.warning( + "tox-gh-actions won't override envlist because tox is not running " + "in GitHub Actions" ) return elif is_env_specified(config): - verbosity1( + logger.warning( "tox-gh-actions won't override envlist because " "envlist is explicitly given via TOXENV or -e option" ) - return - verbosity2("original envconfigs: {}".format(list(config.envconfigs.keys()))) - verbosity2("original envlist_default: {}".format(config.envlist_default)) - verbosity2("original envlist: {}".format(config.envlist)) + original_envlist: EnvList = config.core["envlist"] + # TODO We need to expire cache explicitly otherwise + # the overridden envlist won't be read at all + config.core._defined["envlist"]._cache = _PLACE_HOLDER # type: ignore + logger.debug("original envlist: %s", original_envlist.envs) versions = get_python_version_keys() - verbosity2("Python versions: {}".format(versions)) + logger.debug("Python versions: {}".format(versions)) - gh_actions_config = parse_config(config._cfg.sections) - verbosity2("tox-gh-actions config: {}".format(gh_actions_config)) + gh_actions_config = load_config(config) + logger.debug("tox-gh-actions config: %s", gh_actions_config) factors = get_factors(gh_actions_config, versions) - verbosity2("using the following factors to decide envlist: {}".format(factors)) - - envlist = get_envlist_from_factors(config.envlist, factors) - config.envlist_default = config.envlist = envlist - verbosity1("overriding envlist with: {}".format(envlist)) - - -@hookimpl -def tox_runtest_pre(venv: VirtualEnv) -> None: - if is_running_on_actions(): - envconfig: TestenvConfig = venv.envconfig - message = envconfig.envname - if envconfig.description: - message += " - " + envconfig.description - print("::group::tox: " + message) - - -@hookimpl -def tox_runtest_post(venv: VirtualEnv) -> None: - if is_running_on_actions(): - print("::endgroup::") - - -def parse_config(config: Dict[str, Dict[str, str]]) -> Dict[str, Dict[str, Any]]: - """Parse gh-actions section in tox.ini""" - config_python = parse_dict(config.get("gh-actions", {}).get("python", "")) - config_env = { - name: {k: split_env(v) for k, v in parse_dict(conf).items()} - for name, conf in config.get("gh-actions:env", {}).items() - } - # Example of split_env: - # "py{27,38}" => ["py27", "py38"] + logger.debug("using the following factors to decide envlist: %s", factors) + + envlist = get_envlist_from_factors(original_envlist.envs, factors) + config.core.loaders.insert(0, MemoryLoader(env_list=EnvList(envlist))) + logger.info("overriding envlist with: %s", envlist) + + +def load_config(config: Config) -> Dict[str, Dict[str, Any]]: + # It's better to utilize ConfigSet to parse gh-actions configuration but + # we use our custom configuration parser at this point for compatibility with + # the existing config files and limitations in ConfigSet API. + python_config = {} + for loader in config.get_section_config("gh-actions", ConfigSet).loaders: + if "python" not in loader.found_keys(): + continue + python_config = parse_factors_dict(loader.load_raw("python", None, None)) + + env = {} + for loader in config.get_section_config("gh-actions:env", ConfigSet).loaders: + for env_variable in loader.found_keys(): + if env_variable.upper() in env: + continue + env[env_variable.upper()] = parse_factors_dict( + loader.load_raw(env_variable, None, None) + ) + + # TODO Use more precise type return { - "python": {k: split_env(v) for k, v in config_python.items()}, - "env": config_env, + "python": python_config, + "env": env, } @@ -84,7 +84,7 @@ def get_factors( factors: List[List[str]] = [] for version in versions: if version in gh_actions_config["python"]: - verbosity2("got factors for Python version: {}".format(version)) + logger.debug("got factors for Python version: %s", version) factors.append(gh_actions_config["python"][version]) break # Shouldn't check remaining versions for env, env_config in gh_actions_config.get("env", {}).items(): @@ -146,12 +146,27 @@ def is_env_specified(config: Config) -> bool: if os.environ.get("TOXENV"): # When TOXENV is a non-empty string return True - elif config.option.env is not None: + elif hasattr(config.options, "env") and not config.options.env.use_default_list: # When command line argument (-e) is given return True return False +def parse_factors_dict(value: str) -> Dict[str, List[str]]: + """Parse a dict value from key to factors. + + For example, this function converts an input + 3.8: py38, docs + 3.9: py39-django{2,3} + to a dict + { + "3.8": ["py38", "docs"], + "3.9": ["py39-django2", "py39-django3"], + } + """ + return {k: StrConvert.to_env_list(v).envs for k, v in parse_dict(value).items()} + + # The following function was copied from # https://github.com/tox-dev/tox-travis/blob/0.12/src/tox_travis/utils.py#L11-L32 # which is licensed under MIT LICENSE diff --git a/tests/integration/tox.ini b/tests/integration/tox.ini deleted file mode 100644 index b4025c4..0000000 --- a/tests/integration/tox.ini +++ /dev/null @@ -1,16 +0,0 @@ -[tox] -envlist = py38, py39, unused -skipsdist = True - -[gh-actions] -python = - 3.8: py38 - 3.9: py39 - -[testenv] -allowlist_externals = - bash - mkdir -commands = - mkdir -p out - bash -c 'touch out/$TOX_ENV_NAME' diff --git a/tests/test_integration.py b/tests/test_integration.py deleted file mode 100644 index 3ec7e7e..0000000 --- a/tests/test_integration.py +++ /dev/null @@ -1,72 +0,0 @@ -from collections import defaultdict -import os -import shutil -import subprocess -import sys - -import pytest - - -INT_TEST_DIR = os.path.join(os.path.dirname(__file__), "integration") - - -@pytest.fixture(autouse=True) -def precondition(): - # Clean up files from previous integration tests - shutil.rmtree(os.path.join(INT_TEST_DIR, ".tox"), ignore_errors=True) - shutil.rmtree(os.path.join(INT_TEST_DIR, "out"), ignore_errors=True) - - # Make sure tox and tox-gh-actions are installed - stdout = run_tox(["--version"]) - assert "tox-gh-actions" in stdout.decode("utf-8") - - -@pytest.mark.integration -def test_integration(): - expected_envs_map = defaultdict( - list, - [ - ((3, 8), ["py38"]), - ((3, 9), ["py39"]), - ], - ) - python_version = sys.version_info[:2] - # TODO Support non CPython implementation - if "PyPy" not in sys.version: - expected_envs = expected_envs_map[python_version] - else: - expected_envs = [] - - stdout = run_tox() - - assert_envs_executed(INT_TEST_DIR, expected_envs) - - # Make sure to support both POSIX and Windows - stdout_lines = stdout.decode("utf-8").splitlines() - # TODO Assert ordering - for expected_env in expected_envs: - assert "::group::tox: " + expected_env in stdout_lines - if len(expected_envs) > 0: - assert "::endgroup::" in stdout_lines - - -def assert_envs_executed(root_dir, envs): - out_dir = os.path.join(root_dir, "out") - if os.path.isdir(out_dir): - out_envs = set(os.listdir(out_dir)) - else: - out_envs = set() - expected_envs = set(envs) - assert out_envs == expected_envs - - -def run_tox(args=None): - if args is None: - args = [] - env = os.environ.copy() - env["GITHUB_ACTIONS"] = "true" - return subprocess.check_output( - [sys.executable, "-m", "tox"] + args, - cwd=INT_TEST_DIR, - env=env, - ) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index b1796bb..cca2c68 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,75 +1,8 @@ import pytest -from tox.config import Config from tox_gh_actions import plugin -@pytest.mark.parametrize( - "config,expected", - [ - ( - { - "gh-actions": { - "python": """3.7: py37 -3.8: py38 -3.9: py39, flake8""" - } - }, - { - "python": { - "3.7": ["py37"], - "3.8": ["py38"], - "3.9": ["py39", "flake8"], - }, - "env": {}, - }, - ), - ( - { - "gh-actions": { - "python": """3.7: py37 -3.8: py38""" - }, - "gh-actions:env": { - "PLATFORM": """ubuntu-latest: linux -macos-latest: macos -windows-latest: windows""" - }, - }, - { - "python": { - "3.7": ["py37"], - "3.8": ["py38"], - }, - "env": { - "PLATFORM": { - "ubuntu-latest": ["linux"], - "macos-latest": ["macos"], - "windows-latest": ["windows"], - }, - }, - }, - ), - ( - {"gh-actions": {}}, - { - "python": {}, - "env": {}, - }, - ), - ( - {}, - { - "python": {}, - "env": {}, - }, - ), - ], -) -def test_parse_config(config, expected): - assert plugin.parse_config(config) == expected - - @pytest.mark.parametrize( "config,version,environ,expected", [ @@ -353,21 +286,3 @@ def test_get_version_keys_on_pyston(mocker): def test_is_running_on_actions(mocker, environ, expected): mocker.patch("tox_gh_actions.plugin.os.environ", environ) assert plugin.is_running_on_actions() == expected - - -@pytest.mark.parametrize( - "option_env,environ,expected", - [ - (None, {"TOXENV": "flake8"}, True), - (["py37,py38"], {}, True), - (["py37", "py38"], {}, True), - (["py37"], {"TOXENV": "flake8"}, True), - (None, {}, False), - ], -) -def test_is_env_specified(mocker, option_env, environ, expected): - mocker.patch("tox_gh_actions.plugin.os.environ", environ) - option = mocker.MagicMock() - option.env = option_env - config = Config(None, option, None, mocker.MagicMock(), []) - assert plugin.is_env_specified(config) == expected