Skip to content

Tox config parser doesn't properly validate nested complex types like EnvList #3898

@Daverball

Description

@Daverball

Issue

tox-gh registers its config value like this:

        self.add_config(
            "python",
            of_type=dict[str, EnvList],
            default={},
            desc="python version to mapping",
        )

this appears to work fine at first glance if you use a simple configuration, but as soon as you would like to use a product and possible a prefix, it no longer works, because the validate function handles extra types by simply calling their constructor, instead of using TomlLoader.to_env_list.

Environment

Provide at least:

  • OS: OpenSUSE Tumbleweed
Output of pip list of the host Python, where tox is installed
Using Python 3.14.3 environment at: venv                                        
Package            Version         Editable project location
------------------ --------------- -------------------------
annotated-types    0.7.0
ansible            13.4.0
ansible-core       2.20.4
anyio              4.13.0
bandit             1.9.4
bcrypt             5.0.0
bracex             2.6
bump-my-version    1.3.0
cachetools         7.0.5
certifi            2026.2.25
cffi               2.0.0
cfgv               3.5.0
charset-normalizer 3.4.6
click              8.3.1
colorama           0.4.6
coverage           7.13.5
cryptography       46.0.5
distlib            0.4.0
filelock           3.25.2
gitdb              4.0.12
gitpython          3.1.46
h11                0.16.0
httpcore           1.0.9
httpx              0.28.1
identify           2.6.18
idna               3.11
iniconfig          2.3.0
invoke             2.2.1
jinja2             3.1.6
librt              0.8.1
markdown-it-py     4.0.0
markupsafe         3.0.3
mdurl              0.1.2
mitogen            0.3.44
mypy               1.19.1
mypy-extensions    1.1.0
nodeenv            1.10.0
packaging          26.0
paramiko           4.0.0
pathspec           1.0.4
pip                25.3
platformdirs       4.9.4
pluggy             1.6.0
port-for           1.0.0
prek               0.3.8
prompt-toolkit     3.0.52
pycparser          3.0
pydantic           2.12.5
pydantic-core      2.41.5
pydantic-settings  2.13.1
pygments           2.19.2
pynacl             1.6.2
pyproject-api      1.10.0
pytest             9.0.2
pytest-codecov     0.7.0
pytest-cov         7.1.0
python-discovery   1.2.0
python-dotenv      1.2.2
pyyaml             6.0.3
questionary        2.1.1
requests           2.32.5
resolvelib         1.2.1
rich               14.3.3
rich-click         1.9.7
ruff               0.15.7
smmap              5.0.3
stevedore          5.7.0
suitable           0.22.0
tomli-w            1.2.0
tomlkit            0.14.0
tox                4.50.3
tox-gh             1.7.1
tox-uv             1.33.4
tox-uv-bare        1.33.4
types-paramiko     4.0.0.20260322
types-pyyaml       6.0.12.20250915
typing-extensions  4.15.0
typing-inspection  0.4.2
urllib3            2.6.3
uv                 0.11.1
virtualenv         21.2.0
wcmatch            10.1
wcwidth            0.6.0

Output of running tox

Output of tox -rvv
ROOT: 241 W running tox-gh [tox_gh/plugin.py:87]                                
Traceback (most recent call last):
  File "/home/david/git/suitable/venv/lib64/python3.14/site-packages/tox/config/loader/toml/_validate.py", line 67, in validate
    of_type(val)
    ~~~~~~~^^^^^
  File "/home/david/git/suitable/venv/lib64/python3.14/site-packages/tox/config/types.py", line 70, in __init__
    self.envs = list(OrderedDict((e, None) for e in envs).keys())
                     ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: unhashable type: 'dict'

ROOT: 242 E HandledError| failed to load core.python: [{'product': [['py311'], ['ansible9', 'ansible10', 'ansible11', 'ansible12']]}] is not of type 'EnvList' [tox/run.py:26]

Minimal example

Put something like the following in a tox.toml and try to run tox with tox-gh and GITHUB_ACTIONS=true

[gh.python]
"3.12" = [
    { product = [["py312"], { prefix = "ansible", start = 9, stop = 13 }] },
    "ruff",
    "bandit",
    "mypy"
]

Suggested solution

Since Convert._to_typing calls self.to again for each key and value, I think the easiest thing to do is to just remove the recursive validation, since you end up validating each key and value twice, once before conversion and once after conversion. Which doesn't really make a ton of sense.

So this

    elif casting_to in {dict, dict}:
        key_type, value_type = type_args[0], type_args[1]
        if isinstance(val, dict):
            for va in val:
                validate(va, key_type)
            for va in val.values():
                validate(va, value_type)
        else:
            msg = f"{val!r} is not dictionary"

Alternatively you can do the workaround that's currently in place for Command and validate EnvList

should simplify to this:

    elif casting_to in {dict, dict}:
        key_type, value_type = type_args[0], type_args[1]
        if not isinstance(val, dict):
            msg = f"{val!r} is not dictionary"

The same simplfication can be applied to the branch for lists. Alternatively you can do something similar like with Command and add a new branch to validate that skips deep validation for EnvList, since to_env_list already performs its own validation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bug:normalaffects many people or has quite an impact

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions