Merge pull request #2 from koloideal/feat/full_support_argparser

Added support for optional parameters for various types of arguments
This commit is contained in:
kolo
2025-10-11 19:39:36 +03:00
committed by GitHub
15 changed files with 346 additions and 125 deletions
+2 -4
View File
@@ -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()
print(arg[:arg.rfind('-')+1])
+5 -6
View File
@@ -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)
+3 -2
View File
@@ -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)
+5 -2
View File
@@ -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"]
+3 -8
View File
@@ -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
+6 -2
View File
@@ -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:
+2 -6
View File
@@ -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
@@ -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
@@ -1,8 +1,8 @@
__all__ = ["BooleanArgument", "PositionalArgument", "OptionalArgument"]
__all__ = ["BooleanArgument", "ValueArgument", "InputArgument"]
from argenta.orchestrator.argparser.arguments.models import (
BooleanArgument,
PositionalArgument,
OptionalArgument,
ValueArgument,
InputArgument
)
@@ -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<name={self.name}, value={self.value}, founder_class={self.founder_class.__name__}>"
+53 -14
View File
@@ -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)
+9 -16
View File
@@ -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)
@@ -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):
@@ -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):
+164
View File
@@ -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)