from __future__ import annotations
import configparser
import logging
import os
import re
import sys
import sysconfig
import textwrap
import tomllib
from dataclasses import dataclass
from typing import TYPE_CHECKING
from nur.core.models import Task
if TYPE_CHECKING:
from collections.abc import Iterable
from pathlib import Path
__all__ = ["ToxProvider"]
log = logging.getLogger("nur")
_CONFIG_NAMES = ("tox.ini", "setup.cfg", "pyproject.toml", "tox.toml")
_RANGE = re.compile(r"^(\d*)-(\d*)$")
_PROVISIONING_ENVS = frozenset({".pkg", ".tox"})
_KNOWN_ARCHITECTURES = frozenset({
"arm64",
"loongarch64",
"ppc",
"ppc64",
"ppc64le",
"riscv64",
"s390x",
"sparc64",
"x86",
"x86_64",
})
_PYTHON_MINOR_MIN = 10
_PYTHON_MINOR_MAX = 14
_FACTOR_LINE = re.compile(r"^([a-zA-Z0-9_!{},.-]+):\s+(.*)$")
_MISSING = object()
@dataclass(frozen=True, slots=True)
class _Config:
"""Holds parsed tox configuration details and source metadata."""
path: Path
kind: str
data: object
def _split_envlist(value: str) -> list[str]:
"""Split an INI envlist without splitting commas inside braces."""
items: list[str] = []
current: list[str] = []
depth = 0
for char in value:
if char == "{":
depth += 1
elif char == "}" and depth:
depth -= 1
if (char == "," or char.isspace()) and not depth:
item = "".join(current).strip()
if item:
items.append(item)
current = []
else:
current.append(char)
item = "".join(current).strip()
if item:
items.append(item)
return items
def _resolve_substitution(
contents: str, parser: configparser.ConfigParser | None
) -> str | None:
"""Resolve an env or section reference substitution inside braces."""
if contents.startswith("env:"):
var_name, has_default, default_val = contents[4:].partition(":")
var_name = var_name.strip()
env_val = os.environ.get(var_name)
if env_val is not None:
return env_val
return default_val if has_default else None
if contents.startswith("[") and "]" in contents:
sec, _, key = contents[1:].partition("]")
sec, key = sec.strip(), key.strip()
if parser is not None and parser.has_section(sec) and key in parser[sec]:
return parser.get(sec, key)
return None
def _find_matching_brace(value: str, start: int) -> int:
"""Find the index of the closing brace matching the opening brace at start."""
depth = 0
for i in range(start, len(value)):
if value[i] == "{":
depth += 1
elif value[i] == "}":
depth -= 1
if depth == 0:
return i
return -1
def _expand_brace_options(contents: str) -> list[str]:
"""Expand numeric ranges or comma-separated alternatives inside braces."""
match = _RANGE.fullmatch(contents)
if match and any(match.groups()):
first_text, last_text = match.groups()
first = int(first_text) if first_text else _PYTHON_MINOR_MIN
last = int(last_text) if last_text else _PYTHON_MINOR_MAX
width = (
max(len(first_text), len(last_text))
if first_text.startswith("0") or last_text.startswith("0")
else 0
)
step = 1 if first <= last else -1
return [
str(number).zfill(width) if width else str(number)
for number in range(first, last + step, step)
]
if "," in contents:
return [option.strip() for option in contents.split(",")]
return [option.strip() for option in _split_envlist(contents)]
def _expand_env_name(
value: str, parser: configparser.ConfigParser | None = None
) -> list[str]:
"""Expand tox's simple brace alternatives, numeric ranges, and substitutions."""
start = value.find("{")
if start < 0:
return [value]
end = _find_matching_brace(value, start)
if end < 0:
return [value]
contents = value[start + 1 : end]
if contents.startswith("env:") or (contents.startswith("[") and "]" in contents):
resolved = _resolve_substitution(contents, parser)
if not resolved or not resolved.strip():
return []
return [
name
for item in _split_envlist(resolved)
for name in _expand_env_name(
value[:start] + item + value[end + 1 :], parser
)
]
expanded: list[str] = []
for option in _expand_brace_options(contents):
prefix = value[:start]
suffix = value[end + 1 :]
whole_factor = (not prefix or prefix.endswith("-")) and (
not suffix or suffix.startswith("-")
)
if not option and whole_factor:
if prefix.endswith("-"):
prefix = prefix[:-1]
elif suffix.startswith("-"):
suffix = suffix[1:]
expanded.extend(_expand_env_name(prefix + option + suffix, parser))
return expanded
def _expand_simple_factors(expr: str) -> list[str]:
"""Expand simple comma and brace groups in factor conditions."""
start = expr.find("{")
if start >= 0:
end = _find_matching_brace(expr, start)
if end >= 0:
opts = [o.strip() for o in expr[start + 1 : end].split(",")]
res: list[str] = []
for o in opts:
res.extend(_expand_simple_factors(expr[:start] + o + expr[end + 1 :]))
return res
return [o.strip() for o in expr.split(",")]
def _factor_group_matches(group: str, env_factors: set[str]) -> bool:
"""Determine whether all conditions in a factor group match the environment."""
factors = [f.strip() for f in group.split("-") if f.strip()]
for factor in factors:
if factor.startswith("!"):
if factor[1:] in env_factors:
return False
elif factor not in env_factors:
return False
return True
def _has_balanced_braces(value: str) -> bool:
"""Return whether braces are balanced and correctly ordered."""
depth = 0
for char in value:
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth < 0:
return False
return depth == 0
def _normalize_isa(value: str) -> str:
"""Normalize common machine architecture aliases to tox's factor names."""
normalized = value.lower()
return {
"amd64": "x86_64",
"aarch64": "arm64",
"i386": "x86",
"i486": "x86",
"i586": "x86",
"i686": "x86",
"powerpc": "ppc",
"powerpc64": "ppc64",
"powerpc64le": "ppc64le",
"sparcv9": "sparc64",
}.get(normalized, normalized)
def _environment_factors(env_name: str) -> set[str]:
"""Return explicit and host-specific factors available to tox."""
factors = set(env_name.split("-"))
current = {*factors, sys.platform}
platform_parts = sysconfig.get_platform().rsplit("-", 1)
if len(platform_parts) > 1:
machine_isa = _normalize_isa(platform_parts[-1])
if not factors.intersection(_KNOWN_ARCHITECTURES) and not any(
_normalize_isa(factor) == machine_isa for factor in factors
):
current.add(machine_isa)
return current
def _command_lines(value: str) -> list[str]:
"""Return non-empty command lines with backslash continuations coalesced."""
lines: list[str] = []
continued = ""
for raw_line in value.splitlines():
line = raw_line.strip()
if not line:
continue
continued = f"{continued} {line}".strip()
if continued.endswith("\\"):
continued = continued[:-1].rstrip()
else:
lines.append(continued)
continued = ""
if continued:
lines.append(continued)
return lines
def _filter_ini_commands(value: object, env_name: str) -> str:
"""Filter INI command lines matching the current environment's factors."""
if not isinstance(value, str):
return ""
env_factors = _environment_factors(env_name)
kept: list[str] = []
for line in _command_lines(value):
match = _FACTOR_LINE.match(line)
if not match:
kept.append(line)
continue
factor_expr, cmd = match.groups()
if not _has_balanced_braces(factor_expr):
kept.append(line)
continue
if (
any(
_factor_group_matches(grp, env_factors)
for grp in _expand_simple_factors(factor_expr)
)
and cmd.strip()
):
kept.append(cmd.strip())
return "\n".join(kept)
def _command_argument(value: object) -> str: # pylint: disable=too-many-return-statements # noqa: PLR0911
"""Render a TOML command argument in tox's familiar INI notation."""
if not isinstance(value, dict):
return str(value)
replacement = value.get("replace")
default = value.get("default")
default_text = (
" ".join(_command_argument(argument) for argument in default)
if isinstance(default, list)
else _command_argument(default)
if default is not None
else ""
)
if replacement == "posargs":
return f"{{posargs:{default_text}}}" if default_text else "{posargs}"
if replacement == "glob":
pattern = value.get("pattern")
suffix = f":{default_text}" if default_text else ""
return f"{{glob:{pattern}{suffix}}}"
if replacement == "env":
name = value.get("name")
suffix = f":{default_text}" if default_text else ""
return f"{{env:{name}{suffix}}}"
if replacement == "ref":
if isinstance(value.get("env"), str) and isinstance(value.get("key"), str):
environment = value["env"]
key = value["key"]
return f"{{[{environment}]{key}}}"
source = value.get("of")
if isinstance(source, list):
return "{ref:" + ".".join(str(part) for part in source) + "}"
return "{" + str(replacement or "...") + "}"
def _command_strings(command: object) -> list[str]:
"""Convert a single command structure into rendered command string segments."""
if isinstance(command, str):
return [command]
if isinstance(command, list):
return [" ".join(_command_argument(argument) for argument in command)]
if not isinstance(command, dict):
return []
default = command.get("default")
if command.get("replace") != "posargs" or not isinstance(default, list):
return [_command_argument(command)]
return [
" ".join(_command_argument(argument) for argument in default_command)
if isinstance(default_command, list)
else default_command
for default_command in default
if isinstance(default_command, (list, str))
]
def _command_definition(value: object) -> str:
"""Render tox's command forms without trying to evaluate substitutions."""
if isinstance(value, str):
return " && ".join(_command_lines(value))
if not isinstance(value, list):
return ""
return " && ".join(
rendered
for command in value
for rendered in _command_strings(command)
if rendered
)
def _render_command_groups(*groups: object) -> str:
"""Render and combine pre-, main, and post-command groups in order."""
rendered = [_command_definition(group) for group in groups]
return " && ".join(part for part in rendered if part)
def _toml_tox_table(path: Path) -> object:
"""Parse and return the tox configuration table from a TOML file."""
try:
data = tomllib.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc:
log.warning("nur: skipping %s (%s)", path.name, exc)
return _MISSING
if path.name == "pyproject.toml":
tool = data.get("tool")
tox = tool.get("tox") if isinstance(tool, dict) else None
else:
tox = data
return tox if isinstance(tox, dict) else None
def _ini_config(path: Path) -> configparser.ConfigParser | None:
"""Parse and return a ConfigParser instance from an INI file."""
parser = configparser.ConfigParser(interpolation=None)
try:
with path.open(encoding="utf-8") as stream:
parser.read_file(stream)
except (OSError, UnicodeDecodeError, configparser.Error) as exc:
log.warning("nur: skipping %s (%s)", path.name, exc)
return None
return parser
def _find_config(cwd: Path) -> _Config | None:
"""Locate and load the first matching tox configuration in discovery order."""
for name in _CONFIG_NAMES:
path = cwd / name
if not path.is_file():
continue
if name in {"tox.ini", "setup.cfg"}:
parser = _ini_config(path)
if parser is None:
return None
if name == "setup.cfg" and not parser.has_section("tox:tox"):
continue
return _Config(path, "ini", parser)
table = _toml_tox_table(path)
if table is _MISSING:
break
if isinstance(table, dict):
if name == "pyproject.toml" and isinstance(
table.get("legacy_tox_ini"), str
):
parser = configparser.ConfigParser(interpolation=None)
try:
parser.read_string(textwrap.dedent(table["legacy_tox_ini"]))
except configparser.Error as exc:
log.warning("nur: skipping %s (%s)", path.name, exc)
return None
return _Config(path, "ini", parser)
return _Config(path, "toml", table)
return None
def _configured_internal_envs_ini(
parser: configparser.ConfigParser, tox_section: str
) -> set[str]:
"""Collect default and configured internal tox environment names from INI."""
internal = set(_PROVISIONING_ENVS)
if parser.has_section(tox_section):
sec = parser[tox_section]
for key in (
"provision_tox_env",
"package_env",
"isolated_build_env",
"wheel_build_env",
):
val = sec.get(key)
if val and val.strip():
internal.add(val.strip())
for s_name in parser.sections():
if s_name != "testenv" and not s_name.startswith("testenv:"):
continue
s = parser[s_name]
for key in ("package_env", "isolated_build_env", "wheel_build_env"):
val = s.get(key)
if val and val.strip():
internal.add(val.strip())
return internal
def _collect_envs_from_mapping(
mapping: dict[str, object], keys: tuple[str, ...]
) -> list[str]:
"""Extract trimmed environment names from mapping keys."""
names: list[str] = []
for key in keys:
val = mapping.get(key)
if isinstance(val, str) and val.strip():
names.append(val.strip())
return names
def _configured_internal_envs_toml(tox: dict[str, object]) -> set[str]:
"""Collect default and configured internal tox environment names from TOML."""
internal = set(_PROVISIONING_ENVS)
keys = ("provision_tox_env", "package_env", "isolated_build_env", "wheel_build_env")
internal.update(_collect_envs_from_mapping(tox, keys))
base = tox.get("env_run_base")
if isinstance(base, dict):
internal.update(_collect_envs_from_mapping(base, keys[1:]))
envs = tox.get("env")
if isinstance(envs, dict):
for sec in envs.values():
if isinstance(sec, dict):
internal.update(_collect_envs_from_mapping(sec, keys[1:]))
return internal
def _without_provisioning(
names: Iterable[str], internal_envs: set[str] | frozenset[str] = _PROVISIONING_ENVS
) -> list[str]:
"""Remove tox's internal packaging/provisioning environments."""
return list(
dict.fromkeys(name for name in names if name and name not in internal_envs)
)
def _ini_label_envs(parser: configparser.ConfigParser, tox_section: str) -> list[str]:
"""Collect environment names referenced by INI labels."""
if not parser.has_section(tox_section) or "labels" not in parser[tox_section]:
return []
names: list[str] = []
for line in parser[tox_section]["labels"].splitlines():
_, separator, members = line.partition("=")
if separator:
names.extend(
name
for member in _split_envlist(members)
for name in _expand_env_name(member, parser)
)
return names
def _resolve_ini_setting(
section: configparser.SectionProxy | None,
key: str,
env_name: str,
parser: configparser.ConfigParser,
seen: set[str],
) -> str | None:
"""Recursively resolve a setting through an INI section's declared base chain."""
if section is not None and key in section:
return section[key]
sec_name = section.name if section is not None else ""
seen.add(sec_name)
if section is not None and "base" in section:
base_val = _filter_ini_commands(section["base"], env_name).strip()
bases = (
[
name
for item in _split_envlist(base_val)
for name in _expand_env_name(item, parser)
]
if base_val
else []
)
elif sec_name != "testenv" and parser.has_section("testenv"):
bases = ["testenv"]
else:
bases = []
for base_name in bases:
if base_name in seen:
continue
base_sec = (
parser[base_name]
if parser.has_section(base_name)
else parser[f"testenv:{base_name}"]
if parser.has_section(f"testenv:{base_name}")
else None
)
if base_sec is not None:
val = _resolve_ini_setting(base_sec, key, env_name, parser, seen)
if val is not None:
return val
return None
def _resolve_toml_setting(
sec_name: str,
sec_data: dict[str, object],
key: str,
tox: dict[str, object],
seen: set[str],
) -> object:
"""Recursively resolve a setting through an environment's TOML base chain."""
if key in sec_data:
return sec_data[key]
seen.add(sec_name)
if "base" in sec_data:
raw_base = sec_data["base"]
if isinstance(raw_base, str):
bases = [raw_base]
elif isinstance(raw_base, list):
bases = [b for b in raw_base if isinstance(b, str)]
else:
bases = []
elif sec_name != "env_run_base":
bases = ["env_run_base"]
else:
bases = []
envs = tox.get("env")
for base_name in bases:
if base_name in seen:
continue
base_data = (
tox.get("env_run_base")
if base_name == "env_run_base"
else envs.get(base_name)
if isinstance(envs, dict) and base_name in envs
else tox.get(base_name)
)
if isinstance(base_data, dict):
val = _resolve_toml_setting(base_name, base_data, key, tox, seen)
if val is not _MISSING:
return val
return _MISSING
def _toml_env_setting(
name: str, section: dict[str, object], key: str, tox: dict[str, object]
) -> object | None:
"""Look up a TOML setting for an environment, falling back to its base chain."""
val = _resolve_toml_setting(name, section, key, tox, set())
return val if val is not _MISSING else None
def _toml_env_list(value: object) -> list[str]:
"""Resolve string entries and environment replacements in a TOML env list."""
if not isinstance(value, list):
return []
names: list[str] = []
for item in value:
if isinstance(item, str):
names.append(item)
continue
if not isinstance(item, dict) or item.get("replace") != "env":
continue
env_value = os.environ.get(str(item.get("name")))
replacement = env_value if env_value is not None else item.get("default")
if isinstance(replacement, str):
names.extend(_split_envlist(replacement))
elif isinstance(replacement, list):
names.extend(name for name in replacement if isinstance(name, str))
return names
def _ini_tasks(config: _Config) -> list[Task]:
"""Build tasks from an INI-based tox configuration file."""
parser = config.data
if not isinstance(parser, configparser.ConfigParser):
return []
tox_section = "tox:tox" if config.path.name == "setup.cfg" else "tox"
has_envlist = parser.has_section(tox_section) and (
"envlist" in parser[tox_section] or "env_list" in parser[tox_section]
)
if has_envlist:
envlist = parser.get(
tox_section,
"envlist",
fallback=parser.get(tox_section, "env_list", fallback=""),
)
names = [
name
for item in _split_envlist(envlist)
for name in _expand_env_name(item, parser)
]
else:
names = ["py"]
names.extend(_ini_label_envs(parser, tox_section))
generative_sections: dict[str, configparser.SectionProxy] = {}
explicit: list[str] = []
for section_name in parser.sections():
if not section_name.startswith("testenv:"):
continue
for name in _expand_env_name(section_name.removeprefix("testenv:"), parser):
explicit.append(name)
generative_sections.setdefault(name, parser[section_name])
internal_envs = _configured_internal_envs_ini(parser, tox_section)
names = _without_provisioning([*names, *explicit], internal_envs)
tasks: list[Task] = []
for name in names:
exact_section = f"testenv:{name}"
section = (
parser[exact_section]
if parser.has_section(exact_section)
else generative_sections.get(name)
)
description = _resolve_ini_setting(section, "description", name, parser, set())
commands_pre = _resolve_ini_setting(
section, "commands_pre", name, parser, set()
)
commands = _resolve_ini_setting(section, "commands", name, parser, set())
commands_post = _resolve_ini_setting(
section, "commands_post", name, parser, set()
)
tasks.append(
Task(
name=name,
prefix="tox",
argv_base=("tox", "-e", name),
passthrough_prefix=("--",),
description=(
_filter_ini_commands(description, name)
if description is not None
else None
),
definition=_render_command_groups(
_filter_ini_commands(commands_pre, name),
_filter_ini_commands(commands, name),
_filter_ini_commands(commands_post, name),
),
source_file=config.path.name,
)
)
return tasks
def _toml_tasks(config: _Config) -> list[Task]:
"""Build tasks from a native TOML tox configuration table."""
tox = config.data
if not isinstance(tox, dict):
return []
if "env_list" in tox:
raw_env_list = tox["env_list"]
elif "envlist" in tox:
raw_env_list = tox["envlist"]
else:
raw_env_list = ["py"]
names = (
_toml_env_list(raw_env_list)
if isinstance(raw_env_list, list)
else [
name
for item in _split_envlist(raw_env_list)
for name in _expand_env_name(item)
]
if isinstance(raw_env_list, str)
else []
)
envs = tox.get("env")
envs = envs if isinstance(envs, dict) else {}
labels = tox.get("labels")
if isinstance(labels, dict):
for members in labels.values():
if isinstance(members, list):
names.extend(member for member in members if isinstance(member, str))
elif isinstance(members, str):
names.extend(_split_envlist(members))
explicit = [
name
for name, value in envs.items()
if isinstance(name, str) and isinstance(value, dict)
]
internal_envs = _configured_internal_envs_toml(tox)
names = _without_provisioning([*names, *explicit], internal_envs)
tasks: list[Task] = []
for name in names:
section = envs.get(name)
section = section if isinstance(section, dict) else {}
desc = _toml_env_setting(name, section, "description", tox)
tasks.append(
Task(
name=name,
prefix="tox",
argv_base=("tox", "-e", name),
passthrough_prefix=("--",),
description=desc if isinstance(desc, str) else None,
definition=_render_command_groups(
_toml_env_setting(name, section, "commands_pre", tox),
_toml_env_setting(name, section, "commands", tox),
_toml_env_setting(name, section, "commands_post", tox),
),
source_file=config.path.name,
)
)
return tasks
[docs]
class ToxProvider:
"""Task provider for tox test automation configurations."""
prefix = "tox"
[docs]
def detect(self, cwd: Path) -> bool:
"""Check whether a supported tox configuration exists in the directory."""
return _find_config(cwd) is not None
[docs]
def discover(self, cwd: Path) -> list[Task]:
"""Discover runnable tox environments as tasks from configuration."""
config = _find_config(cwd)
if config is None:
return []
return _ini_tasks(config) if config.kind == "ini" else _toml_tasks(config)