Skip to content

Commit

Permalink
Upgrade configuration syntax to v4 (#146)
Browse files Browse the repository at this point in the history
  • Loading branch information
gaborbernat committed Apr 7, 2023
1 parent 792e559 commit 9aac31d
Show file tree
Hide file tree
Showing 15 changed files with 303 additions and 136 deletions.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ classifiers = [
dynamic = [
"version",
]
dependencies = [
"packaging>=23",
]
optional-dependencies.test = [
"covdefaults>=2.3",
"pytest>=7.2.2",
Expand Down
4 changes: 2 additions & 2 deletions src/tox_ini_fmt/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ def run(args: Sequence[str] | None = None) -> int:
else []
)
if diff:
diff = color_diff(diff)
print("\n".join(diff)) # print diff on change
diff_text = "\n".join(color_diff(diff))
print(diff_text) # print diff on change
else:
print(f"no change for {name}")
# exit with non success on change
Expand Down
2 changes: 1 addition & 1 deletion src/tox_ini_fmt/formatter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ def format_tox_ini(tox_ini: str | Path, opts: ToxIniFmtNamespace | None = None)
text = tox_ini
parser.read_string(text)

order_sections(parser, opts.pin_toxenvs)
format_tox_section(parser, opts.pin_toxenvs)
for section_name in parser.sections():
if section_name == "testenv" or section_name.startswith("testenv:"):
format_test_env(parser, section_name)
order_sections(parser, opts.pin_toxenvs)

return _generate_tox_ini(parser)

Expand Down
58 changes: 27 additions & 31 deletions src/tox_ini_fmt/formatter/requires.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,36 @@
from __future__ import annotations

import re
from packaging.requirements import InvalidRequirement, Requirement

BASE_NAME_REGEX = re.compile(r"[^!=><~\s@]+")
REQ_REGEX = re.compile(r"(===|==|!=|~=|>=?|<=?|@)\s*([^,]+)")

def normalize_req(req: str) -> str:
try:
parsed = Requirement(req)
except InvalidRequirement:
return req

for spec in parsed.specifier:
if spec.operator in (">=", "=="):
version = spec.version
while version.endswith(".0"):
version = version[:-2]
spec._spec = (spec._spec[0], version)
return str(parsed)


def _req_name(req: str) -> str:
try:
return Requirement(req).name
except InvalidRequirement:
return req


def requires(raws: list[str]) -> list[str]:
values = (_normalize_req(req) for req in raws if req)
normalized = sorted(values, key=lambda req: (";" in req, _req_base(req), req))
values = (normalize_req(req) for req in raws if req)
normalized = sorted(values, key=lambda req: (";" in req, _req_name(req), req))
return normalized


def _normalize_req(req: str) -> str:
lib, _, envs = req.partition(";")
normalized = _normalize_lib(lib)
envs = envs.strip()
if not envs:
return normalized
return f"{normalized};{envs}"


def _normalize_lib(lib: str) -> str:
base = _req_base(lib)
values = sorted(
(f"{m.group(1)}{m.group(2)}" for m in REQ_REGEX.finditer(lib)),
key=lambda c: ("<" in c, ">" in "c", c),
)
if values: # strip .0 version
while values[0].endswith(".0") and (values[0].startswith(">=") or values[0].startswith("==")):
values[0] = values[0][:-2]
return f"{base}{','.join(values)}"


def _req_base(lib: str) -> str:
match = re.match(BASE_NAME_REGEX, lib)
if match is None:
raise ValueError(repr(lib))
return match.group(0)
__all__ = [
"requires",
]
2 changes: 0 additions & 2 deletions src/tox_ini_fmt/formatter/section_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ def order_sections(parser: ConfigParser, pin_toxenvs: list[str]) -> None:


def load_and_order_env_list(parser: ConfigParser, pin_toxenvs: list[str]) -> list[str]:
if not parser.has_section("tox"):
return []
result: list[str] = next(
(explode_env_list(parser["tox"][i]) for i in ("envlist", "env_list") if i in parser["tox"]),
[],
Expand Down
27 changes: 19 additions & 8 deletions src/tox_ini_fmt/formatter/test_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,15 @@ def format_test_env(parser: ConfigParser, name: str) -> None:
"runner": str,
"description": str,
"base_python": str,
"basepython": str,
"system_site_packages": to_boolean,
"sitepackages": to_boolean,
"always_copy": to_boolean,
"alwayscopy": to_boolean,
"download": to_boolean,
"package": str,
"package_env": str,
"wheel_build_env": str,
"package_tox_env_type": str,
"package_root": str,
"skip_install": to_boolean,
"use_develop": to_boolean,
"usedevelop": to_boolean,
"meta_dir": str,
"pkg_dir": str,
"pip_pre": to_boolean,
Expand All @@ -34,11 +29,9 @@ def format_test_env(parser: ConfigParser, name: str) -> None:
"recreate": to_boolean,
"parallel_show_output": to_boolean,
"pass_env": to_pass_env,
"passenv": to_pass_env,
"set_env": to_set_env,
"setenv": to_set_env,
"change_dir": str,
"changedir": str,
"args_are_paths": to_boolean,
"ignore_errors": to_boolean,
"ignore_outcome": to_boolean,
Expand All @@ -51,7 +44,25 @@ def format_test_env(parser: ConfigParser, name: str) -> None:
"terminate_timeout": str,
"depends": partial(to_list_of_env_values, []),
}
fix_and_reorder(parser, name, tox_section_cfg)
upgrade = {
"envdir": "env_dir",
"envtmpdir": "env_tmp_dir",
"envlogdir": "env_log_dir",
"passenv": "pass_env",
"setenv": "set_env",
"changedir": "change_dir",
"basepython": "base_python",
"setupdir": "package_root",
"sitepackages": "system_site_packages",
"alwayscopy": "always_copy",
}

section = parser[name]
use_develop = next((section.pop(i) for i in ("usedevelop", "use_develop") if i in section), "false")
if to_boolean(use_develop) == "true":
parser[name]["package"] = "editable"

fix_and_reorder(parser, name, tox_section_cfg, upgrade)


def to_ordered_list(value: str) -> str:
Expand Down
56 changes: 47 additions & 9 deletions src/tox_ini_fmt/formatter/tox_section.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,67 @@
from __future__ import annotations

from configparser import ConfigParser
from configparser import ConfigParser, SectionProxy
from functools import partial
from typing import Callable, Mapping

from .util import fix_and_reorder, to_boolean, to_list_of_env_values, to_py_dependencies
from packaging.requirements import Requirement
from packaging.version import Version

from .requires import requires
from .util import collect_multi_line, fix_and_reorder, to_boolean, to_list_of_env_values, to_py_dependencies


def format_tox_section(parser: ConfigParser, pin_toxenvs: list[str]) -> None:
if not parser.has_section("tox"):
return
parser.add_section("tox")
tox = parser["tox"]
_handle_min_version(tox)
tox.pop("isolated_build", None)

tox_section_cfg: Mapping[str, Callable[[str], str]] = {
"minversion": str,
"min_version": str,
"requires": to_py_dependencies,
"provision_tox_env": str,
"env_list": partial(to_list_of_env_values, pin_toxenvs),
"envlist": partial(to_list_of_env_values, pin_toxenvs),
"isolated_build": to_boolean,
"package_env": str,
"isolated_build_env": str,
"no_package": to_boolean,
"skipsdist": to_boolean,
"skip_missing_interpreters": to_boolean,
"ignore_base_python_conflict": to_boolean,
"ignore_basepython_conflict": to_boolean,
}
fix_and_reorder(parser, "tox", tox_section_cfg)
upgrade = {
"envlist": "env_list",
"toxinidir": "tox_root",
"toxworkdir": "work_dir",
"skipsdist": "no_package",
"isolated_build_env": "package_env",
"setupdir": "package_root",
"ignore_basepython_conflict": "ignore_base_python_conflict",
}
fix_and_reorder(parser, "tox", tox_section_cfg, upgrade)


def _handle_min_version(tox: SectionProxy) -> None:
min_version = next((tox.pop(i) for i in ("minversion", "min_version") if i in tox), None)
if min_version is None or int(min_version.split(".")[0]) < 4:
min_version = "4.2"
tox_requires = [
Requirement(i)
for i in collect_multi_line(
tox.get("requires", ""),
line_split=None,
normalize=lambda groups: {k: requires(v) for k, v in groups.items()},
)[0]
]
for _at, entry in enumerate(tox_requires):
if entry.name == "tox":
break
else:
_at = -1
if _at == -1:
tox_requires.append(Requirement(f"tox>={min_version}"))
else:
specifiers = list(tox_requires[_at].specifier)
if len(specifiers) == 0 or Version(specifiers[0].version) < Version(min_version):
tox_requires[_at] = Requirement(f"tox>={min_version}")
tox["requires"] = "\n".join(str(i) for i in tox_requires)
14 changes: 13 additions & 1 deletion src/tox_ini_fmt/formatter/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,20 @@ def to_boolean(payload: str) -> str:
return "true" if payload.lower() == "true" else "false"


def fix_and_reorder(parser: ConfigParser, name: str, fix_cfg: Mapping[str, Callable[[str], str]]) -> None:
def fix_and_reorder(
parser: ConfigParser,
name: str,
fix_cfg: Mapping[str, Callable[[str], str]],
upgrade: dict[str, str],
) -> None:
section = parser[name]
# upgrade
for key, to in upgrade.items():
if key in section:
if to in section:
raise RuntimeError(f"upgrade alias {to} also present for {key}")
section[to] = section.pop(key)
# normalize
for key, fix in fix_cfg.items():
if key in section:
section[key] = fix(section[key])
Expand Down
10 changes: 5 additions & 5 deletions tests/formatter/test_line_endings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ def test_platform_default(tox_ini: Path) -> None:

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


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

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

original_text = "[tox]\r\n \r \nenvlist=py39"
expected_text = "[tox]!!envlist =!! py39!!"
original_text = "[tox]\r\n \r \nenv_list=py39"
expected_text = "[tox]!!requires =!! tox>=4.2!!env_list =!! py39!!"
tox_ini.write_bytes(original_text.encode("utf8"))
with tox_ini.open("rt") as file:
file.read()
Expand Down
21 changes: 2 additions & 19 deletions tests/formatter/test_requires.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
"pytest-xdist>=1.31.0\n",
[
"pytest-xdist>=1.31",
'packaging>=20;python_version>"3.4"',
"xonsh>=0.9.16;python_version > '3.4' and python_version != '3.9'",
'packaging>=20; python_version > "3.4"',
'xonsh>=0.9.16; python_version > "3.4" and python_version != "3.9"',
],
),
("pytest>=6.0.0", ["pytest>=6"]),
Expand All @@ -33,20 +33,3 @@
def test_requires_fmt(value: str, result: list[str]) -> None:
outcome = requires([i.strip() for i in value.splitlines() if i.strip()])
assert outcome == result


@pytest.mark.parametrize(
"char",
[
"!",
"=",
">",
"<",
" ",
"\t",
"@",
],
)
def test_bad_syntax_requires(char: str) -> None:
with pytest.raises(ValueError, match=f"[{char}]" if char.strip() else None):
requires([f"{char};"])
14 changes: 9 additions & 5 deletions tests/formatter/test_section_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def test_section_order(tox_ini: Path) -> None:
[magic]
i = j
[tox]
envlist = py38,py37
env_list = py38,py37
e = f
""",
Expand All @@ -48,7 +48,9 @@ def test_section_order(tox_ini: Path) -> None:
expected = dedent(
"""
[tox]
envlist =
requires =
tox>=4.2
env_list =
py38
py37
e = f
Expand All @@ -70,15 +72,15 @@ def test_section_order(tox_ini: Path) -> None:


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

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


def test_pin(tox_ini: Path) -> None:
tox_ini.write_text(
"[tox]\nenvlist=py38,pkg,py,py39,pypy3,pypy,pin,extra\n"
"[tox]\nenv_list=py38,pkg,py,py39,pypy3,pypy,pin,extra\n"
"[testenv:py38]\ne=f\n"
"[testenv:pkg]\nc=d\n"
"[testenv:py]\ng=h\n"
Expand All @@ -93,7 +95,9 @@ def test_pin(tox_ini: Path) -> None:
expected = dedent(
"""
[tox]
envlist =
requires =
tox>=4.2
env_list =
pin
pkg
py39
Expand Down
Loading

0 comments on commit 9aac31d

Please sign in to comment.