Skip to content

Commit 6aa4b36

Browse files
authored
Parse target version from pyproject.toml (#543)
For #322.
1 parent 475e50f commit 6aa4b36

File tree

5 files changed

+240
-33
lines changed

5 files changed

+240
-33
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
Changelog
33
=========
44

5+
* Parse target Django version from ``pyproject.toml``.
6+
Now, if you don’t specify a version with ``--target-version``, django-upgrade will try to parse your minimum-supported target Django version from ``project.dependencies`` in ``pyproject.toml``.
7+
It supports several common formats, like ``django>=5.2,<6.0``.
8+
59
* Add Django 5.2+ fixer ``postgres_aggregate_order_by`` to rewrite PostgreSQL aggregate functions using the old argument name ``ordering`` to the new name ``order_by``.
610

711
* Add Django 5.2+ fixer ``staticfiles_find_all`` to rewrite calls to the staticfiles ``find()`` function using the old argument name ``all`` to the new name ``find_all``.

README.rst

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,16 @@ Add the following to the ``repos`` section of your ``.pre-commit-config.yaml`` f
5252
rev: "" # replace with latest tag on GitHub
5353
hooks:
5454
- id: django-upgrade
55-
args: [--target-version, "5.2"] # Replace with Django version
5655
57-
Then, upgrade your entire project:
56+
django-upgrade attempts to parse your current Django version from ``pyproject.toml``.
57+
If this doesn’t work for you, specify your target version with the ``--target-version`` option:
58+
59+
.. code-block:: diff
60+
61+
- id: django-upgrade
62+
+ args: [--target-version, "5.2"] # Replace with Django version
63+
64+
Now, upgrade your entire project:
5865

5966
.. code-block:: sh
6067
@@ -69,15 +76,12 @@ pre-commit’s ``autoupdate`` command will also let you take advantage of future
6976
Usage
7077
=====
7178

72-
``django-upgrade`` is a commandline tool that rewrites files in place.
73-
Pass your Django version as ``<major>.<minor>`` to the ``--target-version`` flag and a list of files.
74-
django-upgrade’s fixers will rewrite your code to avoid ``DeprecationWarning``\s and use some new features.
75-
79+
``django-upgrade`` is a commandline tool that rewrites files in place to avoid ``DeprecationWarning``\s and use some new features.
7680
For example:
7781

7882
.. code-block:: sh
7983
80-
django-upgrade --target-version 5.2 example/core/models.py example/settings.py
84+
django-upgrade example/core/models.py example/settings.py
8185
8286
``django-upgrade`` focuses on upgrading your code and not on making it look nice.
8387
Run django-upgrade before formatters like `Black <https://black.readthedocs.io/en/stable/>`__.
@@ -120,10 +124,23 @@ Options
120124

121125
The version of Django to target, in the format ``<major>.<minor>``.
122126
django-upgrade enables all of its fixers for versions up to and including the target version.
123-
124-
This option defaults to 2.2, the oldest supported version when this project was created.
125127
See the list of available versions with ``django-upgrade --help``.
126128

129+
When ``--target-version`` is not specified, django-upgrade attempts to detect the target version from a ``pyproject.toml`` in the current directory.
130+
If found, it attempts to parse your current minimum-supported Django version from |project.dependencies|__, supporting formats like ``django>=5.2,<6.0``.
131+
When available, it reports:
132+
133+
.. |project.dependencies| replace:: ``project.dependencies``
134+
__ https://packaging.python.org/en/latest/specifications/pyproject-toml/#dependencies-optional-dependencies
135+
136+
.. code-block:: sh
137+
138+
$ django-upgrade example.py
139+
Detected Django version from pyproject.toml: 5.2
140+
...
141+
142+
If this doesn’t work, ``--target-version`` defaults to 2.2, the oldest supported Django version when django-upgrade was created.
143+
127144
``--exit-zero-even-if-changed``
128145
-------------------------------
129146

src/django_upgrade/main.py

Lines changed: 97 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import argparse
4+
import re
45
import sys
56
import tokenize
67
from collections.abc import Sequence
@@ -20,31 +21,36 @@
2021
from django_upgrade.data import visit
2122
from django_upgrade.tokens import DEDENT
2223

24+
SUPPORTED_TARGET_VERSIONS = {
25+
(1, 7),
26+
(1, 8),
27+
(1, 9),
28+
(1, 10),
29+
(1, 11),
30+
(2, 0),
31+
(2, 1),
32+
(2, 2),
33+
(3, 0),
34+
(3, 1),
35+
(3, 2),
36+
(4, 0),
37+
(4, 1),
38+
(4, 2),
39+
(5, 0),
40+
(5, 1),
41+
(5, 2),
42+
}
43+
2344

2445
def main(argv: Sequence[str] | None = None) -> int:
2546
parser = argparse.ArgumentParser(prog="django-upgrade")
2647
parser.add_argument("filenames", nargs="+")
2748
parser.add_argument(
2849
"--target-version",
29-
default="2.2",
50+
default="auto",
3051
choices=[
31-
"1.7",
32-
"1.8",
33-
"1.9",
34-
"1.10",
35-
"1.11",
36-
"2.0",
37-
"2.1",
38-
"2.2",
39-
"3.0",
40-
"3.1",
41-
"3.2",
42-
"4.0",
43-
"4.1",
44-
"4.2",
45-
"5.0",
46-
"5.1",
47-
"5.2",
52+
"auto",
53+
*[f"{major}.{minor}" for major, minor in SUPPORTED_TARGET_VERSIONS],
4854
],
4955
help="The version of Django to target.",
5056
)
@@ -77,13 +83,8 @@ def main(argv: Sequence[str] | None = None) -> int:
7783

7884
args = parser.parse_args(argv)
7985

80-
target_version: tuple[int, int] = cast(
81-
tuple[int, int],
82-
tuple(int(x) for x in args.target_version.split(".", 1)),
83-
)
84-
8586
settings = Settings(
86-
target_version=target_version,
87+
target_version=get_target_version(args.target_version),
8788
only_fixers=set(args.only) if args.only else None,
8889
skip_fixers=set(args.skip) if args.skip else None,
8990
)
@@ -118,6 +119,78 @@ def __call__(
118119
parser.exit()
119120

120121

122+
def get_target_version(string: str) -> tuple[int, int]:
123+
default = (2, 2)
124+
if string != "auto":
125+
return cast(
126+
tuple[int, int],
127+
tuple(int(x) for x in string.split(".", 1)),
128+
)
129+
130+
if sys.version_info < (3, 11):
131+
return default
132+
133+
import tomllib
134+
135+
try:
136+
with open("pyproject.toml", "rb") as fp:
137+
config = tomllib.load(fp)
138+
except FileNotFoundError:
139+
return default
140+
141+
deps = config.get("project", {}).get("dependencies", [])
142+
for dep in deps:
143+
match = re.fullmatch(
144+
r"""
145+
django
146+
\s*
147+
(
148+
\[[^]]+\]
149+
\s*
150+
)?
151+
(?:==|~=|>=)
152+
\s*
153+
(
154+
(?P<major>[0-9]+)
155+
\.
156+
(?P<minor>[0-9]+)
157+
(
158+
(?:a|b|rc)
159+
[0-9]+
160+
|
161+
\.
162+
[0-9]+
163+
|
164+
)
165+
)
166+
(
167+
\s*,\s*
168+
(<|<=)
169+
\s*
170+
[0-9]+
171+
(
172+
\.
173+
[0-9]+
174+
(
175+
\.
176+
[0-9]+
177+
)?
178+
)?
179+
)?
180+
""",
181+
dep.lower(),
182+
re.VERBOSE,
183+
)
184+
if match:
185+
major = int(match["major"])
186+
minor = int(match["minor"])
187+
if (major, minor) in SUPPORTED_TARGET_VERSIONS:
188+
print(f"Detected Django version from pyproject.toml: {major}.{minor}")
189+
return (major, minor)
190+
191+
return default
192+
193+
121194
def fix_file(
122195
filename: str,
123196
settings: Settings,

tests/compat.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
5+
if sys.version_info >= (3, 11):
6+
from contextlib import chdir
7+
else:
8+
import os
9+
from contextlib import AbstractContextManager
10+
11+
class chdir(AbstractContextManager):
12+
"""Non thread-safe context manager to change the current working directory."""
13+
14+
def __init__(self, path):
15+
self.path = path
16+
self._old_cwd = []
17+
18+
def __enter__(self):
19+
self._old_cwd.append(os.getcwd())
20+
os.chdir(self.path)
21+
22+
def __exit__(self, *excinfo):
23+
os.chdir(self._old_cwd.pop())
24+
25+
26+
__all__ = ["chdir"]

tests/test_main.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313

1414
from django_upgrade import __main__ # noqa: F401
1515
from django_upgrade.main import fixup_dedent_tokens
16+
from django_upgrade.main import get_target_version
1617
from django_upgrade.main import main
1718
from django_upgrade.tokens import DEDENT
19+
from tests.compat import chdir
1820

1921

2022
def test_main_no_files(capsys):
@@ -128,6 +130,91 @@ def test_main_stdin_with_changes(capsys):
128130
assert err == ""
129131

130132

133+
@pytest.mark.parametrize(
134+
"string,expected",
135+
[
136+
("1.7", (1, 7)),
137+
("2.2", (2, 2)),
138+
("3.2", (3, 2)),
139+
("5.2", (5, 2)),
140+
],
141+
)
142+
def test_get_target_version_explicit(capsys, string, expected):
143+
assert get_target_version(string) == expected
144+
out, err = capsys.readouterr()
145+
assert out == ""
146+
assert err == ""
147+
148+
149+
def test_get_target_version_auto_no_pyproject_toml(tmp_path, capsys):
150+
with chdir(tmp_path):
151+
assert get_target_version("auto") == (2, 2)
152+
153+
out, err = capsys.readouterr()
154+
assert out == ""
155+
assert err == ""
156+
157+
158+
@pytest.mark.skipif(sys.version_info < (3, 11), reason="Python 3.11+")
159+
@pytest.mark.parametrize(
160+
"deps_line,expected",
161+
[
162+
("django>=3.2", (3, 2)),
163+
("DJANGO>=3.2", (3, 2)),
164+
("Django==4.0", (4, 0)),
165+
("django~=4.1.3", (4, 1)),
166+
("django>=3.2,<4.0", (3, 2)),
167+
("django >= 2.2, <= 3.1", (2, 2)),
168+
("django[argon2] >= 5.2", (5, 2)),
169+
("django[argon2] >= 5.2, <6", (5, 2)),
170+
],
171+
)
172+
def test_get_target_version_auto_matched(tmp_path, capsys, deps_line, expected):
173+
pyproject_content = f"""[project]
174+
dependencies = [
175+
"{deps_line}",
176+
]
177+
"""
178+
(tmp_path / "pyproject.toml").write_text(pyproject_content)
179+
180+
with chdir(tmp_path):
181+
assert get_target_version("auto") == expected
182+
183+
out, err = capsys.readouterr()
184+
assert (
185+
out
186+
== f"Detected Django version from pyproject.toml: {expected[0]}.{expected[1]}\n"
187+
)
188+
assert err == ""
189+
190+
191+
@pytest.mark.skipif(sys.version_info < (3, 11), reason="Python 3.11+")
192+
@pytest.mark.parametrize(
193+
"deps_line",
194+
[
195+
"django-upgrade>=1.0.0",
196+
"Django>=5.2 ; sys_platform == 'linux'",
197+
"Django>=5.2, <6.0, !=5.2.1",
198+
"Django>=0.0",
199+
"DJANGO[argon2]>=0.0",
200+
],
201+
)
202+
def test_get_target_version_auto_unmatched(tmp_path, capsys, deps_line):
203+
pyproject_content = f"""[project]
204+
dependencies = [
205+
"{deps_line}",
206+
]
207+
"""
208+
(tmp_path / "pyproject.toml").write_text(pyproject_content)
209+
210+
with chdir(tmp_path):
211+
assert get_target_version("auto") == (2, 2)
212+
213+
out, err = capsys.readouterr()
214+
assert out == ""
215+
assert err == ""
216+
217+
131218
def test_fixup_dedent_tokens():
132219
code = dedent(
133220
"""\

0 commit comments

Comments
 (0)