From: Łukasz Langa Date: Wed, 6 Jun 2018 22:30:02 +0000 (-0700) Subject: Support pyproject.toml X-Git-Url: https://git.madduck.net/etc/vim.git/commitdiff_plain/489d00ed8f4fb90d5788609a474632ab5a16591f?hp=f71db23824a25300618dd0625085ade8d2b3a7a8 Support pyproject.toml Fixes #65 --- diff --git a/README.md b/README.md index 00179a9..831401b 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ possible. *Contents:* **[Installation and usage](#installation-and-usage)** | **[The *Black* code style](#the-black-code-style)** | +**[pyproject.toml](#pyproject.toml)** | **[Editor integration](#editor-integration)** | **[Version control integration](#version-control-integration)** | **[Ignoring unmodified files](#ignoring-unmodified-files)** | @@ -103,6 +104,7 @@ Options: that were not changed or were ignored due to --exclude=. --version Show the version and exit. + --config PATH Read configuration from PATH. --help Show this message and exit. ``` @@ -487,6 +489,98 @@ a future version of the formatter: * use `float` instead of `Union[int, float]`. +## pyproject.toml + +*Black* is able to read project-specific default values for its +command line options from a `pyproject.toml` file. This is +especially useful for specifying custom `--include` and `--exclude` +patterns for your project. + +**Pro-tip**: If you're asking yourself "Do I need to configure anything?" +the answer is "No". *Black* is all about sensible defaults. + + +### What on Earth is a `pyproject.toml` file? + +[PEP 518](https://www.python.org/dev/peps/pep-0518/) defines +`pyproject.toml` as a configuration file to store build system +requirements for Python projects. With the help of tools +like [Poetry](https://poetry.eustace.io/) or +[Flit](https://flit.readthedocs.io/en/latest/) it can fully replace the +need for `setup.py` and `setup.cfg` files. + + +### Where *Black* looks for the file + +By default *Black* looks for `pyproject.toml` starting from the common +base directory of all files and directories passed on the command line. +If it's not there, it looks in parent directories. It stops looking +when it finds the file, or a `.git` directory, or a `.hg` directory, +or the root of the file system, whichever comes first. + +If you're formatting standard input, *Black* will look for configuration +starting from the current working directory. + +You can also explicitly specify the path to a particular file that you +want with `--config`. In this situation *Black* will not look for any +other file. + +If you're running with `--verbose`, you will see a blue message if +a file was found and used. + + +### Configuration format + +As the file extension suggests, `pyproject.toml` is a [TOML](https://github.com/toml-lang/toml) file. It contains separate +sections for different tools. *Black* is using the `[tool.black]` +section. The option keys are the same as long names of options on +the command line. + +Note that you have to use single-quoted strings in TOML for regular +expressions. It's the equivalent of r-strings in Python. Multiline +strings are treated as verbose regular expressions by Black. Use `[ ]` +to denote a significant space character. + +
+Example `pyproject.toml` + +```toml +[tool.black] +line-length = 88 +py36 = true +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist + + # The following are specific to Black, you probably don't want those. + | blib2to3 + | tests/data +)/ +''' +``` + +
+ +### Lookup hierarchy + +Command-line options have defaults that you can see in `--help`. +A `pyproject.toml` can override those defaults. Finally, options +provided by the user on the command line override both. + +*Black* will only ever use one `pyproject.toml` file during an entire +run. It doesn't look for multiple files, and doesn't compose +configuration from different levels of the file hierarchy. + + ## Editor integration ### Emacs @@ -632,16 +726,18 @@ repos: rev: stable hooks: - id: black - args: [--line-length=88, --safe] language_version: python3.6 ``` Then run `pre-commit install` and you're ready to go. -`args` in the above config is optional but shows you how you can change -the line length if you really need to. If you're already using Python -3.7, switch the `language_version` accordingly. Finally, `stable` is a tag -that is pinned to the latest release on PyPI. If you'd rather run on -master, this is also an option. +Avoid using `args` in the hook. Instead, store necessary configuration +in `pyproject.toml` so that editors and command-line usage of Black all +behave consistently for your project. See *Black*'s own `pyproject.toml` +for an example. + +If you're already using Python 3.7, switch the `language_version` +accordingly. Finally, `stable` is a tag that is pinned to the latest +release on PyPI. If you'd rather run on master, this is also an option. ## Ignoring unmodified files @@ -714,6 +810,8 @@ More details can be found in [CONTRIBUTING](CONTRIBUTING.md). ### 18.6b2 +* added `--config` (#65) + * fixed improper unmodified file caching when `-S` was used @@ -1039,18 +1137,3 @@ Multiple contributions by: * [Stavros Korokithakis](mailto:hi@stavros.io) * [Sunil Kapil](mailto:snlkapil@gmail.com) * [Vishwas B Sharma](mailto:sharma.vishwas88@gmail.com) - ---- - -*Contents:* -**[Installation and Usage](#installation-and-usage)** | -**[The *Black* code style](#the-black-code-style)** | -**[Editor integration](#editor-integration)** | -**[Version control integration](#version-control-integration)** | -**[Ignoring unmodified files](#ignoring-unmodified-files)** | -**[Testimonials](#testimonials)** | -**[Show your style](#show-your-style)** | -**[License](#license)** | -**[Contributing](#contributing-to-black)** | -**[Change Log](#change-log)** | -**[Authors](#authors)** diff --git a/black.py b/black.py index 80401f5..dd7fe39 100644 --- a/black.py +++ b/black.py @@ -3,7 +3,7 @@ from asyncio.base_events import BaseEventLoop from concurrent.futures import Executor, ProcessPoolExecutor from datetime import datetime from enum import Enum, Flag -from functools import partial, wraps +from functools import lru_cache, partial, wraps import io import keyword import logging @@ -38,6 +38,7 @@ from typing import ( from appdirs import user_cache_dir from attr import dataclass, Factory import click +import toml # lib2to3 fork from blib2to3.pytree import Node, Leaf, type_repr @@ -156,6 +157,40 @@ class FileMode(Flag): return mode +def read_pyproject_toml( + ctx: click.Context, param: click.Parameter, value: Union[str, int, bool, None] +) -> Optional[str]: + """Inject Black configuration from "pyproject.toml" into defaults in `ctx`. + + Returns the path to a successfully found and read configuration file, None + otherwise. + """ + assert not isinstance(value, (int, bool)), "Invalid parameter type passed" + if not value: + root = find_project_root(ctx.params.get("src", ())) + path = root / "pyproject.toml" + if path.is_file(): + value = str(path) + else: + return None + + try: + pyproject_toml = toml.load(value) + config = pyproject_toml.get("tool", {}).get("black", {}) + except (toml.TomlDecodeError, OSError) as e: + raise click.BadOptionUsage(f"Error reading configuration file: {e}", ctx) + + if not config: + return None + + if ctx.default_map is None: + ctx.default_map = {} + ctx.default_map.update( # type: ignore # bad types in .pyi + {k.replace("--", "").replace("-", "_"): v for k, v in config.items()} + ) + return value + + @click.command() @click.option( "-l", @@ -257,6 +292,16 @@ class FileMode(Flag): type=click.Path( exists=True, file_okay=True, dir_okay=True, readable=True, allow_dash=True ), + is_eager=True, +) +@click.option( + "--config", + type=click.Path( + exists=False, file_okay=True, dir_okay=False, readable=True, allow_dash=False + ), + is_eager=True, + callback=read_pyproject_toml, + help="Read configuration from PATH.", ) @click.pass_context def main( @@ -272,26 +317,29 @@ def main( verbose: bool, include: str, exclude: str, - src: List[str], + src: Tuple[str], + config: Optional[str], ) -> None: """The uncompromising code formatter.""" write_back = WriteBack.from_configuration(check=check, diff=diff) mode = FileMode.from_configuration( py36=py36, pyi=pyi, skip_string_normalization=skip_string_normalization ) - report = Report(check=check, quiet=quiet, verbose=verbose) - sources: Set[Path] = set() + if config and verbose: + out(f"Using configuration from {config}.", bold=False, fg="blue") try: - include_regex = re.compile(include) + include_regex = re_compile_maybe_verbose(include) except re.error: err(f"Invalid regular expression for include given: {include!r}") ctx.exit(2) try: - exclude_regex = re.compile(exclude) + exclude_regex = re_compile_maybe_verbose(exclude) except re.error: err(f"Invalid regular expression for exclude given: {exclude!r}") ctx.exit(2) + report = Report(check=check, quiet=quiet, verbose=verbose) root = find_project_root(src) + sources: Set[Path] = set() for s in src: p = Path(s) if p.is_dir(): @@ -307,9 +355,8 @@ def main( if verbose or not quiet: out("No paths given. Nothing to do 😴") ctx.exit(0) - return - elif len(sources) == 1: + if len(sources) == 1: reformat_one( src=sources.pop(), line_length=line_length, @@ -2894,7 +2941,7 @@ def gen_python_files_in_dir( normalized_path += "/" exclude_match = exclude.search(normalized_path) if exclude_match and exclude_match.group(0): - report.path_ignored(child, f"matches --exclude={exclude.pattern}") + report.path_ignored(child, f"matches the --exclude regular expression") continue if child.is_dir(): @@ -2906,7 +2953,8 @@ def gen_python_files_in_dir( yield child -def find_project_root(srcs: List[str]) -> Path: +@lru_cache() +def find_project_root(srcs: Iterable[str]) -> Path: """Return a directory containing .git, .hg, or pyproject.toml. That directory can be one of the directories passed in `srcs` or their @@ -3164,6 +3212,16 @@ def sub_twice(regex: Pattern[str], replacement: str, original: str) -> str: return regex.sub(replacement, regex.sub(replacement, original)) +def re_compile_maybe_verbose(regex: str) -> Pattern[str]: + """Compile a regular expression string in `regex`. + + If it contains newlines, use verbose mode. + """ + if "\n" in regex: + regex = "(?x)" + regex + return re.compile(regex) + + def enumerate_reversed(sequence: Sequence[T]) -> Iterator[Tuple[Index, T]]: """Like `reversed(enumerate(sequence))` if that were possible.""" index = len(sequence) - 1 diff --git a/docs/index.rst b/docs/index.rst index 30c24a3..da60f7b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -48,10 +48,12 @@ Contents installation_and_usage the_black_code_style + pyproject_toml editor_integration version_control_integration ignoring_unmodified_files contributing + show_your_style change_log reference/reference_summary authors diff --git a/docs/pyproject_toml.md b/docs/pyproject_toml.md new file mode 120000 index 0000000..57c8664 --- /dev/null +++ b/docs/pyproject_toml.md @@ -0,0 +1 @@ +_build/generated/pyproject_toml.md \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cbb530c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +# Example configuration for Black. + +# NOTE: you have to use single-quoted strings in TOML for regular expressions. +# It's the equivalent of r-strings in Python. Multiline strings are treated as +# verbose regular expressions by Black. Use [ ] to denote a significant space +# character. + +[tool.black] +line-length = 88 +py36 = true +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist + + # The following are specific to Black, you probably don't want those. + | blib2to3 + | tests/data +)/ +''' diff --git a/tests/data/empty_pyproject.toml b/tests/data/empty_pyproject.toml new file mode 100644 index 0000000..8c5274d --- /dev/null +++ b/tests/data/empty_pyproject.toml @@ -0,0 +1,2 @@ +# Empty pyproject.toml to use with some tests that depend on Python 3.6 autodiscovery +# and so on. diff --git a/tests/test_black.py b/tests/test_black.py index e555ba9..b8af711 100644 --- a/tests/test_black.py +++ b/tests/test_black.py @@ -176,10 +176,10 @@ class BlackTestCase(unittest.TestCase): ) source, _ = read_data("expression.py") expected, _ = read_data("expression.diff") + config = THIS_DIR / "data" / "empty_pyproject.toml" stderrbuf = BytesIO() - result = BlackRunner(stderrbuf).invoke( - black.main, ["-", "--fast", f"--line-length={ll}", "--diff"], input=source - ) + args = ["-", "--fast", f"--line-length={ll}", "--diff", f"--config={config}"] + result = BlackRunner(stderrbuf).invoke(black.main, args, input=source) self.assertEqual(result.exit_code, 0) actual = diff_header.sub("[Deterministic header]", result.output) actual = actual.rstrip() + "\n" # the diff output has a trailing space