Skip to content

Commit

Permalink
Merge pull request #306 from akaihola/stdin-filepath
Browse files Browse the repository at this point in the history
  • Loading branch information
akaihola authored Feb 11, 2023
2 parents f90ff54 + 9822d02 commit adfa90e
Show file tree
Hide file tree
Showing 14 changed files with 645 additions and 132 deletions.
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ Added
``rev2`` to get the current situation. Old linter messages which fall on unmodified
lines are hidden, so effectively the user gets new linter messages introduced by
latest changes, as well as persistent linter messages on modified lines.
- ``--stdin-filename=PATH`` now allows reading contents of a single file from standard
input. This also makes ``:STDIN:``, a new magic value, the default ``rev2`` for
``--revision``.
- Add configuration for ``darglint`` and ``flake8-docstrings``, preparing for enabling
those linters in CI builds.

Expand Down
19 changes: 12 additions & 7 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -315,13 +315,14 @@ For more details, see:
The following `command line arguments`_ can also be used to modify the defaults:

-r REV, --revision REV
Git revision against which to compare the working tree. Tags, branch names,
commit hashes, and other expressions like ``HEAD~5`` work here. Also a range like
``master...HEAD`` or ``master...`` can be used to compare the best common
ancestor. With the magic value ``:PRE-COMMIT:``, Darker works in pre-commit
compatible mode. Darker expects the revision range from the
``PRE_COMMIT_FROM_REF`` and ``PRE_COMMIT_TO_REF`` environment variables. If those
are not found, Darker works against ``HEAD``.
Revisions to compare. The default is ``HEAD..:WORKTREE:`` which compares the
latest commit to the working tree. Tags, branch names, commit hashes, and other
expressions like ``HEAD~5`` work here. Also a range like ``main...HEAD`` or
``main...`` can be used to compare the best common ancestor. With the magic value
``:PRE-COMMIT:``, Darker works in pre-commit compatible mode. Darker expects the
revision range from the ``PRE_COMMIT_FROM_REF`` and ``PRE_COMMIT_TO_REF``
environment variables. If those are not found, Darker works against ``HEAD``.
Also see ``--stdin-filename=`` for the ``:STDIN:`` special value.
--diff
Don't write the files back, just output a diff for each file on stdout. Highlight
syntax if on a terminal and the ``pygments`` package is available, or if enabled
Expand All @@ -330,6 +331,10 @@ The following `command line arguments`_ can also be used to modify the defaults:
Force complete reformatted output to stdout, instead of in-place. Only valid if
there's just one file to reformat. Highlight syntax if on a terminal and the
``pygments`` package is available, or if enabled by configuration.
--stdin-filename PATH
The path to the file when passing it through stdin. Useful so Darker can find the
previous version from Git. Only valid with ``--revision=<rev1>..:STDIN:``
(``HEAD..:STDIN:`` being the default if ``--stdin-filename`` is enabled).
--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
48 changes: 31 additions & 17 deletions src/darker/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from darker.fstring import apply_flynt, flynt
from darker.git import (
PRE_COMMIT_FROM_TO_REFS,
STDIN,
WORKTREE,
EditedLinenumsDiffer,
RevisionRange,
Expand Down Expand Up @@ -129,9 +130,12 @@ def _modify_and_reformat_single_file( # pylint: disable=too-many-arguments
# With VSCode, `relative_path_in_rev2` may be a `.py.<HASH>.tmp` file in the
# working tree instead of a `.py` file.
absolute_path_in_rev2 = root / relative_path_in_rev2
rev2_content = git_get_content_at_revision(
relative_path_in_rev2, revrange.rev2, root
)
if revrange.rev2 == STDIN:
rev2_content = TextDocument.from_bytes(sys.stdin.buffer.read())
else:
rev2_content = git_get_content_at_revision(
relative_path_in_rev2, revrange.rev2, root
)
# 1. run isort on each edited file (optional).
rev2_isorted = apply_isort(
rev2_content,
Expand Down Expand Up @@ -515,10 +519,16 @@ def main( # pylint: disable=too-many-locals,too-many-branches,too-many-statemen
if args.skip_magic_trailing_comma is not None:
black_config["skip_magic_trailing_comma"] = args.skip_magic_trailing_comma

paths = {Path(p) for p in args.src}
stdin_mode = args.stdin_filename is not None
if stdin_mode:
paths = {Path(args.stdin_filename)}
# `parse_command_line` guarantees that `args.src` is empty
else:
paths = {Path(p) for p in args.src}
# `parse_command_line` guarantees that `args.stdin_filename` is `None`
root = get_common_root(paths)

revrange = RevisionRange.parse_with_common_ancestor(args.revision, root)
revrange = RevisionRange.parse_with_common_ancestor(args.revision, root, stdin_mode)
output_mode = OutputMode.from_args(args)
write_modified_files = not args.check and output_mode == OutputMode.NOTHING
if write_modified_files:
Expand All @@ -528,30 +538,34 @@ def main( # pylint: disable=too-many-locals,too-many-branches,too-many-statemen
" As an experimental feature, allowing overwriting of files."
" See https://github.com/akaihola/darker/issues/180 for details."
)
elif revrange.rev2 != WORKTREE:
elif revrange.rev2 not in {STDIN, WORKTREE}:
raise ArgumentError(
Action(["-r", "--revision"], "revision"),
f"Can't write reformatted files for revision {revrange.rev2!r}."
" Either --diff or --check must be used.",
)

missing = get_missing_at_revision(paths, revrange.rev2, root)
if missing:
missing_reprs = " ".join(repr(str(path)) for path in missing)
rev2_repr = "the working tree" if revrange.rev2 == WORKTREE else revrange.rev2
raise ArgumentError(
Action(["PATH"], "path"),
f"Error: Path(s) {missing_reprs} do not exist in {rev2_repr}",
)
if revrange.rev2 != STDIN:
missing = get_missing_at_revision(paths, revrange.rev2, root)
if missing:
missing_reprs = " ".join(repr(str(path)) for path in missing)
rev2_repr = (
"the working tree" if revrange.rev2 == WORKTREE else revrange.rev2
)
raise ArgumentError(
Action(["PATH"], "path"),
f"Error: Path(s) {missing_reprs} do not exist in {rev2_repr}",
)

# These paths are relative to `root`:
files_to_process = filter_python_files(paths, root, {})
files_to_blacken = filter_python_files(paths, root, black_config)
# Now decide which files to reformat (Black & isort). Note that this doesn't apply
# to linting.
if output_mode == OutputMode.CONTENT:
# With `-d` / `--stdout`, reformat the file whether modified or not. Paths have
# previously been validated to contain exactly one existing file.
if output_mode == OutputMode.CONTENT or revrange.rev2 == STDIN:
# With `-d` / `--stdout` and `--stdin-filename`, process the file whether
# modified or not. Paths have previously been validated to contain exactly one
# existing file.
changed_files_to_reformat = files_to_process
black_exclude = set()
else:
Expand Down
36 changes: 26 additions & 10 deletions src/darker/command_line.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
get_modified_config,
load_config,
override_color_with_environment,
validate_stdin_src,
)
from darker.version import __version__

Expand All @@ -42,6 +43,7 @@ def add_arg(help_text: Optional[str], *name_or_flags: str, **kwargs: Any) -> Non
add_arg(hlp.REVISION, "-r", "--revision", default="HEAD", metavar="REV")
add_arg(hlp.DIFF, "--diff", action="store_true")
add_arg(hlp.STDOUT, "-d", "--stdout", action="store_true")
add_arg(hlp.STDIN_FILENAME, "--stdin-filename", metavar="PATH")
add_arg(hlp.CHECK, "--check", action="store_true")
add_arg(hlp.FLYNT, "-f", "--flynt", action="store_true")
add_arg(hlp.ISORT, "-i", "--isort", action="store_true")
Expand Down Expand Up @@ -114,31 +116,45 @@ def parse_command_line(argv: List[str]) -> Tuple[Namespace, DarkerConfig, Darker
Finally, also return the set of configuration options which differ from defaults.
"""
# 1. Parse the paths of files/directories to process into `args.src`.
# 1. Parse the paths of files/directories to process into `args.src`, and the config
# file path into `args.config`.
parser_for_srcs = make_argument_parser(require_src=False)
args = parser_for_srcs.parse_args(argv)

# 2. Locate `pyproject.toml` based on the `-c`/`--config` command line option, or
# if it's not provided, based on the paths to process, or in the current
# directory if no paths were given. Load Darker configuration from it.
pyproject_config = load_config(args.config, args.src)

# 3. The PY_COLORS, NO_COLOR and FORCE_COLOR environment variables override the
# `--color` command line option.
config = override_color_with_environment(pyproject_config)

# 3. Use configuration as defaults for re-parsing command line arguments, and don't
# require file/directory paths if they are specified in configuration.
parser = make_argument_parser(require_src=not config.get("src"))
parser.set_defaults(**config)
args = parser.parse_args(argv)
# 4. Re-run the parser with configuration defaults. This way we get combined values
# based on the configuration file and the command line options for all options
# except `src` (the list of files to process).
parser_for_srcs.set_defaults(**config)
args = parser_for_srcs.parse_args(argv)

# 5. Make sure an error for missing file/directory paths is thrown if we're not
# running in stdin mode and no file/directory is configured in `pyproject.toml`.
if args.stdin_filename is None and not config.get("src"):
parser = make_argument_parser(require_src=True)
parser.set_defaults(**config)
args = parser.parse_args(argv)

# 4. Make sure there aren't invalid option combinations after merging configuration
# 6. 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)
OutputMode.validate_stdout_src(args.stdout, args.src, args.stdin_filename)
validate_stdin_src(args.stdin_filename, args.src)

# 5. Also create a parser which uses the original default configuration values.
# 7. 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)
parser_with_original_defaults = make_argument_parser(
require_src=args.stdin_filename is None
)
return (
args,
get_effective_config(args),
Expand Down
25 changes: 20 additions & 5 deletions src/darker/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,19 @@ def validate_diff_stdout(diff: bool, stdout: bool) -> None:
)

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


Expand All @@ -101,6 +105,17 @@ def validate_config_output_mode(config: DarkerConfig) -> None:
)


def validate_stdin_src(stdin_filename: Optional[str], src: List[str]) -> None:
"""Make sure both ``stdin`` mode and paths/directories are specified"""
if stdin_filename is None:
return
if len(src) == 0:
return
raise ConfigurationError(
"No Python source files are allowed when using the `stdin-filename` option"
)


def override_color_with_environment(pyproject_config: DarkerConfig) -> DarkerConfig:
"""Override ``color`` if the ``PY_COLORS`` environment variable is '0' or '1'
Expand Down
53 changes: 43 additions & 10 deletions src/darker/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def shlex_join(split_command: Iterable[str]) -> str:
# - referring to the `PRE_COMMIT_FROM_REF` and `PRE_COMMIT_TO_REF` environment variables
# for determining the revision range
WORKTREE = ":WORKTREE:"
STDIN = ":STDIN:"
PRE_COMMIT_FROM_TO_REFS = ":PRE-COMMIT:"


Expand Down Expand Up @@ -175,34 +176,55 @@ class RevisionRange:

@classmethod
def parse_with_common_ancestor(
cls, revision_range: str, cwd: Path
cls, revision_range: str, cwd: Path, stdin_mode: bool
) -> "RevisionRange":
"""Convert a range expression to a ``RevisionRange`` object
If the expression contains triple dots (e.g. ``master...HEAD``), finds the
common ancestor of the two revisions and uses that as the first revision.
:param revision_range: The revision range as a string to parse
:param cwd: The working directory to use if invoking Git
:param stdin_mode: If `True`, the default for ``rev2`` is ``:STDIN:``
:return: The range parsed into a `RevisionRange` object
"""
rev1, rev2, use_common_ancestor = cls._parse(revision_range)
rev1, rev2, use_common_ancestor = cls._parse(revision_range, stdin_mode)
if use_common_ancestor:
return cls._with_common_ancestor(rev1, rev2, cwd)
return cls(rev1, rev2)

@staticmethod
def _parse(revision_range: str) -> Tuple[str, str, bool]:
def _parse(revision_range: str, stdin_mode: bool) -> Tuple[str, str, bool]:
"""Convert a range expression to revisions, using common ancestor if appropriate
>>> RevisionRange._parse("a..b")
A `ValueError` is raised if ``--stdin-filename`` is used by the revision range
is ``:PRE-COMMIT:`` or the end of the range is not ``:STDIN:``.
:param revision_range: The revision range as a string to parse
:param stdin_mode: If `True`, the default for ``rev2`` is ``:STDIN:``
:raises ValueError: for an invalid revision when ``--stdin-filename`` is used
:return: The range parsed into a `RevisionRange` object
>>> RevisionRange._parse("a..b", stdin_mode=False)
('a', 'b', False)
>>> RevisionRange._parse("a...b")
>>> RevisionRange._parse("a...b", stdin_mode=False)
('a', 'b', True)
>>> RevisionRange._parse("a..")
>>> RevisionRange._parse("a..", stdin_mode=False)
('a', ':WORKTREE:', False)
>>> RevisionRange._parse("a...")
>>> RevisionRange._parse("a...", stdin_mode=False)
('a', ':WORKTREE:', True)
>>> RevisionRange._parse("a..", stdin_mode=True)
('a', ':STDIN:', False)
>>> RevisionRange._parse("a...", stdin_mode=True)
('a', ':STDIN:', True)
"""
if revision_range == PRE_COMMIT_FROM_TO_REFS:
if stdin_mode:
raise ValueError(
f"With --stdin-filename, revision {revision_range!r} is not allowed"
)
try:
return (
os.environ["PRE_COMMIT_FROM_REF"],
Expand All @@ -213,16 +235,27 @@ def _parse(revision_range: str) -> Tuple[str, str, bool]:
# Fallback to running against HEAD
revision_range = "HEAD"
match = COMMIT_RANGE_RE.match(revision_range)
default_rev2 = STDIN if stdin_mode else WORKTREE
if match:
rev1, range_dots, rev2 = match.groups()
use_common_ancestor = range_dots == "..."
return (rev1 or "HEAD", rev2 or WORKTREE, use_common_ancestor)
return (revision_range or "HEAD", WORKTREE, revision_range not in ["", "HEAD"])
effective_rev2 = rev2 or default_rev2
if stdin_mode and effective_rev2 != STDIN:
raise ValueError(
f"With --stdin-filename, rev2 in {revision_range} must be"
f" {STDIN!r}, not {effective_rev2!r}"
)
return (rev1 or "HEAD", rev2 or default_rev2, use_common_ancestor)
return (
revision_range or "HEAD",
default_rev2,
revision_range not in ["", "HEAD"],
)

@classmethod
def _with_common_ancestor(cls, rev1: str, rev2: str, cwd: Path) -> "RevisionRange":
"""Find common ancestor for revisions and return a ``RevisionRange`` object"""
rev2_for_merge_base = "HEAD" if rev2 == WORKTREE else rev2
rev2_for_merge_base = "HEAD" if rev2 in [WORKTREE, STDIN] else rev2
merge_base_cmd = ["merge-base", rev1, rev2_for_merge_base]
common_ancestor = _git_check_output_lines(merge_base_cmd, cwd)[0]
rev1_hash = _git_check_output_lines(["show", "-s", "--pretty=%H", rev1], cwd)[0]
Expand Down
21 changes: 14 additions & 7 deletions src/darker/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,14 @@ def get_extra_instruction(dependency: str) -> str:
SRC = "Path(s) to the Python source file(s) to reformat"

REVISION = (
"Git revision against which to compare the working tree. Tags, branch names, commit"
" hashes, and other expressions like `HEAD~5` work here. Also a range like"
" `master...HEAD` or `master...` can be used to compare the best common ancestor."
" With the magic value `:PRE-COMMIT:`, Darker works in pre-commit compatible mode."
" Darker expects the revision range from the `PRE_COMMIT_FROM_REF` and"
" `PRE_COMMIT_TO_REF` environment variables. If those are not found, Darker works"
" against `HEAD`."
"Revisions to compare. The default is `HEAD..:WORKTREE:` which compares the latest"
" commit to the working tree. Tags, branch names, commit hashes, and other"
" expressions like `HEAD~5` work here. Also a range like `main...HEAD` or `main...`"
" can be used to compare the best common ancestor. With the magic value"
" `:PRE-COMMIT:`, Darker works in pre-commit compatible mode. Darker expects the"
" revision range from the `PRE_COMMIT_FROM_REF` and `PRE_COMMIT_TO_REF` environment"
" variables. If those are not found, Darker works against `HEAD`. Also see"
" `--stdin-filename=` for the `:STDIN:` special value."
)

DIFF = (
Expand All @@ -77,6 +78,12 @@ def get_extra_instruction(dependency: str) -> str:
" `pygments` package is available, or if enabled by configuration."
)

STDIN_FILENAME = (
"The path to the file when passing it through stdin. Useful so Darker can find the"
" previous version from Git. Only valid with `--revision=<rev1>..:STDIN:`"
" (`HEAD..:STDIN:` being the default if `--stdin-filename` is enabled)."
)

FLYNT_PARTS = [
"Also convert string formatting to use f-strings using the `flynt` package"
]
Expand Down
Loading

0 comments on commit adfa90e

Please sign in to comment.