From 75b1efb259777fb867c1fcae451ff43bc6f79719 Mon Sep 17 00:00:00 2001 From: kolo Date: Mon, 8 Dec 2025 14:17:31 +0300 Subject: [PATCH 1/6] better perf --- mock/local_test.py | 23 ++--- src/argenta/app/models.py | 103 +++++++++++++------ src/argenta/response/status.py | 3 +- src/argenta/router/command_handler/entity.py | 19 +++- src/argenta/router/defaults.py | 5 - src/argenta/router/entity.py | 40 ++++--- src/argenta/router/exceptions.py | 24 +++++ tests/unit_tests/test_app.py | 18 ++-- tests/unit_tests/test_router.py | 43 +++++++- 9 files changed, 190 insertions(+), 88 deletions(-) delete mode 100644 src/argenta/router/defaults.py diff --git a/mock/local_test.py b/mock/local_test.py index 7061395..0aaef4e 100644 --- a/mock/local_test.py +++ b/mock/local_test.py @@ -1,19 +1,10 @@ -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 +from argenta import Command, Response, Router +from argenta.command import InputCommand -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) - -_auto_inject_handlers(app) -_auto_inject_handlers(app) +@router.command(Command('heLLo')) +def handler(_res: Response) -> None: + print("Hello World!") + +router.finds_appropriate_handler(InputCommand('HellO')) diff --git a/src/argenta/app/models.py b/src/argenta/app/models.py index 1da6c74..9b0d31b 100644 --- a/src/argenta/app/models.py +++ b/src/argenta/app/models.py @@ -26,7 +26,6 @@ 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] @@ -50,12 +49,12 @@ def __init__( 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._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 @@ -75,18 +74,20 @@ def __init__( 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._unknown_command_handler: NonStandardBehaviorHandler[InputCommand] = lambda _: print_func( - f"Unknown command: {_.trigger}" + self._empty_input_command_handler: EmptyCommandHandler = lambda: print_func( + "Empty input command" ) - self._exit_command_handler: NonStandardBehaviorHandler[Response] = lambda _: print_func( - self._farewell_message + 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) ) def set_description_message_pattern(self, _: DescriptionMessageGenerator, /) -> None: @@ -97,7 +98,9 @@ def set_description_message_pattern(self, _: DescriptionMessageGenerator, /) -> """ 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 +108,9 @@ def set_incorrect_input_syntax_handler(self, _: NonStandardBehaviorHandler[str], """ 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 +118,9 @@ def set_repeated_input_flags_handler(self, _: NonStandardBehaviorHandler[str], / """ 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 +136,9 @@ def set_empty_command_handler(self, _: EmptyCommandHandler, /) -> None: """ 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 @@ -164,7 +173,11 @@ def _print_framed_text(self, text: str) -> None: clear_text = re.sub(r"\u001b\[[0-9;]*m", "", 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 +194,15 @@ def _print_framed_text(self, text: str) -> None: 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: @@ -219,10 +236,14 @@ def _is_unknown_command(self, command: InputCommand) -> bool: """ input_command_trigger = command.trigger if self._ignore_command_register: - if input_command_trigger.lower() in list(self._current_matching_triggers_with_routers.keys()): + 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()): + if input_command_trigger in list( + self._current_matching_triggers_with_routers.keys() + ): return False return True @@ -245,14 +266,13 @@ def _setup_system_router(self) -> None: 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.system_router.command_register_ignore = self._ignore_command_register + self.registered_routers.add_registered_router(self.system_router) def _most_similar_command(self, unknown_command: str) -> str | None: all_commands = list(self._current_matching_triggers_with_routers.keys()) @@ -279,7 +299,9 @@ def _setup_default_view(self) -> None: :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 +319,20 @@ def _setup_default_view(self) -> None: 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) @@ -324,9 +352,13 @@ def _pre_cycle_setup(self) -> None: for trigger in combined: self._matching_default_triggers_with_routers[trigger] = router_entity - self._matching_lower_triggers_with_routers[trigger.lower()] = router_entity + self._matching_lower_triggers_with_routers[trigger.lower()] = ( + router_entity + ) - self._autocompleter.initial_setup(list(self._current_matching_triggers_with_routers.keys())) + self._autocompleter.initial_setup( + list(self._current_matching_triggers_with_routers.keys()) + ) if not self._override_system_messages: self._setup_default_view() @@ -339,9 +371,11 @@ def _pre_cycle_setup(self) -> None: print("\n") if not self._repeat_command_groups_printing_description: 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._current_matching_triggers_with_routers[ + input_command.trigger.lower() + ] if processing_router.disable_redirect_stdout: dividing_line_unit_part: str = self._dividing_line.get_unit_part() @@ -439,9 +473,10 @@ def run_polling(self) -> None: continue if self._is_exit_command(input_command): - system_router.finds_appropriate_handler(input_command) + self.system_router.finds_appropriate_handler(input_command) self._autocompleter.exit_setup( - list(self._current_matching_triggers_with_routers.keys()), self._ignore_command_register + list(self._current_matching_triggers_with_routers.keys()), + self._ignore_command_register, ) return diff --git a/src/argenta/response/status.py b/src/argenta/response/status.py index c736de0..c156494 100644 --- a/src/argenta/response/status.py +++ b/src/argenta/response/status.py @@ -1,6 +1,7 @@ __all__ = ["ResponseStatus"] from enum import Enum +from typing import Self class ResponseStatus(Enum): @@ -10,7 +11,7 @@ class ResponseStatus(Enum): UNDEFINED_AND_INVALID_FLAGS = "UNDEFINED_AND_INVALID_FLAGS" @classmethod - def from_flags(cls, *, has_invalid_value_flags: bool, has_undefined_flags: bool) -> "ResponseStatus": + def from_flags(cls, *, has_invalid_value_flags: bool, has_undefined_flags: bool) -> Self: key = (has_invalid_value_flags, has_undefined_flags) status_map: dict[tuple[bool, bool], ResponseStatus] = { (True, True): cls.UNDEFINED_AND_INVALID_FLAGS, diff --git a/src/argenta/router/command_handler/entity.py b/src/argenta/router/command_handler/entity.py index b95afd6..51dc1cb 100644 --- a/src/argenta/router/command_handler/entity.py +++ b/src/argenta/router/command_handler/entity.py @@ -7,14 +7,17 @@ from argenta.response import Response +HandlerFunc = Callable[..., None] + + 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 +30,13 @@ def handling(self, response: Response) -> None: class CommandHandlers: - def __init__(self, command_handlers: list[CommandHandler] | None = None): + def __init__(self, command_handlers: tuple[CommandHandler] = 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 +45,13 @@ def add_handler(self, command_handler: CommandHandler) -> None: :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): + print(self.paired_command_handler_trigger) + 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..12edb33 100644 --- a/src/argenta/router/entity.py +++ b/src/argenta/router/entity.py @@ -11,7 +11,9 @@ from argenta.response import Response, ResponseStatus from argenta.router.command_handler.entity import CommandHandler, CommandHandlers from argenta.router.exceptions import ( + RepeatedAliasNameException, RepeatedFlagNameException, + RepeatedTriggerNameException, RequiredArgumentNotPassedException, TriggerContainSpacesException, ) @@ -57,13 +59,8 @@ def command(self, command: Command | str) -> Callable[[HandlerFunc], HandlerFunc 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 +77,22 @@ def _validate_command(self, command: Command) -> None: command_name: str = command.trigger if command_name.find(" ") != -1: raise TriggerContainSpacesException() + + if command_name.lower() in self.triggers: + raise RepeatedTriggerNameException() + + if overlapping := (self.aliases | self.triggers) & set(map(lambda x: x.lower(), command.aliases)): + raise RepeatedAliasNameException(overlapping) + flags: Flags = command.registered_flags flags_name: list[str] = [flag.string_entity.lower() for flag in flags] if len(set(flags_name)) < len(flags_name): raise RepeatedFlagNameException() + + def _update_routing_keys(self, registered_command: Command): + 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 +100,15 @@ def finds_appropriate_handler(self, input_command: InputCommand) -> None: :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!") + 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 +156,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 diff --git a/src/argenta/router/exceptions.py b/src/argenta/router/exceptions.py index 6754a37..772c02b 100644 --- a/src/argenta/router/exceptions.py +++ b/src/argenta/router/exceptions.py @@ -11,7 +11,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/unit_tests/test_app.py b/tests/unit_tests/test_app.py index 2b99db4..e75d89f 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 @@ -207,24 +208,17 @@ 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'})) - def handler2(_res: Response) -> None: - pass - - app.include_routers(router) - app._pre_cycle_setup() - - captured = capsys.readouterr() - - assert "Overlapping" in captured.out + with pytest.raises(RepeatedAliasNameException): + @router.command(Command('test2', aliases={'alias'})) + def handler2(_res: Response) -> None: + pass # ============================================================================ diff --git a/tests/unit_tests/test_router.py b/tests/unit_tests/test_router.py index 1ec082f..d6c022a 100644 --- a/tests/unit_tests/test_router.py +++ b/tests/unit_tests/test_router.py @@ -12,6 +12,7 @@ from argenta.router.entity import _structuring_input_flags, _validate_func_args # pyright: ignore[reportPrivateUsage] from argenta.router.exceptions import ( RepeatedFlagNameException, + RepeatedTriggerNameException, RequiredArgumentNotPassedException, TriggerContainSpacesException, ) @@ -26,7 +27,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 +206,33 @@ def handler(_res: Response) -> None: output = capsys.readouterr() assert "Hello World!" in output.out + +def test_finds_appropriate_handler_executes_handler_by_alias_with_differrent_register(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_with_differrent_register(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: From 183f0697666de757fba094bc30c46d6663971d3b Mon Sep 17 00:00:00 2001 From: kolo Date: Mon, 8 Dec 2025 19:29:54 +0300 Subject: [PATCH 2/6] Update documentation and code snippets --- docs/conf.py | 4 +- src/argenta/app/models.py | 24 +++++++++ src/argenta/router/entity.py | 3 ++ src/argenta/router/exceptions.py | 8 ++- tests/unit_tests/test_app.py | 84 ++++++++++++++++++++++++++++++++ tests/unit_tests/test_router.py | 60 ++++++++++++++++++++++- 6 files changed, 179 insertions(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 9e754a8..f5862a5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,8 +29,8 @@ html_context = { "languages": [ - ("English", "/en/latest/%s/", "en"), - ("Русский", "/ru/latest/%s/", "ru"), + ("English", "/en/latest/%s.html", "en"), + ("Русский", "/ru/latest/%s.html", "ru"), ] } diff --git a/src/argenta/app/models.py b/src/argenta/app/models.py index 9b0d31b..32c02ce 100644 --- a/src/argenta/app/models.py +++ b/src/argenta/app/models.py @@ -23,6 +23,7 @@ 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 @@ -273,6 +274,28 @@ def _(response: Response) -> None: self.system_router.command_register_ignore = self._ignore_command_register 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: + trigger_collisions: set[str] = (all_triggers | all_aliases) & router_entity.triggers + if trigger_collisions: + raise RepeatedTriggerNameException() + + alias_collisions: set[str] = (all_aliases | all_triggers) & 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()) @@ -344,6 +367,7 @@ def _pre_cycle_setup(self) -> None: :return: None """ self._setup_system_router() + self._validate_routers_for_collisions() for router_entity in self.registered_routers: router_triggers = router_entity.triggers diff --git a/src/argenta/router/entity.py b/src/argenta/router/entity.py index 12edb33..f3f50a0 100644 --- a/src/argenta/router/entity.py +++ b/src/argenta/router/entity.py @@ -80,6 +80,9 @@ def _validate_command(self, command: Command) -> None: 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) diff --git a/src/argenta/router/exceptions.py b/src/argenta/router/exceptions.py index 772c02b..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 diff --git a/tests/unit_tests/test_app.py b/tests/unit_tests/test_app.py index e75d89f..7909ce6 100644 --- a/tests/unit_tests/test_app.py +++ b/tests/unit_tests/test_app.py @@ -221,6 +221,90 @@ 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_router(router1) + app.include_router(router2) + + with pytest.raises(RepeatedTriggerNameException): + app._pre_cycle_setup() + + +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() + + # ============================================================================ # Tests for startup messages # ============================================================================ diff --git a/tests/unit_tests/test_router.py b/tests/unit_tests/test_router.py index d6c022a..347338a 100644 --- a/tests/unit_tests/test_router.py +++ b/tests/unit_tests/test_router.py @@ -9,8 +9,9 @@ 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, @@ -247,3 +248,60 @@ def handler(_res: Response) -> None: 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 From 22970f7115bce6cfaf7ea11e2efd69459281fd24 Mon Sep 17 00:00:00 2001 From: kolo Date: Mon, 8 Dec 2025 19:53:03 +0300 Subject: [PATCH 3/6] Update documentation --- .../en/LC_MESSAGES/root/api/command/index.po | 18 +-- .../locales/en/LC_MESSAGES/root/api/router.po | 71 +++++++---- .../en/LC_MESSAGES/root/redirect_stdout.po | 111 +++++++++--------- docs/root/api/command/index.rst | 8 +- docs/root/api/router.rst | 40 ++++++- docs/root/redirect_stdout.rst | 11 +- 6 files changed, 164 insertions(+), 95 deletions(-) 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..e56e899 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:17 +msgid "✓ Используют прогресс-бары (``tqdm``, ``rich.progress``)" +msgstr "✓ Use progress bars (``tqdm``, ``rich.progress``)" -#: ../../root/redirect_stdout.rst:20 +#: ../../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/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()`` перехват можно оставить включённым — это не влияет на их работу. From 725a1f2e40b37344e67b6564317fa3a15eca5f1a Mon Sep 17 00:00:00 2001 From: kolo Date: Mon, 8 Dec 2025 21:49:46 +0300 Subject: [PATCH 4/6] perf --- src/argenta/app/autocompleter/entity.py | 27 +++++++--- src/argenta/app/models.py | 2 - src/argenta/app/protocols.py | 1 - src/argenta/app/registered_routers/entity.py | 6 ++- src/argenta/command/flag/models.py | 1 - src/argenta/command/models.py | 54 +++++++++++-------- src/argenta/metrics/__init__.py | 3 +- .../argparser/arguments/__init__.py | 3 +- src/argenta/orchestrator/argparser/entity.py | 9 ++-- src/argenta/response/status.py | 3 +- src/argenta/router/command_handler/entity.py | 7 ++- src/argenta/router/entity.py | 21 +++----- 12 files changed, 76 insertions(+), 61 deletions(-) diff --git a/src/argenta/app/autocompleter/entity.py b/src/argenta/app/autocompleter/entity.py index 7f1479f..22aa6e9 100644 --- a/src/argenta/app/autocompleter/entity.py +++ b/src/argenta/app/autocompleter/entity.py @@ -6,7 +6,9 @@ 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 @@ def _complete(self, text: str, state: int) -> str | None: :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: @@ -72,13 +80,17 @@ def exit_setup(self, all_commands: list[str], ignore_command_register: bool) -> 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 _is_command_exist( + line.split()[0], all_commands, ignore_command_register + ): 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: +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 @@ -89,4 +101,7 @@ 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 32c02ce..a4d4214 100644 --- a/src/argenta/app/models.py +++ b/src/argenta/app/models.py @@ -272,7 +272,6 @@ def _setup_system_router(self) -> None: def _(response: Response) -> None: self._exit_command_handler(response) - self.system_router.command_register_ignore = self._ignore_command_register self.registered_routers.add_registered_router(self.system_router) def _validate_routers_for_collisions(self) -> None: @@ -519,7 +518,6 @@ def include_router(self, router: Router) -> None: :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..abd2ee0 100644 --- a/src/argenta/app/protocols.py +++ b/src/argenta/app/protocols.py @@ -2,7 +2,6 @@ from typing import Protocol, TypeVar - T = TypeVar("T", contravariant=True) # noqa: WPS111 diff --git a/src/argenta/app/registered_routers/entity.py b/src/argenta/app/registered_routers/entity.py index 366676e..7ffa841 100644 --- a/src/argenta/app/registered_routers/entity.py +++ b/src/argenta/app/registered_routers/entity.py @@ -1,18 +1,20 @@ __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, registered_routers: list[Router] | None = None) -> 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._matching_lower_triggers_with_routers def add_registered_router(self, router: Router, /) -> None: """ 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 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 @@ def __init__( :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 @@ def __init__(self, trigger: str, *, input_flags: InputFlag | InputFlags = DEFAUL """ 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 @@ def parse(cls, raw_command: str) -> Self: :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 @@ def parse(cls, raw_command: str) -> Self: 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 index 9888ab8..e97a8ca 100644 --- a/src/argenta/metrics/__init__.py +++ b/src/argenta/metrics/__init__.py @@ -1 +1,2 @@ -from argenta.metrics.main import get_time_of_pre_cycle_setup as get_time_of_pre_cycle_setup +from argenta.metrics.main import \ + get_time_of_pre_cycle_setup as get_time_of_pre_cycle_setup 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 @@ 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/response/status.py b/src/argenta/response/status.py index c156494..c736de0 100644 --- a/src/argenta/response/status.py +++ b/src/argenta/response/status.py @@ -1,7 +1,6 @@ __all__ = ["ResponseStatus"] from enum import Enum -from typing import Self class ResponseStatus(Enum): @@ -11,7 +10,7 @@ class ResponseStatus(Enum): UNDEFINED_AND_INVALID_FLAGS = "UNDEFINED_AND_INVALID_FLAGS" @classmethod - def from_flags(cls, *, has_invalid_value_flags: bool, has_undefined_flags: bool) -> Self: + def from_flags(cls, *, has_invalid_value_flags: bool, has_undefined_flags: bool) -> "ResponseStatus": key = (has_invalid_value_flags, has_undefined_flags) status_map: dict[tuple[bool, bool], ResponseStatus] = { (True, True): cls.UNDEFINED_AND_INVALID_FLAGS, diff --git a/src/argenta/router/command_handler/entity.py b/src/argenta/router/command_handler/entity.py index 51dc1cb..9ac05b3 100644 --- a/src/argenta/router/command_handler/entity.py +++ b/src/argenta/router/command_handler/entity.py @@ -1,12 +1,11 @@ __all__ = ["CommandHandler", "CommandHandlers"] from collections.abc import Iterator -from typing import Callable +from typing import Callable, Never from argenta.command import Command from argenta.response import Response - HandlerFunc = Callable[..., None] @@ -30,7 +29,7 @@ def handling(self, response: Response) -> None: class CommandHandlers: - def __init__(self, command_handlers: tuple[CommandHandler] = tuple()): + 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 @@ -49,7 +48,7 @@ def add_handler(self, command_handler: CommandHandler) -> None: 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): + def get_command_handler_by_trigger(self, trigger: str) -> CommandHandler | None: print(self.paired_command_handler_trigger) return self.paired_command_handler_trigger.get(trigger) diff --git a/src/argenta/router/entity.py b/src/argenta/router/entity.py index f3f50a0..29df179 100644 --- a/src/argenta/router/entity.py +++ b/src/argenta/router/entity.py @@ -7,16 +7,14 @@ 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 ( - RepeatedAliasNameException, - RepeatedFlagNameException, - RepeatedTriggerNameException, - RequiredArgumentNotPassedException, - TriggerContainSpacesException, -) +from argenta.router.exceptions import (RepeatedAliasNameException, + RepeatedFlagNameException, + RepeatedTriggerNameException, + RequiredArgumentNotPassedException, + TriggerContainSpacesException) HandlerFunc: TypeAlias = Callable[..., None] @@ -42,8 +40,6 @@ def __init__( 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() @@ -87,12 +83,11 @@ def _validate_command(self, command: Command) -> None: if overlapping := (self.aliases | self.triggers) & set(map(lambda x: x.lower(), command.aliases)): raise RepeatedAliasNameException(overlapping) - flags: Flags = command.registered_flags - flags_name: list[str] = [flag.string_entity.lower() for flag in flags] + 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): + 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()) From 56189be6abe1e44b4ef21cd0e7b76092860cf4c9 Mon Sep 17 00:00:00 2001 From: kolo Date: Tue, 9 Dec 2025 12:02:26 +0300 Subject: [PATCH 5/6] better perf --- mock/local_test.py | 14 ++- src/argenta/app/autocompleter/entity.py | 16 +--- src/argenta/app/models.py | 83 +++-------------- src/argenta/app/registered_routers/entity.py | 17 +++- src/argenta/router/entity.py | 2 +- tests/unit_tests/test_autocompleter.py | 25 +---- tests/unit_tests/test_router.py | 96 +++++++++++++++++++- 7 files changed, 138 insertions(+), 115 deletions(-) diff --git a/mock/local_test.py b/mock/local_test.py index 0aaef4e..c34d3b5 100644 --- a/mock/local_test.py +++ b/mock/local_test.py @@ -1,10 +1,14 @@ -from argenta import Command, Response, Router +from argenta import Command, Response, Router, App, Orchestrator from argenta.command import InputCommand router = Router() +orchestrator = Orchestrator() -@router.command(Command('heLLo')) -def handler(_res: Response) -> None: - print("Hello World!") +@router.command(Command('test')) +def test(_response: Response) -> None: # pyright: ignore[reportUnusedFunction] + print('test command') -router.finds_appropriate_handler(InputCommand('HellO')) +app = App(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) \ No newline at end of file diff --git a/src/argenta/app/autocompleter/entity.py b/src/argenta/app/autocompleter/entity.py index 22aa6e9..a50bad0 100644 --- a/src/argenta/app/autocompleter/entity.py +++ b/src/argenta/app/autocompleter/entity.py @@ -48,7 +48,7 @@ def _complete(self, text: str, state: int) -> str | None: 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 @@ -69,7 +69,7 @@ def initial_setup(self, all_commands: list[str]) -> None: 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 @@ -80,22 +80,12 @@ def exit_setup(self, all_commands: list[str], ignore_command_register: bool) -> 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 diff --git a/src/argenta/app/models.py b/src/argenta/app/models.py index a4d4214..b82019b 100644 --- a/src/argenta/app/models.py +++ b/src/argenta/app/models.py @@ -40,7 +40,6 @@ def __init__( 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, @@ -51,8 +50,7 @@ def __init__( self._print_func: Printer = print_func self._exit_command: Command = exit_command 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) @@ -66,15 +64,6 @@ def __init__( 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: {_}") ) @@ -217,37 +206,12 @@ def _is_exit_command(self, command: InputCommand) -> bool: """ 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, command: InputCommand) -> bool: - """ - 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 - """ - 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 - def _error_handler(self, error: InputCommandException, raw_command: str) -> None: """ Private. Handles parsing errors of the entered command @@ -297,7 +261,7 @@ def _validate_routers_for_collisions(self) -> None: 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) @@ -368,20 +332,7 @@ def _pre_cycle_setup(self) -> 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()) - ) + self._autocompleter.initial_setup(self.registered_routers.get_triggers()) if not self._override_system_messages: self._setup_default_view() @@ -392,13 +343,14 @@ def _pre_cycle_setup(self) -> None: 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() @@ -452,7 +404,6 @@ def __init__( :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 @@ -466,7 +417,6 @@ def __init__( 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, @@ -481,7 +431,7 @@ def run_polling(self) -> None: """ 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) @@ -497,13 +447,10 @@ def run_polling(self) -> None: if self._is_exit_command(input_command): self.system_router.finds_appropriate_handler(input_command) - self._autocompleter.exit_setup( - list(self._current_matching_triggers_with_routers.keys()), - self._ignore_command_register, - ) + self._autocompleter.exit_setup(self.registered_routers.get_triggers()) return - if self._is_unknown_command(input_command): + if self.registered_routers.get_router_by_trigger(input_command.trigger.lower()): with redirect_stdout(io.StringIO()) as stdout: self._unknown_command_handler(input_command) stdout_res: str = stdout.getvalue() diff --git a/src/argenta/app/registered_routers/entity.py b/src/argenta/app/registered_routers/entity.py index 7ffa841..e670228 100644 --- a/src/argenta/app/registered_routers/entity.py +++ b/src/argenta/app/registered_routers/entity.py @@ -6,15 +6,14 @@ class RegisteredRouters: - def __init__(self, registered_routers: list[Router] | None = 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._matching_lower_triggers_with_routers + self.registered_routers: list[Router] = [] + self._paired_trigger_router: dict[str, Router] = {} def add_registered_router(self, router: Router, /) -> None: """ @@ -23,6 +22,14 @@ def add_registered_router(self, router: Router, /) -> None: :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/router/entity.py b/src/argenta/router/entity.py index 29df179..18b1134 100644 --- a/src/argenta/router/entity.py +++ b/src/argenta/router/entity.py @@ -104,7 +104,7 @@ def finds_appropriate_handler(self, input_command: InputCommand) -> None: 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!") + raise RuntimeError(f"Handler for '{input_command.trigger}' command not found. Panic!") else: self.process_input_command(input_command_flags, command_handler) 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 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 347338a..21bc6d3 100644 --- a/tests/unit_tests/test_router.py +++ b/tests/unit_tests/test_router.py @@ -208,7 +208,7 @@ def handler(_res: Response) -> None: assert "Hello World!" in output.out -def test_finds_appropriate_handler_executes_handler_by_alias_with_differrent_register(capsys: CaptureFixture[str]) -> None: +def test_finds_appropriate_handler_executes_handler_by_alias_case_insensitive(capsys: CaptureFixture[str]) -> None: router = Router() @router.command(Command('hello', aliases={'hI'})) @@ -222,7 +222,7 @@ def handler(_res: Response) -> None: assert "Hello World!" in output.out -def test_finds_appropriate_handler_executes_handler_by_trigger_with_differrent_register(capsys: CaptureFixture[str]) -> None: +def test_finds_appropriate_handler_executes_handler_by_trigger_case_insensitive(capsys: CaptureFixture[str]) -> None: router = Router() @router.command(Command('heLLo')) @@ -305,3 +305,95 @@ def handler(_res: Response) -> None: @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 From c5dab43c87adb6b8592a79d2af4892fd801817b3 Mon Sep 17 00:00:00 2001 From: kolo Date: Fri, 9 Jan 2026 10:14:25 +0300 Subject: [PATCH 6/6] fix tests and improve perf --- mock/local_test.py | 24 ++++----- mock/mock_app/main.py | 7 +-- mock/mock_app/routers.py | 9 +++- src/argenta/app/models.py | 8 ++- src/argenta/router/command_handler/entity.py | 1 - ...t_system_handling_non_standard_behavior.py | 21 -------- .../test_system_handling_normal_behavior.py | 20 ------- tests/unit_tests/test_app.py | 52 ++++--------------- 8 files changed, 37 insertions(+), 105 deletions(-) diff --git a/mock/local_test.py b/mock/local_test.py index c34d3b5..5cd3471 100644 --- a/mock/local_test.py +++ b/mock/local_test.py @@ -1,14 +1,14 @@ -from argenta import Command, Response, Router, App, Orchestrator -from argenta.command import InputCommand +from abc import ABC, abstractmethod -router = Router() -orchestrator = Orchestrator() -@router.command(Command('test')) -def test(_response: Response) -> None: # pyright: ignore[reportUnusedFunction] - print('test command') - -app = App(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) \ No newline at end of file +class Figure(ABC): + @abstractmethod + def draw(self) -> None: + raise NotImplementedError + +class Rectangle(Figure): + def __init__(self, x: int, y: int) -> None: + self.x = x + self.y = y + +rec = Rectangle(5, 2) \ No newline at end of file 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 @@ 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/src/argenta/app/models.py b/src/argenta/app/models.py index b82019b..d2363df 100644 --- a/src/argenta/app/models.py +++ b/src/argenta/app/models.py @@ -211,6 +211,11 @@ def _is_exit_command(self, command: InputCommand) -> bool: 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 _error_handler(self, error: InputCommandException, raw_command: str) -> None: """ @@ -389,7 +394,6 @@ def __init__( 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, @@ -450,7 +454,7 @@ def run_polling(self) -> None: self._autocompleter.exit_setup(self.registered_routers.get_triggers()) return - if self.registered_routers.get_router_by_trigger(input_command.trigger.lower()): + 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() diff --git a/src/argenta/router/command_handler/entity.py b/src/argenta/router/command_handler/entity.py index 9ac05b3..55aa30d 100644 --- a/src/argenta/router/command_handler/entity.py +++ b/src/argenta/router/command_handler/entity.py @@ -49,7 +49,6 @@ def add_handler(self, command_handler: CommandHandler) -> None: self.paired_command_handler_trigger[alias.lower()] = command_handler def get_command_handler_by_trigger(self, trigger: str) -> CommandHandler | None: - print(self.paired_command_handler_trigger) return self.paired_command_handler_trigger.get(trigger) def __iter__(self) -> Iterator[CommandHandler]: 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(_response: Response) -> None: # pyright: ignore[reportUnusedFunction] 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(_response: Response) -> None: # pyright: ignore[reportUnusedFunction] 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 7909ce6..4306b18 100644 --- a/tests/unit_tests/test_app.py +++ b/tests/unit_tests/test_app.py @@ -26,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 # ============================================================================ @@ -74,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 # ============================================================================ @@ -632,7 +598,7 @@ def handler2(_command: InputCommand) -> 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 @@ -645,7 +611,7 @@ def custom_handler(command: InputCommand) -> 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