diff --git a/.gitignore b/.gitignore index 249499b135e..0acfdf80c7f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,8 @@ .venv -.coverage -.coverage.* _build .DS_Store .vscode docs/_static/pypi.svg -.tox __pycache__ # Packaging artifacts @@ -21,6 +18,13 @@ src/_black_version.py .dmypy.json *.swp -.hypothesis/ venv/ .ipynb_checkpoints/ + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.hypothesis/ +.pytest_cache/ diff --git a/CHANGES.md b/CHANGES.md index 6966a91aa11..81a6b27b1b0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -46,6 +46,7 @@ - Don't add whitespace for attribute access on hexadecimal, binary, octal, and complex literals (#2799) - Deprecate the `black-primer` tool (#2809) +- Allow specifying `config` in config files (#2525) ### Packaging diff --git a/docs/usage_and_configuration/the_basics.md b/docs/usage_and_configuration/the_basics.md index b82cef4a52d..2ee390f740b 100644 --- a/docs/usage_and_configuration/the_basics.md +++ b/docs/usage_and_configuration/the_basics.md @@ -200,6 +200,40 @@ project. "No". _Black_ is all about sensible defaults. Applying those defaults will have your code in compliance with many other _Black_ formatted projects. +### Linked Configuration feature + +You can use the `config = ` field to include configuration options from another +configuration file. Values set in the original configuration file have precedence over +those in the included file. + +Suppose `pyproject.toml` contains: + +```{code-block} toml +--- +lineno-start: 1 +emphasize-lines: 2,3 +--- +[tools.black] +config = "linked_config.toml" +color = true +``` + +And `linked_config.toml` contains: + +```{code-block} toml +--- +lineno-start: 1 +emphasize-lines: 2 +--- +[tools.black] +color = false +line-length = 88 +target-version = ['py36', 'py37', 'py38'] +``` + +The resulting value for `color` would be `true` as specified in the first config +(`pyproject.toml`). + ### What on Earth is a `pyproject.toml` file? [PEP 518](https://www.python.org/dev/peps/pep-0518/) defines `pyproject.toml` as a diff --git a/src/black/__init__.py b/src/black/__init__.py index 769e693ed23..6f9781d173c 100644 --- a/src/black/__init__.py +++ b/src/black/__init__.py @@ -127,28 +127,44 @@ def read_pyproject_toml( if not config: return None + + inner_config = config.get("config") or ( + ctx.default_map.get("config") if ctx.default_map else None + ) + if inner_config: + inner_config_path = (Path(value).parent / inner_config).resolve() + + try: + read_pyproject_toml(ctx, param, str(inner_config_path)) + except RecursionError as e: + raise click.FileError( + filename=value, hint=f"Error reading configuration file: {e}" + ) from None + + del config["config"] + + if ctx.default_map: + ctx.default_map.update(config) + config = ctx.default_map.copy() else: - # Sanitize the values to be Click friendly. For more information please see: - # https://github.com/psf/black/issues/1458 - # https://github.com/pallets/click/issues/1567 - config = { - k: str(v) if not isinstance(v, (list, dict)) else v - for k, v in config.items() - } + if ctx.default_map: + config.update(ctx.default_map) + + # Sanitize the values to be Click friendly. For more information please see: + # https://github.com/psf/black/issues/1458 + # https://github.com/pallets/click/issues/1567 + default_map: Dict[str, Any] = { + k: str(v) if not isinstance(v, (list, dict)) else v for k, v in config.items() + } - target_version = config.get("target_version") + target_version = default_map.get("target_version") if target_version is not None and not isinstance(target_version, list): raise click.BadOptionUsage( "target-version", "Config key target-version must be a list" ) - default_map: Dict[str, Any] = {} - if ctx.default_map: - default_map.update(ctx.default_map) - default_map.update(config) - ctx.default_map = default_map - return value + return str(value) def target_version_option_callback( diff --git a/tests/data/toml_configs/_black_config.toml b/tests/data/toml_configs/_black_config.toml new file mode 100644 index 00000000000..175fb417700 --- /dev/null +++ b/tests/data/toml_configs/_black_config.toml @@ -0,0 +1,9 @@ +[tool.black] +verbose = 1 +--check = "no" +diff = "y" +color = true +line-length = 88 +target-version = ["py36", "py37", "py38"] +exclude='\.pyi?$' +include='\.py?$' diff --git a/tests/empty.toml b/tests/data/toml_configs/empty.toml similarity index 100% rename from tests/empty.toml rename to tests/data/toml_configs/empty.toml diff --git a/tests/data/empty_pyproject.toml b/tests/data/toml_configs/empty_pyproject.toml similarity index 100% rename from tests/data/empty_pyproject.toml rename to tests/data/toml_configs/empty_pyproject.toml diff --git a/tests/data/toml_configs/invalid_test.toml b/tests/data/toml_configs/invalid_test.toml new file mode 100644 index 00000000000..8c92ada4f6f --- /dev/null +++ b/tests/data/toml_configs/invalid_test.toml @@ -0,0 +1,2 @@ +[tool.black] +config = "tests/bazqux.toml" diff --git a/tests/data/toml_configs/recursion_1.toml b/tests/data/toml_configs/recursion_1.toml new file mode 100644 index 00000000000..15b88d9fedf --- /dev/null +++ b/tests/data/toml_configs/recursion_1.toml @@ -0,0 +1,2 @@ +[tool.black] +config = "recursion_2.toml" diff --git a/tests/data/toml_configs/recursion_2.toml b/tests/data/toml_configs/recursion_2.toml new file mode 100644 index 00000000000..75ef72cc6ca --- /dev/null +++ b/tests/data/toml_configs/recursion_2.toml @@ -0,0 +1,2 @@ +[tool.black] +config = "recursion_1.toml" diff --git a/tests/test.toml b/tests/data/toml_configs/test.toml similarity index 100% rename from tests/test.toml rename to tests/data/toml_configs/test.toml diff --git a/tests/data/toml_configs/test_replace.toml b/tests/data/toml_configs/test_replace.toml new file mode 100644 index 00000000000..0541876a9d7 --- /dev/null +++ b/tests/data/toml_configs/test_replace.toml @@ -0,0 +1,4 @@ +[tool.black] +config = "_black_config.toml" +verbose = 0 +color = false diff --git a/tests/test_black.py b/tests/test_black.py index 2dd284f2cd6..491807f1b75 100644 --- a/tests/test_black.py +++ b/tests/test_black.py @@ -53,6 +53,7 @@ PROJECT_ROOT, PY36_VERSIONS, THIS_DIR, + TOML_CONFIG_DIR, BlackBaseTestCase, assert_format, change_directory, @@ -123,7 +124,7 @@ def invokeBlack( ) -> None: runner = BlackRunner() if ignore_config: - args = ["--verbose", "--config", str(THIS_DIR / "empty.toml"), *args] + args = ["--verbose", "--config", str(TOML_CONFIG_DIR / "empty.toml"), *args] result = runner.invoke(black.main, args, catch_exceptions=False) assert result.stdout_bytes is not None assert result.stderr_bytes is not None @@ -175,7 +176,7 @@ def test_piping_diff(self) -> None: ) source, _ = read_data("expression.py") expected, _ = read_data("expression.diff") - config = THIS_DIR / "data" / "empty_pyproject.toml" + config = TOML_CONFIG_DIR / "empty_pyproject.toml" args = [ "-", "--fast", @@ -193,7 +194,7 @@ def test_piping_diff(self) -> None: def test_piping_diff_with_color(self) -> None: source, _ = read_data("expression.py") - config = THIS_DIR / "data" / "empty_pyproject.toml" + config = TOML_CONFIG_DIR / "empty_pyproject.toml" args = [ "-", "--fast", @@ -252,7 +253,7 @@ def test_expression_ff(self) -> None: def test_expression_diff(self) -> None: source, _ = read_data("expression.py") - config = THIS_DIR / "data" / "empty_pyproject.toml" + config = TOML_CONFIG_DIR / "empty_pyproject.toml" expected, _ = read_data("expression.diff") tmp_file = Path(black.dump_to_file(source)) diff_header = re.compile( @@ -279,7 +280,7 @@ def test_expression_diff(self) -> None: def test_expression_diff_with_color(self) -> None: source, _ = read_data("expression.py") - config = THIS_DIR / "data" / "empty_pyproject.toml" + config = TOML_CONFIG_DIR / "empty_pyproject.toml" expected, _ = read_data("expression.diff") tmp_file = Path(black.dump_to_file(source)) try: @@ -1283,7 +1284,7 @@ def test_invalid_config_return_code(self) -> None: tmp_file.unlink() def test_parse_pyproject_toml(self) -> None: - test_toml_file = THIS_DIR / "test.toml" + test_toml_file = TOML_CONFIG_DIR / "test.toml" config = black.parse_pyproject_toml(str(test_toml_file)) self.assertEqual(config["verbose"], 1) self.assertEqual(config["check"], "no") @@ -1296,7 +1297,7 @@ def test_parse_pyproject_toml(self) -> None: self.assertEqual(config["include"], r"\.py?$") def test_read_pyproject_toml(self) -> None: - test_toml_file = THIS_DIR / "test.toml" + test_toml_file = TOML_CONFIG_DIR / "test.toml" fake_ctx = FakeContext() black.read_pyproject_toml(fake_ctx, FakeParameter(), str(test_toml_file)) config = fake_ctx.default_map @@ -1309,6 +1310,34 @@ def test_read_pyproject_toml(self) -> None: self.assertEqual(config["exclude"], r"\.pyi?$") self.assertEqual(config["include"], r"\.py?$") + def test_black_replace_config(self) -> None: + test_toml_file = TOML_CONFIG_DIR / "test_replace.toml" + fake_ctx = FakeContext() + black.read_pyproject_toml(fake_ctx, FakeParameter(), str(test_toml_file)) + config = fake_ctx.default_map + # `0` in `test_replace.toml` and `1` in `_black_config.toml`. + # Should be `0` as the root config is on a higher level than linked configs + self.assertEqual(config["verbose"], "0") + self.assertEqual(config["check"], "no") + self.assertEqual(config["diff"], "y") + self.assertEqual(config["color"], "False") + self.assertEqual(config["line_length"], "88") + self.assertEqual(config["target_version"], ["py36", "py37", "py38"]) + self.assertEqual(config["exclude"], r"\.pyi?$") + self.assertEqual(config["include"], r"\.py?$") + + def test_invalid_black_config(self) -> None: + test_toml_file = TOML_CONFIG_DIR / "invalid_test.toml" + fake_ctx = FakeContext() + with self.assertRaises(click.exceptions.FileError): + black.read_pyproject_toml(fake_ctx, FakeParameter(), str(test_toml_file)) + + def test_recursion_black_config(self) -> None: + test_toml_file = TOML_CONFIG_DIR / "recursion_1.toml" + fake_ctx = FakeContext() + with self.assertRaises(click.exceptions.FileError): + black.read_pyproject_toml(fake_ctx, FakeParameter(), str(test_toml_file)) + @pytest.mark.incompatible_with_mypyc def test_find_project_root(self) -> None: with TemporaryDirectory() as workspace: diff --git a/tests/util.py b/tests/util.py index 8755111f7c5..69201efa587 100644 --- a/tests/util.py +++ b/tests/util.py @@ -13,6 +13,7 @@ THIS_DIR = Path(__file__).parent DATA_DIR = THIS_DIR / "data" +TOML_CONFIG_DIR = DATA_DIR / "toml_configs" PROJECT_ROOT = THIS_DIR.parent EMPTY_LINE = "# EMPTY LINE WITH WHITESPACE" + " (this comment will be removed)" DETERMINISTIC_HEADER = "[Deterministic header]"