diff --git a/CHANGES.rst b/CHANGES.rst index 2ab13e73d..f3a0941d5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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. diff --git a/README.rst b/README.rst index 3c2ace819..07ba7dcbb 100644 --- a/README.rst +++ b/README.rst @@ -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 @@ -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=..: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. diff --git a/src/darker/__main__.py b/src/darker/__main__.py index 8c9a38d16..34480c115 100644 --- a/src/darker/__main__.py +++ b/src/darker/__main__.py @@ -25,6 +25,7 @@ from darker.fstring import apply_flynt, flynt from darker.git import ( PRE_COMMIT_FROM_TO_REFS, + STDIN, WORKTREE, EditedLinenumsDiffer, RevisionRange, @@ -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..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, @@ -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: @@ -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: diff --git a/src/darker/command_line.py b/src/darker/command_line.py index c3e5d7fe5..32ca97b04 100644 --- a/src/darker/command_line.py +++ b/src/darker/command_line.py @@ -18,6 +18,7 @@ get_modified_config, load_config, override_color_with_environment, + validate_stdin_src, ) from darker.version import __version__ @@ -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") @@ -114,7 +116,8 @@ 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) @@ -122,23 +125,36 @@ def parse_command_line(argv: List[str]) -> Tuple[Namespace, DarkerConfig, Darker # 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), diff --git a/src/darker/config.py b/src/darker/config.py index ef3577f81..6709a2d8a 100644 --- a/src/darker/config.py +++ b/src/darker/config.py @@ -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= or exactly one Python source file which" + " exists on disk must be provided when using the `stdout` option" ) @@ -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' diff --git a/src/darker/git.py b/src/darker/git.py index 23f15810a..88b90e62d 100644 --- a/src/darker/git.py +++ b/src/darker/git.py @@ -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:" @@ -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"], @@ -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] diff --git a/src/darker/help.py b/src/darker/help.py index 62de3a50a..7df4ee166 100644 --- a/src/darker/help.py +++ b/src/darker/help.py @@ -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 = ( @@ -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=..: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" ] diff --git a/src/darker/linting.py b/src/darker/linting.py index 9079abb68..b0d63da2a 100644 --- a/src/darker/linting.py +++ b/src/darker/linting.py @@ -44,6 +44,7 @@ from darker.diff import map_unmodified_lines from darker.git import ( + STDIN, WORKTREE, RevisionRange, git_clone_local, @@ -382,11 +383,16 @@ def run_linters( :param paths: The files and directories to check, relative to ``root`` :param revrange: The Git revisions to compare :param use_color: ``True`` to use syntax highlighting for linter output + :raises NotImplementedError: if ``--stdin-filename`` is used :return: Total number of linting errors found on modified lines """ if not linter_cmdlines: return 0 + if revrange.rev2 == STDIN: + raise NotImplementedError( + "The -l/--lint option isn't yet available with --stdin-filename" + ) _require_rev2_worktree(revrange.rev2) git_root = git_get_root(root) if not git_root: diff --git a/src/darker/tests/test_config.py b/src/darker/tests/test_config.py index 4ad35be97..577e4b32c 100644 --- a/src/darker/tests/test_config.py +++ b/src/darker/tests/test_config.py @@ -96,29 +96,90 @@ def test_output_mode_validate_diff_stdout(diff, stdout, expect): @pytest.mark.kwparametrize( - dict(stdout=False, src=[], expect=None), - dict(stdout=False, src=["first.py"], expect=None), - dict(stdout=False, src=["first.py", "second.py"], expect=None), - dict(stdout=False, src=["first.py", "missing.py"], expect=None), - dict(stdout=False, src=["missing.py"], expect=None), - dict(stdout=False, src=["missing.py", "another_missing.py"], expect=None), - dict(stdout=False, src=["directory"], expect=None), - dict(stdout=True, src=[], expect=ConfigurationError), - dict(stdout=True, src=["first.py"], expect=None), - dict(stdout=True, src=["first.py", "second.py"], expect=ConfigurationError), - dict(stdout=True, src=["first.py", "missing.py"], expect=ConfigurationError), - dict(stdout=True, src=["missing.py"], expect=ConfigurationError), - dict(stdout=True, src=["missing.py", "another.py"], expect=ConfigurationError), - dict(stdout=True, src=["directory"], expect=ConfigurationError), + dict(stdout=False), + dict(stdout=False, src=["first.py"]), + dict(stdout=False, src=["first.py", "second.py"]), + dict(stdout=False, src=["first.py", "missing.py"]), + dict(stdout=False, src=["missing.py"]), + dict(stdout=False, src=["missing.py", "another_missing.py"]), + dict(stdout=False, src=["directory"]), + dict(stdout=True, expect=ConfigurationError), # input file missing + dict(stdout=True, src=["first.py"]), + dict( # too many input files + stdout=True, src=["first.py", "second.py"], expect=ConfigurationError + ), + dict( # too many input files (even if all but one missing) + stdout=True, src=["first.py", "missing.py"], expect=ConfigurationError + ), + dict( # input file doesn't exist + stdout=True, src=["missing.py"], expect=ConfigurationError + ), + dict( # too many input files (even if all but one missing) + stdout=True, src=["missing.py", "another.py"], expect=ConfigurationError + ), + dict( # input file required, not a directory + stdout=True, src=["directory"], expect=ConfigurationError + ), + dict(stdout=False, stdin_filename="path.py"), + dict(stdout=False, src=["first.py"], stdin_filename="path.py"), + dict(stdout=False, src=["first.py", "second.py"], stdin_filename="path.py"), + dict(stdout=False, src=["first.py", "missing.py"], stdin_filename="path.py"), + dict(stdout=False, src=["missing.py"], stdin_filename="path.py"), + dict( + stdout=False, src=["missing.py", "another_missing.py"], stdin_filename="path.py" + ), + dict(stdout=False, src=["directory"], stdin_filename="path.py"), + dict(stdout=True, stdin_filename="path.py"), + dict( # too many input files, here from two different command line arguments + stdout=True, + src=["first.py"], + stdin_filename="path.py", + expect=ConfigurationError, + ), + dict( # too many input files, here from two different command line arguments + stdout=True, + src=["first.py", "second.py"], + stdin_filename="path.py", + expect=ConfigurationError, + ), + dict( # too many input files, here from two different command line arguments + stdout=True, + src=["first.py", "missing.py"], + stdin_filename="path.py", + expect=ConfigurationError, + ), + dict( # too many input files (even if positional file is missing) + stdout=True, + src=["missing.py"], + stdin_filename="path.py", + expect=ConfigurationError, + ), + dict( # too many input files, here from two different command line arguments + stdout=True, + src=["missing.py", "another.py"], + stdin_filename="path.py", + expect=ConfigurationError, + ), + dict( # too many input files, here from two different command line arguments + stdout=True, + src=["directory"], + stdin_filename="path.py", + expect=ConfigurationError, + ), + src=[], + stdin_filename=None, + expect=None, ) -def test_output_mode_validate_stdout_src(tmp_path, monkeypatch, stdout, expect, src): +def test_output_mode_validate_stdout_src( + tmp_path, monkeypatch, stdout, src, stdin_filename, expect +): """Validation fails only if exactly one file isn't provided for ``--stdout``""" monkeypatch.chdir(tmp_path) Path("first.py").touch() Path("second.py").touch() with raises_if_exception(expect): - OutputMode.validate_stdout_src(stdout, src) + OutputMode.validate_stdout_src(stdout, src, stdin_filename) @pytest.mark.kwparametrize( diff --git a/src/darker/tests/test_git.py b/src/darker/tests/test_git.py index d66ba945d..1b2978b8f 100644 --- a/src/darker/tests/test_git.py +++ b/src/darker/tests/test_git.py @@ -1,6 +1,6 @@ """Unit tests for :mod:`darker.git`""" -# pylint: disable=redefined-outer-name,protected-access,too-many-arguments +# pylint: disable=protected-access,redefined-outer-name,too-many-arguments # pylint: disable=too-many-lines,use-dict-literal import os @@ -16,7 +16,7 @@ from darker import git from darker.tests.conftest import GitRepoFixture -from darker.tests.helpers import raises_or_matches +from darker.tests.helpers import raises_if_exception, raises_or_matches from darker.utils import GIT_DATEFORMAT, TextDocument @@ -118,27 +118,71 @@ def test_git_get_content_at_revision(git_repo, revision, expect_lines, expect_mt assert result.encoding == "utf-8" -@pytest.mark.parametrize( - "revision_range, expect", - [ - ("", ("HEAD", ":WORKTREE:", False)), - ("HEAD", ("HEAD", ":WORKTREE:", False)), - ("a", ("a", ":WORKTREE:", True)), - ("a..", ("a", ":WORKTREE:", False)), - ("a...", ("a", ":WORKTREE:", True)), - ("..HEAD", ("HEAD", "HEAD", False)), - ("...HEAD", ("HEAD", "HEAD", True)), - ("a..HEAD", ("a", "HEAD", False)), - ("a...HEAD", ("a", "HEAD", True)), - ("a..b", ("a", "b", False)), - ("a...b", ("a", "b", True)), - ], +@pytest.mark.kwparametrize( + dict(revision_range="", stdin_mode=False, expect=("HEAD", ":WORKTREE:", False)), + dict(revision_range="HEAD", stdin_mode=False, expect=("HEAD", ":WORKTREE:", False)), + dict(revision_range="a", stdin_mode=False, expect=("a", ":WORKTREE:", True)), + dict(revision_range="a..", stdin_mode=False, expect=("a", ":WORKTREE:", False)), + dict(revision_range="a...", stdin_mode=False, expect=("a", ":WORKTREE:", True)), + dict(revision_range="..HEAD", stdin_mode=False, expect=("HEAD", "HEAD", False)), + dict(revision_range="...HEAD", stdin_mode=False, expect=("HEAD", "HEAD", True)), + dict(revision_range="a..HEAD", stdin_mode=False, expect=("a", "HEAD", False)), + dict(revision_range="a...HEAD", stdin_mode=False, expect=("a", "HEAD", True)), + dict(revision_range="a..b", stdin_mode=False, expect=("a", "b", False)), + dict(revision_range="a...b", stdin_mode=False, expect=("a", "b", True)), + dict(revision_range="", stdin_mode=True, expect=("HEAD", ":STDIN:", False)), + dict(revision_range="HEAD", stdin_mode=True, expect=("HEAD", ":STDIN:", False)), + dict(revision_range="a", stdin_mode=True, expect=("a", ":STDIN:", True)), + dict(revision_range="a..", stdin_mode=True, expect=("a", ":STDIN:", False)), + dict(revision_range="a...", stdin_mode=True, expect=("a", ":STDIN:", True)), + dict( + revision_range="..HEAD", + stdin_mode=True, + expect=ValueError( + "With --stdin-filename, rev2 in ..HEAD must be ':STDIN:', not 'HEAD'" + ), + ), + dict( + revision_range="...HEAD", + stdin_mode=True, + expect=ValueError( + "With --stdin-filename, rev2 in ...HEAD must be ':STDIN:', not 'HEAD'" + ), + ), + dict( + revision_range="a..HEAD", + stdin_mode=True, + expect=ValueError( + "With --stdin-filename, rev2 in a..HEAD must be ':STDIN:', not 'HEAD'" + ), + ), + dict( + revision_range="a...HEAD", + stdin_mode=True, + expect=ValueError( + "With --stdin-filename, rev2 in a...HEAD must be ':STDIN:', not 'HEAD'" + ), + ), + dict( + revision_range="a..b", + stdin_mode=True, + expect=ValueError( + "With --stdin-filename, rev2 in a..b must be ':STDIN:', not 'b'" + ), + ), + dict( + revision_range="a...b", + stdin_mode=True, + expect=ValueError( + "With --stdin-filename, rev2 in a...b must be ':STDIN:', not 'b'" + ), + ), ) -def test_revisionrange_parse(revision_range, expect): +def test_revisionrange_parse(revision_range, stdin_mode, expect): """Test for :meth:`RevisionRange.parse`""" - result = git.RevisionRange._parse(revision_range) + with raises_or_matches(expect, ["args"]) as check: - assert result == expect + check(git.RevisionRange._parse(revision_range, stdin_mode)) def git_call(cmd, encoding=None): @@ -207,23 +251,40 @@ def test_git_get_content_at_revision_obtain_file_content( @pytest.mark.kwparametrize( - dict(revrange="HEAD", expect="HEAD..:WORKTREE:"), - dict(revrange="{initial}", expect="{initial}..:WORKTREE:"), - dict(revrange="{initial}..", expect="{initial}..:WORKTREE:"), - dict(revrange="{initial}..HEAD", expect="{initial}..HEAD"), - dict(revrange="{initial}..feature", expect="{initial}..feature"), - dict(revrange="{initial}...", expect="{initial}..:WORKTREE:"), - dict(revrange="{initial}...HEAD", expect="{initial}..HEAD"), - dict(revrange="{initial}...feature", expect="{initial}..feature"), - dict(revrange="master", expect="{initial}..:WORKTREE:"), - dict(revrange="master..", expect="master..:WORKTREE:"), - dict(revrange="master..HEAD", expect="master..HEAD"), - dict(revrange="master..feature", expect="master..feature"), - dict(revrange="master...", expect="{initial}..:WORKTREE:"), - dict(revrange="master...HEAD", expect="{initial}..HEAD"), - dict(revrange="master...feature", expect="{initial}..feature"), + dict(revrange="HEAD", stdin_mode=False, expect="HEAD..:WORKTREE:"), + dict(revrange="{initial}", stdin_mode=False, expect="{initial}..:WORKTREE:"), + dict(revrange="{initial}..", stdin_mode=False, expect="{initial}..:WORKTREE:"), + dict(revrange="{initial}..HEAD", stdin_mode=False, expect="{initial}..HEAD"), + dict(revrange="{initial}..feature", stdin_mode=False, expect="{initial}..feature"), + dict(revrange="{initial}...", stdin_mode=False, expect="{initial}..:WORKTREE:"), + dict(revrange="{initial}...HEAD", stdin_mode=False, expect="{initial}..HEAD"), + dict(revrange="{initial}...feature", stdin_mode=False, expect="{initial}..feature"), + dict(revrange="master", stdin_mode=False, expect="{initial}..:WORKTREE:"), + dict(revrange="master..", stdin_mode=False, expect="master..:WORKTREE:"), + dict(revrange="master..HEAD", stdin_mode=False, expect="master..HEAD"), + dict(revrange="master..feature", stdin_mode=False, expect="master..feature"), + dict(revrange="master...", stdin_mode=False, expect="{initial}..:WORKTREE:"), + dict(revrange="master...HEAD", stdin_mode=False, expect="{initial}..HEAD"), + dict(revrange="master...feature", stdin_mode=False, expect="{initial}..feature"), + dict(revrange="HEAD", stdin_mode=True, expect="HEAD..:STDIN:"), + dict(revrange="{initial}", stdin_mode=True, expect="{initial}..:STDIN:"), + dict(revrange="{initial}..", stdin_mode=True, expect="{initial}..:STDIN:"), + dict(revrange="{initial}..HEAD", stdin_mode=True, expect=ValueError), + dict(revrange="{initial}..feature", stdin_mode=True, expect=ValueError), + dict(revrange="{initial}...", stdin_mode=True, expect="{initial}..:STDIN:"), + dict(revrange="{initial}...HEAD", stdin_mode=True, expect=ValueError), + dict(revrange="{initial}...feature", stdin_mode=True, expect=ValueError), + dict(revrange="master", stdin_mode=True, expect="{initial}..:STDIN:"), + dict(revrange="master..", stdin_mode=True, expect="master..:STDIN:"), + dict(revrange="master..HEAD", stdin_mode=True, expect=ValueError), + dict(revrange="master..feature", stdin_mode=True, expect=ValueError), + dict(revrange="master...", stdin_mode=True, expect="{initial}..:STDIN:"), + dict(revrange="master...HEAD", stdin_mode=True, expect=ValueError), + dict(revrange="master...feature", stdin_mode=True, expect=ValueError), ) -def test_revisionrange_parse_with_common_ancestor(git_repo, revrange, expect): +def test_revisionrange_parse_with_common_ancestor( + git_repo, revrange, stdin_mode, expect +): """``_git_get_old_revision()`` gets common ancestor using Git when necessary""" git_repo.add({"a": "i"}, commit="Initial commit") initial = git_repo.get_hash() @@ -231,14 +292,15 @@ def test_revisionrange_parse_with_common_ancestor(git_repo, revrange, expect): master = git_repo.get_hash() git_repo.create_branch("feature", initial) git_repo.add({"a": "f"}, commit="in feature") + with raises_if_exception(expect): - result = git.RevisionRange.parse_with_common_ancestor( - revrange.format(initial=initial), git_repo.root - ) + result = git.RevisionRange.parse_with_common_ancestor( + revrange.format(initial=initial), git_repo.root, stdin_mode + ) - rev1, rev2 = expect.format(initial=initial, master=master).split("..") - assert result.rev1 == rev1 - assert result.rev2 == rev2 + rev1, rev2 = expect.format(initial=initial, master=master).split("..") + assert result.rev1 == rev1 + assert result.rev2 == rev2 @pytest.mark.kwparametrize( @@ -781,7 +843,9 @@ def test_git_get_modified_python_files_revision_range( """Test for :func:`darker.git.git_get_modified_python_files` with revision range""" result = git.git_get_modified_python_files( [Path(branched_repo.root)], - git.RevisionRange.parse_with_common_ancestor(revrange, branched_repo.root), + git.RevisionRange.parse_with_common_ancestor( + revrange, branched_repo.root, stdin_mode=False + ), Path(branched_repo.root), ) @@ -896,22 +960,18 @@ def test_git_get_root_not_found(tmp_path, path): @pytest.mark.kwparametrize( dict( - environ={}, expect_rev1="HEAD", expect_rev2=":WORKTREE:", - expect_use_common_ancestor=False, ), dict( environ={"PRE_COMMIT_FROM_REF": "old"}, expect_rev1="HEAD", expect_rev2=":WORKTREE:", - expect_use_common_ancestor=False, ), dict( environ={"PRE_COMMIT_TO_REF": "new"}, expect_rev1="HEAD", expect_rev2=":WORKTREE:", - expect_use_common_ancestor=False, ), dict( environ={"PRE_COMMIT_FROM_REF": "old", "PRE_COMMIT_TO_REF": "new"}, @@ -919,14 +979,45 @@ def test_git_get_root_not_found(tmp_path, path): expect_rev2="new", expect_use_common_ancestor=True, ), + dict( + stdin_mode=True, + expect_rev1=ValueError( + "With --stdin-filename, revision ':PRE-COMMIT:' is not allowed" + ), + ), + dict( + environ={"PRE_COMMIT_FROM_REF": "old"}, + stdin_mode=True, + expect_rev1=ValueError( + "With --stdin-filename, revision ':PRE-COMMIT:' is not allowed" + ), + ), + dict( + environ={"PRE_COMMIT_TO_REF": "new"}, + stdin_mode=True, + expect_rev1=ValueError( + "With --stdin-filename, revision ':PRE-COMMIT:' is not allowed" + ), + ), + dict( + environ={"PRE_COMMIT_FROM_REF": "old", "PRE_COMMIT_TO_REF": "new"}, + stdin_mode=True, + expect_rev1=ValueError( + "With --stdin-filename, revision ':PRE-COMMIT:' is not allowed" + ), + ), + environ={}, + stdin_mode=False, + expect_rev2=None, + expect_use_common_ancestor=False, ) def test_revisionrange_parse_pre_commit( - environ, expect_rev1, expect_rev2, expect_use_common_ancestor + environ, stdin_mode, expect_rev1, expect_rev2, expect_use_common_ancestor ): """RevisionRange._parse(':PRE-COMMIT:') gets the range from environment variables""" - with patch.dict(os.environ, environ): + with patch.dict(os.environ, environ), raises_if_exception(expect_rev1): - result = git.RevisionRange._parse(":PRE-COMMIT:") + result = git.RevisionRange._parse(":PRE-COMMIT:", stdin_mode) assert result == (expect_rev1, expect_rev2, expect_use_common_ancestor) diff --git a/src/darker/tests/test_linting.py b/src/darker/tests/test_linting.py index dbb737e8b..7ddc273a7 100644 --- a/src/darker/tests/test_linting.py +++ b/src/darker/tests/test_linting.py @@ -338,7 +338,9 @@ def test_run_linters_non_worktree(): ["dummy-linter"], Path("/dummy"), {Path("dummy.py")}, - RevisionRange.parse_with_common_ancestor("..HEAD", Path("dummy cwd")), + RevisionRange.parse_with_common_ancestor( + "..HEAD", Path("dummy cwd"), stdin_mode=False + ), use_color=False, ) @@ -431,6 +433,23 @@ def test_run_linters_line_separation(git_repo, capsys): ) +def test_run_linters_stdin(): + """`linting.run_linters` raises a `NotImplementeError` on ``--stdin-filename``""" + with pytest.raises( + NotImplementedError, + match=r"^The -l/--lint option isn't yet available with --stdin-filename$", + ): + # end of test setup + + _ = linting.run_linters( + ["dummy-linter-command"], + Path("/dummy-dir"), + {Path("dummy.py")}, + RevisionRange("HEAD", ":STDIN:"), + use_color=False, + ) + + def _build_messages( lines_and_messages: Iterable[Union[Tuple[int, str], Tuple[int, str, str]]], ) -> Dict[MessageLocation, List[LinterMessage]]: diff --git a/src/darker/tests/test_main.py b/src/darker/tests/test_main.py index d849298da..fd83d1a13 100644 --- a/src/darker/tests/test_main.py +++ b/src/darker/tests/test_main.py @@ -7,10 +7,11 @@ import logging import re from argparse import ArgumentError +from io import BytesIO from pathlib import Path from textwrap import dedent from types import SimpleNamespace -from unittest.mock import ANY, call, patch +from unittest.mock import ANY, Mock, call, patch import pytest @@ -221,6 +222,84 @@ def test_format_edited_parts( assert changes == expect_changes +@pytest.mark.kwparametrize( + dict( + rev1="HEAD", + rev2=":STDIN:", + expect=[ + ( + "a.py", + ("print('a.py HEAD' )", "#", "print( 'a.py STDIN')"), + ("print('a.py HEAD' )", "#", 'print("a.py STDIN")'), + ) + ], + ), + dict( + rev1=":WORKTREE:", + rev2=":STDIN:", + expect=[ + ( + "a.py", + ("print('a.py :WORKTREE:' )", "#", "print( 'a.py STDIN')"), + ("print('a.py :WORKTREE:' )", "#", 'print("a.py STDIN")'), + ) + ], + ), + dict( + rev1="HEAD", + rev2=":WORKTREE:", + expect=[ + ( + "a.py", + ("print('a.py :WORKTREE:' )", "#", "print( 'a.py HEAD')"), + ('print("a.py :WORKTREE:")', "#", "print( 'a.py HEAD')"), + ) + ], + ), +) +@pytest.mark.parametrize("newline", ["\n", "\r\n"], ids=["unix", "windows"]) +def test_format_edited_parts_stdin(git_repo, newline, rev1, rev2, expect): + """`format_edited_parts` with ``--stdin-filename``""" + n = newline # pylint: disable=invalid-name + paths = git_repo.add( + { + "a.py": f"print('a.py HEAD' ){n}#{n}print( 'a.py HEAD'){n}", + "b.py": f"print('b.py HEAD' ){n}#{n}print( 'b.py HEAD'){n}", + }, + commit="Initial commit", + ) + paths["a.py"].write_bytes( + f"print('a.py :WORKTREE:' ){n}#{n}print( 'a.py HEAD'){n}".encode("ascii") + ) + paths["b.py"].write_bytes( + f"print('b.py HEAD' ){n}#{n}print( 'b.py WORKTREE'){n}".encode("ascii") + ) + stdin = f"print('a.py {rev1}' ){n}#{n}print( 'a.py STDIN'){n}".encode("ascii") + with patch.object( + darker.__main__.sys, # type: ignore[attr-defined] + "stdin", + Mock(buffer=BytesIO(stdin)), + ): + # end of test setup + + result = list( + darker.__main__.format_edited_parts( + Path(git_repo.root), + {Path("a.py")}, + Exclusions(black=set(), isort=set()), + RevisionRange(rev1, rev2), + {}, + report_unmodified=False, + ) + ) + + expect = [ + (paths[path], TextDocument.from_lines(before), TextDocument.from_lines(after)) + for path, before, after in expect + ] + assert result == expect + + def test_format_edited_parts_all_unchanged(git_repo, monkeypatch): """``format_edited_parts()`` yields nothing if no reformatting was needed""" monkeypatch.chdir(git_repo.root) diff --git a/src/darker/tests/test_main_blacken_and_flynt_single_file.py b/src/darker/tests/test_main_blacken_and_flynt_single_file.py index 6839802e8..8e620d275 100644 --- a/src/darker/tests/test_main_blacken_and_flynt_single_file.py +++ b/src/darker/tests/test_main_blacken_and_flynt_single_file.py @@ -128,16 +128,16 @@ def test_blacken_and_flynt_single_file_common_ancestor(git_repo): git_repo.create_branch("feature", initial) git_repo.add({"a.py": a_py_feature}, commit="on feature") worktree = TextDocument.from_str(a_py_worktree) + revrange = RevisionRange.parse_with_common_ancestor( + "master...", git_repo.root, stdin_mode=False + ) result = _blacken_and_flynt_single_file( git_repo.root, Path("a.py"), Path("a.py"), Exclusions(), - EditedLinenumsDiffer( - git_repo.root, - RevisionRange.parse_with_common_ancestor("master...", git_repo.root), - ), + EditedLinenumsDiffer(git_repo.root, revrange), rev2_content=worktree, rev2_isorted=worktree, has_isort_changes=False, @@ -187,16 +187,16 @@ def docstring_func(): ) paths = git_repo.add({"a.py": initial}, commit="Initial commit") paths["a.py"].write_text(modified) + revrange = RevisionRange.parse_with_common_ancestor( + "HEAD..", git_repo.root, stdin_mode=False + ) result = _blacken_and_flynt_single_file( git_repo.root, Path("a.py"), Path("a.py"), Exclusions(), - EditedLinenumsDiffer( - git_repo.root, - RevisionRange.parse_with_common_ancestor("HEAD..", git_repo.root), - ), + EditedLinenumsDiffer(git_repo.root, revrange), rev2_content=TextDocument.from_str(modified), rev2_isorted=TextDocument.from_str(modified), has_isort_changes=False, diff --git a/src/darker/tests/test_main_stdin_filename.py b/src/darker/tests/test_main_stdin_filename.py new file mode 100644 index 000000000..ecb4225a1 --- /dev/null +++ b/src/darker/tests/test_main_stdin_filename.py @@ -0,0 +1,164 @@ +"""Tests for `darker.__main__.main` and the ``--stdin-filename`` option""" + +# pylint: disable=too-many-arguments,use-dict-literal + +from io import BytesIO +from typing import List, Optional +from unittest.mock import Mock, patch + +import pytest +import toml + +import darker.__main__ +from darker.config import ConfigurationError +from darker.tests.conftest import GitRepoFixture +from darker.tests.helpers import raises_if_exception + +pytestmark = pytest.mark.usefixtures("find_project_root_cache_clear") + + +@pytest.mark.kwparametrize( + dict(expect=SystemExit(2)), + dict(config_src=["a.py"], expect_a_py='modified = "a.py worktree"\n'), + dict(config_src=["b.py"], src=["a.py"], expect_a_py='modified = "a.py worktree"\n'), + dict( + config_src=["b.py"], + stdin_filename=["a.py"], + expect=ConfigurationError( + "No Python source files are allowed when using the `stdin-filename` option" + ), + ), + dict(config_src=["a.py"], revision="..:STDIN:", expect_a_py='modified = "stdin"\n'), + dict( + config_src=["a.py"], + revision="..:WORKTREE:", + expect_a_py='modified = "a.py worktree"\n', + ), + dict( + config_src=["b.py"], + src=["a.py"], + stdin_filename="a.py", + expect=ConfigurationError( + "No Python source files are allowed when using the `stdin-filename` option" + ), + ), + dict( + config_src=["b.py"], + src=["a.py"], + revision="..:STDIN:", + expect_a_py='modified = "stdin"\n', + ), + dict( + config_src=["b.py"], + src=["a.py"], + revision="..:WORKTREE:", + expect_a_py='modified = "a.py worktree"\n', + ), + dict( + config_src=["b.py"], + src=["a.py"], + stdin_filename="a.py", + revision="..:STDIN:", + expect=ConfigurationError( + "No Python source files are allowed when using the `stdin-filename` option" + ), + ), + dict( + config_src=["b.py"], + src=["a.py"], + stdin_filename="a.py", + revision="..:WORKTREE:", + expect=ConfigurationError( + "No Python source files are allowed when using the `stdin-filename` option" + ), + ), + dict(src=["a.py"], expect_a_py='modified = "a.py worktree"\n'), + dict( + src=["a.py"], + stdin_filename="a.py", + expect=ConfigurationError( + "No Python source files are allowed when using the `stdin-filename` option" + ), + ), + dict( + src=["a.py"], + revision="..:STDIN:", + expect_a_py='modified = "stdin"\n', + ), + dict( + src=["a.py"], + revision="..:WORKTREE:", + expect_a_py='modified = "a.py worktree"\n', + ), + dict( + src=["a.py"], + stdin_filename="a.py", + revision="..:STDIN:", + expect=ConfigurationError( + "No Python source files are allowed when using the `stdin-filename` option" + ), + ), + dict( + src=["a.py"], + stdin_filename="a.py", + revision="..:WORKTREE:", + expect=ConfigurationError( + "No Python source files are allowed when using the `stdin-filename` option" + ), + ), + dict(stdin_filename="a.py", expect_a_py='modified = "stdin"\n'), + dict( + stdin_filename="a.py", revision="..:STDIN:", expect_a_py='modified = "stdin"\n' + ), + dict( + stdin_filename="a.py", + revision="..:WORKTREE:", + expect=ValueError( + "With --stdin-filename, rev2 in ..:WORKTREE: must be ':STDIN:', not" + " ':WORKTREE:'" + ), + ), + dict(revision="..:STDIN:", expect=SystemExit(2)), + dict(revision="..:WORKTREE:", expect=SystemExit(2)), + config_src=None, + src=[], + stdin_filename=None, + revision=None, + expect=0, + expect_a_py="original\n", +) +def test_main_stdin_filename( + git_repo: GitRepoFixture, + config_src: Optional[List[str]], + src: List[str], + stdin_filename: Optional[str], + revision: Optional[str], + expect: int, + expect_a_py: str, +) -> None: + """Tests for `darker.__main__.main` and the ``--stdin-filename`` option""" + if config_src is not None: + configuration = {"tool": {"darker": {"src": config_src}}} + git_repo.add({"pyproject.toml": toml.dumps(configuration)}) + paths = git_repo.add( + {"a.py": "original\n", "b.py": "original\n"}, commit="Initial commit" + ) + paths["a.py"].write_text("modified = 'a.py worktree'") + paths["b.py"].write_text("modified = 'b.py worktree'") + arguments = src[:] + if stdin_filename is not None: + arguments.insert(0, f"--stdin-filename={stdin_filename}") + if revision is not None: + arguments.insert(0, f"--revision={revision}") + with patch.object( + darker.__main__.sys, # type: ignore[attr-defined] + "stdin", + Mock(buffer=BytesIO(b"modified = 'stdin'")), + ), raises_if_exception(expect): + # end of test setup + + retval = darker.__main__.main(arguments) + + assert retval == expect + assert paths["a.py"].read_text() == expect_a_py + assert paths["b.py"].read_text() == "modified = 'b.py worktree'"