diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5051645f..c7ff87e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,7 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest", "ubuntu-24.04-arm", "macos-latest", "windows-latest"] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.9", "pypy3.10"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "pypy3.9", "pypy3.10"] exclude: - os: "macos-latest" python-version: "pypy3.10" @@ -118,7 +118,7 @@ jobs: run: nox -vs integration -p ${{ matrix.python-version }} -- -m "not require_secrets" - name: Run integration tests (with secrets) # Limit CI workload by running integration tests with secrets only on edge Python versions. - if: ${{ env.B2_TEST_APPLICATION_KEY != '' && env.B2_TEST_APPLICATION_KEY_ID != '' && contains(fromJSON('["3.8", "pypy3.10", "3.13"]'), matrix.python-version) }} + if: ${{ env.B2_TEST_APPLICATION_KEY != '' && env.B2_TEST_APPLICATION_KEY_ID != '' && contains(fromJSON('["3.8", "pypy3.10", "3.14"]'), matrix.python-version) }} run: nox -vs integration -p ${{ matrix.python-version }} -- -m "require_secrets" --cleanup test-docker: timeout-minutes: 90 diff --git a/b2/_internal/arg_parser.py b/b2/_internal/arg_parser.py index d59f0336..89cb046a 100644 --- a/b2/_internal/arg_parser.py +++ b/b2/_internal/arg_parser.py @@ -130,7 +130,14 @@ def _encode_description(self, value: str): return textwrap.dedent(value) else: encoding = self._get_encoding() - return rst2ansi(value.encode(encoding), output_encoding=encoding) + try: + return rst2ansi(value.encode(encoding), output_encoding=encoding) + except SystemError: + # FALLBACK(PARSER): rst2ansi can raise SystemError on Python 3.14+ due to + # buffer overflow bug in get_terminal_size ioctl call. + # See: https://github.com/Backblaze/B2_Command_Line_Tool/issues/1119 + # TODO-REMOVE-BY: When rst2ansi is updated or replaced + return textwrap.dedent(value) def _make_short_description(self, usage: str, raw_description: str) -> str: if usage: diff --git a/changelog.d/+python-3.14-support.infrastructure.md b/changelog.d/+python-3.14-support.infrastructure.md new file mode 100644 index 00000000..e803e754 --- /dev/null +++ b/changelog.d/+python-3.14-support.infrastructure.md @@ -0,0 +1 @@ +Added Python 3.14 support to CI/CD pipeline and test matrix. \ No newline at end of file diff --git a/changelog.d/1119.fixed.md b/changelog.d/1119.fixed.md new file mode 100644 index 00000000..e3eb9cfb --- /dev/null +++ b/changelog.d/1119.fixed.md @@ -0,0 +1 @@ +Fixed SystemError buffer overflow crash on Python 3.14+ caused by rst2ansi's terminal size detection bug. The CLI now gracefully handles this error and continues to function normally. \ No newline at end of file diff --git a/noxfile.py b/noxfile.py index 995f6fb5..c6b925ce 100644 --- a/noxfile.py +++ b/noxfile.py @@ -42,6 +42,7 @@ '3.11', '3.12', '3.13', + '3.14', ] if NOX_PYTHONS is None else NOX_PYTHONS.split(',') diff --git a/pyproject.toml b/pyproject.toml index e1c83b0b..d99577eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dependencies = [ "argcomplete>=3.5.2,<4", diff --git a/test/helpers.py b/test/helpers.py index 1b6a48a0..30cfbb33 100644 --- a/test/helpers.py +++ b/test/helpers.py @@ -8,7 +8,9 @@ # ###################################################################### import platform +import sys +import pexpect import pytest _MISSING = object() @@ -21,6 +23,40 @@ def skip_on_windows(*args, reason='Not supported on Windows', **kwargs): )(*args, **kwargs) +def patched_spawn(*args, **kwargs): + """ + Wrapper around pexpect.spawn with improved error messages. + + pexpect's errors are confusing to interpret when things go wrong, + because it doesn't output the actual stdout by default. This wrapper + addresses that inconvenience. + """ + instance = pexpect.spawn(*args, **kwargs) + + def _patch_expect(func): + def _wrapper(pattern_list, **kwargs): + try: + return func(pattern_list, **kwargs) + except pexpect.exceptions.TIMEOUT as exc: + raise pexpect.exceptions.TIMEOUT( + f'Timeout reached waiting for `{pattern_list}`' + ) from exc + except pexpect.exceptions.EOF as exc: + raise pexpect.exceptions.EOF(f'Received EOF waiting for `{pattern_list}`') from exc + except Exception as exc: + raise RuntimeError(f'Unexpected error waiting for `{pattern_list}`') from exc + + return _wrapper + + instance.expect = _patch_expect(instance.expect) + instance.expect_exact = _patch_expect(instance.expect_exact) + + # capture child shell's output for debugging + instance.logfile = sys.stdout.buffer + + return instance + + def b2_uri_args_v3(bucket_name, path=_MISSING): if path is _MISSING: return [bucket_name] diff --git a/test/integration/helpers.py b/test/integration/helpers.py index f35c99f9..9ec876c0 100755 --- a/test/integration/helpers.py +++ b/test/integration/helpers.py @@ -368,7 +368,15 @@ def print_json_indented(value): def remove_warnings(text): - return linesep.join(line for line in text.split(linesep) if 'DeprecationWarning' not in line) + """Filter out Python warnings from command output.""" + return linesep.join( + line + for line in text.split(linesep) + if 'DeprecationWarning' not in line + and 'resource_tracker' not in line # Python 3.14+ multiprocessing warnings + and 'UserWarning' not in line # Python 3.14+ shows more warning details + and 'warnings.warn(' not in line # Python 3.14+ shows source line in warnings + ) class StringReader: diff --git a/test/integration/test_autocomplete.py b/test/integration/test_autocomplete.py index 5042bd10..a48cfd93 100644 --- a/test/integration/test_autocomplete.py +++ b/test/integration/test_autocomplete.py @@ -13,7 +13,7 @@ import pexpect import pytest -from test.helpers import skip_on_windows +from test.helpers import patched_spawn, skip_on_windows TIMEOUT = 120 # CI can be slow at times when parallelization is extreme @@ -27,41 +27,6 @@ """ -def patched_spawn(*args, **kwargs): - """ - Patch pexpect.spawn to improve error messages - """ - - instance = pexpect.spawn(*args, **kwargs) - - def _patch_expect(func): - def _wrapper(pattern_list, **kwargs): - try: - return func(pattern_list, **kwargs) - except pexpect.exceptions.TIMEOUT as exc: - raise pexpect.exceptions.TIMEOUT( - f'Timeout reached waiting for `{pattern_list}` to be autocompleted' - ) from exc - except pexpect.exceptions.EOF as exc: - raise pexpect.exceptions.EOF( - f'Received EOF waiting for `{pattern_list}` to be autocompleted' - ) from exc - except Exception as exc: - raise RuntimeError( - f'Unexpected error waiting for `{pattern_list}` to be autocompleted' - ) from exc - - return _wrapper - - instance.expect = _patch_expect(instance.expect) - instance.expect_exact = _patch_expect(instance.expect_exact) - - # capture child shell's output for debugging - instance.logfile = sys.stdout.buffer - - return instance - - @pytest.fixture(scope='session') def bashrc(homedir): bashrc_path = homedir / '.bashrc' diff --git a/test/integration/test_help.py b/test/integration/test_help.py index 96c289f7..e32b62da 100644 --- a/test/integration/test_help.py +++ b/test/integration/test_help.py @@ -7,10 +7,16 @@ # License https://www.backblaze.com/using_b2_code.html # ###################################################################### +import os import platform import re import subprocess +import pexpect +import pytest + +from test.helpers import patched_spawn + def test_help(cli_version): p = subprocess.run( @@ -26,3 +32,54 @@ def test_help(cli_version): expected_name += '.exe' assert re.match(r'^_?b2(v\d+)?(\.exe)?$', expected_name) # test sanity check assert f'{expected_name} --help' in p.stdout + + +@pytest.mark.skipif( + platform.system() == 'Windows', + reason='PTY tests require Unix-like system', +) +def test_help_with_tty(cli_version): + """ + Test that B2 CLI --help works correctly with a real PTY. + + Verifies fix for rst2ansi buffer overflow on Python 3.14+. + See: https://github.com/Backblaze/B2_Command_Line_Tool/issues/1119 + + NOTE: Works in CI with pytest-xdist, but may not trigger the bug locally. + """ + # Set up environment - remove LINES/COLUMNS to ensure ioctl is called + env = os.environ.copy() + env.pop('LINES', None) + env.pop('COLUMNS', None) + + # Spawn b2 --help with pexpect to create a real PTY + # This is where the bug would trigger on Python 3.14 without our fix + child = patched_spawn( + cli_version, + ['--help'], + env=env, + timeout=10, + ) + + # Wait for process to complete + child.expect(pexpect.EOF) + + # Get the output + output = child.before.decode('utf-8', errors='replace') + + # Check exit status + child.close() + exit_code = child.exitstatus + + # Verify the command succeeded and produced help output + assert exit_code == 0, ( + f'b2 --help failed with exit code {exit_code}.\n' + f'This may indicate the buffer overflow bug is not properly handled.\n' + f'Output: {output}\n' + f'See: https://github.com/Backblaze/B2_Command_Line_Tool/issues/1119' + ) + + # Verify help output contains expected content + assert ( + 'b2 ' in output or cli_version in output + ), f'Help output does not contain expected content.\nOutput: {output}'