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
|
import logging
import os
import sys
from functools import lru_cache
from pathlib import Path
from typing import Dict, Optional
import black
from pylsp import hookimpl
from pylsp._utils import get_eol_chars
from pylsp.config.config import Config
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
logger = logging.getLogger(__name__)
GLOBAL_CONFIG: Optional[Path] = None
try:
if os.name == "nt":
GLOBAL_CONFIG = Path.home() / ".black"
elif "XDG_CONFIG_HOME" in os.environ:
GLOBAL_CONFIG = Path(os.environ["XDG_CONFIG_HOME"]) / "black"
else:
GLOBAL_CONFIG = Path.home() / ".config" / "black"
except Exception as e:
logger.error("Error determining black global config file path: %s", e)
else:
if GLOBAL_CONFIG is not None and GLOBAL_CONFIG.exists():
logger.info("Found black global config file at %s", GLOBAL_CONFIG)
@hookimpl(tryfirst=True)
def pylsp_format_document(config, document):
return format_document(config, document)
@hookimpl(tryfirst=True)
def pylsp_format_range(config, document, range):
range["start"]["character"] = 0
range["end"]["line"] += 1
range["end"]["character"] = 0
return format_document(config, document, range)
@hookimpl
def pylsp_settings():
"""Configuration options that can be set on the client."""
return {
"plugins": {
"black": {
"enabled": True,
"line_length": 88,
"preview": False,
"cache_config": False,
},
"yapf": {"enabled": False},
"autopep8": {"enabled": False},
}
}
def format_document(client_config, document, range=None):
text = document.source
config = load_config(document.path, client_config)
# Black lines indices are "1-based and inclusive on both ends"
lines = [(range["start"]["line"] + 1, range["end"]["line"])] if range else ()
try:
formatted_text = format_text(text=text, config=config, lines=lines)
except black.NothingChanged:
# raised when the file is already formatted correctly
return []
if range:
formatted_lines = formatted_text.splitlines(True)
start = range["start"]["line"]
end = range["end"]["line"] + (len(formatted_lines) - len(document.lines))
formatted_text = "".join(formatted_lines[start:end])
else:
range = {
"start": {"line": 0, "character": 0},
"end": {"line": len(document.lines), "character": 0},
}
return [{"range": range, "newText": formatted_text}]
def format_text(*, text, config, lines):
mode = black.FileMode(
target_versions=config["target_version"],
line_length=config["line_length"],
is_pyi=config["pyi"],
string_normalization=not config["skip_string_normalization"],
magic_trailing_comma=not config["skip_magic_trailing_comma"],
preview=config["preview"],
)
try:
# Black's format_file_contents only works reliably when eols are '\n'. It gives
# an error for '\r' and produces wrong formatting for '\r\n'. So we replace
# those eols by '\n' before formatting and restore them afterwards.
replace_eols = False
eol_chars = get_eol_chars(text)
if eol_chars is not None and eol_chars != "\n":
replace_eols = True
text = text.replace(eol_chars, "\n")
# Will raise black.NothingChanged, we want to bubble that exception up
formatted_text = black.format_file_contents(
text, fast=config["fast"], mode=mode, lines=lines
)
# Restore eols if necessary.
if replace_eols:
formatted_text = formatted_text.replace("\n", eol_chars)
return formatted_text
except (
# raised when the file has syntax errors
ValueError,
# raised when the file being formatted has an indentation error
IndentationError,
# raised when black produces invalid Python code or formats the file
# differently on the second pass
black.parsing.ASTSafetyError,
) as e:
# errors will show on lsp stderr stream
logger.error("Error formatting with black: %s", e)
raise black.NothingChanged from e
@lru_cache(100)
def _load_config(filename: str, client_config: Config) -> Dict:
settings = client_config.plugin_settings("black")
defaults = {
"line_length": settings.get("line_length", 88),
"fast": False,
"pyi": filename.endswith(".pyi"),
"skip_string_normalization": settings.get("skip_string_normalization", False),
"skip_magic_trailing_comma": settings.get("skip_magic_trailing_comma", False),
"target_version": set(),
"preview": settings.get("preview", False),
}
root = black.find_project_root((filename,))
# Black 22.1.0+ returns a tuple
if isinstance(root, tuple):
pyproject_filename = root[0] / "pyproject.toml"
else:
pyproject_filename = root / "pyproject.toml"
if not pyproject_filename.is_file():
if GLOBAL_CONFIG is not None and GLOBAL_CONFIG.exists():
pyproject_filename = GLOBAL_CONFIG
logger.info("Using global black config at %s", pyproject_filename)
else:
logger.info("Using defaults: %r", defaults)
return defaults
try:
with open(pyproject_filename, "rb") as f:
pyproject_toml = tomllib.load(f)
except (tomllib.TOMLDecodeError, OSError):
logger.warning(
"Error decoding pyproject.toml, using defaults: %r",
defaults,
)
return defaults
file_config = pyproject_toml.get("tool", {}).get("black", {})
file_config = {
key.replace("--", "").replace("-", "_"): value
for key, value in file_config.items()
}
config = {
key: file_config.get(key, default_value)
for key, default_value in defaults.items()
}
if file_config.get("target_version"):
target_version = set(
black.TargetVersion[x.upper()] for x in file_config["target_version"]
)
else:
target_version = set()
config["target_version"] = target_version
logger.info("Using config from %s: %r", pyproject_filename, config)
return config
def load_config(filename: str, client_config: Config) -> Dict:
settings = client_config.plugin_settings("black")
# Use the original, not cached function to load settings if requested
if not settings.get("cache_config", False):
return _load_config.__wrapped__(filename, client_config)
return _load_config(filename, client_config)
|