-
Notifications
You must be signed in to change notification settings - Fork 10
/
flake8_black.py
240 lines (205 loc) · 8.71 KB
/
flake8_black.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
"""Check Python code passes black style validation via flake8.
This is a plugin for the tool flake8 tool for checking Python
source code using the tool black.
"""
import sys
from os import path
from pathlib import Path
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
import black
from flake8 import utils as stdin_utils
from flake8 import LOG
__version__ = "0.3.7"
black_prefix = "BLK"
def find_diff_start(old_src, new_src):
"""Find line number and column number where text first differs."""
old_lines = old_src.split("\n")
new_lines = new_src.split("\n")
for line in range(min(len(old_lines), len(new_lines))):
old = old_lines[line]
new = new_lines[line]
if old == new:
continue
for col in range(min(len(old), len(new))):
if old[col] != new[col]:
return line, col
# Difference at the end of the line...
return line, min(len(old), len(new))
# Difference at the end of the file...
return min(len(old_lines), len(new_lines)), 0
class BadBlackConfig(ValueError):
"""Bad black TOML configuration file."""
pass
def load_black_mode(toml_filename=None):
"""Load a black configuration TOML file (or return defaults) as FileMode."""
if not toml_filename:
return black.FileMode(
target_versions=set(),
line_length=black.DEFAULT_LINE_LENGTH, # Expect to be 88
string_normalization=True,
magic_trailing_comma=True,
preview=False,
)
LOG.info("flake8-black: loading black settings from %s", toml_filename)
try:
with toml_filename.open(mode="rb") as toml_file:
pyproject_toml = tomllib.load(toml_file)
except ValueError:
LOG.info("flake8-black: invalid TOML file %s", toml_filename)
raise BadBlackConfig(path.relpath(toml_filename))
config = pyproject_toml.get("tool", {}).get("black", {})
black_config = {k.replace("--", "").replace("-", "_"): v for k, v in config.items()}
# Extract the fields we care about,
# cast to int explicitly otherwise line length could be a string
return black.FileMode(
target_versions={
black.TargetVersion[val.upper()]
for val in black_config.get("target_version", [])
},
line_length=int(black_config.get("line_length", black.DEFAULT_LINE_LENGTH)),
string_normalization=not black_config.get("skip_string_normalization", False),
magic_trailing_comma=not black_config.get("skip_magic_trailing_comma", False),
preview=bool(black_config.get("preview", False)),
)
black_config = {None: load_black_mode()} # None key's value is default config
class BlackStyleChecker:
"""Checker of Python code using black."""
name = "black"
version = __version__
override_config = None
STDIN_NAMES = {"stdin", "-", "(none)", None}
def __init__(self, tree, filename="(none)"):
"""Initialise."""
self.tree = tree
self.filename = filename
@property
def _file_mode(self):
"""Return black.FileMode object, using local pyproject.toml as needed."""
if self.override_config:
return self.override_config
# Unless using override, we look for pyproject.toml
project_root = black.find_project_root(
("." if self.filename in self.STDIN_NAMES else self.filename,)
)
if isinstance(project_root, tuple):
# black stable 22.1.0 update find_project_root return value
# from Path to Tuple[Path, str]
project_root = project_root[0]
path = project_root / "pyproject.toml"
if path in black_config:
# Already loaded
LOG.debug("flake8-black: %s using pre-loaded %s", self.filename, path)
return black_config[path]
elif path.is_file():
# Use this pyproject.toml for this python file,
# (unless configured with global override config)
# This should be thread safe - does not matter even if
# two workers load and cache this file at the same time
black_config[path] = load_black_mode(path)
LOG.debug("flake8-black: %s using newly loaded %s", self.filename, path)
return black_config[path]
else:
# No project specific file, use default
LOG.debug("flake8-black: %s using defaults", self.filename)
return black_config[None]
@classmethod
def add_options(cls, parser):
"""Adding black-config option."""
parser.add_option(
"--black-config",
metavar="TOML_FILENAME",
default=None,
action="store",
# type="string", <- breaks using None as a sentinel
# normalize_paths=True, <- broken and breaks None as a sentinel
# https://gitlab.com/pycqa/flake8/issues/562
# https://gitlab.com/pycqa/flake8/merge_requests/337
parse_from_config=True,
help="Path to black TOML configuration file (overrides the "
"default 'pyproject.toml' detection; use empty string '' to mean "
"ignore all 'pyproject.toml' files).",
)
@classmethod
def parse_options(cls, optmanager, options, extra_args):
"""Adding black-config option."""
# We have one and only one flake8 plugin configuration
if options.black_config is None:
LOG.info("flake8-black: No black configuration set")
cls.override_config = None
return
elif not options.black_config:
LOG.info("flake8-black: Explicitly using no black configuration file")
cls.override_config = black_config[None] # explicitly use defaults
return
# Validate the path setting - handling relative paths ourselves,
# see https://gitlab.com/pycqa/flake8/issues/562
black_config_path = Path(options.black_config)
if options.config:
# Assume black config path was via flake8 config file
base_path = Path(path.dirname(path.abspath(options.config)))
black_config_path = base_path / black_config_path
if not black_config_path.is_file():
# Want flake8 to abort, see:
# https://gitlab.com/pycqa/flake8/issues/559
raise ValueError(
"Plugin flake8-black could not find specified black config file: "
"--black-config %s" % black_config_path
)
# Now load the TOML file, and the black section within it
# This configuration is to override any local pyproject.toml
try:
cls.override_config = black_config[black_config_path] = load_black_mode(
black_config_path
)
except BadBlackConfig:
# Could raise BLK997, but view this as an abort condition
raise ValueError(
"Plugin flake8-black could not parse specified black config file: "
"--black-config %s" % black_config_path
)
def run(self):
"""Use black to check code style."""
msg = None
line = 0
col = 0
try:
if self.filename in self.STDIN_NAMES:
self.filename = "stdin"
source = stdin_utils.stdin_get_value()
else:
with open(self.filename, "rb") as buf:
source, _, _ = black.decode_bytes(buf.read())
except Exception as e:
source = ""
msg = "900 Failed to load file: %s" % e
if not source and not msg:
# Empty file (good)
return
elif source:
# Call black...
try:
file_mode = self._file_mode
file_mode.is_pyi = self.filename and self.filename.endswith(".pyi")
new_code = black.format_file_contents(
source, mode=file_mode, fast=False
)
except black.NothingChanged:
return
except black.InvalidInput:
msg = "901 Invalid input."
except BadBlackConfig as err:
msg = "997 Invalid TOML file: %s" % err
except Exception as err:
msg = "999 Unexpected exception: %r" % err
else:
assert (
new_code != source
), "Black made changes without raising NothingChanged"
line, col = find_diff_start(source, new_code)
line += 1 # Strange as col seems to be zero based?
msg = "100 Black would make changes."
# If we don't know the line or column numbers, leaving as zero.
yield line, col, black_prefix + msg, type(self)