X-Git-Url: https://git.madduck.net/etc/vim.git/blobdiff_plain/0a833b4b14953f98e81d632281a75318faa66170..78317a4cfb2cc7958ebd553ff6d7cc1aff0d8296:/tests/test_black.py?ds=inline diff --git a/tests/test_black.py b/tests/test_black.py index 88ea317..301a3a5 100644 --- a/tests/test_black.py +++ b/tests/test_black.py @@ -1,66 +1,77 @@ #!/usr/bin/env python3 -import multiprocessing + import asyncio +import inspect +import io import logging +import multiprocessing +import os +import sys +import types +import unittest from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from dataclasses import replace -import inspect -from io import BytesIO, TextIOWrapper -import os +from io import BytesIO from pathlib import Path from platform import system -import regex as re -import sys from tempfile import TemporaryDirectory -import types from typing import ( Any, - BinaryIO, Callable, Dict, - Generator, - List, Iterator, + List, + Optional, + Sequence, TypeVar, + Union, ) -import unittest -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch import click +import pytest +import regex as re from click import unstyle from click.testing import CliRunner +from pathspec import PathSpec import black +import black.files from black import Feature, TargetVersion - -from pathspec import PathSpec +from black import re_compile_maybe_verbose as compile_pattern +from black.cache import get_cache_file +from black.debug import DebugVisitor +from black.output import color_diff, diff +from black.report import Report # Import other test classes from tests.util import ( - THIS_DIR, - read_data, + DATA_DIR, + DEFAULT_MODE, DETERMINISTIC_HEADER, + PROJECT_ROOT, + PY36_VERSIONS, + THIS_DIR, BlackBaseTestCase, - DEFAULT_MODE, - fs, - ff, + assert_format, + change_directory, dump_to_stderr, + ff, + fs, + read_data, ) -from .test_primer import PrimerCLITests # noqa: F401 - THIS_FILE = Path(__file__) -PY36_VERSIONS = { - TargetVersion.PY36, - TargetVersion.PY37, - TargetVersion.PY38, - TargetVersion.PY39, -} PY36_ARGS = [f"--target-version={version.name.lower()}" for version in PY36_VERSIONS] +DEFAULT_EXCLUDE = black.re_compile_maybe_verbose(black.const.DEFAULT_EXCLUDES) +DEFAULT_INCLUDE = black.re_compile_maybe_verbose(black.const.DEFAULT_INCLUDES) T = TypeVar("T") R = TypeVar("R") +# Match the time output in a diff, but nothing else +DIFF_TIME = re.compile(r"\t[\d-:+\. ]+") + @contextmanager def cache_dir(exists: bool = True) -> Iterator[Path]: @@ -68,7 +79,7 @@ def cache_dir(exists: bool = True) -> Iterator[Path]: cache_dir = Path(workspace) if not exists: cache_dir = cache_dir / "new" - with patch("black.CACHE_DIR", cache_dir): + with patch("black.cache.CACHE_DIR", cache_dir): yield cache_dir @@ -99,56 +110,32 @@ class FakeParameter(click.Parameter): class BlackRunner(CliRunner): - """Modify CliRunner so that stderr is not merged with stdout. - - This is a hack that can be removed once we depend on Click 7.x""" + """Make sure STDOUT and STDERR are kept separate when testing Black via its CLI.""" def __init__(self) -> None: - self.stderrbuf = BytesIO() - self.stdoutbuf = BytesIO() - self.stdout_bytes = b"" - self.stderr_bytes = b"" - super().__init__() - - @contextmanager - def isolation(self, *args: Any, **kwargs: Any) -> Generator[BinaryIO, None, None]: - with super().isolation(*args, **kwargs) as output: - try: - hold_stderr = sys.stderr - sys.stderr = TextIOWrapper(self.stderrbuf, encoding=self.charset) - yield output - finally: - self.stdout_bytes = sys.stdout.buffer.getvalue() # type: ignore - self.stderr_bytes = sys.stderr.buffer.getvalue() # type: ignore - sys.stderr = hold_stderr + super().__init__(mix_stderr=False) + + +def invokeBlack( + args: List[str], exit_code: int = 0, ignore_config: bool = True +) -> None: + runner = BlackRunner() + if ignore_config: + args = ["--verbose", "--config", str(THIS_DIR / "empty.toml"), *args] + result = runner.invoke(black.main, args) + assert result.stdout_bytes is not None + assert result.stderr_bytes is not None + msg = ( + f"Failed with args: {args}\n" + f"stdout: {result.stdout_bytes.decode()!r}\n" + f"stderr: {result.stderr_bytes.decode()!r}\n" + f"exception: {result.exception}" + ) + assert result.exit_code == exit_code, msg class BlackTestCase(BlackBaseTestCase): - def invokeBlack( - self, args: List[str], exit_code: int = 0, ignore_config: bool = True - ) -> None: - runner = BlackRunner() - if ignore_config: - args = ["--verbose", "--config", str(THIS_DIR / "empty.toml"), *args] - result = runner.invoke(black.main, args) - self.assertEqual( - result.exit_code, - exit_code, - msg=( - f"Failed with args: {args}\n" - f"stdout: {runner.stdout_bytes.decode()!r}\n" - f"stderr: {runner.stderr_bytes.decode()!r}\n" - f"exception: {result.exception}" - ), - ) - - @patch("black.dump_to_file", dump_to_stderr) - def test_empty(self) -> None: - source = expected = "" - actual = fs(source) - self.assertFormatEqual(expected, actual) - black.assert_equivalent(source, actual) - black.assert_stable(source, actual, DEFAULT_MODE) + invokeBlack = staticmethod(invokeBlack) def test_empty_ff(self) -> None: expected = "" @@ -170,8 +157,9 @@ class BlackTestCase(BlackBaseTestCase): ) self.assertEqual(result.exit_code, 0) self.assertFormatEqual(expected, result.output) - black.assert_equivalent(source, result.output) - black.assert_stable(source, result.output, DEFAULT_MODE) + if source != result.output: + black.assert_equivalent(source, result.output) + black.assert_stable(source, result.output, DEFAULT_MODE) def test_piping_diff(self) -> None: diff_header = re.compile( @@ -272,24 +260,6 @@ class BlackTestCase(BlackBaseTestCase): actual = fs(fs(source)) # this is what `format_file_contents` does with --safe black.assert_stable(source, actual, DEFAULT_MODE) - @patch("black.dump_to_file", dump_to_stderr) - def test_pep_572(self) -> None: - source, expected = read_data("pep_572") - actual = fs(source) - self.assertFormatEqual(expected, actual) - black.assert_stable(source, actual, DEFAULT_MODE) - if sys.version_info >= (3, 8): - black.assert_equivalent(source, actual) - - @patch("black.dump_to_file", dump_to_stderr) - def test_pep_572_remove_parens(self) -> None: - source, expected = read_data("pep_572_remove_parens") - actual = fs(source) - self.assertFormatEqual(expected, actual) - black.assert_stable(source, actual, DEFAULT_MODE) - if sys.version_info >= (3, 8): - black.assert_equivalent(source, actual) - def test_pep_572_version_detection(self) -> None: source, _ = read_data("pep_572") root = black.lib2to3_parse(source) @@ -359,15 +329,6 @@ class BlackTestCase(BlackBaseTestCase): self.assertIn("\033[31m", actual) self.assertIn("\033[0m", actual) - @patch("black.dump_to_file", dump_to_stderr) - def test_pep_570(self) -> None: - source, expected = read_data("pep_570") - actual = fs(source) - self.assertFormatEqual(expected, actual) - black.assert_stable(source, actual, DEFAULT_MODE) - if sys.version_info >= (3, 8): - black.assert_equivalent(source, actual) - def test_detect_pos_only_arguments(self) -> None: source, _ = read_data("pep_570") root = black.lib2to3_parse(source) @@ -380,52 +341,13 @@ class BlackTestCase(BlackBaseTestCase): def test_string_quotes(self) -> None: source, expected = read_data("string_quotes") mode = black.Mode(experimental_string_processing=True) - actual = fs(source, mode=mode) - self.assertFormatEqual(expected, actual) - black.assert_equivalent(source, actual) - black.assert_stable(source, actual, mode) + assert_format(source, expected, mode) mode = replace(mode, string_normalization=False) not_normalized = fs(source, mode=mode) self.assertFormatEqual(source.replace("\\\n", ""), not_normalized) black.assert_equivalent(source, not_normalized) black.assert_stable(source, not_normalized, mode=mode) - @patch("black.dump_to_file", dump_to_stderr) - def test_docstring_no_string_normalization(self) -> None: - """Like test_docstring but with string normalization off.""" - source, expected = read_data("docstring_no_string_normalization") - mode = replace(DEFAULT_MODE, string_normalization=False) - actual = fs(source, mode=mode) - self.assertFormatEqual(expected, actual) - black.assert_equivalent(source, actual) - black.assert_stable(source, actual, mode) - - def test_long_strings_flag_disabled(self) -> None: - """Tests for turning off the string processing logic.""" - source, expected = read_data("long_strings_flag_disabled") - mode = replace(DEFAULT_MODE, experimental_string_processing=False) - actual = fs(source, mode=mode) - self.assertFormatEqual(expected, actual) - black.assert_stable(expected, actual, mode) - - @patch("black.dump_to_file", dump_to_stderr) - def test_numeric_literals(self) -> None: - source, expected = read_data("numeric_literals") - mode = replace(DEFAULT_MODE, target_versions=PY36_VERSIONS) - actual = fs(source, mode=mode) - self.assertFormatEqual(expected, actual) - black.assert_equivalent(source, actual) - black.assert_stable(source, actual, mode) - - @patch("black.dump_to_file", dump_to_stderr) - def test_numeric_literals_ignoring_underscores(self) -> None: - source, expected = read_data("numeric_literals_skip_underscores") - mode = replace(DEFAULT_MODE, target_versions=PY36_VERSIONS) - actual = fs(source, mode=mode) - self.assertFormatEqual(expected, actual) - black.assert_equivalent(source, actual) - black.assert_stable(source, actual, mode) - def test_skip_magic_trailing_comma(self) -> None: source, _ = read_data("expression.py") expected, _ = read_data("expression_skip_magic_trailing_comma.diff") @@ -451,23 +373,6 @@ class BlackTestCase(BlackBaseTestCase): ) self.assertEqual(expected, actual, msg) - @patch("black.dump_to_file", dump_to_stderr) - def test_python2_print_function(self) -> None: - source, expected = read_data("python2_print_function") - mode = replace(DEFAULT_MODE, target_versions={TargetVersion.PY27}) - actual = fs(source, mode=mode) - self.assertFormatEqual(expected, actual) - black.assert_equivalent(source, actual) - black.assert_stable(source, actual, mode) - - @patch("black.dump_to_file", dump_to_stderr) - def test_stub(self) -> None: - mode = replace(DEFAULT_MODE, is_pyi=True) - source, expected = read_data("stub.pyi") - actual = fs(source, mode=mode) - self.assertFormatEqual(expected, actual) - black.assert_stable(source, actual, mode) - @patch("black.dump_to_file", dump_to_stderr) def test_async_as_identifier(self) -> None: source_path = (THIS_DIR / "data" / "async_as_identifier.py").resolve() @@ -498,26 +403,6 @@ class BlackTestCase(BlackBaseTestCase): # but not on 3.6, because we use async as a reserved keyword self.invokeBlack([str(source_path), "--target-version", "py36"], exit_code=123) - @patch("black.dump_to_file", dump_to_stderr) - def test_python38(self) -> None: - source, expected = read_data("python38") - actual = fs(source) - self.assertFormatEqual(expected, actual) - major, minor = sys.version_info[:2] - if major > 3 or (major == 3 and minor >= 8): - black.assert_equivalent(source, actual) - black.assert_stable(source, actual, DEFAULT_MODE) - - @patch("black.dump_to_file", dump_to_stderr) - def test_python39(self) -> None: - source, expected = read_data("python39") - actual = fs(source) - self.assertFormatEqual(expected, actual) - major, minor = sys.version_info[:2] - if major > 3 or (major == 3 and minor >= 9): - black.assert_equivalent(source, actual) - black.assert_stable(source, actual, DEFAULT_MODE) - def test_tab_comment_indentation(self) -> None: contents_tab = "if 1:\n\tif 2:\n\t\tpass\n\t# comment\n\tpass\n" contents_spc = "if 1:\n if 2:\n pass\n # comment\n pass\n" @@ -541,7 +426,7 @@ class BlackTestCase(BlackBaseTestCase): self.assertFormatEqual(contents_spc, fs(contents_tab)) def test_report_verbose(self) -> None: - report = black.Report(verbose=True) + report = Report(verbose=True) out_lines = [] err_lines = [] @@ -551,7 +436,7 @@ class BlackTestCase(BlackBaseTestCase): def err(msg: str, **kwargs: Any) -> None: err_lines.append(msg) - with patch("black.out", out), patch("black.err", err): + with patch("black.output._out", out), patch("black.output._err", err): report.done(Path("f1"), black.Changed.NO) self.assertEqual(len(out_lines), 1) self.assertEqual(len(err_lines), 0) @@ -643,7 +528,7 @@ class BlackTestCase(BlackBaseTestCase): ) def test_report_quiet(self) -> None: - report = black.Report(quiet=True) + report = Report(quiet=True) out_lines = [] err_lines = [] @@ -653,7 +538,7 @@ class BlackTestCase(BlackBaseTestCase): def err(msg: str, **kwargs: Any) -> None: err_lines.append(msg) - with patch("black.out", out), patch("black.err", err): + with patch("black.output._out", out), patch("black.output._err", err): report.done(Path("f1"), black.Changed.NO) self.assertEqual(len(out_lines), 0) self.assertEqual(len(err_lines), 0) @@ -747,7 +632,7 @@ class BlackTestCase(BlackBaseTestCase): def err(msg: str, **kwargs: Any) -> None: err_lines.append(msg) - with patch("black.out", out), patch("black.err", err): + with patch("black.output._out", out), patch("black.output._err", err): report.done(Path("f1"), black.Changed.NO) self.assertEqual(len(out_lines), 0) self.assertEqual(len(err_lines), 0) @@ -921,6 +806,10 @@ class BlackTestCase(BlackBaseTestCase): self.assertEqual(black.get_features_used(node), set()) node = black.lib2to3_parse(expected) self.assertEqual(black.get_features_used(node), set()) + node = black.lib2to3_parse("lambda a, /, b: ...") + self.assertEqual(black.get_features_used(node), {Feature.POS_ONLY_ARGUMENTS}) + node = black.lib2to3_parse("def fn(a, /, b): ...") + self.assertEqual(black.get_features_used(node), {Feature.POS_ONLY_ARGUMENTS}) def test_get_future_imports(self) -> None: node = black.lib2to3_parse("\n") @@ -964,8 +853,8 @@ class BlackTestCase(BlackBaseTestCase): def err(msg: str, **kwargs: Any) -> None: err_lines.append(msg) - with patch("black.out", out), patch("black.err", err): - black.DebugVisitor.show(source) + with patch("black.debug.out", out): + DebugVisitor.show(source) actual = "\n".join(out_lines) + "\n" log_name = "" if expected != actual: @@ -1013,7 +902,7 @@ class BlackTestCase(BlackBaseTestCase): def err(msg: str, **kwargs: Any) -> None: err_lines.append(msg) - with patch("black.out", out), patch("black.err", err): + with patch("black.output._out", out), patch("black.output._err", err): with self.assertRaises(AssertionError): self.assertFormatEqual("j = [1, 2, 3]", "j = [1, 2, 3,]") @@ -1022,183 +911,6 @@ class BlackTestCase(BlackBaseTestCase): self.assertTrue("Actual tree:" in out_str) self.assertEqual("".join(err_lines), "") - def test_cache_broken_file(self) -> None: - mode = DEFAULT_MODE - with cache_dir() as workspace: - cache_file = black.get_cache_file(mode) - with cache_file.open("w") as fobj: - fobj.write("this is not a pickle") - self.assertEqual(black.read_cache(mode), {}) - src = (workspace / "test.py").resolve() - with src.open("w") as fobj: - fobj.write("print('hello')") - self.invokeBlack([str(src)]) - cache = black.read_cache(mode) - self.assertIn(str(src), cache) - - def test_cache_single_file_already_cached(self) -> None: - mode = DEFAULT_MODE - with cache_dir() as workspace: - src = (workspace / "test.py").resolve() - with src.open("w") as fobj: - fobj.write("print('hello')") - black.write_cache({}, [src], mode) - self.invokeBlack([str(src)]) - with src.open("r") as fobj: - self.assertEqual(fobj.read(), "print('hello')") - - @event_loop() - def test_cache_multiple_files(self) -> None: - mode = DEFAULT_MODE - with cache_dir() as workspace, patch( - "black.ProcessPoolExecutor", new=ThreadPoolExecutor - ): - one = (workspace / "one.py").resolve() - with one.open("w") as fobj: - fobj.write("print('hello')") - two = (workspace / "two.py").resolve() - with two.open("w") as fobj: - fobj.write("print('hello')") - black.write_cache({}, [one], mode) - self.invokeBlack([str(workspace)]) - with one.open("r") as fobj: - self.assertEqual(fobj.read(), "print('hello')") - with two.open("r") as fobj: - self.assertEqual(fobj.read(), 'print("hello")\n') - cache = black.read_cache(mode) - self.assertIn(str(one), cache) - self.assertIn(str(two), cache) - - def test_no_cache_when_writeback_diff(self) -> None: - mode = DEFAULT_MODE - with cache_dir() as workspace: - src = (workspace / "test.py").resolve() - with src.open("w") as fobj: - fobj.write("print('hello')") - with patch("black.read_cache") as read_cache, patch( - "black.write_cache" - ) as write_cache: - self.invokeBlack([str(src), "--diff"]) - cache_file = black.get_cache_file(mode) - self.assertFalse(cache_file.exists()) - write_cache.assert_not_called() - read_cache.assert_not_called() - - def test_no_cache_when_writeback_color_diff(self) -> None: - mode = DEFAULT_MODE - with cache_dir() as workspace: - src = (workspace / "test.py").resolve() - with src.open("w") as fobj: - fobj.write("print('hello')") - with patch("black.read_cache") as read_cache, patch( - "black.write_cache" - ) as write_cache: - self.invokeBlack([str(src), "--diff", "--color"]) - cache_file = black.get_cache_file(mode) - self.assertFalse(cache_file.exists()) - write_cache.assert_not_called() - read_cache.assert_not_called() - - @event_loop() - def test_output_locking_when_writeback_diff(self) -> None: - with cache_dir() as workspace: - for tag in range(0, 4): - src = (workspace / f"test{tag}.py").resolve() - with src.open("w") as fobj: - fobj.write("print('hello')") - with patch("black.Manager", wraps=multiprocessing.Manager) as mgr: - self.invokeBlack(["--diff", str(workspace)], exit_code=0) - # this isn't quite doing what we want, but if it _isn't_ - # called then we cannot be using the lock it provides - mgr.assert_called() - - @event_loop() - def test_output_locking_when_writeback_color_diff(self) -> None: - with cache_dir() as workspace: - for tag in range(0, 4): - src = (workspace / f"test{tag}.py").resolve() - with src.open("w") as fobj: - fobj.write("print('hello')") - with patch("black.Manager", wraps=multiprocessing.Manager) as mgr: - self.invokeBlack(["--diff", "--color", str(workspace)], exit_code=0) - # this isn't quite doing what we want, but if it _isn't_ - # called then we cannot be using the lock it provides - mgr.assert_called() - - def test_no_cache_when_stdin(self) -> None: - mode = DEFAULT_MODE - with cache_dir(): - result = CliRunner().invoke( - black.main, ["-"], input=BytesIO(b"print('hello')") - ) - self.assertEqual(result.exit_code, 0) - cache_file = black.get_cache_file(mode) - self.assertFalse(cache_file.exists()) - - def test_read_cache_no_cachefile(self) -> None: - mode = DEFAULT_MODE - with cache_dir(): - self.assertEqual(black.read_cache(mode), {}) - - def test_write_cache_read_cache(self) -> None: - mode = DEFAULT_MODE - with cache_dir() as workspace: - src = (workspace / "test.py").resolve() - src.touch() - black.write_cache({}, [src], mode) - cache = black.read_cache(mode) - self.assertIn(str(src), cache) - self.assertEqual(cache[str(src)], black.get_cache_info(src)) - - def test_filter_cached(self) -> None: - with TemporaryDirectory() as workspace: - path = Path(workspace) - uncached = (path / "uncached").resolve() - cached = (path / "cached").resolve() - cached_but_changed = (path / "changed").resolve() - uncached.touch() - cached.touch() - cached_but_changed.touch() - cache = { - str(cached): black.get_cache_info(cached), - str(cached_but_changed): (0.0, 0), - } - todo, done = black.filter_cached( - cache, {uncached, cached, cached_but_changed} - ) - self.assertEqual(todo, {uncached, cached_but_changed}) - self.assertEqual(done, {cached}) - - def test_write_cache_creates_directory_if_needed(self) -> None: - mode = DEFAULT_MODE - with cache_dir(exists=False) as workspace: - self.assertFalse(workspace.exists()) - black.write_cache({}, [], mode) - self.assertTrue(workspace.exists()) - - @event_loop() - def test_failed_formatting_does_not_get_cached(self) -> None: - mode = DEFAULT_MODE - with cache_dir() as workspace, patch( - "black.ProcessPoolExecutor", new=ThreadPoolExecutor - ): - failing = (workspace / "failing.py").resolve() - with failing.open("w") as fobj: - fobj.write("not actually python") - clean = (workspace / "clean.py").resolve() - with clean.open("w") as fobj: - fobj.write('print("hello")\n') - self.invokeBlack([str(workspace)], exit_code=123) - cache = black.read_cache(mode) - self.assertNotIn(str(failing), cache) - self.assertIn(str(clean), cache) - - def test_write_cache_write_fail(self) -> None: - mode = DEFAULT_MODE - with cache_dir(), patch.object(Path, "open") as mock: - mock.side_effect = OSError - black.write_cache({}, [], mode) - @event_loop() @patch("black.ProcessPoolExecutor", MagicMock(side_effect=OSError)) def test_works_in_mono_process_only_environment(self) -> None: @@ -1232,22 +944,10 @@ class BlackTestCase(BlackBaseTestCase): symlink = workspace / "broken_link.py" try: symlink.symlink_to("nonexistent.py") - except OSError as e: + except (OSError, NotImplementedError) as e: self.skipTest(f"Can't create symlinks: {e}") self.invokeBlack([str(workspace.resolve())]) - def test_read_cache_line_lengths(self) -> None: - mode = DEFAULT_MODE - short_mode = replace(DEFAULT_MODE, line_length=1) - with cache_dir() as workspace: - path = (workspace / "file.py").resolve() - path.touch() - black.write_cache({}, [path], mode) - one = black.read_cache(mode) - self.assertIn(str(path), one) - two = black.read_cache(short_mode) - self.assertNotIn(str(path), two) - def test_single_file_force_pyi(self) -> None: pyi_mode = replace(DEFAULT_MODE, is_pyi=True) contents, expected = read_data("force_pyi") @@ -1355,195 +1055,54 @@ class BlackTestCase(BlackBaseTestCase): actual = result.output self.assertFormatEqual(actual, expected) - def test_include_exclude(self) -> None: - path = THIS_DIR / "data" / "include_exclude_tests" - include = re.compile(r"\.pyi?$") - exclude = re.compile(r"/exclude/|/\.definitely_exclude/") - report = black.Report() - gitignore = PathSpec.from_lines("gitwildmatch", []) - sources: List[Path] = [] - expected = [ - Path(path / "b/dont_exclude/a.py"), - Path(path / "b/dont_exclude/a.pyi"), - ] - this_abs = THIS_DIR.resolve() - sources.extend( - black.gen_python_files( - path.iterdir(), - this_abs, - include, - exclude, - None, - None, - report, - gitignore, - ) - ) - self.assertEqual(sorted(expected), sorted(sources)) - - @patch("black.find_project_root", lambda *args: THIS_DIR.resolve()) - def test_exclude_for_issue_1572(self) -> None: - # Exclude shouldn't touch files that were explicitly given to Black through the - # CLI. Exclude is supposed to only apply to the recursive discovery of files. - # https://github.com/psf/black/issues/1572 - path = THIS_DIR / "data" / "include_exclude_tests" - include = "" - exclude = r"/exclude/|a\.py" - src = str(path / "b/exclude/a.py") - report = black.Report() - expected = [Path(path / "b/exclude/a.py")] - sources = list( - black.get_sources( - ctx=FakeContext(), - src=(src,), - quiet=True, - verbose=False, - include=re.compile(include), - exclude=re.compile(exclude), - extend_exclude=None, - force_exclude=None, - report=report, - stdin_filename=None, - ) - ) - self.assertEqual(sorted(expected), sorted(sources)) - - @patch("black.find_project_root", lambda *args: THIS_DIR.resolve()) - def test_get_sources_with_stdin(self) -> None: - include = "" - exclude = r"/exclude/|a\.py" - src = "-" - report = black.Report() - expected = [Path("-")] - sources = list( - black.get_sources( - ctx=FakeContext(), - src=(src,), - quiet=True, - verbose=False, - include=re.compile(include), - exclude=re.compile(exclude), - extend_exclude=None, - force_exclude=None, - report=report, - stdin_filename=None, - ) - ) - self.assertEqual(sorted(expected), sorted(sources)) - - @patch("black.find_project_root", lambda *args: THIS_DIR.resolve()) - def test_get_sources_with_stdin_filename(self) -> None: - include = "" - exclude = r"/exclude/|a\.py" - src = "-" - report = black.Report() - stdin_filename = str(THIS_DIR / "data/collections.py") - expected = [Path(f"__BLACK_STDIN_FILENAME__{stdin_filename}")] - sources = list( - black.get_sources( - ctx=FakeContext(), - src=(src,), - quiet=True, - verbose=False, - include=re.compile(include), - exclude=re.compile(exclude), - extend_exclude=None, - force_exclude=None, - report=report, - stdin_filename=stdin_filename, - ) - ) - self.assertEqual(sorted(expected), sorted(sources)) - - @patch("black.find_project_root", lambda *args: THIS_DIR.resolve()) - def test_get_sources_with_stdin_filename_and_exclude(self) -> None: - # Exclude shouldn't exclude stdin_filename since it is mimicing the - # file being passed directly. This is the same as - # test_exclude_for_issue_1572 - path = THIS_DIR / "data" / "include_exclude_tests" - include = "" - exclude = r"/exclude/|a\.py" - src = "-" - report = black.Report() - stdin_filename = str(path / "b/exclude/a.py") - expected = [Path(f"__BLACK_STDIN_FILENAME__{stdin_filename}")] - sources = list( - black.get_sources( - ctx=FakeContext(), - src=(src,), - quiet=True, - verbose=False, - include=re.compile(include), - exclude=re.compile(exclude), - extend_exclude=None, - force_exclude=None, + def test_reformat_one_with_stdin(self) -> None: + with patch( + "black.format_stdin_to_stdout", + return_value=lambda *args, **kwargs: black.Changed.YES, + ) as fsts: + report = MagicMock() + path = Path("-") + black.reformat_one( + path, + fast=True, + write_back=black.WriteBack.YES, + mode=DEFAULT_MODE, report=report, - stdin_filename=stdin_filename, ) - ) - self.assertEqual(sorted(expected), sorted(sources)) + fsts.assert_called_once() + report.done.assert_called_with(path, black.Changed.YES) - @patch("black.find_project_root", lambda *args: THIS_DIR.resolve()) - def test_get_sources_with_stdin_filename_and_extend_exclude(self) -> None: - # Extend exclude shouldn't exclude stdin_filename since it is mimicking the - # file being passed directly. This is the same as - # test_exclude_for_issue_1572 - path = THIS_DIR / "data" / "include_exclude_tests" - include = "" - extend_exclude = r"/exclude/|a\.py" - src = "-" - report = black.Report() - stdin_filename = str(path / "b/exclude/a.py") - expected = [Path(f"__BLACK_STDIN_FILENAME__{stdin_filename}")] - sources = list( - black.get_sources( - ctx=FakeContext(), - src=(src,), - quiet=True, - verbose=False, - include=re.compile(include), - exclude=re.compile(""), - extend_exclude=re.compile(extend_exclude), - force_exclude=None, + def test_reformat_one_with_stdin_filename(self) -> None: + with patch( + "black.format_stdin_to_stdout", + return_value=lambda *args, **kwargs: black.Changed.YES, + ) as fsts: + report = MagicMock() + p = "foo.py" + path = Path(f"__BLACK_STDIN_FILENAME__{p}") + expected = Path(p) + black.reformat_one( + path, + fast=True, + write_back=black.WriteBack.YES, + mode=DEFAULT_MODE, report=report, - stdin_filename=stdin_filename, ) - ) - self.assertEqual(sorted(expected), sorted(sources)) - - @patch("black.find_project_root", lambda *args: THIS_DIR.resolve()) - def test_get_sources_with_stdin_filename_and_force_exclude(self) -> None: - # Force exclude should exclude the file when passing it through - # stdin_filename - path = THIS_DIR / "data" / "include_exclude_tests" - include = "" - force_exclude = r"/exclude/|a\.py" - src = "-" - report = black.Report() - stdin_filename = str(path / "b/exclude/a.py") - sources = list( - black.get_sources( - ctx=FakeContext(), - src=(src,), - quiet=True, - verbose=False, - include=re.compile(include), - exclude=re.compile(""), - extend_exclude=None, - force_exclude=re.compile(force_exclude), - report=report, - stdin_filename=stdin_filename, + fsts.assert_called_once_with( + fast=True, write_back=black.WriteBack.YES, mode=DEFAULT_MODE ) - ) - self.assertEqual([], sorted(sources)) + # __BLACK_STDIN_FILENAME__ should have been stripped + report.done.assert_called_with(expected, black.Changed.YES) - def test_reformat_one_with_stdin(self) -> None: + def test_reformat_one_with_stdin_filename_pyi(self) -> None: with patch( "black.format_stdin_to_stdout", return_value=lambda *args, **kwargs: black.Changed.YES, ) as fsts: report = MagicMock() - path = Path("-") + p = "foo.pyi" + path = Path(f"__BLACK_STDIN_FILENAME__{p}") + expected = Path(p) black.reformat_one( path, fast=True, @@ -1551,16 +1110,21 @@ class BlackTestCase(BlackBaseTestCase): mode=DEFAULT_MODE, report=report, ) - fsts.assert_called_once() - report.done.assert_called_with(path, black.Changed.YES) + fsts.assert_called_once_with( + fast=True, + write_back=black.WriteBack.YES, + mode=replace(DEFAULT_MODE, is_pyi=True), + ) + # __BLACK_STDIN_FILENAME__ should have been stripped + report.done.assert_called_with(expected, black.Changed.YES) - def test_reformat_one_with_stdin_filename(self) -> None: + def test_reformat_one_with_stdin_filename_ipynb(self) -> None: with patch( "black.format_stdin_to_stdout", return_value=lambda *args, **kwargs: black.Changed.YES, ) as fsts: report = MagicMock() - p = "foo.py" + p = "foo.ipynb" path = Path(f"__BLACK_STDIN_FILENAME__{p}") expected = Path(p) black.reformat_one( @@ -1570,8 +1134,12 @@ class BlackTestCase(BlackBaseTestCase): mode=DEFAULT_MODE, report=report, ) - fsts.assert_called_once() - # __BLACK_STDIN_FILENAME__ should have been striped + fsts.assert_called_once_with( + fast=True, + write_back=black.WriteBack.YES, + mode=replace(DEFAULT_MODE, is_ipynb=True), + ) + # __BLACK_STDIN_FILENAME__ should have been stripped report.done.assert_called_with(expected, black.Changed.YES) def test_reformat_one_with_stdin_and_existing_path(self) -> None: @@ -1595,43 +1163,710 @@ class BlackTestCase(BlackBaseTestCase): report=report, ) fsts.assert_called_once() - # __BLACK_STDIN_FILENAME__ should have been striped + # __BLACK_STDIN_FILENAME__ should have been stripped report.done.assert_called_with(expected, black.Changed.YES) - def test_gitignore_exclude(self) -> None: - path = THIS_DIR / "data" / "include_exclude_tests" - include = re.compile(r"\.pyi?$") - exclude = re.compile(r"") - report = black.Report() - gitignore = PathSpec.from_lines( - "gitwildmatch", ["exclude/", ".definitely_exclude"] + def test_reformat_one_with_stdin_empty(self) -> None: + output = io.StringIO() + with patch("io.TextIOWrapper", lambda *args, **kwargs: output): + try: + black.format_stdin_to_stdout( + fast=True, + content="", + write_back=black.WriteBack.YES, + mode=DEFAULT_MODE, + ) + except io.UnsupportedOperation: + pass # StringIO does not support detach + assert output.getvalue() == "" + + def test_invalid_cli_regex(self) -> None: + for option in ["--include", "--exclude", "--extend-exclude", "--force-exclude"]: + self.invokeBlack(["-", option, "**()(!!*)"], exit_code=2) + + def test_required_version_matches_version(self) -> None: + self.invokeBlack( + ["--required-version", black.__version__], exit_code=0, ignore_config=True ) - sources: List[Path] = [] - expected = [ - Path(path / "b/dont_exclude/a.py"), - Path(path / "b/dont_exclude/a.pyi"), - ] - this_abs = THIS_DIR.resolve() - sources.extend( - black.gen_python_files( - path.iterdir(), - this_abs, - include, - exclude, - None, - None, - report, - gitignore, - ) + + def test_required_version_does_not_match_version(self) -> None: + self.invokeBlack( + ["--required-version", "20.99b"], exit_code=1, ignore_config=True ) - self.assertEqual(sorted(expected), sorted(sources)) + + def test_preserves_line_endings(self) -> None: + with TemporaryDirectory() as workspace: + test_file = Path(workspace) / "test.py" + for nl in ["\n", "\r\n"]: + contents = nl.join(["def f( ):", " pass"]) + test_file.write_bytes(contents.encode()) + ff(test_file, write_back=black.WriteBack.YES) + updated_contents: bytes = test_file.read_bytes() + self.assertIn(nl.encode(), updated_contents) + if nl == "\n": + self.assertNotIn(b"\r\n", updated_contents) + + def test_preserves_line_endings_via_stdin(self) -> None: + for nl in ["\n", "\r\n"]: + contents = nl.join(["def f( ):", " pass"]) + runner = BlackRunner() + result = runner.invoke( + black.main, ["-", "--fast"], input=BytesIO(contents.encode("utf8")) + ) + self.assertEqual(result.exit_code, 0) + output = result.stdout_bytes + self.assertIn(nl.encode("utf8"), output) + if nl == "\n": + self.assertNotIn(b"\r\n", output) + + def test_assert_equivalent_different_asts(self) -> None: + with self.assertRaises(AssertionError): + black.assert_equivalent("{}", "None") + + def test_shhh_click(self) -> None: + try: + from click import _unicodefun + except ModuleNotFoundError: + self.skipTest("Incompatible Click version") + if not hasattr(_unicodefun, "_verify_python3_env"): + self.skipTest("Incompatible Click version") + # First, let's see if Click is crashing with a preferred ASCII charset. + with patch("locale.getpreferredencoding") as gpe: + gpe.return_value = "ASCII" + with self.assertRaises(RuntimeError): + _unicodefun._verify_python3_env() # type: ignore + # Now, let's silence Click... + black.patch_click() + # ...and confirm it's silent. + with patch("locale.getpreferredencoding") as gpe: + gpe.return_value = "ASCII" + try: + _unicodefun._verify_python3_env() # type: ignore + except RuntimeError as re: + self.fail(f"`patch_click()` failed, exception still raised: {re}") + + def test_root_logger_not_used_directly(self) -> None: + def fail(*args: Any, **kwargs: Any) -> None: + self.fail("Record created with root logger") + + with patch.multiple( + logging.root, + debug=fail, + info=fail, + warning=fail, + error=fail, + critical=fail, + log=fail, + ): + ff(THIS_DIR / "util.py") + + def test_invalid_config_return_code(self) -> None: + tmp_file = Path(black.dump_to_file()) + try: + tmp_config = Path(black.dump_to_file()) + tmp_config.unlink() + args = ["--config", str(tmp_config), str(tmp_file)] + self.invokeBlack(args, exit_code=2, ignore_config=False) + finally: + tmp_file.unlink() + + def test_parse_pyproject_toml(self) -> None: + test_toml_file = THIS_DIR / "test.toml" + config = black.parse_pyproject_toml(str(test_toml_file)) + self.assertEqual(config["verbose"], 1) + self.assertEqual(config["check"], "no") + self.assertEqual(config["diff"], "y") + self.assertEqual(config["color"], True) + self.assertEqual(config["line_length"], 79) + self.assertEqual(config["target_version"], ["py36", "py37", "py38"]) + self.assertEqual(config["exclude"], r"\.pyi?$") + self.assertEqual(config["include"], r"\.py?$") + + def test_read_pyproject_toml(self) -> None: + test_toml_file = THIS_DIR / "test.toml" + fake_ctx = FakeContext() + black.read_pyproject_toml(fake_ctx, FakeParameter(), str(test_toml_file)) + config = fake_ctx.default_map + self.assertEqual(config["verbose"], "1") + self.assertEqual(config["check"], "no") + self.assertEqual(config["diff"], "y") + self.assertEqual(config["color"], "True") + self.assertEqual(config["line_length"], "79") + self.assertEqual(config["target_version"], ["py36", "py37", "py38"]) + self.assertEqual(config["exclude"], r"\.pyi?$") + self.assertEqual(config["include"], r"\.py?$") + + def test_find_project_root(self) -> None: + with TemporaryDirectory() as workspace: + root = Path(workspace) + test_dir = root / "test" + test_dir.mkdir() + + src_dir = root / "src" + src_dir.mkdir() + + root_pyproject = root / "pyproject.toml" + root_pyproject.touch() + src_pyproject = src_dir / "pyproject.toml" + src_pyproject.touch() + src_python = src_dir / "foo.py" + src_python.touch() + + self.assertEqual( + black.find_project_root((src_dir, test_dir)), root.resolve() + ) + self.assertEqual(black.find_project_root((src_dir,)), src_dir.resolve()) + self.assertEqual(black.find_project_root((src_python,)), src_dir.resolve()) + + @patch( + "black.files.find_user_pyproject_toml", + black.files.find_user_pyproject_toml.__wrapped__, + ) + def test_find_user_pyproject_toml_linux(self) -> None: + if system() == "Windows": + return + + # Test if XDG_CONFIG_HOME is checked + with TemporaryDirectory() as workspace: + tmp_user_config = Path(workspace) / "black" + with patch.dict("os.environ", {"XDG_CONFIG_HOME": workspace}): + self.assertEqual( + black.files.find_user_pyproject_toml(), tmp_user_config.resolve() + ) + + # Test fallback for XDG_CONFIG_HOME + with patch.dict("os.environ"): + os.environ.pop("XDG_CONFIG_HOME", None) + fallback_user_config = Path("~/.config").expanduser() / "black" + self.assertEqual( + black.files.find_user_pyproject_toml(), fallback_user_config.resolve() + ) + + def test_find_user_pyproject_toml_windows(self) -> None: + if system() != "Windows": + return + + user_config_path = Path.home() / ".black" + self.assertEqual( + black.files.find_user_pyproject_toml(), user_config_path.resolve() + ) + + def test_bpo_33660_workaround(self) -> None: + if system() == "Windows": + return + + # https://bugs.python.org/issue33660 + root = Path("/") + with change_directory(root): + path = Path("workspace") / "project" + report = black.Report(verbose=True) + normalized_path = black.normalize_path_maybe_ignore(path, root, report) + self.assertEqual(normalized_path, "workspace/project") + + def test_newline_comment_interaction(self) -> None: + source = "class A:\\\r\n# type: ignore\n pass\n" + output = black.format_str(source, mode=DEFAULT_MODE) + black.assert_stable(source, output, mode=DEFAULT_MODE) + + def test_bpo_2142_workaround(self) -> None: + + # https://bugs.python.org/issue2142 + + source, _ = read_data("missing_final_newline.py") + # read_data adds a trailing newline + source = source.rstrip() + expected, _ = read_data("missing_final_newline.diff") + tmp_file = Path(black.dump_to_file(source, ensure_final_newline=False)) + diff_header = re.compile( + rf"{re.escape(str(tmp_file))}\t\d\d\d\d-\d\d-\d\d " + r"\d\d:\d\d:\d\d\.\d\d\d\d\d\d \+\d\d\d\d" + ) + try: + result = BlackRunner().invoke(black.main, ["--diff", str(tmp_file)]) + self.assertEqual(result.exit_code, 0) + finally: + os.unlink(tmp_file) + actual = result.output + actual = diff_header.sub(DETERMINISTIC_HEADER, actual) + self.assertEqual(actual, expected) + + @pytest.mark.python2 + def test_docstring_reformat_for_py27(self) -> None: + """ + Check that stripping trailing whitespace from Python 2 docstrings + doesn't trigger a "not equivalent to source" error + """ + source = ( + b'def foo():\r\n """Testing\r\n Testing """\r\n print "Foo"\r\n' + ) + expected = 'def foo():\n """Testing\n Testing"""\n print "Foo"\n' + + result = BlackRunner().invoke( + black.main, + ["-", "-q", "--target-version=py27"], + input=BytesIO(source), + ) + + self.assertEqual(result.exit_code, 0) + actual = result.stdout + self.assertFormatEqual(actual, expected) + + @staticmethod + def compare_results( + result: click.testing.Result, expected_value: str, expected_exit_code: int + ) -> None: + """Helper method to test the value and exit code of a click Result.""" + assert ( + result.output == expected_value + ), "The output did not match the expected value." + assert result.exit_code == expected_exit_code, "The exit code is incorrect." + + def test_code_option(self) -> None: + """Test the code option with no changes.""" + code = 'print("Hello world")\n' + args = ["--code", code] + result = CliRunner().invoke(black.main, args) + + self.compare_results(result, code, 0) + + def test_code_option_changed(self) -> None: + """Test the code option when changes are required.""" + code = "print('hello world')" + formatted = black.format_str(code, mode=DEFAULT_MODE) + + args = ["--code", code] + result = CliRunner().invoke(black.main, args) + + self.compare_results(result, formatted, 0) + + def test_code_option_check(self) -> None: + """Test the code option when check is passed.""" + args = ["--check", "--code", 'print("Hello world")\n'] + result = CliRunner().invoke(black.main, args) + self.compare_results(result, "", 0) + + def test_code_option_check_changed(self) -> None: + """Test the code option when changes are required, and check is passed.""" + args = ["--check", "--code", "print('hello world')"] + result = CliRunner().invoke(black.main, args) + self.compare_results(result, "", 1) + + def test_code_option_diff(self) -> None: + """Test the code option when diff is passed.""" + code = "print('hello world')" + formatted = black.format_str(code, mode=DEFAULT_MODE) + result_diff = diff(code, formatted, "STDIN", "STDOUT") + + args = ["--diff", "--code", code] + result = CliRunner().invoke(black.main, args) + + # Remove time from diff + output = DIFF_TIME.sub("", result.output) + + assert output == result_diff, "The output did not match the expected value." + assert result.exit_code == 0, "The exit code is incorrect." + + def test_code_option_color_diff(self) -> None: + """Test the code option when color and diff are passed.""" + code = "print('hello world')" + formatted = black.format_str(code, mode=DEFAULT_MODE) + + result_diff = diff(code, formatted, "STDIN", "STDOUT") + result_diff = color_diff(result_diff) + + args = ["--diff", "--color", "--code", code] + result = CliRunner().invoke(black.main, args) + + # Remove time from diff + output = DIFF_TIME.sub("", result.output) + + assert output == result_diff, "The output did not match the expected value." + assert result.exit_code == 0, "The exit code is incorrect." + + def test_code_option_safe(self) -> None: + """Test that the code option throws an error when the sanity checks fail.""" + # Patch black.assert_equivalent to ensure the sanity checks fail + with patch.object(black, "assert_equivalent", side_effect=AssertionError): + code = 'print("Hello world")' + error_msg = f"{code}\nerror: cannot format : \n" + + args = ["--safe", "--code", code] + result = CliRunner().invoke(black.main, args) + + self.compare_results(result, error_msg, 123) + + def test_code_option_fast(self) -> None: + """Test that the code option ignores errors when the sanity checks fail.""" + # Patch black.assert_equivalent to ensure the sanity checks fail + with patch.object(black, "assert_equivalent", side_effect=AssertionError): + code = 'print("Hello world")' + formatted = black.format_str(code, mode=DEFAULT_MODE) + + args = ["--fast", "--code", code] + result = CliRunner().invoke(black.main, args) + + self.compare_results(result, formatted, 0) + + def test_code_option_config(self) -> None: + """ + Test that the code option finds the pyproject.toml in the current directory. + """ + with patch.object(black, "parse_pyproject_toml", return_value={}) as parse: + args = ["--code", "print"] + # This is the only directory known to contain a pyproject.toml + with change_directory(PROJECT_ROOT): + CliRunner().invoke(black.main, args) + pyproject_path = Path(Path.cwd(), "pyproject.toml").resolve() + + assert ( + len(parse.mock_calls) >= 1 + ), "Expected config parse to be called with the current directory." + + _, call_args, _ = parse.mock_calls[0] + assert ( + call_args[0].lower() == str(pyproject_path).lower() + ), "Incorrect config loaded." + + def test_code_option_parent_config(self) -> None: + """ + Test that the code option finds the pyproject.toml in the parent directory. + """ + with patch.object(black, "parse_pyproject_toml", return_value={}) as parse: + with change_directory(THIS_DIR): + args = ["--code", "print"] + CliRunner().invoke(black.main, args) + + pyproject_path = Path(Path().cwd().parent, "pyproject.toml").resolve() + assert ( + len(parse.mock_calls) >= 1 + ), "Expected config parse to be called with the current directory." + + _, call_args, _ = parse.mock_calls[0] + assert ( + call_args[0].lower() == str(pyproject_path).lower() + ), "Incorrect config loaded." + + +class TestCaching: + def test_cache_broken_file(self) -> None: + mode = DEFAULT_MODE + with cache_dir() as workspace: + cache_file = get_cache_file(mode) + cache_file.write_text("this is not a pickle") + assert black.read_cache(mode) == {} + src = (workspace / "test.py").resolve() + src.write_text("print('hello')") + invokeBlack([str(src)]) + cache = black.read_cache(mode) + assert str(src) in cache + + def test_cache_single_file_already_cached(self) -> None: + mode = DEFAULT_MODE + with cache_dir() as workspace: + src = (workspace / "test.py").resolve() + src.write_text("print('hello')") + black.write_cache({}, [src], mode) + invokeBlack([str(src)]) + assert src.read_text() == "print('hello')" + + @event_loop() + def test_cache_multiple_files(self) -> None: + mode = DEFAULT_MODE + with cache_dir() as workspace, patch( + "black.ProcessPoolExecutor", new=ThreadPoolExecutor + ): + one = (workspace / "one.py").resolve() + with one.open("w") as fobj: + fobj.write("print('hello')") + two = (workspace / "two.py").resolve() + with two.open("w") as fobj: + fobj.write("print('hello')") + black.write_cache({}, [one], mode) + invokeBlack([str(workspace)]) + with one.open("r") as fobj: + assert fobj.read() == "print('hello')" + with two.open("r") as fobj: + assert fobj.read() == 'print("hello")\n' + cache = black.read_cache(mode) + assert str(one) in cache + assert str(two) in cache + + @pytest.mark.parametrize("color", [False, True], ids=["no-color", "with-color"]) + def test_no_cache_when_writeback_diff(self, color: bool) -> None: + mode = DEFAULT_MODE + with cache_dir() as workspace: + src = (workspace / "test.py").resolve() + with src.open("w") as fobj: + fobj.write("print('hello')") + with patch("black.read_cache") as read_cache, patch( + "black.write_cache" + ) as write_cache: + cmd = [str(src), "--diff"] + if color: + cmd.append("--color") + invokeBlack(cmd) + cache_file = get_cache_file(mode) + assert cache_file.exists() is False + write_cache.assert_not_called() + read_cache.assert_not_called() + + @pytest.mark.parametrize("color", [False, True], ids=["no-color", "with-color"]) + @event_loop() + def test_output_locking_when_writeback_diff(self, color: bool) -> None: + with cache_dir() as workspace: + for tag in range(0, 4): + src = (workspace / f"test{tag}.py").resolve() + with src.open("w") as fobj: + fobj.write("print('hello')") + with patch("black.Manager", wraps=multiprocessing.Manager) as mgr: + cmd = ["--diff", str(workspace)] + if color: + cmd.append("--color") + invokeBlack(cmd, exit_code=0) + # this isn't quite doing what we want, but if it _isn't_ + # called then we cannot be using the lock it provides + mgr.assert_called() + + def test_no_cache_when_stdin(self) -> None: + mode = DEFAULT_MODE + with cache_dir(): + result = CliRunner().invoke( + black.main, ["-"], input=BytesIO(b"print('hello')") + ) + assert not result.exit_code + cache_file = get_cache_file(mode) + assert not cache_file.exists() + + def test_read_cache_no_cachefile(self) -> None: + mode = DEFAULT_MODE + with cache_dir(): + assert black.read_cache(mode) == {} + + def test_write_cache_read_cache(self) -> None: + mode = DEFAULT_MODE + with cache_dir() as workspace: + src = (workspace / "test.py").resolve() + src.touch() + black.write_cache({}, [src], mode) + cache = black.read_cache(mode) + assert str(src) in cache + assert cache[str(src)] == black.get_cache_info(src) + + def test_filter_cached(self) -> None: + with TemporaryDirectory() as workspace: + path = Path(workspace) + uncached = (path / "uncached").resolve() + cached = (path / "cached").resolve() + cached_but_changed = (path / "changed").resolve() + uncached.touch() + cached.touch() + cached_but_changed.touch() + cache = { + str(cached): black.get_cache_info(cached), + str(cached_but_changed): (0.0, 0), + } + todo, done = black.filter_cached( + cache, {uncached, cached, cached_but_changed} + ) + assert todo == {uncached, cached_but_changed} + assert done == {cached} + + def test_write_cache_creates_directory_if_needed(self) -> None: + mode = DEFAULT_MODE + with cache_dir(exists=False) as workspace: + assert not workspace.exists() + black.write_cache({}, [], mode) + assert workspace.exists() + + @event_loop() + def test_failed_formatting_does_not_get_cached(self) -> None: + mode = DEFAULT_MODE + with cache_dir() as workspace, patch( + "black.ProcessPoolExecutor", new=ThreadPoolExecutor + ): + failing = (workspace / "failing.py").resolve() + with failing.open("w") as fobj: + fobj.write("not actually python") + clean = (workspace / "clean.py").resolve() + with clean.open("w") as fobj: + fobj.write('print("hello")\n') + invokeBlack([str(workspace)], exit_code=123) + cache = black.read_cache(mode) + assert str(failing) not in cache + assert str(clean) in cache + + def test_write_cache_write_fail(self) -> None: + mode = DEFAULT_MODE + with cache_dir(), patch.object(Path, "open") as mock: + mock.side_effect = OSError + black.write_cache({}, [], mode) + + def test_read_cache_line_lengths(self) -> None: + mode = DEFAULT_MODE + short_mode = replace(DEFAULT_MODE, line_length=1) + with cache_dir() as workspace: + path = (workspace / "file.py").resolve() + path.touch() + black.write_cache({}, [path], mode) + one = black.read_cache(mode) + assert str(path) in one + two = black.read_cache(short_mode) + assert str(path) not in two + + +def assert_collected_sources( + src: Sequence[Union[str, Path]], + expected: Sequence[Union[str, Path]], + *, + exclude: Optional[str] = None, + include: Optional[str] = None, + extend_exclude: Optional[str] = None, + force_exclude: Optional[str] = None, + stdin_filename: Optional[str] = None, +) -> None: + gs_src = tuple(str(Path(s)) for s in src) + gs_expected = [Path(s) for s in expected] + gs_exclude = None if exclude is None else compile_pattern(exclude) + gs_include = DEFAULT_INCLUDE if include is None else compile_pattern(include) + gs_extend_exclude = ( + None if extend_exclude is None else compile_pattern(extend_exclude) + ) + gs_force_exclude = None if force_exclude is None else compile_pattern(force_exclude) + collected = black.get_sources( + ctx=FakeContext(), + src=gs_src, + quiet=False, + verbose=False, + include=gs_include, + exclude=gs_exclude, + extend_exclude=gs_extend_exclude, + force_exclude=gs_force_exclude, + report=black.Report(), + stdin_filename=stdin_filename, + ) + assert sorted(list(collected)) == sorted(gs_expected) + + +class TestFileCollection: + def test_include_exclude(self) -> None: + path = THIS_DIR / "data" / "include_exclude_tests" + src = [path] + expected = [ + Path(path / "b/dont_exclude/a.py"), + Path(path / "b/dont_exclude/a.pyi"), + ] + assert_collected_sources( + src, + expected, + include=r"\.pyi?$", + exclude=r"/exclude/|/\.definitely_exclude/", + ) + + def test_gitignore_used_as_default(self) -> None: + base = Path(DATA_DIR / "include_exclude_tests") + expected = [ + base / "b/.definitely_exclude/a.py", + base / "b/.definitely_exclude/a.pyi", + ] + src = [base / "b/"] + assert_collected_sources(src, expected, extend_exclude=r"/exclude/") + + @patch("black.find_project_root", lambda *args: THIS_DIR.resolve()) + def test_exclude_for_issue_1572(self) -> None: + # Exclude shouldn't touch files that were explicitly given to Black through the + # CLI. Exclude is supposed to only apply to the recursive discovery of files. + # https://github.com/psf/black/issues/1572 + path = DATA_DIR / "include_exclude_tests" + src = [path / "b/exclude/a.py"] + expected = [path / "b/exclude/a.py"] + assert_collected_sources(src, expected, include="", exclude=r"/exclude/|a\.py") + + def test_gitignore_exclude(self) -> None: + path = THIS_DIR / "data" / "include_exclude_tests" + include = re.compile(r"\.pyi?$") + exclude = re.compile(r"") + report = black.Report() + gitignore = PathSpec.from_lines( + "gitwildmatch", ["exclude/", ".definitely_exclude"] + ) + sources: List[Path] = [] + expected = [ + Path(path / "b/dont_exclude/a.py"), + Path(path / "b/dont_exclude/a.pyi"), + ] + this_abs = THIS_DIR.resolve() + sources.extend( + black.gen_python_files( + path.iterdir(), + this_abs, + include, + exclude, + None, + None, + report, + gitignore, + verbose=False, + quiet=False, + ) + ) + assert sorted(expected) == sorted(sources) + + def test_nested_gitignore(self) -> None: + path = Path(THIS_DIR / "data" / "nested_gitignore_tests") + include = re.compile(r"\.pyi?$") + exclude = re.compile(r"") + root_gitignore = black.files.get_gitignore(path) + report = black.Report() + expected: List[Path] = [ + Path(path / "x.py"), + Path(path / "root/b.py"), + Path(path / "root/c.py"), + Path(path / "root/child/c.py"), + ] + this_abs = THIS_DIR.resolve() + sources = list( + black.gen_python_files( + path.iterdir(), + this_abs, + include, + exclude, + None, + None, + report, + root_gitignore, + verbose=False, + quiet=False, + ) + ) + assert sorted(expected) == sorted(sources) + + def test_invalid_gitignore(self) -> None: + path = THIS_DIR / "data" / "invalid_gitignore_tests" + empty_config = path / "pyproject.toml" + result = BlackRunner().invoke( + black.main, ["--verbose", "--config", str(empty_config), str(path)] + ) + assert result.exit_code == 1 + assert result.stderr_bytes is not None + + gitignore = path / ".gitignore" + assert f"Could not parse {gitignore}" in result.stderr_bytes.decode() + + def test_invalid_nested_gitignore(self) -> None: + path = THIS_DIR / "data" / "invalid_nested_gitignore_tests" + empty_config = path / "pyproject.toml" + result = BlackRunner().invoke( + black.main, ["--verbose", "--config", str(empty_config), str(path)] + ) + assert result.exit_code == 1 + assert result.stderr_bytes is not None + + gitignore = path / "a" / ".gitignore" + assert f"Could not parse {gitignore}" in result.stderr_bytes.decode() def test_empty_include(self) -> None: - path = THIS_DIR / "data" / "include_exclude_tests" - report = black.Report() - gitignore = PathSpec.from_lines("gitwildmatch", []) - empty = re.compile(r"") - sources: List[Path] = [] + path = DATA_DIR / "include_exclude_tests" + src = [path] expected = [ Path(path / "b/exclude/a.pie"), Path(path / "b/exclude/a.py"), @@ -1642,78 +1877,22 @@ class BlackTestCase(BlackBaseTestCase): Path(path / "b/.definitely_exclude/a.pie"), Path(path / "b/.definitely_exclude/a.py"), Path(path / "b/.definitely_exclude/a.pyi"), + Path(path / ".gitignore"), + Path(path / "pyproject.toml"), ] - this_abs = THIS_DIR.resolve() - sources.extend( - black.gen_python_files( - path.iterdir(), - this_abs, - empty, - re.compile(black.DEFAULT_EXCLUDES), - None, - None, - report, - gitignore, - ) - ) - self.assertEqual(sorted(expected), sorted(sources)) + # Setting exclude explicitly to an empty string to block .gitignore usage. + assert_collected_sources(src, expected, include="", exclude="") def test_extend_exclude(self) -> None: - path = THIS_DIR / "data" / "include_exclude_tests" - report = black.Report() - gitignore = PathSpec.from_lines("gitwildmatch", []) - sources: List[Path] = [] + path = DATA_DIR / "include_exclude_tests" + src = [path] expected = [ Path(path / "b/exclude/a.py"), Path(path / "b/dont_exclude/a.py"), ] - this_abs = THIS_DIR.resolve() - sources.extend( - black.gen_python_files( - path.iterdir(), - this_abs, - re.compile(black.DEFAULT_INCLUDES), - re.compile(r"\.pyi$"), - re.compile(r"\.definitely_exclude"), - None, - report, - gitignore, - ) + assert_collected_sources( + src, expected, exclude=r"\.pyi$", extend_exclude=r"\.definitely_exclude" ) - self.assertEqual(sorted(expected), sorted(sources)) - - def test_invalid_cli_regex(self) -> None: - for option in ["--include", "--exclude", "--extend-exclude", "--force-exclude"]: - self.invokeBlack(["-", option, "**()(!!*)"], exit_code=2) - - def test_preserves_line_endings(self) -> None: - with TemporaryDirectory() as workspace: - test_file = Path(workspace) / "test.py" - for nl in ["\n", "\r\n"]: - contents = nl.join(["def f( ):", " pass"]) - test_file.write_bytes(contents.encode()) - ff(test_file, write_back=black.WriteBack.YES) - updated_contents: bytes = test_file.read_bytes() - self.assertIn(nl.encode(), updated_contents) - if nl == "\n": - self.assertNotIn(b"\r\n", updated_contents) - - def test_preserves_line_endings_via_stdin(self) -> None: - for nl in ["\n", "\r\n"]: - contents = nl.join(["def f( ):", " pass"]) - runner = BlackRunner() - result = runner.invoke( - black.main, ["-", "--fast"], input=BytesIO(contents.encode("utf8")) - ) - self.assertEqual(result.exit_code, 0) - output = runner.stdout_bytes - self.assertIn(nl.encode("utf8"), output) - if nl == "\n": - self.assertNotIn(b"\r\n", output) - - def test_assert_equivalent_different_asts(self) -> None: - with self.assertRaises(AssertionError): - black.assert_equivalent("{}", "None") def test_symlink_out_of_root_directory(self) -> None: path = MagicMock() @@ -1740,17 +1919,19 @@ class BlackTestCase(BlackBaseTestCase): None, report, gitignore, + verbose=False, + quiet=False, ) ) except ValueError as ve: - self.fail(f"`get_python_files_in_dir()` failed: {ve}") + pytest.fail(f"`get_python_files_in_dir()` failed: {ve}") path.iterdir.assert_called_once() child.resolve.assert_called_once() child.is_symlink.assert_called_once() # `child` should behave like a strange file which resolved path is clearly # outside of the `root` directory. child.is_symlink.return_value = False - with self.assertRaises(ValueError): + with pytest.raises(ValueError): list( black.gen_python_files( path.iterdir(), @@ -1761,208 +1942,118 @@ class BlackTestCase(BlackBaseTestCase): None, report, gitignore, + verbose=False, + quiet=False, ) ) path.iterdir.assert_called() - self.assertEqual(path.iterdir.call_count, 2) + assert path.iterdir.call_count == 2 child.resolve.assert_called() - self.assertEqual(child.resolve.call_count, 2) + assert child.resolve.call_count == 2 child.is_symlink.assert_called() - self.assertEqual(child.is_symlink.call_count, 2) - - def test_shhh_click(self) -> None: - try: - from click import _unicodefun # type: ignore - except ModuleNotFoundError: - self.skipTest("Incompatible Click version") - if not hasattr(_unicodefun, "_verify_python3_env"): - self.skipTest("Incompatible Click version") - # First, let's see if Click is crashing with a preferred ASCII charset. - with patch("locale.getpreferredencoding") as gpe: - gpe.return_value = "ASCII" - with self.assertRaises(RuntimeError): - _unicodefun._verify_python3_env() - # Now, let's silence Click... - black.patch_click() - # ...and confirm it's silent. - with patch("locale.getpreferredencoding") as gpe: - gpe.return_value = "ASCII" - try: - _unicodefun._verify_python3_env() - except RuntimeError as re: - self.fail(f"`patch_click()` failed, exception still raised: {re}") - - def test_root_logger_not_used_directly(self) -> None: - def fail(*args: Any, **kwargs: Any) -> None: - self.fail("Record created with root logger") - - with patch.multiple( - logging.root, - debug=fail, - info=fail, - warning=fail, - error=fail, - critical=fail, - log=fail, - ): - ff(THIS_FILE) - - def test_invalid_config_return_code(self) -> None: - tmp_file = Path(black.dump_to_file()) - try: - tmp_config = Path(black.dump_to_file()) - tmp_config.unlink() - args = ["--config", str(tmp_config), str(tmp_file)] - self.invokeBlack(args, exit_code=2, ignore_config=False) - finally: - tmp_file.unlink() - - def test_parse_pyproject_toml(self) -> None: - test_toml_file = THIS_DIR / "test.toml" - config = black.parse_pyproject_toml(str(test_toml_file)) - self.assertEqual(config["verbose"], 1) - self.assertEqual(config["check"], "no") - self.assertEqual(config["diff"], "y") - self.assertEqual(config["color"], True) - self.assertEqual(config["line_length"], 79) - self.assertEqual(config["target_version"], ["py36", "py37", "py38"]) - self.assertEqual(config["exclude"], r"\.pyi?$") - self.assertEqual(config["include"], r"\.py?$") - - def test_read_pyproject_toml(self) -> None: - test_toml_file = THIS_DIR / "test.toml" - fake_ctx = FakeContext() - black.read_pyproject_toml(fake_ctx, FakeParameter(), str(test_toml_file)) - config = fake_ctx.default_map - self.assertEqual(config["verbose"], "1") - self.assertEqual(config["check"], "no") - self.assertEqual(config["diff"], "y") - self.assertEqual(config["color"], "True") - self.assertEqual(config["line_length"], "79") - self.assertEqual(config["target_version"], ["py36", "py37", "py38"]) - self.assertEqual(config["exclude"], r"\.pyi?$") - self.assertEqual(config["include"], r"\.py?$") - - def test_find_project_root(self) -> None: - with TemporaryDirectory() as workspace: - root = Path(workspace) - test_dir = root / "test" - test_dir.mkdir() - - src_dir = root / "src" - src_dir.mkdir() - - root_pyproject = root / "pyproject.toml" - root_pyproject.touch() - src_pyproject = src_dir / "pyproject.toml" - src_pyproject.touch() - src_python = src_dir / "foo.py" - src_python.touch() - - self.assertEqual( - black.find_project_root((src_dir, test_dir)), root.resolve() - ) - self.assertEqual(black.find_project_root((src_dir,)), src_dir.resolve()) - self.assertEqual(black.find_project_root((src_python,)), src_dir.resolve()) - - @patch("black.find_user_pyproject_toml", black.find_user_pyproject_toml.__wrapped__) - def test_find_user_pyproject_toml_linux(self) -> None: - if system() == "Windows": - return - - # Test if XDG_CONFIG_HOME is checked - with TemporaryDirectory() as workspace: - tmp_user_config = Path(workspace) / "black" - with patch.dict("os.environ", {"XDG_CONFIG_HOME": workspace}): - self.assertEqual( - black.find_user_pyproject_toml(), tmp_user_config.resolve() - ) - - # Test fallback for XDG_CONFIG_HOME - with patch.dict("os.environ"): - os.environ.pop("XDG_CONFIG_HOME", None) - fallback_user_config = Path("~/.config").expanduser() / "black" - self.assertEqual( - black.find_user_pyproject_toml(), fallback_user_config.resolve() - ) - - def test_find_user_pyproject_toml_windows(self) -> None: - if system() != "Windows": - return - - user_config_path = Path.home() / ".black" - self.assertEqual(black.find_user_pyproject_toml(), user_config_path.resolve()) - - def test_bpo_33660_workaround(self) -> None: - if system() == "Windows": - return - - # https://bugs.python.org/issue33660 - - old_cwd = Path.cwd() - try: - root = Path("/") - os.chdir(str(root)) - path = Path("workspace") / "project" - report = black.Report(verbose=True) - normalized_path = black.normalize_path_maybe_ignore(path, root, report) - self.assertEqual(normalized_path, "workspace/project") - finally: - os.chdir(str(old_cwd)) - - def test_newline_comment_interaction(self) -> None: - source = "class A:\\\r\n# type: ignore\n pass\n" - output = black.format_str(source, mode=DEFAULT_MODE) - black.assert_stable(source, output, mode=DEFAULT_MODE) + assert child.is_symlink.call_count == 2 - def test_bpo_2142_workaround(self) -> None: + @patch("black.find_project_root", lambda *args: THIS_DIR.resolve()) + def test_get_sources_with_stdin(self) -> None: + src = ["-"] + expected = ["-"] + assert_collected_sources(src, expected, include="", exclude=r"/exclude/|a\.py") - # https://bugs.python.org/issue2142 + @patch("black.find_project_root", lambda *args: THIS_DIR.resolve()) + def test_get_sources_with_stdin_filename(self) -> None: + src = ["-"] + stdin_filename = str(THIS_DIR / "data/collections.py") + expected = [f"__BLACK_STDIN_FILENAME__{stdin_filename}"] + assert_collected_sources( + src, + expected, + exclude=r"/exclude/a\.py", + stdin_filename=stdin_filename, + ) - source, _ = read_data("missing_final_newline.py") - # read_data adds a trailing newline - source = source.rstrip() - expected, _ = read_data("missing_final_newline.diff") - tmp_file = Path(black.dump_to_file(source, ensure_final_newline=False)) - diff_header = re.compile( - rf"{re.escape(str(tmp_file))}\t\d\d\d\d-\d\d-\d\d " - r"\d\d:\d\d:\d\d\.\d\d\d\d\d\d \+\d\d\d\d" + @patch("black.find_project_root", lambda *args: THIS_DIR.resolve()) + def test_get_sources_with_stdin_filename_and_exclude(self) -> None: + # Exclude shouldn't exclude stdin_filename since it is mimicking the + # file being passed directly. This is the same as + # test_exclude_for_issue_1572 + path = DATA_DIR / "include_exclude_tests" + src = ["-"] + stdin_filename = str(path / "b/exclude/a.py") + expected = [f"__BLACK_STDIN_FILENAME__{stdin_filename}"] + assert_collected_sources( + src, + expected, + exclude=r"/exclude/|a\.py", + stdin_filename=stdin_filename, ) - try: - result = BlackRunner().invoke(black.main, ["--diff", str(tmp_file)]) - self.assertEqual(result.exit_code, 0) - finally: - os.unlink(tmp_file) - actual = result.output - actual = diff_header.sub(DETERMINISTIC_HEADER, actual) - self.assertEqual(actual, expected) - def test_docstring_reformat_for_py27(self) -> None: - """ - Check that stripping trailing whitespace from Python 2 docstrings - doesn't trigger a "not equivalent to source" error - """ - source = ( - b'def foo():\r\n """Testing\r\n Testing """\r\n print "Foo"\r\n' + @patch("black.find_project_root", lambda *args: THIS_DIR.resolve()) + def test_get_sources_with_stdin_filename_and_extend_exclude(self) -> None: + # Extend exclude shouldn't exclude stdin_filename since it is mimicking the + # file being passed directly. This is the same as + # test_exclude_for_issue_1572 + src = ["-"] + path = THIS_DIR / "data" / "include_exclude_tests" + stdin_filename = str(path / "b/exclude/a.py") + expected = [f"__BLACK_STDIN_FILENAME__{stdin_filename}"] + assert_collected_sources( + src, + expected, + extend_exclude=r"/exclude/|a\.py", + stdin_filename=stdin_filename, ) - expected = 'def foo():\n """Testing\n Testing"""\n print "Foo"\n' - result = CliRunner().invoke( - black.main, - ["-", "-q", "--target-version=py27"], - input=BytesIO(source), + @patch("black.find_project_root", lambda *args: THIS_DIR.resolve()) + def test_get_sources_with_stdin_filename_and_force_exclude(self) -> None: + # Force exclude should exclude the file when passing it through + # stdin_filename + path = THIS_DIR / "data" / "include_exclude_tests" + stdin_filename = str(path / "b/exclude/a.py") + assert_collected_sources( + src=["-"], + expected=[], + force_exclude=r"/exclude/|a\.py", + stdin_filename=stdin_filename, ) - self.assertEqual(result.exit_code, 0) - actual = result.output - self.assertFormatEqual(actual, expected) + +@pytest.mark.python2 +@pytest.mark.parametrize("explicit", [True, False], ids=["explicit", "autodetection"]) +def test_python_2_deprecation_with_target_version(explicit: bool) -> None: + args = [ + "--config", + str(THIS_DIR / "empty.toml"), + str(DATA_DIR / "python2.py"), + "--check", + ] + if explicit: + args.append("--target-version=py27") + with cache_dir(): + result = BlackRunner().invoke(black.main, args) + assert "DEPRECATION: Python 2 support will be removed" in result.stderr + + +@pytest.mark.python2 +def test_python_2_deprecation_autodetection_extended() -> None: + # this test has a similar construction to test_get_features_used_decorator + python2, non_python2 = read_data("python2_detection") + for python2_case in python2.split("###"): + node = black.lib2to3_parse(python2_case) + assert black.detect_target_versions(node) == {TargetVersion.PY27}, python2_case + for non_python2_case in non_python2.split("###"): + node = black.lib2to3_parse(non_python2_case) + assert black.detect_target_versions(node) != { + TargetVersion.PY27 + }, non_python2_case with open(black.__file__, "r", encoding="utf-8") as _bf: black_source_lines = _bf.readlines() -def tracefunc(frame: types.FrameType, event: str, arg: Any) -> Callable: +def tracefunc( + frame: types.FrameType, event: str, arg: Any +) -> Callable[[types.FrameType, str, Any], Any]: """Show function calls `from black/__init__.py` as they happen. Register this with `sys.settrace()` in a test you're debugging. @@ -1982,7 +2073,3 @@ def tracefunc(frame: types.FrameType, event: str, arg: Any) -> Callable: if "black/__init__.py" in filename: print(f"{' ' * stack}{lineno}:{funcname}") return tracefunc - - -if __name__ == "__main__": - unittest.main(module="test_black")