From 295e260a46600b97ed8d034516dd59cc96b1d77e Mon Sep 17 00:00:00 2001 From: kolo Date: Thu, 22 Jan 2026 03:13:42 +0300 Subject: [PATCH 1/4] start work --- mock/local_test.py | 149 ++++++++++++++--------- pyproject.toml | 1 + src/argenta/app/autocompleter/entity.py | 152 +++++++++++------------- uv.lock | 23 ++++ 4 files changed, 191 insertions(+), 134 deletions(-) diff --git a/mock/local_test.py b/mock/local_test.py index 9e20232..12e4c38 100644 --- a/mock/local_test.py +++ b/mock/local_test.py @@ -1,61 +1,102 @@ -import math +__all__ = ["AutoCompleter"] + +from prompt_toolkit import PromptSession, HTML +from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit.document import Document +from prompt_toolkit.history import History, ThreadedHistory, FileHistory, InMemoryHistory +from prompt_toolkit.key_binding import KeyBindings -def estimate_nth_prime_upper_bound(n: int): - if n < 6: - return 15 - - log_n = math.log(n) - log_log_n = math.log(log_n) - - if n < 100: - return int(n * (log_n + log_log_n) * 1.5) - elif n < 1000: - return int(n * (log_n + log_log_n) * 1.3) - elif n >= 8009824: - return int(n * (log_n + log_log_n - 1 + 1.8 * log_log_n / log_n)) - else: - return int(n * (log_n + log_log_n - 1 + 2.0 * log_log_n / log_n)) +class HistoryCompleter(Completer): + def __init__(self, history_container: History, static_commands: set[str]) -> None: + self.history_container: History = history_container + self.static_commands: set[str] = static_commands + + def get_completions(self, document: Document, complete_event): + text: str = document.text_before_cursor + history_items: set[str] = set(self.history_container.load_history_strings()) + all_candidates: set[str] = history_items.union(self.static_commands) + matches: list[str] = sorted(cmd for cmd in all_candidates if cmd.startswith(text)) + + if not matches: + return + + for match in matches: + yield Completion( + match, + start_position=-len(text), + display=match + ) + + @staticmethod + def _find_common_prefix(matches: list[str]) -> str: + if not matches: + return "" + common: str = matches[0] + for match in matches[1:]: + i: int = 0 + while i < len(common) and i < len(match) and common[i] == match[i]: + i += 1 + common = common[:i] + return common -def odd_dig_primes(n: int) -> list[int]: - nums = {k: True for k in range(2, n+1)} - - for num, is_checkable in nums.items(): - if not is_checkable: - continue - - if nums[2]: - nums[2] = False - - for x in range(num * num, n, num): - nums[x] = False - - primes = len([x for x in nums.items() if x[1]]) - max_prime = max([x[0] for x in nums.items() if x[1]]) - - upper_bound = estimate_nth_prime_upper_bound(primes+1) - print(upper_bound) - nums2 = {k: True for k in range(2, upper_bound)} +class AutoCompleter: + def __init__( + self, + history_filename: str | None = None, + autocomplete_button: str = "tab" + ) -> None: + self.history_filename: str | None = history_filename + self.autocomplete_button: str = autocomplete_button + self._session: PromptSession | None = None - for num, is_checkable in nums2.items(): - if not is_checkable: - continue - - if nums2[2]: - nums2[2] = False - - for x in range(num * num, upper_bound, num): - nums2[x] = False - - print([x for x in nums2.items() if x[1]]) + def initial_setup(self, all_commands: set[str]) -> None: + kb = KeyBindings() - next_prime_after_max = [x[0] for x in nums2.items() if x[1]][-1] + def _(event): + buff = event.app.current_buffer - return [ - primes, - max_prime, - next_prime_after_max - ] - -print(odd_dig_primes(13)) \ No newline at end of file + if buff.complete_state: + buff.complete_next() + else: + completions = list(buff.completer.get_completions(buff.document, None)) + if len(completions) == 1: + buff.apply_completion(completions[0]) + else: + buff.start_completion(select_first=False) + + kb.add(self.autocomplete_button)(_) + + if self.history_filename: + history = FileHistory(self.history_filename) + history = ThreadedHistory(history) + else: + history = InMemoryHistory() + + self._session = PromptSession( + history=history, + completer=HistoryCompleter(history, all_commands), + complete_while_typing=False, + key_bindings=kb, + ) + + def prompt(self, prompt_text: str | HTML = ">>> ") -> str: + if self._session is None: + raise RuntimeError("Call initial_setup() before using prompt()") + return self._session.prompt( + HTML(f"{prompt_text}") if isinstance(prompt_text, str) else prompt_text + ) + + +if __name__ == "__main__": + test_commands: set[str] = {"start", "qwertyu", "stop", "exit"} + hist_file: str = "history.txt" + + ac: AutoCompleter = AutoCompleter(autocomplete_button='tab') + ac.initial_setup(test_commands) + + while True: + inp: str = ac.prompt(">>> ").strip() + if inp == "exit": + break diff --git a/pyproject.toml b/pyproject.toml index eeabf62..0c68235 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "art (>=6.4,<7.0)", "pyreadline3>=3.5.4; sys_platform == 'win32'", "dishka>=1.7.2", + "prompt-toolkit>=3.0.52", ] [dependency-groups] diff --git a/src/argenta/app/autocompleter/entity.py b/src/argenta/app/autocompleter/entity.py index a50bad0..db0f6f1 100644 --- a/src/argenta/app/autocompleter/entity.py +++ b/src/argenta/app/autocompleter/entity.py @@ -1,97 +1,89 @@ __all__ = ["AutoCompleter"] -import os -import readline -from typing import Never +from prompt_toolkit import PromptSession, HTML +from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit.document import Document +from prompt_toolkit.history import History, ThreadedHistory, FileHistory, InMemoryHistory +from prompt_toolkit.key_binding import KeyBindings + + +class HistoryCompleter(Completer): + def __init__(self, history_container: History, static_commands: set[str]) -> None: + self.history_container: History = history_container + self.static_commands: set[str] = static_commands + + def get_completions(self, document: Document, complete_event): + text: str = document.text_before_cursor + history_items: set[str] = set(self.history_container.load_history_strings()) + all_candidates: set[str] = history_items.union(self.static_commands) + matches: list[str] = sorted(cmd for cmd in all_candidates if cmd.startswith(text)) + + if not matches: + return + + for match in matches: + yield Completion( + match, + start_position=-len(text), + display=match + ) + + @staticmethod + def _find_common_prefix(matches: list[str]) -> str: + if not matches: + return "" + common: str = matches[0] + for match in matches[1:]: + i: int = 0 + while i < len(common) and i < len(match) and common[i] == match[i]: + i += 1 + common = common[:i] + return common class AutoCompleter: def __init__( - self, history_filename: str | None = None, autocomplete_button: str = "tab" + self, + history_filename: str | None = None, + autocomplete_button: str = "tab" ) -> None: - """ - Public. Configures and implements auto-completion of input command - :param history_filename: the name of the file for saving the history of the autocompleter - :param autocomplete_button: the button for auto-completion - :return: None - """ self.history_filename: str | None = history_filename self.autocomplete_button: str = autocomplete_button - - def _complete(self, text: str, state: int) -> str | None: - """ - Private. Auto-completion function - :param text: part of the command being entered - :param state: the current cursor position is relative to the beginning of the line - :return: the desired candidate as str or None - """ - matches: list[str] = sorted( - cmd for cmd in _get_history_items() if cmd.startswith(text) - ) - if len(matches) > 1: - common_prefix = matches[0] - for match in matches[1:]: - i = 0 - while ( - i < len(common_prefix) - and i < len(match) - and common_prefix[i] == match[i] - ): - i += 1 - common_prefix = common_prefix[:i] - if state == 0: - readline.insert_text(common_prefix[len(text) :]) - readline.redisplay() - return None - elif len(matches) == 1: - return matches[0] if state == 0 else None - else: - return None + self._session: PromptSession | None = None def initial_setup(self, all_commands: set[str]) -> None: - """ - Private. Initial setup function - :param all_commands: Registered commands for adding them to the autocomplete history - :return: None - """ - if self.history_filename: - if os.path.exists(self.history_filename): - readline.read_history_file(self.history_filename) + kb = KeyBindings() + + def _(event): + buff = event.app.current_buffer + + if buff.complete_state: + buff.complete_next() else: - for line in all_commands: - readline.add_history(line) + completions = list(buff.completer.get_completions(buff.document, None)) + if len(completions) == 1: + buff.apply_completion(completions[0]) + else: + buff.start_completion(select_first=False) - if not self.history_filename: - for line in all_commands: - readline.add_history(line) + kb.add(self.autocomplete_button)(_) - readline.set_completer(self._complete) - readline.set_completer_delims(readline.get_completer_delims().replace(" ", "")) - readline.parse_and_bind(f"{self.autocomplete_button}: complete") - - def exit_setup(self, all_commands: set[str]) -> None: - """ - Private. Exit setup function - :return: None - """ if self.history_filename: - readline.write_history_file(self.history_filename) - with open(self.history_filename, "r") as history_file: - raw_history = history_file.read() - pretty_history: list[str] = [] - for line in set(raw_history.strip().split("\n")): - if line.split()[0] in all_commands: - pretty_history.append(line) - with open(self.history_filename, "w") as history_file: - _ = history_file.write("\n".join(pretty_history)) + history = FileHistory(self.history_filename) + history = ThreadedHistory(history) + else: + history = InMemoryHistory() + self._session = PromptSession( + history=history, + completer=HistoryCompleter(history, all_commands), + complete_while_typing=False, + key_bindings=kb, + ) -def _get_history_items() -> list[str] | list[Never]: - """ - Private. Returns a list of all commands entered by the user - :return: all commands entered by the user as list[str] | list[Never] - """ - return [ - readline.get_history_item(i) - for i in range(1, readline.get_current_history_length() + 1) - ] + def prompt(self, prompt_text: str | HTML = ">>> ") -> str: + if self._session is None: + raise RuntimeError("Call initial_setup() before using prompt()") + return self._session.prompt( + HTML(f"{prompt_text}") if isinstance(prompt_text, str) else prompt_text + ) \ No newline at end of file diff --git a/uv.lock b/uv.lock index d5fb31f..fe65840 100644 --- a/uv.lock +++ b/uv.lock @@ -44,6 +44,7 @@ source = { editable = "." } dependencies = [ { name = "art" }, { name = "dishka" }, + { name = "prompt-toolkit" }, { name = "pyreadline3", marker = "sys_platform == 'win32'" }, { name = "rich" }, ] @@ -75,6 +76,7 @@ typecheckers = [ requires-dist = [ { name = "art", specifier = ">=6.4,<7.0" }, { name = "dishka", specifier = ">=1.7.2" }, + { name = "prompt-toolkit", specifier = ">=3.0.52" }, { name = "pyreadline3", marker = "sys_platform == 'win32'", specifier = ">=3.5.4" }, { name = "rich", specifier = ">=14.0.0,<15.0.0" }, ] @@ -589,6 +591,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + [[package]] name = "pycodestyle" version = "2.14.0" @@ -1023,6 +1037,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, ] +[[package]] +name = "wcwidth" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/75/2144b65e4fba12a2d9868e9a3f99db7fa0760670d064603634bef9ff1709/wcwidth-0.3.0.tar.gz", hash = "sha256:af1a2fb0b83ef4a7fc0682a4c95ca2576e14d0280bca2a9e67b7dc9f2733e123", size = 172238, upload-time = "2026-01-21T17:44:09.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/0e/a5f0257ab47492b7afb5fb60347d14ba19445e2773fc8352d4be6bd2f6f8/wcwidth-0.3.0-py3-none-any.whl", hash = "sha256:073a1acb250e4add96cfd5ef84e0036605cd6e0d0782c8c15c80e42202348458", size = 85520, upload-time = "2026-01-21T17:44:08.002Z" }, +] + [[package]] name = "websockets" version = "15.0.1" From b8e9fdcb9c5a18ba407aea0a4245d8be4a0f9d6a Mon Sep 17 00:00:00 2001 From: kolo Date: Thu, 22 Jan 2026 04:29:13 +0300 Subject: [PATCH 2/4] Update documentation and code snippets --- .../LC_MESSAGES/root/api/app/autocompleter.po | 85 +++--- docs/root/api/app/autocompleter.rst | 11 +- mock/local_test.py | 90 +----- src/argenta/app/autocompleter/entity.py | 51 +++- src/argenta/app/models.py | 8 +- tests/unit_tests/test_app.py | 2 +- tests/unit_tests/test_autocompleter.py | 274 ++++++++---------- 7 files changed, 234 insertions(+), 287 deletions(-) diff --git a/docs/locales/en/LC_MESSAGES/root/api/app/autocompleter.po b/docs/locales/en/LC_MESSAGES/root/api/app/autocompleter.po index b0db0a9..45f78e8 100644 --- a/docs/locales/en/LC_MESSAGES/root/api/app/autocompleter.po +++ b/docs/locales/en/LC_MESSAGES/root/api/app/autocompleter.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Argenta \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-12-02 22:27+0300\n" +"POT-Creation-Date: 2026-01-22 04:26+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -29,92 +29,107 @@ msgid "" "подсказки и завершая ввод на основе истории команд, что ускоряет работу и" " снижает вероятность опечаток." msgstr "" -"``AutoCompleter`` is a component responsible for interactive command autocompletion. " -"It improves user experience by offering suggestions and completing input based on " -"command history, which speeds up work and reduces the likelihood of typos." +"``AutoCompleter`` is a component responsible for interactive command " +"autocompletion. It improves user experience by offering suggestions and " +"completing input based on command history, which speeds up work and " +"reduces the likelihood of typos." #: ../../root/api/app/autocompleter.rst:11 msgid "Инициализация" msgstr "Initialization" -#: ../../root/api/app/autocompleter.rst:18 +#: ../../root/api/app/autocompleter.rst:21 msgid "Создаёт и настраивает экземпляр ``AutoCompleter``." msgstr "Creates and configures an ``AutoCompleter`` instance." -#: ../../root/api/app/autocompleter.rst:20 +#: ../../root/api/app/autocompleter.rst:23 msgid "" "``history_filename``: Имя файла для сохранения истории команд. Если " "указано, история будет сохраняться между сессиями. При значении ``None`` " "история хранится только в контексте сессии." msgstr "" -"``history_filename``: Filename for saving command history. If specified, history " -"will be saved between sessions. When set to ``None``, history is stored only within " -"the session context." +"``history_filename``: Filename for saving command history. If specified, " +"history will be saved between sessions. When set to ``None``, history is " +"stored only within the session context." -#: ../../root/api/app/autocompleter.rst:21 +#: ../../root/api/app/autocompleter.rst:24 msgid "" "``autocomplete_button``: Клавиша, активирующая автодополнение. По " "умолчанию — **Tab**." msgstr "" -"``autocomplete_button``: Key that activates autocompletion. Defaults to **Tab**." +"``autocomplete_button``: Key that activates autocompletion. Defaults to " +"**Tab**." + +#: ../../root/api/app/autocompleter.rst:25 +msgid "" +"``command_highlighting``: Если True, то в реальном времени при вводе " +"команды она будет подсвечиваться: зелёным, если такой триггер существует " +"и красный, если нет." +msgstr "" +"``command_highlighting``: If True, then in real time, when entering a " +" command, it will be highlighted: green if such a trigger exists " +"and red if not." #: ../../root/api/app/autocompleter.rst:26 +msgid "" +"``auto_suggestions``: Если True, то дополнение до раннее введённой " +"команды будет сразу отображаться светло-серым в строке ввода." +msgstr "" +"``auto_suggestions``: If True, the addition to the previously entered " +" command will immediately be displayed in light gray in the input line." + +#: ../../root/api/app/autocompleter.rst:31 msgid "Назначение и возможности" msgstr "Purpose and Features" -#: ../../root/api/app/autocompleter.rst:28 +#: ../../root/api/app/autocompleter.rst:33 msgid "Основные возможности ``AutoCompleter``:" msgstr "Main features of ``AutoCompleter``:" -#: ../../root/api/app/autocompleter.rst:30 +#: ../../root/api/app/autocompleter.rst:35 msgid "" "**Автодополнение по истории**: При нажатии клавиши автодополнения (по " "умолчанию **Tab**) система ищет в истории команды, начинающиеся с уже " "введённого текста." msgstr "" -"**History-based autocompletion**: When the autocompletion key is pressed (by default **Tab**), " -"the system searches history for commands starting with the already entered text." +"**History-based autocompletion**: When the autocompletion key is pressed " +"(by default **Tab**), the system searches history for commands starting " +"with the already entered text." -#: ../../root/api/app/autocompleter.rst:32 +#: ../../root/api/app/autocompleter.rst:37 msgid "" "**Общий префикс**: Если найдено несколько команд с общим префиксом, будет" " подставлена только общая часть. Например, для команд ``show_users`` и " "``show_profile`` при вводе ``sho`` и нажатии **Tab** ввод дополнится до " "``show_``." msgstr "" -"**Common prefix**: If multiple commands with a common prefix are found, only the common " -"part will be inserted. For example, for commands ``show_users`` and ``show_profile``, " -"when entering ``sho`` and pressing **Tab**, the input will be completed to ``show_``." +"**Common prefix**: If multiple commands with a common prefix are found, " +"only the common part will be inserted. For example, for commands " +"``show_users`` and ``show_profile``, when entering ``sho`` and pressing " +"**Tab**, the input will be completed to ``show_``." -#: ../../root/api/app/autocompleter.rst:34 +#: ../../root/api/app/autocompleter.rst:39 msgid "" "**Постоянная история**: Если указан ``history_filename``, история команд " "сохраняется в файл при выходе и загружается при следующем запуске. Это " "делает автодополнение со временем «умнее»." msgstr "" -"**Persistent history**: If ``history_filename`` is specified, command history is saved " -"to a file on exit and loaded on the next startup. This makes autocompletion \"smarter\" over time." +"**Persistent history**: If ``history_filename`` is specified, command " +"history is saved to a file on exit and loaded on the next startup. This " +"makes autocompletion \"smarter\" over time." -#: ../../root/api/app/autocompleter.rst:36 -msgid "" -"**Очистка истории**: При сохранении ``AutoCompleter`` удаляет дубликаты и" -" несуществующие команды, поддерживая историю в актуальном состоянии." -msgstr "" -"**History cleanup**: When saving, ``AutoCompleter`` removes duplicates and non-existent " -"commands, keeping the history up to date." - -#: ../../root/api/app/autocompleter.rst:38 +#: ../../root/api/app/autocompleter.rst:41 msgid "" "**Настройка клавиши**: Клавишу автодополнения можно изменить с помощью " "параметра ``autocomplete_button``." msgstr "" -"**Key customization**: The autocompletion key can be changed using the ``autocomplete_button`` parameter." +"**Key customization**: The autocompletion key can be changed using the " +"``autocomplete_button`` parameter." -#: ../../root/api/app/autocompleter.rst:43 +#: ../../root/api/app/autocompleter.rst:46 msgid "Пример использования" msgstr "Usage Example" -#: ../../root/api/app/autocompleter.rst:45 +#: ../../root/api/app/autocompleter.rst:48 msgid "``AutoCompleter`` передаётся как аргумент при инициализации `App`." msgstr "``AutoCompleter`` is passed as an argument when initializing `App`." - diff --git a/docs/root/api/app/autocompleter.rst b/docs/root/api/app/autocompleter.rst index ede5993..4711087 100644 --- a/docs/root/api/app/autocompleter.rst +++ b/docs/root/api/app/autocompleter.rst @@ -12,13 +12,18 @@ AutoCompleter .. code-block:: python - __init__(self, history_filename: str | None = None, - autocomplete_button: str = "tab") -> None + __init__(self, + history_filename: str | None = None, + autocomplete_button: str = "tab", + command_highlighting: bool = True, + auto_suggestions: bool = True) -> None: Создаёт и настраивает экземпляр ``AutoCompleter``. * ``history_filename``: Имя файла для сохранения истории команд. Если указано, история будет сохраняться между сессиями. При значении ``None`` история хранится только в контексте сессии. * ``autocomplete_button``: Клавиша, активирующая автодополнение. По умолчанию — **Tab**. +* ``command_highlighting``: Если True, то в реальном времени при вводе команды она будет подсвечиваться: зелёным, если такой триггер существует и красный, если нет. +* ``auto_suggestions``: Если True, то дополнение до раннее введённой команды будет сразу отображаться светло-серым в строке ввода. ----- @@ -33,8 +38,6 @@ AutoCompleter * **Постоянная история**: Если указан ``history_filename``, история команд сохраняется в файл при выходе и загружается при следующем запуске. Это делает автодополнение со временем «умнее». -* **Очистка истории**: При сохранении ``AutoCompleter`` удаляет дубликаты и несуществующие команды, поддерживая историю в актуальном состоянии. - * **Настройка клавиши**: Клавишу автодополнения можно изменить с помощью параметра ``autocomplete_button``. ----- diff --git a/mock/local_test.py b/mock/local_test.py index 12e4c38..3e47833 100644 --- a/mock/local_test.py +++ b/mock/local_test.py @@ -1,92 +1,4 @@ -__all__ = ["AutoCompleter"] - -from prompt_toolkit import PromptSession, HTML -from prompt_toolkit.completion import Completer, Completion -from prompt_toolkit.document import Document -from prompt_toolkit.history import History, ThreadedHistory, FileHistory, InMemoryHistory -from prompt_toolkit.key_binding import KeyBindings - - -class HistoryCompleter(Completer): - def __init__(self, history_container: History, static_commands: set[str]) -> None: - self.history_container: History = history_container - self.static_commands: set[str] = static_commands - - def get_completions(self, document: Document, complete_event): - text: str = document.text_before_cursor - history_items: set[str] = set(self.history_container.load_history_strings()) - all_candidates: set[str] = history_items.union(self.static_commands) - matches: list[str] = sorted(cmd for cmd in all_candidates if cmd.startswith(text)) - - if not matches: - return - - for match in matches: - yield Completion( - match, - start_position=-len(text), - display=match - ) - - @staticmethod - def _find_common_prefix(matches: list[str]) -> str: - if not matches: - return "" - common: str = matches[0] - for match in matches[1:]: - i: int = 0 - while i < len(common) and i < len(match) and common[i] == match[i]: - i += 1 - common = common[:i] - return common - - -class AutoCompleter: - def __init__( - self, - history_filename: str | None = None, - autocomplete_button: str = "tab" - ) -> None: - self.history_filename: str | None = history_filename - self.autocomplete_button: str = autocomplete_button - self._session: PromptSession | None = None - - def initial_setup(self, all_commands: set[str]) -> None: - kb = KeyBindings() - - def _(event): - buff = event.app.current_buffer - - if buff.complete_state: - buff.complete_next() - else: - completions = list(buff.completer.get_completions(buff.document, None)) - if len(completions) == 1: - buff.apply_completion(completions[0]) - else: - buff.start_completion(select_first=False) - - kb.add(self.autocomplete_button)(_) - - if self.history_filename: - history = FileHistory(self.history_filename) - history = ThreadedHistory(history) - else: - history = InMemoryHistory() - - self._session = PromptSession( - history=history, - completer=HistoryCompleter(history, all_commands), - complete_while_typing=False, - key_bindings=kb, - ) - - def prompt(self, prompt_text: str | HTML = ">>> ") -> str: - if self._session is None: - raise RuntimeError("Call initial_setup() before using prompt()") - return self._session.prompt( - HTML(f"{prompt_text}") if isinstance(prompt_text, str) else prompt_text - ) +from argenta.app import AutoCompleter if __name__ == "__main__": diff --git a/src/argenta/app/autocompleter/entity.py b/src/argenta/app/autocompleter/entity.py index db0f6f1..ffbf53d 100644 --- a/src/argenta/app/autocompleter/entity.py +++ b/src/argenta/app/autocompleter/entity.py @@ -1,10 +1,40 @@ __all__ = ["AutoCompleter"] +import sys + from prompt_toolkit import PromptSession, HTML +from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.completion import Completer, Completion from prompt_toolkit.document import Document +from prompt_toolkit.formatted_text import StyleAndTextTuples from prompt_toolkit.history import History, ThreadedHistory, FileHistory, InMemoryHistory from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.lexers import Lexer +from prompt_toolkit.styles import Style + + +class CommandLexer(Lexer): + def __init__(self, valid_commands: set[str]) -> None: + self.valid_commands: set[str] = valid_commands + + def lex_document(self, document): + def get_line_tokens(lineno: int) -> StyleAndTextTuples: + if lineno >= len(document.lines): + return [] + + line_text: str = document.lines[lineno] + + if not line_text.strip(): + return [("", line_text)] + + first_word: str = line_text.split()[0] if line_text.split() else "" + + if first_word in self.valid_commands: + return [("class:valid", line_text)] + else: + return [("class:invalid", line_text)] + + return get_line_tokens class HistoryCompleter(Completer): @@ -45,13 +75,23 @@ class AutoCompleter: def __init__( self, history_filename: str | None = None, - autocomplete_button: str = "tab" + autocomplete_button: str = "tab", + command_highlighting: bool = True, + auto_suggestions: bool = True, ) -> None: self.history_filename: str | None = history_filename self.autocomplete_button: str = autocomplete_button + self.command_highlighting: bool = command_highlighting + self.auto_suggestions: bool = auto_suggestions self._session: PromptSession | None = None + self._fallback_mode: bool = False def initial_setup(self, all_commands: set[str]) -> None: + if not sys.stdin.isatty(): + self._session = None + self._fallback_mode = True + return + kb = KeyBindings() def _(event): @@ -74,16 +114,23 @@ class AutoCompleter: else: history = InMemoryHistory() + style = Style.from_dict({'valid': '#00ff00', 'invalid': '#ff0000'}) + self._session = PromptSession( history=history, completer=HistoryCompleter(history, all_commands), complete_while_typing=False, key_bindings=kb, + auto_suggest=AutoSuggestFromHistory() if self.auto_suggestions else None, + style=style if self.command_highlighting else style, + lexer=CommandLexer(all_commands) if self.command_highlighting else None, ) def prompt(self, prompt_text: str | HTML = ">>> ") -> str: + if self._fallback_mode: + return input(prompt_text if isinstance(prompt_text, str) else ">>> ") if self._session is None: raise RuntimeError("Call initial_setup() before using prompt()") return self._session.prompt( - HTML(f"{prompt_text}") if isinstance(prompt_text, str) else prompt_text + HTML(prompt_text) if isinstance(prompt_text, str) else prompt_text ) \ No newline at end of file diff --git a/src/argenta/app/models.py b/src/argenta/app/models.py index 2bf37f8..f21afe8 100644 --- a/src/argenta/app/models.py +++ b/src/argenta/app/models.py @@ -6,6 +6,7 @@ from contextlib import redirect_stdout from typing import Callable, Never, TypeAlias from art import text2art +from prompt_toolkit import HTML from rich.console import Console from rich.markup import escape @@ -306,7 +307,7 @@ class BaseApp: Private. Sets up default app view :return: None """ - self._prompt = f"[italic dim bold]{self._prompt}" + self._prompt = f"{self._prompt}" self._initial_message = ( "\n" + f"[bold red]{text2art(self._initial_message, font='tarty1')}" + "\n" ) @@ -403,7 +404,7 @@ class App(BaseApp): def __init__( self, *, - prompt: str = "What do you want to do?\n\n", + prompt: str | HTML = ">>> ", initial_message: str = "Argenta\n", farewell_message: str = "\nSee you\n", exit_command: Command = DEFAULT_EXIT_COMMAND, @@ -454,7 +455,7 @@ class App(BaseApp): if self._repeat_command_groups_printing: self._print_command_group_description() - raw_command: str = Console().input(self._prompt) + raw_command: str = self._autocompleter.prompt(self._prompt) try: input_command: InputCommand = InputCommand.parse(raw_command=raw_command) @@ -467,7 +468,6 @@ class App(BaseApp): if self._is_exit_command(input_command): self.system_router.finds_appropriate_handler(input_command) - self._autocompleter.exit_setup(self.registered_routers.get_triggers()) return if self._is_unknown_command(input_command): diff --git a/tests/unit_tests/test_app.py b/tests/unit_tests/test_app.py index 13b7924..17dd8a0 100644 --- a/tests/unit_tests/test_app.py +++ b/tests/unit_tests/test_app.py @@ -361,7 +361,7 @@ def test_set_exit_command_handler_stores_handler() -> None: def test_setup_default_view_formats_prompt() -> None: app = App(prompt='>>') - assert app._prompt == '[italic dim bold]>>' + assert app._prompt == '>>' def test_setup_default_view_sets_default_unknown_command_handler() -> None: diff --git a/tests/unit_tests/test_autocompleter.py b/tests/unit_tests/test_autocompleter.py index 5b1c9c7..b7022a3 100644 --- a/tests/unit_tests/test_autocompleter.py +++ b/tests/unit_tests/test_autocompleter.py @@ -1,192 +1,162 @@ -import os -from typing import Any - import pytest -from pyfakefs.fake_filesystem import FakeFilesystem -from pytest_mock import MockerFixture +from prompt_toolkit.document import Document +from prompt_toolkit.history import InMemoryHistory from argenta.app.autocompleter.entity import ( AutoCompleter, - _get_history_items + CommandLexer, + HistoryCompleter ) -HISTORY_FILE: str = "test_history.txt" COMMANDS: set[str] = {"start", "stop", "status"} -# ============================================================================ -# Fixtures -# ============================================================================ - - -@pytest.fixture -def mock_readline(mocker: MockerFixture) -> Any: - _history: list[str] = [] - - def add_history(item: str) -> None: - _history.append(item) - - def get_history_item(index: int) -> str | None: - if 1 <= index <= len(_history): - return _history[index - 1] - return None - - def get_current_history_length() -> int: - return len(_history) - - def clear_history() -> None: - _history.clear() - - mock: Any = mocker.MagicMock() # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] - mocker.patch('argenta.app.autocompleter.entity.readline', mock) # pyright: ignore[reportUnknownArgumentType] - - mock.reset_mock() # pyright: ignore[reportUnknownMemberType] - clear_history() - - mock.add_history.side_effect = add_history # pyright: ignore[reportUnknownMemberType] - mock.get_history_item.side_effect = get_history_item # pyright: ignore[reportUnknownMemberType] - mock.get_current_history_length.side_effect = get_current_history_length # pyright: ignore[reportUnknownMemberType] - mock.get_completer_delims.return_value = " " # pyright: ignore[reportUnknownMemberType] - - return mock # pyright: ignore[reportReturnType, reportUnknownVariableType] - - -# ============================================================================ -# Tests for AutoCompleter initialization -# ============================================================================ - - -def test_autocompleter_initializes_with_history_file_and_button() -> None: - completer: AutoCompleter = AutoCompleter(history_filename=HISTORY_FILE, autocomplete_button="tab") - assert completer.history_filename == HISTORY_FILE +def test_autocompleter_initializes_with_default_params() -> None: + completer = AutoCompleter() + assert completer.history_filename is None assert completer.autocomplete_button == "tab" + assert completer.command_highlighting is True + assert completer.auto_suggestions is True -# ============================================================================ -# Tests for initial setup -# ============================================================================ +def test_autocompleter_initializes_with_custom_params() -> None: + completer = AutoCompleter( + history_filename="test.txt", + autocomplete_button="c-space", + command_highlighting=False, + auto_suggestions=False + ) + assert completer.history_filename == "test.txt" + assert completer.autocomplete_button == "c-space" + assert completer.command_highlighting is False + assert completer.auto_suggestions is False -def test_initial_setup_creates_history_when_file_does_not_exist(fs: FakeFilesystem, mock_readline: Any) -> None: - if os.path.exists(HISTORY_FILE): - os.remove(HISTORY_FILE) - - completer: AutoCompleter = AutoCompleter(history_filename=HISTORY_FILE) - completer.initial_setup(COMMANDS) - - mock_readline.read_history_file.assert_not_called() - assert mock_readline.add_history.call_count == len(COMMANDS) - - mock_readline.set_completer.assert_called_with(completer._complete) - mock_readline.parse_and_bind.assert_called_with("tab: complete") +def test_prompt_raises_error_without_initial_setup() -> None: + completer = AutoCompleter() + with pytest.raises(RuntimeError, match="Call initial_setup"): + completer.prompt(">>> ") -def test_initial_setup_reads_existing_history_file(fs: FakeFilesystem, mock_readline: Any) -> None: - fs.create_file(HISTORY_FILE, contents="previous_command\n") # pyright: ignore[reportUnknownMemberType] - - completer: AutoCompleter = AutoCompleter(history_filename=HISTORY_FILE) - completer.initial_setup(COMMANDS) - - mock_readline.read_history_file.assert_called_once_with(HISTORY_FILE) - mock_readline.add_history.assert_not_called() - mock_readline.set_completer.assert_called_once() - mock_readline.parse_and_bind.assert_called_once() +def test_command_lexer_highlights_valid_command() -> None: + lexer = CommandLexer({"start", "stop"}) + doc = Document("start server") + tokens = lexer.lex_document(doc)(0) + assert tokens == [("class:valid", "start server")] -def test_initial_setup_works_without_history_filename(mock_readline: Any) -> None: - completer: AutoCompleter = AutoCompleter(history_filename=None) - completer.initial_setup(COMMANDS) - - mock_readline.read_history_file.assert_not_called() - assert mock_readline.add_history.call_count == len(COMMANDS) +def test_command_lexer_highlights_invalid_command() -> None: + lexer = CommandLexer({"start", "stop"}) + doc = Document("invalid command") + tokens = lexer.lex_document(doc)(0) + assert tokens == [("class:invalid", "invalid command")] -# ============================================================================ -# Tests for exit setup and history filtering -# ============================================================================ +def test_command_lexer_handles_empty_line() -> None: + lexer = CommandLexer({"start", "stop"}) + doc = Document("") + tokens = lexer.lex_document(doc)(0) + assert tokens == [("", "")] -def test_exit_setup_writes_and_filters_duplicate_commands(fs: FakeFilesystem, mock_readline: Any) -> None: - mock_readline.add_history.side_effect = None - mock_readline.add_history("start server") - mock_readline.add_history("stop client") - mock_readline.add_history("invalid command") - mock_readline.add_history("start server") - - raw_history_content: str = "\n".join(["start server", "stop client", "invalid command", "start server"]) - fs.create_file(HISTORY_FILE, contents=raw_history_content) # pyright: ignore[reportUnknownMemberType] - - completer: AutoCompleter = AutoCompleter(history_filename=HISTORY_FILE) - completer.exit_setup(all_commands={"start", "stop"}) - - mock_readline.write_history_file.assert_called_once_with(HISTORY_FILE) - - with open(HISTORY_FILE) as f: - content: str = f.read() - lines: list[str] = sorted(content.strip().split("\n")) - assert lines == ["start server", "stop client"] +def test_command_lexer_handles_whitespace_only() -> None: + lexer = CommandLexer({"start", "stop"}) + doc = Document(" ") + tokens = lexer.lex_document(doc)(0) + assert tokens == [("", " ")] -def test_exit_setup_skips_writing_when_no_history_filename(mock_readline: Any) -> None: - completer: AutoCompleter = AutoCompleter(history_filename=None) - completer.exit_setup(all_commands=COMMANDS) - mock_readline.write_history_file.assert_not_called() +def test_history_completer_returns_matching_commands() -> None: + history = InMemoryHistory() + history.append_string("start server") + history.append_string("stop server") + + completer = HistoryCompleter(history, {"status"}) + doc = Document("sta") + + completions = list(completer.get_completions(doc, None)) + completion_texts = [c.text for c in completions] + + assert "start server" in completion_texts + assert "status" in completion_texts + assert "stop server" not in completion_texts -# ============================================================================ -# Tests for autocomplete functionality -# ============================================================================ +def test_history_completer_returns_all_when_empty_input() -> None: + history = InMemoryHistory() + history.append_string("start") + history.append_string("stop") + + completer = HistoryCompleter(history, {"status"}) + doc = Document("") + + completions = list(completer.get_completions(doc, None)) + completion_texts = [c.text for c in completions] + + assert len(completion_texts) == 3 + assert "start" in completion_texts + assert "stop" in completion_texts + assert "status" in completion_texts -def test_complete_returns_none_when_no_matches_found(mock_readline: Any) -> None: - cmd: str - for cmd in ["start", "stop"]: - mock_readline.add_history(cmd) - - completer: AutoCompleter = AutoCompleter() - assert completer._complete("run", 0) is None - assert completer._complete("run", 1) is None +def test_history_completer_returns_empty_when_no_matches() -> None: + history = InMemoryHistory() + history.append_string("start") + + completer = HistoryCompleter(history, {"stop"}) + doc = Document("xyz") + + completions = list(completer.get_completions(doc, None)) + assert len(completions) == 0 -def test_complete_returns_single_match(mock_readline: Any) -> None: - mock_readline.add_history("start server") - mock_readline.add_history("stop server") - - completer: AutoCompleter = AutoCompleter() - assert completer._complete("start", 0) == "start server" - assert completer._complete("start", 1) is None +def test_history_completer_deduplicates_commands() -> None: + history = InMemoryHistory() + history.append_string("start") + history.append_string("start") + + completer = HistoryCompleter(history, {"start"}) + doc = Document("sta") + + completions = list(completer.get_completions(doc, None)) + assert len(completions) == 1 -def test_complete_inserts_common_prefix_for_multiple_matches(mock_readline: Any) -> None: - mock_readline.add_history("status client") - mock_readline.add_history("status server") - mock_readline.add_history("stop") - - completer: AutoCompleter = AutoCompleter() - - result: str | None = completer._complete("stat", 0) - assert result is None - mock_readline.insert_text.assert_called_once_with("us ") - mock_readline.redisplay.assert_called_once() - - mock_readline.reset_mock() - result_state_1: str | None = completer._complete("stat", 1) - assert result_state_1 is None - mock_readline.insert_text.assert_not_called() +def test_history_completer_sorts_results() -> None: + history = InMemoryHistory() + history.append_string("stop") + history.append_string("start") + history.append_string("status") + + completer = HistoryCompleter(history, set()) + doc = Document("st") + + completions = list(completer.get_completions(doc, None)) + completion_texts = [c.text for c in completions] + + assert completion_texts == ["start", "status", "stop"] -# ============================================================================ -# Tests for helper functions -# ============================================================================ +def test_find_common_prefix_with_multiple_matches() -> None: + matches = ["start server", "start client", "start process"] + prefix = HistoryCompleter._find_common_prefix(matches) + assert prefix == "start " -def test_get_history_items_returns_empty_list_initially(mock_readline: Any) -> None: - assert _get_history_items() == [] +def test_find_common_prefix_with_no_common() -> None: + matches = ["start", "stop", "status"] + prefix = HistoryCompleter._find_common_prefix(matches) + assert prefix == "st" -def test_get_history_items_returns_all_added_items(mock_readline: Any) -> None: - mock_readline.add_history("first item") - mock_readline.add_history("second item") +def test_find_common_prefix_with_single_match() -> None: + matches = ["start"] + prefix = HistoryCompleter._find_common_prefix(matches) + assert prefix == "start" - assert _get_history_items() == ["first item", "second item"] + +def test_find_common_prefix_with_empty_list() -> None: + matches: list[str] = [] + prefix = HistoryCompleter._find_common_prefix(matches) + assert prefix == "" From 838f33db874f0ff1641042687f0b67712fd0a197 Mon Sep 17 00:00:00 2001 From: kolo Date: Thu, 22 Jan 2026 04:31:22 +0300 Subject: [PATCH 3/4] remove pyreadline3 from dependencies --- pyproject.toml | 1 - uv.lock | 11 ----------- 2 files changed, 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0c68235..00fc3e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ license = { text = "MIT" } dependencies = [ "rich (>=14.0.0,<15.0.0)", "art (>=6.4,<7.0)", - "pyreadline3>=3.5.4; sys_platform == 'win32'", "dishka>=1.7.2", "prompt-toolkit>=3.0.52", ] diff --git a/uv.lock b/uv.lock index fe65840..9be6588 100644 --- a/uv.lock +++ b/uv.lock @@ -45,7 +45,6 @@ dependencies = [ { name = "art" }, { name = "dishka" }, { name = "prompt-toolkit" }, - { name = "pyreadline3", marker = "sys_platform == 'win32'" }, { name = "rich" }, ] @@ -77,7 +76,6 @@ requires-dist = [ { name = "art", specifier = ">=6.4,<7.0" }, { name = "dishka", specifier = ">=1.7.2" }, { name = "prompt-toolkit", specifier = ">=3.0.52" }, - { name = "pyreadline3", marker = "sys_platform == 'win32'", specifier = ">=3.5.4" }, { name = "rich", specifier = ">=14.0.0,<15.0.0" }, ] @@ -665,15 +663,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/3d/8888e7ca0c6b093b52aa5c6693b0022e66d5958adcc685ed7a6a8ae615e8/pygments_styles-0.2.0-py3-none-any.whl", hash = "sha256:40fb7f1d34ce2b2792aecabc8d3877ca364eb04bb3b7f7747cfc9a7f0569bae9", size = 34200, upload-time = "2025-09-26T08:39:02.262Z" }, ] -[[package]] -name = "pyreadline3" -version = "3.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, -] - [[package]] name = "pytest" version = "8.4.2" From 088c1720c445b6b35c60daf86072c0f7a11ed515 Mon Sep 17 00:00:00 2001 From: kolo Date: Thu, 22 Jan 2026 04:48:53 +0300 Subject: [PATCH 4/4] fix typechecker errors --- src/argenta/app/autocompleter/entity.py | 20 +++++++++++--------- src/argenta/app/models.py | 4 ++-- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/argenta/app/autocompleter/entity.py b/src/argenta/app/autocompleter/entity.py index ffbf53d..dedc320 100644 --- a/src/argenta/app/autocompleter/entity.py +++ b/src/argenta/app/autocompleter/entity.py @@ -1,14 +1,15 @@ __all__ = ["AutoCompleter"] import sys +from typing import Callable, Iterable from prompt_toolkit import PromptSession, HTML from prompt_toolkit.auto_suggest import AutoSuggestFromHistory -from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit.completion import Completer, Completion, CompleteEvent from prompt_toolkit.document import Document from prompt_toolkit.formatted_text import StyleAndTextTuples from prompt_toolkit.history import History, ThreadedHistory, FileHistory, InMemoryHistory -from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent from prompt_toolkit.lexers import Lexer from prompt_toolkit.styles import Style @@ -17,7 +18,7 @@ class CommandLexer(Lexer): def __init__(self, valid_commands: set[str]) -> None: self.valid_commands: set[str] = valid_commands - def lex_document(self, document): + def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]: def get_line_tokens(lineno: int) -> StyleAndTextTuples: if lineno >= len(document.lines): return [] @@ -42,7 +43,7 @@ class HistoryCompleter(Completer): self.history_container: History = history_container self.static_commands: set[str] = static_commands - def get_completions(self, document: Document, complete_event): + def get_completions(self, document: Document, complete_event: CompleteEvent) -> Iterable[Completion]: text: str = document.text_before_cursor history_items: set[str] = set(self.history_container.load_history_strings()) all_candidates: set[str] = history_items.union(self.static_commands) @@ -83,7 +84,7 @@ class AutoCompleter: self.autocomplete_button: str = autocomplete_button self.command_highlighting: bool = command_highlighting self.auto_suggestions: bool = auto_suggestions - self._session: PromptSession | None = None + self._session: PromptSession[str] | None = None self._fallback_mode: bool = False def initial_setup(self, all_commands: set[str]) -> None: @@ -94,13 +95,13 @@ class AutoCompleter: kb = KeyBindings() - def _(event): + def _(event: KeyPressEvent) -> None: buff = event.app.current_buffer if buff.complete_state: buff.complete_next() else: - completions = list(buff.completer.get_completions(buff.document, None)) + completions = list(buff.completer.get_completions(buff.document, CompleteEvent())) if len(completions) == 1: buff.apply_completion(completions[0]) else: @@ -108,9 +109,10 @@ class AutoCompleter: kb.add(self.autocomplete_button)(_) + history: InMemoryHistory | ThreadedHistory + if self.history_filename: - history = FileHistory(self.history_filename) - history = ThreadedHistory(history) + history = ThreadedHistory(FileHistory(self.history_filename)) else: history = InMemoryHistory() diff --git a/src/argenta/app/models.py b/src/argenta/app/models.py index f21afe8..e3c62b5 100644 --- a/src/argenta/app/models.py +++ b/src/argenta/app/models.py @@ -38,7 +38,7 @@ class BaseApp: def __init__( self, *, - prompt: str, + prompt: str | HTML, initial_message: str, farewell_message: str, exit_command: Command, @@ -49,7 +49,7 @@ class BaseApp: autocompleter: AutoCompleter, print_func: Printer, ) -> None: - self._prompt: str = prompt + self._prompt: str | HTML = prompt self._print_func: Printer = print_func self._exit_command: Command = exit_command self._dividing_line: StaticDividingLine | DynamicDividingLine = dividing_line