diff --git a/mock/local_test.py b/mock/local_test.py index e672a5a..a9c4c13 100644 --- a/mock/local_test.py +++ b/mock/local_test.py @@ -1,5 +1,3 @@ -import argparse +arg = '-repeat' -parser = argparse.ArgumentParser(prog='myprogram') -_ = parser.add_argument('--foo', help='foo of the %(prog)s program') -parser.print_help() \ No newline at end of file +print(arg[:arg.rfind('-')+1]) \ No newline at end of file diff --git a/mock/mock_app/main.py b/mock/mock_app/main.py index 67cc55f..84664dd 100644 --- a/mock/mock_app/main.py +++ b/mock/mock_app/main.py @@ -3,25 +3,24 @@ from mock.mock_app.routers import work_router from argenta import App, Orchestrator from argenta.app import PredefinedMessages, DynamicDividingLine, AutoCompleter from argenta.orchestrator import ArgParser -from argenta.orchestrator.argparser import BooleanArgument +from argenta.orchestrator.argparser import BooleanArgument, ValueArgument -arg_parser = ArgParser(processed_args=[BooleanArgument("repeat")]) +arg_parser: ArgParser = ArgParser(processed_args=[BooleanArgument(name="repeat", is_deprecated=True), + ValueArgument(name="required", is_required=True)]) app: App = App( dividing_line=DynamicDividingLine(), autocompleter=AutoCompleter(), ) -orchestrator: Orchestrator = Orchestrator(arg_parser) - +orchestrator: Orchestrator = Orchestrator() def main(): app.include_router(work_router) - print(f"\n\n{orchestrator.get_input_args()}") app.add_message_on_startup(PredefinedMessages.USAGE) app.add_message_on_startup(PredefinedMessages.AUTOCOMPLETE) app.add_message_on_startup(PredefinedMessages.HELP) - + orchestrator.start_polling(app) diff --git a/mock/mock_app/routers.py b/mock/mock_app/routers.py index 030fe51..5d36e1f 100644 --- a/mock/mock_app/routers.py +++ b/mock/mock_app/routers.py @@ -12,8 +12,9 @@ flag = Flag('csdv', possible_values=PossibleValues.NEITHER) Command("get", description="Get Help", aliases=["help", "Get_help"], - flags=Flags([PredefinedFlags.PORT, - PredefinedFlags.HOST]))) + flags=Flags([PredefinedFlags.PORT, PredefinedFlags.HOST]) + ) +) def command_help(response: Response): print(response.status) print(response.input_flags.flags) diff --git a/pyproject.toml b/pyproject.toml index 6e0356a..b63175f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ license = { text = "MIT" } dependencies = [ "rich (>=14.0.0,<15.0.0)", "art (>=6.4,<7.0)", - "pyreadline3>=3.5.4", + "pyreadline3>=3.5.4; sys_platform == 'win32'", ] [tool.ruff] @@ -20,7 +20,10 @@ exclude = [ "poetry.lock", ".__pycache__", "tests" - ] +] + +[tool.pyright] +typeCheckingMode = "strict" [build-system] requires = ["hatchling"] diff --git a/src/argenta/__init__.py b/src/argenta/__init__.py index 6498ca5..8f4f423 100644 --- a/src/argenta/__init__.py +++ b/src/argenta/__init__.py @@ -1,10 +1,5 @@ -__all__ = [ - 'App', - 'Orchestrator', - 'Router', -] +__all__ = ["App", "Orchestrator", "Router"] -from argenta.app import App -from argenta.orchestrator import Orchestrator -from argenta.router import Router +from argenta.orchestrator.entity import Orchestrator, App +from argenta.router.entity import Router diff --git a/src/argenta/app/models.py b/src/argenta/app/models.py index 6c7da55..c8821dd 100644 --- a/src/argenta/app/models.py +++ b/src/argenta/app/models.py @@ -3,6 +3,7 @@ import re from contextlib import redirect_stdout from typing import Never, TypeAlias +from argenta.orchestrator.argparser.entity import ArgSpace from art import text2art # pyright: ignore[reportMissingTypeStubs, reportUnknownVariableType] from rich.console import Console from rich.markup import escape @@ -41,7 +42,8 @@ class BaseApp: repeat_command_groups: bool, override_system_messages: bool, autocompleter: AutoCompleter, - print_func: Printer) -> None: + print_func: Printer, + argspace: ArgSpace | None = None) -> None: self._prompt: str = prompt self._print_func: Printer = print_func self._exit_command: Command = exit_command @@ -51,6 +53,7 @@ class BaseApp: self._repeat_command_groups_description: bool = repeat_command_groups self._override_system_messages: bool = override_system_messages self._autocompleter: AutoCompleter = autocompleter + self._argspace: ArgSpace | None = argspace self._farewell_message: str = farewell_message self._initial_message: str = initial_message @@ -392,11 +395,12 @@ class App(BaseApp): print_func=print_func, ) - def run_polling(self) -> None: + def run_polling(self, argspace: ArgSpace | None) -> None: """ Private. Starts the user input processing cycle :return: None """ + self._argspace = argspace self._pre_cycle_setup() while True: if self._repeat_command_groups_description: diff --git a/src/argenta/orchestrator/__init__.py b/src/argenta/orchestrator/__init__.py index b658f94..b980d66 100644 --- a/src/argenta/orchestrator/__init__.py +++ b/src/argenta/orchestrator/__init__.py @@ -1,8 +1,4 @@ -__all__ = [ - "Orchestrator", - "ArgParser" -] +__all__ = ["ArgParser", "Orchestrator"] - -from argenta.orchestrator.entity import Orchestrator from argenta.orchestrator.argparser.entity import ArgParser +from argenta.orchestrator.entity import Orchestrator diff --git a/src/argenta/orchestrator/argparser/__init__.py b/src/argenta/orchestrator/argparser/__init__.py index 01d2f42..4f59581 100644 --- a/src/argenta/orchestrator/argparser/__init__.py +++ b/src/argenta/orchestrator/argparser/__init__.py @@ -1,12 +1,9 @@ __all__ = [ "ArgParser", - "PositionalArgument", - "OptionalArgument", - "BooleanArgument" + "BooleanArgument", + "ValueArgument" ] from argenta.orchestrator.argparser.entity import ArgParser -from argenta.orchestrator.argparser.arguments import (BooleanArgument, - PositionalArgument, - OptionalArgument) +from argenta.orchestrator.argparser.arguments import BooleanArgument, ValueArgument diff --git a/src/argenta/orchestrator/argparser/arguments/__init__.py b/src/argenta/orchestrator/argparser/arguments/__init__.py index 5b6270c..0308877 100644 --- a/src/argenta/orchestrator/argparser/arguments/__init__.py +++ b/src/argenta/orchestrator/argparser/arguments/__init__.py @@ -1,8 +1,8 @@ -__all__ = ["BooleanArgument", "PositionalArgument", "OptionalArgument"] +__all__ = ["BooleanArgument", "ValueArgument", "InputArgument"] from argenta.orchestrator.argparser.arguments.models import ( BooleanArgument, - PositionalArgument, - OptionalArgument, + ValueArgument, + InputArgument ) diff --git a/src/argenta/orchestrator/argparser/arguments/models.py b/src/argenta/orchestrator/argparser/arguments/models.py index 1a74312..32d6f02 100644 --- a/src/argenta/orchestrator/argparser/arguments/models.py +++ b/src/argenta/orchestrator/argparser/arguments/models.py @@ -1,62 +1,82 @@ -from abc import ABC, abstractmethod -from typing import Literal, override +from typing import Literal -class BaseArgument(ABC): +class BaseArgument: """ Private. Base class for all arguments """ - @property - @abstractmethod - def string_entity(self) -> str: - """ - Public. Returns the string representation of the argument - :return: the string representation as a str - """ - raise NotImplementedError - - -class PositionalArgument(BaseArgument): - def __init__(self, name: str): - """ - Public. Required argument at startup - :param name: name of the argument, must not start with minus (-) - """ - self.name: str = name - - @property - @override - def string_entity(self) -> str: - return self.name - - -class OptionalArgument(BaseArgument): - def __init__(self, name: str, prefix: Literal["-", "--", "---"] = "--"): - """ - Public. Optional argument, must have the value - :param name: name of the argument - :param prefix: prefix of the argument - """ - self.name: str = name - self.prefix: Literal["-", "--", "---"] = prefix - - @property - @override - def string_entity(self) -> str: - return self.prefix + self.name - - -class BooleanArgument(BaseArgument): - def __init__(self, name: str, prefix: Literal["-", "--", "---"] = "--"): + def __init__(self, name: str, *, + help: str, + is_deprecated: bool, + prefix: Literal["-", "--", "---"]): """ Public. Boolean argument, does not require a value :param name: name of the argument - :param prefix: prefix of the argument + :param help: help message for the argument + :param is_required: whether the argument is required + :param is_deprecated: whether the argument is deprecated """ self.name: str = name + self.help: str = help + self.is_deprecated: bool = is_deprecated self.prefix: Literal["-", "--", "---"] = prefix - + @property - @override def string_entity(self) -> str: return self.prefix + self.name + + +class ValueArgument(BaseArgument): + def __init__(self, name: str, *, + prefix: Literal["-", "--", "---"] = "--", + help: str = "Help message for the value argument", + possible_values: list[str] | None = None, + default: str | None = None, + is_required: bool = False, + is_deprecated: bool = False): + """ + Public. Value argument, must have the value + :param name: name of the argument + :param prefix: prefix for the argument + :param help: help message for the argument + :param possible_values: list of possible values for the argument + :param default: default value for the argument + :param is_required: whether the argument is required + :param is_deprecated: whether the argument is deprecated + """ + self.default: str | None = default + self.possible_values: list[str] | None = possible_values + self.is_required: bool = is_required + self.action: str = "store" + super().__init__(name, prefix=prefix, help=help, is_deprecated=is_deprecated) + + +class BooleanArgument(BaseArgument): + def __init__(self, name: str, *, + prefix: Literal["-", "--", "---"] = "--", + help: str = "Help message for the boolean argument", + is_deprecated: bool = False): + """ + Public. Boolean argument, does not require a value + :param name: name of the argument + :param help: help message for the argument + :param is_required: whether the argument is required + :param is_deprecated: whether the argument is deprecated + """ + self.action: str = "store_true" + super().__init__(name, prefix=prefix, help=help, is_deprecated=is_deprecated) + + +class InputArgument: + def __init__(self, name: str, + value: str | None, + founder_class: type[BaseArgument]) -> None: + self.name: str = name + self.value: str | None = value + self.founder_class: type[BaseArgument] = founder_class + + def __str__(self) -> str: + return f"InputArgument({self.name}={self.value})" + + def __repr__(self) -> str: + return f"InputArgument" diff --git a/src/argenta/orchestrator/argparser/entity.py b/src/argenta/orchestrator/argparser/entity.py index 5c3f29d..b49e426 100644 --- a/src/argenta/orchestrator/argparser/entity.py +++ b/src/argenta/orchestrator/argparser/entity.py @@ -1,16 +1,44 @@ from argparse import ArgumentParser, Namespace +from typing import Never, Self from argenta.orchestrator.argparser.arguments.models import ( + BaseArgument, BooleanArgument, - OptionalArgument, - PositionalArgument, + InputArgument, + ValueArgument ) + +class ArgSpace: + def __init__(self, all_arguments: list[InputArgument]) -> None: + self.all_arguments = all_arguments + + @classmethod + def from_namespace(cls, namespace: Namespace, + processed_args: list[ValueArgument | BooleanArgument]) -> Self: + name_type_paired_args: dict[str, type[BaseArgument]] = { + arg.name: type(arg) + for arg in processed_args + } + return cls([InputArgument(name=name, + value=value, + founder_class=name_type_paired_args[name]) + for name, value in vars(namespace).items()]) + + def get_by_name(self, name: str) -> InputArgument | None: + for arg in self.all_arguments: + if arg.name == name: + return arg + return None + + def get_by_type(self, arg_type: type[BaseArgument]) -> list[InputArgument] | list[Never]: + return [arg for arg in self.all_arguments if arg.founder_class is arg_type] + class ArgParser: def __init__( self, - processed_args: list[PositionalArgument | OptionalArgument | BooleanArgument], *, + processed_args: list[ValueArgument | BooleanArgument], *, name: str = "Argenta", description: str = "Argenta available arguments", epilog: str = "github.com/koloideal/Argenta | made by kolo", @@ -22,18 +50,29 @@ class ArgParser: :param epilog: the epilog of the ArgParse instance :param processed_args: registered and processed arguments """ - self._name: str = name - self._description: str = description - self._epilog: str = epilog + self.name: str = name + self.description: str = description + self.epilog: str = epilog + self.processed_args: list[ValueArgument | BooleanArgument] = processed_args - self._entity: ArgumentParser = ArgumentParser(prog=name, description=description, epilog=epilog) - self._processed_args: list[PositionalArgument | OptionalArgument | BooleanArgument] = processed_args + self._core: ArgumentParser = ArgumentParser(prog=name, description=description, epilog=epilog) for arg in processed_args: - if isinstance(arg, PositionalArgument) or isinstance(arg, OptionalArgument): - _ = self._entity.add_argument(arg.string_entity) - else: - _ = self._entity.add_argument(arg.string_entity, action="store_true") + if isinstance(arg, BooleanArgument): + _ = self._core.add_argument(arg.string_entity, + action=arg.action, + help=arg.help, + deprecated=arg.is_deprecated) + else: + _ = self._core.add_argument(arg.string_entity, + action=arg.action, + help=arg.help, + default=arg.default, + choices=arg.possible_values, + required=arg.is_required, + deprecated=arg.is_deprecated) - def parse_args(self) -> Namespace: - return self._entity.parse_args() + def parse_args(self) -> ArgSpace: + return ArgSpace.from_namespace(namespace=self._core.parse_args(), + processed_args=self.processed_args) + \ No newline at end of file diff --git a/src/argenta/orchestrator/entity.py b/src/argenta/orchestrator/entity.py index ff73623..3e7143e 100644 --- a/src/argenta/orchestrator/entity.py +++ b/src/argenta/orchestrator/entity.py @@ -1,17 +1,19 @@ -from argparse import Namespace - -from argenta.app import App +from argenta.app.models import App from argenta.orchestrator.argparser import ArgParser +from argenta.orchestrator.argparser.entity import ArgSpace + + +DEFAULT_ARGPARSER: ArgParser = ArgParser(processed_args=[]) class Orchestrator: - def __init__(self, arg_parser: ArgParser | None = None): + def __init__(self, arg_parser: ArgParser = DEFAULT_ARGPARSER): """ Public. An orchestrator and configurator that defines the behavior of an integrated system, one level higher than the App :param arg_parser: Cmd argument parser and configurator at startup :return: None """ - self._arg_parser: ArgParser | None = arg_parser + self._arg_parser: ArgParser = arg_parser def start_polling(self, app: App) -> None: """ @@ -19,14 +21,5 @@ class Orchestrator: :param app: a running application :return: None """ - app.run_polling() - - def get_input_args(self) -> Namespace | None: - """ - Public. Returns the arguments parsed - :return: None - """ - if self._arg_parser: - return self._arg_parser.parse_args() - else: - return None + parsed_argspace: ArgSpace = self._arg_parser.parse_args() + app.run_polling(argspace=parsed_argspace) 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 67ba794..f711575 100644 --- a/tests/system_tests/test_system_handling_non_standard_behavior.py +++ b/tests/system_tests/test_system_handling_non_standard_behavior.py @@ -3,18 +3,24 @@ from unittest.mock import patch, MagicMock from unittest import TestCase import io import re +import sys -from argenta.app import App from argenta.command import Command, PredefinedFlags from argenta.command.flag.models import ValidationStatus -from argenta.router import Router from argenta.command.flag.flags.models import Flags -from argenta.orchestrator import Orchestrator +from argenta import Orchestrator, App, Router from argenta.response import Response +class PatchedArgvTestCase(TestCase): + def setUp(self): + super().setUp() + self.patcher = patch.object(sys, 'argv', ['program.py']) + self.mock_argv = self.patcher.start() + self.addCleanup(self.patcher.stop) -class TestSystemHandlerNormalWork(TestCase): + +class TestSystemHandlerNormalWork(PatchedArgvTestCase): @patch("builtins.input", side_effect=["help", "q"]) @patch("sys.stdout", new_callable=io.StringIO) def test_input_incorrect_command(self, mock_stdout: _io.StringIO, magick_mock: MagicMock): diff --git a/tests/system_tests/test_system_handling_normal_behavior.py b/tests/system_tests/test_system_handling_normal_behavior.py index 29fa790..f2f5a7b 100644 --- a/tests/system_tests/test_system_handling_normal_behavior.py +++ b/tests/system_tests/test_system_handling_normal_behavior.py @@ -3,19 +3,25 @@ from unittest.mock import patch, MagicMock from unittest import TestCase import io import re +import sys -from argenta.app import App from argenta.command import Command, PredefinedFlags from argenta.command.flag.models import PossibleValues, ValidationStatus from argenta.response import Response -from argenta.router import Router -from argenta.orchestrator import Orchestrator +from argenta import Orchestrator, App, Router from argenta.command.flag import Flag from argenta.command.flag.flags import Flags +class PatchedArgvTestCase(TestCase): + def setUp(self): + super().setUp() + self.patcher = patch.object(sys, 'argv', ['program.py']) + self.mock_argv = self.patcher.start() + self.addCleanup(self.patcher.stop) -class TestSystemHandlerNormalWork(TestCase): + +class TestSystemHandlerNormalWork(PatchedArgvTestCase): @patch("builtins.input", side_effect=["test", "q"]) @patch("sys.stdout", new_callable=io.StringIO) def test_input_correct_command(self, mock_stdout: _io.StringIO, magick_mock: MagicMock): diff --git a/tests/unit_tests/test_argparser.py b/tests/unit_tests/test_argparser.py new file mode 100644 index 0000000..da86049 --- /dev/null +++ b/tests/unit_tests/test_argparser.py @@ -0,0 +1,164 @@ +import unittest +from unittest.mock import MagicMock, patch +from argparse import Namespace + +from argenta.orchestrator.argparser.entity import ArgParser, ArgSpace +from argenta.orchestrator.argparser.arguments.models import ( + ValueArgument, + BooleanArgument, + InputArgument, + BaseArgument +) + + +class TestArgumentClasses(unittest.TestCase): + def test_value_argument_creation(self): + arg = ValueArgument( + name="test_arg", + prefix="--", + help="A test argument.", + possible_values=["one", "two"], + default="one", + is_required=True, + is_deprecated=False + ) + self.assertEqual(arg.name, "test_arg") + self.assertEqual(arg.prefix, "--") + self.assertEqual(arg.help, "A test argument.") + self.assertEqual(arg.possible_values, ["one", "two"]) + self.assertEqual(arg.default, "one") + self.assertTrue(arg.is_required) + self.assertFalse(arg.is_deprecated) + self.assertEqual(arg.action, "store") + self.assertEqual(arg.string_entity, "--test_arg") + + def test_boolean_argument_creation(self): + arg = BooleanArgument( + name="verbose", + prefix="-", + help="Enable verbose mode.", + is_deprecated=True + ) + self.assertEqual(arg.name, "verbose") + self.assertEqual(arg.prefix, "-") + self.assertEqual(arg.help, "Enable verbose mode.") + self.assertTrue(arg.is_deprecated) + self.assertEqual(arg.action, "store_true") + self.assertEqual(arg.string_entity, "-verbose") + + def test_input_argument_creation(self): + arg = InputArgument( + name="file", + value="/path/to/file", + founder_class=ValueArgument + ) + self.assertEqual(arg.name, "file") + self.assertEqual(arg.value, "/path/to/file") + self.assertEqual(arg.founder_class, ValueArgument) + + +class TestArgParser(unittest.TestCase): + def setUp(self): + self.value_arg = ValueArgument(name="config", help="Path to config file") + self.bool_arg = BooleanArgument(name="debug", help="Enable debug mode") + self.processed_args = [self.value_arg, self.bool_arg] + + def test_argparser_initialization(self): + parser = ArgParser( + processed_args=self.processed_args, + name="TestApp", + description="A test application.", + epilog="Test epilog." + ) + self.assertEqual(parser.name, "TestApp") + self.assertEqual(parser.description, "A test application.") + self.assertEqual(parser.epilog, "Test epilog.") + self.assertEqual(parser.processed_args, self.processed_args) + + @patch('argenta.orchestrator.argparser.entity.ArgumentParser.parse_args') + def test_parse_args(self, mock_parse_args: MagicMock): + mock_namespace = Namespace(config='config.json', debug=True) + mock_parse_args.return_value = mock_namespace + + parser = ArgParser(processed_args=self.processed_args) + arg_space = parser.parse_args() + + self.assertIsInstance(arg_space, ArgSpace) + self.assertEqual(len(arg_space.all_arguments), 2) + + config_arg = arg_space.get_by_name('config') + debug_arg = arg_space.get_by_name('debug') + + self.assertIsNotNone(config_arg) + if config_arg: + self.assertEqual(config_arg.value, 'config.json') + self.assertEqual(config_arg.founder_class, ValueArgument) + + self.assertIsNotNone(debug_arg) + if debug_arg: + self.assertTrue(debug_arg.value) + self.assertEqual(debug_arg.founder_class, BooleanArgument) + + +class TestArgSpace(unittest.TestCase): + def setUp(self): + self.input_arg1 = InputArgument(name="arg1", value="val1", founder_class=ValueArgument) + self.input_arg2 = InputArgument(name="arg2", value="val2", founder_class=BooleanArgument) + self.input_arg3 = InputArgument(name="arg3", value="val3", founder_class=ValueArgument) + self.arg_space = ArgSpace(all_arguments=[self.input_arg1, self.input_arg2, self.input_arg3]) + + def test_argspace_initialization(self): + self.assertEqual(len(self.arg_space.all_arguments), 3) + self.assertIn(self.input_arg1, self.arg_space.all_arguments) + self.assertIn(self.input_arg2, self.arg_space.all_arguments) + self.assertIn(self.input_arg3, self.arg_space.all_arguments) + + def test_get_by_name(self): + found_arg = self.arg_space.get_by_name("arg1") + self.assertIsNotNone(found_arg) + if found_arg: + self.assertEqual(found_arg, self.input_arg1) + + def test_get_by_name_not_found(self): + found_arg = self.arg_space.get_by_name("non_existent_arg") + self.assertIsNone(found_arg) + + def test_get_by_type(self): + value_args = self.arg_space.get_by_type(ValueArgument) + self.assertEqual(len(value_args), 2) + self.assertIn(self.input_arg1, value_args) + self.assertIn(self.input_arg3, value_args) + + bool_args = self.arg_space.get_by_type(BooleanArgument) + self.assertEqual(len(bool_args), 1) + self.assertIn(self.input_arg2, bool_args) + + def test_get_by_type_not_found(self): + class OtherArgument(BaseArgument): + pass + + other_args = self.arg_space.get_by_type(OtherArgument) + self.assertEqual(len(other_args), 0) + + def test_from_namespace(self): + namespace = Namespace(arg1="val1", debug=True) + processed_args = [ + ValueArgument(name="arg1", prefix="--"), + BooleanArgument(name="debug", prefix="-") + ] + + arg_space = ArgSpace.from_namespace(namespace, processed_args) + self.assertEqual(len(arg_space.all_arguments), 2) + + arg1 = arg_space.get_by_name('arg1') + debug_arg = arg_space.get_by_name('debug') + + self.assertIsNotNone(arg1) + if arg1: + self.assertEqual(arg1.value, "val1") + self.assertEqual(arg1.founder_class, ValueArgument) + + self.assertIsNotNone(debug_arg) + if debug_arg: + self.assertTrue(debug_arg.value) + self.assertEqual(debug_arg.founder_class, BooleanArgument)