diff --git a/.tool-versions b/.tool-versions index ff78fd6a6..491175fbf 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -uv 0.6.1 +uv 0.6.3 python 3.13.2 3.12.9 3.11.11 3.10.16 3.9.21 3.8.20 3.7.17 diff --git a/CHANGES b/CHANGES index cfb7f923d..451ce501c 100644 --- a/CHANGES +++ b/CHANGES @@ -9,12 +9,62 @@ To install via [pip](https://pip.pypa.io/en/stable/), use: $ pip install --user --upgrade --pre libtmux ``` -## libtmux 0.46.x (Yet to be released) +## libtmux 0.47.x (Yet to be released) - _Future release notes will be placed here_ +## libtmux 0.46.0 (2025-02-25) + +### Breaking + +#### Imports removed from libtmux.test (#580) + +Root-level of imports from `libtmux.test` are no longer possible. + +```python +# Before 0.46.0 +from libtmux.test import namer +``` + +```python +# From 0.46.0 onward +from libtmux.test.named import namer +``` + +Same thing with constants: + +```python +# Before 0.46.0 +from libtmux.test import ( + RETRY_INTERVAL_SECONDS, + RETRY_TIMEOUT_SECONDS, + TEST_SESSION_PREFIX +) +``` + +```python +# From 0.46.0 onward +from libtmux.test.constants import ( + RETRY_INTERVAL_SECONDS, + RETRY_TIMEOUT_SECONDS, + TEST_SESSION_PREFIX +) +``` + +### Development + +#### Test helpers: Increased coverage (#580) + +Several improvements to the test helper modules: + +- Enhanced `EnvironmentVarGuard` in `libtmux.test.environment` to better handle variable cleanup +- Added comprehensive test suites for test constants and environment utilities +- Improved docstrings and examples in `libtmux.test.random` with test coverage annotations +- Fixed potential issues with environment variable handling during tests +- Added proper coverage markers to exclude type checking blocks from coverage reports + ## libtmux 0.45.0 (2025-02-23) ### Breaking Changes diff --git a/MIGRATION b/MIGRATION index e3b097e50..6d62cf917 100644 --- a/MIGRATION +++ b/MIGRATION @@ -25,6 +25,42 @@ _Detailed migration steps for the next version will be posted here._ +## libtmux 0.46.0 (2025-02-25) + +#### Imports removed from libtmux.test (#580) + +Root-level of imports from `libtmux.test` are no longer possible. + +```python +# Before 0.46.0 +from libtmux.test import namer +``` + +```python +# From 0.46.0 onward +from libtmux.test.named import namer +``` + +Same thing with constants: + +```python +# Before 0.46.0 +from libtmux.test import ( + RETRY_INTERVAL_SECONDS, + RETRY_TIMEOUT_SECONDS, + TEST_SESSION_PREFIX +) +``` + +```python +# From 0.46.0 onward +from libtmux.test.constants import ( + RETRY_INTERVAL_SECONDS, + RETRY_TIMEOUT_SECONDS, + TEST_SESSION_PREFIX +) +``` + ## libtmux 0.45.0 (2025-02-23) ### Test helpers: Module moves diff --git a/pyproject.toml b/pyproject.toml index f3dc1aa14..1115cd419 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "libtmux" -version = "0.45.0" +version = "0.46.0" description = "Typed library that provides an ORM wrapper for tmux, a terminal multiplexer." requires-python = ">=3.9,<4.0" authors = [ @@ -150,7 +150,9 @@ exclude_lines = [ "if TYPE_CHECKING:", "if t.TYPE_CHECKING:", "@overload( |$)", + 'class .*\bProtocol\):', "from __future__ import annotations", + "import typing as t", ] [tool.ruff] diff --git a/src/libtmux/__about__.py b/src/libtmux/__about__.py index 4de25b74b..7870bc6f1 100644 --- a/src/libtmux/__about__.py +++ b/src/libtmux/__about__.py @@ -4,7 +4,7 @@ __title__ = "libtmux" __package_name__ = "libtmux" -__version__ = "0.45.0" +__version__ = "0.46.0" __description__ = "Typed scripting library / ORM / API wrapper for tmux" __email__ = "tony@git-pull.com" __author__ = "Tony Narlock" diff --git a/src/libtmux/test/__init__.py b/src/libtmux/test/__init__.py index 8fba9fa82..a1a350204 100644 --- a/src/libtmux/test/__init__.py +++ b/src/libtmux/test/__init__.py @@ -1,36 +1 @@ """Helper methods for libtmux and downstream libtmux libraries.""" - -from __future__ import annotations - -import contextlib -import logging -import os -import pathlib -import random -import time -import typing as t - -from libtmux.exc import WaitTimeout -from libtmux.test.constants import ( - RETRY_INTERVAL_SECONDS, - RETRY_TIMEOUT_SECONDS, - TEST_SESSION_PREFIX, -) - -from .random import namer - -logger = logging.getLogger(__name__) - -if t.TYPE_CHECKING: - import sys - import types - from collections.abc import Callable, Generator - - from libtmux.server import Server - from libtmux.session import Session - from libtmux.window import Window - - if sys.version_info >= (3, 11): - from typing import Self - else: - from typing_extensions import Self diff --git a/src/libtmux/test/constants.py b/src/libtmux/test/constants.py index 63d644da3..7923d00ea 100644 --- a/src/libtmux/test/constants.py +++ b/src/libtmux/test/constants.py @@ -4,6 +4,15 @@ import os +#: Prefix used for test session names to identify and cleanup test sessions TEST_SESSION_PREFIX = "libtmux_" + +#: Number of seconds to wait before timing out when retrying operations +#: Can be configured via :envvar:`RETRY_TIMEOUT_SECONDS` environment variable +#: Defaults to 8 seconds RETRY_TIMEOUT_SECONDS = int(os.getenv("RETRY_TIMEOUT_SECONDS", 8)) + +#: Interval in seconds between retry attempts +#: Can be configured via :envvar:`RETRY_INTERVAL_SECONDS` environment variable +#: Defaults to 0.05 seconds (50ms) RETRY_INTERVAL_SECONDS = float(os.getenv("RETRY_INTERVAL_SECONDS", 0.05)) diff --git a/src/libtmux/test/environment.py b/src/libtmux/test/environment.py index 9023c1f83..c08ba377f 100644 --- a/src/libtmux/test/environment.py +++ b/src/libtmux/test/environment.py @@ -52,7 +52,12 @@ def set(self, envvar: str, value: str) -> None: def unset(self, envvar: str) -> None: """Unset environment variable.""" if envvar in self._environ: - self._reset[envvar] = self._environ[envvar] + # If we previously set this variable in this context, remove it from _unset + if envvar in self._unset: + self._unset.remove(envvar) + # If we haven't saved the original value yet, save it + if envvar not in self._reset: + self._reset[envvar] = self._environ[envvar] del self._environ[envvar] def __enter__(self) -> Self: @@ -69,4 +74,5 @@ def __exit__( for envvar, value in self._reset.items(): self._environ[envvar] = value for unset in self._unset: - del self._environ[unset] + if unset not in self._reset: # Don't delete variables that were reset + del self._environ[unset] diff --git a/src/libtmux/test/random.py b/src/libtmux/test/random.py index abcb95bce..7869fb328 100644 --- a/src/libtmux/test/random.py +++ b/src/libtmux/test/random.py @@ -1,35 +1,29 @@ """Random helpers for libtmux and downstream libtmux libraries.""" -from __future__ import annotations +from __future__ import annotations # pragma: no cover import logging import random -import typing as t +import typing as t # pragma: no cover from libtmux.test.constants import ( TEST_SESSION_PREFIX, ) -logger = logging.getLogger(__name__) - -if t.TYPE_CHECKING: - import sys +if t.TYPE_CHECKING: # pragma: no cover + import sys # pragma: no cover - from libtmux.server import Server - from libtmux.session import Session + from libtmux.server import Server # pragma: no cover + from libtmux.session import Session # pragma: no cover - if sys.version_info >= (3, 11): - pass + if sys.version_info >= (3, 11): # pragma: no cover + pass # pragma: no cover + else: # pragma: no cover + pass # pragma: no cover logger = logging.getLogger(__name__) -if t.TYPE_CHECKING: - import sys - - if sys.version_info >= (3, 11): - pass - class RandomStrSequence: """Factory to generate random string.""" @@ -40,12 +34,12 @@ def __init__( ) -> None: """Create a random letter / number generator. 8 chars in length. - >>> rng = RandomStrSequence() - >>> next(rng) + >>> rng = RandomStrSequence() # pragma: no cover + >>> next(rng) # pragma: no cover '...' - >>> len(next(rng)) + >>> len(next(rng)) # pragma: no cover 8 - >>> type(next(rng)) + >>> type(next(rng)) # pragma: no cover """ self.characters: str = characters @@ -81,11 +75,13 @@ def get_test_session_name(server: Server, prefix: str = TEST_SESSION_PREFIX) -> Examples -------- - >>> get_test_session_name(server=server) + >>> get_test_session_name(server=server) # pragma: no cover 'libtmux_...' Never the same twice: - >>> get_test_session_name(server=server) != get_test_session_name(server=server) + >>> name1 = get_test_session_name(server=server) # pragma: no cover + >>> name2 = get_test_session_name(server=server) # pragma: no cover + >>> name1 != name2 # pragma: no cover True """ while True: @@ -119,11 +115,13 @@ def get_test_window_name( Examples -------- - >>> get_test_window_name(session=session) + >>> get_test_window_name(session=session) # pragma: no cover 'libtmux_...' Never the same twice: - >>> get_test_window_name(session=session) != get_test_window_name(session=session) + >>> name1 = get_test_window_name(session=session) # pragma: no cover + >>> name2 = get_test_window_name(session=session) # pragma: no cover + >>> name1 != name2 # pragma: no cover True """ assert prefix is not None diff --git a/tests/test/test_constants.py b/tests/test/test_constants.py new file mode 100644 index 000000000..59e9b3d7b --- /dev/null +++ b/tests/test/test_constants.py @@ -0,0 +1,51 @@ +"""Tests for libtmux's test constants.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from libtmux.test.constants import ( + RETRY_INTERVAL_SECONDS, + RETRY_TIMEOUT_SECONDS, + TEST_SESSION_PREFIX, +) + +if TYPE_CHECKING: + import pytest + + +def test_test_session_prefix() -> None: + """Test TEST_SESSION_PREFIX is correctly defined.""" + assert TEST_SESSION_PREFIX == "libtmux_" + + +def test_retry_timeout_seconds_default() -> None: + """Test RETRY_TIMEOUT_SECONDS default value.""" + assert RETRY_TIMEOUT_SECONDS == 8 + + +def test_retry_timeout_seconds_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Test RETRY_TIMEOUT_SECONDS can be configured via environment variable.""" + monkeypatch.setenv("RETRY_TIMEOUT_SECONDS", "10") + from importlib import reload + + import libtmux.test.constants + + reload(libtmux.test.constants) + assert libtmux.test.constants.RETRY_TIMEOUT_SECONDS == 10 + + +def test_retry_interval_seconds_default() -> None: + """Test RETRY_INTERVAL_SECONDS default value.""" + assert RETRY_INTERVAL_SECONDS == 0.05 + + +def test_retry_interval_seconds_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Test RETRY_INTERVAL_SECONDS can be configured via environment variable.""" + monkeypatch.setenv("RETRY_INTERVAL_SECONDS", "0.1") + from importlib import reload + + import libtmux.test.constants + + reload(libtmux.test.constants) + assert libtmux.test.constants.RETRY_INTERVAL_SECONDS == 0.1 diff --git a/tests/test/test_environment.py b/tests/test/test_environment.py new file mode 100644 index 000000000..6c7cc83a7 --- /dev/null +++ b/tests/test/test_environment.py @@ -0,0 +1,151 @@ +"""Tests for libtmux's test environment utilities.""" + +from __future__ import annotations + +import os +import typing as t + +from libtmux.test.environment import EnvironmentVarGuard + + +def test_environment_var_guard_set() -> None: + """Test setting environment variables with EnvironmentVarGuard.""" + env = EnvironmentVarGuard() + + # Test setting a new variable + env.set("TEST_NEW_VAR", "new_value") + assert os.environ["TEST_NEW_VAR"] == "new_value" + + # Test setting an existing variable + os.environ["TEST_EXISTING_VAR"] = "original_value" + env.set("TEST_EXISTING_VAR", "new_value") + assert os.environ["TEST_EXISTING_VAR"] == "new_value" + + # Test cleanup + env.__exit__(None, None, None) + assert "TEST_NEW_VAR" not in os.environ + assert os.environ["TEST_EXISTING_VAR"] == "original_value" + + +def test_environment_var_guard_unset() -> None: + """Test unsetting environment variables with EnvironmentVarGuard.""" + env = EnvironmentVarGuard() + + # Test unsetting an existing variable + os.environ["TEST_EXISTING_VAR"] = "original_value" + env.unset("TEST_EXISTING_VAR") + assert "TEST_EXISTING_VAR" not in os.environ + + # Test unsetting a non-existent variable (should not raise) + env.unset("TEST_NON_EXISTENT_VAR") + + # Test cleanup + env.__exit__(None, None, None) + assert os.environ["TEST_EXISTING_VAR"] == "original_value" + + +def test_environment_var_guard_context_manager() -> None: + """Test using EnvironmentVarGuard as a context manager.""" + os.environ["TEST_EXISTING_VAR"] = "original_value" + + with EnvironmentVarGuard() as env: + # Set new and existing variables + env.set("TEST_NEW_VAR", "new_value") + env.set("TEST_EXISTING_VAR", "new_value") + assert os.environ["TEST_NEW_VAR"] == "new_value" + assert os.environ["TEST_EXISTING_VAR"] == "new_value" + + # Unset a variable + env.unset("TEST_EXISTING_VAR") + assert "TEST_EXISTING_VAR" not in os.environ + + # Test cleanup after context + assert "TEST_NEW_VAR" not in os.environ + assert os.environ["TEST_EXISTING_VAR"] == "original_value" + + +def test_environment_var_guard_cleanup_on_exception() -> None: + """Test that EnvironmentVarGuard cleans up even when an exception occurs.""" + os.environ["TEST_EXISTING_VAR"] = "original_value" + + def _raise_error() -> None: + raise RuntimeError + + try: + with EnvironmentVarGuard() as env: + env.set("TEST_NEW_VAR", "new_value") + env.set("TEST_EXISTING_VAR", "new_value") + _raise_error() + except RuntimeError: + pass + + # Test cleanup after exception + assert "TEST_NEW_VAR" not in os.environ + assert os.environ["TEST_EXISTING_VAR"] == "original_value" + + +def test_environment_var_guard_unset_and_reset() -> None: + """Test unsetting and then resetting a variable.""" + env = EnvironmentVarGuard() + + # Set up test variables + os.environ["TEST_VAR1"] = "value1" + os.environ["TEST_VAR2"] = "value2" + + # Unset a variable + env.unset("TEST_VAR1") + assert "TEST_VAR1" not in os.environ + + # Set it again with a different value + env.set("TEST_VAR1", "new_value1") + assert os.environ["TEST_VAR1"] == "new_value1" + + # Unset a variable that was previously set in this context + env.set("TEST_VAR2", "new_value2") + env.unset("TEST_VAR2") + assert "TEST_VAR2" not in os.environ + + # Cleanup + env.__exit__(None, None, None) + assert os.environ["TEST_VAR1"] == "value1" + assert os.environ["TEST_VAR2"] == "value2" + + +def test_environment_var_guard_exit_with_exception() -> None: + """Test __exit__ method with exception parameters.""" + env = EnvironmentVarGuard() + + # Set up test variables + os.environ["TEST_VAR"] = "original_value" + env.set("TEST_VAR", "new_value") + + # Call __exit__ with exception parameters + env.__exit__( + t.cast("type[BaseException]", RuntimeError), + RuntimeError("Test exception"), + None, + ) + + # Verify cleanup still happened + assert os.environ["TEST_VAR"] == "original_value" + + +def test_environment_var_guard_unset_previously_set() -> None: + """Test unsetting a variable that was previously set in the same context.""" + env = EnvironmentVarGuard() + + # Make sure the variable doesn't exist initially + if "TEST_NEW_VAR" in os.environ: + del os.environ["TEST_NEW_VAR"] + + # Set a new variable + env.set("TEST_NEW_VAR", "new_value") + assert "TEST_NEW_VAR" in os.environ + assert os.environ["TEST_NEW_VAR"] == "new_value" + + # Now unset it - this should hit line 57 + env.unset("TEST_NEW_VAR") + assert "TEST_NEW_VAR" not in os.environ + + # No need to check after cleanup since the variable was never in the environment + # before we started diff --git a/tests/test/test_random.py b/tests/test/test_random.py new file mode 100644 index 000000000..dd5a24421 --- /dev/null +++ b/tests/test/test_random.py @@ -0,0 +1,602 @@ +"""Tests for libtmux's random test utilities.""" + +from __future__ import annotations + +import logging +import string +import sys +import typing as t + +import pytest + +from libtmux.test.constants import TEST_SESSION_PREFIX +from libtmux.test.random import ( + RandomStrSequence, + get_test_session_name, + get_test_window_name, + logger, + namer, +) + +if t.TYPE_CHECKING: + from pytest import MonkeyPatch + + from libtmux.server import Server + from libtmux.session import Session + + +def test_logger() -> None: + """Test that the logger is properly configured.""" + assert isinstance(logger, logging.Logger) + assert logger.name == "libtmux.test.random" + + +def test_random_str_sequence_default() -> None: + """Test RandomStrSequence with default characters.""" + rng = RandomStrSequence() + result = next(rng) + + assert isinstance(result, str) + assert len(result) == 8 + assert all(c in rng.characters for c in result) + + +def test_random_str_sequence_custom_chars() -> None: + """Test RandomStrSequence with custom characters.""" + custom_chars = string.ascii_uppercase # Enough characters for sampling + rng = RandomStrSequence(characters=custom_chars) + result = next(rng) + + assert isinstance(result, str) + assert len(result) == 8 + assert all(c in custom_chars for c in result) + + +def test_random_str_sequence_uniqueness() -> None: + """Test that RandomStrSequence generates unique strings.""" + rng = RandomStrSequence() + results = [next(rng) for _ in range(100)] + + # Check uniqueness + assert len(set(results)) == len(results) + + +def test_random_str_sequence_iterator() -> None: + """Test that RandomStrSequence is a proper iterator.""" + rng = RandomStrSequence() + assert iter(rng) is rng + + +def test_random_str_sequence_doctest_examples() -> None: + """Test the doctest examples for RandomStrSequence.""" + rng = RandomStrSequence() + result1 = next(rng) + result2 = next(rng) + + assert isinstance(result1, str) + assert len(result1) == 8 + assert isinstance(result2, str) + assert len(result2) == 8 + assert isinstance(next(rng), str) + + +def test_namer_global_instance() -> None: + """Test the global namer instance.""" + # Test that namer is an instance of RandomStrSequence + assert isinstance(namer, RandomStrSequence) + + # Test that it generates valid strings + result = next(namer) + assert isinstance(result, str) + assert len(result) == 8 + assert all(c in namer.characters for c in result) + + # Test uniqueness + results = [next(namer) for _ in range(10)] + assert len(set(results)) == len(results) + + +def test_get_test_session_name_doctest_examples(server: Server) -> None: + """Test the doctest examples for get_test_session_name.""" + # Test basic functionality + result = get_test_session_name(server=server) + assert result.startswith(TEST_SESSION_PREFIX) + assert len(result) == len(TEST_SESSION_PREFIX) + 8 + + # Test uniqueness (from doctest example) + result1 = get_test_session_name(server=server) + result2 = get_test_session_name(server=server) + assert result1 != result2 + + +def test_get_test_session_name_default_prefix(server: Server) -> None: + """Test get_test_session_name with default prefix.""" + result = get_test_session_name(server=server) + + assert isinstance(result, str) + assert result.startswith(TEST_SESSION_PREFIX) + assert len(result) == len(TEST_SESSION_PREFIX) + 8 # prefix + random(8) + assert not server.has_session(result) + + +def test_get_test_session_name_custom_prefix(server: Server) -> None: + """Test get_test_session_name with custom prefix.""" + prefix = "test_" + result = get_test_session_name(server=server, prefix=prefix) + + assert isinstance(result, str) + assert result.startswith(prefix) + assert len(result) == len(prefix) + 8 # prefix + random(8) + assert not server.has_session(result) + + +def test_get_test_session_name_loop_behavior( + server: Server, +) -> None: + """Test the loop behavior in get_test_session_name using real sessions.""" + # Get a first session name + first_name = get_test_session_name(server=server) + + # Create this session to trigger the loop behavior + with server.new_session(first_name): + # Now when we call get_test_session_name again, it should + # give us a different name since the first one is taken + second_name = get_test_session_name(server=server) + + # Verify we got a different name + assert first_name != second_name + + # Verify the first name exists as a session + assert server.has_session(first_name) + + # Verify the second name doesn't exist yet + assert not server.has_session(second_name) + + # Create a second session with the second name + with server.new_session(second_name): + # Now get a third name, to trigger another iteration + third_name = get_test_session_name(server=server) + + # Verify all names are different + assert first_name != third_name + assert second_name != third_name + + # Verify the first two names exist as sessions + assert server.has_session(first_name) + assert server.has_session(second_name) + + # Verify the third name doesn't exist yet + assert not server.has_session(third_name) + + +def test_get_test_window_name_doctest_examples(session: Session) -> None: + """Test the doctest examples for get_test_window_name.""" + # Test basic functionality + result = get_test_window_name(session=session) + assert result.startswith(TEST_SESSION_PREFIX) + assert len(result) == len(TEST_SESSION_PREFIX) + 8 + + # Test uniqueness (from doctest example) + result1 = get_test_window_name(session=session) + result2 = get_test_window_name(session=session) + assert result1 != result2 + + +def test_get_test_window_name_default_prefix(session: Session) -> None: + """Test get_test_window_name with default prefix.""" + result = get_test_window_name(session=session) + + assert isinstance(result, str) + assert result.startswith(TEST_SESSION_PREFIX) + assert len(result) == len(TEST_SESSION_PREFIX) + 8 # prefix + random(8) + assert not any(w.window_name == result for w in session.windows) + + +def test_get_test_window_name_custom_prefix(session: Session) -> None: + """Test get_test_window_name with custom prefix.""" + prefix = "test_" + result = get_test_window_name(session=session, prefix=prefix) + + assert isinstance(result, str) + assert result.startswith(prefix) + assert len(result) == len(prefix) + 8 # prefix + random(8) + assert not any(w.window_name == result for w in session.windows) + + +def test_get_test_window_name_loop_behavior( + session: Session, +) -> None: + """Test the loop behavior in get_test_window_name using real windows.""" + # Get a window name first + first_name = get_test_window_name(session=session) + + # Create this window + window = session.new_window(window_name=first_name) + try: + # Now when we call get_test_window_name again, it should + # give us a different name since the first one is taken + second_name = get_test_window_name(session=session) + + # Verify we got a different name + assert first_name != second_name + + # Verify the first name exists as a window + assert any(w.window_name == first_name for w in session.windows) + + # Verify the second name doesn't exist yet + assert not any(w.window_name == second_name for w in session.windows) + + # Create a second window with the second name + window2 = session.new_window(window_name=second_name) + try: + # Now get a third name, to trigger another iteration + third_name = get_test_window_name(session=session) + + # Verify all names are different + assert first_name != third_name + assert second_name != third_name + + # Verify the first two names exist as windows + assert any(w.window_name == first_name for w in session.windows) + assert any(w.window_name == second_name for w in session.windows) + + # Verify the third name doesn't exist yet + assert not any(w.window_name == third_name for w in session.windows) + finally: + # Clean up the second window + if window2: + window2.kill() + finally: + # Clean up + if window: + window.kill() + + +def test_get_test_window_name_requires_prefix() -> None: + """Test that get_test_window_name requires a prefix.""" + with pytest.raises(AssertionError): + get_test_window_name(session=t.cast("Session", object()), prefix=None) + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Self type only available in Python 3.11+", +) +def test_random_str_sequence_self_type() -> None: + """Test that RandomStrSequence works with Self type annotation.""" + rng = RandomStrSequence() + iter_result = iter(rng) + assert isinstance(iter_result, RandomStrSequence) + assert iter_result is rng + + +def test_random_str_sequence_small_character_set() -> None: + """Test RandomStrSequence with a small character set.""" + # Using a small set forces it to use all characters + small_chars = "abcdefgh" # Exactly 8 characters + rng = RandomStrSequence(characters=small_chars) + result = next(rng) + + assert isinstance(result, str) + assert len(result) == 8 + # Since it samples exactly 8 characters, all chars must be used + assert sorted(result) == sorted(small_chars) + + +def test_random_str_sequence_insufficient_characters() -> None: + """Test RandomStrSequence with too few characters.""" + # When fewer than 8 chars are provided, random.sample can't work + tiny_chars = "abc" # Only 3 characters + rng = RandomStrSequence(characters=tiny_chars) + + # Should raise ValueError since random.sample(population, k) + # requires k <= len(population) + with pytest.raises(ValueError): + next(rng) + + +def test_logger_configured(caplog: pytest.LogCaptureFixture) -> None: + """Test that the logger in random.py is properly configured.""" + # Verify the logger is set up with the correct name + assert logger.name == "libtmux.test.random" + + # Test that the logger functions properly + with caplog.at_level(logging.DEBUG): + logger.debug("Test debug message") + logger.info("Test info message") + + assert "Test debug message" in caplog.text + assert "Test info message" in caplog.text + + +def test_next_method_directly() -> None: + """Test directly calling __next__ method on RandomStrSequence.""" + rng = RandomStrSequence() + result = next(rng) + assert isinstance(result, str) + assert len(result) == 8 + assert all(c in rng.characters for c in result) + + +def test_namer_initialization() -> None: + """Test that the namer global instance is initialized correctly.""" + # Since namer is a global instance from the random module, + # we want to ensure it's properly initialized + from libtmux.test.random import namer as direct_namer + + assert namer is direct_namer + assert isinstance(namer, RandomStrSequence) + assert namer.characters == "abcdefghijklmnopqrstuvwxyz0123456789_" + + +def test_random_str_sequence_iter_next_methods() -> None: + """Test both __iter__ and __next__ methods directly.""" + # Initialize the sequence + rng = RandomStrSequence() + + # Test __iter__ method + iter_result = iter(rng) + assert iter_result is rng + + # Test __next__ method directly multiple times + results = [] + for _ in range(5): + next_result = next(rng) + results.append(next_result) + assert isinstance(next_result, str) + assert len(next_result) == 8 + assert all(c in rng.characters for c in next_result) + + # Verify all results are unique + assert len(set(results)) == len(results) + + +def test_collisions_with_real_objects( + server: Server, + session: Session, +) -> None: + """Test collision behavior using real tmux objects instead of mocks. + + This test replaces multiple monkeypatched tests: + - test_get_test_session_name_collision + - test_get_test_session_name_multiple_collisions + - test_get_test_window_name_collision + - test_get_test_window_name_multiple_collisions + + Instead of mocking the random generator, we create real sessions and + windows with predictable names and verify the uniqueness logic. + """ + # Test session name collisions + # ---------------------------- + # Create a known prefix for testing + prefix = "test_collision_" + + # Create several sessions with predictable names + session_name1 = prefix + "session1" + session_name2 = prefix + "session2" + + # Create a couple of actual sessions to force collisions + with server.new_session(session_name1), server.new_session(session_name2): + # Verify our sessions exist + assert server.has_session(session_name1) + assert server.has_session(session_name2) + + # When requesting a session name with same prefix, we should get a unique name + # that doesn't match either existing session + result = get_test_session_name(server=server, prefix=prefix) + assert result.startswith(prefix) + assert result != session_name1 + assert result != session_name2 + assert not server.has_session(result) + + # Test window name collisions + # -------------------------- + # Create windows with predictable names + window_name1 = prefix + "window1" + window_name2 = prefix + "window2" + + # Create actual windows to force collisions + window1 = session.new_window(window_name=window_name1) + window2 = session.new_window(window_name=window_name2) + + try: + # Verify our windows exist + assert any(w.window_name == window_name1 for w in session.windows) + assert any(w.window_name == window_name2 for w in session.windows) + + # When requesting a window name with same prefix, we should get a unique name + # that doesn't match either existing window + result = get_test_window_name(session=session, prefix=prefix) + assert result.startswith(prefix) + assert result != window_name1 + assert result != window_name2 + assert not any(w.window_name == result for w in session.windows) + finally: + # Clean up the windows we created + if window1: + window1.kill() + if window2: + window2.kill() + + +def test_imports_coverage() -> None: + """Test coverage for import statements in random.py.""" + # This test simply ensures the imports are covered + from libtmux.test import random + + assert hasattr(random, "logging") + assert hasattr(random, "random") + assert hasattr(random, "t") + assert hasattr(random, "TEST_SESSION_PREFIX") + + +def test_iterator_protocol() -> None: + """Test the complete iterator protocol of RandomStrSequence.""" + # Test the __iter__ method explicitly + rng = RandomStrSequence() + iterator = iter(rng) + + # Verify __iter__ returns self + assert iterator is rng + + # Verify __next__ method works after explicit __iter__ call + result = next(iterator) + assert isinstance(result, str) + assert len(result) == 8 + + +def test_get_test_session_name_collision_handling( + server: Server, + monkeypatch: MonkeyPatch, +) -> None: + """Test that get_test_session_name handles collisions properly.""" + # Mock server.has_session to first return True (collision) then False + call_count = 0 + + def mock_has_session(name: str) -> bool: + nonlocal call_count + call_count += 1 + # First call returns True (collision), second call returns False + return call_count == 1 + + # Mock the server.has_session method + monkeypatch.setattr(server, "has_session", mock_has_session) + + # Should break out of the loop on the second iteration + session_name = get_test_session_name(server) + + # Verify the method was called twice due to the collision + assert call_count == 2 + assert session_name.startswith(TEST_SESSION_PREFIX) + + +def test_get_test_window_name_null_prefix() -> None: + """Test that get_test_window_name with None prefix raises an assertion error.""" + # Create a mock session + mock_session = t.cast("Session", object()) + + # Verify that None prefix raises an assertion error + with pytest.raises(AssertionError): + get_test_window_name(mock_session, prefix=None) + + +def test_import_typing_coverage() -> None: + """Test coverage for typing imports in random.py.""" + # This test covers the TYPE_CHECKING imports + import typing as t + + # Import directly from the module to cover lines + from libtmux.test import random + + # Verify the t.TYPE_CHECKING attribute exists + assert hasattr(t, "TYPE_CHECKING") + + # Check for the typing module import + assert "t" in dir(random) + + +def test_random_str_sequence_direct_instantiation() -> None: + """Test direct instantiation of RandomStrSequence class.""" + # This covers lines in the class definition and __init__ method + rng = RandomStrSequence() + + # Check attributes + assert hasattr(rng, "characters") + assert rng.characters == "abcdefghijklmnopqrstuvwxyz0123456789_" + + # Check methods + assert hasattr(rng, "__iter__") + assert hasattr(rng, "__next__") + + +def test_get_test_window_name_collision_handling( + session: Session, + monkeypatch: MonkeyPatch, +) -> None: + """Test that get_test_window_name handles collisions properly.""" + # Create a specific prefix for this test + prefix = "collision_test_" + + # Generate a random window name with our prefix + first_name = prefix + next(namer) + + # Create a real window with this name to force a collision + window = session.new_window(window_name=first_name) + try: + # Now when we call get_test_window_name, it should generate a different name + window_name = get_test_window_name(session, prefix=prefix) + + # Verify we got a different name + assert window_name != first_name + assert window_name.startswith(prefix) + + # Verify the function worked around the collision properly + assert not any(w.window_name == window_name for w in session.windows) + assert any(w.window_name == first_name for w in session.windows) + finally: + # Clean up the window we created + if window: + window.kill() + + +def test_random_str_sequence_return_statements() -> None: + """Test the return statements in RandomStrSequence methods.""" + # Test __iter__ return statement (Line 47) + rng = RandomStrSequence() + iter_result = iter(rng) + assert iter_result is rng # Verify it returns self + + # Test __next__ return statement (Line 51) + next_result = next(rng) + assert isinstance(next_result, str) + assert len(next_result) == 8 + + +def test_get_test_session_name_implementation_details( + server: Server, + monkeypatch: MonkeyPatch, +) -> None: + """Test specific implementation details of get_test_session_name function.""" + # Create a session with a name that will cause a collision + # This will test the while loop behavior (Lines 56-59) + prefix = "collision_prefix_" + first_random = next(namer) + + # Create a session that will match our first attempt inside get_test_session_name + collision_name = prefix + first_random + + # Create a real session to force a collision + with server.new_session(collision_name): + # Now when we call get_test_session_name, it will need to try again + # since the first attempt will collide with our created session + result = get_test_session_name(server, prefix=prefix) + + # Verify collision handling + assert result != collision_name + assert result.startswith(prefix) + + +def test_get_test_window_name_branch_coverage(session: Session) -> None: + """Test branch coverage for get_test_window_name function.""" + # This tests the branch condition on line 130->128 + + # Create a window with a name that will cause a collision + prefix = "branch_test_" + first_random = next(namer) + collision_name = prefix + first_random + + # Create a real window with this name + window = session.new_window(window_name=collision_name) + + try: + # Call function that should handle the collision + result = get_test_window_name(session, prefix=prefix) + + # Verify collision handling behavior + assert result != collision_name + assert result.startswith(prefix) + + finally: + # Clean up the window + if window: + window.kill() diff --git a/tests/test/test_retry.py b/tests/test/test_retry.py index 36e35930d..ca09d8b4f 100644 --- a/tests/test/test_retry.py +++ b/tests/test/test_retry.py @@ -2,7 +2,7 @@ from __future__ import annotations -from time import time +from time import sleep, time import pytest @@ -17,19 +17,19 @@ def test_retry_three_times() -> None: def call_me_three_times() -> bool: nonlocal value + sleep(0.3) # Sleep for 0.3 seconds to simulate work if value == 2: return True value += 1 - return False retry_until(call_me_three_times, 1) end = time() - assert abs((end - ini) - 1.0) > 0 < 0.1 + assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations def test_function_times_out() -> None: @@ -37,6 +37,9 @@ def test_function_times_out() -> None: ini = time() def never_true() -> bool: + sleep( + 0.1, + ) # Sleep for 0.1 seconds to simulate work (called ~10 times in 1 second) return False with pytest.raises(WaitTimeout): @@ -44,7 +47,7 @@ def never_true() -> bool: end = time() - assert abs((end - ini) - 1.0) > 0 < 0.1 + assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations def test_function_times_out_no_raise() -> None: @@ -52,13 +55,15 @@ def test_function_times_out_no_raise() -> None: ini = time() def never_true() -> bool: + sleep( + 0.1, + ) # Sleep for 0.1 seconds to simulate work (called ~10 times in 1 second) return False retry_until(never_true, 1, raises=False) end = time() - - assert abs((end - ini) - 1.0) > 0 < 0.1 + assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations def test_function_times_out_no_raise_assert() -> None: @@ -66,13 +71,15 @@ def test_function_times_out_no_raise_assert() -> None: ini = time() def never_true() -> bool: + sleep( + 0.1, + ) # Sleep for 0.1 seconds to simulate work (called ~10 times in 1 second) return False assert not retry_until(never_true, 1, raises=False) end = time() - - assert abs((end - ini) - 1.0) > 0 < 0.1 + assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations def test_retry_three_times_no_raise_assert() -> None: @@ -82,16 +89,17 @@ def test_retry_three_times_no_raise_assert() -> None: def call_me_three_times() -> bool: nonlocal value + sleep( + 0.3, + ) # Sleep for 0.3 seconds to simulate work (called 3 times in ~0.9 seconds) if value == 2: return True value += 1 - return False assert retry_until(call_me_three_times, 1, raises=False) end = time() - - assert abs((end - ini) - 1.0) > 0 < 0.1 + assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations diff --git a/tests/test/test_temporary.py b/tests/test/test_temporary.py new file mode 100644 index 000000000..d0cca352a --- /dev/null +++ b/tests/test/test_temporary.py @@ -0,0 +1,136 @@ +"""Tests for libtmux's temporary test utilities.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.test.temporary import temp_session, temp_window + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session + + +def test_temp_session_creates_and_destroys(server: Server) -> None: + """Test that temp_session creates and destroys a session.""" + with temp_session(server) as session: + session_name = session.session_name + assert session_name is not None + assert server.has_session(session_name) + + assert session_name is not None + assert not server.has_session(session_name) + + +def test_temp_session_with_name(server: Server) -> None: + """Test temp_session with a provided session name.""" + session_name = "test_session" + with temp_session(server, session_name=session_name) as session: + assert session.session_name == session_name + assert server.has_session(session_name) + + assert not server.has_session(session_name) + + +def test_temp_session_cleanup_on_exception(server: Server) -> None: + """Test that temp_session cleans up even when an exception occurs.""" + test_error = RuntimeError() + session_name = None + + with pytest.raises(RuntimeError), temp_session(server) as session: + session_name = session.session_name + assert session_name is not None + assert server.has_session(session_name) + raise test_error + + assert session_name is not None + assert not server.has_session(session_name) + + +def test_temp_window_creates_and_destroys(session: Session) -> None: + """Test that temp_window creates and destroys a window.""" + initial_windows = len(session.windows) + + with temp_window(session) as window: + window_id = window.window_id + assert window_id is not None + assert len(session.windows) == initial_windows + 1 + assert any(w.window_id == window_id for w in session.windows) + + assert len(session.windows) == initial_windows + assert window_id is not None + assert not any(w.window_id == window_id for w in session.windows) + + +def test_temp_window_with_name(session: Session) -> None: + """Test temp_window with a provided window name.""" + window_name = "test_window" + initial_windows = len(session.windows) + + with temp_window(session, window_name=window_name) as window: + assert window.window_name == window_name + assert len(session.windows) == initial_windows + 1 + assert any(w.window_name == window_name for w in session.windows) + + assert len(session.windows) == initial_windows + assert not any(w.window_name == window_name for w in session.windows) + + +def test_temp_window_cleanup_on_exception(session: Session) -> None: + """Test that temp_window cleans up even when an exception occurs.""" + initial_windows = len(session.windows) + test_error = RuntimeError() + window_id = None + + with pytest.raises(RuntimeError), temp_window(session) as window: + window_id = window.window_id + assert window_id is not None + assert len(session.windows) == initial_windows + 1 + assert any(w.window_id == window_id for w in session.windows) + raise test_error + + assert len(session.windows) == initial_windows + assert window_id is not None + assert not any(w.window_id == window_id for w in session.windows) + + +def test_temp_session_outside_context(server: Server) -> None: + """Test that temp_session's finally block handles a session already killed.""" + session_name = None + + with temp_session(server) as session: + session_name = session.session_name + assert session_name is not None + assert server.has_session(session_name) + + # Kill the session while inside the context + session.kill() + assert not server.has_session(session_name) + + # The temp_session's finally block should handle gracefully + # that the session is already gone + assert session_name is not None + assert not server.has_session(session_name) + + +def test_temp_window_outside_context(session: Session) -> None: + """Test that temp_window's finally block handles a window already killed.""" + initial_windows = len(session.windows) + window_id = None + + with temp_window(session) as window: + window_id = window.window_id + assert window_id is not None + assert len(session.windows) == initial_windows + 1 + + # Kill the window inside the context + window.kill() + assert len(session.windows) == initial_windows + + # The temp_window's finally block should handle gracefully + # that the window is already gone + assert window_id is not None + assert len(session.windows) == initial_windows + assert not any(w.window_id == window_id for w in session.windows) diff --git a/uv.lock b/uv.lock index eb2387c12..d560e4af4 100644 --- a/uv.lock +++ b/uv.lock @@ -381,7 +381,7 @@ wheels = [ [[package]] name = "libtmux" -version = "0.45.0" +version = "0.46.0" source = { editable = "." } [package.dev-dependencies]