Support pyproject.toml

Fixes #65
This commit is contained in:
Łukasz Langa 2018-06-06 15:30:02 -07:00
parent f71db23824
commit 489d00ed8f
7 changed files with 208 additions and 34 deletions

125
README.md
View File

@ -30,6 +30,7 @@ possible.
*Contents:* **[Installation and usage](#installation-and-usage)** | *Contents:* **[Installation and usage](#installation-and-usage)** |
**[The *Black* code style](#the-black-code-style)** | **[The *Black* code style](#the-black-code-style)** |
**[pyproject.toml](#pyproject.toml)** |
**[Editor integration](#editor-integration)** | **[Editor integration](#editor-integration)** |
**[Version control integration](#version-control-integration)** | **[Version control integration](#version-control-integration)** |
**[Ignoring unmodified files](#ignoring-unmodified-files)** | **[Ignoring unmodified files](#ignoring-unmodified-files)** |
@ -103,6 +104,7 @@ Options:
that were not changed or were ignored due to that were not changed or were ignored due to
--exclude=. --exclude=.
--version Show the version and exit. --version Show the version and exit.
--config PATH Read configuration from PATH.
--help Show this message and exit. --help Show this message and exit.
``` ```
@ -487,6 +489,98 @@ a future version of the formatter:
* use `float` instead of `Union[int, float]`. * 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.
<details>
<summary>Example `pyproject.toml`</summary>
```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
)/
'''
```
</details>
### 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 ## Editor integration
### Emacs ### Emacs
@ -632,16 +726,18 @@ repos:
rev: stable rev: stable
hooks: hooks:
- id: black - id: black
args: [--line-length=88, --safe]
language_version: python3.6 language_version: python3.6
``` ```
Then run `pre-commit install` and you're ready to go. 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 Avoid using `args` in the hook. Instead, store necessary configuration
the line length if you really need to. If you're already using Python in `pyproject.toml` so that editors and command-line usage of Black all
3.7, switch the `language_version` accordingly. Finally, `stable` is a tag behave consistently for your project. See *Black*'s own `pyproject.toml`
that is pinned to the latest release on PyPI. If you'd rather run on for an example.
master, this is also an option.
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 ## Ignoring unmodified files
@ -714,6 +810,8 @@ More details can be found in [CONTRIBUTING](CONTRIBUTING.md).
### 18.6b2 ### 18.6b2
* added `--config` (#65)
* fixed improper unmodified file caching when `-S` was used * fixed improper unmodified file caching when `-S` was used
@ -1039,18 +1137,3 @@ Multiple contributions by:
* [Stavros Korokithakis](mailto:hi@stavros.io) * [Stavros Korokithakis](mailto:hi@stavros.io)
* [Sunil Kapil](mailto:snlkapil@gmail.com) * [Sunil Kapil](mailto:snlkapil@gmail.com)
* [Vishwas B Sharma](mailto:sharma.vishwas88@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)**

View File

@ -3,7 +3,7 @@
from concurrent.futures import Executor, ProcessPoolExecutor from concurrent.futures import Executor, ProcessPoolExecutor
from datetime import datetime from datetime import datetime
from enum import Enum, Flag from enum import Enum, Flag
from functools import partial, wraps from functools import lru_cache, partial, wraps
import io import io
import keyword import keyword
import logging import logging
@ -38,6 +38,7 @@
from appdirs import user_cache_dir from appdirs import user_cache_dir
from attr import dataclass, Factory from attr import dataclass, Factory
import click import click
import toml
# lib2to3 fork # lib2to3 fork
from blib2to3.pytree import Node, Leaf, type_repr from blib2to3.pytree import Node, Leaf, type_repr
@ -156,6 +157,40 @@ def from_configuration(
return mode 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.command()
@click.option( @click.option(
"-l", "-l",
@ -257,6 +292,16 @@ def from_configuration(
type=click.Path( type=click.Path(
exists=True, file_okay=True, dir_okay=True, readable=True, allow_dash=True 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 @click.pass_context
def main( def main(
@ -272,26 +317,29 @@ def main(
verbose: bool, verbose: bool,
include: str, include: str,
exclude: str, exclude: str,
src: List[str], src: Tuple[str],
config: Optional[str],
) -> None: ) -> None:
"""The uncompromising code formatter.""" """The uncompromising code formatter."""
write_back = WriteBack.from_configuration(check=check, diff=diff) write_back = WriteBack.from_configuration(check=check, diff=diff)
mode = FileMode.from_configuration( mode = FileMode.from_configuration(
py36=py36, pyi=pyi, skip_string_normalization=skip_string_normalization py36=py36, pyi=pyi, skip_string_normalization=skip_string_normalization
) )
report = Report(check=check, quiet=quiet, verbose=verbose) if config and verbose:
sources: Set[Path] = set() out(f"Using configuration from {config}.", bold=False, fg="blue")
try: try:
include_regex = re.compile(include) include_regex = re_compile_maybe_verbose(include)
except re.error: except re.error:
err(f"Invalid regular expression for include given: {include!r}") err(f"Invalid regular expression for include given: {include!r}")
ctx.exit(2) ctx.exit(2)
try: try:
exclude_regex = re.compile(exclude) exclude_regex = re_compile_maybe_verbose(exclude)
except re.error: except re.error:
err(f"Invalid regular expression for exclude given: {exclude!r}") err(f"Invalid regular expression for exclude given: {exclude!r}")
ctx.exit(2) ctx.exit(2)
report = Report(check=check, quiet=quiet, verbose=verbose)
root = find_project_root(src) root = find_project_root(src)
sources: Set[Path] = set()
for s in src: for s in src:
p = Path(s) p = Path(s)
if p.is_dir(): if p.is_dir():
@ -307,9 +355,8 @@ def main(
if verbose or not quiet: if verbose or not quiet:
out("No paths given. Nothing to do 😴") out("No paths given. Nothing to do 😴")
ctx.exit(0) ctx.exit(0)
return
elif len(sources) == 1: if len(sources) == 1:
reformat_one( reformat_one(
src=sources.pop(), src=sources.pop(),
line_length=line_length, line_length=line_length,
@ -2894,7 +2941,7 @@ def gen_python_files_in_dir(
normalized_path += "/" normalized_path += "/"
exclude_match = exclude.search(normalized_path) exclude_match = exclude.search(normalized_path)
if exclude_match and exclude_match.group(0): 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 continue
if child.is_dir(): if child.is_dir():
@ -2906,7 +2953,8 @@ def gen_python_files_in_dir(
yield child 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. """Return a directory containing .git, .hg, or pyproject.toml.
That directory can be one of the directories passed in `srcs` or their 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)) 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]]: def enumerate_reversed(sequence: Sequence[T]) -> Iterator[Tuple[Index, T]]:
"""Like `reversed(enumerate(sequence))` if that were possible.""" """Like `reversed(enumerate(sequence))` if that were possible."""
index = len(sequence) - 1 index = len(sequence) - 1

View File

@ -48,10 +48,12 @@ Contents
installation_and_usage installation_and_usage
the_black_code_style the_black_code_style
pyproject_toml
editor_integration editor_integration
version_control_integration version_control_integration
ignoring_unmodified_files ignoring_unmodified_files
contributing contributing
show_your_style
change_log change_log
reference/reference_summary reference/reference_summary
authors authors

1
docs/pyproject_toml.md Symbolic link
View File

@ -0,0 +1 @@
_build/generated/pyproject_toml.md

28
pyproject.toml Normal file
View File

@ -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
)/
'''

View File

@ -0,0 +1,2 @@
# Empty pyproject.toml to use with some tests that depend on Python 3.6 autodiscovery
# and so on.

View File

@ -176,10 +176,10 @@ def test_piping_diff(self) -> None:
) )
source, _ = read_data("expression.py") source, _ = read_data("expression.py")
expected, _ = read_data("expression.diff") expected, _ = read_data("expression.diff")
config = THIS_DIR / "data" / "empty_pyproject.toml"
stderrbuf = BytesIO() stderrbuf = BytesIO()
result = BlackRunner(stderrbuf).invoke( args = ["-", "--fast", f"--line-length={ll}", "--diff", f"--config={config}"]
black.main, ["-", "--fast", f"--line-length={ll}", "--diff"], input=source result = BlackRunner(stderrbuf).invoke(black.main, args, input=source)
)
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
actual = diff_header.sub("[Deterministic header]", result.output) actual = diff_header.sub("[Deterministic header]", result.output)
actual = actual.rstrip() + "\n" # the diff output has a trailing space actual = actual.rstrip() + "\n" # the diff output has a trailing space