Skip to content

Commit 48b7ba1

Browse files
authored
Fixed crash in Python 3.15.0a6 (#1604)
The crash was caused by changes in the argparse internal API in Python 3.15, specifically in how it handles colorization and formatter initialization. Changes Made: Add testing for Python 3.15-dev: Start testing on Python 3.15 pre-release versions, currently 3.15.0a6 Cmd2HelpFormatter._set_color: Added an override for the _set_color method to handle the new file keyword argument introduced in Python 3.15. It uses a try-except block to fall back to the older signature if the underlying RichHelpFormatter (from rich-argparse) does not yet support the new keyword argument. Cmd2ArgumentParser._get_formatter: Updated the _get_formatter method to accept **kwargs and pass them to the superclass. This is necessary because Python 3.15's argparse now passes a file argument to this method in several places (e.g., print_usage). TextGroup.__init__: Updated the type hint for the formatter_creator parameter from Callable[[], Cmd2HelpFormatter] to Callable[..., Cmd2HelpFormatter] to remain consistent with the updated _get_formatter signature. string_utils.common_prefix function added as a replacement for os.path.commonprefix which is deprecated in Python 3.15.
1 parent 42d2271 commit 48b7ba1

File tree

7 files changed

+120
-5
lines changed

7 files changed

+120
-5
lines changed

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ jobs:
1212
strategy:
1313
matrix:
1414
os: [ubuntu-latest, macos-latest, windows-latest]
15-
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"]
15+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15-dev"]
1616
fail-fast: false
1717

1818
runs-on: ${{ matrix.os }}

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ prompt is displayed.
8181
- `cmd2.Cmd.select` has been revamped to use the
8282
[choice](https://python-prompt-toolkit.readthedocs.io/en/3.0.52/pages/asking_for_a_choice.html)
8383
function from `prompt-toolkit` when both **stdin** and **stdout** are TTYs
84+
- Add support for Python 3.15 by fixing various bugs related to internal `argparse` changes
85+
- Added `common_prefix` method to `cmd2.string_utils` module as a replacement for
86+
`os.path.commonprefix` since that is now deprecated in Python 3.15
8487

8588
## 3.4.0 (March 3, 2026)
8689

cmd2/argparse_custom.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1030,6 +1030,22 @@ def console(self, console: Cmd2RichArgparseConsole) -> None:
10301030
"""Set our console instance."""
10311031
self._console = console
10321032

1033+
def _set_color(self, color: bool, **kwargs: Any) -> None:
1034+
"""Set the color for the help output.
1035+
1036+
This override is needed because Python 3.15 added a 'file' keyword argument
1037+
to _set_color() which some versions of RichHelpFormatter don't support.
1038+
"""
1039+
# Argparse didn't add color support until 3.14
1040+
if sys.version_info < (3, 14):
1041+
return
1042+
1043+
try: # type: ignore[unreachable]
1044+
super()._set_color(color, **kwargs)
1045+
except TypeError:
1046+
# Fallback for older versions of RichHelpFormatter that don't support keyword arguments
1047+
super()._set_color(color)
1048+
10331049
def _build_nargs_range_str(self, nargs_range: tuple[int, int | float]) -> str:
10341050
"""Generate nargs range string for help text."""
10351051
if nargs_range[1] == constants.INFINITY:
@@ -1134,7 +1150,7 @@ def __init__(
11341150
self,
11351151
title: str,
11361152
text: RenderableType,
1137-
formatter_creator: Callable[[], Cmd2HelpFormatter],
1153+
formatter_creator: Callable[..., Cmd2HelpFormatter],
11381154
) -> None:
11391155
"""TextGroup initializer.
11401156
@@ -1258,9 +1274,9 @@ def error(self, message: str) -> NoReturn:
12581274

12591275
self.exit(2, f'{formatted_message}\n')
12601276

1261-
def _get_formatter(self) -> Cmd2HelpFormatter:
1277+
def _get_formatter(self, **kwargs: Any) -> Cmd2HelpFormatter:
12621278
"""Override with customizations for Cmd2HelpFormatter."""
1263-
return cast(Cmd2HelpFormatter, super()._get_formatter())
1279+
return cast(Cmd2HelpFormatter, super()._get_formatter(**kwargs))
12641280

12651281
def format_help(self) -> str:
12661282
"""Override to add a newline."""

cmd2/cmd2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1921,7 +1921,7 @@ def delimiter_complete(
19211921
match_strings = basic_completions.to_strings()
19221922

19231923
# Calculate what portion of the match we are completing
1924-
common_prefix = os.path.commonprefix(match_strings)
1924+
common_prefix = su.common_prefix(match_strings)
19251925
prefix_tokens = common_prefix.split(delimiter)
19261926
display_token_index = len(prefix_tokens) - 1
19271927

cmd2/string_utils.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
full-width characters (like those used in CJK languages).
66
"""
77

8+
from collections.abc import (
9+
Sequence,
10+
)
11+
812
from rich.align import AlignMethod
913
from rich.style import StyleType
1014
from rich.text import Text
@@ -167,3 +171,22 @@ def norm_fold(val: str) -> str:
167171
import unicodedata
168172

169173
return unicodedata.normalize("NFC", val).casefold()
174+
175+
176+
def common_prefix(m: Sequence[str]) -> str:
177+
"""Return the longest common leading component of a list of strings.
178+
179+
This is a replacement for os.path.commonprefix which is deprecated in Python 3.15.
180+
181+
:param m: list of strings
182+
:return: common prefix
183+
"""
184+
if not m:
185+
return ""
186+
187+
s1 = min(m)
188+
s2 = max(m)
189+
for i, c in enumerate(s1):
190+
if i >= len(s2) or c != s2[i]:
191+
return s1[:i]
192+
return s1

tests/test_argparse_custom.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Unit/functional testing for argparse customizations in cmd2"""
22

33
import argparse
4+
import sys
45

56
import pytest
67

@@ -12,6 +13,8 @@
1213
)
1314
from cmd2.argparse_custom import (
1415
ChoicesCallable,
16+
Cmd2HelpFormatter,
17+
Cmd2RichArgparseConsole,
1518
generate_range_error,
1619
)
1720

@@ -353,3 +356,47 @@ def test_completion_items_as_choices(capsys) -> None:
353356
# Confirm error text contains correct value type of int
354357
_out, err = capsys.readouterr()
355358
assert 'invalid choice: 3 (choose from 1, 2)' in err
359+
360+
361+
def test_formatter_console() -> None:
362+
# self._console = console (inside console.setter)
363+
formatter = Cmd2HelpFormatter(prog='test')
364+
new_console = Cmd2RichArgparseConsole()
365+
formatter.console = new_console
366+
assert formatter._console is new_console
367+
368+
369+
@pytest.mark.skipif(
370+
sys.version_info < (3, 14),
371+
reason="Argparse didn't support color until Python 3.14",
372+
)
373+
def test_formatter_set_color(mocker) -> None:
374+
formatter = Cmd2HelpFormatter(prog='test')
375+
376+
# return (inside _set_color if sys.version_info < (3, 14))
377+
mocker.patch('cmd2.argparse_custom.sys.version_info', (3, 13, 0))
378+
# This should return early without calling super()._set_color
379+
mock_set_color = mocker.patch('rich_argparse.RichHelpFormatter._set_color')
380+
formatter._set_color(True)
381+
mock_set_color.assert_not_called()
382+
383+
# except TypeError and super()._set_color(color)
384+
mocker.patch('cmd2.argparse_custom.sys.version_info', (3, 15, 0))
385+
386+
# Reset mock and make it raise TypeError when called with kwargs
387+
mock_set_color.reset_mock()
388+
389+
def side_effect(color, **kwargs):
390+
if kwargs:
391+
raise TypeError("unexpected keyword argument 'file'")
392+
return
393+
394+
mock_set_color.side_effect = side_effect
395+
396+
# This call should trigger the TypeError and then the fallback call
397+
formatter._set_color(True, file=sys.stdout)
398+
399+
# It should have been called twice: once with kwargs (failed) and once without (fallback)
400+
assert mock_set_color.call_count == 2
401+
mock_set_color.assert_any_call(True, file=sys.stdout)
402+
mock_set_color.assert_any_call(True)

tests/test_string_utils.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,3 +246,29 @@ def test_unicode_casefold() -> None:
246246
micro_cf = micro.casefold()
247247
assert micro != micro_cf
248248
assert su.norm_fold(micro) == su.norm_fold(micro_cf)
249+
250+
251+
def test_common_prefix() -> None:
252+
# Empty list
253+
assert su.common_prefix([]) == ""
254+
255+
# Single item
256+
assert su.common_prefix(["abc"]) == "abc"
257+
258+
# Common prefix exists
259+
assert su.common_prefix(["abcdef", "abcde", "abcd"]) == "abcd"
260+
261+
# No common prefix
262+
assert su.common_prefix(["abc", "def"]) == ""
263+
264+
# One is a prefix of another
265+
assert su.common_prefix(["apple", "app"]) == "app"
266+
267+
# Identical strings
268+
assert su.common_prefix(["test", "test"]) == "test"
269+
270+
# Case sensitivity (matches os.path.commonprefix behavior)
271+
assert su.common_prefix(["Apple", "apple"]) == ""
272+
273+
# Empty string in list
274+
assert su.common_prefix(["abc", ""]) == ""

0 commit comments

Comments
 (0)