Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pyupgrade formatter plugin #755

Merged
merged 13 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/pylint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ jobs:
defusedxml \
pygments \
'pylint<=3.2.7' \
pylint-per-file-ignores \
pytest>=6.2.0 \
pyupgrade>=2.31.0 \
regex \
requests \
requests-cache \
Expand Down
13 changes: 5 additions & 8 deletions .github/workflows/pyupgrade.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,13 @@ jobs:

steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v3
- uses: actions/setup-python@v5
- name: Ensure modern Python style using pyupgrade
# This script is written in a Linux / macos / windows portable way
run: |
uvx --from pyupgrade python -c "
import sys
from pyupgrade._main import main
from glob import glob
files = glob('**/*.py', recursive=True)
sys.exit(main(files + ['--py39-plus']))
" || ( git diff ; false )
uvx \
--from '.[pyupgrade]' \
darker --formatter=pyupgrade --target-version=py39 --diff
9 changes: 8 additions & 1 deletion .github/workflows/test-working-directory.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
---
name: "GH Action `working-directory:` test"

# If these tests fail with "fatal: bad object" in the "Run Darker" step
# and "Error: Expected exit code <n>" in the "Check exit code" step,
# make sure `fetch-steps:` in `actions/checkout` below is
# at least as large as the number of commits in the branch the test is run in.

on: push # yamllint disable-line rule:truthy

jobs:
Expand Down Expand Up @@ -66,7 +71,9 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 10
# This needs to be at least the number of commits
# in the branch the test is run in:
fetch-depth: 30

- name: Download test repository
uses: actions/download-artifact@v3
Expand Down
7 changes: 5 additions & 2 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ Added
- Display exit code in parentheses after error message.
- Do not reformat renamed files.
- CI workflow to post recent project activity in a discussion. Triggered manually.
- CI "future" test now tests against ``main`` of Darkgraylib_ in addition to Black_,
Flynt_ and isort_.
- CI "future" test now tests against ``main`` of Darkgraylib_ and pyupgrade_ in addition
to Black_, Flynt_ and isort_.
- The ``--preview`` configuration flag is now supported in the configuration files for
Darker and Black
- Prevent Pylint from updating beyond version 3.2.7 due to dropped Python 3.8 support.
Expand All @@ -23,6 +23,8 @@ Added
Isort or Flynt_.
- Black_ is no longer installed by default. Use ``pip install 'darker[black]'`` to get
Black support.
- pyupgrade_ is now supported as a formatter plugin. Note that changes from pyupgrade
are applied on a per-file basis, not only for modified lines as with Black_ and Ruff_.

Removed
-------
Expand Down Expand Up @@ -697,3 +699,4 @@ Added
.. _Black: https://black.readthedocs.io/
.. _isort: https://pycqa.github.io/isort/
.. _NixOS: https://nixos.org/
.. _pyupgrade: https://pypi.org/project/pyupgrade/
3 changes: 2 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,8 @@ The following `command line arguments`_ can also be used to modify the defaults:
versions that should be supported by Black's output. [default: per-file auto-
detection]
--formatter FORMATTER
[black\|none\|ruff] Formatter to use for reformatting code. [default: black]
[black\|none\|pyupgrade\|ruff] Formatter to use for reformatting code. [default:
black]

To change default values for these options for a given project,
add a ``[tool.darker]`` section to ``pyproject.toml`` in the project's root directory,
Expand Down
1 change: 1 addition & 0 deletions constraints-future.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ black @ git+https://github.com/psf/black.git@main
darkgraylib @ git+https://github.com/akaihola/darkgraylib.git@main
flynt @ git+https://github.com/ikamensh/flynt.git@master
isort @ git+https://github.com/PyCQA/isort.git@main
pyupgrade @ git+https://github.com/asottile/pyupgrade.git@main
2 changes: 2 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
[mypy]
mypy_path = stubs/

disallow_any_unimported = True
disallow_any_expr = False
disallow_any_decorated = True
Expand Down
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,18 @@ revision = "origin/master..."
revision = "origin/master..."
src = ["."]

[tool.pylint.MASTER]
load-plugins = [
"pylint_per_file_ignores",
]

[tool.pylint."messages control"]
# Check import order only with isort. Pylint doesn't support a custom list of first
# party packages. We want to consider "darkgraylib" and "graylint" as first party.
disable = ["wrong-import-order"]
per-file-ignores = [
"/stubs/:missing-class-docstring,missing-function-docstring,unused-argument",
]

[tool.ruff]
target-version = "py39"
Expand All @@ -47,6 +55,7 @@ ignore = [
"D203", # One blank line required before class docstring
"D213", # Multi-line docstring summary should start at the second line
"D400", # First line should end with a period (duplicates D415)
"ISC001", # Checks for implicitly concatenated strings on a single line
]

[tool.ruff.lint.per-file-ignores]
Expand Down
5 changes: 5 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ darker =
darker.formatter =
black = darker.formatters.black_formatter:BlackFormatter
ruff = darker.formatters.ruff_formatter:RuffFormatter
pyupgrade = darker.formatters.pyupgrade_formatter:PyupgradeFormatter
none = darker.formatters.none_formatter:NoneFormatter
console_scripts =
darker = darker.__main__:main_with_error_handling
Expand All @@ -59,6 +60,8 @@ isort =
isort>=5.0.1
color =
Pygments>=2.4.0
pyupgrade =
pyupgrade>=2.31.0
test =
# NOTE: remember to keep `constraints-oldest.txt` in sync with these
black>=22.3.0
Expand All @@ -72,8 +75,10 @@ test =
pydocstyle
pygments
pylint<=3.2.7 # pylint 3.3.0 dropped Python 3.8 support
pylint-per-file-ignores
pytest>=6.2.0
pytest-kwparametrize>=0.0.3
pyupgrade>=2.31.0
regex>=2021.4.4
requests_cache>=0.7
ruamel.yaml>=0.17.21
Expand Down
52 changes: 27 additions & 25 deletions src/darker/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,17 @@ def _reformat_and_flynt_single_file( # noqa: PLR0913
"no" if formatted == fstringified else "some",
len(formatted.lines),
)
# 4. apply all re-formatter modifications if the re-formatter doesn't guarantee
# preserving the abstract syntax tree (AST); otherwise do steps 5 to 10
if not formatter.preserves_ast:
logger.debug(
"Preserving the AST not guaranteed by %s, applying all changes",
formatter.name,
)
return formatted

# 4. get a diff between the edited to-file and the processed content
# 5. convert the diff into chunks, keeping original and reformatted content for each
# 5. get a diff between the edited to-file and the processed content
# 6. convert the diff into chunks, keeping original and reformatted content for each
# chunk
new_chunks = diff_chunks(rev2_isorted, formatted)

Expand Down Expand Up @@ -337,9 +345,9 @@ def _drop_changes_on_unedited_lines(
context_lines,
abspath_in_rev2,
)
# 6. diff the given revisions (optionally with isort modifications) for each
# 7. diff the given revisions (optionally with isort modifications) for each
# file
# 7. extract line numbers in each edited to-file for changed lines
# 8. extract line numbers in each edited to-file for changed lines
edited_linenums = edited_linenums_differ.revision_vs_lines(
relpath_in_repo, rev2_isorted, context_lines
)
Expand All @@ -348,7 +356,7 @@ def _drop_changes_on_unedited_lines(
last_successful_reformat = rev2_isorted
break

# 8. choose processed content for each chunk if there were any changed lines
# 9. choose processed content for each chunk if there were any changed lines
# inside the chunk in the edited to-file, or choose the chunk's original
# contents if no edits were done in that chunk
chosen = TextDocument.from_lines(
Expand All @@ -358,8 +366,8 @@ def _drop_changes_on_unedited_lines(
mtime=datetime.utcnow().strftime(GIT_DATEFORMAT),
)

# 9. verify that the resulting reformatted source code parses to an identical
# AST as the original edited to-file
# 10. verify that the resulting reformatted source code parses to an identical
# AST as the original edited to-file
if not has_fstring_changes and not verifier.is_equivalent_to_baseline(chosen):
logger.debug(
"Verifying that the %s original edited lines and %s reformatted lines "
Expand Down Expand Up @@ -459,32 +467,26 @@ def _import_pygments(): # type: ignore
def main( # noqa: C901,PLR0912,PLR0915
argv: List[str] = None,
) -> int:
"""Parse the command line and reformat and optionally lint each source file
"""Parse the command line and reformat and optionally lint each source file.

1. run isort on each edited file (optional)
2. run flynt (optional) on the isorted contents of each edited to-file
3. run a code re-formatter on the isorted and fstringified contents of each edited
to-file
4. get a diff between the edited to-file and the processed content
5. convert the diff into chunks, keeping original and reformatted content for each
4. apply all re-formatter modifications if the re-formatter doesn't guarantee
preserving the abstract syntax tree (AST); otherwise do steps 5 to 10
5. get a diff between the edited to-file and the processed content
6. convert the diff into chunks, keeping original and reformatted content for each
chunk
6. diff the given revisions (optionally with isort modifications) for each
7. diff the given revisions (optionally with isort modifications) for each
file
7. extract line numbers in each edited to-file for changed lines
8. choose processed content for each chunk if there were any changed lines inside
8. extract line numbers in each edited to-file for changed lines
9. choose processed content for each chunk if there were any changed lines inside
the chunk in the edited to-file, or choose the chunk's original contents if no
edits were done in that chunk
9. verify that the resulting reformatted source code parses to an identical AST as
the original edited to-file
9. write the reformatted source back to the original file or print the diff
10. run linter subprocesses twice for all modified and unmodified files which are
mentioned on the command line: first establish a baseline by running against
``rev1``, then get current linting status by running against the working tree
(steps 10.-12. are optional)
11. create a mapping from line numbers of unmodified lines in the current versions
to corresponding line numbers in ``rev1``
12. hide linter messages which appear in the current versions and identically on
corresponding lines in ``rev1``, and show all other linter messages
10. verify that the resulting reformatted source code parses to an identical AST as
the original edited to-file
11. write the reformatted source back to the original file or print the diff

:param argv: The command line arguments to the ``darker`` command
:return: 1 if the ``--check`` argument was provided and at least one file was (or
Expand Down Expand Up @@ -611,7 +613,7 @@ def main( # noqa: C901,PLR0912,PLR0915
workers=config["workers"],
),
):
# 10. A re-formatted Python file which produces an identical AST was
# 11. A re-formatted Python file which produces an identical AST was
# created successfully - write an updated file or print the diff if
# there were any changes to the original
formatting_failures_on_modified_lines = True
Expand Down
1 change: 1 addition & 0 deletions src/darker/formatters/base_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class BaseFormatter(HasConfig[FormatterConfig]):
"""Base class for code re-formatters."""

name: str
preserves_ast: bool

def read_config(self, src: tuple[str, ...], args: Namespace) -> None:
"""Read code re-formatter configuration from a configuration file.
Expand Down
1 change: 1 addition & 0 deletions src/darker/formatters/black_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class BlackFormatter(BaseFormatter, HasConfig[BlackCompatibleConfig]):

name = "black"
config_section = "tool.black"
preserves_ast = True

def read_config(self, src: tuple[str, ...], args: Namespace) -> None:
"""Read Black configuration from ``pyproject.toml``.
Expand Down
1 change: 1 addition & 0 deletions src/darker/formatters/none_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class NoneFormatter(BaseFormatter):
"""A dummy code formatter plugin interface."""

name = "dummy reformat"
preserves_ast = True

def run(
self, content: TextDocument, path_from_cwd: Path # noqa: ARG002
Expand Down
11 changes: 11 additions & 0 deletions src/darker/formatters/pyupgrade_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Pyupgrade code formatter plugin configuration type definitions."""

from __future__ import annotations

from darker.formatters.formatter_config import FormatterConfig


class PyupgradeConfig(FormatterConfig, total=False):
"""Type definition for configuration dictionaries of Black compatible formatters."""

target_version: tuple[int, int]
Loading
Loading