Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 61 additions & 8 deletions bundled/tool/lsp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,66 @@ def update_environ_path() -> None:
)


TOOL_MODULE = "black"
TOOL_DISPLAY = "Black Formatter"

# Default arguments always passed to black.
TOOL_ARGS = []

# Minimum version of black supported.
MIN_VERSION = "22.3.0"

BLACK_CONFIG = ToolServerConfig(
tool_module=TOOL_MODULE,
tool_display=TOOL_DISPLAY,
tool_args=TOOL_ARGS,
min_version=MIN_VERSION,
runner_script=str(RUNNER),
)

tool_server = ToolServer(BLACK_CONFIG, server=LSP_SERVER)

WORKSPACE_SETTINGS = tool_server.workspace_settings
GLOBAL_SETTINGS = tool_server.global_settings
Comment on lines +91 to +111

# Minimum version of black that supports the `--line-ranges` CLI option.
LINE_RANGES_MIN_VERSION = (23, 11, 0)

# Timeout in seconds for formatting operations to prevent indefinite blocking.
FORMATTING_TIMEOUT = 120

# Versions of black found by workspace
VERSION_LOOKUP: Dict[str, Tuple[int, int, int]] = {}

Comment on lines +113 to +121

def _parse_tool_version(stdout: str) -> str:
"""Extract the actual tool version from the --version stdout.

Black prints lines like "black, 22.3.0 (compiled: yes)"; a CPython banner
like "Python 3.12.0" can also appear on the first line in some
environments. We scan the first line for any token matching the
version-shape "X.Y..." and return the leftmost such token, so a real
"24.3.0" wins over a stray "3.12" in a Python banner. Returns "0.0.0" when
no candidate is found so the existing >= 22.3.0 path can still report
"NOT supported" with a clear message instead of crashing.

Pre-release versions ("24.3.0rc1") are returned as-is; parse_version
handles them.
"""
if not stdout:
return "0.0.0"
lines = stdout.splitlines()
if not lines:
return "0.0.0"
first_line = lines[0]
candidates = re.findall(r"\d+\.\d+(?:\.\d+)?[A-Za-z0-9.+-]*", first_line)
if not candidates:
return "0.0.0"
# When multiple version-shaped tokens are present, prefer the leftmost so
# the real tool version beats any "Python 3.12" fragment in parentheses.
return candidates[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "leftmost version-shaped token wins" heuristic only fails safe for black by coincidence: a leading Python 3.x banner is always < 22.3.0 (black's CalVer), so it correctly lands on "NOT supported". Since this file is the shared vscode-python-tools template and the helper is generically named _parse_tool_version, copying it to a tool whose real version is in the 2.x/3.x range (e.g. isort/autopep8) would mistake a leading Python banner for a supported version. Consider anchoring selection on TOOL_MODULE, or at least add a comment noting the leftmost rule is only safe here because black's version numbers exceed CPython's.



def _get_document_path(document: TextDocument) -> str:
"""Returns the filesystem path for a document.

Expand Down Expand Up @@ -344,14 +404,7 @@ def _update_workspace_settings_with_version_info(
'Install black in your environment and set "black-formatter.importStrategy": "fromEnvironment"'
)

# This is text we get from running `black --version`
# black, 22.3.0 (compiled: yes) <--- This is the version we want.
first_line = result.stdout.splitlines(keepends=False)[0]
parts = [v for v in first_line.split(" ") if re.match(r"\d+\.\d+\S*", v)]
if len(parts) == 1:
actual_version = parts[0]
else:
actual_version = "0.0.0"
actual_version = _parse_tool_version(result.stdout)

version = parse_version(actual_version)
min_version = parse_version(MIN_VERSION)
Expand Down
88 changes: 88 additions & 0 deletions bundled/tool/tests/test_parse_tool_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Unit tests for lsp_server._parse_tool_version.

The version parser is called on the raw stdout of the user's local
``black --version`` invocation, which on a real install can include
parenthetical info ("compiled: yes"), the embedded Python interpreter
version ("(Python 3.12.1)"), or just be empty. The previous
inline implementation only kept a version when exactly one such token
appeared on the first line, so the parenthetical case silently fell
back to the literal string "0.0.0" and logged a misleading
"NOT supported" line for every workspace.
"""
from __future__ import annotations

import pathlib
import sys

# Ensure bundled/tool is on sys.path so we can import lsp_server directly.
_TOOL_DIR = str(pathlib.Path(__file__).resolve().parent.parent)
if _TOOL_DIR not in sys.path:
sys.path.insert(0, _TOOL_DIR)

import lsp_server # noqa: E402


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bundled/ is the shipped VSIX payload. Please confirm .vscodeignore excludes bundled/**/tests/** (or relocate these tests to the conventional src/test/python_tests/ location) so test code isn't shipped in the extension.

class TestParseToolVersion:
"""Tests for lsp_server._parse_tool_version."""

def test_standard_black_output(self):
assert lsp_server._parse_tool_version("black, 26.3.1 (compiled: yes)") == "26.3.1"

def test_just_version(self):
assert lsp_server._parse_tool_version("black, 22.3.0") == "22.3.0"

def test_pre_release_version(self):
# PEP 440 pre-release syntax: 24.3.0rc1
assert lsp_server._parse_tool_version("black, 24.3.0rc1") == "24.3.0rc1"

def test_dev_version_with_local_segment(self):
# PEP 440 dev / local-version: 24.3.0.dev1+g1234
assert (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR headline is that a trailing ) (e.g. 3.12.0)) is no longer swallowed, but in every test the returned leftmost token is followed by whitespace, so the )-stripping only ever affects the non-returned second candidate. Add a case that pins the headline behavior on the returned token, e.g. assert _parse_tool_version("black, 22.3.0)") == "22.3.0" (and ideally the no-space "black,22.3.0)" form).

lsp_server._parse_tool_version("black, 24.3.0.dev1+g1234")
== "24.3.0.dev1+g1234"
)

def test_compiled_and_python_in_parens(self):
# The original bug: the parenthetical "(Python 3.12.1)" is matched
# by the version regex, so the previous "exactly one match" check
# silently fell back to "0.0.0".
out = lsp_server._parse_tool_version(
"black, 26.3.1 (compiled: yes, Python 3.12.1)"
)
assert out == "26.3.1"

def test_compiled_and_python_in_parens_alternate(self):
out = lsp_server._parse_tool_version(
"black 22.3.0 (compiled: yes, Python: 3.11.4)"
)
assert out == "22.3.0"

def test_trailing_newline(self):
assert lsp_server._parse_tool_version("black, 22.3.0\n") == "22.3.0"

def test_crlf_line_endings(self):
# Black is a Windows-friendly formatter; on Windows the captured
# stdout uses CRLF. The helper should ignore the trailing CR.
assert lsp_server._parse_tool_version("black, 22.3.0\r\n") == "22.3.0"

def test_empty_stdout(self):
# Empty input must not crash — returning the literal "0.0.0" lets
# the >= 22.3.0 comparison below it report "NOT supported" with
# a clear message instead of a KeyError or IndexError.
assert lsp_server._parse_tool_version("") == "0.0.0"

def test_whitespace_only(self):
assert lsp_server._parse_tool_version("\n \n") == "0.0.0"

def test_no_version_token(self):
assert lsp_server._parse_tool_version("no version here") == "0.0.0"

def test_only_python_banner(self):
# Standalone CPython banner (no tool name). Leftmost match wins.
assert lsp_server._parse_tool_version("Python 3.12.0") == "3.12.0"

def test_uses_first_line_only(self):
# The first line is the only one that matters — a banner on the
# second line should be ignored.
out = "black, 22.3.0 (compiled: yes)\nPython 3.12.0\n"
assert lsp_server._parse_tool_version(out) == "22.3.0"