diff --git a/changelog.md b/changelog.md index a13b6044..7e2291ad 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,11 @@ +Upcoming (TBD) +============== + +Features +--------- +* Set minimum times for transient toolbar messages. + + 2.2.0 (2026/07/11) ============== diff --git a/mycli/client.py b/mycli/client.py index e5430270..a082d8b8 100644 --- a/mycli/client.py +++ b/mycli/client.py @@ -155,7 +155,7 @@ def __init__( self.echo("Error: Unable to open the audit log file. Your queries will not be logged.", err=True, fg="red") self.logfile = False - self.completion_refresher = CompletionRefresher() + self.completion_refresher = CompletionRefresher(self._invalidate_prompt_session) self.prefetch_schemas_mode = c["main"].get("prefetch_schemas_mode", "always") or "always" raw_prefetch_list = c["main"].as_list("prefetch_schemas_list") if "prefetch_schemas_list" in c["main"] else [] self.prefetch_schemas_list = [s.strip() for s in raw_prefetch_list if s and s.strip()] @@ -201,6 +201,10 @@ def __init__( self.destructive_keywords = destructive_keywords_from_config(c) special.set_destructive_keywords(self.destructive_keywords) + def _invalidate_prompt_session(self) -> None: + if self.prompt_session: + self.prompt_session.app.invalidate() + def close(self) -> None: try: self.schema_prefetcher.stop() diff --git a/mycli/completion_refresher.py b/mycli/completion_refresher.py index cba79bcc..914a8709 100644 --- a/mycli/completion_refresher.py +++ b/mycli/completion_refresher.py @@ -1,4 +1,5 @@ import threading +from time import monotonic from typing import Callable import pymysql @@ -8,13 +9,18 @@ from mycli.sqlcompleter import SQLCompleter from mycli.sqlexecute import ServerSpecies, SQLExecute +MIN_COMPLETION_REFRESH_MESSAGE_SECONDS = 1.0 + class CompletionRefresher: refreshers: dict = {} - def __init__(self) -> None: + def __init__(self, invalidate_app: Callable[[], None] | None = None) -> None: self._completer_thread: threading.Thread | None = None self._restart_refresh = threading.Event() + self._refresh_visible_until = 0.0 + self._visibility_timer: threading.Timer | None = None + self._invalidate_app = invalidate_app def refresh( self, @@ -36,10 +42,14 @@ def refresh( if completer_options is None: completer_options = {} - if self.is_refreshing(): + if self._thread_is_alive(): self._restart_refresh.set() return [SQLResult(status="Auto-completion refresh restarted.")] else: + if self._visibility_timer is not None: + self._visibility_timer.cancel() + self._visibility_timer = None + self._refresh_visible_until = monotonic() + MIN_COMPLETION_REFRESH_MESSAGE_SECONDS self._completer_thread = threading.Thread( target=self._bg_refresh, args=(executor, callbacks, completer_options), name="completion_refresh" ) @@ -48,6 +58,9 @@ def refresh( return [SQLResult(status="Auto-completion refresh started in the background.")] def is_refreshing(self) -> bool: + return self._thread_is_alive() or monotonic() < self._refresh_visible_until + + def _thread_is_alive(self) -> bool: return bool(self._completer_thread and self._completer_thread.is_alive()) def _bg_refresh( @@ -73,31 +86,53 @@ def _bg_refresh( e.ssl, ) except pymysql.err.OperationalError: + self._finish_refreshing() return - # If callbacks is a single function then push it into a list. - if callable(callbacks): - callbacks = [callbacks] - - while 1: - for refresher in self.refreshers.values(): - refresher(completer, executor) - if self._restart_refresh.is_set(): - self._restart_refresh.clear() + try: + # If callbacks is a single function then push it into a list. + if callable(callbacks): + callbacks = [callbacks] + + while 1: + for refresher in self.refreshers.values(): + refresher(completer, executor) + if self._restart_refresh.is_set(): + self._restart_refresh.clear() + break + else: + # Break out of while loop if the for loop finishes natually + # without hitting the break statement. break - else: - # Break out of while loop if the for loop finishes natually - # without hitting the break statement. - break - # Start over the refresh from the beginning if the for loop hit the - # break statement. - continue + # Start over the refresh from the beginning if the for loop hit the + # break statement. + continue - for callback in callbacks: - callback(completer) + for callback in callbacks: + callback(completer) + finally: + executor.close() + self._finish_refreshing() - executor.close() + def _finish_refreshing(self) -> None: + self._invalidate() + remaining = self._refresh_visible_until - monotonic() + if remaining <= 0: + return + if self._visibility_timer is not None: + self._visibility_timer.cancel() + self._visibility_timer = threading.Timer(remaining, self._invalidate_after_visibility_deadline) + self._visibility_timer.daemon = True + self._visibility_timer.start() + + def _invalidate_after_visibility_deadline(self) -> None: + self._visibility_timer = None + self._invalidate() + + def _invalidate(self) -> None: + if self._invalidate_app is not None: + self._invalidate_app() def refresher(name: str, refreshers: dict = CompletionRefresher.refreshers) -> Callable: diff --git a/mycli/schema_prefetcher.py b/mycli/schema_prefetcher.py index 7b7e4c77..aedcc69e 100644 --- a/mycli/schema_prefetcher.py +++ b/mycli/schema_prefetcher.py @@ -12,6 +12,7 @@ from enum import Enum import logging import threading +from time import monotonic from typing import TYPE_CHECKING, Any, Iterable from mycli.sqlexecute import SQLExecute @@ -21,6 +22,7 @@ from mycli.sqlcompleter import SQLCompleter _logger = logging.getLogger(__name__) +MIN_PREFETCH_MESSAGE_SECONDS = 1.0 class PrefetchMode(str, Enum): @@ -56,9 +58,11 @@ def __init__(self, mycli: 'MyCli') -> None: self._thread: threading.Thread | None = None self._cancel = threading.Event() self._loaded: set[str] = set() + self._prefetch_visible_until = 0.0 + self._visibility_timer: threading.Timer | None = None def is_prefetching(self) -> bool: - return bool(self._thread and self._thread.is_alive()) + return bool(self._thread and self._thread.is_alive()) or monotonic() < self._prefetch_visible_until def clear_loaded(self) -> None: """Forget which schemas have been prefetched (used on reset).""" @@ -69,6 +73,10 @@ def stop(self, timeout: float = 2.0) -> None: if self._thread and self._thread.is_alive(): self._cancel.set() self._thread.join(timeout=timeout) + self._prefetch_visible_until = 0.0 + if self._visibility_timer is not None: + self._visibility_timer.cancel() + self._visibility_timer = None self._cancel = threading.Event() self._thread = None @@ -105,6 +113,7 @@ def _start(self, schemas: Iterable[str] | None) -> None: self.stop() queue: list[str] | None = None if schemas is None else list(schemas) self._cancel = threading.Event() + self._prefetch_visible_until = monotonic() + MIN_PREFETCH_MESSAGE_SECONDS self._thread = threading.Thread( target=self._run, args=(queue,), @@ -120,7 +129,7 @@ def _run(self, schemas: list[str] | None) -> None: executor = self._make_executor() except Exception as e: # pragma: no cover - defensive _logger.error('schema prefetch could not open connection: %r', e) - self._invalidate_app() + self._finish_prefetching() return try: if schemas is None: @@ -145,7 +154,22 @@ def _run(self, schemas: list[str] | None) -> None: executor.close() except Exception: # pragma: no cover - defensive pass - self._invalidate_app() + self._finish_prefetching() + + def _finish_prefetching(self) -> None: + self._invalidate_app() + remaining = self._prefetch_visible_until - monotonic() + if remaining <= 0: + return + if self._visibility_timer is not None: + self._visibility_timer.cancel() + self._visibility_timer = threading.Timer(remaining, self._invalidate_after_visibility_deadline) + self._visibility_timer.daemon = True + self._visibility_timer.start() + + def _invalidate_after_visibility_deadline(self) -> None: + self._visibility_timer = None + self._invalidate_app() def _prefetch_one(self, executor: SQLExecute, schema: str) -> None: _logger.debug('prefetching schema %r', schema) diff --git a/test/pytests/test_client.py b/test/pytests/test_client.py index eec4b225..fdda21da 100644 --- a/test/pytests/test_client.py +++ b/test/pytests/test_client.py @@ -158,6 +158,16 @@ def fail() -> None: MyCli.close(cli) +def test_invalidate_prompt_session_invalidates_prompt_app() -> None: + cli = MyCli.__new__(MyCli) + invalidate_calls: list[bool] = [] + cast(Any, cli).prompt_session = SimpleNamespace(app=SimpleNamespace(invalidate=lambda: invalidate_calls.append(True))) + + MyCli._invalidate_prompt_session(cli) + + assert invalidate_calls == [True] + + def test_run_cli_delegates_to_main_repl(monkeypatch: pytest.MonkeyPatch) -> None: cli = MyCli.__new__(MyCli) calls: list[MyCli] = [] diff --git a/test/pytests/test_completion_refresher.py b/test/pytests/test_completion_refresher.py index 53859325..53b46179 100644 --- a/test/pytests/test_completion_refresher.py +++ b/test/pytests/test_completion_refresher.py @@ -164,7 +164,8 @@ def fake_bg_refresh(executor, callbacks, options) -> None: refresher._completer_thread.run_target() assert calls == [(sqlexecute, callbacks, {})] - assert refresher.is_refreshing() is False + assert refresher._thread_is_alive() is False + assert refresher.is_refreshing() is True def test_refresh_passes_explicit_completer_options(monkeypatch, refresher) -> None: @@ -205,6 +206,137 @@ def fail_thread(*, target, args, name): assert thread_calls == [] +def test_refresh_starts_new_thread_during_visibility_window(monkeypatch, refresher) -> None: + thread_calls: list[tuple[object, object, str]] = [] + monkeypatch.setattr(completion_refresher, 'monotonic', lambda: 10.5) + + def make_thread(*, target, args, name): + thread_calls.append((target, args, name)) + return FakeThread(target, args, name) + + monkeypatch.setattr(completion_refresher.threading, 'Thread', make_thread) + refresher._refresh_visible_until = 11.0 + + actual = refresher.refresh(Mock(), Mock()) + + assert actual[0].status == "Auto-completion refresh started in the background." + assert len(thread_calls) == 1 + + +def test_refresh_sets_one_second_visibility_deadline(monkeypatch, refresher) -> None: + monkeypatch.setattr(completion_refresher, 'monotonic', lambda: 10.0) + monkeypatch.setattr(completion_refresher.threading, 'Thread', FakeThread) + + refresher.refresh(Mock(), Mock()) + + assert refresher._refresh_visible_until == 11.0 + + +def test_is_refreshing_remains_true_until_visibility_deadline(monkeypatch, refresher) -> None: + now = 10.0 + monkeypatch.setattr(completion_refresher, 'monotonic', lambda: now) + refresher._refresh_visible_until = 11.0 + + assert refresher.is_refreshing() is True + + now = 11.0 + + assert refresher.is_refreshing() is False + + +def test_refresh_cancels_pending_visibility_timer(monkeypatch, refresher) -> None: + timer = Mock() + refresher._visibility_timer = timer + monkeypatch.setattr(completion_refresher.threading, 'Thread', FakeThread) + + refresher.refresh(Mock(), Mock()) + + timer.cancel.assert_called_once_with() + assert refresher._visibility_timer is None + + +def test_finish_refreshing_schedules_delayed_invalidation_before_deadline(monkeypatch, refresher) -> None: + now = 10.0 + monkeypatch.setattr(completion_refresher, 'monotonic', lambda: now) + invalidate = Mock() + refresher._invalidate_app = invalidate + refresher._refresh_visible_until = 11.0 + timers: list = [] + + class FakeTimer: + def __init__(self, delay: float, callback) -> None: + self.delay = delay + self.callback = callback + self.daemon = False + self.started = False + timers.append(self) + + def start(self) -> None: + self.started = True + + def cancel(self) -> None: + pass + + monkeypatch.setattr(completion_refresher.threading, 'Timer', FakeTimer) + + refresher._finish_refreshing() + + invalidate.assert_called_once_with() + assert len(timers) == 1 + assert timers[0].delay == 1.0 + assert timers[0].daemon is True + assert timers[0].started is True + + timers[0].callback() + + assert refresher._visibility_timer is None + assert invalidate.call_count == 2 + + +def test_finish_refreshing_cancels_existing_visibility_timer(monkeypatch, refresher) -> None: + monkeypatch.setattr(completion_refresher, 'monotonic', lambda: 10.0) + old_timer = Mock() + new_timers: list = [] + refresher._visibility_timer = old_timer + refresher._refresh_visible_until = 11.0 + + class FakeTimer: + def __init__(self, delay: float, callback) -> None: + self.delay = delay + self.callback = callback + self.daemon = False + self.started = False + new_timers.append(self) + + def start(self) -> None: + self.started = True + + def cancel(self) -> None: + pass + + monkeypatch.setattr(completion_refresher.threading, 'Timer', FakeTimer) + + refresher._finish_refreshing() + + old_timer.cancel.assert_called_once_with() + assert len(new_timers) == 1 + assert refresher._visibility_timer is new_timers[0] + + +def test_finish_refreshing_does_not_schedule_after_deadline(monkeypatch, refresher) -> None: + monkeypatch.setattr(completion_refresher, 'monotonic', lambda: 11.0) + invalidate = Mock() + make_timer = Mock() + refresher._invalidate_app = invalidate + refresher._refresh_visible_until = 10.0 + monkeypatch.setattr(completion_refresher.threading, 'Timer', make_timer) + + refresher._finish_refreshing() + + invalidate.assert_called_once_with() + make_timer.assert_not_called() + + def test_bg_refresh_restarts_wraps_callbacks_and_closes(monkeypatch, refresher) -> None: completers: list[SimpleNamespace] = [] executor_inits: list[tuple[object, ...]] = [] diff --git a/test/pytests/test_schema_prefetcher.py b/test/pytests/test_schema_prefetcher.py index 9ea81a8a..c6ee5c91 100644 --- a/test/pytests/test_schema_prefetcher.py +++ b/test/pytests/test_schema_prefetcher.py @@ -228,6 +228,20 @@ def is_alive(self) -> bool: assert prefetcher._loaded == set() +def test_is_prefetching_remains_true_until_visibility_deadline(monkeypatch) -> None: + now = 10.0 + monkeypatch.setattr(schema_prefetcher_module, 'monotonic', lambda: now) + mycli = make_mycli() + prefetcher = SchemaPrefetcher(mycli) + prefetcher._prefetch_visible_until = 11.0 + + assert prefetcher.is_prefetching() is True + + now = 11.0 + + assert prefetcher.is_prefetching() is False + + def test_stop_joins_alive_thread_and_resets_state() -> None: mycli = make_mycli() prefetcher = SchemaPrefetcher(mycli) @@ -254,6 +268,20 @@ def join(self, timeout: float) -> None: assert prefetcher._cancel is not old_cancel +def test_stop_clears_visibility_deadline_and_cancels_timer() -> None: + mycli = make_mycli() + prefetcher = SchemaPrefetcher(mycli) + timer = MagicMock() + prefetcher._prefetch_visible_until = 10.0 + prefetcher._visibility_timer = timer + + prefetcher.stop() + + assert prefetcher._prefetch_visible_until == 0.0 + assert prefetcher._visibility_timer is None + timer.cancel.assert_called_once_with() + + def test_prefetch_schema_now_ignores_empty_schema(monkeypatch) -> None: mycli = make_mycli() prefetcher = SchemaPrefetcher(mycli) @@ -268,6 +296,118 @@ def test_prefetch_schema_now_ignores_empty_schema(monkeypatch) -> None: start.assert_not_called() +def test_start_sets_one_second_visibility_deadline(monkeypatch) -> None: + monkeypatch.setattr(schema_prefetcher_module, 'monotonic', lambda: 10.0) + mycli = make_mycli() + prefetcher = SchemaPrefetcher(mycli) + + class FakeThread: + def __init__(self, **_kwargs) -> None: + self.daemon = False + + def start(self) -> None: + pass + + def is_alive(self) -> bool: + return False + + monkeypatch.setattr(schema_prefetcher_module.threading, 'Thread', FakeThread) + + prefetcher._start(['schema1']) + + assert prefetcher._prefetch_visible_until == 11.0 + + +def test_finish_prefetching_schedules_delayed_invalidation_before_deadline(monkeypatch) -> None: + now = 10.0 + monkeypatch.setattr(schema_prefetcher_module, 'monotonic', lambda: now) + mycli = make_mycli() + prefetcher = SchemaPrefetcher(mycli) + invalidate = MagicMock() + monkeypatch.setattr(prefetcher, '_invalidate_app', invalidate) + prefetcher._prefetch_visible_until = 11.0 + timers: list = [] + + class FakeTimer: + def __init__(self, delay: float, callback) -> None: + self.delay = delay + self.callback = callback + self.daemon = False + self.started = False + self.cancelled = False + timers.append(self) + + def start(self) -> None: + self.started = True + + def cancel(self) -> None: + self.cancelled = True + + monkeypatch.setattr(schema_prefetcher_module.threading, 'Timer', FakeTimer) + + prefetcher._finish_prefetching() + + invalidate.assert_called_once_with() + assert len(timers) == 1 + assert timers[0].delay == 1.0 + assert timers[0].daemon is True + assert timers[0].started is True + + timers[0].callback() + + assert prefetcher._visibility_timer is None + assert invalidate.call_count == 2 + + +def test_finish_prefetching_cancels_existing_visibility_timer(monkeypatch) -> None: + monkeypatch.setattr(schema_prefetcher_module, 'monotonic', lambda: 10.0) + mycli = make_mycli() + prefetcher = SchemaPrefetcher(mycli) + old_timer = MagicMock() + new_timers: list = [] + prefetcher._visibility_timer = old_timer + prefetcher._prefetch_visible_until = 11.0 + + class FakeTimer: + def __init__(self, delay: float, callback) -> None: + self.delay = delay + self.callback = callback + self.daemon = False + self.started = False + new_timers.append(self) + + def start(self) -> None: + self.started = True + + def cancel(self) -> None: + pass + + monkeypatch.setattr(schema_prefetcher_module.threading, 'Timer', FakeTimer) + + prefetcher._finish_prefetching() + + old_timer.cancel.assert_called_once_with() + assert len(new_timers) == 1 + assert prefetcher._visibility_timer is new_timers[0] + + +def test_finish_prefetching_does_not_schedule_after_deadline(monkeypatch) -> None: + now = 11.0 + monkeypatch.setattr(schema_prefetcher_module, 'monotonic', lambda: now) + mycli = make_mycli() + prefetcher = SchemaPrefetcher(mycli) + invalidate = MagicMock() + monkeypatch.setattr(prefetcher, '_invalidate_app', invalidate) + make_timer = MagicMock() + monkeypatch.setattr(schema_prefetcher_module.threading, 'Timer', make_timer) + prefetcher._prefetch_visible_until = 10.0 + + prefetcher._finish_prefetching() + + invalidate.assert_called_once_with() + make_timer.assert_not_called() + + def test_run_returns_when_database_listing_fails(monkeypatch) -> None: mycli = make_mycli() prefetcher = SchemaPrefetcher(mycli)