Skip to content

Commit

Permalink
Fix generating Python version classifiers based on python-requires (#140
Browse files Browse the repository at this point in the history
)

Co-authored-by: Andreas Motl <[email protected]>
  • Loading branch information
gaborbernat and amotl committed Oct 25, 2023
1 parent ea4dc58 commit c62cf3f
Show file tree
Hide file tree
Showing 11 changed files with 135 additions and 140 deletions.
30 changes: 21 additions & 9 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand All @@ -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
Expand Down
7 changes: 1 addition & 6 deletions src/pyproject_fmt/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 1 addition & 4 deletions src/pyproject_fmt/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 2 additions & 8 deletions src/pyproject_fmt/formatter/build_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
164 changes: 73 additions & 91 deletions src/pyproject_fmt/formatter/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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():
Expand Down Expand Up @@ -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",
]
1 change: 1 addition & 0 deletions src/pyproject_fmt/formatter/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def fmt_tools(parsed: TOMLDocument, conf: Config) -> None: # noqa: ARG001
"isort",
"flake8",
"pytest",
"pytest_env",
"coverage",
"mypy",
]
Expand Down
12 changes: 2 additions & 10 deletions src/pyproject_fmt/formatter/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit c62cf3f

Please sign in to comment.