From 75b1efb259777fb867c1fcae451ff43bc6f79719 Mon Sep 17 00:00:00 2001 From: kolo Date: Mon, 8 Dec 2025 14:17:31 +0300 Subject: [PATCH 01/15] 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.exceptions import ( 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 @@ class BaseApp: self._prompt: str = prompt self._print_func: Printer = print_func self._exit_command: Command = exit_command - self._system_router_title: str = system_router_title self._dividing_line: StaticDividingLine | DynamicDividingLine = dividing_line self._ignore_command_register: bool = ignore_command_register self._repeat_command_groups_printing_description: bool = repeat_command_groups_printing self._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 @@ class BaseApp: 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 @@ class BaseApp: """ self._description_message_gen = _ - def set_incorrect_input_syntax_handler(self, _: NonStandardBehaviorHandler[str], /) -> None: + def set_incorrect_input_syntax_handler( + self, _: NonStandardBehaviorHandler[str], / + ) -> None: """ Public. Sets the handler for incorrect flags when entering a command :param _: handler for incorrect flags when entering a command @@ -105,7 +108,9 @@ class BaseApp: """ self._incorrect_input_syntax_handler = _ - def set_repeated_input_flags_handler(self, _: NonStandardBehaviorHandler[str], /) -> None: + def set_repeated_input_flags_handler( + self, _: NonStandardBehaviorHandler[str], / + ) -> None: """ Public. Sets the handler for repeated flags when entering a command :param _: handler for repeated flags when entering a command @@ -113,7 +118,9 @@ class BaseApp: """ self._repeated_input_flags_handler = _ - def set_unknown_command_handler(self, _: NonStandardBehaviorHandler[InputCommand], /) -> None: + def set_unknown_command_handler( + self, _: NonStandardBehaviorHandler[InputCommand], / + ) -> None: """ Public. Sets the handler for unknown commands when entering a command :param _: handler for unknown commands when entering a command @@ -129,7 +136,9 @@ class BaseApp: """ self._empty_input_command_handler = _ - def set_exit_command_handler(self, _: NonStandardBehaviorHandler[Response], /) -> None: + def set_exit_command_handler( + self, _: NonStandardBehaviorHandler[Response], / + ) -> None: """ Public. Sets the handler for exit command when entering a command :param _: handler for exit command when entering a command @@ -164,7 +173,11 @@ class BaseApp: 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 @@ class BaseApp: elif isinstance(self._dividing_line, StaticDividingLine): # pyright: ignore[reportUnnecessaryIsInstance] self._print_func( - self._dividing_line.get_full_static_line(is_override=self._override_system_messages) + self._dividing_line.get_full_static_line( + is_override=self._override_system_messages + ) ) print(text.strip("\n")) self._print_func( - self._dividing_line.get_full_static_line(is_override=self._override_system_messages) + self._dividing_line.get_full_static_line( + is_override=self._override_system_messages + ) ) else: @@ -219,10 +236,14 @@ class BaseApp: """ 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 @@ class BaseApp: Private. Sets up system router :return: None """ - system_router.title = self._system_router_title - @system_router.command(self._exit_command) + @self.system_router.command(self._exit_command) def _(response: Response) -> None: self._exit_command_handler(response) - system_router.command_register_ignore = self._ignore_command_register - self.registered_routers.add_registered_router(system_router) + self.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 @@ class BaseApp: :return: None """ self._prompt = f"[italic dim bold]{self._prompt}" - self._initial_message = "\n" + f"[bold red]{text2art(self._initial_message, font='tarty1')}" + "\n" + self._initial_message = ( + "\n" + f"[bold red]{text2art(self._initial_message, font='tarty1')}" + "\n" + ) self._farewell_message = ( "[bold red]\n\n" + str(text2art(self._farewell_message, font="chanky")) # pyright: ignore[reportUnknownArgumentType] @@ -297,14 +319,20 @@ class BaseApp: self._repeated_input_flags_handler = lambda raw_command: self._print_func( f"[red bold]Repeated input flags: {escape(raw_command)}" ) - self._empty_input_command_handler = lambda: self._print_func("[red bold]Empty input command") + self._empty_input_command_handler = lambda: self._print_func( + "[red bold]Empty input command" + ) def unknown_command_handler(command: InputCommand) -> None: cmd_trg: str = command.trigger mst_sim_cmd: str | None = self._most_similar_command(cmd_trg) - first_part_of_text = f"[red]Unknown command:[/red] [blue]{escape(cmd_trg)}[/blue]" + first_part_of_text = ( + f"[red]Unknown command:[/red] [blue]{escape(cmd_trg)}[/blue]" + ) second_part_of_text = ( - ("[red], most similar:[/red] " + ("[blue]" + mst_sim_cmd + "[/blue]")) if mst_sim_cmd else "" + ("[red], most similar:[/red] " + ("[blue]" + mst_sim_cmd + "[/blue]")) + if mst_sim_cmd + else "" ) self._print_func(first_part_of_text + second_part_of_text) @@ -324,9 +352,13 @@ class BaseApp: 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 @@ class BaseApp: 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 @@ class App(BaseApp): 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.command import Command 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 @@ class CommandHandler: 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 @@ class CommandHandlers: :return: None """ self.command_handlers.append(command_handler) + self.paired_command_handler_trigger[command_handler.handled_command.trigger.lower()] = command_handler + for alias in command_handler.handled_command.aliases: + self.paired_command_handler_trigger[alias.lower()] = command_handler + + def get_command_handler_by_trigger(self, trigger: str): + 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.command.flag.flags import Flags, 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, ) @@ -57,13 +59,8 @@ class Router: redefined_command = command self._validate_command(redefined_command) - - if overlapping := (self.aliases | self.triggers) & redefined_command.aliases: - Console().print(f"\n[b red]WARNING:[/b red] Overlapping trigger or alias: [b blue]{overlapping}[/b blue]") + self._update_routing_keys(redefined_command) - self.aliases.update(redefined_command.aliases) - self.triggers.add(redefined_command.trigger) - def decorator(func: HandlerFunc) -> HandlerFunc: _validate_func_args(func) self.command_handlers.add_handler(CommandHandler(func, redefined_command)) @@ -80,10 +77,22 @@ class Router: 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 @@ class Router: :param input_command: input command as InputCommand :return: None """ - input_command_name: str = input_command.trigger + input_command_name: str = input_command.trigger.lower() input_command_flags: InputFlags = input_command.input_flags - for command_handler in self.command_handlers: - handle_command = command_handler.handled_command - if input_command_name.lower() == handle_command.trigger.lower(): - self.process_input_command(input_command_flags, command_handler) - if input_command_name.lower() in handle_command.aliases: - self.process_input_command(input_command_flags, command_handler) + command_handler = self.command_handlers.get_command_handler_by_trigger(input_command_name) + + if not command_handler: + raise RuntimeError(f"Handler for '{input_command.trigger}' command not found!") + 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 import Router 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 test_finds_appropriate_handler_executes_handler_by_alias(capsys: CaptureFixt output = capsys.readouterr() assert "Hello World!" in output.out + +def test_finds_appropriate_handler_executes_handler_by_alias_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 02/15] 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_static_path = ["_static"] 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 @@ from argenta.command.exceptions import ( RepeatedInputFlagsException, UnprocessedInputFlagException, ) +from argenta.router.exceptions import RepeatedAliasNameException, RepeatedTriggerNameException from argenta.command.models import Command, InputCommand from argenta.response import Response from argenta.router import Router @@ -273,6 +274,28 @@ class BaseApp: 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 @@ class BaseApp: :return: None """ self._setup_system_router() + self._validate_routers_for_collisions() for router_entity in self.registered_routers: router_triggers = router_entity.triggers 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 @@ class Router: 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 test_overlapping_aliases_raises_exception() -> 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.flags import Flags, InputFlags from argenta.command.flag.models import PossibleValues, ValidationStatus from argenta.response.entity import Response from argenta.router import Router -from argenta.router.entity import _structuring_input_flags, _validate_func_args # pyright: ignore[reportPrivateUsage] +from argenta.router.entity import _structuring_input_flags, _validate_func_args from argenta.router.exceptions import ( + RepeatedAliasNameException, RepeatedFlagNameException, RepeatedTriggerNameException, RequiredArgumentNotPassedException, @@ -247,3 +248,60 @@ def test_finds_appropriate_handler_executes_handler_with_flags_by_alias(capsys: output = capsys.readouterr() assert "Hello World!" in output.out + + +# ============================================================================ +# Tests for alias and trigger collision detection +# ============================================================================ + + +def test_validate_command_raises_error_for_alias_collision_with_existing_trigger() -> None: + router = Router() + + @router.command('hello') + def handler(_res: Response) -> None: + pass + + with pytest.raises(RepeatedAliasNameException): + @router.command(Command('world', aliases={'hello'})) + def handler2(_res: Response) -> None: + pass + + +def test_validate_command_raises_error_for_alias_collision_with_existing_alias() -> None: + router = Router() + + @router.command(Command('hello', aliases={'hi'})) + def handler(_res: Response) -> None: + pass + + with pytest.raises(RepeatedAliasNameException): + @router.command(Command('world', aliases={'hi'})) + def handler2(_res: Response) -> None: + pass + + +def test_validate_command_raises_error_for_trigger_collision_with_existing_alias() -> None: + router = Router() + + @router.command(Command('hello', aliases={'hi'})) + def handler(_res: Response) -> None: + pass + + with pytest.raises(RepeatedAliasNameException): + @router.command('hi') + def handler2(_res: Response) -> None: + pass + + +def test_validate_command_raises_error_for_alias_collision_case_insensitive() -> None: + router = Router() + + @router.command(Command('hello', aliases={'Hi'})) + def handler(_res: Response) -> None: + pass + + with pytest.raises(RepeatedAliasNameException): + @router.command(Command('world', aliases={'hI'})) + def handler2(_res: Response) -> None: + pass From 22970f7115bce6cfaf7ea11e2efd69459281fd24 Mon Sep 17 00:00:00 2001 From: kolo Date: Mon, 8 Dec 2025 19:53:03 +0300 Subject: [PATCH 03/15] 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:20 +#: ../../root/redirect_stdout.rst:17 +msgid "✓ Используют прогресс-бары (``tqdm``, ``rich.progress``)" +msgstr "✓ Use progress bars (``tqdm``, ``rich.progress``)" + +#: ../../root/redirect_stdout.rst:19 +msgid "✓ Выводят данные в реальном времени (streaming, логи)" +msgstr "✓ Output data in real-time (streaming, logs)" + +#: ../../root/redirect_stdout.rst:21 +msgid "✓ Используют библиотеки, которые напрямую работают с ``stdout``" +msgstr "✓ Use libraries that work directly with ``stdout``" + +#: ../../root/redirect_stdout.rst:23 msgid "" "Для обычных команд с ``print()`` перехват можно оставить включённым — это" " не влияет на их работу." @@ -64,11 +69,11 @@ msgstr "" "For regular commands with ``print()``, interception can be left enabled —" " it does not affect their operation." -#: ../../root/redirect_stdout.rst:25 +#: ../../root/redirect_stdout.rst:28 msgid "Механизм перехвата ``stdout``" msgstr "``stdout`` Interception Mechanism" -#: ../../root/redirect_stdout.rst:27 +#: ../../root/redirect_stdout.rst:30 msgid "" "По умолчанию ``Argenta`` перехватывает весь текст, выводимый в ``stdout``" " внутри обработчика команды. Это необходимо для реализации **динамических" @@ -83,15 +88,15 @@ msgstr "" "draw the top and bottom borders. This approach creates a neat interface " "where the command output is \"wrapped\" in a frame fitted to its content." -#: ../../root/redirect_stdout.rst:29 +#: ../../root/redirect_stdout.rst:32 msgid "Пример приложения с динамической разделительной линией:" msgstr "Example of an application with a dynamic dividing line:" -#: ../../root/redirect_stdout.rst:31 +#: ../../root/redirect_stdout.rst:34 msgid "Example of an application with a dynamic dividing line" msgstr "Example of an application with a dynamic dividing line" -#: ../../root/redirect_stdout.rst:34 +#: ../../root/redirect_stdout.rst:37 msgid "" "Как вы можете заметить, разделительная линия ровно той же длины, что и " "самая длинная строка в выводе." @@ -99,15 +104,15 @@ msgstr "" "As you can see, the dividing line is exactly the same length as the " "longest line in the output." -#: ../../root/redirect_stdout.rst:36 +#: ../../root/redirect_stdout.rst:39 msgid "То же приложение с статической линией:" msgstr "The same application with a static line:" -#: ../../root/redirect_stdout.rst:38 +#: ../../root/redirect_stdout.rst:41 msgid "Example of an application with a static dividing line" msgstr "Example of an application with a static dividing line" -#: ../../root/redirect_stdout.rst:41 +#: ../../root/redirect_stdout.rst:44 msgid "" "В этом примере разделительная линия имеет фиксированную длину (по " "умолчанию 25 символов)." @@ -115,11 +120,11 @@ msgstr "" "In this example, the dividing line has a fixed length (25 characters by " "default)." -#: ../../root/redirect_stdout.rst:46 +#: ../../root/redirect_stdout.rst:49 msgid "Побочные эффекты перехвата ``stdout``" msgstr "Side Effects of ``stdout`` Interception" -#: ../../root/redirect_stdout.rst:48 +#: ../../root/redirect_stdout.rst:51 msgid "" "Побочный эффект этого механизма проявляется при использовании функций, " "которые последовательно выводят текст в консоль и ожидают ввод от " @@ -129,7 +134,7 @@ msgstr "" "sequentially output text to the console and expect user input. A classic " "example is the standard ``input()`` function." -#: ../../root/redirect_stdout.rst:57 +#: ../../root/redirect_stdout.rst:60 msgid "" "При включённом перехвате ``stdout`` текст (например, ``\"Введите ваше " "имя: \"``) **не будет выведен в консоль немедленно**. Он попадёт в буфер " @@ -141,11 +146,11 @@ msgstr "" " into a buffer and appear only after the handler finishes, along with the" " rest of the output. This can confuse the user." -#: ../../root/redirect_stdout.rst:62 +#: ../../root/redirect_stdout.rst:65 msgid "Отключение перехвата ``stdout`` с помощью ``disable_redirect_stdout``" msgstr "Disabling ``stdout`` Interception with ``disable_redirect_stdout``" -#: ../../root/redirect_stdout.rst:64 +#: ../../root/redirect_stdout.rst:67 msgid "" "Чтобы решить эту проблему, в конструкторе ``Router`` предусмотрен " "специальный аргумент:" @@ -153,11 +158,11 @@ msgstr "" "To solve this problem, the ``Router`` constructor provides a special " "argument:" -#: ../../root/redirect_stdout.rst:66 +#: ../../root/redirect_stdout.rst:69 msgid "**disable_redirect_stdout** (``bool``, по умолчанию ``False``)" msgstr "**disable_redirect_stdout** (``bool``, default ``False``)" -#: ../../root/redirect_stdout.rst:68 +#: ../../root/redirect_stdout.rst:71 msgid "" "Если при создании роутера установить ``disable_redirect_stdout=True``, " "механизм перехвата ``stdout`` будет отключён для всех его обработчиков." @@ -165,11 +170,11 @@ msgstr "" "If you set ``disable_redirect_stdout=True`` when creating a router, the " "``stdout`` interception mechanism will be disabled for all its handlers." -#: ../../root/redirect_stdout.rst:70 ../../root/redirect_stdout.rst:100 +#: ../../root/redirect_stdout.rst:73 ../../root/redirect_stdout.rst:103 msgid "**Пример использования:**" msgstr "**Usage example:**" -#: ../../root/redirect_stdout.rst:76 +#: ../../root/redirect_stdout.rst:79 msgid "" "В этом случае ``input()`` будет работать как обычно, и пользователь сразу" " увидит приглашение к вводу." @@ -177,11 +182,11 @@ msgstr "" "In this case, ``input()`` will work as usual, and the user will " "immediately see the input prompt." -#: ../../root/redirect_stdout.rst:81 +#: ../../root/redirect_stdout.rst:84 msgid "Типы разделительных линий" msgstr "Types of Dividing Lines" -#: ../../root/redirect_stdout.rst:83 +#: ../../root/redirect_stdout.rst:86 msgid "" "``Argenta`` поддерживает два типа разделителей, которые настраиваются при" " инициализации ``App``:" @@ -189,11 +194,11 @@ msgstr "" "``Argenta`` supports two types of dividers, which are configured during " "``App`` initialization:" -#: ../../root/redirect_stdout.rst:85 +#: ../../root/redirect_stdout.rst:88 msgid "**``DynamicDividingLine()``**" msgstr "**``DynamicDividingLine()``**" -#: ../../root/redirect_stdout.rst:86 +#: ../../root/redirect_stdout.rst:89 msgid "" "Поведение по умолчанию. Длина линии динамически подстраивается под самый " "длинный текст в выводе." @@ -201,7 +206,7 @@ msgstr "" "Default behavior. The line length dynamically adjusts to the longest text" " in the output." -#: ../../root/redirect_stdout.rst:87 +#: ../../root/redirect_stdout.rst:90 msgid "" "Требует включённого перехвата ``stdout`` " "(``disable_redirect_stdout=False`` в роутере)." @@ -209,11 +214,11 @@ msgstr "" "Requires enabled ``stdout`` interception " "(``disable_redirect_stdout=False`` in the router)." -#: ../../root/redirect_stdout.rst:89 +#: ../../root/redirect_stdout.rst:92 msgid "**``StaticDividingLine(length: int = 25)``**" msgstr "**``StaticDividingLine(length: int = 25)``**" -#: ../../root/redirect_stdout.rst:90 +#: ../../root/redirect_stdout.rst:93 msgid "" "Линия имеет фиксированную длину (по умолчанию 25 символов), которую можно" " задать через аргумент ``length``." @@ -221,7 +226,7 @@ msgstr "" "The line has a fixed length (25 characters by default), which can be set " "via the ``length`` argument." -#: ../../root/redirect_stdout.rst:91 +#: ../../root/redirect_stdout.rst:94 msgid "" "Используется принудительно для роутеров с " "``disable_redirect_stdout=True``, так как без перехвата вывода невозможно" @@ -230,11 +235,11 @@ msgstr "" "Used forcibly for routers with ``disable_redirect_stdout=True``, as it is" " impossible to determine dynamic length without output interception." -#: ../../root/redirect_stdout.rst:96 +#: ../../root/redirect_stdout.rst:99 msgid "Настройка разделительной линии в ``App``" msgstr "Configuring the Dividing Line in ``App``" -#: ../../root/redirect_stdout.rst:98 +#: ../../root/redirect_stdout.rst:101 msgid "" "Вы можете глобально задать тип разделителя для всего приложения через " "аргумент ``dividing_line`` в конструкторе ``App``." @@ -242,63 +247,63 @@ msgstr "" "You can globally set the divider type for the entire application via the " "``dividing_line`` argument in the ``App`` constructor." -#: ../../root/redirect_stdout.rst:109 +#: ../../root/redirect_stdout.rst:112 msgid "Итоговое поведение" msgstr "Resulting Behavior" -#: ../../root/redirect_stdout.rst:115 +#: ../../root/redirect_stdout.rst:118 msgid "``disable_redirect_stdout`` на ``Router``" msgstr "``disable_redirect_stdout`` on ``Router``" -#: ../../root/redirect_stdout.rst:116 +#: ../../root/redirect_stdout.rst:119 msgid "Тип линии в ``App``" msgstr "Line type in ``App``" -#: ../../root/redirect_stdout.rst:117 +#: ../../root/redirect_stdout.rst:120 msgid "Фактическое поведение" msgstr "Actual behavior" -#: ../../root/redirect_stdout.rst:118 +#: ../../root/redirect_stdout.rst:121 msgid "``input()`` работает корректно?" msgstr "Does ``input()`` work correctly?" -#: ../../root/redirect_stdout.rst:119 ../../root/redirect_stdout.rst:123 +#: ../../root/redirect_stdout.rst:122 ../../root/redirect_stdout.rst:126 msgid "``False`` (по умолчанию)" msgstr "``False`` (default)" -#: ../../root/redirect_stdout.rst:120 ../../root/redirect_stdout.rst:128 +#: ../../root/redirect_stdout.rst:123 ../../root/redirect_stdout.rst:131 msgid "``DynamicDividingLine``" msgstr "``DynamicDividingLine``" -#: ../../root/redirect_stdout.rst:121 +#: ../../root/redirect_stdout.rst:124 msgid "Динамическая линия, длина по содержимому" msgstr "Dynamic line, length by content" -#: ../../root/redirect_stdout.rst:122 ../../root/redirect_stdout.rst:126 +#: ../../root/redirect_stdout.rst:125 ../../root/redirect_stdout.rst:129 msgid "Нет" msgstr "No" -#: ../../root/redirect_stdout.rst:124 ../../root/redirect_stdout.rst:132 +#: ../../root/redirect_stdout.rst:127 ../../root/redirect_stdout.rst:135 msgid "``StaticDividingLine``" msgstr "``StaticDividingLine``" -#: ../../root/redirect_stdout.rst:125 ../../root/redirect_stdout.rst:133 +#: ../../root/redirect_stdout.rst:128 ../../root/redirect_stdout.rst:136 msgid "Статическая линия указанной длины" msgstr "Static line of specified length" -#: ../../root/redirect_stdout.rst:127 ../../root/redirect_stdout.rst:131 +#: ../../root/redirect_stdout.rst:130 ../../root/redirect_stdout.rst:134 msgid "``True``" msgstr "``True``" -#: ../../root/redirect_stdout.rst:129 +#: ../../root/redirect_stdout.rst:132 msgid "**Принудительно статическая линия** (длина по умолч.)" msgstr "**Forcibly static line** (default length)" -#: ../../root/redirect_stdout.rst:130 ../../root/redirect_stdout.rst:134 +#: ../../root/redirect_stdout.rst:133 ../../root/redirect_stdout.rst:137 msgid "Да" msgstr "Yes" -#: ../../root/redirect_stdout.rst:136 +#: ../../root/redirect_stdout.rst:139 msgid "" "Таким образом, для интерактивных команд, требующих ввода от пользователя," " отключайте перехват ``stdout`` на уровне роутера. Для всех остальных " diff --git a/docs/root/api/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 04/15] 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 @@ from typing import Never class AutoCompleter: - def __init__(self, history_filename: str | None = None, autocomplete_button: str = "tab") -> None: + def __init__( + self, history_filename: str | None = None, autocomplete_button: str = "tab" + ) -> None: """ Public. Configures and implements auto-completion of input command :param history_filename: the name of the file for saving the history of the autocompleter @@ -23,12 +25,18 @@ class AutoCompleter: :param state: the current cursor position is relative to the beginning of the line :return: the desired candidate as str or None """ - matches: list[str] = sorted(cmd for cmd in _get_history_items() if cmd.startswith(text)) + matches: list[str] = sorted( + cmd for cmd in _get_history_items() if cmd.startswith(text) + ) if len(matches) > 1: common_prefix = matches[0] for match in matches[1:]: i = 0 - while i < len(common_prefix) and i < len(match) and common_prefix[i] == match[i]: + while ( + i < len(common_prefix) + and i < len(match) + and common_prefix[i] == match[i] + ): i += 1 common_prefix = common_prefix[:i] if state == 0: @@ -72,13 +80,17 @@ class AutoCompleter: raw_history = history_file.read() pretty_history: list[str] = [] for line in set(raw_history.strip().split("\n")): - if _is_command_exist(line.split()[0], all_commands, ignore_command_register): + if _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 @@ class BaseApp: 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 @@ class App(BaseApp): :param router: registered router :return: None """ - router.command_register_ignore = self._ignore_command_register self.registered_routers.add_registered_router(router) def include_routers(self, *routers: Router) -> None: diff --git a/src/argenta/app/protocols.py b/src/argenta/app/protocols.py index 530b520..abd2ee0 100644 --- a/src/argenta/app/protocols.py +++ b/src/argenta/app/protocols.py @@ -2,7 +2,6 @@ __all__ = ["NonStandardBehaviorHandler", "EmptyCommandHandler", "Printer", "Desc 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 enum import Enum from re import Pattern from typing import Literal, override - PREFIX_TYPE = Literal["-", "--", "---"] diff --git a/src/argenta/command/models.py b/src/argenta/command/models.py index 858b174..fc8507f 100644 --- a/src/argenta/command/models.py +++ b/src/argenta/command/models.py @@ -1,7 +1,7 @@ __all__ = ["Command", "InputCommand"] import shlex -from typing import Never, Self, cast, Literal +from typing import Literal, Never, Self, cast from argenta.command.exceptions import ( EmptyInputCommandException, @@ -38,30 +38,38 @@ class Command: :param flags: processed commands :param aliases: string synonyms for the main trigger """ - self.registered_flags: Flags = flags if isinstance(flags, Flags) else Flags([flags]) + pretty_flags = flags if isinstance(flags, Flags) else Flags([flags]) + self.registered_flags: Flags = pretty_flags self.trigger: str = trigger self.description: str = description self.aliases: set[str] | set[Never] = aliases + self._paired_string_entity_flag: dict[str, Flag] = { + flag.string_entity: flag for flag in pretty_flags + } + def validate_input_flag(self, flag: InputFlag) -> ValidationStatus: """ Private. Validates the input flag :param flag: input flag for validation :return: is input flag valid as bool """ - registered_flags: Flags = self.registered_flags - for registered_flag in registered_flags: - if registered_flag.string_entity == flag.string_entity: - is_valid = registered_flag.validate_input_flag_value(flag.input_value) - if is_valid: - return ValidationStatus.VALID - else: - return ValidationStatus.INVALID + if registered_flag := self._paired_string_entity_flag.get(flag.string_entity): + is_valid = registered_flag.validate_input_flag_value(flag.input_value) + if is_valid: + return ValidationStatus.VALID + else: + return ValidationStatus.INVALID return ValidationStatus.UNDEFINED class InputCommand: - def __init__(self, trigger: str, *, input_flags: InputFlag | InputFlags = DEFAULT_WITHOUT_INPUT_FLAGS): + def __init__( + self, + trigger: str, + *, + input_flags: InputFlag | InputFlags = DEFAULT_WITHOUT_INPUT_FLAGS, + ): """ Private. The model of the input command, after parsing :param trigger:the trigger of the command @@ -70,7 +78,9 @@ class InputCommand: """ self.trigger: str = trigger self.input_flags: InputFlags = ( - input_flags if isinstance(input_flags, InputFlags) else InputFlags([input_flags]) + input_flags + if isinstance(input_flags, InputFlags) + else InputFlags([input_flags]) ) @classmethod @@ -81,17 +91,17 @@ class InputCommand: :return: model of the input command, after parsing as InputCommand """ tokens = shlex.split(raw_command) - + if not tokens: raise EmptyInputCommandException - + command = tokens[0] flags: InputFlags = InputFlags() - + i = 1 while i < len(tokens): token = tokens[i] - + if token.startswith("---"): prefix = "---" name = token[3:] @@ -103,24 +113,24 @@ class InputCommand: name = token[1:] else: raise UnprocessedInputFlagException - + if i + 1 < len(tokens) and not tokens[i + 1].startswith("-"): input_value = tokens[i + 1] i += 2 else: input_value = "" i += 1 - + input_flag = InputFlag( name=name, prefix=cast(PREFIX_TYPE, prefix), # pyright: ignore[reportUnnecessaryCast] input_value=input_value, - status=None + status=None, ) - + if input_flag in flags: raise RepeatedInputFlagsException(input_flag) - + flags.add_flag(input_flag) - + return cls(command, input_flags=flags) diff --git a/src/argenta/metrics/__init__.py b/src/argenta/metrics/__init__.py 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 @@ import sys from argparse import ArgumentParser, Namespace from typing import Never, Self -from argenta.orchestrator.argparser.arguments.models import ( - BaseArgument, - BooleanArgument, - InputArgument, - ValueArgument, -) +from argenta.orchestrator.argparser.arguments.models import (BaseArgument, + BooleanArgument, + InputArgument, ValueArgument) class ArgSpace: diff --git a/src/argenta/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 @@ class CommandHandler: 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 @@ class CommandHandlers: 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 rich.console import Console 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 @@ class Router: self.disable_redirect_stdout: bool = disable_redirect_stdout self.command_handlers: CommandHandlers = CommandHandlers() - self.command_register_ignore: bool = False - self.aliases: set[str] = set() self.triggers: set[str] = set() @@ -87,12 +83,11 @@ class Router: 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 05/15] 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 @@ class AutoCompleter: else: return None - def initial_setup(self, all_commands: list[str]) -> None: + def initial_setup(self, all_commands: set[str]) -> None: """ Private. Initial setup function :param all_commands: Registered commands for adding them to the autocomplete history @@ -69,7 +69,7 @@ class AutoCompleter: readline.set_completer_delims(readline.get_completer_delims().replace(" ", "")) readline.parse_and_bind(f"{self.autocomplete_button}: complete") - def exit_setup(self, all_commands: list[str], ignore_command_register: bool) -> None: + def exit_setup(self, all_commands: set[str]) -> None: """ Private. Exit setup function :return: None @@ -80,22 +80,12 @@ class AutoCompleter: raw_history = history_file.read() pretty_history: list[str] = [] for line in set(raw_history.strip().split("\n")): - if _is_command_exist( - line.split()[0], all_commands, ignore_command_register - ): + if line.split()[0] in all_commands: pretty_history.append(line) with open(self.history_filename, "w") as history_file: _ = history_file.write("\n".join(pretty_history)) -def _is_command_exist( - command: str, existing_commands: list[str], ignore_command_register: bool -) -> bool: - if ignore_command_register: - return command.lower() in existing_commands - return command in existing_commands - - def _get_history_items() -> list[str] | list[Never]: """ Private. Returns a list of all commands entered by the user 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 @@ class BaseApp: farewell_message: str, exit_command: Command, system_router_title: str, - ignore_command_register: bool, dividing_line: StaticDividingLine | DynamicDividingLine, repeat_command_groups_printing: bool, override_system_messages: bool, @@ -51,8 +50,7 @@ class BaseApp: 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 @@ class BaseApp: 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 @@ class BaseApp: """ trigger = command.trigger exit_trigger = self._exit_command.trigger - if self._ignore_command_register: - if trigger.lower() == exit_trigger.lower(): - return True - elif trigger.lower() in [x.lower() for x in self._exit_command.aliases]: - return True - else: - if trigger == exit_trigger: - return True - elif trigger in self._exit_command.aliases: - return True + if trigger.lower() == exit_trigger.lower(): + return True + elif trigger.lower() in [x.lower() for x in self._exit_command.aliases]: + return True return False - def _is_unknown_command(self, 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 @@ class BaseApp: 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 @@ class BaseApp: 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 @@ class BaseApp: self._print_func(message) if self._messages_on_startup: print("\n") - if not self._repeat_command_groups_printing_description: + if not self._repeat_command_groups_printing: self._print_command_group_description() def _process_exist_and_valid_command(self, input_command: InputCommand) -> None: - processing_router = self._current_matching_triggers_with_routers[ - input_command.trigger.lower() - ] + processing_router = self.registered_routers.get_router_by_trigger(input_command.trigger.lower()) + + if not processing_router: + raise RuntimeError(f"Router for '{input_command.trigger}' not found. Panic!") if processing_router.disable_redirect_stdout: dividing_line_unit_part: str = self._dividing_line.get_unit_part() @@ -452,7 +404,6 @@ class App(BaseApp): :param farewell_message: displayed at the end of the app :param exit_command: the entity of the command that will be terminated when entered :param system_router_title: system router title - :param ignore_command_register: whether to ignore the case of the entered commands :param dividing_line: the entity of the dividing line :param repeat_command_groups_printing: whether to repeat the available commands and their description :param override_system_messages: whether to redefine the default formatting of system messages @@ -466,7 +417,6 @@ class App(BaseApp): farewell_message=farewell_message, exit_command=exit_command, system_router_title=system_router_title, - ignore_command_register=ignore_command_register, dividing_line=dividing_line, repeat_command_groups_printing=repeat_command_groups_printing, override_system_messages=override_system_messages, @@ -481,7 +431,7 @@ class App(BaseApp): """ self._pre_cycle_setup() while True: - if self._repeat_command_groups_printing_description: + if self._repeat_command_groups_printing: self._print_command_group_description() raw_command: str = Console().input(self._prompt) @@ -497,13 +447,10 @@ class App(BaseApp): 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 @@ from argenta.router import Router 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 @@ class RegisteredRouters: :return: None """ self.registered_routers.append(router) - + for trigger in (router.aliases | router.triggers): + self._paired_trigger_router[trigger] = router + + def get_router_by_trigger(self, trigger: str) -> Router | None: + return self._paired_trigger_router.get(trigger) + + def get_triggers(self) -> set[str]: + return set(self._paired_trigger_router.keys()) + def __iter__(self) -> Iterator[Router]: return iter(self.registered_routers) diff --git a/src/argenta/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 @@ class Router: 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 pytest_mock import MockerFixture from argenta.app.autocompleter.entity import ( AutoCompleter, - _get_history_items, - _is_command_exist, + _get_history_items ) HISTORY_FILE: str = "test_history.txt" -COMMANDS: list[str] = ["start", "stop", "status"] +COMMANDS: set[str] = {"start", "stop", "status"} # ============================================================================ @@ -119,7 +118,7 @@ def test_exit_setup_writes_and_filters_duplicate_commands(fs: FakeFilesystem, mo fs.create_file(HISTORY_FILE, contents=raw_history_content) # pyright: ignore[reportUnknownMemberType] completer: AutoCompleter = AutoCompleter(history_filename=HISTORY_FILE) - completer.exit_setup(all_commands=["start", "stop"], ignore_command_register=False) + completer.exit_setup(all_commands={"start", "stop"}) mock_readline.write_history_file.assert_called_once_with(HISTORY_FILE) @@ -131,7 +130,7 @@ def test_exit_setup_writes_and_filters_duplicate_commands(fs: FakeFilesystem, mo def test_exit_setup_skips_writing_when_no_history_filename(mock_readline: Any) -> None: completer: AutoCompleter = AutoCompleter(history_filename=None) - completer.exit_setup(all_commands=COMMANDS, ignore_command_register=False) + completer.exit_setup(all_commands=COMMANDS) mock_readline.write_history_file.assert_not_called() @@ -182,22 +181,6 @@ def test_complete_inserts_common_prefix_for_multiple_matches(mock_readline: Any) # ============================================================================ -def test_is_command_exist_checks_case_sensitive_when_enabled() -> None: - existing: list[str] = ["start", "stop", "status"] - - assert _is_command_exist("start", existing, ignore_command_register=False) is True - assert _is_command_exist("START", existing, ignore_command_register=False) is False - assert _is_command_exist("unknown", existing, ignore_command_register=False) is False - - -def test_is_command_exist_checks_case_insensitive_when_enabled() -> None: - existing: list[str] = ["start", "stop", "status"] - - assert _is_command_exist("start", existing, ignore_command_register=True) is True - assert _is_command_exist("START", existing, ignore_command_register=True) is True - assert _is_command_exist("unknown", existing, ignore_command_register=True) is False - - def test_get_history_items_returns_empty_list_initially(mock_readline: Any) -> None: assert _get_history_items() == [] diff --git a/tests/unit_tests/test_router.py b/tests/unit_tests/test_router.py index 347338a..21bc6d3 100644 --- a/tests/unit_tests/test_router.py +++ b/tests/unit_tests/test_router.py @@ -208,7 +208,7 @@ def test_finds_appropriate_handler_executes_handler_by_alias(capsys: CaptureFixt 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 test_finds_appropriate_handler_executes_handler_by_alias_with_differrent_reg 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 test_validate_command_raises_error_for_alias_collision_case_insensitive() -> @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 06/15] 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 @@ from argenta.command import Flag, Flags work_router: Router = Router(title="Base points:", disable_redirect_stdout=True) -@work_router.command(Command("hello", flags=Flags(Flag("test")), description="Hello, world!")) +@work_router.command( + Command( + "hello", + flags=Flags([ + Flag("test") + ]), + description="Hello, world!") +) def command_help(response: Response): c = input("Enter your name: ") print(f"Hello, {c}!") diff --git a/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 @@ class BaseApp: 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 @@ class App(BaseApp): farewell_message: str = "\nSee you\n", exit_command: Command = DEFAULT_EXIT_COMMAND, system_router_title: str = "System points:", - ignore_command_register: bool = True, dividing_line: AVAILABLE_DIVIDING_LINES = DEFAULT_DIVIDING_LINE, repeat_command_groups_printing: bool = False, override_system_messages: bool = False, @@ -450,7 +454,7 @@ class App(BaseApp): 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 @@ class CommandHandlers: 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_unknown_command_triggers_unknown_command_handler(monkeypatch: pytest.Mo assert "\nUnknown command: help\n" in output -def test_case_sensitive_command_triggers_unknown_command_handler(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: - inputs = iter(["TeSt", "Q"]) - monkeypatch.setattr('builtins.input', lambda _prompt="": _mock_input(inputs)) - - router = Router() - orchestrator = Orchestrator() - - @router.command(Command('test')) - def test(_response: Response) -> None: # pyright: ignore[reportUnusedFunction] - print('test command') - - app = App(ignore_command_register=False, override_system_messages=True, print_func=print) - app.include_router(router) - app.set_unknown_command_handler(lambda command: print(f'Unknown command: {command.trigger}')) - orchestrator.start_polling(app) - - output = capsys.readouterr().out - - assert '\nUnknown command: TeSt\n' in output - - def test_mixed_valid_and_unknown_commands_handled_correctly(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: inputs = iter(["test", "some", "q"]) monkeypatch.setattr('builtins.input', lambda _prompt="": _mock_input(inputs)) diff --git a/tests/system_tests/test_system_handling_normal_behavior.py b/tests/system_tests/test_system_handling_normal_behavior.py index 77e88c6..dc231aa 100644 --- a/tests/system_tests/test_system_handling_normal_behavior.py +++ b/tests/system_tests/test_system_handling_normal_behavior.py @@ -46,26 +46,6 @@ def test_simple_command_executes_successfully(monkeypatch: pytest.MonkeyPatch, c assert '\ntest command\n' in output -def test_case_insensitive_command_executes_when_enabled(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: - inputs = iter(["TeSt", "q"]) - monkeypatch.setattr('builtins.input', lambda _prompt="": _mock_input(inputs)) - - router = Router() - orchestrator = Orchestrator() - - @router.command(Command('test')) - def test(_response: Response) -> None: # pyright: ignore[reportUnusedFunction] - print('test command') - - app = App(ignore_command_register=True, override_system_messages=True, print_func=print) - app.include_router(router) - orchestrator.start_polling(app) - - output = capsys.readouterr().out - - assert '\ntest command\n' in output - - def test_two_commands_execute_sequentially(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: inputs = iter(["test", "some", "q"]) monkeypatch.setattr('builtins.input', lambda _prompt="": _mock_input(inputs)) diff --git a/tests/unit_tests/test_app.py b/tests/unit_tests/test_app.py index 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 test_handler_can_be_replaced_multiple_times() -> None: def test_handler_receives_correct_parameters() -> None: app = App() - received_data = {'trigger': None} + received_data: dict[str, None | str] = {'trigger': None} def custom_handler(command: InputCommand) -> None: received_data['trigger'] = command.trigger @@ -645,7 +611,7 @@ def test_handler_receives_correct_parameters() -> None: def test_exit_handler_receives_response_object() -> None: app = App() - received_data = {'response': None} + received_data: dict[str, None | Response] = {'response': None} def custom_handler(response: Response) -> None: received_data['response'] = response From 4957de95d3814d870dd16c0ffbf27bf9d6f4632b Mon Sep 17 00:00:00 2001 From: kolo Date: Tue, 13 Jan 2026 22:18:57 +0300 Subject: [PATCH 07/15] Update documentation and code snippets --- .../en/LC_MESSAGES/root/api/app/index.po | 113 ++++++++++-------- .../locales/en/LC_MESSAGES/root/api/router.po | 8 +- docs/root/api/app/index.rst | 5 +- mock/local_test.py | 67 +++++++++-- 4 files changed, 126 insertions(+), 67 deletions(-) diff --git a/docs/locales/en/LC_MESSAGES/root/api/app/index.po b/docs/locales/en/LC_MESSAGES/root/api/app/index.po index d6b43b0..e27c887 100644 --- a/docs/locales/en/LC_MESSAGES/root/api/app/index.po +++ b/docs/locales/en/LC_MESSAGES/root/api/app/index.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Argenta \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-12-04 20:39+0300\n" +"POT-Creation-Date: 2026-01-13 21:50+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -38,23 +38,23 @@ msgstr "" msgid "Инициализация" msgstr "Initialization" -#: ../../root/api/app/index.rst:38 +#: ../../root/api/app/index.rst:37 msgid "Создаёт и настраивает экземпляр приложения." msgstr "Creates and configures an application instance." -#: ../../root/api/app/index.rst:40 +#: ../../root/api/app/index.rst:39 msgid "``prompt``: Приглашение к вводу, отображаемое перед каждой командой." msgstr "``prompt``: Input prompt displayed before each command." -#: ../../root/api/app/index.rst:41 +#: ../../root/api/app/index.rst:40 msgid "``initial_message``: Сообщение, выводимое при запуске приложения." msgstr "``initial_message``: Message displayed when the application starts." -#: ../../root/api/app/index.rst:42 +#: ../../root/api/app/index.rst:41 msgid "``farewell_message``: Сообщение, выводимое при выходе из приложения." msgstr "``farewell_message``: Message displayed when exiting the application." -#: ../../root/api/app/index.rst:43 +#: ../../root/api/app/index.rst:42 msgid "" "``exit_command``: Команда, которая маркируется как триггер для выхода из " "приложения." @@ -62,7 +62,7 @@ msgstr "" "``exit_command``: Command that is marked as a trigger for exiting the " "application." -#: ../../root/api/app/index.rst:44 +#: ../../root/api/app/index.rst:43 msgid "" "``system_router_title``: Заголовок для системного роутера (содержит " "команду выхода)." @@ -70,15 +70,7 @@ msgstr "" "``system_router_title``: Title for the system router (contains the exit " "command)." -#: ../../root/api/app/index.rst:45 -msgid "" -"``ignore_command_register``: Если ``True``, регистр вводимых команд " -"игнорируется при поиске обработчика." -msgstr "" -"``ignore_command_register``: If ``True``, command case is ignored when " -"searching for a handler." - -#: ../../root/api/app/index.rst:46 +#: ../../root/api/app/index.rst:44 msgid "" "``dividing_line``: Тип разделительной линии (``StaticDividingLine`` или " "``DynamicDividingLine``)." @@ -86,7 +78,7 @@ msgstr "" "``dividing_line``: Type of dividing line (``StaticDividingLine`` or " "``DynamicDividingLine``)." -#: ../../root/api/app/index.rst:47 +#: ../../root/api/app/index.rst:45 msgid "" "``repeat_command_groups_printing``: Если ``True``, список доступных " "команд выводится перед каждым вводом." @@ -94,7 +86,7 @@ msgstr "" "``repeat_command_groups_printing``: If ``True``, the list of available " "commands is displayed before each input." -#: ../../root/api/app/index.rst:48 +#: ../../root/api/app/index.rst:46 msgid "" "``override_system_messages``: Если ``True``, стандартное форматирование " "(цвета, ASCII-арт) отключается." @@ -102,7 +94,7 @@ msgstr "" "``override_system_messages``: If ``True``, standard formatting (colors, " "ASCII art) is disabled." -#: ../../root/api/app/index.rst:49 +#: ../../root/api/app/index.rst:47 msgid "" "``autocompleter``: Экземпляр класса :ref:`AutoCompleter " "`, отвечающий за автодополнение команд." @@ -111,7 +103,7 @@ msgstr "" "` class responsible for command " "autocompletion." -#: ../../root/api/app/index.rst:50 +#: ../../root/api/app/index.rst:48 msgid "" "``print_func``: Функция для вывода всех системных сообщений (по умолчанию" " ``rich.Console().print``)." @@ -119,11 +111,21 @@ msgstr "" "``print_func``: Function for outputting all system messages (defaults to " "``rich.Console().print``)." -#: ../../root/api/app/index.rst:55 +#: ../../root/api/app/index.rst:53 +msgid "" +"В приложениях на Argenta регистр вводимых команд не важен, проверка на " +"существование и роутинг команд производится на основании триггеров, " +"приведённых к нижнему регистру." +msgstr "" +"In applications on Argenta, the case of the entered commands is not important, checking for the " +" existence and routing of commands is performed based on triggers " +"reduced to lowercase." + +#: ../../root/api/app/index.rst:56 msgid "Основные методы" msgstr "Main Methods" -#: ../../root/api/app/index.rst:59 +#: ../../root/api/app/index.rst:60 msgid "" "Регистрирует роутер в приложении. Все команды из этого роутера становятся" " доступными для вызова." @@ -135,19 +137,19 @@ msgstr "" msgid "Parameters" msgstr "Parameters" -#: ../../root/api/app/index.rst:61 +#: ../../root/api/app/index.rst:62 msgid "Экземпляр ``Router`` для регистрации." msgstr "``Router`` instance to register." -#: ../../root/api/app/index.rst:65 +#: ../../root/api/app/index.rst:66 msgid "Регистрирует несколько роутеров одновременно." msgstr "Registers multiple routers simultaneously." -#: ../../root/api/app/index.rst:67 +#: ../../root/api/app/index.rst:68 msgid "Последовательность экземпляров ``Router`` для регистрации." msgstr "Sequence of ``Router`` instances to register." -#: ../../root/api/app/index.rst:71 +#: ../../root/api/app/index.rst:72 msgid "" "Добавляет текстовое сообщение, которое выводится при запуске приложения " "после ``initial_message``." @@ -155,11 +157,11 @@ msgstr "" "Adds a text message that is displayed when the application starts after " "``initial_message``." -#: ../../root/api/app/index.rst:73 +#: ../../root/api/app/index.rst:74 msgid "Строка с сообщением." msgstr "String with the message." -#: ../../root/api/app/index.rst:76 +#: ../../root/api/app/index.rst:77 msgid "" "Для вывода стандартных сообщений можно использовать готовые шаблоны из " ":ref:`PredefinedMessages `." @@ -167,11 +169,11 @@ msgstr "" "For outputting standard messages, you can use ready-made templates from " ":ref:`PredefinedMessages `." -#: ../../root/api/app/index.rst:81 +#: ../../root/api/app/index.rst:82 msgid "Методы установки обработчиков" msgstr "Handler Setup Methods" -#: ../../root/api/app/index.rst:83 +#: ../../root/api/app/index.rst:84 msgid "" "``App`` позволяет настраивать реакцию на различные события, такие как " "ошибки ввода или неизвестные команды." @@ -179,7 +181,7 @@ msgstr "" "``App`` allows you to configure responses to various events, such as " "input errors or unknown commands." -#: ../../root/api/app/index.rst:86 +#: ../../root/api/app/index.rst:87 msgid "" "Подробнее об исключениях и их обработке в соответствующем :ref:`разделе " "документации `." @@ -187,59 +189,59 @@ msgstr "" "For more details on exceptions and their handling, see the corresponding " ":ref:`documentation section `." -#: ../../root/api/app/index.rst:92 +#: ../../root/api/app/index.rst:93 msgid "Устанавливает шаблон для форматирования описания команды." msgstr "Sets the template for formatting command descriptions." -#: ../../root/api/app/index.rst:94 +#: ../../root/api/app/index.rst:95 msgid "Обработчик принимает триггер команды (``str``) и её описание (``str``)." msgstr "" "The handler accepts the command trigger (``str``) and its description " "(``str``)." -#: ../../root/api/app/index.rst:100 +#: ../../root/api/app/index.rst:101 msgid "Устанавливает обработчик при некорректном введённом синтаксисе флагов." msgstr "Sets the handler for incorrect flag syntax input." -#: ../../root/api/app/index.rst:102 ../../root/api/app/index.rst:110 +#: ../../root/api/app/index.rst:103 ../../root/api/app/index.rst:111 msgid "Обработчик принимает строку, введённую пользователем." msgstr "The handler accepts the string entered by the user." -#: ../../root/api/app/index.rst:108 +#: ../../root/api/app/index.rst:109 msgid "Устанавливает обработчик при повторяющихся флагах в введённой команде." msgstr "Sets the handler for duplicate flags in the entered command." -#: ../../root/api/app/index.rst:116 +#: ../../root/api/app/index.rst:117 msgid "Устанавливает обработчик при вводе неизвестной команды." msgstr "Sets the handler for entering an unknown command." -#: ../../root/api/app/index.rst:118 +#: ../../root/api/app/index.rst:119 msgid "Обработчик принимает объект ``InputCommand`` - объект введённой команды." msgstr "" "The handler accepts an ``InputCommand`` object - the entered command " "object." -#: ../../root/api/app/index.rst:124 +#: ../../root/api/app/index.rst:125 msgid "Устанавливает обработчик при вводе пустой строки." msgstr "Sets the handler for entering an empty string." -#: ../../root/api/app/index.rst:126 +#: ../../root/api/app/index.rst:127 msgid "Обработчик не принимает аргументов." msgstr "The handler accepts no arguments." -#: ../../root/api/app/index.rst:132 +#: ../../root/api/app/index.rst:133 msgid "Переопределяет стандартное поведение при вызове команды выхода." msgstr "Overrides the default behavior when the exit command is invoked." -#: ../../root/api/app/index.rst:134 +#: ../../root/api/app/index.rst:135 msgid "Обработчик принимает объект ``Response``." msgstr "The handler accepts a ``Response`` object." -#: ../../root/api/app/index.rst:147 +#: ../../root/api/app/index.rst:148 msgid "PredefinedMessages" msgstr "PredefinedMessages" -#: ../../root/api/app/index.rst:149 +#: ../../root/api/app/index.rst:150 msgid "" "``PredefinedMessages`` — это контейнер, содержащий набор готовых к " "использованию сообщений. Они отформатированы с использованием синтаксиса " @@ -250,31 +252,40 @@ msgstr "" "messages. They are formatted using ``rich`` syntax and are intended for " "displaying standard information, such as usage hints." -#: ../../root/api/app/index.rst:151 +#: ../../root/api/app/index.rst:152 msgid "Рекомендуется использовать их при старте приложения." msgstr "It is recommended to use them when starting the application." -#: ../../root/api/app/index.rst:178 +#: ../../root/api/app/index.rst:179 msgid "Строка: ``[b dim]Usage[/b dim]: [i] <[green]flags[/green]>[/i]``" msgstr "String: ``[b dim]Usage[/b dim]: [i] <[green]flags[/green]>[/i]``" -#: ../../root/api/app/index.rst:180 +#: ../../root/api/app/index.rst:181 msgid "Отображается как: ``Usage: ``" msgstr "Displayed as: ``Usage: ``" -#: ../../root/api/app/index.rst:184 +#: ../../root/api/app/index.rst:185 msgid "Строка: ``[b dim]Help[/b dim]: [i][/i] [b red]--help[/b red]``" msgstr "String: ``[b dim]Help[/b dim]: [i][/i] [b red]--help[/b red]``" -#: ../../root/api/app/index.rst:186 +#: ../../root/api/app/index.rst:187 msgid "Отображается как: ``Help: --help``" msgstr "Displayed as: ``Help: --help``" -#: ../../root/api/app/index.rst:190 +#: ../../root/api/app/index.rst:191 msgid "Строка: ``[b dim]Autocomplete[/b dim]: [i][/i] [bold]``" msgstr "String: ``[b dim]Autocomplete[/b dim]: [i][/i] [bold]``" -#: ../../root/api/app/index.rst:192 +#: ../../root/api/app/index.rst:193 msgid "Отображается как: ``Autocomplete: ``" msgstr "Displayed as: ``Autocomplete: ``" +#~ msgid "" +#~ "``ignore_command_register``: Если ``True``, регистр" +#~ " вводимых команд игнорируется при поиске" +#~ " обработчика." +#~ msgstr "" +#~ "``ignore_command_register``: If ``True``, command" +#~ " case is ignored when searching for" +#~ " a handler." + diff --git a/docs/locales/en/LC_MESSAGES/root/api/router.po b/docs/locales/en/LC_MESSAGES/root/api/router.po index e56e899..e5c3d59 100644 --- a/docs/locales/en/LC_MESSAGES/root/api/router.po +++ b/docs/locales/en/LC_MESSAGES/root/api/router.po @@ -137,8 +137,8 @@ msgid "" "используйте его декоратор ``@command``." msgstr "" "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." +"``.system_router`` attribute of the created ``Orchestrator`` instance and" +" use its ``@command`` decorator." #: ../../root/api/router.rst:62 msgid "Возможные исключения" @@ -194,7 +194,7 @@ msgstr "" 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." @@ -202,7 +202,7 @@ msgstr "" #: ../../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/root/api/app/index.rst b/docs/root/api/app/index.rst index f099c18..9646edc 100644 --- a/docs/root/api/app/index.rst +++ b/docs/root/api/app/index.rst @@ -28,7 +28,6 @@ App farewell_message: str = "\nSee you\n", exit_command: Command = DEFAULT_EXIT_COMMAND, system_router_title: str | None = "System points:", - ignore_command_register: bool = True, dividing_line: AVAILABLE_DIVIDING_LINES = DEFAULT_DIVIDING_LINE, repeat_command_groups_printing: bool = False, override_system_messages: bool = False, @@ -42,7 +41,6 @@ App * ``farewell_message``: Сообщение, выводимое при выходе из приложения. * ``exit_command``: Команда, которая маркируется как триггер для выхода из приложения. * ``system_router_title``: Заголовок для системного роутера (содержит команду выхода). - * ``ignore_command_register``: Если ``True``, регистр вводимых команд игнорируется при поиске обработчика. * ``dividing_line``: Тип разделительной линии (``StaticDividingLine`` или ``DynamicDividingLine``). * ``repeat_command_groups_printing``: Если ``True``, список доступных команд выводится перед каждым вводом. * ``override_system_messages``: Если ``True``, стандартное форматирование (цвета, ASCII-арт) отключается. @@ -50,6 +48,9 @@ App * ``print_func``: Функция для вывода всех системных сообщений (по умолчанию ``rich.Console().print``). ----- + +.. note:: + В приложениях на Argenta регистр вводимых команд не важен, проверка на существование и роутинг команд производится на основании триггеров, приведённых к нижнему регистру. Основные методы --------------- diff --git a/mock/local_test.py b/mock/local_test.py index 5cd3471..9e20232 100644 --- a/mock/local_test.py +++ b/mock/local_test.py @@ -1,14 +1,61 @@ -from abc import ABC, abstractmethod +import math -class Figure(ABC): - @abstractmethod - def draw(self) -> None: - raise NotImplementedError +def estimate_nth_prime_upper_bound(n: int): + if n < 6: + return 15 + + log_n = math.log(n) + log_log_n = math.log(log_n) + + if n < 100: + return int(n * (log_n + log_log_n) * 1.5) + elif n < 1000: + return int(n * (log_n + log_log_n) * 1.3) + elif n >= 8009824: + return int(n * (log_n + log_log_n - 1 + 1.8 * log_log_n / log_n)) + else: + return int(n * (log_n + log_log_n - 1 + 2.0 * log_log_n / log_n)) + + +def odd_dig_primes(n: int) -> list[int]: + nums = {k: True for k in range(2, n+1)} + + for num, is_checkable in nums.items(): + if not is_checkable: + continue + + if nums[2]: + nums[2] = False -class Rectangle(Figure): - def __init__(self, x: int, y: int) -> None: - self.x = x - self.y = y + for x in range(num * num, n, num): + nums[x] = False + + primes = len([x for x in nums.items() if x[1]]) + max_prime = max([x[0] for x in nums.items() if x[1]]) + + upper_bound = estimate_nth_prime_upper_bound(primes+1) + print(upper_bound) + nums2 = {k: True for k in range(2, upper_bound)} + + for num, is_checkable in nums2.items(): + if not is_checkable: + continue + + if nums2[2]: + nums2[2] = False -rec = Rectangle(5, 2) \ No newline at end of file + for x in range(num * num, upper_bound, num): + nums2[x] = False + + print([x for x in nums2.items() if x[1]]) + + next_prime_after_max = [x[0] for x in nums2.items() if x[1]][-1] + + return [ + primes, + max_prime, + next_prime_after_max + ] + +print(odd_dig_primes(13)) \ No newline at end of file From b8d8c44bdd83e8a9b00a497e76d093c1f06b943e Mon Sep 17 00:00:00 2001 From: kolo Date: Wed, 14 Jan 2026 14:40:47 +0300 Subject: [PATCH 08/15] start build pretty benchmarks --- metrics/__init__.py | 0 metrics/__main__.py | 0 metrics/benchmarks/pre_cycle_setup.py | 117 ++++++++++++++++++ metrics/registry.py | 88 +++++++++++++ .../metrics/main.py => metrics/utils.py | 6 +- pyproject.toml | 5 + src/argenta/metrics/__init__.py | 2 - 7 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 metrics/__init__.py create mode 100644 metrics/__main__.py create mode 100644 metrics/benchmarks/pre_cycle_setup.py create mode 100644 metrics/registry.py rename src/argenta/metrics/main.py => metrics/utils.py (87%) delete mode 100644 src/argenta/metrics/__init__.py diff --git a/metrics/__init__.py b/metrics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/metrics/__main__.py b/metrics/__main__.py new file mode 100644 index 0000000..e69de29 diff --git a/metrics/benchmarks/pre_cycle_setup.py b/metrics/benchmarks/pre_cycle_setup.py new file mode 100644 index 0000000..419a34f --- /dev/null +++ b/metrics/benchmarks/pre_cycle_setup.py @@ -0,0 +1,117 @@ +from argenta import App +from argenta.router import Router +from argenta.command.models import Command +from argenta.response import Response + +from ..utils import get_time_of_pre_cycle_setup +from ..registry import benchmark + + +@benchmark(name="Time of pre_cycle_setup", description="With no aliases") +def benchmark_no_aliases() -> float: + app = App(override_system_messages=True) + router = Router() + + @router.command(Command('command1')) + def handler1(_res: Response) -> None: + pass + + @router.command(Command('command2')) + def handler2(_res: Response) -> None: + pass + + @router.command(Command('command3')) + def handler3(_res: Response) -> None: + pass + + app.include_router(router) + execution_time = get_time_of_pre_cycle_setup(app) + return execution_time + + +@benchmark(name="Time of pre_cycle_setup", description="With few aliases (6 total)") +def benchmark_few_aliases() -> float: + app = App(override_system_messages=True) + router = Router() + + @router.command(Command('command1', aliases={'c1', 'cmd1'})) + def handler1(_res: Response) -> None: + pass + + @router.command(Command('command2', aliases={'c2', 'cmd2'})) + def handler2(_res: Response) -> None: + pass + + @router.command(Command('command3', aliases={'c3', 'cmd3'})) + def handler3(_res: Response) -> None: + pass + + app.include_router(router) + execution_time = get_time_of_pre_cycle_setup(app) + return execution_time + + +@benchmark(name="Time of pre_cycle_setup", description="With many aliases (15 total)") +def benchmark_many_aliases() -> float: + app = App(override_system_messages=True) + router = Router() + + @router.command(Command('command1', aliases={'c1', 'cmd1', 'com1', 'first', 'one'})) + def handler1(_res: Response) -> None: + pass + + @router.command(Command('command2', aliases={'c2', 'cmd2', 'com2', 'second', 'two'})) + def handler2(_res: Response) -> None: + pass + + @router.command(Command('command3', aliases={'c3', 'cmd3', 'com3', 'third', 'three'})) + def handler3(_res: Response) -> None: + pass + + app.include_router(router) + execution_time = get_time_of_pre_cycle_setup(app) + return execution_time + + +@benchmark(name="Time of pre_cycle_setup", description="With very many aliases (60 total)") +def benchmark_very_many_aliases() -> float: + app = App(override_system_messages=True) + router = Router() + + @router.command(Command('command1', aliases={f'alias1_{i}' for i in range(20)})) + def handler1(_res: Response) -> None: + pass + + @router.command(Command('command2', aliases={f'alias2_{i}' for i in range(20)})) + def handler2(_res: Response) -> None: + pass + + @router.command(Command('command3', aliases={f'alias3_{i}' for i in range(20)})) + def handler3(_res: Response) -> None: + pass + + app.include_router(router) + execution_time = get_time_of_pre_cycle_setup(app) + return execution_time + + +@benchmark(name="Time of pre_cycle_setup", description="With extreme aliases (300 total)") +def benchmark_extreme_aliases() -> float: + app = App(override_system_messages=True) + router = Router() + + @router.command(Command('command1', aliases={f'alias1_{i}' for i in range(100)})) + def handler1(_res: Response) -> None: + pass + + @router.command(Command('command2', aliases={f'alias2_{i}' for i in range(100)})) + def handler2(_res: Response) -> None: + pass + + @router.command(Command('command3', aliases={f'alias3_{i}' for i in range(100)})) + def handler3(_res: Response) -> None: + pass + + app.include_router(router) + execution_time = get_time_of_pre_cycle_setup(app) + return execution_time diff --git a/metrics/registry.py b/metrics/registry.py new file mode 100644 index 0000000..02d5cb1 --- /dev/null +++ b/metrics/registry.py @@ -0,0 +1,88 @@ +from typing import Any, Callable, ClassVar, ParamSpec, TypeVar, overload, override, Generic + + +P = ParamSpec("P") +R = TypeVar("R", default=float) + + +class Benchmark(Generic[P, R]): + def __init__( + self, + func: Callable[P, R], + *, + name: str, + description: str, + iterations: int + ) -> None: + self.func = func + self.name = name + self.description = description + self.iterations = iterations + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: + return self.func(*args, **kwargs) + + @override + def __repr__(self) -> str: + return f'Benchmark<{self.name=}, {self.description=}, {self.iterations=}>' + + @override + def __str__(self) -> str: + return f'Benchmark({self.name=}, {self.description=}, {self.iterations=})' + + +class Benchmarks: + _benchmarks: ClassVar[list[Benchmark[Any, Any]]] = [] + + @overload + @classmethod + def register( + cls, + call: Callable[P, R], + *, + name: str = "", + description: str = "", + iterations: int = 100, + ) -> Callable[P, R]: ... + + @overload + @classmethod + def register( + cls, + call: None = None, + *, + name: str = "", + description: str = "", + iterations: int = 100, + ) -> Callable[[Callable[P, R]], Callable[P, R]]: ... + + @classmethod + def register( + cls, + call: Callable[P, R] | None = None, + *, + name: str = "", + description: str = "", + iterations: int = 100, + ) -> Callable[[Callable[P, R]], Callable[P, R]] | Callable[P, R]: + def decorator(func: Callable[P, R]) -> Callable[P, R]: + cls._benchmarks.append( + Benchmark( + func, + name = name or func.__name__, + description = description or f'description for {name or func.__name__} with {iterations} iterations', + iterations = iterations + ) + ) + return func + + if call is None: + return decorator + else: + return decorator(call) + + @classmethod + def get_benchmarks(cls) -> list[Benchmark[Any, Any]]: + return cls._benchmarks + +benchmark = Benchmarks.register diff --git a/src/argenta/metrics/main.py b/metrics/utils.py similarity index 87% rename from src/argenta/metrics/main.py rename to metrics/utils.py index 12861f7..ac6adf9 100644 --- a/src/argenta/metrics/main.py +++ b/metrics/utils.py @@ -4,7 +4,7 @@ __all__ = [ import io from contextlib import redirect_stdout -from time import time +import time from argenta import App @@ -15,8 +15,8 @@ def get_time_of_pre_cycle_setup(app: App) -> float: :param app: app instance for testing time of pre cycle setup :return: time of pre cycle setup as float """ - start = time() + start = time.monotonic() with redirect_stdout(io.StringIO()): app._pre_cycle_setup() # pyright: ignore[reportPrivateUsage] - end = time() + end = time.monotonic() return end - start diff --git a/pyproject.toml b/pyproject.toml index eb066f7..eeabf62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,11 @@ root = "tests/" reportPrivateUsage = false reportUnusedFunction = false +[[tool.pyright.executionEnvironments]] +root = "metrics/" +reportPrivateUsage = false +reportUnusedFunction = false + [tool.coverage.run] branch = true omit = [ diff --git a/src/argenta/metrics/__init__.py b/src/argenta/metrics/__init__.py deleted file mode 100644 index e97a8ca..0000000 --- a/src/argenta/metrics/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from argenta.metrics.main import \ - get_time_of_pre_cycle_setup as get_time_of_pre_cycle_setup From cd3dd10d11e50c5a44f9dc2b23570b3098aa7955 Mon Sep 17 00:00:00 2001 From: kolo Date: Wed, 14 Jan 2026 14:51:43 +0300 Subject: [PATCH 09/15] start build pretty benchmarks --- metrics/__main__.py | 11 +++++++++++ metrics/registry.py | 36 ++++++++++++++++++++---------------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/metrics/__main__.py b/metrics/__main__.py index e69de29..0082c3b 100644 --- a/metrics/__main__.py +++ b/metrics/__main__.py @@ -0,0 +1,11 @@ +from .registry import Benchmarks, Benchmark + + +def main(): + all_benchmarks: list[Benchmark] = Benchmarks.get_benchmarks() + + for benchmark in all_benchmarks: pass + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/metrics/registry.py b/metrics/registry.py index 02d5cb1..306a305 100644 --- a/metrics/registry.py +++ b/metrics/registry.py @@ -1,14 +1,18 @@ -from typing import Any, Callable, ClassVar, ParamSpec, TypeVar, overload, override, Generic +__all__ = [ + "Benchmark", + "Benchmarks", + "benchmark" +] + +from typing import Callable, ClassVar, overload, override -P = ParamSpec("P") -R = TypeVar("R", default=float) +BenchmarkAsFunc = Callable[[], float] - -class Benchmark(Generic[P, R]): +class Benchmark: def __init__( self, - func: Callable[P, R], + func: BenchmarkAsFunc, *, name: str, description: str, @@ -19,8 +23,8 @@ class Benchmark(Generic[P, R]): self.description = description self.iterations = iterations - def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: - return self.func(*args, **kwargs) + def __call__(self) -> float: + return self.func() @override def __repr__(self) -> str: @@ -32,18 +36,18 @@ class Benchmark(Generic[P, R]): class Benchmarks: - _benchmarks: ClassVar[list[Benchmark[Any, Any]]] = [] + _benchmarks: ClassVar[list[Benchmark]] = [] @overload @classmethod def register( cls, - call: Callable[P, R], + call: BenchmarkAsFunc, *, name: str = "", description: str = "", iterations: int = 100, - ) -> Callable[P, R]: ... + ) -> BenchmarkAsFunc: ... @overload @classmethod @@ -54,18 +58,18 @@ class Benchmarks: name: str = "", description: str = "", iterations: int = 100, - ) -> Callable[[Callable[P, R]], Callable[P, R]]: ... + ) -> Callable[[BenchmarkAsFunc], BenchmarkAsFunc]: ... @classmethod def register( cls, - call: Callable[P, R] | None = None, + call: BenchmarkAsFunc | None = None, *, name: str = "", description: str = "", iterations: int = 100, - ) -> Callable[[Callable[P, R]], Callable[P, R]] | Callable[P, R]: - def decorator(func: Callable[P, R]) -> Callable[P, R]: + ) -> Callable[[BenchmarkAsFunc], BenchmarkAsFunc] | BenchmarkAsFunc: + def decorator(func: BenchmarkAsFunc) -> BenchmarkAsFunc: cls._benchmarks.append( Benchmark( func, @@ -82,7 +86,7 @@ class Benchmarks: return decorator(call) @classmethod - def get_benchmarks(cls) -> list[Benchmark[Any, Any]]: + def get_benchmarks(cls) -> list[Benchmark]: return cls._benchmarks benchmark = Benchmarks.register From 0f8b1c05fcb8cab224780d3a3c7ddfa70bbd0dfa Mon Sep 17 00:00:00 2001 From: kolo Date: Thu, 15 Jan 2026 02:02:50 +0300 Subject: [PATCH 10/15] pretty gifff --- README.md | 2 +- README.ru.md | 2 +- metrics/__init__.py | 1 + metrics/__main__.py | 11 ++++++++- metrics/benchmarks/pre_cycle_setup.py | 8 +++++++ metrics/registry.py | 6 ++--- metrics/utils.py | 13 +++++----- metrics_tests/__init__.py | 0 metrics_tests/time_of_precycle_setup.py | 32 ------------------------- mock/min_app/main.py | 3 ++- src/argenta/app/models.py | 7 +++--- tests/unit_tests/test_app.py | 2 -- 12 files changed, 36 insertions(+), 51 deletions(-) delete mode 100644 metrics_tests/__init__.py delete mode 100644 metrics_tests/time_of_precycle_setup.py diff --git a/README.md b/README.md index e36e48f..6bcc4c1 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Argenta is the **"Simplest"**, **"Most Modular"**, and **"Most Elegant"** way to --- -![preview](https://i.ibb.co/fzWcfgFq/2025-12-04-173045.png) +![preview](https://vhs.charm.sh/vhs-2hvLCEgclmwZPJZt1vLGKi.gif) **Argenta** allows you to build interactive CLI applications incredibly easily. There's no need to manually parse complex command structures or manage state transitions — just use routers and commands! diff --git a/README.ru.md b/README.ru.md index fe00e96..5ee21fc 100644 --- a/README.ru.md +++ b/README.ru.md @@ -9,7 +9,7 @@ Argenta — это **"Самый простой"**, **"Самый модульн --- -![preview](https://i.ibb.co/fzWcfgFq/2025-12-04-173045.png) +![preview](https://vhs.charm.sh/vhs-2hvLCEgclmwZPJZt1vLGKi.gif) **Argenta** позволяет создавать интерактивные CLI-приложения невероятно легко. Не нужно вручную парсить сложные структуры команд или управлять переходами состояний — просто используйте роутеры и команды! diff --git a/metrics/__init__.py b/metrics/__init__.py index e69de29..bb87be0 100644 --- a/metrics/__init__.py +++ b/metrics/__init__.py @@ -0,0 +1 @@ +from .benchmarks.pre_cycle_setup import * \ No newline at end of file diff --git a/metrics/__main__.py b/metrics/__main__.py index 0082c3b..6e3bee0 100644 --- a/metrics/__main__.py +++ b/metrics/__main__.py @@ -1,10 +1,19 @@ +from metrics.utils import attempts_to_average from .registry import Benchmarks, Benchmark def main(): all_benchmarks: list[Benchmark] = Benchmarks.get_benchmarks() - for benchmark in all_benchmarks: pass + for benchmark in all_benchmarks: + bench_attempts: list[float] = [] + for _ in range(benchmark.iterations): + bench_attempts.append(benchmark.run()) + + print(f'Name: {benchmark.name}\n' + f'Description: {benchmark.description}\n' + f'Iterations: {benchmark.iterations}\n' + f'Average time per iteration: {attempts_to_average(bench_attempts, benchmark.iterations)} ms\n') if __name__ == '__main__': diff --git a/metrics/benchmarks/pre_cycle_setup.py b/metrics/benchmarks/pre_cycle_setup.py index 419a34f..8db6d17 100644 --- a/metrics/benchmarks/pre_cycle_setup.py +++ b/metrics/benchmarks/pre_cycle_setup.py @@ -1,3 +1,11 @@ +__all__ = [ + "benchmark_no_aliases", + "benchmark_many_aliases", + "benchmark_few_aliases", + "benchmark_extreme_aliases", + "benchmark_very_many_aliases" +] + from argenta import App from argenta.router import Router from argenta.command.models import Command diff --git a/metrics/registry.py b/metrics/registry.py index 306a305..db7ca06 100644 --- a/metrics/registry.py +++ b/metrics/registry.py @@ -23,7 +23,7 @@ class Benchmark: self.description = description self.iterations = iterations - def __call__(self) -> float: + def run(self) -> float: return self.func() @override @@ -84,9 +84,9 @@ class Benchmarks: return decorator else: return decorator(call) - + @classmethod def get_benchmarks(cls) -> list[Benchmark]: return cls._benchmarks - + benchmark = Benchmarks.register diff --git a/metrics/utils.py b/metrics/utils.py index ac6adf9..2c3f83e 100644 --- a/metrics/utils.py +++ b/metrics/utils.py @@ -1,22 +1,23 @@ __all__ = [ "get_time_of_pre_cycle_setup", + "attempts_to_average" ] import io from contextlib import redirect_stdout import time +from decimal import Decimal, ROUND_HALF_UP from argenta import App -def get_time_of_pre_cycle_setup(app: App) -> float: - """ - Public. Return time of pre cycle setup - :param app: app instance for testing time of pre cycle setup - :return: time of pre cycle setup as float - """ +def get_time_of_pre_cycle_setup(app: App) -> float: start = time.monotonic() with redirect_stdout(io.StringIO()): app._pre_cycle_setup() # pyright: ignore[reportPrivateUsage] end = time.monotonic() return end - start + + +def attempts_to_average(bench_attempts: list[float], iterations: int) -> Decimal: + return Decimal(sum(bench_attempts) / iterations).quantize(Decimal("0.00001"), rounding=ROUND_HALF_UP) diff --git a/metrics_tests/__init__.py b/metrics_tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/metrics_tests/time_of_precycle_setup.py b/metrics_tests/time_of_precycle_setup.py deleted file mode 100644 index 83e36f5..0000000 --- a/metrics_tests/time_of_precycle_setup.py +++ /dev/null @@ -1,32 +0,0 @@ -from argenta.app import App -from argenta.command import Command -from argenta.metrics import get_time_of_pre_cycle_setup -from argenta.response import Response -from argenta.router import Router - - -def commands_with_two_aliases(num_of_commands: int): - router = Router() - - for i in range(num_of_commands): - @router.command(Command(f'cmd{i}', aliases=[f'cdr{i}', f'prt{i}'])) - def handler(response: Response): # pyright: ignore[reportUnusedFunction, reportUnusedParameter] - pass - - app = App() - app.include_router(router) - - return get_time_of_pre_cycle_setup(app) - -def commands_with_one_aliases(num_of_commands: int): - router = Router() - - for i in range(num_of_commands): - @router.command(Command(f'cmd{i}', aliases=[f'cdr{i}'])) - def handler(response: Response): # pyright: ignore[reportUnusedFunction, reportUnusedParameter] - pass - - app = App() - app.include_router(router) - - return get_time_of_pre_cycle_setup(app) diff --git a/mock/min_app/main.py b/mock/min_app/main.py index 97eb5e9..a2b4326 100644 --- a/mock/min_app/main.py +++ b/mock/min_app/main.py @@ -1,9 +1,10 @@ # main.py from argenta import App, Orchestrator +from argenta.app import DynamicDividingLine from .routers import router -app: App = App() +app: App = App(prompt='>>> ', dividing_line=DynamicDividingLine('~')) orchestrator: Orchestrator = Orchestrator() def main() -> None: diff --git a/src/argenta/app/models.py b/src/argenta/app/models.py index d2363df..644c42c 100644 --- a/src/argenta/app/models.py +++ b/src/argenta/app/models.py @@ -339,9 +339,6 @@ class BaseApp: self._autocompleter.initial_setup(self.registered_routers.get_triggers()) - if not self._override_system_messages: - self._setup_default_view() - self._print_func(self._initial_message) for message in self._messages_on_startup: @@ -382,7 +379,7 @@ DEFAULT_DIVIDING_LINE: StaticDividingLine = StaticDividingLine() DEFAULT_PRINT_FUNC: Printer = Console().print DEFAULT_AUTOCOMPLETER: AutoCompleter = AutoCompleter() -DEFAULT_EXIT_COMMAND: Command = Command("Q", description="Exit command") +DEFAULT_EXIT_COMMAND: Command = Command("q", description="Exit command") class App(BaseApp): @@ -427,6 +424,8 @@ class App(BaseApp): autocompleter=autocompleter, print_func=print_func, ) + if not self._override_system_messages: + self._setup_default_view() def run_polling(self) -> None: """ diff --git a/tests/unit_tests/test_app.py b/tests/unit_tests/test_app.py index 4306b18..13b7924 100644 --- a/tests/unit_tests/test_app.py +++ b/tests/unit_tests/test_app.py @@ -361,8 +361,6 @@ def test_set_exit_command_handler_stores_handler() -> None: def test_setup_default_view_formats_prompt() -> None: app = App(prompt='>>') - app._setup_default_view() - assert app._prompt == '[italic dim bold]>>' From 9bde1321e12b51648656cf796e0c29587c335618 Mon Sep 17 00:00:00 2001 From: kolo Date: Thu, 15 Jan 2026 02:49:12 +0300 Subject: [PATCH 11/15] benchs --- metrics/__init__.py | 2 +- metrics/__main__.py | 46 ++++++++++------ metrics/benchmarks/__init__.py | 1 + metrics/benchmarks/pre_cycle_setup.py | 10 ++-- metrics/registry.py | 78 ++++++++++++++------------- metrics/utils.py | 23 +++++++- 6 files changed, 102 insertions(+), 58 deletions(-) create mode 100644 metrics/benchmarks/__init__.py diff --git a/metrics/__init__.py b/metrics/__init__.py index bb87be0..63f99dd 100644 --- a/metrics/__init__.py +++ b/metrics/__init__.py @@ -1 +1 @@ -from .benchmarks.pre_cycle_setup import * \ No newline at end of file +from .benchmarks import * \ No newline at end of file diff --git a/metrics/__main__.py b/metrics/__main__.py index 6e3bee0..477965a 100644 --- a/metrics/__main__.py +++ b/metrics/__main__.py @@ -1,20 +1,36 @@ -from metrics.utils import attempts_to_average +from concurrent.futures import ProcessPoolExecutor +import os + +from rich import Console + +from metrics.utils import run_benchmark, BenchmarkResult from .registry import Benchmarks, Benchmark def main(): + console = Console() all_benchmarks: list[Benchmark] = Benchmarks.get_benchmarks() - - for benchmark in all_benchmarks: - bench_attempts: list[float] = [] - for _ in range(benchmark.iterations): - bench_attempts.append(benchmark.run()) - - print(f'Name: {benchmark.name}\n' - f'Description: {benchmark.description}\n' - f'Iterations: {benchmark.iterations}\n' - f'Average time per iteration: {attempts_to_average(bench_attempts, benchmark.iterations)} ms\n') - - -if __name__ == '__main__': - main() \ No newline at end of file + + workers = os.cpu_count() or 1 + with ProcessPoolExecutor(max_workers=workers) as executor: + results = executor.map(run_benchmark, all_benchmarks) + + type_paired_benchmarks: dict[str, list[BenchmarkResult]] = {} + + for result in results: + type_paired_benchmarks.setdefault(result.type_, []).append(result) + + for type_, benchmarks in type_paired_benchmarks.items(): + console.print('\n' + ('='*(len(type_)+14))) + console.print(f' TYPE: {type_.upper()}') + console.print('='*(len(type_)+14) + '\n') + + for benchmark in benchmarks: + console.print(f'Name: {benchmark.name}\n' + f'Description: {benchmark.description}\n' + f'Iterations: {benchmark.iterations}\n' + f'Average time per iteration: {benchmark.avg_time} ms\n') + + +if __name__ == "__main__": + main() diff --git a/metrics/benchmarks/__init__.py b/metrics/benchmarks/__init__.py new file mode 100644 index 0000000..64424de --- /dev/null +++ b/metrics/benchmarks/__init__.py @@ -0,0 +1 @@ +from .pre_cycle_setup import * \ No newline at end of file diff --git a/metrics/benchmarks/pre_cycle_setup.py b/metrics/benchmarks/pre_cycle_setup.py index 8db6d17..4d0e386 100644 --- a/metrics/benchmarks/pre_cycle_setup.py +++ b/metrics/benchmarks/pre_cycle_setup.py @@ -15,7 +15,7 @@ from ..utils import get_time_of_pre_cycle_setup from ..registry import benchmark -@benchmark(name="Time of pre_cycle_setup", description="With no aliases") +@benchmark(type_="pre_cycle_setup", description="With no aliases") def benchmark_no_aliases() -> float: app = App(override_system_messages=True) router = Router() @@ -37,7 +37,7 @@ def benchmark_no_aliases() -> float: return execution_time -@benchmark(name="Time of pre_cycle_setup", description="With few aliases (6 total)") +@benchmark(type_="pre_cycle_setup", description="With few aliases (6 total)") def benchmark_few_aliases() -> float: app = App(override_system_messages=True) router = Router() @@ -59,7 +59,7 @@ def benchmark_few_aliases() -> float: return execution_time -@benchmark(name="Time of pre_cycle_setup", description="With many aliases (15 total)") +@benchmark(type_="pre_cycle_setup", description="With many aliases (15 total)") def benchmark_many_aliases() -> float: app = App(override_system_messages=True) router = Router() @@ -81,7 +81,7 @@ def benchmark_many_aliases() -> float: return execution_time -@benchmark(name="Time of pre_cycle_setup", description="With very many aliases (60 total)") +@benchmark(type_="pre_cycle_setup", description="With very many aliases (60 total)") def benchmark_very_many_aliases() -> float: app = App(override_system_messages=True) router = Router() @@ -103,7 +103,7 @@ def benchmark_very_many_aliases() -> float: return execution_time -@benchmark(name="Time of pre_cycle_setup", description="With extreme aliases (300 total)") +@benchmark(type_="pre_cycle_setup", description="With extreme aliases (300 total)") def benchmark_extreme_aliases() -> float: app = App(override_system_messages=True) router = Router() diff --git a/metrics/registry.py b/metrics/registry.py index db7ca06..40c9537 100644 --- a/metrics/registry.py +++ b/metrics/registry.py @@ -6,34 +6,36 @@ __all__ = [ from typing import Callable, ClassVar, overload, override - BenchmarkAsFunc = Callable[[], float] + class Benchmark: def __init__( - self, - func: BenchmarkAsFunc, - *, - name: str, - description: str, - iterations: int + self, + func: BenchmarkAsFunc, + *, + type_: str, + name: str, + description: str, + iterations: int ) -> None: self.func = func + self.type_ = type_ self.name = name self.description = description self.iterations = iterations - + def run(self) -> float: return self.func() - + @override def __repr__(self) -> str: - return f'Benchmark<{self.name=}, {self.description=}, {self.iterations=}>' - + return f'Benchmark<{self.type_=}, {self.name=}, {self.description=}, {self.iterations=}>' + @override def __str__(self) -> str: - return f'Benchmark({self.name=}, {self.description=}, {self.iterations=})' - + return f'Benchmark({self.type_=}, {self.name=}, {self.description=}, {self.iterations=})' + class Benchmarks: _benchmarks: ClassVar[list[Benchmark]] = [] @@ -41,41 +43,44 @@ class Benchmarks: @overload @classmethod def register( - cls, - call: BenchmarkAsFunc, - *, - name: str = "", - description: str = "", - iterations: int = 100, - ) -> BenchmarkAsFunc: ... + cls, + call: BenchmarkAsFunc, + *, + type_: str = "", + description: str = "", + iterations: int = 100, + ) -> BenchmarkAsFunc: + ... @overload @classmethod def register( - cls, - call: None = None, - *, - name: str = "", - description: str = "", - iterations: int = 100, - ) -> Callable[[BenchmarkAsFunc], BenchmarkAsFunc]: ... + cls, + call: None = None, + *, + type_: str = "", + description: str = "", + iterations: int = 100, + ) -> Callable[[BenchmarkAsFunc], BenchmarkAsFunc]: + ... @classmethod def register( - cls, - call: BenchmarkAsFunc | None = None, - *, - name: str = "", - description: str = "", - iterations: int = 100, + cls, + call: BenchmarkAsFunc | None = None, + *, + type_: str = "", + description: str = "", + iterations: int = 100, ) -> Callable[[BenchmarkAsFunc], BenchmarkAsFunc] | BenchmarkAsFunc: def decorator(func: BenchmarkAsFunc) -> BenchmarkAsFunc: cls._benchmarks.append( Benchmark( func, - name = name or func.__name__, - description = description or f'description for {name or func.__name__} with {iterations} iterations', - iterations = iterations + type_=type_, + name=func.__name__, + description=description or f'description for {func.__name__} with {iterations} iterations', + iterations=iterations ) ) return func @@ -89,4 +94,5 @@ class Benchmarks: def get_benchmarks(cls) -> list[Benchmark]: return cls._benchmarks + benchmark = Benchmarks.register diff --git a/metrics/utils.py b/metrics/utils.py index 2c3f83e..315431f 100644 --- a/metrics/utils.py +++ b/metrics/utils.py @@ -1,14 +1,18 @@ __all__ = [ "get_time_of_pre_cycle_setup", - "attempts_to_average" + "attempts_to_average", + "run_benchmark", + "BenchmarkResult" ] import io from contextlib import redirect_stdout import time +from dataclasses import dataclass from decimal import Decimal, ROUND_HALF_UP from argenta import App +from metrics.registry import Benchmark def get_time_of_pre_cycle_setup(app: App) -> float: @@ -21,3 +25,20 @@ def get_time_of_pre_cycle_setup(app: App) -> float: def attempts_to_average(bench_attempts: list[float], iterations: int) -> Decimal: return Decimal(sum(bench_attempts) / iterations).quantize(Decimal("0.00001"), rounding=ROUND_HALF_UP) + + +@dataclass(frozen=True) +class BenchmarkResult: + type_: str + name: str + description: str + iterations: int + avg_time: Decimal + + +def run_benchmark(benchmark: Benchmark) -> BenchmarkResult: + bench_attempts: list[float] = [] + for _ in range(benchmark.iterations): + bench_attempts.append(benchmark.run()) + avg = attempts_to_average(bench_attempts, benchmark.iterations) + return BenchmarkResult(benchmark.type_, benchmark.name, benchmark.description, benchmark.iterations, avg) From 3cd74fc18676f8f9bc3850b0191241a9b3bcedfd Mon Sep 17 00:00:00 2001 From: kolo Date: Thu, 15 Jan 2026 03:02:41 +0300 Subject: [PATCH 12/15] benchs --- metrics/__main__.py | 29 +++++++++++++++++++++-------- metrics/utils.py | 8 ++++---- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/metrics/__main__.py b/metrics/__main__.py index 477965a..6e7f0c4 100644 --- a/metrics/__main__.py +++ b/metrics/__main__.py @@ -1,7 +1,10 @@ from concurrent.futures import ProcessPoolExecutor import os -from rich import Console +from rich.console import Console +from rich.table import Table +from rich.panel import Panel +from rich.text import Text from metrics.utils import run_benchmark, BenchmarkResult from .registry import Benchmarks, Benchmark @@ -21,15 +24,25 @@ def main(): type_paired_benchmarks.setdefault(result.type_, []).append(result) for type_, benchmarks in type_paired_benchmarks.items(): - console.print('\n' + ('='*(len(type_)+14))) - console.print(f' TYPE: {type_.upper()}') - console.print('='*(len(type_)+14) + '\n') + header_text = Text(f"TYPE: {type_.upper()}", style="bold magenta") + console.print(Panel(header_text, expand=False, border_style="magenta")) + + table = Table(show_header=True, header_style="bold cyan", border_style="blue", show_lines=True) + table.add_column("Name", style="green") + table.add_column("Description", style="dim") + table.add_column("Iterations", justify="right") + table.add_column("Avg Time (ms)", justify="right", style="bold yellow") for benchmark in benchmarks: - console.print(f'Name: {benchmark.name}\n' - f'Description: {benchmark.description}\n' - f'Iterations: {benchmark.iterations}\n' - f'Average time per iteration: {benchmark.avg_time} ms\n') + table.add_row( + benchmark.name, + benchmark.description, + str(benchmark.iterations), + str(benchmark.avg_time) + ) + + console.print(table) + console.print() if __name__ == "__main__": diff --git a/metrics/utils.py b/metrics/utils.py index 315431f..2153323 100644 --- a/metrics/utils.py +++ b/metrics/utils.py @@ -16,15 +16,15 @@ from metrics.registry import Benchmark def get_time_of_pre_cycle_setup(app: App) -> float: - start = time.monotonic() + start = time.perf_counter() with redirect_stdout(io.StringIO()): app._pre_cycle_setup() # pyright: ignore[reportPrivateUsage] - end = time.monotonic() - return end - start + end = time.perf_counter() + return (end - start) * 1000 # as milliseconds def attempts_to_average(bench_attempts: list[float], iterations: int) -> Decimal: - return Decimal(sum(bench_attempts) / iterations).quantize(Decimal("0.00001"), rounding=ROUND_HALF_UP) + return Decimal(sum(bench_attempts) / iterations).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) @dataclass(frozen=True) From 18f62d3e7cef5eb4274fe34a4b21fb6c8f8c8104 Mon Sep 17 00:00:00 2001 From: kolo Date: Thu, 15 Jan 2026 14:52:41 +0300 Subject: [PATCH 13/15] perffff --- src/argenta/app/models.py | 38 ++++++++++++++------ src/argenta/app/protocols.py | 13 +++++-- src/argenta/orchestrator/entity.py | 4 +-- src/argenta/router/command_handler/entity.py | 5 ++- src/argenta/router/entity.py | 22 ++++++------ 5 files changed, 51 insertions(+), 31 deletions(-) diff --git a/src/argenta/app/models.py b/src/argenta/app/models.py index 644c42c..e78669d 100644 --- a/src/argenta/app/models.py +++ b/src/argenta/app/models.py @@ -3,7 +3,7 @@ __all__ = ["App"] import io import re from contextlib import redirect_stdout -from typing import Never, TypeAlias +from typing import Callable, Never, TypeAlias from art import text2art from rich.console import Console @@ -30,6 +30,8 @@ from argenta.router import Router Matches: TypeAlias = list[str] | list[Never] +_ANSI_ESCAPE_RE: re.Pattern[str] = re.compile(r"\u001b\[[0-9;]*m") + class BaseApp: def __init__( @@ -58,6 +60,8 @@ class BaseApp: self._farewell_message: str = farewell_message self._initial_message: str = initial_message + self._stdout_buffer: io.StringIO = io.StringIO() + self._description_message_gen: DescriptionMessageGenerator = ( lambda command, description: f"{command} *=*=* {description}" ) @@ -160,7 +164,7 @@ class BaseApp: :return: None """ if isinstance(self._dividing_line, DynamicDividingLine): - clear_text = re.sub(r"\u001b\[[0-9;]*m", "", text) + clear_text = _ANSI_ESCAPE_RE.sub("", text) max_length_line = max([len(line) for line in clear_text.split("\n")]) max_length_line = ( max_length_line @@ -217,6 +221,18 @@ class BaseApp: return True return False + def _capture_stdout(self, func: Callable[[], None]) -> str: + """ + Private. Captures stdout from a function call using a reusable buffer + :param func: function to execute with captured stdout + :return: captured stdout as string + """ + self._stdout_buffer.seek(0) + self._stdout_buffer.truncate(0) + with redirect_stdout(self._stdout_buffer): + func() + return self._stdout_buffer.getvalue() + def _error_handler(self, error: InputCommandException, raw_command: str) -> None: """ Private. Handles parsing errors of the entered command @@ -368,9 +384,9 @@ class BaseApp: ) ) else: - with redirect_stdout(io.StringIO()) as stdout: - processing_router.finds_appropriate_handler(input_command) - stdout_result: str = stdout.getvalue() + stdout_result = self._capture_stdout( + lambda: processing_router.finds_appropriate_handler(input_command) + ) self._print_framed_text(stdout_result) @@ -442,9 +458,9 @@ class App(BaseApp): try: input_command: InputCommand = InputCommand.parse(raw_command=raw_command) except InputCommandException as error: - with redirect_stdout(io.StringIO()) as stderr: - self._error_handler(error, raw_command) - stderr_result: str = stderr.getvalue() + stderr_result = self._capture_stdout( + lambda: self._error_handler(error, raw_command) + ) self._print_framed_text(stderr_result) continue @@ -454,9 +470,9 @@ class App(BaseApp): return if self._is_unknown_command(input_command): - with redirect_stdout(io.StringIO()) as stdout: - self._unknown_command_handler(input_command) - stdout_res: str = stdout.getvalue() + stdout_res = self._capture_stdout( + lambda: self._unknown_command_handler(input_command) + ) self._print_framed_text(stdout_res) continue diff --git a/src/argenta/app/protocols.py b/src/argenta/app/protocols.py index abd2ee0..c5232f6 100644 --- a/src/argenta/app/protocols.py +++ b/src/argenta/app/protocols.py @@ -1,8 +1,10 @@ -__all__ = ["NonStandardBehaviorHandler", "EmptyCommandHandler", "Printer", "DescriptionMessageGenerator"] +__all__ = ["NonStandardBehaviorHandler", "EmptyCommandHandler", "Printer", "DescriptionMessageGenerator", "HandlerFunc"] -from typing import Protocol, TypeVar +from typing import ParamSpec, Protocol, TypeVar +from argenta.response import Response -T = TypeVar("T", contravariant=True) # noqa: WPS111 +T = TypeVar("T", contravariant=True) +P = ParamSpec("P") class NonStandardBehaviorHandler(Protocol[T]): @@ -23,3 +25,8 @@ class Printer(Protocol): class DescriptionMessageGenerator(Protocol): def __call__(self, _command: str, _description: str, /) -> str: raise NotImplementedError + + +class HandlerFunc(Protocol): + def __call__(self, response: Response) -> None: + raise NotImplementedError diff --git a/src/argenta/orchestrator/entity.py b/src/argenta/orchestrator/entity.py index 17fb243..c6d5193 100644 --- a/src/argenta/orchestrator/entity.py +++ b/src/argenta/orchestrator/entity.py @@ -14,7 +14,7 @@ class Orchestrator: def __init__( self, arg_parser: ArgParser = DEFAULT_ARGPARSER, - custom_providers: list[Provider] = [], + custom_providers: list[Provider] | None = None, auto_inject_handlers: bool = True, ): """ @@ -23,7 +23,7 @@ class Orchestrator: :return: None """ self._arg_parser: ArgParser = arg_parser - self._custom_providers: list[Provider] = custom_providers + self._custom_providers: list[Provider] = custom_providers or [] self._auto_inject_handlers: bool = auto_inject_handlers self._arg_parser._parse_args() # pyright: ignore[reportPrivateUsage] diff --git a/src/argenta/router/command_handler/entity.py b/src/argenta/router/command_handler/entity.py index 55aa30d..7f53cec 100644 --- a/src/argenta/router/command_handler/entity.py +++ b/src/argenta/router/command_handler/entity.py @@ -1,13 +1,12 @@ __all__ = ["CommandHandler", "CommandHandlers"] from collections.abc import Iterator -from typing import Callable, Never +from typing import Never +from argenta.app.protocols import HandlerFunc from argenta.command import Command from argenta.response import Response -HandlerFunc = Callable[..., None] - class CommandHandler: def __init__(self, handler_as_func: HandlerFunc, handled_command: Command): diff --git a/src/argenta/router/entity.py b/src/argenta/router/entity.py index 18b1134..c32695a 100644 --- a/src/argenta/router/entity.py +++ b/src/argenta/router/entity.py @@ -1,10 +1,11 @@ __all__ = ["Router"] from inspect import get_annotations, getfullargspec, getsourcefile, getsourcelines -from typing import Callable, TypeAlias +from typing import Callable from rich.console import Console +from argenta.app.protocols import HandlerFunc from argenta.command import Command, InputCommand from argenta.command.flag import ValidationStatus from argenta.command.flag.flags import InputFlags @@ -16,8 +17,6 @@ from argenta.router.exceptions import (RepeatedAliasNameException, RequiredArgumentNotPassedException, TriggerContainSpacesException) -HandlerFunc: TypeAlias = Callable[..., None] - class Router: def __init__( @@ -176,13 +175,12 @@ def _validate_func_args(func: HandlerFunc) -> None: response_arg_annotation = func_annotations.get(response_arg) - if response_arg_annotation is not None: - if response_arg_annotation is not Response: - source_line: int = getsourcelines(func)[1] - Console().print( - f'\nFile "{getsourcefile(func)}", line {source_line}\n[b red]WARNING:[/b red] [i]The typehint ' - + f"of argument([green]{response_arg}[/green]) passed to the handler must be [/i][bold blue]{Response}[/bold blue]," - + f" [i]but[/i] [bold blue]{response_arg_annotation}[/bold blue] [i]is specified[/i]", - highlight=False, - ) + if response_arg_annotation is not None and response_arg_annotation is not Response: + source_line: int = getsourcelines(func)[1] + Console().print( + f'\nFile "{getsourcefile(func)}", line {source_line}\n[b red]WARNING:[/b red] [i]The typehint ' + + f"of argument([green]{response_arg}[/green]) passed to the handler must be [/i][bold blue]{Response}[/bold blue]," + + f" [i]but[/i] [bold blue]{response_arg_annotation}[/bold blue] [i]is specified[/i]", + highlight=False, + ) \ No newline at end of file From 30e5fd6ebe12bf6fbcac3a0b45b72b770ca079c9 Mon Sep 17 00:00:00 2001 From: kolo Date: Thu, 15 Jan 2026 16:41:50 +0300 Subject: [PATCH 14/15] perf --- src/argenta/app/models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/argenta/app/models.py b/src/argenta/app/models.py index e78669d..f74d514 100644 --- a/src/argenta/app/models.py +++ b/src/argenta/app/models.py @@ -270,11 +270,12 @@ class BaseApp: all_aliases: set[str] = set() for router_entity in self.registered_routers: - trigger_collisions: set[str] = (all_triggers | all_aliases) & router_entity.triggers + union_units: set[str] = all_triggers | all_aliases + trigger_collisions: set[str] = union_units & router_entity.triggers if trigger_collisions: raise RepeatedTriggerNameException() - alias_collisions: set[str] = (all_aliases | all_triggers) & router_entity.aliases + alias_collisions: set[str] = union_units & router_entity.aliases if alias_collisions: raise RepeatedAliasNameException(alias_collisions) From 285ea39fa9663d1d30835a7bbdd98b3832495a76 Mon Sep 17 00:00:00 2001 From: kolo Date: Thu, 15 Jan 2026 16:59:57 +0300 Subject: [PATCH 15/15] ruff wtf --- src/argenta/app/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/argenta/app/models.py b/src/argenta/app/models.py index f74d514..2bf37f8 100644 --- a/src/argenta/app/models.py +++ b/src/argenta/app/models.py @@ -458,9 +458,9 @@ class App(BaseApp): try: input_command: InputCommand = InputCommand.parse(raw_command=raw_command) - except InputCommandException as error: + except InputCommandException as error: # noqa F841 stderr_result = self._capture_stdout( - lambda: self._error_handler(error, raw_command) + lambda: self._error_handler(error, raw_command) # noqa F821 ) self._print_framed_text(stderr_result) continue