├── pylsp_black ├── __init__.py └── plugin.py ├── .envrc ├── tests ├── fixtures │ ├── pyproject.toml │ ├── invalid.txt │ ├── config │ │ ├── config.txt │ │ └── pyproject.toml │ ├── formatted.pyi │ ├── target_version │ │ └── pyproject.toml │ ├── unformatted-crlf.py │ ├── formatted-crlf.py │ ├── unformatted.pyi │ ├── unformatted.txt │ ├── unformatted-line-length.py │ ├── formatted-line-length.py │ └── formatted.txt └── test_plugin.py ├── setup.py ├── .gitignore ├── pyproject.toml ├── Makefile ├── MAINTENANCE.md ├── .pre-commit-config.yaml ├── .github └── workflows │ └── python.yml ├── RELEASE.md ├── LICENSE ├── SECURITY.md ├── setup.cfg ├── README.md └── CHANGELOG.md /pylsp_black/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | layout python 2 | -------------------------------------------------------------------------------- /tests/fixtures/pyproject.toml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/invalid.txt: -------------------------------------------------------------------------------- 1 | x = 1+2 2 | -------------------------------------------------------------------------------- /tests/fixtures/config/config.txt: -------------------------------------------------------------------------------- 1 | run(these, arguments, should, be, wrapped) 2 | -------------------------------------------------------------------------------- /tests/fixtures/formatted.pyi: -------------------------------------------------------------------------------- 1 | def foo() -> None: ... 2 | def bar() -> int: ... 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | if __name__ == "__main__": 4 | setup() 5 | -------------------------------------------------------------------------------- /tests/fixtures/target_version/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ['py39'] 3 | -------------------------------------------------------------------------------- /tests/fixtures/unformatted-crlf.py: -------------------------------------------------------------------------------- 1 | if True: 2 | print("foo") 3 | 4 | print("bar") # noqa -------------------------------------------------------------------------------- /tests/fixtures/formatted-crlf.py: -------------------------------------------------------------------------------- 1 | if True: 2 | print("foo") 3 | 4 | print("bar") # noqa 5 | -------------------------------------------------------------------------------- /tests/fixtures/unformatted.pyi: -------------------------------------------------------------------------------- 1 | def foo() -> None: 2 | ... 3 | 4 | def bar() -> int: 5 | ... 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | .mypy_cache 3 | *.egg-info 4 | *.pyc 5 | *.orig 6 | .pytest_cache 7 | .ropeproject 8 | build 9 | dist 10 | -------------------------------------------------------------------------------- /tests/fixtures/unformatted.txt: -------------------------------------------------------------------------------- 1 | a = 'hello' 2 | b = ["a", "very", "very", "very", "very", "very", "very", "very", "very", "long", "line"] 3 | c = 42 4 | -------------------------------------------------------------------------------- /tests/fixtures/unformatted-line-length.py: -------------------------------------------------------------------------------- 1 | 2 | def foo(aaaaa, bbbbb, ccccc, ddddd, eeeee, fffff, ggggg, hhhhh, iiiii, jjjjj, kkkkk): 3 | return aaaaa # noqa -------------------------------------------------------------------------------- /tests/fixtures/formatted-line-length.py: -------------------------------------------------------------------------------- 1 | def foo( 2 | aaaaa, bbbbb, ccccc, ddddd, eeeee, fffff, ggggg, hhhhh, iiiii, jjjjj, kkkkk 3 | ): 4 | return aaaaa # noqa 5 | -------------------------------------------------------------------------------- /tests/fixtures/config/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 20 3 | --fast = true 4 | pyi = true 5 | skip-magic-trailing-comma = true 6 | skip-string-normalization = true 7 | preview = true -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ['py38', 'py39', 'py310', 'py311'] 3 | exclude = ''' 4 | /( 5 | \.venv 6 | | \.git 7 | | \.mypy_cache 8 | | build 9 | | dist 10 | | fixtures 11 | )/ 12 | ''' 13 | -------------------------------------------------------------------------------- /tests/fixtures/formatted.txt: -------------------------------------------------------------------------------- 1 | a = "hello" 2 | b = [ 3 | "a", 4 | "very", 5 | "very", 6 | "very", 7 | "very", 8 | "very", 9 | "very", 10 | "very", 11 | "very", 12 | "long", 13 | "line", 14 | ] 15 | c = 42 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | lint: 2 | pre-commit run -a 3 | 4 | black: 5 | pre-commit run -a black 6 | 7 | flake8: 8 | pre-commit run -a flake8 9 | 10 | isort: 11 | pre-commit run -a isort 12 | 13 | mypy: 14 | pre-commit run -a mypy 15 | 16 | test: 17 | pytest -vv . 18 | 19 | build: lint test 20 | python3 setup.py sdist bdist_wheel 21 | 22 | test-upload: 23 | twine upload --repository-url https://test.pypi.org/legacy/ dist/* 24 | 25 | upload: 26 | twine upload dist/* 27 | 28 | clean: 29 | rm -rf dist 30 | 31 | .PHONY: build 32 | -------------------------------------------------------------------------------- /MAINTENANCE.md: -------------------------------------------------------------------------------- 1 | The following are instructions to maintain python-lsp-black 2 | 3 | 1. Releases are tracked using Github milestones, which can be created and closed 4 | under the `Issues > Milestones` page: https://github.com/python-lsp/python-lsp-black/milestones. 5 | 1. If a issue will be fixed as part of a particular release, then its milestone 6 | should be the release-corresponding one. 7 | 1. Please make sure that PRs are also tracked under a specific milestone. 8 | 1. Please follow the [RELEASE.md](./RELEASE.md) file when making a release. 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/PyCQA/isort 3 | rev: 5.12.0 4 | hooks: 5 | - id: isort 6 | - repo: https://github.com/psf/black 7 | rev: 23.3.0 8 | hooks: 9 | - id: black 10 | exclude: fixtures 11 | args: [--check, --config=pyproject.toml] 12 | - repo: https://github.com/PyCQA/flake8 13 | rev: 6.0.0 14 | hooks: 15 | - id: flake8 16 | - repo: https://github.com/pre-commit/mirrors-mypy 17 | rev: v1.3.0 18 | hooks: 19 | - id: mypy 20 | additional_dependencies: [black, types-pkg_resources, types-setuptools] 21 | - repo: https://github.com/pre-commit/pre-commit-hooks 22 | rev: v4.4.0 23 | hooks: 24 | - id: check-merge-conflict 25 | - id: debug-statements 26 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | name: Python 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install dependencies 22 | run: python -m pip install -e .[dev] 23 | - name: pre-commit checks 24 | uses: pre-commit/action@v2.0.2 25 | - name: Tests 26 | run: pytest -v tests/ 27 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | To release a new version of python-lsp-black: 2 | 3 | 1. git fetch upstream && git checkout upstream/master 4 | 1. Close milestone on GitHub 5 | 1. git clean -xfdi 6 | 1. Update CHANGELOG.md with loghub: `loghub python-lsp/python-lsp-black --milestone vX.X.X` 7 | 1. git add -A && git commit -m "Update Changelog" 8 | 1. Update release version in `setup.cfg` (set release version, remove '.dev0') 9 | 1. git add -A && git commit -m "Release vX.X.X" 10 | 1. python setup.py sdist 11 | 1. python setup.py bdist_wheel 12 | 1. twine check dist/\* 13 | 1. twine upload dist/\* 14 | 1. git tag -a vX.X.X -m "Release vX.X.X" 15 | 1. Update development version in `setup.cfg` (add '.dev0' and increment minor) 16 | 1. git add -A && git commit -m "Back to work" 17 | 1. git push upstream master 18 | 1. git push upstream --tags 19 | 1. Draft a new release in GitHub using the new tag. 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2020 Rupert Bedford 4 | Copyright (c) 2021 Python LSP contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | 4 | ## Supported Versions 5 | 6 | We normally support only the most recently released version with bug fixes, security updates and compatibility improvements. 7 | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | If you believe you've discovered a security vulnerability in this project, please open a new security advisory with [our GitHub repo's private vulnerability reporting](https://github.com/python-lsp/python-lsp-black/security/advisories/new). 12 | Please be sure to carefully document the vulnerability, including a summary, describing the impacts, identifying the line(s) of code affected, stating the conditions under which it is exploitable and including a minimal reproducible test case. 13 | Further information and advice or patches on how to mitigate it is always welcome. 14 | You can usually expect to hear back within 1 week, at which point we'll inform you of our evaluation of the vulnerability and what steps we plan to take, and will reach out if we need further clarification from you. 15 | We'll discuss and update the advisory thread, and are happy to update you on its status should you further inquire. 16 | While this is a volunteer project and we don't have financial compensation to offer, we can certainly publicly thank and credit you for your help if you would like. 17 | Thanks! 18 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = python-lsp-black 3 | version = 2.1.0.dev0 4 | author = Python LSP contributors 5 | author_email = f@fidelramos.net 6 | description = Black plugin for the Python LSP Server 7 | url = https://github.com/python-lsp/python-lsp-black 8 | long_description = file: README.md 9 | long_description_content_type = text/markdown 10 | project_urls = 11 | Bug Tracker = https://github.com/python-lsp/python-lsp-black/issues 12 | Changelog = https://github.com/python-lsp/python-lsp-black/blob/master/CHANGELOG.md 13 | Source Code = https://github.com/python-lsp/python-lsp-black 14 | classifiers = 15 | Programming Language :: Python 16 | License :: OSI Approved :: MIT License 17 | Operating System :: OS Independent 18 | 19 | [options] 20 | packages = find: 21 | install_requires = 22 | python-lsp-server>=1.4.0 23 | black>=23.11.0 24 | tomli; python_version<'3.11' 25 | tests_require = 26 | black>=24.2.0 27 | python_requires = >= 3.8 28 | 29 | [options.entry_points] 30 | pylsp = black = pylsp_black.plugin 31 | 32 | [options.extras_require] 33 | # add any types-* packages to .pre-commit-config.yaml mypy additional_dependencies 34 | dev = isort>=5.0; flake8; pre-commit; pytest; mypy; pytest; types-pkg_resources; types-setuptools 35 | 36 | [flake8] 37 | max-line-length = 88 38 | ignore = E203 39 | exclude = 40 | .venv 41 | 42 | [mypy] 43 | ignore_missing_imports = true 44 | 45 | [isort] 46 | profile = black 47 | skip_glob = [".venv"] 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-lsp-black 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/python-lsp-black.svg)](https://pypi.org/project/python-lsp-black) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 4 | [![Python](https://github.com/python-lsp/python-lsp-black/actions/workflows/python.yml/badge.svg)](https://github.com/python-lsp/python-lsp-black/actions/workflows/python.yml) 5 | 6 | [Black](https://github.com/psf/black) plugin for the [Python LSP Server](https://github.com/python-lsp/python-lsp-server). 7 | 8 | ## Install 9 | 10 | In the same `virtualenv` as `python-lsp-server`: 11 | 12 | ```shell 13 | pip install python-lsp-black 14 | ``` 15 | 16 | # Usage 17 | 18 | This plugin will disable the yapf and autopep8 plugins if installed. 19 | 20 | - `python-lsp-black` can either format an entire file or just the selected text. 21 | - The code will only be formatted if it is syntactically valid Python. 22 | - Text selections are treated as if they were a separate Python file. 23 | Unfortunately this means you can't format an indented block of code. 24 | - `python-lsp-black` will use your project's 25 | [pyproject.toml](https://github.com/psf/black#pyprojecttoml) if it has one. 26 | - `python-lsp-black` only officially supports the latest stable version of 27 | [black](https://github.com/psf/black). An effort is made to keep backwards-compatibility 28 | but older black versions will not be actively tested. 29 | - The plugin can cache the black configuration that applies to each Python file, this 30 | improves performance of the plugin. When configuration caching is enabled any changes to 31 | black's configuration will need the LSP server to be restarted. Configuration caching 32 | can be disabled with the `cache_config` option, see *Configuration* below. 33 | 34 | # Configuration 35 | 36 | The plugin follows [python-lsp-server's 37 | configuration](https://github.com/python-lsp/python-lsp-server/#configuration). These are 38 | the valid configuration keys: 39 | 40 | - `pylsp.plugins.black.enabled`: boolean to enable/disable the plugin. 41 | - `pylsp.plugins.black.cache_config`: a boolean to enable black configuration caching (see 42 | *Usage*). `false` by default. 43 | - `pylsp.plugins.black.line_length`: an integer that maps to [black's 44 | `max-line-length`](https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html#line-length) 45 | setting. Defaults to 88 (same as black's default). This can also be set through black's 46 | configuration files, which should be preferred for multi-user projects. 47 | - `pylsp.plugins.black.preview`: a boolean to enable or disable [black's `--preview` 48 | setting](https://black.readthedocs.io/en/stable/the_black_code_style/future_style.html#preview-style). `false` by default. 49 | - `pylsp.plugins.black.skip_string_normalization`: a boolean to enable or disable black's `--skip-string-normalization` setting. `false` by default. 50 | - `pylsp.plugins.black.skip_magic_trailing_comma`: a boolean to enable or disable black's `skip-magic-trailing-comma` setting. `false` by default. 51 | 52 | # Development 53 | 54 | To install the project for development you need to specify the dev optional dependencies: 55 | 56 | ```shell 57 | python -m venv .venv 58 | . .venv/bin/activate 59 | pip install -e .[dev] 60 | ``` 61 | 62 | This project uses [pre-commit](https://pre-commit.com/) hooks to control code quality, 63 | install them to run automatically when creating a git commit, thus avoiding seeing errors 64 | when you create a pull request: 65 | 66 | ```shell 67 | pre-commit install 68 | ``` 69 | 70 | To run tests: 71 | 72 | ```shell 73 | make test 74 | ``` 75 | 76 | To run linters: 77 | 78 | ```shell 79 | make lint # just a shortcut to pre-commit run -a 80 | make # black, flake8, isort, mypy 81 | ``` 82 | 83 | To upgrade the version of the pre-commit hooks: 84 | 85 | ```shell 86 | pre-commit autoupdate 87 | # check and git commit changes to .pre-commit-config.yaml 88 | ``` 89 | -------------------------------------------------------------------------------- /pylsp_black/plugin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | from functools import lru_cache 5 | from pathlib import Path 6 | from typing import Dict, Optional 7 | 8 | import black 9 | from pylsp import hookimpl 10 | from pylsp._utils import get_eol_chars 11 | from pylsp.config.config import Config 12 | 13 | if sys.version_info >= (3, 11): 14 | import tomllib 15 | else: 16 | import tomli as tomllib 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | GLOBAL_CONFIG: Optional[Path] = None 22 | try: 23 | if os.name == "nt": 24 | GLOBAL_CONFIG = Path.home() / ".black" 25 | elif "XDG_CONFIG_HOME" in os.environ: 26 | GLOBAL_CONFIG = Path(os.environ["XDG_CONFIG_HOME"]) / "black" 27 | else: 28 | GLOBAL_CONFIG = Path.home() / ".config" / "black" 29 | except Exception as e: 30 | logger.error("Error determining black global config file path: %s", e) 31 | else: 32 | if GLOBAL_CONFIG is not None and GLOBAL_CONFIG.exists(): 33 | logger.info("Found black global config file at %s", GLOBAL_CONFIG) 34 | 35 | 36 | @hookimpl(tryfirst=True) 37 | def pylsp_format_document(config, document): 38 | return format_document(config, document) 39 | 40 | 41 | @hookimpl(tryfirst=True) 42 | def pylsp_format_range(config, document, range): 43 | range["start"]["character"] = 0 44 | range["end"]["line"] += 1 45 | range["end"]["character"] = 0 46 | return format_document(config, document, range) 47 | 48 | 49 | @hookimpl 50 | def pylsp_settings(): 51 | """Configuration options that can be set on the client.""" 52 | return { 53 | "plugins": { 54 | "black": { 55 | "enabled": True, 56 | "line_length": 88, 57 | "preview": False, 58 | "cache_config": False, 59 | }, 60 | "yapf": {"enabled": False}, 61 | "autopep8": {"enabled": False}, 62 | } 63 | } 64 | 65 | 66 | def format_document(client_config, document, range=None): 67 | text = document.source 68 | config = load_config(document.path, client_config) 69 | # Black lines indices are "1-based and inclusive on both ends" 70 | lines = [(range["start"]["line"] + 1, range["end"]["line"])] if range else () 71 | 72 | try: 73 | formatted_text = format_text(text=text, config=config, lines=lines) 74 | except black.NothingChanged: 75 | # raised when the file is already formatted correctly 76 | return [] 77 | 78 | if range: 79 | formatted_lines = formatted_text.splitlines(True) 80 | 81 | start = range["start"]["line"] 82 | end = range["end"]["line"] + (len(formatted_lines) - len(document.lines)) 83 | 84 | formatted_text = "".join(formatted_lines[start:end]) 85 | else: 86 | range = { 87 | "start": {"line": 0, "character": 0}, 88 | "end": {"line": len(document.lines), "character": 0}, 89 | } 90 | 91 | return [{"range": range, "newText": formatted_text}] 92 | 93 | 94 | def format_text(*, text, config, lines): 95 | mode = black.FileMode( 96 | target_versions=config["target_version"], 97 | line_length=config["line_length"], 98 | is_pyi=config["pyi"], 99 | string_normalization=not config["skip_string_normalization"], 100 | magic_trailing_comma=not config["skip_magic_trailing_comma"], 101 | preview=config["preview"], 102 | ) 103 | try: 104 | # Black's format_file_contents only works reliably when eols are '\n'. It gives 105 | # an error for '\r' and produces wrong formatting for '\r\n'. So we replace 106 | # those eols by '\n' before formatting and restore them afterwards. 107 | replace_eols = False 108 | eol_chars = get_eol_chars(text) 109 | if eol_chars is not None and eol_chars != "\n": 110 | replace_eols = True 111 | text = text.replace(eol_chars, "\n") 112 | 113 | # Will raise black.NothingChanged, we want to bubble that exception up 114 | formatted_text = black.format_file_contents( 115 | text, fast=config["fast"], mode=mode, lines=lines 116 | ) 117 | 118 | # Restore eols if necessary. 119 | if replace_eols: 120 | formatted_text = formatted_text.replace("\n", eol_chars) 121 | 122 | return formatted_text 123 | except ( 124 | # raised when the file has syntax errors 125 | ValueError, 126 | # raised when the file being formatted has an indentation error 127 | IndentationError, 128 | # raised when black produces invalid Python code or formats the file 129 | # differently on the second pass 130 | black.parsing.ASTSafetyError, 131 | ) as e: 132 | # errors will show on lsp stderr stream 133 | logger.error("Error formatting with black: %s", e) 134 | raise black.NothingChanged from e 135 | 136 | 137 | @lru_cache(100) 138 | def _load_config(filename: str, client_config: Config) -> Dict: 139 | settings = client_config.plugin_settings("black") 140 | 141 | defaults = { 142 | "line_length": settings.get("line_length", 88), 143 | "fast": False, 144 | "pyi": filename.endswith(".pyi"), 145 | "skip_string_normalization": settings.get("skip_string_normalization", False), 146 | "skip_magic_trailing_comma": settings.get("skip_magic_trailing_comma", False), 147 | "target_version": set(), 148 | "preview": settings.get("preview", False), 149 | } 150 | 151 | root = black.find_project_root((filename,)) 152 | 153 | # Black 22.1.0+ returns a tuple 154 | if isinstance(root, tuple): 155 | pyproject_filename = root[0] / "pyproject.toml" 156 | else: 157 | pyproject_filename = root / "pyproject.toml" 158 | 159 | if not pyproject_filename.is_file(): 160 | if GLOBAL_CONFIG is not None and GLOBAL_CONFIG.exists(): 161 | pyproject_filename = GLOBAL_CONFIG 162 | logger.info("Using global black config at %s", pyproject_filename) 163 | else: 164 | logger.info("Using defaults: %r", defaults) 165 | return defaults 166 | 167 | try: 168 | with open(pyproject_filename, "rb") as f: 169 | pyproject_toml = tomllib.load(f) 170 | except (tomllib.TOMLDecodeError, OSError): 171 | logger.warning( 172 | "Error decoding pyproject.toml, using defaults: %r", 173 | defaults, 174 | ) 175 | return defaults 176 | 177 | file_config = pyproject_toml.get("tool", {}).get("black", {}) 178 | file_config = { 179 | key.replace("--", "").replace("-", "_"): value 180 | for key, value in file_config.items() 181 | } 182 | 183 | config = { 184 | key: file_config.get(key, default_value) 185 | for key, default_value in defaults.items() 186 | } 187 | 188 | if file_config.get("target_version"): 189 | target_version = set( 190 | black.TargetVersion[x.upper()] for x in file_config["target_version"] 191 | ) 192 | else: 193 | target_version = set() 194 | 195 | config["target_version"] = target_version 196 | 197 | logger.info("Using config from %s: %r", pyproject_filename, config) 198 | 199 | return config 200 | 201 | 202 | def load_config(filename: str, client_config: Config) -> Dict: 203 | settings = client_config.plugin_settings("black") 204 | 205 | # Use the original, not cached function to load settings if requested 206 | if not settings.get("cache_config", False): 207 | return _load_config.__wrapped__(filename, client_config) 208 | 209 | return _load_config(filename, client_config) 210 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # History of changes 2 | 3 | ## Version 2.0.0 (2023-12-19) 4 | 5 | ### New features 6 | 7 | * Add support to format indented selections of code. This requires Black 23.11.0+ 8 | * Change entrypoint name to be `black`. This changes the options namespace for 9 | this plugin from `pylsp.pylsp_black` to `pylsp.black`. 10 | * Drop support for Python 3.7. 11 | 12 | ### Issues Closed 13 | 14 | * [Issue 42](https://github.com/python-lsp/python-lsp-black/issues/42) - Ineffective range formatting ([PR 52](https://github.com/python-lsp/python-lsp-black/pull/52) by [@remisalmon](https://github.com/remisalmon)) 15 | * [Issue 41](https://github.com/python-lsp/python-lsp-black/issues/41) - Configuration key and plugin name mismatch ([PR 39](https://github.com/python-lsp/python-lsp-black/pull/39) by [@chantera](https://github.com/chantera)) 16 | 17 | In this release 2 issues were closed. 18 | 19 | ### Pull Requests Merged 20 | 21 | * [PR 53](https://github.com/python-lsp/python-lsp-black/pull/53) - Drop support for Python 3.7, by [@ccordoba12](https://github.com/ccordoba12) 22 | * [PR 52](https://github.com/python-lsp/python-lsp-black/pull/52) - Use new `lines` option in Black 23.11 to format range, by [@remisalmon](https://github.com/remisalmon) ([42](https://github.com/python-lsp/python-lsp-black/issues/42)) 23 | * [PR 49](https://github.com/python-lsp/python-lsp-black/pull/49) - Read skip options from plugin settings, by [@seruman](https://github.com/seruman) 24 | * [PR 39](https://github.com/python-lsp/python-lsp-black/pull/39) - Change entrypoint name to simply be `black`, by [@chantera](https://github.com/chantera) ([41](https://github.com/python-lsp/python-lsp-black/issues/41)) 25 | 26 | In this release 4 pull requests were closed. 27 | 28 | ## Version 1.3.0 (2023/05/19) 29 | 30 | ### Issues Closed 31 | 32 | * [Issue 36](https://github.com/python-lsp/python-lsp-black/issues/36) - python-lsp-black ignores skip-magic-trailing-comma in .config/black ([PR 37](https://github.com/python-lsp/python-lsp-black/pull/37) by [@wstevick](https://github.com/wstevick)) 33 | * [Issue 35](https://github.com/python-lsp/python-lsp-black/issues/35) - python-lsp-black does not respect black configurations 34 | 35 | In this release 2 issues were closed. 36 | 37 | ### Pull Requests Merged 38 | 39 | * [PR 47](https://github.com/python-lsp/python-lsp-black/pull/47) - direnv support, by [@haplo](https://github.com/haplo) 40 | * [PR 46](https://github.com/python-lsp/python-lsp-black/pull/46) - Add Python 3.11, drop 3.7 from test matrix, by [@haplo](https://github.com/haplo) 41 | * [PR 45](https://github.com/python-lsp/python-lsp-black/pull/45) - Test preview and skip-magic-trailing-comma config parsing, by [@haplo](https://github.com/haplo) 42 | * [PR 44](https://github.com/python-lsp/python-lsp-black/pull/44) - pre-commit autoupdate, by [@haplo](https://github.com/haplo) 43 | * [PR 40](https://github.com/python-lsp/python-lsp-black/pull/40) - Replace the obsolete toml package with tomllib/tomli, by [@mgorny](https://github.com/mgorny) 44 | * [PR 38](https://github.com/python-lsp/python-lsp-black/pull/38) - Added missing `preview` kwarg in `black.FileMode`. Fixes #35., by [@JesusTorrado](https://github.com/JesusTorrado) 45 | * [PR 37](https://github.com/python-lsp/python-lsp-black/pull/37) - Add the possibility to configure skip-magic-trailing-comma, by [@wstevick](https://github.com/wstevick) ([36](https://github.com/python-lsp/python-lsp-black/issues/36)) 46 | 47 | In this release 7 pull requests were closed. 48 | 49 | ## Version 1.2.1 (2022-04-12) 50 | 51 | ### Pull Requests Merged 52 | 53 | * [PR 34](https://github.com/python-lsp/python-lsp-black/pull/34) - Disable Autopep8 and Yapf if this plugin is installed, by [@bageljrkhanofemus](https://github.com/bageljrkhanofemus) 54 | 55 | In this release 1 pull request was closed. 56 | 57 | ## Version 1.2.0 (2022-03-28) 58 | 59 | ### Issues Closed 60 | 61 | * [Issue 24](https://github.com/python-lsp/python-lsp-black/issues/24) - Option to cache black configuration per-file 62 | 63 | In this release 1 issue was closed. 64 | 65 | ### Pull Requests Merged 66 | 67 | * [PR 33](https://github.com/python-lsp/python-lsp-black/pull/33) - Update pre-commit hooks' versions, by [@haplo](https://github.com/haplo) 68 | * [PR 32](https://github.com/python-lsp/python-lsp-black/pull/32) - Fix PyPI badge in Readme, by [@ccordoba12](https://github.com/ccordoba12) 69 | * [PR 28](https://github.com/python-lsp/python-lsp-black/pull/28) - Correctly format files and ranges with line endings other than LF, by [@ccordoba12](https://github.com/ccordoba12) 70 | * [PR 26](https://github.com/python-lsp/python-lsp-black/pull/26) - Add client side configuration and cache configuration per file, by [@haplo](https://github.com/haplo) 71 | 72 | In this release 4 pull requests were closed. 73 | 74 | ## Version 1.1.0 (2022-01-30) 75 | 76 | ### Issues Closed 77 | 78 | * [Issue 29](https://github.com/python-lsp/python-lsp-black/issues/29) - TypeError when formatting with Black 22.1 ([PR 30](https://github.com/python-lsp/python-lsp-black/pull/30) by [@wlcx](https://github.com/wlcx)) 79 | * [Issue 25](https://github.com/python-lsp/python-lsp-black/issues/25) - Support global config file for black ([PR 19](https://github.com/python-lsp/python-lsp-black/pull/19) by [@jdost](https://github.com/jdost)) 80 | 81 | In this release 2 issues were closed. 82 | 83 | ### Pull Requests Merged 84 | 85 | * [PR 30](https://github.com/python-lsp/python-lsp-black/pull/30) - Fix TypeError when formatting with black 22.1.0+, by [@wlcx](https://github.com/wlcx) ([29](https://github.com/python-lsp/python-lsp-black/issues/29)) 86 | * [PR 19](https://github.com/python-lsp/python-lsp-black/pull/19) - Support global config as a fallback, by [@jdost](https://github.com/jdost) ([25](https://github.com/python-lsp/python-lsp-black/issues/25)) 87 | 88 | In this release 2 pull requests were closed. 89 | 90 | ## Version 1.0.1 (2021-12-01) 91 | 92 | ### Issues Closed 93 | 94 | * [Issue 20](https://github.com/python-lsp/python-lsp-black/issues/20) - Formatting fails silently 95 | * [Issue 12](https://github.com/python-lsp/python-lsp-black/issues/12) - Fix MyPy linting 96 | * [Issue 9](https://github.com/python-lsp/python-lsp-black/issues/9) - Ignore virtualenv in linters 97 | * [Issue 8](https://github.com/python-lsp/python-lsp-black/issues/8) - Add Development section to README 98 | * [Issue 7](https://github.com/python-lsp/python-lsp-black/issues/7) - Add pre-commit checks 99 | 100 | In this release 5 issues were closed. 101 | 102 | ### Pull Requests Merged 103 | 104 | * [PR 23](https://github.com/python-lsp/python-lsp-black/pull/23) - Add pre-commit hooks, by [@haplo](https://github.com/haplo) 105 | * [PR 22](https://github.com/python-lsp/python-lsp-black/pull/22) - Log black errors to stderr, by [@haplo](https://github.com/haplo) ([20](https://github.com/python-lsp/python-lsp-black/issues/20)) 106 | * [PR 14](https://github.com/python-lsp/python-lsp-black/pull/14) - Add virtualenv to gitignore and Python 3.9 to black target versions, by [@haplo](https://github.com/haplo) 107 | * [PR 13](https://github.com/python-lsp/python-lsp-black/pull/13) - Install MyPy stubs, by [@haplo](https://github.com/haplo) ([12](https://github.com/python-lsp/python-lsp-black/issues/12)) 108 | * [PR 11](https://github.com/python-lsp/python-lsp-black/pull/11) - Add Development section to README, by [@haplo](https://github.com/haplo) ([8](https://github.com/python-lsp/python-lsp-black/issues/8)) 109 | * [PR 10](https://github.com/python-lsp/python-lsp-black/pull/10) - Exclude venv and other directories from linters, by [@haplo](https://github.com/haplo) ([9](https://github.com/python-lsp/python-lsp-black/issues/9)) 110 | 111 | In this release 6 pull request was closed. 112 | 113 | ## Version 1.0.0 (2021/05/18) 114 | 115 | ### Issues Closed 116 | 117 | - [Issue 3](https://github.com/python-lsp/python-lsp-black/issues/3) - Update README and add RELEASE instructions 118 | - [Issue 2](https://github.com/python-lsp/python-lsp-black/issues/2) - Release v1.0.0 119 | 120 | In this release 2 issues were closed. 121 | 122 | ### Pull Requests Merged 123 | 124 | - [PR 1](https://github.com/python-lsp/python-lsp-black/pull/1) - PR: Python LSP server migration, by [@andfoy](https://github.com/andfoy) 125 | 126 | In this release 1 pull request was closed. 127 | -------------------------------------------------------------------------------- /tests/test_plugin.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | import types 3 | from pathlib import Path 4 | from unittest.mock import Mock 5 | 6 | # Third-party imports 7 | import black 8 | import pkg_resources 9 | import pytest 10 | 11 | # Python LSP imports 12 | from pylsp import uris 13 | from pylsp.config.config import Config 14 | from pylsp.workspace import Document, Workspace 15 | 16 | # Local imports 17 | from pylsp_black.plugin import ( 18 | _load_config, 19 | load_config, 20 | pylsp_format_document, 21 | pylsp_format_range, 22 | pylsp_settings, 23 | ) 24 | 25 | here = Path(__file__).parent 26 | fixtures_dir = here / "fixtures" 27 | 28 | 29 | @pytest.fixture 30 | def workspace(tmpdir): 31 | """Return a workspace.""" 32 | return Workspace(uris.from_fs_path(str(tmpdir)), Mock()) 33 | 34 | 35 | @pytest.fixture 36 | def config(workspace): 37 | """Return a config object.""" 38 | cfg = Config(workspace.root_uri, {}, 0, {}) 39 | cfg._plugin_settings = { 40 | "plugins": {"black": {"line_length": 88, "cache_config": False}} 41 | } 42 | return cfg 43 | 44 | 45 | @pytest.fixture 46 | def config_with_skip_options(workspace): 47 | """Return a config object.""" 48 | cfg = Config(workspace.root_uri, {}, 0, {}) 49 | cfg._plugin_settings = { 50 | "plugins": { 51 | "black": { 52 | "line_length": 88, 53 | "cache_config": False, 54 | "skip_string_normalization": True, 55 | "skip_magic_trailing_comma": True, 56 | } 57 | } 58 | } 59 | return cfg 60 | 61 | 62 | @pytest.fixture 63 | def unformatted_document(workspace): 64 | path = fixtures_dir / "unformatted.txt" 65 | uri = f"file:/{path}" # noqa 66 | return Document(uri, workspace) 67 | 68 | 69 | @pytest.fixture 70 | def unformatted_pyi_document(workspace): 71 | path = fixtures_dir / "unformatted.pyi" 72 | uri = f"file:/{path}" # noqa 73 | return Document(uri, workspace) 74 | 75 | 76 | @pytest.fixture 77 | def unformatted_crlf_document(workspace): 78 | path = fixtures_dir / "unformatted-crlf.py" 79 | uri = f"file:/{path}" # noqa 80 | with open(path, "r", newline="") as f: 81 | source = f.read() 82 | return Document(uri, workspace, source=source) 83 | 84 | 85 | @pytest.fixture 86 | def formatted_document(workspace): 87 | path = fixtures_dir / "formatted.txt" 88 | uri = f"file:/{path}" # noqa 89 | return Document(uri, workspace) 90 | 91 | 92 | @pytest.fixture 93 | def formatted_pyi_document(workspace): 94 | path = fixtures_dir / "formatted.pyi" 95 | uri = f"file:/{path}" # noqa 96 | return Document(uri, workspace) 97 | 98 | 99 | @pytest.fixture 100 | def formatted_crlf_document(workspace): 101 | path = fixtures_dir / "formatted-crlf.py" 102 | uri = f"file:/{path}" # noqa 103 | with open(path, "r", newline="") as f: 104 | source = f.read() 105 | return Document(uri, workspace, source=source) 106 | 107 | 108 | @pytest.fixture 109 | def invalid_document(workspace): 110 | path = fixtures_dir / "invalid.txt" 111 | uri = f"file:/{path}" # noqa 112 | return Document(uri, workspace) 113 | 114 | 115 | @pytest.fixture 116 | def config_document(workspace): 117 | path = fixtures_dir / "config" / "config.txt" 118 | uri = f"file:/{path}" # noqa 119 | return Document(uri, workspace) 120 | 121 | 122 | @pytest.fixture 123 | def unformatted_line_length(workspace): 124 | path = fixtures_dir / "unformatted-line-length.py" 125 | uri = f"file:/{path}" # noqa 126 | return Document(uri, workspace) 127 | 128 | 129 | @pytest.fixture 130 | def formatted_line_length(workspace): 131 | path = fixtures_dir / "formatted-line-length.py" 132 | uri = f"file:/{path}" # noqa 133 | return Document(uri, workspace) 134 | 135 | 136 | def test_pylsp_format_document(config, unformatted_document, formatted_document): 137 | result = pylsp_format_document(config, unformatted_document) 138 | 139 | assert result == [ 140 | { 141 | "range": { 142 | "start": {"line": 0, "character": 0}, 143 | "end": {"line": 3, "character": 0}, 144 | }, 145 | "newText": formatted_document.source, 146 | } 147 | ] 148 | 149 | 150 | def test_pyls_format_pyi_document( 151 | config, unformatted_pyi_document, formatted_pyi_document 152 | ): 153 | result = pylsp_format_document(config, unformatted_pyi_document) 154 | 155 | assert result == [ 156 | { 157 | "range": { 158 | "start": {"line": 0, "character": 0}, 159 | "end": {"line": 5, "character": 0}, 160 | }, 161 | "newText": formatted_pyi_document.source, 162 | } 163 | ] 164 | 165 | 166 | def test_pylsp_format_document_unchanged(config, formatted_document): 167 | result = pylsp_format_document(config, formatted_document) 168 | 169 | assert result == [] 170 | 171 | 172 | def test_pyls_format_pyi_document_unchanged(config, formatted_pyi_document): 173 | result = pylsp_format_document(config, formatted_pyi_document) 174 | 175 | assert result == [] 176 | 177 | 178 | def test_pylsp_format_document_syntax_error(config, invalid_document): 179 | result = pylsp_format_document(config, invalid_document) 180 | 181 | assert result == [] 182 | 183 | 184 | def test_pylsp_format_document_with_config(config, config_document): 185 | result = pylsp_format_document(config, config_document) 186 | 187 | assert result == [ 188 | { 189 | "range": { 190 | "start": {"line": 0, "character": 0}, 191 | "end": {"line": 1, "character": 0}, 192 | }, 193 | "newText": ( 194 | "run(\n" 195 | " these,\n" 196 | " arguments,\n" 197 | " should,\n" 198 | " be,\n" 199 | " wrapped,\n" 200 | ")\n" 201 | ), 202 | } 203 | ] 204 | 205 | 206 | @pytest.mark.parametrize( 207 | ("start", "end", "expected"), 208 | [ 209 | (0, 0, 'a = "hello"\n'), 210 | ( 211 | 1, 212 | 1, 213 | 'b = [\n "a",\n "very",\n "very",\n "very",\n "very",\n "very",\n "very",\n "very",\n "very",\n "long",\n "line",\n]\n', # noqa: E501 214 | ), 215 | (2, 2, "c = 42\n"), 216 | ( 217 | 0, 218 | 2, 219 | 'a = "hello"\nb = [\n "a",\n "very",\n "very",\n "very",\n "very",\n "very",\n "very",\n "very",\n "very",\n "long",\n "line",\n]\nc = 42\n', # noqa: E501 220 | ), 221 | ], 222 | ) 223 | def test_pylsp_format_range(config, unformatted_document, start, end, expected): 224 | range = { 225 | "start": {"line": start, "character": 0}, 226 | "end": {"line": end, "character": 0}, 227 | } 228 | 229 | result = pylsp_format_range(config, unformatted_document, range=range) 230 | 231 | assert result == [ 232 | { 233 | "range": { 234 | "start": {"line": start, "character": 0}, 235 | "end": {"line": end + 1, "character": 0}, 236 | }, 237 | "newText": expected, 238 | } 239 | ] 240 | 241 | 242 | def test_pylsp_format_range_unchanged(config, formatted_document): 243 | range = {"start": {"line": 0, "character": 0}, "end": {"line": 1, "character": 0}} 244 | 245 | result = pylsp_format_range(config, formatted_document, range=range) 246 | 247 | assert result == [] 248 | 249 | 250 | def test_pylsp_format_range_syntax_error(config, invalid_document): 251 | range = {"start": {"line": 0, "character": 0}, "end": {"line": 1, "character": 0}} 252 | 253 | result = pylsp_format_range(config, invalid_document, range=range) 254 | 255 | assert result == [] 256 | 257 | 258 | def test_load_config(config): 259 | config = load_config(str(fixtures_dir / "config" / "example.py"), config) 260 | 261 | # TODO split into smaller tests 262 | assert config == { 263 | "line_length": 20, 264 | "target_version": set(), 265 | "pyi": True, 266 | "fast": True, 267 | "skip_magic_trailing_comma": True, 268 | "skip_string_normalization": True, 269 | "preview": True, 270 | } 271 | 272 | 273 | def test_load_config_target_version(config): 274 | config = load_config(str(fixtures_dir / "target_version" / "example.py"), config) 275 | 276 | assert config["target_version"] == {black.TargetVersion.PY39} 277 | 278 | 279 | def test_load_config_defaults(config): 280 | config = load_config(str(fixtures_dir / "example.py"), config) 281 | 282 | assert config == { 283 | "line_length": 88, 284 | "target_version": set( 285 | [ 286 | black.TargetVersion.PY38, 287 | black.TargetVersion.PY39, 288 | black.TargetVersion.PY310, 289 | black.TargetVersion.PY311, 290 | ] 291 | ), 292 | "pyi": False, 293 | "fast": False, 294 | "skip_magic_trailing_comma": False, 295 | "skip_string_normalization": False, 296 | "preview": False, 297 | } 298 | 299 | 300 | def test_load_config_with_skip_options(config_with_skip_options): 301 | config = load_config( 302 | str(fixtures_dir / "skip_options" / "example.py"), config_with_skip_options 303 | ) 304 | 305 | assert config == { 306 | "line_length": 88, 307 | "target_version": set( 308 | [ 309 | black.TargetVersion.PY38, 310 | black.TargetVersion.PY39, 311 | black.TargetVersion.PY310, 312 | black.TargetVersion.PY311, 313 | ] 314 | ), 315 | "pyi": False, 316 | "fast": False, 317 | "skip_magic_trailing_comma": True, 318 | "skip_string_normalization": True, 319 | "preview": False, 320 | } 321 | 322 | 323 | def test_entry_point(): 324 | distribution = pkg_resources.get_distribution("python-lsp-black") 325 | entry_point = distribution.get_entry_info("pylsp", "black") 326 | 327 | assert entry_point is not None 328 | 329 | module = entry_point.load() 330 | assert isinstance(module, types.ModuleType) 331 | 332 | 333 | def test_pylsp_format_crlf_document( 334 | config, unformatted_crlf_document, formatted_crlf_document 335 | ): 336 | result = pylsp_format_document(config, unformatted_crlf_document) 337 | 338 | assert result == [ 339 | { 340 | "range": { 341 | "start": {"line": 0, "character": 0}, 342 | "end": {"line": 4, "character": 0}, 343 | }, 344 | "newText": formatted_crlf_document.source, 345 | } 346 | ] 347 | 348 | 349 | def test_pylsp_format_line_length( 350 | config, unformatted_line_length, formatted_line_length 351 | ): 352 | config.update({"plugins": {"black": {"line_length": 79}}}) 353 | result = pylsp_format_document(config, unformatted_line_length) 354 | 355 | assert result == [ 356 | { 357 | "range": { 358 | "start": {"line": 0, "character": 0}, 359 | "end": {"line": 3, "character": 0}, 360 | }, 361 | "newText": formatted_line_length.source, 362 | } 363 | ] 364 | 365 | 366 | def test_cache_config(config, unformatted_document): 367 | # Cache should be off by default 368 | for _ in range(5): 369 | pylsp_format_document(config, unformatted_document) 370 | assert _load_config.cache_info().hits == 0 371 | 372 | # Enable cache 373 | config.update({"plugins": {"black": {"cache_config": True}}}) 374 | 375 | # Cache should be working now 376 | for _ in range(5): 377 | pylsp_format_document(config, unformatted_document) 378 | assert _load_config.cache_info().hits == 4 379 | 380 | 381 | def test_pylsp_settings(config): 382 | plugins = dict(config.plugin_manager.list_name_plugin()) 383 | assert "black" in plugins 384 | assert plugins["black"] not in config.disabled_plugins 385 | config.update({"plugins": {"black": {"enabled": False}}}) 386 | assert plugins["black"] in config.disabled_plugins 387 | config.update(pylsp_settings()) 388 | assert plugins["black"] not in config.disabled_plugins 389 | --------------------------------------------------------------------------------