diff --git a/README.md b/README.md index e36e48f..6bcc4c1 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Argenta is the **"Simplest"**, **"Most Modular"**, and **"Most Elegant"** way to --- -![preview](https://i.ibb.co/fzWcfgFq/2025-12-04-173045.png) +![preview](https://vhs.charm.sh/vhs-2hvLCEgclmwZPJZt1vLGKi.gif) **Argenta** allows you to build interactive CLI applications incredibly easily. There's no need to manually parse complex command structures or manage state transitions — just use routers and commands! diff --git a/README.ru.md b/README.ru.md index fe00e96..5ee21fc 100644 --- a/README.ru.md +++ b/README.ru.md @@ -9,7 +9,7 @@ Argenta — это **"Самый простой"**, **"Самый модульн --- -![preview](https://i.ibb.co/fzWcfgFq/2025-12-04-173045.png) +![preview](https://vhs.charm.sh/vhs-2hvLCEgclmwZPJZt1vLGKi.gif) **Argenta** позволяет создавать интерактивные CLI-приложения невероятно легко. Не нужно вручную парсить сложные структуры команд или управлять переходами состояний — просто используйте роутеры и команды! diff --git a/docs/locales/en/LC_MESSAGES/root/api/app/index.po b/docs/locales/en/LC_MESSAGES/root/api/app/index.po index d6b43b0..e27c887 100644 --- a/docs/locales/en/LC_MESSAGES/root/api/app/index.po +++ b/docs/locales/en/LC_MESSAGES/root/api/app/index.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Argenta \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-12-04 20:39+0300\n" +"POT-Creation-Date: 2026-01-13 21:50+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -38,23 +38,23 @@ msgstr "" msgid "Инициализация" msgstr "Initialization" -#: ../../root/api/app/index.rst:38 +#: ../../root/api/app/index.rst:37 msgid "Создаёт и настраивает экземпляр приложения." msgstr "Creates and configures an application instance." -#: ../../root/api/app/index.rst:40 +#: ../../root/api/app/index.rst:39 msgid "``prompt``: Приглашение к вводу, отображаемое перед каждой командой." msgstr "``prompt``: Input prompt displayed before each command." -#: ../../root/api/app/index.rst:41 +#: ../../root/api/app/index.rst:40 msgid "``initial_message``: Сообщение, выводимое при запуске приложения." msgstr "``initial_message``: Message displayed when the application starts." -#: ../../root/api/app/index.rst:42 +#: ../../root/api/app/index.rst:41 msgid "``farewell_message``: Сообщение, выводимое при выходе из приложения." msgstr "``farewell_message``: Message displayed when exiting the application." -#: ../../root/api/app/index.rst:43 +#: ../../root/api/app/index.rst:42 msgid "" "``exit_command``: Команда, которая маркируется как триггер для выхода из " "приложения." @@ -62,7 +62,7 @@ msgstr "" "``exit_command``: Command that is marked as a trigger for exiting the " "application." -#: ../../root/api/app/index.rst:44 +#: ../../root/api/app/index.rst:43 msgid "" "``system_router_title``: Заголовок для системного роутера (содержит " "команду выхода)." @@ -70,15 +70,7 @@ msgstr "" "``system_router_title``: Title for the system router (contains the exit " "command)." -#: ../../root/api/app/index.rst:45 -msgid "" -"``ignore_command_register``: Если ``True``, регистр вводимых команд " -"игнорируется при поиске обработчика." -msgstr "" -"``ignore_command_register``: If ``True``, command case is ignored when " -"searching for a handler." - -#: ../../root/api/app/index.rst:46 +#: ../../root/api/app/index.rst:44 msgid "" "``dividing_line``: Тип разделительной линии (``StaticDividingLine`` или " "``DynamicDividingLine``)." @@ -86,7 +78,7 @@ msgstr "" "``dividing_line``: Type of dividing line (``StaticDividingLine`` or " "``DynamicDividingLine``)." -#: ../../root/api/app/index.rst:47 +#: ../../root/api/app/index.rst:45 msgid "" "``repeat_command_groups_printing``: Если ``True``, список доступных " "команд выводится перед каждым вводом." @@ -94,7 +86,7 @@ msgstr "" "``repeat_command_groups_printing``: If ``True``, the list of available " "commands is displayed before each input." -#: ../../root/api/app/index.rst:48 +#: ../../root/api/app/index.rst:46 msgid "" "``override_system_messages``: Если ``True``, стандартное форматирование " "(цвета, ASCII-арт) отключается." @@ -102,7 +94,7 @@ msgstr "" "``override_system_messages``: If ``True``, standard formatting (colors, " "ASCII art) is disabled." -#: ../../root/api/app/index.rst:49 +#: ../../root/api/app/index.rst:47 msgid "" "``autocompleter``: Экземпляр класса :ref:`AutoCompleter " "`, отвечающий за автодополнение команд." @@ -111,7 +103,7 @@ msgstr "" "` class responsible for command " "autocompletion." -#: ../../root/api/app/index.rst:50 +#: ../../root/api/app/index.rst:48 msgid "" "``print_func``: Функция для вывода всех системных сообщений (по умолчанию" " ``rich.Console().print``)." @@ -119,11 +111,21 @@ msgstr "" "``print_func``: Function for outputting all system messages (defaults to " "``rich.Console().print``)." -#: ../../root/api/app/index.rst:55 +#: ../../root/api/app/index.rst:53 +msgid "" +"В приложениях на Argenta регистр вводимых команд не важен, проверка на " +"существование и роутинг команд производится на основании триггеров, " +"приведённых к нижнему регистру." +msgstr "" +"In applications on Argenta, the case of the entered commands is not important, checking for the " +" existence and routing of commands is performed based on triggers " +"reduced to lowercase." + +#: ../../root/api/app/index.rst:56 msgid "Основные методы" msgstr "Main Methods" -#: ../../root/api/app/index.rst:59 +#: ../../root/api/app/index.rst:60 msgid "" "Регистрирует роутер в приложении. Все команды из этого роутера становятся" " доступными для вызова." @@ -135,19 +137,19 @@ msgstr "" msgid "Parameters" msgstr "Parameters" -#: ../../root/api/app/index.rst:61 +#: ../../root/api/app/index.rst:62 msgid "Экземпляр ``Router`` для регистрации." msgstr "``Router`` instance to register." -#: ../../root/api/app/index.rst:65 +#: ../../root/api/app/index.rst:66 msgid "Регистрирует несколько роутеров одновременно." msgstr "Registers multiple routers simultaneously." -#: ../../root/api/app/index.rst:67 +#: ../../root/api/app/index.rst:68 msgid "Последовательность экземпляров ``Router`` для регистрации." msgstr "Sequence of ``Router`` instances to register." -#: ../../root/api/app/index.rst:71 +#: ../../root/api/app/index.rst:72 msgid "" "Добавляет текстовое сообщение, которое выводится при запуске приложения " "после ``initial_message``." @@ -155,11 +157,11 @@ msgstr "" "Adds a text message that is displayed when the application starts after " "``initial_message``." -#: ../../root/api/app/index.rst:73 +#: ../../root/api/app/index.rst:74 msgid "Строка с сообщением." msgstr "String with the message." -#: ../../root/api/app/index.rst:76 +#: ../../root/api/app/index.rst:77 msgid "" "Для вывода стандартных сообщений можно использовать готовые шаблоны из " ":ref:`PredefinedMessages `." @@ -167,11 +169,11 @@ msgstr "" "For outputting standard messages, you can use ready-made templates from " ":ref:`PredefinedMessages `." -#: ../../root/api/app/index.rst:81 +#: ../../root/api/app/index.rst:82 msgid "Методы установки обработчиков" msgstr "Handler Setup Methods" -#: ../../root/api/app/index.rst:83 +#: ../../root/api/app/index.rst:84 msgid "" "``App`` позволяет настраивать реакцию на различные события, такие как " "ошибки ввода или неизвестные команды." @@ -179,7 +181,7 @@ msgstr "" "``App`` allows you to configure responses to various events, such as " "input errors or unknown commands." -#: ../../root/api/app/index.rst:86 +#: ../../root/api/app/index.rst:87 msgid "" "Подробнее об исключениях и их обработке в соответствующем :ref:`разделе " "документации `." @@ -187,59 +189,59 @@ msgstr "" "For more details on exceptions and their handling, see the corresponding " ":ref:`documentation section `." -#: ../../root/api/app/index.rst:92 +#: ../../root/api/app/index.rst:93 msgid "Устанавливает шаблон для форматирования описания команды." msgstr "Sets the template for formatting command descriptions." -#: ../../root/api/app/index.rst:94 +#: ../../root/api/app/index.rst:95 msgid "Обработчик принимает триггер команды (``str``) и её описание (``str``)." msgstr "" "The handler accepts the command trigger (``str``) and its description " "(``str``)." -#: ../../root/api/app/index.rst:100 +#: ../../root/api/app/index.rst:101 msgid "Устанавливает обработчик при некорректном введённом синтаксисе флагов." msgstr "Sets the handler for incorrect flag syntax input." -#: ../../root/api/app/index.rst:102 ../../root/api/app/index.rst:110 +#: ../../root/api/app/index.rst:103 ../../root/api/app/index.rst:111 msgid "Обработчик принимает строку, введённую пользователем." msgstr "The handler accepts the string entered by the user." -#: ../../root/api/app/index.rst:108 +#: ../../root/api/app/index.rst:109 msgid "Устанавливает обработчик при повторяющихся флагах в введённой команде." msgstr "Sets the handler for duplicate flags in the entered command." -#: ../../root/api/app/index.rst:116 +#: ../../root/api/app/index.rst:117 msgid "Устанавливает обработчик при вводе неизвестной команды." msgstr "Sets the handler for entering an unknown command." -#: ../../root/api/app/index.rst:118 +#: ../../root/api/app/index.rst:119 msgid "Обработчик принимает объект ``InputCommand`` - объект введённой команды." msgstr "" "The handler accepts an ``InputCommand`` object - the entered command " "object." -#: ../../root/api/app/index.rst:124 +#: ../../root/api/app/index.rst:125 msgid "Устанавливает обработчик при вводе пустой строки." msgstr "Sets the handler for entering an empty string." -#: ../../root/api/app/index.rst:126 +#: ../../root/api/app/index.rst:127 msgid "Обработчик не принимает аргументов." msgstr "The handler accepts no arguments." -#: ../../root/api/app/index.rst:132 +#: ../../root/api/app/index.rst:133 msgid "Переопределяет стандартное поведение при вызове команды выхода." msgstr "Overrides the default behavior when the exit command is invoked." -#: ../../root/api/app/index.rst:134 +#: ../../root/api/app/index.rst:135 msgid "Обработчик принимает объект ``Response``." msgstr "The handler accepts a ``Response`` object." -#: ../../root/api/app/index.rst:147 +#: ../../root/api/app/index.rst:148 msgid "PredefinedMessages" msgstr "PredefinedMessages" -#: ../../root/api/app/index.rst:149 +#: ../../root/api/app/index.rst:150 msgid "" "``PredefinedMessages`` — это контейнер, содержащий набор готовых к " "использованию сообщений. Они отформатированы с использованием синтаксиса " @@ -250,31 +252,40 @@ msgstr "" "messages. They are formatted using ``rich`` syntax and are intended for " "displaying standard information, such as usage hints." -#: ../../root/api/app/index.rst:151 +#: ../../root/api/app/index.rst:152 msgid "Рекомендуется использовать их при старте приложения." msgstr "It is recommended to use them when starting the application." -#: ../../root/api/app/index.rst:178 +#: ../../root/api/app/index.rst:179 msgid "Строка: ``[b dim]Usage[/b dim]: [i] <[green]flags[/green]>[/i]``" msgstr "String: ``[b dim]Usage[/b dim]: [i] <[green]flags[/green]>[/i]``" -#: ../../root/api/app/index.rst:180 +#: ../../root/api/app/index.rst:181 msgid "Отображается как: ``Usage: ``" msgstr "Displayed as: ``Usage: ``" -#: ../../root/api/app/index.rst:184 +#: ../../root/api/app/index.rst:185 msgid "Строка: ``[b dim]Help[/b dim]: [i][/i] [b red]--help[/b red]``" msgstr "String: ``[b dim]Help[/b dim]: [i][/i] [b red]--help[/b red]``" -#: ../../root/api/app/index.rst:186 +#: ../../root/api/app/index.rst:187 msgid "Отображается как: ``Help: --help``" msgstr "Displayed as: ``Help: --help``" -#: ../../root/api/app/index.rst:190 +#: ../../root/api/app/index.rst:191 msgid "Строка: ``[b dim]Autocomplete[/b dim]: [i][/i] [bold]``" msgstr "String: ``[b dim]Autocomplete[/b dim]: [i][/i] [bold]``" -#: ../../root/api/app/index.rst:192 +#: ../../root/api/app/index.rst:193 msgid "Отображается как: ``Autocomplete: ``" msgstr "Displayed as: ``Autocomplete: ``" +#~ msgid "" +#~ "``ignore_command_register``: Если ``True``, регистр" +#~ " вводимых команд игнорируется при поиске" +#~ " обработчика." +#~ msgstr "" +#~ "``ignore_command_register``: If ``True``, command" +#~ " case is ignored when searching for" +#~ " a handler." + diff --git a/docs/locales/en/LC_MESSAGES/root/api/command/index.po b/docs/locales/en/LC_MESSAGES/root/api/command/index.po index b116603..4c73cc6 100644 --- a/docs/locales/en/LC_MESSAGES/root/api/command/index.po +++ b/docs/locales/en/LC_MESSAGES/root/api/command/index.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Argenta \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-12-04 20:39+0300\n" +"POT-Creation-Date: 2025-12-08 19:48+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -35,10 +35,10 @@ msgstr "" #: ../../root/api/command/index.rst:8 msgid "" "``Command`` инкапсулирует всю информацию о команде: её триггер (ключевое " -"слово для вызова), описание, набор флагов и список псевдонимов." +"слово для вызова), описание, набор флагов и множество псевдонимов." msgstr "" "``Command`` encapsulates all information about a command: its trigger " -"(keyword for invocation), description, set of flags, and list of aliases." +"(keyword for invocation), description, set of flags, and set of aliases." #: ../../root/api/command/index.rst:13 msgid "Инициализация" @@ -73,8 +73,8 @@ msgstr "" "``Flag`` object or a ``Flags`` collection." #: ../../root/api/command/index.rst:28 -msgid "``aliases``: Список строковых псевдонимов для основного триггера." -msgstr "``aliases``: List of string aliases for the main trigger." +msgid "``aliases``: Множество строковых псевдонимов для основного триггера." +msgstr "``aliases``: Set of string aliases for the main trigger." #: ../../root/api/command/index.rst:30 ../../root/api/command/index.rst:108 msgid "**Атрибуты:**" @@ -107,8 +107,8 @@ msgstr "" "during initialization." #: ../../root/api/command/index.rst:46 -msgid "Список строковых псевдонимов. Пуст, если псевдонимы не заданы." -msgstr "List of string aliases. Empty if no aliases are defined." +msgid "Множество строковых псевдонимов. Пуст, если псевдонимы не заданы." +msgstr "Set of string aliases. Empty if no aliases are defined." #: ../../root/api/command/index.rst:48 msgid "**Пример использования:**" @@ -119,8 +119,8 @@ msgid "" "Подробнее про флаги: :ref:`Flags ` и :ref:`Флаги " "команд `." msgstr "" -"More about flags: :ref:`Flags ` and :ref:`Command " -"flags `." +"More about flags: :ref:`Flags ` and :ref:`Command" +" flags `." #: ../../root/api/command/index.rst:59 msgid "Регистрация команд" diff --git a/docs/locales/en/LC_MESSAGES/root/api/router.po b/docs/locales/en/LC_MESSAGES/root/api/router.po index c572a30..e5c3d59 100644 --- a/docs/locales/en/LC_MESSAGES/root/api/router.po +++ b/docs/locales/en/LC_MESSAGES/root/api/router.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: 2025-12-08 19:48+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -30,8 +30,9 @@ msgid "" "набора функций." msgstr "" "``Router`` is the main building block for organizing logic in an " -"application. Its purpose is to group related commands and their handlers. " -"Each router represents a logical container for a specific set of functions." +"application. Its purpose is to group related commands and their handlers." +" Each router represents a logical container for a specific set of " +"functions." #: ../../root/api/router.rst:8 msgid "" @@ -56,8 +57,8 @@ msgid "" "``title``: Необязательный заголовок для группы команд. Отображается в " "списке доступных команд, помогая пользователю ориентироваться." msgstr "" -"``title``: Optional title for the command group. Displayed in the list of " -"available commands to help users navigate." +"``title``: Optional title for the command group. Displayed in the list of" +" available commands to help users navigate." #: ../../root/api/router.rst:24 msgid "" @@ -67,11 +68,11 @@ msgid "" "используется статическая разделительная линия. Подробнее см. в разделе " ":ref:`Переопределение стандартного вывода `." msgstr "" -"``disable_redirect_stdout``: If ``True``, disables ``stdout`` capture for " -"all commands in this router. This is necessary for interactive commands " -"(e.g., with ``input()``). When capture is disabled, a static separator line " -"is automatically used. See :ref:`Overriding standard output ` " -"for more details." +"``disable_redirect_stdout``: If ``True``, disables ``stdout`` capture for" +" all commands in this router. This is necessary for interactive commands " +"(e.g., with ``input()``). When capture is disabled, a static separator " +"line is automatically used. See :ref:`Overriding standard output " +"` for more details." #: ../../root/api/router.rst:29 msgid "Регистрация команд" @@ -82,7 +83,8 @@ msgid "" "Для регистрации команды и привязки к ней обработчика используется " "декоратор ``@command``." msgstr "" -"The ``@command`` decorator is used to register a command and bind a handler to it." +"The ``@command`` decorator is used to register a command and bind a " +"handler to it." #: ../../root/api/router.rst:35 msgid "Декоратор для регистрации функции как обработчика команды." @@ -98,9 +100,9 @@ msgid "" "Может быть строкой, которая станет триггером (без возможности настройки " "флагов и описания)." msgstr "" -"A ``Command`` instance describing the trigger, flags, and command description. " -"Can be a string that will become the trigger (without the ability to configure " -"flags and description)." +"A ``Command`` instance describing the trigger, flags, and command " +"description. Can be a string that will become the trigger (without the " +"ability to configure flags and description)." #: ../../root/api/router.rst:39 msgid "**Пример использования:**" @@ -130,12 +132,13 @@ msgstr "" #: ../../root/api/router.rst:57 msgid "" -"Вы можете добавлять свои команды в этот роутер. Для этого импортируйте " -"``argenta.router.defaults.system_router`` и используйте его декоратор " -"``@command``." +"Вы можете добавлять свои команды в этот роутер. Для этого используйте " +"атрибут ``.system_router`` у созданного экхемпляра ``Orchestrator`` и " +"используйте его декоратор ``@command``." msgstr "" -"You can add your own commands to this router. To do this, import " -"``argenta.router.defaults.system_router`` and use its ``@command`` decorator." +"You can add your own commands to this router. To do this, use the " +"``.system_router`` attribute of the created ``Orchestrator`` instance and" +" use its ``@command`` decorator." #: ../../root/api/router.rst:62 msgid "Возможные исключения" @@ -146,15 +149,16 @@ msgid "" "При регистрации команд и флагов в ``Router`` могут возникнуть следующие " "исключения:" msgstr "" -"The following exceptions may occur when registering commands and flags in ``Router``:" +"The following exceptions may occur when registering commands and flags in" +" ``Router``:" #: ../../root/api/router.rst:68 msgid "" "Выбрасывается, если триггер команды в ``Command`` содержит пробелы. " "Триггеры должны быть одним словом." msgstr "" -"Raised if the command trigger in ``Command`` contains spaces. " -"Triggers must be a single word." +"Raised if the command trigger in ``Command`` contains spaces. Triggers " +"must be a single word." #: ../../root/api/router.rst:70 msgid "**Неправильно:** ``Command(\"add user\")``" @@ -173,7 +177,8 @@ msgstr "" "Raised if duplicate names were used when defining flags for a command. " "Flag names within a single command must be unique." -#: ../../root/api/router.rst:78 +#: ../../root/api/router.rst:78 ../../root/api/router.rst:96 +#: ../../root/api/router.rst:115 msgid "**Пример, вызывающий исключение:**" msgstr "**Example that raises an exception:**" @@ -182,5 +187,23 @@ msgid "" "Возникает, если обработчик команды не принимает обязательный аргумент " "``Response``." msgstr "" -"Raised if the command handler does not accept the required ``Response`` argument." +"Raised if the command handler does not accept the required ``Response`` " +"argument." + +#: ../../root/api/router.rst:94 +msgid "" +"Возникает, если при регистрации команд в роутере были использованы " +"дублирующиеся триггеры. Каждая команда должна иметь уникальный триггер в " +"рамках приложения." +msgstr "" +"Raised if duplicate triggers were used when registering commands in the " +"router. Each command must have a unique trigger within a single router." + +#: ../../root/api/router.rst:113 +msgid "" +"Возникает, если при регистрации команд были использованы дублирующиеся " +"алиасы. Алиасы должны быть уникальны в рамках всего приложения." +msgstr "" +"Raised if duplicate aliases were used when registering commands. Aliases " +"must be unique within the entire router." diff --git a/docs/locales/en/LC_MESSAGES/root/redirect_stdout.po b/docs/locales/en/LC_MESSAGES/root/redirect_stdout.po index f558928..da061b1 100644 --- a/docs/locales/en/LC_MESSAGES/root/redirect_stdout.po +++ b/docs/locales/en/LC_MESSAGES/root/redirect_stdout.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Argenta \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-12-04 20:39+0300\n" +"POT-Creation-Date: 2025-12-08 19:48+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -46,17 +46,22 @@ msgstr "" "``Router``) if your commands:" #: ../../root/redirect_stdout.rst:15 -msgid "" -"✓ Используют ``input()`` для интерактивного ввода данных от пользователя " -"✓ Используют прогресс-бары (``tqdm``, ``rich.progress``) ✓ Выводят данные" -" в реальном времени (streaming, логи) ✓ Используют библиотеки, которые " -"напрямую работают с ``stdout``" -msgstr "" -"✓ Use ``input()`` for interactive user input ✓ Use progress bars " -"(``tqdm``, ``rich.progress``) ✓ Output data in real-time (streaming, " -"logs) ✓ Use libraries that work directly with ``stdout``" +msgid "✓ Используют ``input()`` для интерактивного ввода данных от пользователя" +msgstr "✓ Use ``input()`` for interactive user input" -#: ../../root/redirect_stdout.rst:20 +#: ../../root/redirect_stdout.rst:17 +msgid "✓ Используют прогресс-бары (``tqdm``, ``rich.progress``)" +msgstr "✓ Use progress bars (``tqdm``, ``rich.progress``)" + +#: ../../root/redirect_stdout.rst:19 +msgid "✓ Выводят данные в реальном времени (streaming, логи)" +msgstr "✓ Output data in real-time (streaming, logs)" + +#: ../../root/redirect_stdout.rst:21 +msgid "✓ Используют библиотеки, которые напрямую работают с ``stdout``" +msgstr "✓ Use libraries that work directly with ``stdout``" + +#: ../../root/redirect_stdout.rst:23 msgid "" "Для обычных команд с ``print()`` перехват можно оставить включённым — это" " не влияет на их работу." @@ -64,11 +69,11 @@ msgstr "" "For regular commands with ``print()``, interception can be left enabled —" " it does not affect their operation." -#: ../../root/redirect_stdout.rst:25 +#: ../../root/redirect_stdout.rst:28 msgid "Механизм перехвата ``stdout``" msgstr "``stdout`` Interception Mechanism" -#: ../../root/redirect_stdout.rst:27 +#: ../../root/redirect_stdout.rst:30 msgid "" "По умолчанию ``Argenta`` перехватывает весь текст, выводимый в ``stdout``" " внутри обработчика команды. Это необходимо для реализации **динамических" @@ -83,15 +88,15 @@ msgstr "" "draw the top and bottom borders. This approach creates a neat interface " "where the command output is \"wrapped\" in a frame fitted to its content." -#: ../../root/redirect_stdout.rst:29 +#: ../../root/redirect_stdout.rst:32 msgid "Пример приложения с динамической разделительной линией:" msgstr "Example of an application with a dynamic dividing line:" -#: ../../root/redirect_stdout.rst:31 +#: ../../root/redirect_stdout.rst:34 msgid "Example of an application with a dynamic dividing line" msgstr "Example of an application with a dynamic dividing line" -#: ../../root/redirect_stdout.rst:34 +#: ../../root/redirect_stdout.rst:37 msgid "" "Как вы можете заметить, разделительная линия ровно той же длины, что и " "самая длинная строка в выводе." @@ -99,15 +104,15 @@ msgstr "" "As you can see, the dividing line is exactly the same length as the " "longest line in the output." -#: ../../root/redirect_stdout.rst:36 +#: ../../root/redirect_stdout.rst:39 msgid "То же приложение с статической линией:" msgstr "The same application with a static line:" -#: ../../root/redirect_stdout.rst:38 +#: ../../root/redirect_stdout.rst:41 msgid "Example of an application with a static dividing line" msgstr "Example of an application with a static dividing line" -#: ../../root/redirect_stdout.rst:41 +#: ../../root/redirect_stdout.rst:44 msgid "" "В этом примере разделительная линия имеет фиксированную длину (по " "умолчанию 25 символов)." @@ -115,11 +120,11 @@ msgstr "" "In this example, the dividing line has a fixed length (25 characters by " "default)." -#: ../../root/redirect_stdout.rst:46 +#: ../../root/redirect_stdout.rst:49 msgid "Побочные эффекты перехвата ``stdout``" msgstr "Side Effects of ``stdout`` Interception" -#: ../../root/redirect_stdout.rst:48 +#: ../../root/redirect_stdout.rst:51 msgid "" "Побочный эффект этого механизма проявляется при использовании функций, " "которые последовательно выводят текст в консоль и ожидают ввод от " @@ -129,7 +134,7 @@ msgstr "" "sequentially output text to the console and expect user input. A classic " "example is the standard ``input()`` function." -#: ../../root/redirect_stdout.rst:57 +#: ../../root/redirect_stdout.rst:60 msgid "" "При включённом перехвате ``stdout`` текст (например, ``\"Введите ваше " "имя: \"``) **не будет выведен в консоль немедленно**. Он попадёт в буфер " @@ -141,11 +146,11 @@ msgstr "" " into a buffer and appear only after the handler finishes, along with the" " rest of the output. This can confuse the user." -#: ../../root/redirect_stdout.rst:62 +#: ../../root/redirect_stdout.rst:65 msgid "Отключение перехвата ``stdout`` с помощью ``disable_redirect_stdout``" msgstr "Disabling ``stdout`` Interception with ``disable_redirect_stdout``" -#: ../../root/redirect_stdout.rst:64 +#: ../../root/redirect_stdout.rst:67 msgid "" "Чтобы решить эту проблему, в конструкторе ``Router`` предусмотрен " "специальный аргумент:" @@ -153,11 +158,11 @@ msgstr "" "To solve this problem, the ``Router`` constructor provides a special " "argument:" -#: ../../root/redirect_stdout.rst:66 +#: ../../root/redirect_stdout.rst:69 msgid "**disable_redirect_stdout** (``bool``, по умолчанию ``False``)" msgstr "**disable_redirect_stdout** (``bool``, default ``False``)" -#: ../../root/redirect_stdout.rst:68 +#: ../../root/redirect_stdout.rst:71 msgid "" "Если при создании роутера установить ``disable_redirect_stdout=True``, " "механизм перехвата ``stdout`` будет отключён для всех его обработчиков." @@ -165,11 +170,11 @@ msgstr "" "If you set ``disable_redirect_stdout=True`` when creating a router, the " "``stdout`` interception mechanism will be disabled for all its handlers." -#: ../../root/redirect_stdout.rst:70 ../../root/redirect_stdout.rst:100 +#: ../../root/redirect_stdout.rst:73 ../../root/redirect_stdout.rst:103 msgid "**Пример использования:**" msgstr "**Usage example:**" -#: ../../root/redirect_stdout.rst:76 +#: ../../root/redirect_stdout.rst:79 msgid "" "В этом случае ``input()`` будет работать как обычно, и пользователь сразу" " увидит приглашение к вводу." @@ -177,11 +182,11 @@ msgstr "" "In this case, ``input()`` will work as usual, and the user will " "immediately see the input prompt." -#: ../../root/redirect_stdout.rst:81 +#: ../../root/redirect_stdout.rst:84 msgid "Типы разделительных линий" msgstr "Types of Dividing Lines" -#: ../../root/redirect_stdout.rst:83 +#: ../../root/redirect_stdout.rst:86 msgid "" "``Argenta`` поддерживает два типа разделителей, которые настраиваются при" " инициализации ``App``:" @@ -189,11 +194,11 @@ msgstr "" "``Argenta`` supports two types of dividers, which are configured during " "``App`` initialization:" -#: ../../root/redirect_stdout.rst:85 +#: ../../root/redirect_stdout.rst:88 msgid "**``DynamicDividingLine()``**" msgstr "**``DynamicDividingLine()``**" -#: ../../root/redirect_stdout.rst:86 +#: ../../root/redirect_stdout.rst:89 msgid "" "Поведение по умолчанию. Длина линии динамически подстраивается под самый " "длинный текст в выводе." @@ -201,7 +206,7 @@ msgstr "" "Default behavior. The line length dynamically adjusts to the longest text" " in the output." -#: ../../root/redirect_stdout.rst:87 +#: ../../root/redirect_stdout.rst:90 msgid "" "Требует включённого перехвата ``stdout`` " "(``disable_redirect_stdout=False`` в роутере)." @@ -209,11 +214,11 @@ msgstr "" "Requires enabled ``stdout`` interception " "(``disable_redirect_stdout=False`` in the router)." -#: ../../root/redirect_stdout.rst:89 +#: ../../root/redirect_stdout.rst:92 msgid "**``StaticDividingLine(length: int = 25)``**" msgstr "**``StaticDividingLine(length: int = 25)``**" -#: ../../root/redirect_stdout.rst:90 +#: ../../root/redirect_stdout.rst:93 msgid "" "Линия имеет фиксированную длину (по умолчанию 25 символов), которую можно" " задать через аргумент ``length``." @@ -221,7 +226,7 @@ msgstr "" "The line has a fixed length (25 characters by default), which can be set " "via the ``length`` argument." -#: ../../root/redirect_stdout.rst:91 +#: ../../root/redirect_stdout.rst:94 msgid "" "Используется принудительно для роутеров с " "``disable_redirect_stdout=True``, так как без перехвата вывода невозможно" @@ -230,11 +235,11 @@ msgstr "" "Used forcibly for routers with ``disable_redirect_stdout=True``, as it is" " impossible to determine dynamic length without output interception." -#: ../../root/redirect_stdout.rst:96 +#: ../../root/redirect_stdout.rst:99 msgid "Настройка разделительной линии в ``App``" msgstr "Configuring the Dividing Line in ``App``" -#: ../../root/redirect_stdout.rst:98 +#: ../../root/redirect_stdout.rst:101 msgid "" "Вы можете глобально задать тип разделителя для всего приложения через " "аргумент ``dividing_line`` в конструкторе ``App``." @@ -242,63 +247,63 @@ msgstr "" "You can globally set the divider type for the entire application via the " "``dividing_line`` argument in the ``App`` constructor." -#: ../../root/redirect_stdout.rst:109 +#: ../../root/redirect_stdout.rst:112 msgid "Итоговое поведение" msgstr "Resulting Behavior" -#: ../../root/redirect_stdout.rst:115 +#: ../../root/redirect_stdout.rst:118 msgid "``disable_redirect_stdout`` на ``Router``" msgstr "``disable_redirect_stdout`` on ``Router``" -#: ../../root/redirect_stdout.rst:116 +#: ../../root/redirect_stdout.rst:119 msgid "Тип линии в ``App``" msgstr "Line type in ``App``" -#: ../../root/redirect_stdout.rst:117 +#: ../../root/redirect_stdout.rst:120 msgid "Фактическое поведение" msgstr "Actual behavior" -#: ../../root/redirect_stdout.rst:118 +#: ../../root/redirect_stdout.rst:121 msgid "``input()`` работает корректно?" msgstr "Does ``input()`` work correctly?" -#: ../../root/redirect_stdout.rst:119 ../../root/redirect_stdout.rst:123 +#: ../../root/redirect_stdout.rst:122 ../../root/redirect_stdout.rst:126 msgid "``False`` (по умолчанию)" msgstr "``False`` (default)" -#: ../../root/redirect_stdout.rst:120 ../../root/redirect_stdout.rst:128 +#: ../../root/redirect_stdout.rst:123 ../../root/redirect_stdout.rst:131 msgid "``DynamicDividingLine``" msgstr "``DynamicDividingLine``" -#: ../../root/redirect_stdout.rst:121 +#: ../../root/redirect_stdout.rst:124 msgid "Динамическая линия, длина по содержимому" msgstr "Dynamic line, length by content" -#: ../../root/redirect_stdout.rst:122 ../../root/redirect_stdout.rst:126 +#: ../../root/redirect_stdout.rst:125 ../../root/redirect_stdout.rst:129 msgid "Нет" msgstr "No" -#: ../../root/redirect_stdout.rst:124 ../../root/redirect_stdout.rst:132 +#: ../../root/redirect_stdout.rst:127 ../../root/redirect_stdout.rst:135 msgid "``StaticDividingLine``" msgstr "``StaticDividingLine``" -#: ../../root/redirect_stdout.rst:125 ../../root/redirect_stdout.rst:133 +#: ../../root/redirect_stdout.rst:128 ../../root/redirect_stdout.rst:136 msgid "Статическая линия указанной длины" msgstr "Static line of specified length" -#: ../../root/redirect_stdout.rst:127 ../../root/redirect_stdout.rst:131 +#: ../../root/redirect_stdout.rst:130 ../../root/redirect_stdout.rst:134 msgid "``True``" msgstr "``True``" -#: ../../root/redirect_stdout.rst:129 +#: ../../root/redirect_stdout.rst:132 msgid "**Принудительно статическая линия** (длина по умолч.)" msgstr "**Forcibly static line** (default length)" -#: ../../root/redirect_stdout.rst:130 ../../root/redirect_stdout.rst:134 +#: ../../root/redirect_stdout.rst:133 ../../root/redirect_stdout.rst:137 msgid "Да" msgstr "Yes" -#: ../../root/redirect_stdout.rst:136 +#: ../../root/redirect_stdout.rst:139 msgid "" "Таким образом, для интерактивных команд, требующих ввода от пользователя," " отключайте перехват ``stdout`` на уровне роутера. Для всех остальных " diff --git a/docs/root/api/app/index.rst b/docs/root/api/app/index.rst index f099c18..9646edc 100644 --- a/docs/root/api/app/index.rst +++ b/docs/root/api/app/index.rst @@ -28,7 +28,6 @@ App farewell_message: str = "\nSee you\n", exit_command: Command = DEFAULT_EXIT_COMMAND, system_router_title: str | None = "System points:", - ignore_command_register: bool = True, dividing_line: AVAILABLE_DIVIDING_LINES = DEFAULT_DIVIDING_LINE, repeat_command_groups_printing: bool = False, override_system_messages: bool = False, @@ -42,7 +41,6 @@ App * ``farewell_message``: Сообщение, выводимое при выходе из приложения. * ``exit_command``: Команда, которая маркируется как триггер для выхода из приложения. * ``system_router_title``: Заголовок для системного роутера (содержит команду выхода). - * ``ignore_command_register``: Если ``True``, регистр вводимых команд игнорируется при поиске обработчика. * ``dividing_line``: Тип разделительной линии (``StaticDividingLine`` или ``DynamicDividingLine``). * ``repeat_command_groups_printing``: Если ``True``, список доступных команд выводится перед каждым вводом. * ``override_system_messages``: Если ``True``, стандартное форматирование (цвета, ASCII-арт) отключается. @@ -50,6 +48,9 @@ App * ``print_func``: Функция для вывода всех системных сообщений (по умолчанию ``rich.Console().print``). ----- + +.. note:: + В приложениях на Argenta регистр вводимых команд не важен, проверка на существование и роутинг команд производится на основании триггеров, приведённых к нижнему регистру. Основные методы --------------- diff --git a/docs/root/api/command/index.rst b/docs/root/api/command/index.rst index f3afcdf..beac9dc 100644 --- a/docs/root/api/command/index.rst +++ b/docs/root/api/command/index.rst @@ -5,7 +5,7 @@ Command ``Command`` — это основная единица функциональности в приложении. Каждая команда связывает хэндлер с триггером, введя который он будет вызван для обработки. -``Command`` инкапсулирует всю информацию о команде: её триггер (ключевое слово для вызова), описание, набор флагов и список псевдонимов. +``Command`` инкапсулирует всю информацию о команде: её триггер (ключевое слово для вызова), описание, набор флагов и множество псевдонимов. ----- @@ -18,14 +18,14 @@ Command __init__(self, trigger: str, *, description: str | None = None, flags: Flag | Flags = DEFAULT_WITHOUT_FLAGS, - aliases: list[str] | list[Never] = DEFAULT_WITHOUT_ALIASES) -> None + aliases: set[str] = DEFAULT_WITHOUT_ALIASES) -> None Создаёт новую команду для регистрации в роутере. * ``trigger``: Строковый триггер, который пользователь вводит для вызова команды. Является основным идентификатором. * ``description``: Необязательное описание, объясняющее назначение команды. Отображается в справке. * ``flags``: Набор флагов для настройки поведения. Может быть одиночным объектом ``Flag`` или коллекцией ``Flags``. -* ``aliases``: Список строковых псевдонимов для основного триггера. +* ``aliases``: Множество строковых псевдонимов для основного триггера. **Атрибуты:** @@ -43,7 +43,7 @@ Command .. py:attribute:: aliases - Список строковых псевдонимов. Пуст, если псевдонимы не заданы. + Множество строковых псевдонимов. Пуст, если псевдонимы не заданы. **Пример использования:** diff --git a/docs/root/api/router.rst b/docs/root/api/router.rst index 5bd3ca2..a0879c5 100644 --- a/docs/root/api/router.rst +++ b/docs/root/api/router.rst @@ -54,7 +54,7 @@ Router Предопределённый экземпляр ``Router`` с базовыми системными командами (по умолчанию — команда выхода). Имеет заголовок **«System points:»**, который можно переопределить в ``App``. - Вы можете добавлять свои команды в этот роутер. Для этого импортируйте ``argenta.router.defaults.system_router`` и используйте его декоратор ``@command``. + Вы можете добавлять свои команды в этот роутер. Для этого используйте атрибут ``.system_router`` у созданного экхемпляра ``Orchestrator`` и используйте его декоратор ``@command``. ----- @@ -89,3 +89,41 @@ Router Возникает, если обработчик команды не принимает обязательный аргумент ``Response``. +.. py:exception:: RepeatedTriggerNameException + + Возникает, если при регистрации команд в роутере были использованы дублирующиеся триггеры. Каждая команда должна иметь уникальный триггер в рамках приложения. + + **Пример, вызывающий исключение:** + + .. code-block:: python + :linenos: + + router = Router() + + @router.command(Command("start")) + def start_handler(response: Response) -> None: + pass + + @router.command(Command("start")) # Duplicate trigger! + def another_start_handler(response: Response) -> None: + pass + +.. py:exception:: RepeatedAliasNameException + + Возникает, если при регистрации команд были использованы дублирующиеся алиасы. Алиасы должны быть уникальны в рамках всего приложения. + + **Пример, вызывающий исключение:** + + .. code-block:: python + :linenos: + + router = Router() + + @router.command(Command("start", aliases={"s", "run"})) + def start_handler(response: Response) -> None: + pass + + @router.command(Command("begin", aliases={"s"})) # Duplicate alias "s"! + def begin_handler(response: Response) -> None: + pass + diff --git a/docs/root/redirect_stdout.rst b/docs/root/redirect_stdout.rst index 2fca44c..aa7540c 100644 --- a/docs/root/redirect_stdout.rst +++ b/docs/root/redirect_stdout.rst @@ -12,10 +12,13 @@ Отключайте перехват ``stdout`` (``disable_redirect_stdout=True`` в ``Router``), если ваши команды: -✓ Используют ``input()`` для интерактивного ввода данных от пользователя -✓ Используют прогресс-бары (``tqdm``, ``rich.progress``) -✓ Выводят данные в реальном времени (streaming, логи) -✓ Используют библиотеки, которые напрямую работают с ``stdout`` +✓ Используют ``input()`` для интерактивного ввода данных от пользователя + +✓ Используют прогресс-бары (``tqdm``, ``rich.progress``) + +✓ Выводят данные в реальном времени (streaming, логи) + +✓ Используют библиотеки, которые напрямую работают с ``stdout`` Для обычных команд с ``print()`` перехват можно оставить включённым — это не влияет на их работу. diff --git a/metrics/__init__.py b/metrics/__init__.py new file mode 100644 index 0000000..63f99dd --- /dev/null +++ b/metrics/__init__.py @@ -0,0 +1 @@ +from .benchmarks import * \ No newline at end of file diff --git a/metrics/__main__.py b/metrics/__main__.py new file mode 100644 index 0000000..6e7f0c4 --- /dev/null +++ b/metrics/__main__.py @@ -0,0 +1,49 @@ +from concurrent.futures import ProcessPoolExecutor +import os + +from rich.console import Console +from rich.table import Table +from rich.panel import Panel +from rich.text import Text + +from metrics.utils import run_benchmark, BenchmarkResult +from .registry import Benchmarks, Benchmark + + +def main(): + console = Console() + all_benchmarks: list[Benchmark] = Benchmarks.get_benchmarks() + + workers = os.cpu_count() or 1 + with ProcessPoolExecutor(max_workers=workers) as executor: + results = executor.map(run_benchmark, all_benchmarks) + + type_paired_benchmarks: dict[str, list[BenchmarkResult]] = {} + + for result in results: + type_paired_benchmarks.setdefault(result.type_, []).append(result) + + for type_, benchmarks in type_paired_benchmarks.items(): + header_text = Text(f"TYPE: {type_.upper()}", style="bold magenta") + console.print(Panel(header_text, expand=False, border_style="magenta")) + + table = Table(show_header=True, header_style="bold cyan", border_style="blue", show_lines=True) + table.add_column("Name", style="green") + table.add_column("Description", style="dim") + table.add_column("Iterations", justify="right") + table.add_column("Avg Time (ms)", justify="right", style="bold yellow") + + for benchmark in benchmarks: + table.add_row( + benchmark.name, + benchmark.description, + str(benchmark.iterations), + str(benchmark.avg_time) + ) + + console.print(table) + console.print() + + +if __name__ == "__main__": + main() diff --git a/metrics/benchmarks/__init__.py b/metrics/benchmarks/__init__.py new file mode 100644 index 0000000..64424de --- /dev/null +++ b/metrics/benchmarks/__init__.py @@ -0,0 +1 @@ +from .pre_cycle_setup import * \ No newline at end of file diff --git a/metrics/benchmarks/pre_cycle_setup.py b/metrics/benchmarks/pre_cycle_setup.py new file mode 100644 index 0000000..4d0e386 --- /dev/null +++ b/metrics/benchmarks/pre_cycle_setup.py @@ -0,0 +1,125 @@ +__all__ = [ + "benchmark_no_aliases", + "benchmark_many_aliases", + "benchmark_few_aliases", + "benchmark_extreme_aliases", + "benchmark_very_many_aliases" +] + +from argenta import App +from argenta.router import Router +from argenta.command.models import Command +from argenta.response import Response + +from ..utils import get_time_of_pre_cycle_setup +from ..registry import benchmark + + +@benchmark(type_="pre_cycle_setup", description="With no aliases") +def benchmark_no_aliases() -> float: + app = App(override_system_messages=True) + router = Router() + + @router.command(Command('command1')) + def handler1(_res: Response) -> None: + pass + + @router.command(Command('command2')) + def handler2(_res: Response) -> None: + pass + + @router.command(Command('command3')) + def handler3(_res: Response) -> None: + pass + + app.include_router(router) + execution_time = get_time_of_pre_cycle_setup(app) + return execution_time + + +@benchmark(type_="pre_cycle_setup", description="With few aliases (6 total)") +def benchmark_few_aliases() -> float: + app = App(override_system_messages=True) + router = Router() + + @router.command(Command('command1', aliases={'c1', 'cmd1'})) + def handler1(_res: Response) -> None: + pass + + @router.command(Command('command2', aliases={'c2', 'cmd2'})) + def handler2(_res: Response) -> None: + pass + + @router.command(Command('command3', aliases={'c3', 'cmd3'})) + def handler3(_res: Response) -> None: + pass + + app.include_router(router) + execution_time = get_time_of_pre_cycle_setup(app) + return execution_time + + +@benchmark(type_="pre_cycle_setup", description="With many aliases (15 total)") +def benchmark_many_aliases() -> float: + app = App(override_system_messages=True) + router = Router() + + @router.command(Command('command1', aliases={'c1', 'cmd1', 'com1', 'first', 'one'})) + def handler1(_res: Response) -> None: + pass + + @router.command(Command('command2', aliases={'c2', 'cmd2', 'com2', 'second', 'two'})) + def handler2(_res: Response) -> None: + pass + + @router.command(Command('command3', aliases={'c3', 'cmd3', 'com3', 'third', 'three'})) + def handler3(_res: Response) -> None: + pass + + app.include_router(router) + execution_time = get_time_of_pre_cycle_setup(app) + return execution_time + + +@benchmark(type_="pre_cycle_setup", description="With very many aliases (60 total)") +def benchmark_very_many_aliases() -> float: + app = App(override_system_messages=True) + router = Router() + + @router.command(Command('command1', aliases={f'alias1_{i}' for i in range(20)})) + def handler1(_res: Response) -> None: + pass + + @router.command(Command('command2', aliases={f'alias2_{i}' for i in range(20)})) + def handler2(_res: Response) -> None: + pass + + @router.command(Command('command3', aliases={f'alias3_{i}' for i in range(20)})) + def handler3(_res: Response) -> None: + pass + + app.include_router(router) + execution_time = get_time_of_pre_cycle_setup(app) + return execution_time + + +@benchmark(type_="pre_cycle_setup", description="With extreme aliases (300 total)") +def benchmark_extreme_aliases() -> float: + app = App(override_system_messages=True) + router = Router() + + @router.command(Command('command1', aliases={f'alias1_{i}' for i in range(100)})) + def handler1(_res: Response) -> None: + pass + + @router.command(Command('command2', aliases={f'alias2_{i}' for i in range(100)})) + def handler2(_res: Response) -> None: + pass + + @router.command(Command('command3', aliases={f'alias3_{i}' for i in range(100)})) + def handler3(_res: Response) -> None: + pass + + app.include_router(router) + execution_time = get_time_of_pre_cycle_setup(app) + return execution_time diff --git a/metrics/registry.py b/metrics/registry.py new file mode 100644 index 0000000..40c9537 --- /dev/null +++ b/metrics/registry.py @@ -0,0 +1,98 @@ +__all__ = [ + "Benchmark", + "Benchmarks", + "benchmark" +] + +from typing import Callable, ClassVar, overload, override + +BenchmarkAsFunc = Callable[[], float] + + +class Benchmark: + def __init__( + self, + func: BenchmarkAsFunc, + *, + type_: str, + name: str, + description: str, + iterations: int + ) -> None: + self.func = func + self.type_ = type_ + self.name = name + self.description = description + self.iterations = iterations + + def run(self) -> float: + return self.func() + + @override + def __repr__(self) -> str: + return f'Benchmark<{self.type_=}, {self.name=}, {self.description=}, {self.iterations=}>' + + @override + def __str__(self) -> str: + return f'Benchmark({self.type_=}, {self.name=}, {self.description=}, {self.iterations=})' + + +class Benchmarks: + _benchmarks: ClassVar[list[Benchmark]] = [] + + @overload + @classmethod + def register( + cls, + call: BenchmarkAsFunc, + *, + type_: str = "", + description: str = "", + iterations: int = 100, + ) -> BenchmarkAsFunc: + ... + + @overload + @classmethod + def register( + cls, + call: None = None, + *, + type_: str = "", + description: str = "", + iterations: int = 100, + ) -> Callable[[BenchmarkAsFunc], BenchmarkAsFunc]: + ... + + @classmethod + def register( + cls, + call: BenchmarkAsFunc | None = None, + *, + type_: str = "", + description: str = "", + iterations: int = 100, + ) -> Callable[[BenchmarkAsFunc], BenchmarkAsFunc] | BenchmarkAsFunc: + def decorator(func: BenchmarkAsFunc) -> BenchmarkAsFunc: + cls._benchmarks.append( + Benchmark( + func, + type_=type_, + name=func.__name__, + description=description or f'description for {func.__name__} with {iterations} iterations', + iterations=iterations + ) + ) + return func + + if call is None: + return decorator + else: + return decorator(call) + + @classmethod + def get_benchmarks(cls) -> list[Benchmark]: + return cls._benchmarks + + +benchmark = Benchmarks.register diff --git a/metrics/utils.py b/metrics/utils.py new file mode 100644 index 0000000..2153323 --- /dev/null +++ b/metrics/utils.py @@ -0,0 +1,44 @@ +__all__ = [ + "get_time_of_pre_cycle_setup", + "attempts_to_average", + "run_benchmark", + "BenchmarkResult" +] + +import io +from contextlib import redirect_stdout +import time +from dataclasses import dataclass +from decimal import Decimal, ROUND_HALF_UP + +from argenta import App +from metrics.registry import Benchmark + + +def get_time_of_pre_cycle_setup(app: App) -> float: + start = time.perf_counter() + with redirect_stdout(io.StringIO()): + app._pre_cycle_setup() # pyright: ignore[reportPrivateUsage] + end = time.perf_counter() + return (end - start) * 1000 # as milliseconds + + +def attempts_to_average(bench_attempts: list[float], iterations: int) -> Decimal: + return Decimal(sum(bench_attempts) / iterations).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) + + +@dataclass(frozen=True) +class BenchmarkResult: + type_: str + name: str + description: str + iterations: int + avg_time: Decimal + + +def run_benchmark(benchmark: Benchmark) -> BenchmarkResult: + bench_attempts: list[float] = [] + for _ in range(benchmark.iterations): + bench_attempts.append(benchmark.run()) + avg = attempts_to_average(bench_attempts, benchmark.iterations) + return BenchmarkResult(benchmark.type_, benchmark.name, benchmark.description, benchmark.iterations, avg) diff --git a/metrics_tests/__init__.py b/metrics_tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/metrics_tests/time_of_precycle_setup.py b/metrics_tests/time_of_precycle_setup.py deleted file mode 100644 index 83e36f5..0000000 --- a/metrics_tests/time_of_precycle_setup.py +++ /dev/null @@ -1,32 +0,0 @@ -from argenta.app import App -from argenta.command import Command -from argenta.metrics import get_time_of_pre_cycle_setup -from argenta.response import Response -from argenta.router import Router - - -def commands_with_two_aliases(num_of_commands: int): - router = Router() - - for i in range(num_of_commands): - @router.command(Command(f'cmd{i}', aliases=[f'cdr{i}', f'prt{i}'])) - def handler(response: Response): # pyright: ignore[reportUnusedFunction, reportUnusedParameter] - pass - - app = App() - app.include_router(router) - - return get_time_of_pre_cycle_setup(app) - -def commands_with_one_aliases(num_of_commands: int): - router = Router() - - for i in range(num_of_commands): - @router.command(Command(f'cmd{i}', aliases=[f'cdr{i}'])) - def handler(response: Response): # pyright: ignore[reportUnusedFunction, reportUnusedParameter] - pass - - app = App() - app.include_router(router) - - return get_time_of_pre_cycle_setup(app) diff --git a/mock/local_test.py b/mock/local_test.py index 7061395..9e20232 100644 --- a/mock/local_test.py +++ b/mock/local_test.py @@ -1,19 +1,61 @@ -from argenta import App, DataBridge, Response, Router -from argenta.di import FromDishka -from argenta.di.integration import setup_dishka, _auto_inject_handlers -from argenta.di.providers import SystemProvider -from dishka import make_container +import math -container = make_container() -Response.patch_by_container(container) - -app = App() -router = Router() - -@router.command('command') -def handler(res: Response, data_bridge: FromDishka[DataBridge]): - print(data_bridge) +def estimate_nth_prime_upper_bound(n: int): + if n < 6: + return 15 -_auto_inject_handlers(app) -_auto_inject_handlers(app) + 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)) + + +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)} + + 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]]) + + next_prime_after_max = [x[0] for x in nums2.items() if x[1]][-1] + + return [ + primes, + max_prime, + next_prime_after_max + ] + +print(odd_dig_primes(13)) \ No newline at end of file diff --git a/mock/min_app/main.py b/mock/min_app/main.py index 97eb5e9..a2b4326 100644 --- a/mock/min_app/main.py +++ b/mock/min_app/main.py @@ -1,9 +1,10 @@ # main.py from argenta import App, Orchestrator +from argenta.app import DynamicDividingLine from .routers import router -app: App = App() +app: App = App(prompt='>>> ', dividing_line=DynamicDividingLine('~')) orchestrator: Orchestrator = Orchestrator() def main() -> None: diff --git a/mock/mock_app/main.py b/mock/mock_app/main.py index 0e07271..1305567 100644 --- a/mock/mock_app/main.py +++ b/mock/mock_app/main.py @@ -1,16 +1,13 @@ from argenta import App, Orchestrator from argenta.app import PredefinedMessages -from argenta.orchestrator.argparser import ArgParser, BooleanArgument from argenta.app.dividing_line.models import DynamicDividingLine from mock.mock_app.routers import work_router app: App = App( dividing_line=DynamicDividingLine('^'), ) -argparser = ArgParser([BooleanArgument('some')]) -orchestrator: Orchestrator = Orchestrator(argparser) +orchestrator: Orchestrator = Orchestrator() -print(argparser.parsed_argspace.get_by_type(BooleanArgument)) def main(): app.include_router(work_router) @@ -22,5 +19,5 @@ def main(): orchestrator.start_polling(app) if __name__ == "__main__": - orchestrator.start_polling(app) + main() \ No newline at end of file diff --git a/mock/mock_app/routers.py b/mock/mock_app/routers.py index d75a91f..a433c50 100644 --- a/mock/mock_app/routers.py +++ b/mock/mock_app/routers.py @@ -4,7 +4,14 @@ from argenta.command import Flag, Flags work_router: Router = Router(title="Base points:", disable_redirect_stdout=True) -@work_router.command(Command("hello", flags=Flags(Flag("test")), description="Hello, world!")) +@work_router.command( + Command( + "hello", + flags=Flags([ + Flag("test") + ]), + description="Hello, world!") +) def command_help(response: Response): c = input("Enter your name: ") print(f"Hello, {c}!") diff --git a/pyproject.toml b/pyproject.toml index eb066f7..eeabf62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,11 @@ root = "tests/" reportPrivateUsage = false reportUnusedFunction = false +[[tool.pyright.executionEnvironments]] +root = "metrics/" +reportPrivateUsage = false +reportUnusedFunction = false + [tool.coverage.run] branch = true omit = [ diff --git a/src/argenta/app/autocompleter/entity.py b/src/argenta/app/autocompleter/entity.py index 7f1479f..a50bad0 100644 --- a/src/argenta/app/autocompleter/entity.py +++ b/src/argenta/app/autocompleter/entity.py @@ -6,7 +6,9 @@ from typing import Never class AutoCompleter: - def __init__(self, history_filename: str | None = None, autocomplete_button: str = "tab") -> None: + def __init__( + 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 @@ -23,12 +25,18 @@ class AutoCompleter: :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)) + 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]: + 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: @@ -40,7 +48,7 @@ class AutoCompleter: else: return None - def initial_setup(self, all_commands: list[str]) -> 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 @@ -61,7 +69,7 @@ class AutoCompleter: readline.set_completer_delims(readline.get_completer_delims().replace(" ", "")) readline.parse_and_bind(f"{self.autocomplete_button}: complete") - def exit_setup(self, all_commands: list[str], ignore_command_register: bool) -> None: + def exit_setup(self, all_commands: set[str]) -> None: """ Private. Exit setup function :return: None @@ -72,21 +80,18 @@ class AutoCompleter: raw_history = history_file.read() pretty_history: list[str] = [] for line in set(raw_history.strip().split("\n")): - if _is_command_exist(line.split()[0], all_commands, ignore_command_register): + 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)) -def _is_command_exist(command: str, existing_commands: list[str], ignore_command_register: bool) -> bool: - if ignore_command_register: - return command.lower() in existing_commands - return command in existing_commands - - 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)] + return [ + readline.get_history_item(i) + for i in range(1, readline.get_current_history_length() + 1) + ] diff --git a/src/argenta/app/models.py b/src/argenta/app/models.py index 1da6c74..2bf37f8 100644 --- a/src/argenta/app/models.py +++ b/src/argenta/app/models.py @@ -3,7 +3,7 @@ __all__ = ["App"] import io import re from contextlib import redirect_stdout -from typing import Never, TypeAlias +from typing import Callable, Never, TypeAlias from art import text2art from rich.console import Console @@ -23,13 +23,15 @@ from argenta.command.exceptions import ( RepeatedInputFlagsException, UnprocessedInputFlagException, ) +from argenta.router.exceptions import RepeatedAliasNameException, RepeatedTriggerNameException from argenta.command.models import Command, InputCommand from argenta.response import Response from argenta.router import Router -from argenta.router.defaults import system_router Matches: TypeAlias = list[str] | list[Never] +_ANSI_ESCAPE_RE: re.Pattern[str] = re.compile(r"\u001b\[[0-9;]*m") + class BaseApp: def __init__( @@ -40,7 +42,6 @@ class BaseApp: farewell_message: str, exit_command: Command, system_router_title: str, - ignore_command_register: bool, dividing_line: StaticDividingLine | DynamicDividingLine, repeat_command_groups_printing: bool, override_system_messages: bool, @@ -50,43 +51,37 @@ class BaseApp: self._prompt: str = prompt self._print_func: Printer = print_func self._exit_command: Command = exit_command - self._system_router_title: str = system_router_title self._dividing_line: StaticDividingLine | DynamicDividingLine = dividing_line - self._ignore_command_register: bool = ignore_command_register - self._repeat_command_groups_printing_description: bool = repeat_command_groups_printing + self._repeat_command_groups_printing: bool = repeat_command_groups_printing self._override_system_messages: bool = override_system_messages self._autocompleter: AutoCompleter = autocompleter + self.system_router: Router = Router(title=system_router_title) self._farewell_message: str = farewell_message self._initial_message: str = initial_message + self._stdout_buffer: io.StringIO = io.StringIO() + self._description_message_gen: DescriptionMessageGenerator = ( lambda command, description: f"{command} *=*=* {description}" ) self.registered_routers: RegisteredRouters = RegisteredRouters() self._messages_on_startup: list[str] = [] - self._matching_lower_triggers_with_routers: dict[str, Router] = {} - self._matching_default_triggers_with_routers: dict[str, Router] = {} - - self._current_matching_triggers_with_routers: dict[str, Router] = ( - self._matching_lower_triggers_with_routers - if self._ignore_command_register - else self._matching_default_triggers_with_routers + self._incorrect_input_syntax_handler: NonStandardBehaviorHandler[str] = ( + lambda _: print_func(f"Incorrect flag syntax: {_}") ) - - self._incorrect_input_syntax_handler: NonStandardBehaviorHandler[str] = lambda _: print_func( - f"Incorrect flag syntax: {_}" + self._repeated_input_flags_handler: NonStandardBehaviorHandler[str] = ( + lambda _: print_func(f"Repeated input flags: {_}") ) - self._repeated_input_flags_handler: NonStandardBehaviorHandler[str] = lambda _: print_func( - f"Repeated input flags: {_}" + self._empty_input_command_handler: EmptyCommandHandler = lambda: print_func( + "Empty input command" ) - self._empty_input_command_handler: EmptyCommandHandler = lambda: print_func("Empty input command") - self._unknown_command_handler: NonStandardBehaviorHandler[InputCommand] = lambda _: print_func( - f"Unknown command: {_.trigger}" + self._unknown_command_handler: NonStandardBehaviorHandler[InputCommand] = ( + lambda _: print_func(f"Unknown command: {_.trigger}") ) - self._exit_command_handler: NonStandardBehaviorHandler[Response] = lambda _: print_func( - self._farewell_message + self._exit_command_handler: NonStandardBehaviorHandler[Response] = ( + lambda _: print_func(self._farewell_message) ) def set_description_message_pattern(self, _: DescriptionMessageGenerator, /) -> None: @@ -97,7 +92,9 @@ class BaseApp: """ self._description_message_gen = _ - def set_incorrect_input_syntax_handler(self, _: NonStandardBehaviorHandler[str], /) -> None: + def set_incorrect_input_syntax_handler( + self, _: NonStandardBehaviorHandler[str], / + ) -> None: """ Public. Sets the handler for incorrect flags when entering a command :param _: handler for incorrect flags when entering a command @@ -105,7 +102,9 @@ class BaseApp: """ self._incorrect_input_syntax_handler = _ - def set_repeated_input_flags_handler(self, _: NonStandardBehaviorHandler[str], /) -> None: + def set_repeated_input_flags_handler( + self, _: NonStandardBehaviorHandler[str], / + ) -> None: """ Public. Sets the handler for repeated flags when entering a command :param _: handler for repeated flags when entering a command @@ -113,7 +112,9 @@ class BaseApp: """ self._repeated_input_flags_handler = _ - def set_unknown_command_handler(self, _: NonStandardBehaviorHandler[InputCommand], /) -> None: + def set_unknown_command_handler( + self, _: NonStandardBehaviorHandler[InputCommand], / + ) -> None: """ Public. Sets the handler for unknown commands when entering a command :param _: handler for unknown commands when entering a command @@ -129,7 +130,9 @@ class BaseApp: """ self._empty_input_command_handler = _ - def set_exit_command_handler(self, _: NonStandardBehaviorHandler[Response], /) -> None: + def set_exit_command_handler( + self, _: NonStandardBehaviorHandler[Response], / + ) -> None: """ Public. Sets the handler for exit command when entering a command :param _: handler for exit command when entering a command @@ -161,10 +164,14 @@ class BaseApp: :return: None """ if isinstance(self._dividing_line, DynamicDividingLine): - clear_text = re.sub(r"\u001b\[[0-9;]*m", "", text) + clear_text = _ANSI_ESCAPE_RE.sub("", text) max_length_line = max([len(line) for line in clear_text.split("\n")]) max_length_line = ( - max_length_line if 10 <= max_length_line <= 80 else 80 if max_length_line > 80 else 10 + max_length_line + if 10 <= max_length_line <= 80 + else 80 + if max_length_line > 80 + else 10 ) self._print_func( @@ -181,11 +188,15 @@ class BaseApp: elif isinstance(self._dividing_line, StaticDividingLine): # pyright: ignore[reportUnnecessaryIsInstance] self._print_func( - self._dividing_line.get_full_static_line(is_override=self._override_system_messages) + self._dividing_line.get_full_static_line( + is_override=self._override_system_messages + ) ) print(text.strip("\n")) self._print_func( - self._dividing_line.get_full_static_line(is_override=self._override_system_messages) + self._dividing_line.get_full_static_line( + is_override=self._override_system_messages + ) ) else: @@ -199,32 +210,28 @@ class BaseApp: """ trigger = command.trigger exit_trigger = self._exit_command.trigger - if self._ignore_command_register: - if trigger.lower() == exit_trigger.lower(): - return True - elif trigger.lower() in [x.lower() for x in self._exit_command.aliases]: - return True - else: - if trigger == exit_trigger: - return True - elif trigger in self._exit_command.aliases: - return True + if trigger.lower() == exit_trigger.lower(): + return True + elif trigger.lower() in [x.lower() for x in self._exit_command.aliases]: + return True + return False + + def _is_unknown_command(self, input_command: InputCommand) -> bool: + if not self.registered_routers.get_router_by_trigger(input_command.trigger.lower()): + return True return False - def _is_unknown_command(self, command: InputCommand) -> bool: + def _capture_stdout(self, func: Callable[[], None]) -> str: """ - Private. Checks if the given command is an unknown command - :param command: command to check - :return: is it an unknown command or not as bool + Private. Captures stdout from a function call using a reusable buffer + :param func: function to execute with captured stdout + :return: captured stdout as string """ - input_command_trigger = command.trigger - if self._ignore_command_register: - if input_command_trigger.lower() in list(self._current_matching_triggers_with_routers.keys()): - return False - else: - if input_command_trigger in list(self._current_matching_triggers_with_routers.keys()): - return False - return True + self._stdout_buffer.seek(0) + self._stdout_buffer.truncate(0) + with redirect_stdout(self._stdout_buffer): + func() + return self._stdout_buffer.getvalue() def _error_handler(self, error: InputCommandException, raw_command: str) -> None: """ @@ -245,17 +252,38 @@ class BaseApp: Private. Sets up system router :return: None """ - system_router.title = self._system_router_title - @system_router.command(self._exit_command) + @self.system_router.command(self._exit_command) def _(response: Response) -> None: self._exit_command_handler(response) - system_router.command_register_ignore = self._ignore_command_register - self.registered_routers.add_registered_router(system_router) + self.registered_routers.add_registered_router(self.system_router) + + def _validate_routers_for_collisions(self) -> None: + """ + Private. Validates that there are no trigger/alias collisions between routers + :return: None + :raises: RepeatedTriggerNameException or RepeatedAliasNameException if collision detected + """ + + all_triggers: set[str] = set() + all_aliases: set[str] = set() + + for router_entity in self.registered_routers: + union_units: set[str] = all_triggers | all_aliases + trigger_collisions: set[str] = union_units & router_entity.triggers + if trigger_collisions: + raise RepeatedTriggerNameException() + + alias_collisions: set[str] = union_units & router_entity.aliases + if alias_collisions: + raise RepeatedAliasNameException(alias_collisions) + + all_triggers.update(router_entity.triggers) + all_aliases.update(router_entity.aliases) def _most_similar_command(self, unknown_command: str) -> str | None: - all_commands = list(self._current_matching_triggers_with_routers.keys()) + all_commands = self.registered_routers.get_triggers() matches_startswith_unknown_command: Matches = sorted( cmd for cmd in all_commands if cmd.startswith(unknown_command) @@ -279,7 +307,9 @@ class BaseApp: :return: None """ self._prompt = f"[italic dim bold]{self._prompt}" - self._initial_message = "\n" + f"[bold red]{text2art(self._initial_message, font='tarty1')}" + "\n" + self._initial_message = ( + "\n" + f"[bold red]{text2art(self._initial_message, font='tarty1')}" + "\n" + ) self._farewell_message = ( "[bold red]\n\n" + str(text2art(self._farewell_message, font="chanky")) # pyright: ignore[reportUnknownArgumentType] @@ -297,14 +327,20 @@ class BaseApp: self._repeated_input_flags_handler = lambda raw_command: self._print_func( f"[red bold]Repeated input flags: {escape(raw_command)}" ) - self._empty_input_command_handler = lambda: self._print_func("[red bold]Empty input command") + self._empty_input_command_handler = lambda: self._print_func( + "[red bold]Empty input command" + ) def unknown_command_handler(command: InputCommand) -> None: cmd_trg: str = command.trigger mst_sim_cmd: str | None = self._most_similar_command(cmd_trg) - first_part_of_text = f"[red]Unknown command:[/red] [blue]{escape(cmd_trg)}[/blue]" + first_part_of_text = ( + f"[red]Unknown command:[/red] [blue]{escape(cmd_trg)}[/blue]" + ) second_part_of_text = ( - ("[red], most similar:[/red] " + ("[blue]" + mst_sim_cmd + "[/blue]")) if mst_sim_cmd else "" + ("[red], most similar:[/red] " + ("[blue]" + mst_sim_cmd + "[/blue]")) + if mst_sim_cmd + else "" ) self._print_func(first_part_of_text + second_part_of_text) @@ -316,20 +352,9 @@ class BaseApp: :return: None """ self._setup_system_router() + self._validate_routers_for_collisions() - for router_entity in self.registered_routers: - router_triggers = router_entity.triggers - router_aliases = router_entity.aliases - combined = router_triggers | router_aliases - - for trigger in combined: - self._matching_default_triggers_with_routers[trigger] = router_entity - self._matching_lower_triggers_with_routers[trigger.lower()] = router_entity - - self._autocompleter.initial_setup(list(self._current_matching_triggers_with_routers.keys())) - - if not self._override_system_messages: - self._setup_default_view() + self._autocompleter.initial_setup(self.registered_routers.get_triggers()) self._print_func(self._initial_message) @@ -337,11 +362,14 @@ class BaseApp: self._print_func(message) if self._messages_on_startup: print("\n") - if not self._repeat_command_groups_printing_description: + if not self._repeat_command_groups_printing: self._print_command_group_description() - + def _process_exist_and_valid_command(self, input_command: InputCommand) -> None: - processing_router = self._current_matching_triggers_with_routers[input_command.trigger.lower()] + processing_router = self.registered_routers.get_router_by_trigger(input_command.trigger.lower()) + + if not processing_router: + raise RuntimeError(f"Router for '{input_command.trigger}' not found. Panic!") if processing_router.disable_redirect_stdout: dividing_line_unit_part: str = self._dividing_line.get_unit_part() @@ -357,9 +385,9 @@ class BaseApp: ) ) else: - with redirect_stdout(io.StringIO()) as stdout: - processing_router.finds_appropriate_handler(input_command) - stdout_result: str = stdout.getvalue() + stdout_result = self._capture_stdout( + lambda: processing_router.finds_appropriate_handler(input_command) + ) self._print_framed_text(stdout_result) @@ -368,7 +396,7 @@ DEFAULT_DIVIDING_LINE: StaticDividingLine = StaticDividingLine() DEFAULT_PRINT_FUNC: Printer = Console().print DEFAULT_AUTOCOMPLETER: AutoCompleter = AutoCompleter() -DEFAULT_EXIT_COMMAND: Command = Command("Q", description="Exit command") +DEFAULT_EXIT_COMMAND: Command = Command("q", description="Exit command") class App(BaseApp): @@ -380,7 +408,6 @@ class App(BaseApp): farewell_message: str = "\nSee you\n", exit_command: Command = DEFAULT_EXIT_COMMAND, system_router_title: str = "System points:", - ignore_command_register: bool = True, dividing_line: AVAILABLE_DIVIDING_LINES = DEFAULT_DIVIDING_LINE, repeat_command_groups_printing: bool = False, override_system_messages: bool = False, @@ -395,7 +422,6 @@ class App(BaseApp): :param farewell_message: displayed at the end of the app :param exit_command: the entity of the command that will be terminated when entered :param system_router_title: system router title - :param ignore_command_register: whether to ignore the case of the entered commands :param dividing_line: the entity of the dividing line :param repeat_command_groups_printing: whether to repeat the available commands and their description :param override_system_messages: whether to redefine the default formatting of system messages @@ -409,13 +435,14 @@ class App(BaseApp): farewell_message=farewell_message, exit_command=exit_command, system_router_title=system_router_title, - ignore_command_register=ignore_command_register, dividing_line=dividing_line, repeat_command_groups_printing=repeat_command_groups_printing, override_system_messages=override_system_messages, autocompleter=autocompleter, print_func=print_func, ) + if not self._override_system_messages: + self._setup_default_view() def run_polling(self) -> None: """ @@ -424,31 +451,29 @@ class App(BaseApp): """ self._pre_cycle_setup() while True: - if self._repeat_command_groups_printing_description: + if self._repeat_command_groups_printing: self._print_command_group_description() raw_command: str = Console().input(self._prompt) try: input_command: InputCommand = InputCommand.parse(raw_command=raw_command) - except InputCommandException as error: - with redirect_stdout(io.StringIO()) as stderr: - self._error_handler(error, raw_command) - stderr_result: str = stderr.getvalue() + except InputCommandException as error: # noqa F841 + stderr_result = self._capture_stdout( + lambda: self._error_handler(error, raw_command) # noqa F821 + ) self._print_framed_text(stderr_result) continue if self._is_exit_command(input_command): - system_router.finds_appropriate_handler(input_command) - self._autocompleter.exit_setup( - list(self._current_matching_triggers_with_routers.keys()), self._ignore_command_register - ) + 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): - with redirect_stdout(io.StringIO()) as stdout: - self._unknown_command_handler(input_command) - stdout_res: str = stdout.getvalue() + stdout_res = self._capture_stdout( + lambda: self._unknown_command_handler(input_command) + ) self._print_framed_text(stdout_res) continue @@ -460,7 +485,6 @@ class App(BaseApp): :param router: registered router :return: None """ - router.command_register_ignore = self._ignore_command_register self.registered_routers.add_registered_router(router) def include_routers(self, *routers: Router) -> None: diff --git a/src/argenta/app/protocols.py b/src/argenta/app/protocols.py index 530b520..c5232f6 100644 --- a/src/argenta/app/protocols.py +++ b/src/argenta/app/protocols.py @@ -1,9 +1,10 @@ -__all__ = ["NonStandardBehaviorHandler", "EmptyCommandHandler", "Printer", "DescriptionMessageGenerator"] +__all__ = ["NonStandardBehaviorHandler", "EmptyCommandHandler", "Printer", "DescriptionMessageGenerator", "HandlerFunc"] -from typing import Protocol, TypeVar +from typing import ParamSpec, Protocol, TypeVar +from argenta.response import Response - -T = TypeVar("T", contravariant=True) # noqa: WPS111 +T = TypeVar("T", contravariant=True) +P = ParamSpec("P") class NonStandardBehaviorHandler(Protocol[T]): @@ -24,3 +25,8 @@ class Printer(Protocol): class DescriptionMessageGenerator(Protocol): def __call__(self, _command: str, _description: str, /) -> str: raise NotImplementedError + + +class HandlerFunc(Protocol): + def __call__(self, response: Response) -> None: + raise NotImplementedError diff --git a/src/argenta/app/registered_routers/entity.py b/src/argenta/app/registered_routers/entity.py index 366676e..e670228 100644 --- a/src/argenta/app/registered_routers/entity.py +++ b/src/argenta/app/registered_routers/entity.py @@ -1,18 +1,19 @@ __all__ = ["RegisteredRouters"] -from typing import Iterator, Optional +from typing import Iterator from argenta.router import Router class RegisteredRouters: - def __init__(self, registered_routers: Optional[list[Router]] = None) -> None: + def __init__(self) -> None: """ Private. Combines registered routers :param registered_routers: list of the registered routers :return: None """ - self.registered_routers: list[Router] = registered_routers if registered_routers else [] + self.registered_routers: list[Router] = [] + self._paired_trigger_router: dict[str, Router] = {} def add_registered_router(self, router: Router, /) -> None: """ @@ -21,6 +22,14 @@ class RegisteredRouters: :return: None """ self.registered_routers.append(router) - + for trigger in (router.aliases | router.triggers): + self._paired_trigger_router[trigger] = router + + def get_router_by_trigger(self, trigger: str) -> Router | None: + return self._paired_trigger_router.get(trigger) + + def get_triggers(self) -> set[str]: + return set(self._paired_trigger_router.keys()) + def __iter__(self) -> Iterator[Router]: return iter(self.registered_routers) diff --git a/src/argenta/command/flag/models.py b/src/argenta/command/flag/models.py index 2cea82f..32b5891 100644 --- a/src/argenta/command/flag/models.py +++ b/src/argenta/command/flag/models.py @@ -4,7 +4,6 @@ from enum import Enum from re import Pattern from typing import Literal, override - PREFIX_TYPE = Literal["-", "--", "---"] diff --git a/src/argenta/command/models.py b/src/argenta/command/models.py index 858b174..fc8507f 100644 --- a/src/argenta/command/models.py +++ b/src/argenta/command/models.py @@ -1,7 +1,7 @@ __all__ = ["Command", "InputCommand"] import shlex -from typing import Never, Self, cast, Literal +from typing import Literal, Never, Self, cast from argenta.command.exceptions import ( EmptyInputCommandException, @@ -38,30 +38,38 @@ class Command: :param flags: processed commands :param aliases: string synonyms for the main trigger """ - self.registered_flags: Flags = flags if isinstance(flags, Flags) else Flags([flags]) + pretty_flags = flags if isinstance(flags, Flags) else Flags([flags]) + self.registered_flags: Flags = pretty_flags self.trigger: str = trigger self.description: str = description self.aliases: set[str] | set[Never] = aliases + self._paired_string_entity_flag: dict[str, Flag] = { + flag.string_entity: flag for flag in pretty_flags + } + def validate_input_flag(self, flag: InputFlag) -> ValidationStatus: """ Private. Validates the input flag :param flag: input flag for validation :return: is input flag valid as bool """ - registered_flags: Flags = self.registered_flags - for registered_flag in registered_flags: - if registered_flag.string_entity == flag.string_entity: - is_valid = registered_flag.validate_input_flag_value(flag.input_value) - if is_valid: - return ValidationStatus.VALID - else: - return ValidationStatus.INVALID + if registered_flag := self._paired_string_entity_flag.get(flag.string_entity): + is_valid = registered_flag.validate_input_flag_value(flag.input_value) + if is_valid: + return ValidationStatus.VALID + else: + return ValidationStatus.INVALID return ValidationStatus.UNDEFINED class InputCommand: - def __init__(self, trigger: str, *, input_flags: InputFlag | InputFlags = DEFAULT_WITHOUT_INPUT_FLAGS): + def __init__( + self, + trigger: str, + *, + input_flags: InputFlag | InputFlags = DEFAULT_WITHOUT_INPUT_FLAGS, + ): """ Private. The model of the input command, after parsing :param trigger:the trigger of the command @@ -70,7 +78,9 @@ class InputCommand: """ self.trigger: str = trigger self.input_flags: InputFlags = ( - input_flags if isinstance(input_flags, InputFlags) else InputFlags([input_flags]) + input_flags + if isinstance(input_flags, InputFlags) + else InputFlags([input_flags]) ) @classmethod @@ -81,17 +91,17 @@ class InputCommand: :return: model of the input command, after parsing as InputCommand """ tokens = shlex.split(raw_command) - + if not tokens: raise EmptyInputCommandException - + command = tokens[0] flags: InputFlags = InputFlags() - + i = 1 while i < len(tokens): token = tokens[i] - + if token.startswith("---"): prefix = "---" name = token[3:] @@ -103,24 +113,24 @@ class InputCommand: name = token[1:] else: raise UnprocessedInputFlagException - + if i + 1 < len(tokens) and not tokens[i + 1].startswith("-"): input_value = tokens[i + 1] i += 2 else: input_value = "" i += 1 - + input_flag = InputFlag( name=name, prefix=cast(PREFIX_TYPE, prefix), # pyright: ignore[reportUnnecessaryCast] input_value=input_value, - status=None + status=None, ) - + if input_flag in flags: raise RepeatedInputFlagsException(input_flag) - + flags.add_flag(input_flag) - + return cls(command, input_flags=flags) diff --git a/src/argenta/metrics/__init__.py b/src/argenta/metrics/__init__.py deleted file mode 100644 index 9888ab8..0000000 --- a/src/argenta/metrics/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from argenta.metrics.main import get_time_of_pre_cycle_setup as get_time_of_pre_cycle_setup diff --git a/src/argenta/metrics/main.py b/src/argenta/metrics/main.py deleted file mode 100644 index 12861f7..0000000 --- a/src/argenta/metrics/main.py +++ /dev/null @@ -1,22 +0,0 @@ -__all__ = [ - "get_time_of_pre_cycle_setup", -] - -import io -from contextlib import redirect_stdout -from time import time - -from argenta import App - - -def get_time_of_pre_cycle_setup(app: App) -> float: - """ - Public. Return time of pre cycle setup - :param app: app instance for testing time of pre cycle setup - :return: time of pre cycle setup as float - """ - start = time() - with redirect_stdout(io.StringIO()): - app._pre_cycle_setup() # pyright: ignore[reportPrivateUsage] - end = time() - return end - start diff --git a/src/argenta/orchestrator/argparser/arguments/__init__.py b/src/argenta/orchestrator/argparser/arguments/__init__.py index d2058a3..f8907ce 100644 --- a/src/argenta/orchestrator/argparser/arguments/__init__.py +++ b/src/argenta/orchestrator/argparser/arguments/__init__.py @@ -1,3 +1,4 @@ -from argenta.orchestrator.argparser.arguments.models import BooleanArgument as BooleanArgument +from argenta.orchestrator.argparser.arguments.models import \ + BooleanArgument as BooleanArgument from argenta.orchestrator.argparser.arguments.models import InputArgument as InputArgument from argenta.orchestrator.argparser.arguments.models import ValueArgument as ValueArgument diff --git a/src/argenta/orchestrator/argparser/entity.py b/src/argenta/orchestrator/argparser/entity.py index 47fb358..0ca2ef9 100644 --- a/src/argenta/orchestrator/argparser/entity.py +++ b/src/argenta/orchestrator/argparser/entity.py @@ -7,12 +7,9 @@ import sys from argparse import ArgumentParser, Namespace from typing import Never, Self -from argenta.orchestrator.argparser.arguments.models import ( - BaseArgument, - BooleanArgument, - InputArgument, - ValueArgument, -) +from argenta.orchestrator.argparser.arguments.models import (BaseArgument, + BooleanArgument, + InputArgument, ValueArgument) class ArgSpace: diff --git a/src/argenta/orchestrator/entity.py b/src/argenta/orchestrator/entity.py index 17fb243..c6d5193 100644 --- a/src/argenta/orchestrator/entity.py +++ b/src/argenta/orchestrator/entity.py @@ -14,7 +14,7 @@ class Orchestrator: def __init__( self, arg_parser: ArgParser = DEFAULT_ARGPARSER, - custom_providers: list[Provider] = [], + custom_providers: list[Provider] | None = None, auto_inject_handlers: bool = True, ): """ @@ -23,7 +23,7 @@ class Orchestrator: :return: None """ self._arg_parser: ArgParser = arg_parser - self._custom_providers: list[Provider] = custom_providers + self._custom_providers: list[Provider] = custom_providers or [] self._auto_inject_handlers: bool = auto_inject_handlers self._arg_parser._parse_args() # pyright: ignore[reportPrivateUsage] diff --git a/src/argenta/router/command_handler/entity.py b/src/argenta/router/command_handler/entity.py index b95afd6..7f53cec 100644 --- a/src/argenta/router/command_handler/entity.py +++ b/src/argenta/router/command_handler/entity.py @@ -1,20 +1,21 @@ __all__ = ["CommandHandler", "CommandHandlers"] from collections.abc import Iterator -from typing import Callable +from typing import Never +from argenta.app.protocols import HandlerFunc from argenta.command import Command from argenta.response import Response class CommandHandler: - def __init__(self, handler_as_func: Callable[..., None], handled_command: Command): + def __init__(self, handler_as_func: HandlerFunc, handled_command: Command): """ Private. Entity of the model linking the handler and the command being processed :param handler: the handler being called :param handled_command: the command being processed """ - self.handler_as_func: Callable[..., None] = handler_as_func + self.handler_as_func: HandlerFunc = handler_as_func self.handled_command: Command = handled_command def handling(self, response: Response) -> None: @@ -27,12 +28,13 @@ class CommandHandler: class CommandHandlers: - def __init__(self, command_handlers: list[CommandHandler] | None = None): + def __init__(self, command_handlers: tuple[CommandHandler] | tuple[Never, ...] = tuple()): """ Private. The model that unites all CommandHandler of the routers :param command_handlers: list of CommandHandlers for register """ - self.command_handlers: list[CommandHandler] = command_handlers if command_handlers else [] + self.command_handlers: list[CommandHandler] = list(command_handlers) if command_handlers else [] + self.paired_command_handler_trigger: dict[str, CommandHandler] = {x.handled_command.trigger: x for x in command_handlers} def add_handler(self, command_handler: CommandHandler) -> None: """ @@ -41,6 +43,12 @@ class CommandHandlers: :return: None """ self.command_handlers.append(command_handler) + self.paired_command_handler_trigger[command_handler.handled_command.trigger.lower()] = command_handler + for alias in command_handler.handled_command.aliases: + self.paired_command_handler_trigger[alias.lower()] = command_handler + + def get_command_handler_by_trigger(self, trigger: str) -> CommandHandler | None: + return self.paired_command_handler_trigger.get(trigger) def __iter__(self) -> Iterator[CommandHandler]: return iter(self.command_handlers) diff --git a/src/argenta/router/defaults.py b/src/argenta/router/defaults.py deleted file mode 100644 index b0b96aa..0000000 --- a/src/argenta/router/defaults.py +++ /dev/null @@ -1,5 +0,0 @@ -__all__ = ["system_router"] - -from argenta.router import Router - -system_router = Router(title="System points:") diff --git a/src/argenta/router/entity.py b/src/argenta/router/entity.py index b267a4f..c32695a 100644 --- a/src/argenta/router/entity.py +++ b/src/argenta/router/entity.py @@ -1,22 +1,21 @@ __all__ = ["Router"] from inspect import get_annotations, getfullargspec, getsourcefile, getsourcelines -from typing import Callable, TypeAlias +from typing import Callable from rich.console import Console +from argenta.app.protocols import HandlerFunc from argenta.command import Command, InputCommand from argenta.command.flag import ValidationStatus -from argenta.command.flag.flags import Flags, InputFlags +from argenta.command.flag.flags import InputFlags from argenta.response import Response, ResponseStatus from argenta.router.command_handler.entity import CommandHandler, CommandHandlers -from argenta.router.exceptions import ( - RepeatedFlagNameException, - RequiredArgumentNotPassedException, - TriggerContainSpacesException, -) - -HandlerFunc: TypeAlias = Callable[..., None] +from argenta.router.exceptions import (RepeatedAliasNameException, + RepeatedFlagNameException, + RepeatedTriggerNameException, + RequiredArgumentNotPassedException, + TriggerContainSpacesException) class Router: @@ -40,8 +39,6 @@ class Router: self.disable_redirect_stdout: bool = disable_redirect_stdout self.command_handlers: CommandHandlers = CommandHandlers() - self.command_register_ignore: bool = False - self.aliases: set[str] = set() self.triggers: set[str] = set() @@ -57,13 +54,8 @@ class Router: redefined_command = command self._validate_command(redefined_command) - - if overlapping := (self.aliases | self.triggers) & redefined_command.aliases: - Console().print(f"\n[b red]WARNING:[/b red] Overlapping trigger or alias: [b blue]{overlapping}[/b blue]") + self._update_routing_keys(redefined_command) - self.aliases.update(redefined_command.aliases) - self.triggers.add(redefined_command.trigger) - def decorator(func: HandlerFunc) -> HandlerFunc: _validate_func_args(func) self.command_handlers.add_handler(CommandHandler(func, redefined_command)) @@ -80,10 +72,24 @@ class Router: command_name: str = command.trigger if command_name.find(" ") != -1: raise TriggerContainSpacesException() - flags: Flags = command.registered_flags - flags_name: list[str] = [flag.string_entity.lower() for flag in flags] + + if command_name.lower() in self.triggers: + raise RepeatedTriggerNameException() + + if command_name.lower() in self.aliases: + raise RepeatedAliasNameException({command_name.lower()}) + + if overlapping := (self.aliases | self.triggers) & set(map(lambda x: x.lower(), command.aliases)): + raise RepeatedAliasNameException(overlapping) + + flags_name: list[str] = [flag.string_entity.lower() for flag in command.registered_flags] if len(set(flags_name)) < len(flags_name): raise RepeatedFlagNameException() + + def _update_routing_keys(self, registered_command: Command) -> None: + redefined_command_aliases_in_lower = set(map(lambda x: x.lower(), registered_command.aliases)) + self.aliases.update(redefined_command_aliases_in_lower) + self.triggers.add(registered_command.trigger.lower()) def finds_appropriate_handler(self, input_command: InputCommand) -> None: """ @@ -91,15 +97,15 @@ class Router: :param input_command: input command as InputCommand :return: None """ - input_command_name: str = input_command.trigger + input_command_name: str = input_command.trigger.lower() input_command_flags: InputFlags = input_command.input_flags - for command_handler in self.command_handlers: - handle_command = command_handler.handled_command - if input_command_name.lower() == handle_command.trigger.lower(): - self.process_input_command(input_command_flags, command_handler) - if input_command_name.lower() in handle_command.aliases: - self.process_input_command(input_command_flags, command_handler) + command_handler = self.command_handlers.get_command_handler_by_trigger(input_command_name) + + if not command_handler: + raise RuntimeError(f"Handler for '{input_command.trigger}' command not found. Panic!") + else: + self.process_input_command(input_command_flags, command_handler) def process_input_command(self, input_command_flags: InputFlags, command_handler: CommandHandler) -> None: """ @@ -147,13 +153,14 @@ def _structuring_input_flags(handled_command: Command, input_flags: InputFlags) undefined_flags = True status = ResponseStatus.from_flags( - has_invalid_value_flags=invalid_value_flags, has_undefined_flags=undefined_flags + has_invalid_value_flags=invalid_value_flags, + has_undefined_flags=undefined_flags ) return Response(status=status, input_flags=input_flags) -def _validate_func_args(func: Callable[..., None]) -> None: +def _validate_func_args(func: HandlerFunc) -> None: """ Private. Validates the arguments of the handler :param func: entity of the handler func @@ -168,13 +175,12 @@ def _validate_func_args(func: Callable[..., None]) -> None: response_arg_annotation = func_annotations.get(response_arg) - if response_arg_annotation is not None: - if response_arg_annotation is not Response: - source_line: int = getsourcelines(func)[1] - Console().print( - f'\nFile "{getsourcefile(func)}", line {source_line}\n[b red]WARNING:[/b red] [i]The typehint ' - + f"of argument([green]{response_arg}[/green]) passed to the handler must be [/i][bold blue]{Response}[/bold blue]," - + f" [i]but[/i] [bold blue]{response_arg_annotation}[/bold blue] [i]is specified[/i]", - highlight=False, - ) + if response_arg_annotation is not None and response_arg_annotation is not Response: + source_line: int = getsourcelines(func)[1] + Console().print( + f'\nFile "{getsourcefile(func)}", line {source_line}\n[b red]WARNING:[/b red] [i]The typehint ' + + f"of argument([green]{response_arg}[/green]) passed to the handler must be [/i][bold blue]{Response}[/bold blue]," + + f" [i]but[/i] [bold blue]{response_arg_annotation}[/bold blue] [i]is specified[/i]", + highlight=False, + ) \ No newline at end of file diff --git a/src/argenta/router/exceptions.py b/src/argenta/router/exceptions.py index 6754a37..478dcdc 100644 --- a/src/argenta/router/exceptions.py +++ b/src/argenta/router/exceptions.py @@ -1,4 +1,10 @@ -__all__ = ["RepeatedFlagNameException", "RequiredArgumentNotPassedException", "TriggerContainSpacesException"] +__all__ = [ + "RepeatedFlagNameException", + "RepeatedTriggerNameException", + "RepeatedAliasNameException", + "RequiredArgumentNotPassedException", + "TriggerContainSpacesException", +] from typing import override @@ -11,7 +17,31 @@ class RepeatedFlagNameException(Exception): @override def __str__(self) -> str: return "Repeated registered flag names in register command" + +class RepeatedTriggerNameException(Exception): + """ + Private. Raised when a repeated trigger name is registered + """ + + @override + def __str__(self) -> str: + return "Repeated trigger name in registered commands" + + +class RepeatedAliasNameException(Exception): + """ + Private. Raised when a repeated alias name is registered + """ + @override + def __init__(self, repeated_aliases: set[str]) -> None: + self.repeated_aliases = repeated_aliases + super().__init__() + + @override + def __str__(self) -> str: + return f"Repeated aliases names: {self.repeated_aliases}" + class RequiredArgumentNotPassedException(Exception): """ diff --git a/tests/system_tests/test_system_handling_non_standard_behavior.py b/tests/system_tests/test_system_handling_non_standard_behavior.py index e952df0..696d97f 100644 --- a/tests/system_tests/test_system_handling_non_standard_behavior.py +++ b/tests/system_tests/test_system_handling_non_standard_behavior.py @@ -72,27 +72,6 @@ def test_unknown_command_triggers_unknown_command_handler(monkeypatch: pytest.Mo assert "\nUnknown command: help\n" in output -def test_case_sensitive_command_triggers_unknown_command_handler(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: - inputs = iter(["TeSt", "Q"]) - monkeypatch.setattr('builtins.input', lambda _prompt="": _mock_input(inputs)) - - router = Router() - orchestrator = Orchestrator() - - @router.command(Command('test')) - def test(_response: Response) -> None: # pyright: ignore[reportUnusedFunction] - print('test command') - - app = App(ignore_command_register=False, override_system_messages=True, print_func=print) - app.include_router(router) - app.set_unknown_command_handler(lambda command: print(f'Unknown command: {command.trigger}')) - orchestrator.start_polling(app) - - output = capsys.readouterr().out - - assert '\nUnknown command: TeSt\n' in output - - def test_mixed_valid_and_unknown_commands_handled_correctly(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: inputs = iter(["test", "some", "q"]) monkeypatch.setattr('builtins.input', lambda _prompt="": _mock_input(inputs)) diff --git a/tests/system_tests/test_system_handling_normal_behavior.py b/tests/system_tests/test_system_handling_normal_behavior.py index 77e88c6..dc231aa 100644 --- a/tests/system_tests/test_system_handling_normal_behavior.py +++ b/tests/system_tests/test_system_handling_normal_behavior.py @@ -46,26 +46,6 @@ def test_simple_command_executes_successfully(monkeypatch: pytest.MonkeyPatch, c assert '\ntest command\n' in output -def test_case_insensitive_command_executes_when_enabled(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: - inputs = iter(["TeSt", "q"]) - monkeypatch.setattr('builtins.input', lambda _prompt="": _mock_input(inputs)) - - router = Router() - orchestrator = Orchestrator() - - @router.command(Command('test')) - def test(_response: Response) -> None: # pyright: ignore[reportUnusedFunction] - print('test command') - - app = App(ignore_command_register=True, override_system_messages=True, print_func=print) - app.include_router(router) - orchestrator.start_polling(app) - - output = capsys.readouterr().out - - assert '\ntest command\n' in output - - def test_two_commands_execute_sequentially(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: inputs = iter(["test", "some", "q"]) monkeypatch.setattr('builtins.input', lambda _prompt="": _mock_input(inputs)) diff --git a/tests/unit_tests/test_app.py b/tests/unit_tests/test_app.py index 2b99db4..13b7924 100644 --- a/tests/unit_tests/test_app.py +++ b/tests/unit_tests/test_app.py @@ -1,3 +1,4 @@ +from argenta.router.exceptions import RepeatedAliasNameException import pytest from pytest import CaptureFixture @@ -25,46 +26,21 @@ def test_default_exit_command_uppercase_q_is_recognized() -> None: assert app._is_exit_command(InputCommand('Q')) is True -def test_exit_command_not_recognized_when_case_sensitivity_enabled() -> None: - app = App(ignore_command_register=False) - assert app._is_exit_command(InputCommand('q')) is False - - def test_custom_exit_command_is_recognized() -> None: app = App(exit_command=Command('quit')) assert app._is_exit_command(InputCommand('quit')) is True -def test_custom_exit_command_case_insensitive_by_default() -> None: - app = App(exit_command=Command('quit')) - assert app._is_exit_command(InputCommand('qUIt')) is True - - -def test_custom_exit_command_case_sensitive_when_enabled() -> None: - app = App(ignore_command_register=False, exit_command=Command('quit')) - assert app._is_exit_command(InputCommand('qUIt')) is False - - def test_exit_command_alias_is_recognized() -> None: app = App(exit_command=Command('q', aliases={'exit'})) assert app._is_exit_command(InputCommand('exit')) is True -def test_exit_command_alias_case_sensitive_when_enabled() -> None: - app = App(exit_command=Command('q', aliases={'exit'}), ignore_command_register=False) - assert app._is_exit_command(InputCommand('exit')) is True - - def test_non_exit_command_is_not_recognized() -> None: app = App(exit_command=Command('q', aliases={'exit'})) assert app._is_exit_command(InputCommand('quit')) is False -def test_non_exit_command_with_wrong_case_is_not_recognized() -> None: - app = App(exit_command=Command('q', aliases={'exit'}), ignore_command_register=False) - assert app._is_exit_command(InputCommand('Exit')) is False - - # ============================================================================ # Tests for unknown command detection # ============================================================================ @@ -73,31 +49,22 @@ def test_non_exit_command_with_wrong_case_is_not_recognized() -> None: def test_registered_command_is_not_unknown() -> None: app = App() app.set_unknown_command_handler(lambda command: None) - app._current_matching_triggers_with_routers = {'fr': Router(), 'tr': Router(), 'de': Router()} + router = Router() + + @router.command('fr') + def handler(res: Response): + pass + + app.include_router(router) assert app._is_unknown_command(InputCommand('fr')) is False def test_unregistered_command_is_unknown() -> None: app = App() app.set_unknown_command_handler(lambda command: None) - app._current_matching_triggers_with_routers = {'fr': Router(), 'tr': Router(), 'de': Router()} assert app._is_unknown_command(InputCommand('cr')) is True -def test_command_with_wrong_case_is_unknown_when_case_sensitivity_enabled() -> None: - app = App(ignore_command_register=False) - app.set_unknown_command_handler(lambda command: None) - app._current_matching_triggers_with_routers = {'Pr': Router(), 'tW': Router(), 'deQW': Router()} - assert app._is_unknown_command(InputCommand('pr')) is True - - -def test_command_with_exact_case_is_not_unknown_when_case_sensitivity_enabled() -> None: - app = App(ignore_command_register=False) - app.set_unknown_command_handler(lambda command: None) - app._current_matching_triggers_with_routers = {'Pr': Router(), 'tW': Router(), 'deQW': Router()} - assert app._is_unknown_command(InputCommand('tW')) is False - - # ============================================================================ # Tests for similar command suggestions # ============================================================================ @@ -207,24 +174,101 @@ def test_include_routers_registers_multiple_routers() -> None: assert app.registered_routers.registered_routers == [router, router2] -def test_overlapping_aliases_prints_warning(capsys: CaptureFixture[str]) -> None: - app = App(override_system_messages=True) +def test_overlapping_aliases_raises_exception() -> None: router = Router() @router.command(Command('test', aliases={'alias'})) def handler(_res: Response) -> None: pass - @router.command(Command('test2', aliases={'alias'})) + with pytest.raises(RepeatedAliasNameException): + @router.command(Command('test2', aliases={'alias'})) + def handler2(_res: Response) -> None: + pass + + +def test_app_detects_trigger_collision_between_routers() -> None: + from argenta.router.exceptions import RepeatedTriggerNameException + + app = App() + router1 = Router() + router2 = Router() + + @router1.command('hello') + def handler1(_res: Response) -> None: + pass + + @router2.command('hello') def handler2(_res: Response) -> None: pass - app.include_routers(router) - app._pre_cycle_setup() + app.include_router(router1) + app.include_router(router2) - captured = capsys.readouterr() + with pytest.raises(RepeatedTriggerNameException): + app._pre_cycle_setup() - assert "Overlapping" in captured.out + +def test_app_detects_alias_collision_between_routers() -> None: + app = App() + router1 = Router() + router2 = Router() + + @router1.command(Command('hello', aliases={'hi'})) + def handler1(_res: Response) -> None: + pass + + @router2.command(Command('world', aliases={'hi'})) + def handler2(_res: Response) -> None: + pass + + app.include_router(router1) + app.include_router(router2) + + with pytest.raises(RepeatedAliasNameException): + app._pre_cycle_setup() + + +def test_app_detects_trigger_alias_collision_between_routers() -> None: + app = App() + router1 = Router() + router2 = Router() + + @router1.command('hello') + def handler1(_res: Response) -> None: + pass + + @router2.command(Command('world', aliases={'hello'})) + def handler2(_res: Response) -> None: + pass + + app.include_router(router1) + app.include_router(router2) + + with pytest.raises(RepeatedAliasNameException): + app._pre_cycle_setup() + + +def test_app_detects_collision_case_insensitive() -> None: + from argenta.router.exceptions import RepeatedTriggerNameException + + app = App() + router1 = Router() + router2 = Router() + + @router1.command('Hello') + def handler1(_res: Response) -> None: + pass + + @router2.command('hELLo') + def handler2(_res: Response) -> None: + pass + + app.include_router(router1) + app.include_router(router2) + + with pytest.raises(RepeatedTriggerNameException): + app._pre_cycle_setup() # ============================================================================ @@ -317,8 +361,6 @@ def test_set_exit_command_handler_stores_handler() -> None: def test_setup_default_view_formats_prompt() -> None: app = App(prompt='>>') - app._setup_default_view() - assert app._prompt == '[italic dim bold]>>' @@ -554,7 +596,7 @@ def test_handler_can_be_replaced_multiple_times() -> None: def test_handler_receives_correct_parameters() -> None: app = App() - received_data = {'trigger': None} + received_data: dict[str, None | str] = {'trigger': None} def custom_handler(command: InputCommand) -> None: received_data['trigger'] = command.trigger @@ -567,7 +609,7 @@ def test_handler_receives_correct_parameters() -> None: def test_exit_handler_receives_response_object() -> None: app = App() - received_data = {'response': None} + received_data: dict[str, None | Response] = {'response': None} def custom_handler(response: Response) -> None: received_data['response'] = response diff --git a/tests/unit_tests/test_autocompleter.py b/tests/unit_tests/test_autocompleter.py index 904c460..5b1c9c7 100644 --- a/tests/unit_tests/test_autocompleter.py +++ b/tests/unit_tests/test_autocompleter.py @@ -7,13 +7,12 @@ from pytest_mock import MockerFixture from argenta.app.autocompleter.entity import ( AutoCompleter, - _get_history_items, - _is_command_exist, + _get_history_items ) HISTORY_FILE: str = "test_history.txt" -COMMANDS: list[str] = ["start", "stop", "status"] +COMMANDS: set[str] = {"start", "stop", "status"} # ============================================================================ @@ -119,7 +118,7 @@ def test_exit_setup_writes_and_filters_duplicate_commands(fs: FakeFilesystem, mo 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"], ignore_command_register=False) + completer.exit_setup(all_commands={"start", "stop"}) mock_readline.write_history_file.assert_called_once_with(HISTORY_FILE) @@ -131,7 +130,7 @@ def test_exit_setup_writes_and_filters_duplicate_commands(fs: FakeFilesystem, mo 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, ignore_command_register=False) + completer.exit_setup(all_commands=COMMANDS) mock_readline.write_history_file.assert_not_called() @@ -182,22 +181,6 @@ def test_complete_inserts_common_prefix_for_multiple_matches(mock_readline: Any) # ============================================================================ -def test_is_command_exist_checks_case_sensitive_when_enabled() -> None: - existing: list[str] = ["start", "stop", "status"] - - assert _is_command_exist("start", existing, ignore_command_register=False) is True - assert _is_command_exist("START", existing, ignore_command_register=False) is False - assert _is_command_exist("unknown", existing, ignore_command_register=False) is False - - -def test_is_command_exist_checks_case_insensitive_when_enabled() -> None: - existing: list[str] = ["start", "stop", "status"] - - assert _is_command_exist("start", existing, ignore_command_register=True) is True - assert _is_command_exist("START", existing, ignore_command_register=True) is True - assert _is_command_exist("unknown", existing, ignore_command_register=True) is False - - def test_get_history_items_returns_empty_list_initially(mock_readline: Any) -> None: assert _get_history_items() == [] diff --git a/tests/unit_tests/test_router.py b/tests/unit_tests/test_router.py index 1ec082f..21bc6d3 100644 --- a/tests/unit_tests/test_router.py +++ b/tests/unit_tests/test_router.py @@ -9,9 +9,11 @@ from argenta.command.flag.flags import Flags, InputFlags from argenta.command.flag.models import PossibleValues, ValidationStatus from argenta.response.entity import Response from argenta.router import Router -from argenta.router.entity import _structuring_input_flags, _validate_func_args # pyright: ignore[reportPrivateUsage] +from argenta.router.entity import _structuring_input_flags, _validate_func_args from argenta.router.exceptions import ( + RepeatedAliasNameException, RepeatedFlagNameException, + RepeatedTriggerNameException, RequiredArgumentNotPassedException, TriggerContainSpacesException, ) @@ -26,7 +28,20 @@ def test_validate_command_raises_error_for_trigger_with_spaces() -> None: router = Router() with pytest.raises(TriggerContainSpacesException): router._validate_command(Command(trigger='command with spaces')) - + + +def test_validate_command_raises_error_for_same_trigger() -> None: + router = Router() + + @router.command('comm') + def handler(res: Response): + pass + + with pytest.raises(RepeatedTriggerNameException): + @router.command('comm') + def handler2(res: Response): + pass + def test_validate_command_raises_error_for_repeated_flag_names() -> None: router = Router() @@ -192,6 +207,33 @@ def test_finds_appropriate_handler_executes_handler_by_alias(capsys: CaptureFixt output = capsys.readouterr() assert "Hello World!" in output.out + +def test_finds_appropriate_handler_executes_handler_by_alias_case_insensitive(capsys: CaptureFixture[str]) -> None: + router = Router() + + @router.command(Command('hello', aliases={'hI'})) + def handler(_res: Response) -> None: + print("Hello World!") + + router.finds_appropriate_handler(InputCommand('HI')) + + output = capsys.readouterr() + + assert "Hello World!" in output.out + + +def test_finds_appropriate_handler_executes_handler_by_trigger_case_insensitive(capsys: CaptureFixture[str]) -> None: + router = Router() + + @router.command(Command('heLLo')) + def handler(_res: Response) -> None: + print("Hello World!") + + router.finds_appropriate_handler(InputCommand('HellO')) + + output = capsys.readouterr() + + assert "Hello World!" in output.out def test_finds_appropriate_handler_executes_handler_with_flags_by_alias(capsys: CaptureFixture[str]) -> None: @@ -206,3 +248,152 @@ def test_finds_appropriate_handler_executes_handler_with_flags_by_alias(capsys: output = capsys.readouterr() assert "Hello World!" in output.out + + +# ============================================================================ +# Tests for alias and trigger collision detection +# ============================================================================ + + +def test_validate_command_raises_error_for_alias_collision_with_existing_trigger() -> None: + router = Router() + + @router.command('hello') + def handler(_res: Response) -> None: + pass + + with pytest.raises(RepeatedAliasNameException): + @router.command(Command('world', aliases={'hello'})) + def handler2(_res: Response) -> None: + pass + + +def test_validate_command_raises_error_for_alias_collision_with_existing_alias() -> None: + router = Router() + + @router.command(Command('hello', aliases={'hi'})) + def handler(_res: Response) -> None: + pass + + with pytest.raises(RepeatedAliasNameException): + @router.command(Command('world', aliases={'hi'})) + def handler2(_res: Response) -> None: + pass + + +def test_validate_command_raises_error_for_trigger_collision_with_existing_alias() -> None: + router = Router() + + @router.command(Command('hello', aliases={'hi'})) + def handler(_res: Response) -> None: + pass + + with pytest.raises(RepeatedAliasNameException): + @router.command('hi') + def handler2(_res: Response) -> None: + pass + + +def test_validate_command_raises_error_for_alias_collision_case_insensitive() -> None: + router = Router() + + @router.command(Command('hello', aliases={'Hi'})) + def handler(_res: Response) -> None: + pass + + with pytest.raises(RepeatedAliasNameException): + @router.command(Command('world', aliases={'hI'})) + def handler2(_res: Response) -> None: + pass + + +# ============================================================================ +# Tests for RegisteredRouters +# ============================================================================ + + +def test_registered_routers_get_router_by_trigger() -> None: + from argenta.app.registered_routers.entity import RegisteredRouters + + registered_routers = RegisteredRouters() + router = Router() + + @router.command('hello') + def handler(_res: Response) -> None: + pass + + registered_routers.add_registered_router(router) + + assert registered_routers.get_router_by_trigger('hello') == router + + +def test_registered_routers_get_router_by_alias() -> None: + from argenta.app.registered_routers.entity import RegisteredRouters + + registered_routers = RegisteredRouters() + router = Router() + + @router.command(Command('hello', aliases={'hi'})) + def handler(_res: Response) -> None: + pass + + registered_routers.add_registered_router(router) + + assert registered_routers.get_router_by_trigger('hi') == router + + +def test_registered_routers_get_router_case_insensitive() -> None: + from argenta.app.registered_routers.entity import RegisteredRouters + + registered_routers = RegisteredRouters() + router = Router() + + @router.command(Command('HeLLo')) + def handler(_res: Response) -> None: + pass + + registered_routers.add_registered_router(router) + + # Trigger stored in lowercase, should match regardless of case + assert registered_routers.get_router_by_trigger('hello') == router + assert registered_routers.get_router_by_trigger('HELLO') is None # Exact match required in dict + + +def test_registered_routers_get_triggers_returns_all_triggers_and_aliases() -> None: + from argenta.app.registered_routers.entity import RegisteredRouters + + registered_routers = RegisteredRouters() + router1 = Router() + router2 = Router() + + @router1.command(Command('hello', aliases={'hi'})) + def handler1(_res: Response) -> None: + pass + + @router2.command(Command('world', aliases={'w'})) + def handler2(_res: Response) -> None: + pass + + registered_routers.add_registered_router(router1) + registered_routers.add_registered_router(router2) + + triggers = registered_routers.get_triggers() + assert 'hello' in triggers + assert 'hi' in triggers + assert 'world' in triggers + assert 'w' in triggers + + +def test_registered_routers_returns_none_for_unknown_trigger() -> None: + from argenta.app.registered_routers.entity import RegisteredRouters + + registered_routers = RegisteredRouters() + router = Router() + + @router.command('hello') + def handler(_res: Response) -> None: + pass + + registered_routers.add_registered_router(router) + + assert registered_routers.get_router_by_trigger('unknown') is None