extract _parse_tool_version helper for black --version parsing#762
extract _parse_tool_version helper for black --version parsing#762HrachShah wants to merge 2 commits into
Conversation
… helper _update_workspace_settings_with_version_info inlined a fragile regex match against the first line of the tool's --version output: \d+\.\d+\S*. If the line was empty (splitlines() raises IndexError) the whole initialization crashed; if the first line had no version token, or had multiple (e.g. a CPython banner like 'Python 3.12.0'), the code defaulted to the literal string '0.0.0' which is a valid (very old) SemVer and silently passes the >= 22.3.0 check while logging a misleading 'NOT supported' line for every workspace. Pull the regex match out into _parse_tool_version(stdout) and call it from the version detection loop with proper ValueError handling: empty stdout -> 'empty --version output'; no candidate -> 'no version candidate in --version output: <line>'. When the first line has multiple version-shaped tokens, pick the longest so a real '24.3.0' or '24.3.0rc1' wins over a stray short match in a Python banner. The previous code only kept a match when len(parts) == 1, silently dropping valid versions whenever the line had any other version-shaped text. Added bundled/tool/tests/test_parse_tool_version.py with 9 unit tests: standard black output 'black, 24.3.0', pre-release '24.3.0rc1', multiple candidates, CRLF line endings, empty stdout, whitespace-only first line, missing version token, two-part version '24.3', and confirms the helper uses the first line only.
|
Danke shon! @HrachShah |
| def test_handles_two_part_version(self): | ||
| out = "black, 24.3\n" | ||
| assert _parse_tool_version(out) == "24.3" | ||
|
|
There was a problem hiding this comment.
📍 bundled/tool/tests/test_parse_tool_version.py:52
Confirmed violation of tests-no-partial-asserts.md: test_raises_on_whitespace_only_output and test_raises_when_no_version_token use match="no version candidate", a partial regex, against fully deterministic exception messages. The inputs are hardcoded constants, so the full message is fixed and must be asserted exactly: match=r"no version candidate in --version output: '\s*'" and match=r"no version candidate in --version output: 'something went wrong'". (test_raises_on_empty_output already uses a full match and is fine.)
| # If the first line contains multiple version-shaped tokens (e.g. black | ||
| # with an embedded 'X.Y.Z' and '24.3.0'), pick the longest so a | ||
| # pre-release suffix like '24.3.0rc1' wins over a stray short match. | ||
| return max(parts, key=len) |
There was a problem hiding this comment.
📍 bundled/tool/lsp_server.py:348
max(parts, key=len) picks the wrong token on a same-length tie: _parse_tool_version("Python 3.12.0 black, 24.3.0") returns "3.12.0", not 24.3.0, because max returns the first maximum. The PR's multi-token test only passes because rc1 makes black's token coincidentally longer — the test is tuned to the heuristic, not the real format. Reachability is low (black emits a single version token on line 1), so this is not a blocker, but consider anchoring on the format instead — e.g. the first token that fullmatches after black, — and retargeting the test.
| # A second line that contains a version is ignored; we only look | ||
| # at the first line because that's where tools emit their banner. | ||
| out = "black, 24.3.0\nsome other 1.2.3.4 token\n" | ||
| assert _parse_tool_version(out) == "24.3.0" |
There was a problem hiding this comment.
📍 bundled/tool/tests/test_parse_tool_version.py:1
The tests exercise _parse_tool_version in isolation only. The PR's most consequential behavioral change — the new continue skipping VERSION_LOOKUP population and the resulting potential KeyError in range formatting — has no integration coverage. A unit test of the helper can never catch this. Consider adding a test that drives the version-detection loop with unparseable --version output and then asserts range formatting still degrades gracefully rather than raising.
| # If the first line contains multiple version-shaped tokens (e.g. black | ||
| # with an embedded 'X.Y.Z' and '24.3.0'), pick the longest so a | ||
| # pre-release suffix like '24.3.0rc1' wins over a stray short match. | ||
| return max(parts, key=len) |
There was a problem hiding this comment.
📍 bundled/tool/lsp_server.py:332
The PR description overstates/misstates the motivation: the old splitlines()[0] IndexError was caught by the surrounding bare except: so initialization did not crash (one workspace was skipped); (0,0,0) >= (22,3,0) is False so the old default logged "NOT supported" rather than silently passing the check; and the old \d+\.\d+\S* regex already matched a bare 24.3, so the (?:\.\d+)? addition doesn't change that case. The refactor still stands on its own merits — please correct the description so future readers trust the rationale. Info-only.
|
Refactor looks good and is safe to merge. A few non-blocking notes below: a tie-break edge case in |
|
Solid refactor with good unit coverage. One non-blocking note on the version-token selection heuristic; the remaining first-pass items were either test-style nitpicks, an inapplicable internal review rule, or PR-description nits and were dropped. |
|
Thanks for your contribution @HrachShah ! |
|
@HrachShah please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.
Contributor License AgreementContribution License AgreementThis Contribution License Agreement (“Agreement”) is agreed to by the party signing below (“You”),
|
|
🔒 Automated review in progress — @rchiodo is auto-reviewing this PR via the Review Center. Other automated reviewers defer to this claim to avoid duplicate, conflicting feedback. The claim is refreshed while the review is active; if it is not refreshed within the ownership timeout it is treated as vacant and another reviewer may take over. This is an automated marker. |
|
🔒 Automated review in progress — @rchiodo is auto-reviewing this PR via the Review Center. Other automated reviewers defer to this claim to avoid duplicate, conflicting feedback. The claim is refreshed while the review is active; if it is not refreshed within the ownership timeout it is treated as vacant and another reviewer may take over. This is an automated marker. |
| if not parts: | ||
| raise ValueError(f"no version candidate in --version output: {lines[0]!r}") | ||
| # If the first line contains multiple version-shaped tokens (e.g. black | ||
| # with an embedded 'X.Y.Z' and '24.3.0'), pick the longest so a |
There was a problem hiding this comment.
📍 bundled/tool/lsp_server.py:308
The max(parts, key=len) disambiguation is unsound — the Skeptic and Architect both verified by execution that it picks the wrong token: 'Python 3.12.0 black, 24.3.0' returns '3.12.0' (Python's version, equal length → first wins), and 'black, 24.3.0 built 2024.10' returns '2024.10', which then falsely passes >= 22.3.0. The author's own passing test only works because 24.3.0rc1 (len 9) coincidentally beats 3.12.0 (len 6). black's format is positionally stable (black, X.Y.Z …); disambiguate by structure — take the token immediately following the tool name, or the first matching token — and validate it via parse_version before returning.
[verified]
| log_error( | ||
| f"Could not parse version of formatter running for " | ||
| f"{code_workspace}: {ve}\r\n" | ||
| ) |
There was a problem hiding this comment.
📍 bundled/tool/lsp_server.py:339
version = parse_version(actual_version) is outside the new try/except ValueError. The Skeptic verified that a malformed-but-fullmatch-ing token (e.g. 'black, 24.3.0 99.99.99,' → longest is '99.99.99,') makes parse_version raise InvalidVersion, which the targeted except ValueError never catches. The Advocate's file read notes an outer bare-except absorbs it, so this isn't a hard init crash — but it bypasses the PR's own diagnostic path and re-introduces the unguarded-parse failure class the PR set out to eliminate. Move parse_version(actual_version) inside the try (catch ValueError, which covers InvalidVersion) and continue on failure.
[verified]
| tool_server.handle_shutdown() | ||
|
|
||
|
|
||
| VERSION_RE = re.compile(r"\d+\.\d+(?:\.\d+)?\S*") |
There was a problem hiding this comment.
📍 bundled/tool/lsp_server.py:291
VERSION_RE = r"\d+\.\d+(?:\.\d+)?\S*" — the trailing \S* greedily swallows punctuation into the version token (verified: 'black 24.3.0,' → '24.3.0,'), which then breaks parse_version. This was pre-existing but becomes newly reachable through the multi-token path. Anchor the suffix to plausible version characters (e.g. (?:[abc]|rc|\.dev|\.post|[-+.\w])*) or strip trailing non-word chars before parsing.
[verified]
| with pytest.raises(ValueError, match="empty --version output"): | ||
| _parse_tool_version("") | ||
|
|
||
| def test_raises_on_whitespace_only_output(self): |
There was a problem hiding this comment.
📍 bundled/tool/tests/test_parse_tool_version.py:41
test_picks_longest_candidate_when_multiple_match cements the flawed "longest wins" heuristic as an invariant — its fixture ('Python 3.12.0 black, 24.3.0rc1') is the one case where longest happens to be correct. A realistic sibling ('Python 3.12.10rc2 black, 24.3.0') breaks it, and none of the tests assert the value on an equal-length or longer-junk-token line. Once the heuristic is fixed to be structure-based, this test must be rewritten to assert the real contract (the token after the tool name).
[verified]
|
Main theme: the version token disambiguation is fragile. Prefer structure-based extraction (the token following the tool name) over 'longest wins', and move |
|
🔒 Automated review in progress — @rchiodo is auto-reviewing this PR via the Review Center. Other automated reviewers defer to this claim to avoid duplicate, conflicting feedback. The claim is refreshed while the review is active; if it is not refreshed within the ownership timeout it is treated as vacant and another reviewer may take over. This is an automated marker. |
|
🔒 Automated review in progress — @rchiodo is auto-reviewing this PR. |
|
🔒 Automated review in progress — @rchiodo is auto-reviewing this PR. |
_update_workspace_settings_with_version_info inlined a fragile regex match against the first line of the tool's --version output. If the line was empty, splitlines() raised IndexError and the whole initialization crashed. If the first line had no version token, or had multiple (e.g. a CPython banner like 'Python 3.12.0'), the code defaulted to the literal string '0.0.0' which is a valid (very old) SemVer and silently passes the >= 22.3.0 check while logging a misleading 'NOT supported' line for every workspace.
This commit pulls the regex match out into _parse_tool_version(stdout) and calls it from the version detection loop with proper ValueError handling: empty stdout -> 'empty --version output'; no candidate -> 'no version candidate in --version output: '. When the first line has multiple version-shaped tokens, the helper picks the longest so a real '24.3.0' or '24.3.0rc1' wins over a stray short match in a Python banner.
Also tightens the regex from \d+.\d+\S* to \d+.\d+(?:.\d+)?\S* fullmatch, so a bare '24.3' is accepted (some dev installs drop the patch number) without matching '24.3.0' twice on a '24.3.0rc1' line.
Added bundled/tool/tests/test_parse_tool_version.py with 9 unit tests covering: standard 'black, 24.3.0' output, pre-release '24.3.0rc1', multiple version tokens on the first line, CRLF line endings, empty stdout, whitespace-only first line, missing version token, two-part version '24.3', and confirms the helper uses the first line only.