Skip to content

Commit

Permalink
Initial tox v4 support
Browse files Browse the repository at this point in the history
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
  • Loading branch information
ymyzk committed Sep 8, 2021
1 parent aea6ea5 commit 1d9df94
Show file tree
Hide file tree
Showing 5 changed files with 73 additions and 234 deletions.
9 changes: 3 additions & 6 deletions setup.cfg
Expand Up @@ -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

Expand Down Expand Up @@ -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 =
Expand All @@ -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}
Expand Down
125 changes: 70 additions & 55 deletions 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
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,
}


Expand All @@ -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():
Expand Down Expand Up @@ -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
Expand Down
16 changes: 0 additions & 16 deletions tests/integration/tox.ini

This file was deleted.

72 changes: 0 additions & 72 deletions tests/test_integration.py

This file was deleted.

85 changes: 0 additions & 85 deletions 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",
[
Expand Down Expand Up @@ -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

0 comments on commit 1d9df94

Please sign in to comment.