Skip to content

Commit

Permalink
Merge pull request #165 from akaihola/reformat-to-stdout
Browse files Browse the repository at this point in the history
Add the `-d` / `--stdout` option. Fixes #164.
  • Loading branch information
akaihola authored Jul 30, 2021
2 parents 9006074 + 123a6fd commit ef0fe9f
Show file tree
Hide file tree
Showing 9 changed files with 282 additions and 39 deletions.
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Added
-----
- Support for Black's ``--skip-magic-trailing-comma`` option
- ``darker --diff`` output is now identical to that of ``black --diff``
- The ``-d`` / ``--stdout`` option outputs the reformatted contents of the single Python
file provided on the command line.

Fixed
-----
Expand Down
18 changes: 17 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,18 @@ which makes edited lines conform to Black rules:
print()
if False: print('there')
If you omit the ``--diff`` option,
Alternatively, Darker can output the full reformatted file
(works only when a single Python file is provided on the command line):

.. code-block:: python
$ darker --stdout our_file.py
if True:
print("CHANGED TEXT")
print()
if False: print('there')
If you omit the ``--diff`` and ``--stdout`` options,
Darker replaces the files listed on the command line
with partially reformatted ones as shown above:

Expand Down Expand Up @@ -226,6 +237,9 @@ The following `command line arguments`_ can also be used to modify the defaults:
each file on stdout. Highlight syntax on screen if
the `pygments` package is available.
-d, --stdout Force complete reformatted output to stdout, instead of
in-place. Only valid if there's just one file to reformat.
--check Don't write the files back, just return the status.
Return code 0 means nothing would change. Return code
1 means some files would be reformatted.
Expand Down Expand Up @@ -292,6 +306,8 @@ For example:

*New in version 1.3.0:* Support for command line option ``--skip-magic-trailing-comma``

*New in version 1.3.0:* The ``-d`` / ``--stdout`` command line option

.. _Black documentation about pyproject.toml: https://black.readthedocs.io/en/stable/pyproject_toml.html
.. _isort documentation about config files: https://timothycrosley.github.io/isort/docs/configuration/config_files/
.. _command line arguments: https://black.readthedocs.io/en/stable/installation_and_usage.html#command-line-options
Expand Down
44 changes: 38 additions & 6 deletions src/darker/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from darker.black_diff import BlackArgs, run_black
from darker.chooser import choose_lines
from darker.command_line import parse_command_line
from darker.config import dump_config
from darker.config import OutputMode, dump_config
from darker.diff import diff_and_get_opcodes, opcodes_to_chunks
from darker.git import (
WORKTREE,
Expand All @@ -35,6 +35,7 @@ def format_edited_parts(
revrange: RevisionRange,
enable_isort: bool,
black_args: BlackArgs,
report_unmodified: bool,
) -> Generator[Tuple[Path, TextDocument, TextDocument], None, None]:
"""Black (and optional isort) formatting for chunks with edits since the last commit
Expand All @@ -44,6 +45,7 @@ def format_edited_parts(
:param revrange: The Git revisions to compare
:param enable_isort: ``True`` to also run ``isort`` first on each changed file
:param black_args: Command-line arguments to send to ``black.FileMode``
:param report_unmodified: ``True`` to yield also files which weren't modified
:return: A generator which yields details about changes for each file which should
be reformatted, and skips unchanged files.
Expand Down Expand Up @@ -139,7 +141,7 @@ def format_edited_parts(
# created successfully - write an updated file or print the diff if
# there were any changes to the original
src, rev2_content, chosen = last_successful_reformat
if chosen != rev2_content:
if report_unmodified or chosen != rev2_content:
yield (src, rev2_content, chosen)


Expand Down Expand Up @@ -186,6 +188,22 @@ def print_diff(path: Path, old: TextDocument, new: TextDocument) -> None:
print(diff)


def print_source(new: TextDocument) -> None:
"""Print the reformatted Python source code"""
if sys.stdout.isatty():
try:
# pylint: disable=import-outside-toplevel
from pygments import highlight
from pygments.formatters import TerminalFormatter
from pygments.lexers.python import PythonLexer
except ImportError:
print(new.string)
else:
print(highlight(new.string, PythonLexer(), TerminalFormatter()))
else:
print(new.string)


def main(argv: List[str] = None) -> int:
"""Parse the command line and reformat and optionally lint each source file
Expand Down Expand Up @@ -251,20 +269,34 @@ def main(argv: List[str] = None) -> int:
failures_on_modified_lines = False

revrange = RevisionRange.parse(args.revision)
write_modified_files = not args.check and not args.diff
output_mode = OutputMode.from_args(args)
write_modified_files = not args.check and output_mode == OutputMode.NOTHING
if revrange.rev2 != WORKTREE and write_modified_files:
raise ArgumentError(
Action(["-r", "--revision"], "revision"),
f"Can't write reformatted files for revision '{revrange.rev2}'."
" Either --diff or --check must be used.",
)
changed_files = git_get_modified_files(paths, revrange, git_root)
if output_mode == OutputMode.CONTENT:
# With `-d` / `--stdout`, process the file whether modified or not. Paths have
# previously been validated to contain exactly one existing file.
changed_files = paths
else:
# In other modes, only process files which have been modified.
changed_files = git_get_modified_files(paths, revrange, git_root)
for path, old, new in format_edited_parts(
git_root, changed_files, revrange, args.isort, black_args
git_root,
changed_files,
revrange,
args.isort,
black_args,
report_unmodified=output_mode == OutputMode.CONTENT,
):
failures_on_modified_lines = True
if args.diff:
if output_mode == OutputMode.DIFF:
print_diff(path, old, new)
elif output_mode == OutputMode.CONTENT:
print_source(new)
if write_modified_files:
modify_file(path, new)
if run_linters(args.lint, git_root, changed_files, revrange):
Expand Down
9 changes: 8 additions & 1 deletion src/darker/command_line.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from darker.argparse_helpers import LogLevelAction, NewlinePreservingFormatter
from darker.config import (
DarkerConfig,
OutputMode,
get_effective_config,
get_modified_config,
load_config,
Expand All @@ -31,6 +32,7 @@ def add_arg(help_text: Optional[Text], *name_or_flags: Text, **kwargs: Any) -> N
add_arg(hlp.SRC, "src", nargs="+" if require_src else "*", metavar="PATH")
add_arg(hlp.REVISION, "-r", "--revision", default="HEAD")
add_arg(hlp.DIFF, "--diff", action="store_true")
add_arg(hlp.STDOUT, "-d", "--stdout", action="store_true")
add_arg(hlp.CHECK, "--check", action="store_true")
add_arg(hlp.ISORT, "-i", "--isort", action="store_true")
add_arg(hlp.LINT, "-L", "--lint", action="append", metavar="CMD", default=[])
Expand Down Expand Up @@ -94,7 +96,12 @@ def parse_command_line(argv: List[str]) -> Tuple[Namespace, DarkerConfig, Darker
parser.set_defaults(**config)
args = parser.parse_args(argv)

# 4. Also create a parser which uses the original default configuration values.
# 4. Make sure there aren't invalid option combinations after merging configuration
# and command line options.
OutputMode.validate_diff_stdout(args.diff, args.stdout)
OutputMode.validate_stdout_src(args.stdout, args.src)

# 5. Also create a parser which uses the original default configuration values.
# This is used to find out differences between the effective configuration and
# default configuration values, and print them out in verbose mode.
parser_with_original_defaults = make_argument_parser(require_src=True)
Expand Down
97 changes: 87 additions & 10 deletions src/darker/config.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
"""Load and save configuration in TOML format"""

import logging
import sys
from argparse import ArgumentParser, Namespace
from typing import Dict, Iterable, List, Union, cast
from pathlib import Path
from typing import Iterable, List, cast

import toml
from black import find_project_root

if sys.version_info >= (3, 8):
from typing import TypedDict
else:
from typing_extensions import TypedDict


class TomlArrayLinesEncoder(toml.TomlEncoder): # type: ignore[name-defined]
"""Format TOML so list items are each on their own line"""
Expand All @@ -18,13 +25,76 @@ def dump_list(self, v: List[str]) -> str:
)


DarkerConfig = Dict[str, Union[str, bool, List[str]]]
class DarkerConfig(TypedDict, total=False):
"""Dictionary representing ``[tool.darker]`` from ``pyproject.toml``"""

src: List[str]
revision: str
diff: bool
stdout: bool
check: bool
isort: bool
lint: List[str]
config: str
log_level: int
skip_string_normalization: bool
skip_magic_trailing_comma: bool
line_length: int


class OutputMode:
"""The output mode to use: all file content, just the diff, or no output"""

NOTHING = "NOTHING"
DIFF = "DIFF"
CONTENT = "CONTENT"

@classmethod
def from_args(cls, args: Namespace) -> str:
"""Resolve output mode based on ``diff`` and ``stdout`` options"""
OutputMode.validate_diff_stdout(args.diff, args.stdout)
if args.diff:
return cls.DIFF
if args.stdout:
return cls.CONTENT
return cls.NOTHING

@staticmethod
def validate_diff_stdout(diff: bool, stdout: bool) -> None:
"""Raise an exception if ``diff`` and ``stdout`` options are both enabled"""
if diff and stdout:
raise ConfigurationError(
"The `diff` and `stdout` options can't both be enabled"
)

@staticmethod
def validate_stdout_src(stdout: bool, src: List[str]) -> None:
"""Raise an exception in ``stdout`` mode if not exactly one path is provided"""
if not stdout:
return
if len(src) == 1 and Path(src[0]).is_file():
return
raise ConfigurationError(
"Exactly one Python source file which exists on disk must be provided when"
" using the `stdout` option"
)


class ConfigurationError(Exception):
"""Exception class for invalid configuration values"""


def replace_log_level_name(config: DarkerConfig) -> None:
"""Replace numeric log level in configuration with the name of the log level"""
if "log_level" in config:
config["log_level"] = logging.getLevelName(cast(int, config["log_level"]))
config["log_level"] = logging.getLevelName(config["log_level"])


def validate_config_output_mode(config: DarkerConfig) -> None:
"""Make sure both ``diff`` and ``stdout`` aren't enabled in configuration"""
OutputMode.validate_diff_stdout(
config.get("diff", False), config.get("stdout", False)
)


def load_config(srcs: Iterable[str]) -> DarkerConfig:
Expand All @@ -37,26 +107,33 @@ def load_config(srcs: Iterable[str]) -> DarkerConfig:
path = find_project_root(tuple(srcs or ["."])) / "pyproject.toml"
if path.is_file():
pyproject_toml = toml.load(path)
config: DarkerConfig = pyproject_toml.get("tool", {}).get("darker", {}) or {}
config = cast(
DarkerConfig, pyproject_toml.get("tool", {}).get("darker", {}) or {}
)
replace_log_level_name(config)
validate_config_output_mode(config)
return config
return {}


def get_effective_config(args: Namespace) -> DarkerConfig:
"""Return all configuration options"""
config = vars(args).copy()
config = cast(DarkerConfig, vars(args).copy())
replace_log_level_name(config)
validate_config_output_mode(config)
return config


def get_modified_config(parser: ArgumentParser, args: Namespace) -> DarkerConfig:
"""Return configuration options which are set to non-default values"""
not_default = {
argument: value
for argument, value in vars(args).items()
if value != parser.get_default(argument)
}
not_default = cast(
DarkerConfig,
{
argument: value
for argument, value in vars(args).items()
if value != parser.get_default(argument)
},
)
replace_log_level_name(not_default)
return not_default

Expand Down
5 changes: 5 additions & 0 deletions src/darker/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
" reformatted."
)

STDOUT = (
"Force complete reformatted output to stdout, instead of in-place. Only valid if"
" there's just one file to reformat."
)

ISORT_PARTS = ["Also sort imports using the `isort` package"]
if not isort:
ISORT_PARTS.append(f". {ISORT_INSTRUCTION} to enable usage of this option.")
Expand Down
Loading

0 comments on commit ef0fe9f

Please sign in to comment.