Skip to content

Commit 9aac31d

Browse files
authored
Upgrade configuration syntax to v4 (#146)
1 parent 792e559 commit 9aac31d

15 files changed

+303
-136
lines changed

pyproject.toml

+3
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ classifiers = [
4343
dynamic = [
4444
"version",
4545
]
46+
dependencies = [
47+
"packaging>=23",
48+
]
4649
optional-dependencies.test = [
4750
"covdefaults>=2.3",
4851
"pytest>=7.2.2",

src/tox_ini_fmt/__main__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ def run(args: Sequence[str] | None = None) -> int:
5050
else []
5151
)
5252
if diff:
53-
diff = color_diff(diff)
54-
print("\n".join(diff)) # print diff on change
53+
diff_text = "\n".join(color_diff(diff))
54+
print(diff_text) # print diff on change
5555
else:
5656
print(f"no change for {name}")
5757
# exit with non success on change

src/tox_ini_fmt/formatter/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ def format_tox_ini(tox_ini: str | Path, opts: ToxIniFmtNamespace | None = None)
2323
text = tox_ini
2424
parser.read_string(text)
2525

26-
order_sections(parser, opts.pin_toxenvs)
2726
format_tox_section(parser, opts.pin_toxenvs)
2827
for section_name in parser.sections():
2928
if section_name == "testenv" or section_name.startswith("testenv:"):
3029
format_test_env(parser, section_name)
30+
order_sections(parser, opts.pin_toxenvs)
3131

3232
return _generate_tox_ini(parser)
3333

src/tox_ini_fmt/formatter/requires.py

+27-31
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,36 @@
11
from __future__ import annotations
22

3-
import re
3+
from packaging.requirements import InvalidRequirement, Requirement
44

5-
BASE_NAME_REGEX = re.compile(r"[^!=><~\s@]+")
6-
REQ_REGEX = re.compile(r"(===|==|!=|~=|>=?|<=?|@)\s*([^,]+)")
5+
6+
def normalize_req(req: str) -> str:
7+
try:
8+
parsed = Requirement(req)
9+
except InvalidRequirement:
10+
return req
11+
12+
for spec in parsed.specifier:
13+
if spec.operator in (">=", "=="):
14+
version = spec.version
15+
while version.endswith(".0"):
16+
version = version[:-2]
17+
spec._spec = (spec._spec[0], version)
18+
return str(parsed)
19+
20+
21+
def _req_name(req: str) -> str:
22+
try:
23+
return Requirement(req).name
24+
except InvalidRequirement:
25+
return req
726

827

928
def requires(raws: list[str]) -> list[str]:
10-
values = (_normalize_req(req) for req in raws if req)
11-
normalized = sorted(values, key=lambda req: (";" in req, _req_base(req), req))
29+
values = (normalize_req(req) for req in raws if req)
30+
normalized = sorted(values, key=lambda req: (";" in req, _req_name(req), req))
1231
return normalized
1332

1433

15-
def _normalize_req(req: str) -> str:
16-
lib, _, envs = req.partition(";")
17-
normalized = _normalize_lib(lib)
18-
envs = envs.strip()
19-
if not envs:
20-
return normalized
21-
return f"{normalized};{envs}"
22-
23-
24-
def _normalize_lib(lib: str) -> str:
25-
base = _req_base(lib)
26-
values = sorted(
27-
(f"{m.group(1)}{m.group(2)}" for m in REQ_REGEX.finditer(lib)),
28-
key=lambda c: ("<" in c, ">" in "c", c),
29-
)
30-
if values: # strip .0 version
31-
while values[0].endswith(".0") and (values[0].startswith(">=") or values[0].startswith("==")):
32-
values[0] = values[0][:-2]
33-
return f"{base}{','.join(values)}"
34-
35-
36-
def _req_base(lib: str) -> str:
37-
match = re.match(BASE_NAME_REGEX, lib)
38-
if match is None:
39-
raise ValueError(repr(lib))
40-
return match.group(0)
34+
__all__ = [
35+
"requires",
36+
]

src/tox_ini_fmt/formatter/section_order.py

-2
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,6 @@ def order_sections(parser: ConfigParser, pin_toxenvs: list[str]) -> None:
2525

2626

2727
def load_and_order_env_list(parser: ConfigParser, pin_toxenvs: list[str]) -> list[str]:
28-
if not parser.has_section("tox"):
29-
return []
3028
result: list[str] = next(
3129
(explode_env_list(parser["tox"][i]) for i in ("envlist", "env_list") if i in parser["tox"]),
3230
[],

src/tox_ini_fmt/formatter/test_env.py

+19-8
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,15 @@ def format_test_env(parser: ConfigParser, name: str) -> None:
1212
"runner": str,
1313
"description": str,
1414
"base_python": str,
15-
"basepython": str,
1615
"system_site_packages": to_boolean,
17-
"sitepackages": to_boolean,
1816
"always_copy": to_boolean,
19-
"alwayscopy": to_boolean,
2017
"download": to_boolean,
2118
"package": str,
2219
"package_env": str,
2320
"wheel_build_env": str,
2421
"package_tox_env_type": str,
2522
"package_root": str,
2623
"skip_install": to_boolean,
27-
"use_develop": to_boolean,
28-
"usedevelop": to_boolean,
2924
"meta_dir": str,
3025
"pkg_dir": str,
3126
"pip_pre": to_boolean,
@@ -34,11 +29,9 @@ def format_test_env(parser: ConfigParser, name: str) -> None:
3429
"recreate": to_boolean,
3530
"parallel_show_output": to_boolean,
3631
"pass_env": to_pass_env,
37-
"passenv": to_pass_env,
3832
"set_env": to_set_env,
3933
"setenv": to_set_env,
4034
"change_dir": str,
41-
"changedir": str,
4235
"args_are_paths": to_boolean,
4336
"ignore_errors": to_boolean,
4437
"ignore_outcome": to_boolean,
@@ -51,7 +44,25 @@ def format_test_env(parser: ConfigParser, name: str) -> None:
5144
"terminate_timeout": str,
5245
"depends": partial(to_list_of_env_values, []),
5346
}
54-
fix_and_reorder(parser, name, tox_section_cfg)
47+
upgrade = {
48+
"envdir": "env_dir",
49+
"envtmpdir": "env_tmp_dir",
50+
"envlogdir": "env_log_dir",
51+
"passenv": "pass_env",
52+
"setenv": "set_env",
53+
"changedir": "change_dir",
54+
"basepython": "base_python",
55+
"setupdir": "package_root",
56+
"sitepackages": "system_site_packages",
57+
"alwayscopy": "always_copy",
58+
}
59+
60+
section = parser[name]
61+
use_develop = next((section.pop(i) for i in ("usedevelop", "use_develop") if i in section), "false")
62+
if to_boolean(use_develop) == "true":
63+
parser[name]["package"] = "editable"
64+
65+
fix_and_reorder(parser, name, tox_section_cfg, upgrade)
5566

5667

5768
def to_ordered_list(value: str) -> str:
+47-9
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,67 @@
11
from __future__ import annotations
22

3-
from configparser import ConfigParser
3+
from configparser import ConfigParser, SectionProxy
44
from functools import partial
55
from typing import Callable, Mapping
66

7-
from .util import fix_and_reorder, to_boolean, to_list_of_env_values, to_py_dependencies
7+
from packaging.requirements import Requirement
8+
from packaging.version import Version
9+
10+
from .requires import requires
11+
from .util import collect_multi_line, fix_and_reorder, to_boolean, to_list_of_env_values, to_py_dependencies
812

913

1014
def format_tox_section(parser: ConfigParser, pin_toxenvs: list[str]) -> None:
1115
if not parser.has_section("tox"):
12-
return
16+
parser.add_section("tox")
17+
tox = parser["tox"]
18+
_handle_min_version(tox)
19+
tox.pop("isolated_build", None)
20+
1321
tox_section_cfg: Mapping[str, Callable[[str], str]] = {
14-
"minversion": str,
1522
"min_version": str,
1623
"requires": to_py_dependencies,
1724
"provision_tox_env": str,
1825
"env_list": partial(to_list_of_env_values, pin_toxenvs),
19-
"envlist": partial(to_list_of_env_values, pin_toxenvs),
20-
"isolated_build": to_boolean,
2126
"package_env": str,
2227
"isolated_build_env": str,
2328
"no_package": to_boolean,
24-
"skipsdist": to_boolean,
2529
"skip_missing_interpreters": to_boolean,
2630
"ignore_base_python_conflict": to_boolean,
27-
"ignore_basepython_conflict": to_boolean,
2831
}
29-
fix_and_reorder(parser, "tox", tox_section_cfg)
32+
upgrade = {
33+
"envlist": "env_list",
34+
"toxinidir": "tox_root",
35+
"toxworkdir": "work_dir",
36+
"skipsdist": "no_package",
37+
"isolated_build_env": "package_env",
38+
"setupdir": "package_root",
39+
"ignore_basepython_conflict": "ignore_base_python_conflict",
40+
}
41+
fix_and_reorder(parser, "tox", tox_section_cfg, upgrade)
42+
43+
44+
def _handle_min_version(tox: SectionProxy) -> None:
45+
min_version = next((tox.pop(i) for i in ("minversion", "min_version") if i in tox), None)
46+
if min_version is None or int(min_version.split(".")[0]) < 4:
47+
min_version = "4.2"
48+
tox_requires = [
49+
Requirement(i)
50+
for i in collect_multi_line(
51+
tox.get("requires", ""),
52+
line_split=None,
53+
normalize=lambda groups: {k: requires(v) for k, v in groups.items()},
54+
)[0]
55+
]
56+
for _at, entry in enumerate(tox_requires):
57+
if entry.name == "tox":
58+
break
59+
else:
60+
_at = -1
61+
if _at == -1:
62+
tox_requires.append(Requirement(f"tox>={min_version}"))
63+
else:
64+
specifiers = list(tox_requires[_at].specifier)
65+
if len(specifiers) == 0 or Version(specifiers[0].version) < Version(min_version):
66+
tox_requires[_at] = Requirement(f"tox>={min_version}")
67+
tox["requires"] = "\n".join(str(i) for i in tox_requires)

src/tox_ini_fmt/formatter/util.py

+13-1
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,20 @@ def to_boolean(payload: str) -> str:
1414
return "true" if payload.lower() == "true" else "false"
1515

1616

17-
def fix_and_reorder(parser: ConfigParser, name: str, fix_cfg: Mapping[str, Callable[[str], str]]) -> None:
17+
def fix_and_reorder(
18+
parser: ConfigParser,
19+
name: str,
20+
fix_cfg: Mapping[str, Callable[[str], str]],
21+
upgrade: dict[str, str],
22+
) -> None:
1823
section = parser[name]
24+
# upgrade
25+
for key, to in upgrade.items():
26+
if key in section:
27+
if to in section:
28+
raise RuntimeError(f"upgrade alias {to} also present for {key}")
29+
section[to] = section.pop(key)
30+
# normalize
1931
for key, fix in fix_cfg.items():
2032
if key in section:
2133
section[key] = fix(section[key])

tests/formatter/test_line_endings.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ def test_platform_default(tox_ini: Path) -> None:
1313

1414
tox_ini.write_bytes(b"[tox]")
1515
run([str(tox_ini)])
16-
assert tox_ini.read_bytes() == f"[tox]{os.linesep}".encode()
16+
assert tox_ini.read_bytes() == f"[tox]{os.linesep}requires ={os.linesep} tox>=4.2{os.linesep}".encode()
1717

1818

1919
@pytest.mark.parametrize("newline", ["\r\n", "\n", "\r"])
2020
def test_line_endings(tox_ini: Path, newline: str) -> None:
2121
"""The ini file's existing newlines must be respected when reformatting."""
2222

23-
original_text = f"[tox]{newline}envlist=py39"
24-
expected_text = f"[tox]{newline}envlist ={newline} py39{newline}"
23+
original_text = f"[tox]{newline}requires ={newline} tox>=4.2{newline}env_list=py39"
24+
expected_text = f"[tox]{newline}requires ={newline} tox>=4.2{newline}env_list ={newline} py39{newline}"
2525
tox_ini.write_bytes(original_text.encode("utf8"))
2626
run([str(tox_ini)])
2727
assert tox_ini.read_bytes() == expected_text.encode("utf8")
@@ -34,8 +34,8 @@ def test_mixed_line_endings(tox_ini: Path) -> None:
3434
Python does not report the newlines in the order they're encountered.
3535
"""
3636

37-
original_text = "[tox]\r\n \r \nenvlist=py39"
38-
expected_text = "[tox]!!envlist =!! py39!!"
37+
original_text = "[tox]\r\n \r \nenv_list=py39"
38+
expected_text = "[tox]!!requires =!! tox>=4.2!!env_list =!! py39!!"
3939
tox_ini.write_bytes(original_text.encode("utf8"))
4040
with tox_ini.open("rt") as file:
4141
file.read()

tests/formatter/test_requires.py

+2-19
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
"pytest-xdist>=1.31.0\n",
2222
[
2323
"pytest-xdist>=1.31",
24-
'packaging>=20;python_version>"3.4"',
25-
"xonsh>=0.9.16;python_version > '3.4' and python_version != '3.9'",
24+
'packaging>=20; python_version > "3.4"',
25+
'xonsh>=0.9.16; python_version > "3.4" and python_version != "3.9"',
2626
],
2727
),
2828
("pytest>=6.0.0", ["pytest>=6"]),
@@ -33,20 +33,3 @@
3333
def test_requires_fmt(value: str, result: list[str]) -> None:
3434
outcome = requires([i.strip() for i in value.splitlines() if i.strip()])
3535
assert outcome == result
36-
37-
38-
@pytest.mark.parametrize(
39-
"char",
40-
[
41-
"!",
42-
"=",
43-
">",
44-
"<",
45-
" ",
46-
"\t",
47-
"@",
48-
],
49-
)
50-
def test_bad_syntax_requires(char: str) -> None:
51-
with pytest.raises(ValueError, match=f"[{char}]" if char.strip() else None):
52-
requires([f"{char};"])

tests/formatter/test_section_order.py

+9-5
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def test_section_order(tox_ini: Path) -> None:
3737
[magic]
3838
i = j
3939
[tox]
40-
envlist = py38,py37
40+
env_list = py38,py37
4141
e = f
4242
4343
""",
@@ -48,7 +48,9 @@ def test_section_order(tox_ini: Path) -> None:
4848
expected = dedent(
4949
"""
5050
[tox]
51-
envlist =
51+
requires =
52+
tox>=4.2
53+
env_list =
5254
py38
5355
py37
5456
e = f
@@ -70,15 +72,15 @@ def test_section_order(tox_ini: Path) -> None:
7072

7173

7274
def test_pin_missing(tox_ini: Path) -> None:
73-
tox_ini.write_text("[tox]\nenvlist=py")
75+
tox_ini.write_text("[tox]\nenv_list=py")
7476

7577
with pytest.raises(RuntimeError, match=r"missing tox environment\(s\) to pin missing_1, missing_2"):
7678
format_tox_ini(tox_ini, ToxIniFmtNamespace(pin_toxenvs=["missing_1", "missing_2"]))
7779

7880

7981
def test_pin(tox_ini: Path) -> None:
8082
tox_ini.write_text(
81-
"[tox]\nenvlist=py38,pkg,py,py39,pypy3,pypy,pin,extra\n"
83+
"[tox]\nenv_list=py38,pkg,py,py39,pypy3,pypy,pin,extra\n"
8284
"[testenv:py38]\ne=f\n"
8385
"[testenv:pkg]\nc=d\n"
8486
"[testenv:py]\ng=h\n"
@@ -93,7 +95,9 @@ def test_pin(tox_ini: Path) -> None:
9395
expected = dedent(
9496
"""
9597
[tox]
96-
envlist =
98+
requires =
99+
tox>=4.2
100+
env_list =
97101
pin
98102
pkg
99103
py39

0 commit comments

Comments
 (0)