Add ArgumentAction.STORE_BOOL_OPTIONAL, Add BreakChainSignal
This commit is contained in:
71
examples/argument_examples.py
Normal file
71
examples/argument_examples.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import asyncio
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from falyx import Falyx
|
||||||
|
from falyx.action import Action
|
||||||
|
from falyx.parser.command_argument_parser import CommandArgumentParser
|
||||||
|
|
||||||
|
|
||||||
|
class Place(Enum):
|
||||||
|
"""Enum for different places."""
|
||||||
|
|
||||||
|
NEW_YORK = "New York"
|
||||||
|
SAN_FRANCISCO = "San Francisco"
|
||||||
|
LONDON = "London"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_args(
|
||||||
|
service: str,
|
||||||
|
place: Place = Place.NEW_YORK,
|
||||||
|
region: str = "us-east-1",
|
||||||
|
verbose: bool | None = None,
|
||||||
|
) -> str:
|
||||||
|
if verbose:
|
||||||
|
print(f"Deploying {service} to {region} at {place}...")
|
||||||
|
return f"{service} deployed to {region} at {place}"
|
||||||
|
|
||||||
|
|
||||||
|
def default_config(parser: CommandArgumentParser) -> None:
|
||||||
|
"""Default argument configuration for the command."""
|
||||||
|
parser.add_argument(
|
||||||
|
"service",
|
||||||
|
type=str,
|
||||||
|
choices=["web", "database", "cache"],
|
||||||
|
help="Service name to deploy.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"place",
|
||||||
|
type=Place,
|
||||||
|
choices=list(Place),
|
||||||
|
default=Place.NEW_YORK,
|
||||||
|
help="Place where the service will be deployed.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--region",
|
||||||
|
type=str,
|
||||||
|
default="us-east-1",
|
||||||
|
help="Deployment region.",
|
||||||
|
choices=["us-east-1", "us-west-2", "eu-west-1"],
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose",
|
||||||
|
action="store_bool_optional",
|
||||||
|
help="Enable verbose output.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
flx = Falyx("Argument Examples")
|
||||||
|
|
||||||
|
flx.add_command(
|
||||||
|
key="T",
|
||||||
|
aliases=["test"],
|
||||||
|
description="Test Command",
|
||||||
|
help_text="A command to test argument parsing.",
|
||||||
|
action=Action(
|
||||||
|
name="test_args",
|
||||||
|
action=test_args,
|
||||||
|
),
|
||||||
|
argument_config=default_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(flx.run())
|
@ -1,5 +1,5 @@
|
|||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""types.py"""
|
"""action_types.py"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
@ -17,6 +17,7 @@ from falyx.execution_registry import ExecutionRegistry as er
|
|||||||
from falyx.hook_manager import Hook, HookManager, HookType
|
from falyx.hook_manager import Hook, HookManager, HookType
|
||||||
from falyx.logger import logger
|
from falyx.logger import logger
|
||||||
from falyx.options_manager import OptionsManager
|
from falyx.options_manager import OptionsManager
|
||||||
|
from falyx.signals import BreakChainSignal
|
||||||
from falyx.themes import OneColors
|
from falyx.themes import OneColors
|
||||||
|
|
||||||
|
|
||||||
@ -106,7 +107,7 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|||||||
def _clear_args(self):
|
def _clear_args(self):
|
||||||
return (), {}
|
return (), {}
|
||||||
|
|
||||||
async def _run(self, *args, **kwargs) -> list[Any]:
|
async def _run(self, *args, **kwargs) -> Any:
|
||||||
if not self.actions:
|
if not self.actions:
|
||||||
raise EmptyChainError(f"[{self.name}] No actions to execute.")
|
raise EmptyChainError(f"[{self.name}] No actions to execute.")
|
||||||
|
|
||||||
@ -166,7 +167,11 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|||||||
context.result = all_results if self.return_list else all_results[-1]
|
context.result = all_results if self.return_list else all_results[-1]
|
||||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||||
return context.result
|
return context.result
|
||||||
|
except BreakChainSignal as error:
|
||||||
|
logger.info("[%s] Chain broken: %s", self.name, error)
|
||||||
|
context.exception = error
|
||||||
|
shared_context.add_error(shared_context.current_index, error)
|
||||||
|
await self._rollback(context.extra["rollback_stack"], *args, **kwargs)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
context.exception = error
|
context.exception = error
|
||||||
shared_context.add_error(shared_context.current_index, error)
|
shared_context.add_error(shared_context.current_index, error)
|
||||||
|
@ -42,7 +42,7 @@ class ExecutionContext(BaseModel):
|
|||||||
kwargs (dict): Keyword arguments passed to the action.
|
kwargs (dict): Keyword arguments passed to the action.
|
||||||
action (BaseAction | Callable): The action instance being executed.
|
action (BaseAction | Callable): The action instance being executed.
|
||||||
result (Any | None): The result of the action, if successful.
|
result (Any | None): The result of the action, if successful.
|
||||||
exception (Exception | None): The exception raised, if execution failed.
|
exception (BaseException | None): The exception raised, if execution failed.
|
||||||
start_time (float | None): High-resolution performance start time.
|
start_time (float | None): High-resolution performance start time.
|
||||||
end_time (float | None): High-resolution performance end time.
|
end_time (float | None): High-resolution performance end time.
|
||||||
start_wall (datetime | None): Wall-clock timestamp when execution began.
|
start_wall (datetime | None): Wall-clock timestamp when execution began.
|
||||||
@ -75,7 +75,7 @@ class ExecutionContext(BaseModel):
|
|||||||
kwargs: dict = Field(default_factory=dict)
|
kwargs: dict = Field(default_factory=dict)
|
||||||
action: Any
|
action: Any
|
||||||
result: Any | None = None
|
result: Any | None = None
|
||||||
exception: Exception | None = None
|
exception: BaseException | None = None
|
||||||
|
|
||||||
start_time: float | None = None
|
start_time: float | None = None
|
||||||
end_time: float | None = None
|
end_time: float | None = None
|
||||||
@ -207,7 +207,7 @@ class SharedContext(BaseModel):
|
|||||||
Attributes:
|
Attributes:
|
||||||
name (str): Identifier for the context (usually the parent action name).
|
name (str): Identifier for the context (usually the parent action name).
|
||||||
results (list[Any]): Captures results from each action, in order of execution.
|
results (list[Any]): Captures results from each action, in order of execution.
|
||||||
errors (list[tuple[int, Exception]]): Indexed list of errors from failed actions.
|
errors (list[tuple[int, BaseException]]): Indexed list of errors from failed actions.
|
||||||
current_index (int): Index of the currently executing action (used in chains).
|
current_index (int): Index of the currently executing action (used in chains).
|
||||||
is_parallel (bool): Whether the context is used in parallel mode (ActionGroup).
|
is_parallel (bool): Whether the context is used in parallel mode (ActionGroup).
|
||||||
shared_result (Any | None): Optional shared value available to all actions in
|
shared_result (Any | None): Optional shared value available to all actions in
|
||||||
@ -232,7 +232,7 @@ class SharedContext(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
action: Any
|
action: Any
|
||||||
results: list[Any] = Field(default_factory=list)
|
results: list[Any] = Field(default_factory=list)
|
||||||
errors: list[tuple[int, Exception]] = Field(default_factory=list)
|
errors: list[tuple[int, BaseException]] = Field(default_factory=list)
|
||||||
current_index: int = -1
|
current_index: int = -1
|
||||||
is_parallel: bool = False
|
is_parallel: bool = False
|
||||||
shared_result: Any | None = None
|
shared_result: Any | None = None
|
||||||
@ -244,7 +244,7 @@ class SharedContext(BaseModel):
|
|||||||
def add_result(self, result: Any) -> None:
|
def add_result(self, result: Any) -> None:
|
||||||
self.results.append(result)
|
self.results.append(result)
|
||||||
|
|
||||||
def add_error(self, index: int, error: Exception) -> None:
|
def add_error(self, index: int, error: BaseException) -> None:
|
||||||
self.errors.append((index, error))
|
self.errors.append((index, error))
|
||||||
|
|
||||||
def set_shared_result(self, result: Any) -> None:
|
def set_shared_result(self, result: Any) -> None:
|
||||||
|
@ -12,6 +12,7 @@ class ArgumentAction(Enum):
|
|||||||
STORE = "store"
|
STORE = "store"
|
||||||
STORE_TRUE = "store_true"
|
STORE_TRUE = "store_true"
|
||||||
STORE_FALSE = "store_false"
|
STORE_FALSE = "store_false"
|
||||||
|
STORE_BOOL_OPTIONAL = "store_bool_optional"
|
||||||
APPEND = "append"
|
APPEND = "append"
|
||||||
EXTEND = "extend"
|
EXTEND = "extend"
|
||||||
COUNT = "count"
|
COUNT = "count"
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
"""command_argument_parser.py"""
|
"""command_argument_parser.py"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
@ -13,6 +14,7 @@ from falyx.console import console
|
|||||||
from falyx.exceptions import CommandArgumentError
|
from falyx.exceptions import CommandArgumentError
|
||||||
from falyx.parser.argument import Argument
|
from falyx.parser.argument import Argument
|
||||||
from falyx.parser.argument_action import ArgumentAction
|
from falyx.parser.argument_action import ArgumentAction
|
||||||
|
from falyx.parser.parser_types import false_none, true_none
|
||||||
from falyx.parser.utils import coerce_value
|
from falyx.parser.utils import coerce_value
|
||||||
from falyx.signals import HelpSignal
|
from falyx.signals import HelpSignal
|
||||||
|
|
||||||
@ -33,6 +35,7 @@ class CommandArgumentParser:
|
|||||||
- Support for positional and keyword arguments.
|
- Support for positional and keyword arguments.
|
||||||
- Support for default values.
|
- Support for default values.
|
||||||
- Support for boolean flags.
|
- Support for boolean flags.
|
||||||
|
- Support for optional boolean flags.
|
||||||
- Exception handling for invalid arguments.
|
- Exception handling for invalid arguments.
|
||||||
- Render Help using Rich library.
|
- Render Help using Rich library.
|
||||||
"""
|
"""
|
||||||
@ -111,10 +114,23 @@ class CommandArgumentParser:
|
|||||||
return dest
|
return dest
|
||||||
|
|
||||||
def _determine_required(
|
def _determine_required(
|
||||||
self, required: bool, positional: bool, nargs: int | str | None
|
self,
|
||||||
|
required: bool,
|
||||||
|
positional: bool,
|
||||||
|
nargs: int | str | None,
|
||||||
|
action: ArgumentAction,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Determine if the argument is required."""
|
"""Determine if the argument is required."""
|
||||||
if required:
|
if required:
|
||||||
|
if action in (
|
||||||
|
ArgumentAction.STORE_TRUE,
|
||||||
|
ArgumentAction.STORE_FALSE,
|
||||||
|
ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||||
|
ArgumentAction.HELP,
|
||||||
|
):
|
||||||
|
raise CommandArgumentError(
|
||||||
|
f"Argument with action {action} cannot be required"
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
if positional:
|
if positional:
|
||||||
assert (
|
assert (
|
||||||
@ -143,6 +159,7 @@ class CommandArgumentParser:
|
|||||||
ArgumentAction.STORE_TRUE,
|
ArgumentAction.STORE_TRUE,
|
||||||
ArgumentAction.COUNT,
|
ArgumentAction.COUNT,
|
||||||
ArgumentAction.HELP,
|
ArgumentAction.HELP,
|
||||||
|
ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||||
):
|
):
|
||||||
if nargs is not None:
|
if nargs is not None:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
@ -163,9 +180,17 @@ class CommandArgumentParser:
|
|||||||
return nargs
|
return nargs
|
||||||
|
|
||||||
def _normalize_choices(
|
def _normalize_choices(
|
||||||
self, choices: Iterable | None, expected_type: Any
|
self, choices: Iterable | None, expected_type: Any, action: ArgumentAction
|
||||||
) -> list[Any]:
|
) -> list[Any]:
|
||||||
if choices is not None:
|
if choices is not None:
|
||||||
|
if action in (
|
||||||
|
ArgumentAction.STORE_TRUE,
|
||||||
|
ArgumentAction.STORE_FALSE,
|
||||||
|
ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||||
|
):
|
||||||
|
raise CommandArgumentError(
|
||||||
|
f"choices cannot be specified for {action} actions"
|
||||||
|
)
|
||||||
if isinstance(choices, dict):
|
if isinstance(choices, dict):
|
||||||
raise CommandArgumentError("choices cannot be a dict")
|
raise CommandArgumentError("choices cannot be a dict")
|
||||||
try:
|
try:
|
||||||
@ -239,6 +264,7 @@ class CommandArgumentParser:
|
|||||||
if action in (
|
if action in (
|
||||||
ArgumentAction.STORE_TRUE,
|
ArgumentAction.STORE_TRUE,
|
||||||
ArgumentAction.STORE_FALSE,
|
ArgumentAction.STORE_FALSE,
|
||||||
|
ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||||
ArgumentAction.COUNT,
|
ArgumentAction.COUNT,
|
||||||
ArgumentAction.HELP,
|
ArgumentAction.HELP,
|
||||||
):
|
):
|
||||||
@ -271,6 +297,14 @@ class CommandArgumentParser:
|
|||||||
return []
|
return []
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
elif action in (
|
||||||
|
ArgumentAction.STORE_TRUE,
|
||||||
|
ArgumentAction.STORE_FALSE,
|
||||||
|
ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||||
|
):
|
||||||
|
raise CommandArgumentError(
|
||||||
|
f"Default value cannot be set for action {action}. It is a boolean flag."
|
||||||
|
)
|
||||||
return default
|
return default
|
||||||
|
|
||||||
def _validate_flags(self, flags: tuple[str, ...]) -> None:
|
def _validate_flags(self, flags: tuple[str, ...]) -> None:
|
||||||
@ -289,6 +323,66 @@ class CommandArgumentParser:
|
|||||||
f"Flag '{flag}' must be a single character or start with '--'"
|
f"Flag '{flag}' must be a single character or start with '--'"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _register_store_bool_optional(
|
||||||
|
self,
|
||||||
|
flags: tuple[str, ...],
|
||||||
|
dest: str,
|
||||||
|
help: str,
|
||||||
|
) -> None:
|
||||||
|
if len(flags) != 1:
|
||||||
|
raise CommandArgumentError(
|
||||||
|
"store_bool_optional action can only have a single flag"
|
||||||
|
)
|
||||||
|
if not flags[0].startswith("--"):
|
||||||
|
raise CommandArgumentError(
|
||||||
|
"store_bool_optional action must use a long flag (e.g. --flag)"
|
||||||
|
)
|
||||||
|
base_flag = flags[0]
|
||||||
|
negated_flag = f"--no-{base_flag.lstrip('-')}"
|
||||||
|
|
||||||
|
argument = Argument(
|
||||||
|
flags=flags,
|
||||||
|
dest=dest,
|
||||||
|
action=ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||||
|
type=true_none,
|
||||||
|
default=None,
|
||||||
|
help=help,
|
||||||
|
)
|
||||||
|
|
||||||
|
negated_argument = Argument(
|
||||||
|
flags=(negated_flag,),
|
||||||
|
dest=dest,
|
||||||
|
action=ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||||
|
type=false_none,
|
||||||
|
default=None,
|
||||||
|
help=help,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._register_argument(argument)
|
||||||
|
self._register_argument(negated_argument)
|
||||||
|
|
||||||
|
def _register_argument(self, argument: Argument):
|
||||||
|
|
||||||
|
for flag in argument.flags:
|
||||||
|
if (
|
||||||
|
flag in self._flag_map
|
||||||
|
and not argument.action == ArgumentAction.STORE_BOOL_OPTIONAL
|
||||||
|
):
|
||||||
|
existing = self._flag_map[flag]
|
||||||
|
raise CommandArgumentError(
|
||||||
|
f"Flag '{flag}' is already used by argument '{existing.dest}'"
|
||||||
|
)
|
||||||
|
for flag in argument.flags:
|
||||||
|
self._flag_map[flag] = argument
|
||||||
|
if not argument.positional:
|
||||||
|
self._keyword[flag] = argument
|
||||||
|
self._dest_set.add(argument.dest)
|
||||||
|
self._arguments.append(argument)
|
||||||
|
if argument.positional:
|
||||||
|
self._positional[argument.dest] = argument
|
||||||
|
else:
|
||||||
|
self._keyword_list.append(argument)
|
||||||
|
|
||||||
def add_argument(
|
def add_argument(
|
||||||
self,
|
self,
|
||||||
*flags,
|
*flags,
|
||||||
@ -334,6 +428,7 @@ class CommandArgumentParser:
|
|||||||
)
|
)
|
||||||
action = self._validate_action(action, positional)
|
action = self._validate_action(action, positional)
|
||||||
resolver = self._validate_resolver(action, resolver)
|
resolver = self._validate_resolver(action, resolver)
|
||||||
|
|
||||||
nargs = self._validate_nargs(nargs, action)
|
nargs = self._validate_nargs(nargs, action)
|
||||||
default = self._resolve_default(default, action, nargs)
|
default = self._resolve_default(default, action, nargs)
|
||||||
if (
|
if (
|
||||||
@ -344,46 +439,34 @@ class CommandArgumentParser:
|
|||||||
self._validate_default_list_type(default, expected_type, dest)
|
self._validate_default_list_type(default, expected_type, dest)
|
||||||
else:
|
else:
|
||||||
self._validate_default_type(default, expected_type, dest)
|
self._validate_default_type(default, expected_type, dest)
|
||||||
choices = self._normalize_choices(choices, expected_type)
|
choices = self._normalize_choices(choices, expected_type, action)
|
||||||
if default is not None and choices and default not in choices:
|
if default is not None and choices and default not in choices:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Default value '{default}' not in allowed choices: {choices}"
|
f"Default value '{default}' not in allowed choices: {choices}"
|
||||||
)
|
)
|
||||||
required = self._determine_required(required, positional, nargs)
|
required = self._determine_required(required, positional, nargs, action)
|
||||||
if not isinstance(lazy_resolver, bool):
|
if not isinstance(lazy_resolver, bool):
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"lazy_resolver must be a boolean, got {type(lazy_resolver)}"
|
f"lazy_resolver must be a boolean, got {type(lazy_resolver)}"
|
||||||
)
|
)
|
||||||
argument = Argument(
|
if action == ArgumentAction.STORE_BOOL_OPTIONAL:
|
||||||
flags=flags,
|
self._register_store_bool_optional(flags, dest, help)
|
||||||
dest=dest,
|
|
||||||
action=action,
|
|
||||||
type=expected_type,
|
|
||||||
default=default,
|
|
||||||
choices=choices,
|
|
||||||
required=required,
|
|
||||||
help=help,
|
|
||||||
nargs=nargs,
|
|
||||||
positional=positional,
|
|
||||||
resolver=resolver,
|
|
||||||
lazy_resolver=lazy_resolver,
|
|
||||||
)
|
|
||||||
for flag in flags:
|
|
||||||
if flag in self._flag_map:
|
|
||||||
existing = self._flag_map[flag]
|
|
||||||
raise CommandArgumentError(
|
|
||||||
f"Flag '{flag}' is already used by argument '{existing.dest}'"
|
|
||||||
)
|
|
||||||
for flag in flags:
|
|
||||||
self._flag_map[flag] = argument
|
|
||||||
if not positional:
|
|
||||||
self._keyword[flag] = argument
|
|
||||||
self._dest_set.add(dest)
|
|
||||||
self._arguments.append(argument)
|
|
||||||
if positional:
|
|
||||||
self._positional[dest] = argument
|
|
||||||
else:
|
else:
|
||||||
self._keyword_list.append(argument)
|
argument = Argument(
|
||||||
|
flags=flags,
|
||||||
|
dest=dest,
|
||||||
|
action=action,
|
||||||
|
type=expected_type,
|
||||||
|
default=default,
|
||||||
|
choices=choices,
|
||||||
|
required=required,
|
||||||
|
help=help,
|
||||||
|
nargs=nargs,
|
||||||
|
positional=positional,
|
||||||
|
resolver=resolver,
|
||||||
|
lazy_resolver=lazy_resolver,
|
||||||
|
)
|
||||||
|
self._register_argument(argument)
|
||||||
|
|
||||||
def get_argument(self, dest: str) -> Argument | None:
|
def get_argument(self, dest: str) -> Argument | None:
|
||||||
return next((a for a in self._arguments if a.dest == dest), None)
|
return next((a for a in self._arguments if a.dest == dest), None)
|
||||||
@ -624,6 +707,10 @@ class CommandArgumentParser:
|
|||||||
result[spec.dest] = False
|
result[spec.dest] = False
|
||||||
consumed_indices.add(i)
|
consumed_indices.add(i)
|
||||||
i += 1
|
i += 1
|
||||||
|
elif action == ArgumentAction.STORE_BOOL_OPTIONAL:
|
||||||
|
result[spec.dest] = spec.type(True)
|
||||||
|
consumed_indices.add(i)
|
||||||
|
i += 1
|
||||||
elif action == ArgumentAction.COUNT:
|
elif action == ArgumentAction.COUNT:
|
||||||
result[spec.dest] = result.get(spec.dest, 0) + 1
|
result[spec.dest] = result.get(spec.dest, 0) + 1
|
||||||
consumed_indices.add(i)
|
consumed_indices.add(i)
|
||||||
@ -889,11 +976,28 @@ class CommandArgumentParser:
|
|||||||
help_text = f"\n{'':<33}{help_text}"
|
help_text = f"\n{'':<33}{help_text}"
|
||||||
self.console.print(f"{arg_line}{help_text}")
|
self.console.print(f"{arg_line}{help_text}")
|
||||||
self.console.print("[bold]options:[/bold]")
|
self.console.print("[bold]options:[/bold]")
|
||||||
|
arg_groups = defaultdict(list)
|
||||||
for arg in self._keyword_list:
|
for arg in self._keyword_list:
|
||||||
flags = ", ".join(arg.flags)
|
arg_groups[arg.dest].append(arg)
|
||||||
flags_choice = f"{flags} {arg.get_choice_text()}"
|
|
||||||
|
for group in arg_groups.values():
|
||||||
|
if len(group) == 2 and all(
|
||||||
|
arg.action == ArgumentAction.STORE_BOOL_OPTIONAL for arg in group
|
||||||
|
):
|
||||||
|
# Merge --flag / --no-flag pair into single help line for STORE_BOOL_OPTIONAL
|
||||||
|
all_flags = tuple(
|
||||||
|
sorted(
|
||||||
|
(arg.flags[0] for arg in group),
|
||||||
|
key=lambda f: f.startswith("--no-"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
all_flags = group[0].flags
|
||||||
|
|
||||||
|
flags = ", ".join(all_flags)
|
||||||
|
flags_choice = f"{flags} {group[0].get_choice_text()}"
|
||||||
arg_line = f" {flags_choice:<30} "
|
arg_line = f" {flags_choice:<30} "
|
||||||
help_text = arg.help or ""
|
help_text = group[0].help or ""
|
||||||
if help_text and len(flags_choice) > 30:
|
if help_text and len(flags_choice) > 30:
|
||||||
help_text = f"\n{'':<33}{help_text}"
|
help_text = f"\n{'':<33}{help_text}"
|
||||||
self.console.print(f"{arg_line}{help_text}")
|
self.console.print(f"{arg_line}{help_text}")
|
||||||
|
15
falyx/parser/parser_types.py
Normal file
15
falyx/parser/parser_types.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
|
"""parser_types.py"""
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def true_none(value: Any) -> bool | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def false_none(value: Any) -> bool | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return False
|
@ -15,9 +15,9 @@ def coerce_bool(value: str) -> bool:
|
|||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return value
|
return value
|
||||||
value = value.strip().lower()
|
value = value.strip().lower()
|
||||||
if value in {"true", "1", "yes", "on"}:
|
if value in {"true", "t", "1", "yes", "on"}:
|
||||||
return True
|
return True
|
||||||
elif value in {"false", "0", "no", "off"}:
|
elif value in {"false", "f", "0", "no", "off"}:
|
||||||
return False
|
return False
|
||||||
return bool(value)
|
return bool(value)
|
||||||
|
|
||||||
|
@ -10,6 +10,13 @@ class FlowSignal(BaseException):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BreakChainSignal(FlowSignal):
|
||||||
|
"""Raised to break the current action chain and return to the previous context."""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "Break chain signal received."):
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
class QuitSignal(FlowSignal):
|
class QuitSignal(FlowSignal):
|
||||||
"""Raised to signal an immediate exit from the CLI framework."""
|
"""Raised to signal an immediate exit from the CLI framework."""
|
||||||
|
|
||||||
|
@ -1 +1 @@
|
|||||||
__version__ = "0.1.59"
|
__version__ = "0.1.60"
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "falyx"
|
name = "falyx"
|
||||||
version = "0.1.59"
|
version = "0.1.60"
|
||||||
description = "Reliable and introspectable async CLI action framework."
|
description = "Reliable and introspectable async CLI action framework."
|
||||||
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
@ -98,13 +98,17 @@ def test_enable_retry_not_action():
|
|||||||
cmd = Command(
|
cmd = Command(
|
||||||
key="C",
|
key="C",
|
||||||
description="Retry action",
|
description="Retry action",
|
||||||
action=DummyInputAction,
|
action=DummyInputAction(
|
||||||
|
name="dummy_input_action",
|
||||||
|
),
|
||||||
retry=True,
|
retry=True,
|
||||||
)
|
)
|
||||||
assert cmd.retry is True
|
assert cmd.retry is True
|
||||||
with pytest.raises(Exception) as exc_info:
|
with pytest.raises(Exception) as exc_info:
|
||||||
assert cmd.action.retry_policy.enabled is False
|
assert cmd.action.retry_policy.enabled is False
|
||||||
assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value)
|
assert "'DummyInputAction' object has no attribute 'retry_policy'" in str(
|
||||||
|
exc_info.value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_chain_retry_all():
|
def test_chain_retry_all():
|
||||||
@ -134,13 +138,17 @@ def test_chain_retry_all_not_base_action():
|
|||||||
cmd = Command(
|
cmd = Command(
|
||||||
key="E",
|
key="E",
|
||||||
description="Chain with retry",
|
description="Chain with retry",
|
||||||
action=DummyInputAction,
|
action=DummyInputAction(
|
||||||
|
name="dummy_input_action",
|
||||||
|
),
|
||||||
retry_all=True,
|
retry_all=True,
|
||||||
)
|
)
|
||||||
assert cmd.retry_all is True
|
assert cmd.retry_all is True
|
||||||
with pytest.raises(Exception) as exc_info:
|
with pytest.raises(Exception) as exc_info:
|
||||||
assert cmd.action.retry_policy.enabled is False
|
assert cmd.action.retry_policy.enabled is False
|
||||||
assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value)
|
assert "'DummyInputAction' object has no attribute 'retry_policy'" in str(
|
||||||
|
exc_info.value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from falyx.action import Action, SelectionAction
|
from falyx.action import Action
|
||||||
from falyx.exceptions import CommandArgumentError
|
from falyx.exceptions import CommandArgumentError
|
||||||
from falyx.parser import ArgumentAction, CommandArgumentParser
|
from falyx.parser import ArgumentAction, CommandArgumentParser
|
||||||
|
|
||||||
@ -217,11 +217,3 @@ async def test_action_with_default_and_value_positional():
|
|||||||
|
|
||||||
with pytest.raises(CommandArgumentError):
|
with pytest.raises(CommandArgumentError):
|
||||||
await parser.parse_args(["be"])
|
await parser.parse_args(["be"])
|
||||||
|
|
||||||
|
|
||||||
# @pytest.mark.asyncio
|
|
||||||
# async def test_selection_action():
|
|
||||||
# parser = CommandArgumentParser()
|
|
||||||
# action = SelectionAction("select", selections=["a", "b", "c"])
|
|
||||||
# parser.add_argument("--select", action=ArgumentAction.ACTION, resolver=action)
|
|
||||||
# args = await parser.parse_args(["--select"])
|
|
||||||
|
@ -8,4 +8,4 @@ def test_argument_action():
|
|||||||
assert action != "invalid_action"
|
assert action != "invalid_action"
|
||||||
assert action.value == "append"
|
assert action.value == "append"
|
||||||
assert str(action) == "append"
|
assert str(action) == "append"
|
||||||
assert len(ArgumentAction.choices()) == 8
|
assert len(ArgumentAction.choices()) == 9
|
||||||
|
83
tests/test_parsers/test_store_bool_optional.py
Normal file
83
tests/test_parsers/test_store_bool_optional.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from falyx.exceptions import CommandArgumentError
|
||||||
|
from falyx.parser import ArgumentAction, CommandArgumentParser
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_store_bool_optional_true():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug",
|
||||||
|
action=ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||||
|
help="Enable debug mode.",
|
||||||
|
)
|
||||||
|
args = await parser.parse_args(["--debug"])
|
||||||
|
assert args["debug"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_store_bool_optional_false():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug",
|
||||||
|
action=ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||||
|
help="Enable debug mode.",
|
||||||
|
)
|
||||||
|
args = await parser.parse_args(["--no-debug"])
|
||||||
|
assert args["debug"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_store_bool_optional_default_none():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug",
|
||||||
|
action=ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||||
|
help="Enable debug mode.",
|
||||||
|
)
|
||||||
|
args = await parser.parse_args([])
|
||||||
|
assert args["debug"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_store_bool_optional_flag_order():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action=ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||||
|
help="Run without making changes.",
|
||||||
|
)
|
||||||
|
args = await parser.parse_args(["--dry-run"])
|
||||||
|
assert args["dry_run"] is True
|
||||||
|
args = await parser.parse_args(["--no-dry-run"])
|
||||||
|
assert args["dry_run"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_bool_optional_requires_long_flag():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument(
|
||||||
|
"-d", action=ArgumentAction.STORE_BOOL_OPTIONAL, help="Invalid"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_bool_optional_disallows_multiple_flags():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument("--debug", "-d", action=ArgumentAction.STORE_BOOL_OPTIONAL)
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_bool_optional_duplicate_dest():
|
||||||
|
parser = CommandArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug",
|
||||||
|
action=ArgumentAction.STORE_BOOL_OPTIONAL,
|
||||||
|
help="Enable debug mode.",
|
||||||
|
)
|
||||||
|
with pytest.raises(CommandArgumentError):
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug",
|
||||||
|
action=ArgumentAction.STORE_TRUE,
|
||||||
|
help="Conflicting debug option.",
|
||||||
|
)
|
Reference in New Issue
Block a user