Update documentation and code snippets

This commit is contained in:
2025-12-04 20:23:11 +03:00
parent 723ed2210f
commit 087c76fed3
19 changed files with 278 additions and 114 deletions
@@ -0,0 +1,24 @@
from argenta import Router, Response
from argenta.command import Command, Flag, PossibleValues
from argenta.command.flag import ValidationStatus
router = Router()
@router.command(Command(
"deploy",
flags=Flag("verbose", possible_values=PossibleValues.NEITHER)
))
def deploy_handler(response: Response):
# Check for toggle flag presence
verbose_flag = response.input_flags.get_flag_by_name("verbose")
if verbose_flag and verbose_flag.status == ValidationStatus.VALID:
print("Deploying with verbose output...")
# Detailed logic
elif verbose_flag and verbose_flag.status == ValidationStatus.INVALID:
print("Incorrect flag value")
return
else:
print("Deploying...")
# Normal logic
+16
View File
@@ -0,0 +1,16 @@
from argenta import Router, Response
from argenta.command import Command, Flag
router = Router()
@router.command(Command("greet", flags=Flag("name")))
def greet_handler(response: Response):
# Get flag by name
name_flag = response.input_flags.get_flag_by_name("name")
# Check if flag was passed
if name_flag:
print(f"Hello, {name_flag.input_value}!")
else:
print("Hello, stranger!")
+2 -2
View File
@@ -5,9 +5,9 @@ flag_with_value = InputFlag(
)
flag_without_value = InputFlag(
name="help", prefix="-", input_value=None, status=ValidationStatus.VALID
name="help", prefix="-", input_value='', status=ValidationStatus.VALID
)
# String representation includes value
print(str(flag_with_value)) # --output result.txt
print(str(flag_without_value)) # -help None
print(str(flag_without_value)) # -help
@@ -0,0 +1,68 @@
import operator
import re
from argenta import App, Orchestrator, Response, Router
from argenta.app import DynamicDividingLine
from argenta.command import Command, Flag, Flags
from argenta.response.status import ResponseStatus
router = Router("Calculator")
operations = {
'mul': operator.mul,
'sub': operator.sub,
'add': operator.add
}
@router.command(
Command(
"calc",
description="Calculator with two numbers",
flags=Flags(
[
Flag("a", possible_values=re.compile(r"^\d{,5}$")), # First number
Flag("b", possible_values=re.compile(r"^\d{,5}$")), # Second number
Flag("operation", possible_values=["add", "sub", "mul"]), # Operation: add, sub, mul
]
),
)
)
def calc_handler(response: Response):
# Get flag values
a_flag = response.input_flags.get_flag_by_name("a")
b_flag = response.input_flags.get_flag_by_name("b")
op_flag = response.input_flags.get_flag_by_name("op")
# Check that all flags are provided
if response.status != ResponseStatus.ALL_FLAGS_VALID or not all([a_flag, b_flag, op_flag]):
print("Error: must specify --a, --b and --op")
return
a = float(a_flag.input_value)
b = float(b_flag.input_value)
operation = op_flag.input_value
try:
result = operations[operation](a, b)
except ZeroDivisionError:
print("Can't divide by zero")
else:
print(f"Result: {result}")
app = App(
initial_message="Calculator",
repeat_command_groups_printing=False,
prompt=">> ",
dividing_line=DynamicDividingLine("~"),
)
orchestrator = Orchestrator()
def main():
app.include_router(router)
orchestrator.start_polling(app)
if __name__ == "__main__":
main()
+2 -2
View File
@@ -17,9 +17,9 @@ Argenta
.. image:: https://github.com/koloideal/Argenta/blob/main/imgs/mock_app_preview4.png?raw=True
:alt: App example
Argenta предназначена для создания приложений, работающих в собственном контексте (scope). Это означает, что при запуске пользователь входит в интерактивную сессию, где ему доступна вся реализованная вами функциональность.
Argenta предназначена для создания приложений, работающих в собственном контексте (scope). Это означает, что приложение запускается один раз и создаёт интерактивную сессию, похожую на Python REPL или MySQL консоль. При запуске пользователь входит в эту сессию, где ему доступна вся реализованная вами функциональность.
Один из ключевых принципов библиотеки — цикличность. После выполнения команды пользователь остаётся в интерактивной сессии, в отличие от таких библиотек, как ``argparse``, ``click`` и ``typer``. Выход из сессии контролируется пользователем.
Один из ключевых принципов библиотеки — цикличность. После выполнения команды пользователь остаётся в интерактивной сессии и имеет доступ к созданной вами функциональности, в отличие от таких библиотек, как ``argparse``, ``click`` и ``typer``. Выход из сессии контролируется пользователем.
**Ключевые особенности:**
@@ -64,7 +64,7 @@ msgid ""
"к БД или API-клиентов) в DI-контейнер."
msgstr ""
"``custom_providers``: List of custom ``dishka.Provider`` providers for adding your services "
"(e.g., database connections or API clients) to the DI container."
"(e.g., database connections or API clients) to the``di``container."
#: ../../root/api/orchestrator/index.rst:32
msgid ""
@@ -93,7 +93,7 @@ msgid "Как это работает?"
msgstr "How Does It Work?"
#: ../../root/dependency_injection.rst:48
msgid "В основе DI в Argenta лежат **провайдеры** и **контейнер**."
msgid "В основе``di``в Argenta лежат **провайдеры** и **контейнер**."
msgstr "DI in Argenta is based on **providers** and a **container**."
#: ../../root/dependency_injection.rst:50
+1 -1
View File
@@ -30,7 +30,7 @@ InputFlag
.. py:attribute:: input_value
Значение, переданное с флагом. Может быть `None` для флагов без значений.
Значение, переданное с флагом. Может быть ``''`` (пустой строкой) для флагов без значений.
.. py:attribute:: status
:no-index:
+1 -1
View File
@@ -44,7 +44,7 @@ ArgParser
Лучшие практики
---------------
Использовать атрибут ``parsed_argspace`` рекомендуется только на этапе настройки приложения. В обработчиках лучшей практикой является получение ``ArgSpace`` через DI. Подробнее см. :ref:`здесь <root_dependency_injection>`.
Использовать атрибут ``parsed_argspace`` рекомендуется только на этапе настройки приложения. В обработчиках лучшей практикой является получение ``ArgSpace`` через ``di``. Подробнее см. :ref:`здесь <root_dependency_injection>`.
**Пример использования:**
+1 -1
View File
@@ -28,7 +28,7 @@ Orchestrator
Создаёт и конфигурирует экземпляр ``Orchestrator``.
* ``arg_parser``: Экземпляр ``ArgParser``, отвечающий за парсинг аргументов командной строки при запуске скрипта (не путать с командами в интерактивном режиме).
* ``custom_providers``: Список пользовательских провайдеров ``dishka.Provider`` для добавления ваших сервисов (например, подключений к БД или API-клиентов) в DI-контейнер.
* ``custom_providers``: Список пользовательских провайдеров ``dishka.Provider`` для добавления ваших сервисов (например, подключений к БД или API-клиентов) в di-контейнер.
* ``auto_inject_handlers``: Если **True** (по умолчанию), ``dishka`` автоматически внедрит зависимости в обработчики команд, инспектируя их сигнатуры.
-----
+8 -5
View File
@@ -3,9 +3,9 @@
Внедрение зависимостей
=======================
Внедрение зависимостей (Dependency Injection, DI) — это паттерн проектирования, который помогает писать слабосвязанный, легко тестируемый и расширяемый код. Вместо того чтобы обработчики сами создавали нужные им объекты (зависимости), они получают их извне.
Внедрение зависимостей (Dependency Injection, ``di`` ) — это паттерн проектирования, который помогает писать слабосвязанный, легко тестируемый и расширяемый код. Вместо того чтобы обработчики сами создавали нужные им объекты (зависимости), они получают их извне.
``Argenta`` использует библиотеку ``dishka`` для реализации DI, что позволяет декларативно объявлять зависимости прямо в сигнатурах ваших обработчиков.
``Argenta`` использует библиотеку ``dishka`` для реализации ``di``, что позволяет декларативно объявлять зависимости прямо в сигнатурах ваших обработчиков.
Подробнее о **DI**, **IoC** и API для создания провайдеров можно прочитать в `официальной документации dishka <https://dishka.readthedocs.io/en/stable/di_intro.html>`_.
-----
@@ -32,7 +32,10 @@
:language: python
:linenos:
После создания провайдера его необходимо зарегистрировать в оркестраторе:
После создания провайдера его необходимо зарегистрировать в оркестраторе.
.. note::
Провайдеры регистрируются в ``Orchestrator``, а не в ``App``, так как оркестратор отвечает за настройку di-контейнера на уровне всего приложения. Вы можете передать список из нескольких провайдеров через параметр ``custom_providers``.
**Пример использования:**
@@ -45,7 +48,7 @@
Как это работает?
-----------------
В основе DI в Argenta лежат **провайдеры** и **контейнер**.
В основе ``di`` в Argenta лежат **провайдеры** и **контейнер**.
* **Провайдер (Provider)** — это "рецепт", который объясняет, как создавать и настраивать ту или иную зависимость (например, подключение к БД, API-клиент или любой другой сервис).
* **Контейнер (IoC Container)** — это "фабрика", которая хранит все рецепты (провайдеры) и по запросу создаёт и выдаёт готовые зависимости.
@@ -68,7 +71,7 @@
Обмен данными между обработчиками
----------------------------------
Помимо DI, обработчики могут обмениваться данными в рамках сессии через **объект контекста**. В ``Argenta`` эту роль выполняет объект ``DataBridge``.
Помимо ``di``, обработчики могут обмениваться данными в рамках сессии через **объект контекста**. В ``Argenta`` эту роль выполняет объект ``DataBridge``.
Каждый обработчик может записывать в него данные, а также читать, обновлять и удалять их.
+32 -2
View File
@@ -1,7 +1,7 @@
.. _root_flags:
Флаги вводимых команд
=====================
Флаги команд
============
Флаги — это специальные параметры, которые пользователь может добавлять к командам для управления их поведением.
@@ -56,6 +56,36 @@
Флаг состоит из префикса (``-``, ``--`` или ``---``), имени и, опционально, значения, которое указывается через пробел.
**Примеры:**
.. code-block:: shell
greet --name John # Flag with value
deploy --verbose # Flag without value (switch)
backup -f --compress # Several flags
-----
Работа с флагами в обработчиках
--------------------------------
Чтобы получить значение флага в обработчике, используйте объект ``response.input_flags`` типа :ref:`InputFlags <root_api_command_input_flags>`.
**Пример с флагом, имеющим значение:**
.. literalinclude:: ../code_snippets/flags/greet_handler.py
:language: python
:linenos:
**Пример с флагом-переключателем:**
.. literalinclude:: ../code_snippets/flags/deploy_handler.py
:language: python
:linenos:
.. seealso::
Подробнее о работе с объектом ``InputFlags`` см. в разделе :ref:`InputFlags <root_api_command_input_flags>`.
-----
Два типа флагов
+30 -2
View File
@@ -23,6 +23,14 @@
:language: python
:linenos:
**Запуск**
Сохраните код в файл (например, ``main.py``) и запустите:
.. code-block:: shell
python main.py
**Результат**
.. image:: https://i.ibb.co/35q24Bh8/image.png
@@ -30,10 +38,30 @@
-----
Более сложный пример: Менеджер задач
Промежуточный пример: Калькулятор с флагами
--------------------------------------------
Прежде чем перейти к сложному примеру с ``di``, рассмотрим промежуточный вариант — калькулятор, который использует флаги для управления поведением.
.. literalinclude:: ../code_snippets/quickstart/calculator_app.py
:language: python
:linenos:
**Использование:**
.. code-block:: shell
calc --a 10 --b 5 --operation add # Result: 15.0
calc --a 10 --b 5 --operation mul # Result: 50.0
Этот пример показывает, как работать с флагами без использования ``di``. Теперь перейдём к более сложному примеру.
-----
Сложный пример: Менеджер задач с ``di``
--------------------------------------
В этом руководстве мы создадим простое, но полнофункциональное CLI-приложение «Менеджер задач», которое продемонстрирует ключевые возможности Argenta.
В этом руководстве мы создадим полнофункциональное CLI-приложение «Менеджер задач», которое продемонстрирует работу с внедрением зависимостей.
1. **Установка**
+14
View File
@@ -7,6 +7,20 @@
-----
Когда нужно отключать перехват stdout
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Отключайте перехват ``stdout`` (``disable_redirect_stdout=True`` в ``Router``), если ваши команды:
✓ Используют ``input()`` для интерактивного ввода данных от пользователя
✓ Используют прогресс-бары (``tqdm``, ``rich.progress``)
✓ Выводят данные в реальном времени (streaming, логи)
✓ Используют библиотеки, которые напрямую работают с ``stdout``
Для обычных команд с ``print()`` перехват можно оставить включённым — это не влияет на их работу.
-----
Механизм перехвата ``stdout``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+13 -10
View File
@@ -5,6 +5,9 @@ from re import Pattern
from typing import Literal, override
PREFIX_TYPE = Literal["-", "--", "---"]
class PossibleValues(Enum):
NEITHER = "NEITHER"
ALL = "ALL"
@@ -21,7 +24,7 @@ class Flag:
self,
name: str,
*,
prefix: Literal["-", "--", "---"] = "--",
prefix: PREFIX_TYPE = "--",
possible_values: list[str] | Pattern[str] | PossibleValues = PossibleValues.ALL,
) -> None:
"""
@@ -32,23 +35,23 @@ class Flag:
:return: None
"""
self.name: str = name
self.prefix: Literal["-", "--", "---"] = prefix
self.prefix: PREFIX_TYPE = prefix
self.possible_values: list[str] | Pattern[str] | PossibleValues = possible_values
def validate_input_flag_value(self, input_flag_value: str | None) -> bool:
def validate_input_flag_value(self, input_flag_value: str) -> bool:
"""
Private. Validates the input flag value
:param input_flag_value: The input flag value to validate
:return: whether the entered flag is valid as bool
"""
if self.possible_values == PossibleValues.NEITHER:
return input_flag_value is None
return input_flag_value == ''
if self.possible_values == PossibleValues.ALL:
return input_flag_value is not None
return input_flag_value != ''
if isinstance(self.possible_values, Pattern):
return isinstance(input_flag_value, str) and bool(self.possible_values.match(input_flag_value))
return bool(self.possible_values.match(input_flag_value))
if isinstance(self.possible_values, list):
return input_flag_value in self.possible_values
@@ -85,8 +88,8 @@ class InputFlag:
self,
name: str,
*,
prefix: Literal["-", "--", "---"] = "--",
input_value: str | None,
prefix: PREFIX_TYPE = "--",
input_value: str,
status: ValidationStatus | None,
):
"""
@@ -97,8 +100,8 @@ class InputFlag:
:return: None
"""
self.name: str = name
self.prefix: Literal["-", "--", "---"] = prefix
self.input_value: str | None = input_value
self.prefix: PREFIX_TYPE = prefix
self.input_value: str = input_value
self.status: ValidationStatus | None = status
@property
+38 -60
View File
@@ -1,6 +1,8 @@
__all__ = ["Command", "InputCommand"]
from typing import Literal, Never, Self, cast
import shlex
from typing import Never, Self, cast
from typing_extensions import Literal
from argenta.command.exceptions import (
EmptyInputCommandException,
@@ -14,6 +16,7 @@ ParseFlagsResult = tuple[InputFlags, str | None, str | None]
ParseResult = tuple[str, InputFlags]
MIN_FLAG_PREFIX: str = "-"
PREFIX_TYPE = Literal["-", "--", "---"]
DEFAULT_WITHOUT_FLAGS: Flags = Flags()
DEFAULT_WITHOUT_ALIASES: list[Never] = []
@@ -78,75 +81,50 @@ class InputCommand:
:param raw_command: raw input command
:return: model of the input command, after parsing as InputCommand
"""
trigger, input_flags = CommandParser(raw_command).parse_raw_command()
tokens = shlex.split(raw_command)
return cls(trigger=trigger, input_flags=input_flags)
if not tokens:
raise EmptyInputCommandException
command = tokens[0]
flags: InputFlags = InputFlags()
class CommandParser:
def __init__(self, raw_command: str) -> None:
self.raw_command: str = raw_command
self._parsed_input_flags: InputFlags = InputFlags()
i = 1
while i < len(tokens):
token = tokens[i]
def parse_raw_command(self) -> ParseResult:
if not self.raw_command:
raise EmptyInputCommandException()
if token.startswith("---"):
prefix = "---"
name = token[3:]
elif token.startswith("--"):
prefix = "--"
name = token[2:]
elif token.startswith("-"):
prefix = "-"
name = token[1:]
else:
raise UnprocessedInputFlagException
input_flags, crnt_flag_name, crnt_flag_val = self._parse_flags(self.raw_command.split()[1:])
if not name:
raise UnprocessedInputFlagException
if any([crnt_flag_name, crnt_flag_val]):
raise UnprocessedInputFlagException()
else:
return (self.raw_command.split()[0], input_flags)
def _parse_flags(self, _tokens: list[str] | list[Never]) -> ParseFlagsResult:
crnt_flg_name, crnt_flg_val = None, None
for index, token in enumerate(_tokens):
crnt_flg_name, crnt_flg_val = _parse_single_token(token, crnt_flg_name, crnt_flg_val)
if not crnt_flg_name or self._is_next_token_value(index, _tokens):
continue
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=crnt_flg_name[crnt_flg_name.rfind(MIN_FLAG_PREFIX) + 1 :],
prefix=cast(
Literal["-", "--", "---"],
crnt_flg_name[: crnt_flg_name.rfind(MIN_FLAG_PREFIX) + 1],
),
input_value=crnt_flg_val,
status=None,
name=name,
prefix=cast(PREFIX_TYPE, prefix), # pyright: ignore[reportUnnecessaryCast]
input_value=input_value,
status=None
)
if input_flag in self._parsed_input_flags:
if input_flag in flags:
raise RepeatedInputFlagsException(input_flag)
self._parsed_input_flags.add_flag(input_flag)
crnt_flg_name, crnt_flg_val = None, None
flags.add_flag(input_flag)
return (self._parsed_input_flags, crnt_flg_name, crnt_flg_val)
def _is_next_token_value(self, current_index: int, _tokens: list[str] | list[Never]) -> bool:
next_index = current_index + 1
if next_index >= len(_tokens):
return False
next_token = _tokens[next_index]
return not next_token.startswith(MIN_FLAG_PREFIX)
def _parse_single_token(
token: str, crnt_flag_name: str | None, crnt_flag_val: str | None
) -> tuple[str | None, str | None]:
if not token.startswith(MIN_FLAG_PREFIX):
if not crnt_flag_name or crnt_flag_val:
raise UnprocessedInputFlagException
return crnt_flag_name, token
prefix = token[: token.rfind(MIN_FLAG_PREFIX)]
if len(token) < 2 or len(prefix) > 2:
raise UnprocessedInputFlagException
new_flag_name = token
new_flag_value = None
return new_flag_name, new_flag_value
return cls(command, input_flags=flags)
+1 -1
View File
@@ -22,8 +22,8 @@ HandlerFunc: TypeAlias = Callable[..., None]
class Router:
def __init__(
self,
*,
title: str | None = "Default title",
*,
disable_redirect_stdout: bool = False,
):
"""
+6 -6
View File
@@ -6,8 +6,8 @@ from argenta.command.exceptions import (EmptyInputCommandException,
UnprocessedInputFlagException)
from argenta.command.flag import Flag, InputFlag
from argenta.command.flag.flags import Flags
from argenta.command.flag.models import PossibleValues
from argenta.command.models import Command, InputCommand, ValidationStatus
from argenta.command.flag.models import PossibleValues, ValidationStatus
from argenta.command.models import Command, InputCommand
class TestInputCommand(unittest.TestCase):
@@ -28,7 +28,7 @@ class TestInputCommand(unittest.TestCase):
def test_validate_invalid_input_flag1(self):
command = Command('some', flags=Flag('test'))
self.assertEqual(command.validate_input_flag(InputFlag('test', input_value=None, status=None)), ValidationStatus.INVALID)
self.assertEqual(command.validate_input_flag(InputFlag('test', input_value='', status=None)), ValidationStatus.INVALID)
def test_validate_valid_input_flag2(self):
command = Command('some', flags=Flags([Flag('test'), Flag('more')]))
@@ -36,15 +36,15 @@ class TestInputCommand(unittest.TestCase):
def test_validate_undefined_input_flag1(self):
command = Command('some', flags=Flag('test'))
self.assertEqual(command.validate_input_flag(InputFlag('more', input_value=None, status=None)), ValidationStatus.UNDEFINED)
self.assertEqual(command.validate_input_flag(InputFlag('more', input_value='', status=None)), ValidationStatus.UNDEFINED)
def test_validate_undefined_input_flag2(self):
command = Command('some', flags=Flags([Flag('test'), Flag('more')]))
self.assertEqual(command.validate_input_flag(InputFlag('case', input_value=None, status=None)), ValidationStatus.UNDEFINED)
self.assertEqual(command.validate_input_flag(InputFlag('case', input_value='', status=None)), ValidationStatus.UNDEFINED)
def test_validate_undefined_input_flag3(self):
command = Command('some')
self.assertEqual(command.validate_input_flag(InputFlag('case', input_value=None, status=None)), ValidationStatus.UNDEFINED)
self.assertEqual(command.validate_input_flag(InputFlag('case', input_value='', status=None)), ValidationStatus.UNDEFINED)
def test_invalid_input_flag1(self):
command = Command('some', flags=Flag('test', possible_values=PossibleValues.NEITHER))
+9 -9
View File
@@ -29,8 +29,8 @@ class TestFlag(unittest.TestCase):
'--')
def test_get_flag_value_without_set(self):
self.assertEqual(InputFlag(name='test', input_value=None, status=None).input_value,
None)
self.assertEqual(InputFlag(name='test', input_value='', status=None).input_value,
'')
def test_get_flag_value_with_set(self):
flag = InputFlag(name='test', input_value='example', status=None)
@@ -54,11 +54,11 @@ class TestFlag(unittest.TestCase):
def test_validate_correct_empty_flag_value_without_possible_flag_values(self):
flag = Flag(name='test', possible_values=PossibleValues.NEITHER)
self.assertEqual(flag.validate_input_flag_value(None), True)
self.assertEqual(flag.validate_input_flag_value(''), True)
def test_validate_correct_empty_flag_value_with_possible_flag_values(self):
flag = Flag(name='test', possible_values=PossibleValues.NEITHER)
self.assertEqual(flag.validate_input_flag_value(None), True)
self.assertEqual(flag.validate_input_flag_value(''), True)
def test_validate_incorrect_random_flag_value_without_possible_flag_values(self):
flag = Flag(name='test', possible_values=PossibleValues.NEITHER)
@@ -69,19 +69,19 @@ class TestFlag(unittest.TestCase):
self.assertEqual(flag.validate_input_flag_value('random value'), True)
def test_get_input_flag1(self):
flag = InputFlag(name='test', input_value=None, status=None)
flag = InputFlag(name='test', input_value='', status=None)
input_flags = InputFlags([flag])
self.assertEqual(input_flags.get_flag_by_name('test'), flag)
def test_get_input_flag2(self):
flag = InputFlag(name='test', input_value=None, status=None)
flag2 = InputFlag(name='some', input_value=None, status=None)
flag = InputFlag(name='test', input_value='', status=None)
flag2 = InputFlag(name='some', input_value='', status=None)
input_flags = InputFlags([flag, flag2])
self.assertEqual(input_flags.get_flag_by_name('some'), flag2)
def test_get_undefined_input_flag(self):
flag = InputFlag(name='test', input_value=None, status=None)
flag2 = InputFlag(name='some', input_value=None, status=None)
flag = InputFlag(name='test', input_value='', status=None)
flag2 = InputFlag(name='some', input_value='', status=None)
input_flags = InputFlags([flag, flag2])
self.assertEqual(input_flags.get_flag_by_name('case'), None)