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