From c62cf3f1b4e12a69a98c556fedfa2b3f4d6b085c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 25 Oct 2023 09:36:45 -0700 Subject: [PATCH] Fix generating Python version classifiers based on python-requires (#140) Co-authored-by: Andreas Motl --- .pre-commit-config.yaml | 30 ++-- docs/index.rst | 4 +- pyproject.toml | 10 +- src/pyproject_fmt/__main__.py | 7 +- src/pyproject_fmt/cli.py | 5 +- src/pyproject_fmt/formatter/build_system.py | 10 +- src/pyproject_fmt/formatter/project.py | 164 +++++++++----------- src/pyproject_fmt/formatter/tools.py | 1 + src/pyproject_fmt/formatter/util.py | 12 +- tests/formatter/test_project.py | 26 +++- tox.ini | 6 +- 11 files changed, 135 insertions(+), 140 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aab1933..862ade9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,30 +4,42 @@ repos: hooks: - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/psf/black - rev: 23.10.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.2 + hooks: + - id: ruff-format + - repo: https://github.com/codespell-project/codespell + rev: v2.2.6 hooks: - - id: black + - id: codespell + args: ["--ignore-words-list", "crate,releas", "--skip", "*.svg"] + additional_dependencies: + - tomli>=2.0.1 - repo: https://github.com/tox-dev/tox-ini-fmt - rev: "1.3.1" + rev: 1.3.1 hooks: - id: tox-ini-fmt args: ["-p", "fix"] - repo: https://github.com/tox-dev/pyproject-fmt - rev: "1.2.0" + rev: 1.2.0 hooks: - id: pyproject-fmt - additional_dependencies: ["tox>=4.8"] + additional_dependencies: ["tox>=4.11.3"] - repo: https://github.com/pre-commit/mirrors-prettier - rev: "v3.0.3" + rev: v3.0.3 hooks: - id: prettier args: ["--print-width=120", "--prose-wrap=always"] + - repo: https://github.com/asottile/blacken-docs + rev: 1.16.0 + hooks: + - id: blacken-docs + additional_dependencies: [black==23.10] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.1.1" + rev: v0.1.2 hooks: - id: ruff - args: [--fix, --exit-non-zero-on-fix] + args: [--fix, --exit-non-zero-on-fix, --unsafe-fixes] - repo: meta hooks: - id: check-hooks-apply diff --git a/docs/index.rst b/docs/index.rst index 8cb8189..39244a4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -33,13 +33,13 @@ See :gh:`pre-commit/pre-commit` for instructions, sample ``.pre-commit-config.ya Calculating max supported Python version ---------------------------------------- -This tool wil automatically generate the ``Programming Language :: Python :: 3.X`` classifiers for you. To do so it +This tool will automatically generate the ``Programming Language :: Python :: 3.X`` classifiers for you. To do so it needs to know what is the range of Python interpreter versions you support. The lower bound can be deduced by looking at the ``requires-python`` key in the ``pyproject.toml`` configuration file. For the upper bound by default will assume the latest stable release when the library is released; however, if you're adding support for a not yet final Python version the tool offers a functionality that it will invoke ``tox`` for you and inspect the test environments and use the latest python version tested against. For this to work ``tox`` needs to be on ``PATH``, an easy way to -ensure this is to set ``tox`` as additonal dependency via: +ensure this is to set ``tox`` as additional dependency via: .. code-block:: yaml diff --git a/pyproject.toml b/pyproject.toml index 9d357c1..8af3212 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,19 +32,19 @@ dynamic = [ ] dependencies = [ "natsort>=8.4", - "packaging>=23.1", + "packaging>=23.2", "tomlkit>=0.12.1", ] optional-dependencies.docs = [ - "furo>=2023.7.26", - "sphinx>=7.1.2", + "furo>=2023.9.10", + "sphinx>=7.2.6", "sphinx-argparse-cli>=1.11.1", "sphinx-autodoc-typehints>=1.24", "sphinx-copybutton>=0.5.2", ] optional-dependencies.test = [ "covdefaults>=2.3", - "pytest>=7.4", + "pytest>=7.4.2", "pytest-cov>=4.1", "pytest-mock>=3.11.1", ] @@ -66,7 +66,7 @@ line-length = 120 target-version = "py38" isort = {known-first-party = ["pyproject_fmt"], required-imports = ["from __future__ import annotations"]} ignore = [ - "ANN101", # no typoe annotation for self + "ANN101", # no type annotation for self "ANN401", # allow Any as type annotation "D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible diff --git a/src/pyproject_fmt/__main__.py b/src/pyproject_fmt/__main__.py index 59fc39c..6090b53 100644 --- a/src/pyproject_fmt/__main__.py +++ b/src/pyproject_fmt/__main__.py @@ -48,12 +48,7 @@ def _handle_one(config: Config, opts: PyProjectFmtNamespace) -> bool: name = str(config.pyproject_toml) diff: Iterable[str] = [] if changed: - diff = difflib.unified_diff( - before.splitlines(), - formatted.splitlines(), - fromfile=name, - tofile=name, - ) + diff = difflib.unified_diff(before.splitlines(), formatted.splitlines(), fromfile=name, tofile=name) if diff: diff = color_diff(diff) diff --git a/src/pyproject_fmt/cli.py b/src/pyproject_fmt/cli.py index 2302c83..d282faf 100644 --- a/src/pyproject_fmt/cli.py +++ b/src/pyproject_fmt/cli.py @@ -26,10 +26,7 @@ class PyProjectFmtNamespace(Namespace): @property def configs(self) -> list[Config]: """:return: configurations""" - return [ - Config(toml, toml.read_text(encoding="utf-8"), self.indent) - for toml in self.inputs - ] + return [Config(toml, toml.read_text(encoding="utf-8"), self.indent) for toml in self.inputs] def pyproject_toml_path_creator(argument: str) -> Path: diff --git a/src/pyproject_fmt/formatter/build_system.py b/src/pyproject_fmt/formatter/build_system.py index 39c5083..52d8f70 100644 --- a/src/pyproject_fmt/formatter/build_system.py +++ b/src/pyproject_fmt/formatter/build_system.py @@ -23,14 +23,8 @@ def fmt_build_system(parsed: TOMLDocument, conf: Config) -> None: """ system = cast(Optional[Table], parsed.get("build-system")) if system is not None: - normalize_pep508_array( - cast(Optional[Array], system.get("requires")), - conf.indent, - ) - sorted_array( - cast(Optional[Array], system.get("backend-path")), - indent=conf.indent, - ) + normalize_pep508_array(cast(Optional[Array], system.get("requires")), conf.indent) + sorted_array(cast(Optional[Array], system.get("backend-path")), indent=conf.indent) order_keys(system, ("build-backend", "requires", "backend-path")) ensure_newline_at_end(system) diff --git a/src/pyproject_fmt/formatter/project.py b/src/pyproject_fmt/formatter/project.py index 4265d94..84435e8 100644 --- a/src/pyproject_fmt/formatter/project.py +++ b/src/pyproject_fmt/formatter/project.py @@ -23,85 +23,6 @@ _PY_MAX_VERSION: int = 12 -def _get_max_version_specifier(specifiers: SpecifierSet) -> int | None: - max_version: list[int] = [] - - for specifier in specifiers: - if specifier.operator == "<=": - max_version.append(Version(specifier.version).minor) - if specifier.operator == "<": - max_version.append(Version(specifier.version).minor - 1) - - if max_version: - return max(max_version) - - return None - - -def _get_max_version_tox() -> int: - max_version = _PY_MAX_VERSION - tox = which("tox") - if tox is not None: # pragma: no branch - try: - tox_environments = check_output( - [tox, "-aqq"], # noqa: S603 - encoding="utf-8", - text=True, - ) - except (OSError, CalledProcessError): - return max_version - if not re.match(r"ROOT: No .* found, assuming empty", tox_environments): - found = set() - for env in tox_environments.split(): - for part in env.split("-"): - match = re.match(r"py(\d)(\d+)", part) - if match: - found.add(int(match.groups()[1])) - if found: - max_version = max(found) - return max_version - - -def _add_py_classifiers(project: Table) -> None: - # update classifiers depending on requires - requires = project.get("requires-python", f">=3.{_PY_MIN_VERSION}") - - specifiers = SpecifierSet(requires) - - max_version = _get_max_version_specifier(specifiers) - if not max_version: - max_version = _get_max_version_tox() - - allowed_versions = list( - specifiers.filter(f"3.{v}" for v in range(_PY_MIN_VERSION, max_version + 1)), - ) - - add = [f"Programming Language :: Python :: {v}" for v in allowed_versions] - add.append("Programming Language :: Python :: 3 :: Only") - - if "classifiers" in project: - classifiers: Array = cast(Array, project["classifiers"]) - else: - classifiers = Array([], Trivia(), multiline=False) - project["classifiers"] = classifiers - - exist = set(classifiers.unwrap()) - remove = [ - e - for e in exist - if re.fullmatch(r"Programming Language :: Python :: \d.*", e) and e not in add - ] - deleted = 0 - for at, item in enumerate(list(classifiers)): - if item in remove: - del classifiers[at - deleted] - deleted += 1 - - for entry in add: - if entry not in classifiers: - classifiers.insert(len(add), entry) - - def fmt_project(parsed: TOMLDocument, conf: Config) -> None: # noqa: C901 """ Format the project table. @@ -113,9 +34,7 @@ def fmt_project(parsed: TOMLDocument, conf: Config) -> None: # noqa: C901 if project is None: return - if ( - "name" in project - ): # normalize names to hyphen so sdist / wheel have the same prefix + if "name" in project: # normalize names to hyphen so sdist / wheel have the same prefix name = project["name"] assert isinstance(name, str) # noqa: S101 project["name"] = canonicalize_name(name) @@ -128,16 +47,9 @@ def fmt_project(parsed: TOMLDocument, conf: Config) -> None: # noqa: C901 if "requires-python" in project: _add_py_classifiers(project) - sorted_array( - cast(Optional[Array], project.get("classifiers")), - indent=conf.indent, - custom_sort="natsort", - ) + sorted_array(cast(Optional[Array], project.get("classifiers")), indent=conf.indent, custom_sort="natsort") - normalize_pep508_array( - cast(Optional[Array], project.get("dependencies")), - conf.indent, - ) + normalize_pep508_array(cast(Optional[Array], project.get("dependencies")), conf.indent) if "optional-dependencies" in project: opt_deps = cast(Table, project["optional-dependencies"]) for value in opt_deps.values(): @@ -185,6 +97,76 @@ def fmt_project(parsed: TOMLDocument, conf: Config) -> None: # noqa: C901 ensure_newline_at_end(project) +def _add_py_classifiers(project: Table) -> None: + specifiers = SpecifierSet(project.get("requires-python", f">=3.{_PY_MIN_VERSION}")) + + min_version = _get_min_version_classifier(specifiers) + max_version = _get_max_version_classifier(specifiers) + + allowed_versions = list(specifiers.filter(f"3.{v}" for v in range(min_version, max_version + 1))) + + add = [f"Programming Language :: Python :: {v}" for v in allowed_versions] + add.append("Programming Language :: Python :: 3 :: Only") + + if "classifiers" in project: + classifiers: Array = cast(Array, project["classifiers"]) + else: + classifiers = Array([], Trivia(), multiline=False) + project["classifiers"] = classifiers + + exist = set(classifiers.unwrap()) + remove = [e for e in exist if re.fullmatch(r"Programming Language :: Python :: \d.*", e) and e not in add] + deleted = 0 + for at, item in enumerate(list(classifiers)): + if item in remove: + del classifiers[at - deleted] + deleted += 1 + + for entry in add: + if entry not in classifiers: + classifiers.insert(len(add), entry) + + +def _get_min_version_classifier(specifiers: SpecifierSet) -> int: + min_version: list[int] = [] + for specifier in specifiers: + if specifier.operator == ">=": + min_version.append(Version(specifier.version).minor) + if specifier.operator == ">": + min_version.append(Version(specifier.version).minor + 1) + return min(min_version) if min_version else _PY_MIN_VERSION + + +def _get_max_version_classifier(specifiers: SpecifierSet) -> int: + max_version: list[int] = [] + + for specifier in specifiers: + if specifier.operator == "<=": + max_version.append(Version(specifier.version).minor) + if specifier.operator == "<": + max_version.append(Version(specifier.version).minor - 1) + return max(max_version) if max_version else (_get_max_version_tox() or _PY_MAX_VERSION) + + +def _get_max_version_tox() -> int | None: + tox = which("tox") + if tox is not None: # pragma: no branch + try: + tox_environments = check_output([tox, "-aqq"], encoding="utf-8", text=True) # noqa: S603 + except (OSError, CalledProcessError): + tox_environments = "" + if not re.match(r"ROOT: No .* found, assuming empty", tox_environments): + found = set() + for env in tox_environments.split(): + for part in env.split("-"): + match = re.match(r"py(\d)(\d+)", part) + if match: + found.add(int(match.groups()[1])) + if found: + return max(found) + return None + + __all__ = [ "fmt_project", ] diff --git a/src/pyproject_fmt/formatter/tools.py b/src/pyproject_fmt/formatter/tools.py index 5e51698..6aa6030 100644 --- a/src/pyproject_fmt/formatter/tools.py +++ b/src/pyproject_fmt/formatter/tools.py @@ -37,6 +37,7 @@ def fmt_tools(parsed: TOMLDocument, conf: Config) -> None: # noqa: ARG001 "isort", "flake8", "pytest", + "pytest_env", "coverage", "mypy", ] diff --git a/src/pyproject_fmt/formatter/util.py b/src/pyproject_fmt/formatter/util.py index 2270737..e52eb88 100644 --- a/src/pyproject_fmt/formatter/util.py +++ b/src/pyproject_fmt/formatter/util.py @@ -157,17 +157,9 @@ def ensure_newline_at_end(body: Table) -> None: """ content: Table = body while True: - if ( - isinstance(content, AoT) - and content.value - and isinstance(content[-1], (AoT, Table)) - ): + if isinstance(content, AoT) and content.value and isinstance(content[-1], (AoT, Table)): content = content[-1] - elif ( - isinstance(content, Table) - and content.value.body - and isinstance(content.value.body[-1][1], (AoT, Table)) - ): + elif isinstance(content, Table) and content.value.body and isinstance(content.value.body[-1][1], (AoT, Table)): content = content.value.body[-1][1] # type: ignore[assignment] # can be AoT temporarily else: break # pragma: no cover # https://github.com/nedbat/coveragepy/issues/1480 diff --git a/tests/formatter/test_project.py b/tests/formatter/test_project.py index 29fba65..1c55793 100644 --- a/tests/formatter/test_project.py +++ b/tests/formatter/test_project.py @@ -187,11 +187,11 @@ def test_classifier_lt(fmt: Fmt) -> None: def test_classifier_gt(fmt: Fmt) -> None: start = """ [project] - requires-python = ">=3.7" + requires-python = ">3.6" """ expected = """ [project] - requires-python = ">=3.7" + requires-python = ">3.6" classifiers = [ "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.7", @@ -205,6 +205,28 @@ def test_classifier_gt(fmt: Fmt) -> None: fmt(fmt_project, start, expected) +def test_classifier_gte(fmt: Fmt) -> None: + start = """ + [project] + requires-python = ">=3.6" + """ + expected = """ + [project] + requires-python = ">=3.6" + classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + ] + """ + fmt(fmt_project, start, expected) + + def test_classifier_eq(fmt: Fmt) -> None: start = """ [project] diff --git a/tox.ini b/tox.ini index 252ef79..8dc85cd 100644 --- a/tox.ini +++ b/tox.ini @@ -36,7 +36,7 @@ commands = description = run static analysis and style check using flake8 skip_install = true deps = - pre-commit>=3.3.3 + pre-commit>=3.5 commands = pre-commit run --all-files --show-diff-on-failure python -c 'print("hint: run {envdir}/bin/pre-commit install to add checks as pre-commit hook")' @@ -44,7 +44,7 @@ commands = [testenv:type] description = run type check on code base deps = - mypy==1.5 + mypy==1.6.1 set_env = {tty:MYPY_FORCE_COLOR = 1} commands = @@ -55,7 +55,7 @@ commands = description = check that the long description is valid skip_install = true deps = - build[virtualenv]>=0.10 + build[virtualenv]>=1.0.3 twine>=4.0.2 commands = python -m build --sdist --wheel -o {envtmpdir} .