Skip to content

Commit 7be33b8

Browse files
authored
✨ feat(plugin): type the plugin and internal API surfaces (#4014)
1 parent 2e247dc commit 7be33b8

61 files changed

Lines changed: 694 additions & 258 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/changelog/4014.feature.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
The plugin and internal APIs now declare their types, and the code base passes ``mypy --strict`` and ``pyrefly`` next to
2+
``ty`` - by :user:`gaborjbernat`.
3+
4+
- :meth:`ConfigSet.get <tox.config.sets.ConfigSet.get>` returns a configuration value after checking it against its
5+
declared type, so plugins can drop ``cast`` around ``conf["key"]`` reads.
6+
- The :func:`tox_on_install <tox.plugin.spec.tox_on_install>` hook and :class:`Installer
7+
<tox.tox_env.installer.Installer>` type their ``arguments`` as :data:`InstallArguments
8+
<tox.tox_env.installer.InstallArguments>`; installer subclasses may narrow it through the new second type parameter.
9+
- :class:`Parsed <tox.config.cli.parser.Parsed>` declares the CLI option surface, so ``options.<name>`` reads
10+
type-check.
11+
- :meth:`ConfigSet.add_config <tox.config.sets.ConfigSet.add_config>` accepts ``default=None`` for optional values and
12+
infers the value type from callable defaults.
13+
- The journal, the environment cache, and execute metadata hold ``JsonValue`` content; structured ``set_env`` entries
14+
match the ``SetEnvEntry`` shape.

docs/conf.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@
7474
"packaging": ("https://packaging.pypa.io/en/latest", None),
7575
}
7676
nitpicky = True
77-
nitpick_ignore = []
77+
# a private stdlib class we cannot document, the stdlib itself has no public name for it
78+
nitpick_ignore = [("py:class", "argparse._ArgumentGroup")]
7879
linkcheck_workers = 10
7980
linkcheck_ignore = [
8081
re.escape(i)
@@ -160,7 +161,8 @@ def resolve_xref( # ruff:ignore[too-many-arguments]
160161
"tox.config.of_type.T": "typing.TypeVar", # used by Sphinx bases
161162
"tox.config.loader.api.T": "typing.TypeVar", # used by Sphinx bases
162163
"tox.config.loader.convert.T": "typing.TypeVar", # used by Sphinx bases
163-
"tox.tox_env.installer.T": "typing.TypeVar", # used by Sphinx bases
164+
"tox.tox_env.installer.EnvT_co": "typing.TypeVar", # used by Sphinx bases
165+
"tox.tox_env.installer.ArgsT": "typing.TypeVar", # used by Sphinx bases
164166
"pathlib._local.Path": "pathlib.Path",
165167
}
166168
if target in mapping:

docs/plugin/api.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,14 @@ execute
139139
installer
140140
=========
141141

142+
.. autodata:: tox.tox_env.installer.InstallArguments
143+
144+
.. autoclass:: tox.tox_env.python.pip.req.file.RequirementsFile
145+
146+
.. autoclass:: tox.tox_env.python.pip.req_file.PythonDeps
147+
148+
.. autoclass:: tox.tox_env.python.pylock.Pylock
149+
142150
.. autoclass:: tox.tox_env.installer.Installer
143151
:members:
144152

pyproject.toml

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ dependencies = [
6060
"python-discovery>=1.4.4",
6161
"tomli>=2.4; python_version<'3.11'",
6262
"tomli-w>=1.2",
63-
"typing-extensions>=4.15; python_version<'3.11'",
63+
"typing-extensions>=4.15; python_version<'3.13'",
6464
"virtualenv>=21.1",
6565
]
6666
optional-dependencies.completion = [
@@ -110,7 +110,10 @@ test = [
110110
"wheel>=0.46.3",
111111
]
112112
type = [
113+
"mypy>=2.3",
114+
"pyrefly>=1.2",
113115
"ty>=0.0.19",
116+
"types-colorama>=0.4.15",
114117
"types-docutils>=0.21",
115118
{ include-group = "docs" },
116119
{ include-group = "release" },
@@ -146,6 +149,7 @@ release = [
146149
]
147150
type-min = [
148151
"ty>=0.0.19",
152+
"types-colorama>=0.4.15",
149153
{ include-group = "test" },
150154
]
151155

@@ -204,6 +208,8 @@ lint.per-file-ignores."tests/**/*.py" = [
204208
lint.isort = { known-first-party = [
205209
"tox",
206210
"tests",
211+
], known-third-party = [
212+
"build", # the local build artifact directory must not sway the classification
207213
], required-imports = [
208214
"from __future__ import annotations",
209215
] }
@@ -219,6 +225,19 @@ count = true
219225
[tool.pyproject-fmt]
220226
max_supported_python = "3.15"
221227

228+
[tool.mypy]
229+
mypy_path = "typestubs"
230+
files = [ "src/tox", "tests/type_check" ]
231+
warn_redundant_casts = false # ty requires casts after callable() narrowing that mypy deems redundant
232+
strict = true
233+
# re_assert publishes no stubs or py.typed marker
234+
overrides = [ { module = [ "re_assert" ], ignore_missing_imports = true } ]
235+
236+
[tool.pyrefly]
237+
project-includes = [ "src/tox", "tests/type_check" ]
238+
python-platform = "linux" # ty --python-platform all covers the other platforms; keeps pyrefly output identical on every runner
239+
search-path = [ "src", "typestubs" ]
240+
222241
[tool.ty]
223242
src.exclude = []
224243
environment.extra-paths = [ "typestubs" ]

src/tox/config/cli/env_var.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44

55
import logging
66
import os
7-
from typing import Any
7+
from typing import TYPE_CHECKING, Any
88

99
from tox.config.loader.str_convert import StrConvert
1010

11+
if TYPE_CHECKING:
12+
from types import UnionType
13+
1114
CONVERT = StrConvert()
1215

1316

14-
def get_env_var(key: str, of_type: type[Any]) -> tuple[Any, str] | None:
17+
def get_env_var(key: str, of_type: type[Any] | UnionType) -> tuple[Any, str] | None:
1518
"""Get the environment variable option.
1619
1720
:param key: the config key requested

src/tox/config/cli/ini.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@
66
import os
77
from configparser import ConfigParser
88
from pathlib import Path
9-
from typing import Any, ClassVar
9+
from typing import TYPE_CHECKING, Any, ClassVar
1010

1111
from platformdirs import user_config_dir
1212

1313
from tox.config.loader.api import ConfigLoadArgs
1414
from tox.config.loader.ini import IniLoader
1515
from tox.config.source.ini_section import CORE
1616

17+
if TYPE_CHECKING:
18+
from types import UnionType
19+
1720
DEFAULT_CONFIG_FILE = Path(user_config_dir("tox")) / "config.ini"
1821

1922

@@ -25,7 +28,7 @@ def __init__(self) -> None:
2528
config_file = os.environ.get(self.TOX_CONFIG_FILE_ENV_VAR, None)
2629
self.is_env_var = config_file is not None
2730
self.config_file = Path(config_file if config_file is not None else DEFAULT_CONFIG_FILE)
28-
self._cache: dict[tuple[str, type[Any]], Any] = {}
31+
self._cache: dict[tuple[str, type[Any] | UnionType], Any] = {}
2932
self.has_config_file: bool | None = self.config_file.exists()
3033
self.ini: IniLoader | None = None
3134

@@ -45,7 +48,7 @@ def _parse_config_file(self) -> None:
4548
if self.has_tox_section:
4649
self.ini = IniLoader(CORE, parser, overrides=[], core_section=CORE)
4750

48-
def get(self, key: str, of_type: type[Any]) -> Any:
51+
def get(self, key: str, of_type: type[Any] | UnionType) -> Any:
4952
cache_key = key, of_type
5053
if cache_key in self._cache:
5154
result = self._cache[cache_key]
@@ -60,7 +63,7 @@ def get(self, key: str, of_type: type[Any]) -> Any:
6063
self._cache[cache_key] = result
6164
return result
6265

63-
def _load_key(self, key: str, of_type: type[Any]) -> Any:
66+
def _load_key(self, key: str, of_type: type[Any] | UnionType) -> Any:
6467
if self.ini is None: # pragma: no cover # this can only happen if we don't call __bool__ first
6568
return None
6669
args = ConfigLoadArgs(chain=[key], name=CORE.prefix, env_name=None)

0 commit comments

Comments
 (0)