diff --git a/CHANGES b/CHANGES index dd4b1c328..2d44bb2b3 100644 --- a/CHANGES +++ b/CHANGES @@ -32,7 +32,17 @@ $ uvx --from 'libtmux' --prerelease allow python -- _Future release notes will be placed here_ +_Future release notes will be placed here_ + +### Deprecations + +- tmux versions below 3.2a are now deprecated (#606). A `FutureWarning` will + be emitted on first use. Support for these versions will be removed in a future + release. Set `LIBTMUX_SUPPRESS_VERSION_WARNING=1` to suppress the warning. + +### Internal + +- Added `TMUX_SOFT_MIN_VERSION` constant (3.2a) for deprecation threshold (#606) ### What's new diff --git a/docs/quickstart.md b/docs/quickstart.md index 90edfcbfc..9e74f6274 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -12,7 +12,8 @@ from inside a live tmux session. ## Requirements -- [tmux] +- [tmux] 3.2a or newer (recommended) + - tmux 1.8 - 3.1 are deprecated and will be unsupported in a future release - [pip] - for this handbook's examples [tmux]: https://tmux.github.io/ diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 886b160fb..6923f0e0f 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -29,11 +29,44 @@ #: Most recent version of tmux supported TMUX_MAX_VERSION = "3.6" +#: Minimum version before deprecation warning is shown +TMUX_SOFT_MIN_VERSION = "3.2a" + SessionDict = dict[str, t.Any] WindowDict = dict[str, t.Any] WindowOptionDict = dict[str, t.Any] PaneDict = dict[str, t.Any] +#: Flag to ensure deprecation warning is only shown once per process +_version_deprecation_checked: bool = False + + +def _check_deprecated_version(version: LooseVersion) -> None: + """Check if tmux version is deprecated and warn once. + + This is called from get_version() on first invocation. + """ + global _version_deprecation_checked + if _version_deprecation_checked: + return + _version_deprecation_checked = True + + import os + import warnings + + if os.environ.get("LIBTMUX_SUPPRESS_VERSION_WARNING"): + return + + if version < LooseVersion(TMUX_SOFT_MIN_VERSION): + warnings.warn( + f"tmux {version} is deprecated and will be unsupported in a future " + f"libtmux release. Please upgrade to tmux {TMUX_SOFT_MIN_VERSION} " + "or newer. Set LIBTMUX_SUPPRESS_VERSION_WARNING=1 to suppress this " + "warning.", + FutureWarning, + stacklevel=4, + ) + class EnvironmentMixin: """Mixin for manager session and server level environment variables in tmux.""" @@ -303,7 +336,9 @@ def get_version() -> LooseVersion: version = re.sub(r"[a-z-]", "", version) - return LooseVersion(version) + version_obj = LooseVersion(version) + _check_deprecated_version(version_obj) + return version_obj def has_version(version: str) -> bool: diff --git a/tests/test_common.py b/tests/test_common.py index 3aa045bc4..57a9e4745 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -508,3 +508,134 @@ def mock_get_version() -> LooseVersion: elif check_type == "type_check": assert mock_version is not None # For type checker assert isinstance(has_version(mock_version), bool) + + +class VersionDeprecationFixture(t.NamedTuple): + """Test fixture for version deprecation warning.""" + + test_id: str + version: str + suppress_env: bool + expected_warning: bool + + +VERSION_DEPRECATION_FIXTURES: list[VersionDeprecationFixture] = [ + VersionDeprecationFixture( + test_id="deprecated_version_warns", + version="3.1", + suppress_env=False, + expected_warning=True, + ), + VersionDeprecationFixture( + test_id="old_deprecated_version_warns", + version="2.9", + suppress_env=False, + expected_warning=True, + ), + VersionDeprecationFixture( + test_id="current_version_no_warning", + version="3.2a", + suppress_env=False, + expected_warning=False, + ), + VersionDeprecationFixture( + test_id="newer_version_no_warning", + version="3.5", + suppress_env=False, + expected_warning=False, + ), + VersionDeprecationFixture( + test_id="env_var_suppresses_warning", + version="3.0", + suppress_env=True, + expected_warning=False, + ), +] + + +@pytest.mark.parametrize( + list(VersionDeprecationFixture._fields), + VERSION_DEPRECATION_FIXTURES, + ids=[test.test_id for test in VERSION_DEPRECATION_FIXTURES], +) +def test_version_deprecation_warning( + test_id: str, + version: str, + suppress_env: bool, + expected_warning: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test version deprecation warning behavior.""" + import warnings + + import libtmux.common + + # Reset the warning flag for each test + monkeypatch.setattr(libtmux.common, "_version_deprecation_checked", False) + + # Set or clear the suppress env var + if suppress_env: + monkeypatch.setenv("LIBTMUX_SUPPRESS_VERSION_WARNING", "1") + else: + monkeypatch.delenv("LIBTMUX_SUPPRESS_VERSION_WARNING", raising=False) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + libtmux.common._check_deprecated_version(LooseVersion(version)) + + if expected_warning: + assert len(w) == 1 + assert issubclass(w[0].category, FutureWarning) + assert version in str(w[0].message) + assert "3.2a" in str(w[0].message) + else: + assert len(w) == 0 + + +def test_version_deprecation_warns_once(monkeypatch: pytest.MonkeyPatch) -> None: + """Test that deprecation warning only fires once per process.""" + import warnings + + import libtmux.common + + monkeypatch.setattr(libtmux.common, "_version_deprecation_checked", False) + monkeypatch.delenv("LIBTMUX_SUPPRESS_VERSION_WARNING", raising=False) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + libtmux.common._check_deprecated_version(LooseVersion("3.1")) + libtmux.common._check_deprecated_version(LooseVersion("3.1")) + + assert len(w) == 1 + + +def test_version_deprecation_via_get_version(monkeypatch: pytest.MonkeyPatch) -> None: + """Test deprecation warning fires through get_version() call. + + This integration test verifies the warning is emitted when calling + get_version() with an old tmux version, testing the full call chain. + """ + import warnings + + import libtmux.common + + class MockTmuxOutput: + stdout: t.ClassVar = ["tmux 3.1"] + stderr: t.ClassVar[list[str]] = [] + + def mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> MockTmuxOutput: + return MockTmuxOutput() + + monkeypatch.setattr(libtmux.common, "_version_deprecation_checked", False) + monkeypatch.setattr(libtmux.common, "tmux_cmd", mock_tmux_cmd) + monkeypatch.delenv("LIBTMUX_SUPPRESS_VERSION_WARNING", raising=False) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + version = libtmux.common.get_version() + + assert str(version) == "3.1" + assert len(w) == 1 + assert issubclass(w[0].category, FutureWarning) + assert "3.1" in str(w[0].message) + assert "3.2a" in str(w[0].message)