diff --git a/changelog.md b/changelog.md index 54bb2c15..a47fedfc 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ Features -------- * Remove support for Python 3.10. * Exit faster when using a Boundary tunnel. +* Add C-o u keybindings to insert literal Unix timestamps. Documentation diff --git a/doc/key_bindings.md b/doc/key_bindings.md index bec58a0e..fc353b33 100644 --- a/doc/key_bindings.md +++ b/doc/key_bindings.md @@ -65,3 +65,11 @@ Insert the current datetime at cursor. ## C-o C-t _(Emacs-mode)_ Insert the quoted current datetime at cursor. + +## C-o u _(Emacs-mode)_ + +Insert the current literal `UNIX_TIMESTAMP()` at cursor, in seconds. + +## C-o C-u _(Emacs-mode)_ + +Insert the current literal `UNIX_TIMESTAMP()` at cursor, in microseconds. diff --git a/mycli/TIPS b/mycli/TIPS index 39d5c1ab..9fcdfcee 100644 --- a/mycli/TIPS +++ b/mycli/TIPS @@ -194,6 +194,8 @@ insert the current datetime using keystrokes control-o + t! insert the quoted current date using keystrokes control-o + control-t! +insert the literal UNIX_TIMESTAMP() with keystroke control-o + u! + search query history using keystroke control-r! use keystroke control-g to cancel completion popups! diff --git a/mycli/key_bindings.py b/mycli/key_bindings.py index 70038e43..4f325655 100644 --- a/mycli/key_bindings.py +++ b/mycli/key_bindings.py @@ -269,6 +269,24 @@ def _(event: KeyPressEvent) -> None: event.app.current_buffer.insert_text(key_binding_utils.server_datetime(mycli.sqlexecute, quoted=True)) + @kb.add('c-o', 'u', filter=emacs_mode) + def _(event: KeyPressEvent) -> None: + """ + Insert the current unix timestamp in seconds. + """ + _logger.debug('Detected key.') + + event.app.current_buffer.insert_text(key_binding_utils.unix_timestamp()) + + @kb.add('c-o', 'c-u', filter=emacs_mode) + def _(event: KeyPressEvent) -> None: + """ + Insert the current unix timestamp in microseconds. + """ + _logger.debug('Detected key.') + + event.app.current_buffer.insert_text(key_binding_utils.unix_timestamp(microseconds=True)) + @kb.add("c-r", filter=control_is_searchable) def _(event: KeyPressEvent) -> None: """Search history using fzf or reverse incremental search.""" diff --git a/mycli/packages/key_binding_utils.py b/mycli/packages/key_binding_utils.py index e77efee6..8ad176d2 100644 --- a/mycli/packages/key_binding_utils.py +++ b/mycli/packages/key_binding_utils.py @@ -1,5 +1,7 @@ from __future__ import annotations +import math +import time from typing import TYPE_CHECKING, Callable from prompt_toolkit.shortcuts import PromptSession @@ -28,6 +30,13 @@ def server_datetime(sqlexecute: SQLExecute, quoted: bool = False) -> str: return server_datetime_str +def unix_timestamp(microseconds: bool = False) -> str: + if microseconds: + return str(math.floor(time.time() * 1e6)) + else: + return str(math.floor(time.time())) + + # todo: maybe these handlers belong in a repl_handlers.py (which does not exist yet) # \clip doesn't even have a keybinding def handle_clip_command(mycli: 'MyCli', text: str) -> bool: diff --git a/test/pytests/test_key_binding_utils.py b/test/pytests/test_key_binding_utils.py index bbb3d619..9f08e955 100644 --- a/test/pytests/test_key_binding_utils.py +++ b/test/pytests/test_key_binding_utils.py @@ -60,6 +60,39 @@ def test_server_datetime_returns_quoted_and_unquoted_values() -> None: assert key_binding_utils.server_datetime(cast(Any, sqlexecute), quoted=True) == "'2026-04-03 14:05:06'" +@pytest.mark.parametrize( + ('timestamp', 'expected'), + [(0.0, '0'), (1700000000.875, '1700000000'), (-0.125, '-1')], +) +def test_unix_timestamp_returns_seconds( + monkeypatch: pytest.MonkeyPatch, + timestamp: float, + expected: str, +) -> None: + monkeypatch.setattr(key_binding_utils.time, 'time', lambda: timestamp) + + assert key_binding_utils.unix_timestamp() == expected + + +@pytest.mark.parametrize( + ('timestamp', 'expected'), + [ + (0.0, '0'), + (1700000000.875, '1700000000875000'), + (0.00000175, '1'), + (-0.00000175, '-2'), + ], +) +def test_unix_timestamp_returns_microseconds( + monkeypatch: pytest.MonkeyPatch, + timestamp: float, + expected: str, +) -> None: + monkeypatch.setattr(key_binding_utils.time, 'time', lambda: timestamp) + + assert key_binding_utils.unix_timestamp(microseconds=True) == expected + + def test_prettify_statement(): statement = 'SELECT 1' mycli = FakeMyCli() diff --git a/test/pytests/test_key_bindings.py b/test/pytests/test_key_bindings.py index c92fc293..2843e7dc 100644 --- a/test/pytests/test_key_bindings.py +++ b/test/pytests/test_key_bindings.py @@ -365,6 +365,29 @@ def test_escape_binding_cancels_completion_menu( assert event.app.current_buffer.complete_state is None +@pytest.mark.parametrize('key, config_name', [(Keys.ControlI, 'tab'), (Keys.ControlAt, 'control_space')]) +@pytest.mark.parametrize('completion_active', [False, True]) +def test_completion_binding_without_configured_behaviors_is_noop( + key: Keys, + config_name: str, + completion_active: bool, +) -> None: + mycli = DummyMyCli(DummyKeysConfig(behaviors={config_name: []})) + kb = key_bindings.mycli_bindings(mycli) + complete_state = object() if completion_active else None + buffer = DummyBuffer(text='sel', complete_state=complete_state) + event = make_event(buffer) + + binding_handler(kb, key)(event) + + assert buffer.complete_state is complete_state + assert buffer.start_completion_calls == [] + assert buffer.complete_next_calls == 0 + assert buffer.cancel_completion_calls == 0 + assert buffer.start_selection_calls == [] + assert buffer.inserted_text == [] + + def test_control_space_toolkit_default_starts_selection_for_non_empty_text() -> None: mycli = DummyMyCli(DummyKeysConfig(behaviors={'control_space': ['toolkit_default']})) kb = key_bindings.mycli_bindings(mycli) @@ -501,6 +524,44 @@ def test_date_and_datetime_bindings_insert_shortcuts( assert event.app.current_buffer.inserted_text == [expected_text] +@pytest.mark.parametrize( + ('keys', 'expected_text'), + [ + ((Keys.ControlO, 'u'), '1700000000'), + ((Keys.ControlO, Keys.ControlU), '1700000000875000'), + ], +) +def test_unix_timestamp_bindings_insert_numeric_literals( + monkeypatch: pytest.MonkeyPatch, + keys: tuple[str | Keys, ...], + expected_text: str, +) -> None: + mycli = DummyMyCli(DummyKeysConfig(), key_bindings_mode='emacs') + kb = key_bindings.mycli_bindings(mycli) + event = make_event() + monkeypatch.setattr(key_bindings.key_binding_utils.time, 'time', lambda: 1700000000.875) + + binding_handler(kb, *keys)(event) + + assert event.app.current_buffer.inserted_text == [expected_text] + + +@pytest.mark.parametrize('keys', [(Keys.ControlO, 'u'), (Keys.ControlO, Keys.ControlU)]) +@pytest.mark.parametrize('editing_mode, enabled', [(EditingMode.EMACS, True), (EditingMode.VI, False)]) +def test_unix_timestamp_bindings_are_emacs_only( + monkeypatch: pytest.MonkeyPatch, + keys: tuple[str | Keys, ...], + editing_mode: EditingMode, + enabled: bool, +) -> None: + mycli = DummyMyCli(DummyKeysConfig()) + kb = key_bindings.mycli_bindings(mycli) + app = DummyApp(current_buffer=DummyBuffer(), editing_mode=editing_mode) + patch_filter_app(monkeypatch, app) + + assert binding_filter(kb, *keys)() is enabled + + def test_control_r_uses_reverse_isearch_mode_when_configured(monkeypatch) -> None: mycli = DummyMyCli(DummyKeysConfig(options={'control_r': 'reverse_isearch'}), key_bindings_mode='emacs') kb = key_bindings.mycli_bindings(mycli)