Make auto_args default fallback, integrate io_actions with argument parsing

This commit is contained in:
Roland Thomas Jr 2025-05-19 20:03:04 -04:00
parent 4fa6e3bf1f
commit 3c0a81359c
Signed by: roland
GPG Key ID: 7C3C2B085A4C2872
16 changed files with 125 additions and 87 deletions

View File

@ -24,7 +24,6 @@ cmd = Command(
key="G", key="G",
description="Greet someone with multiple variations.", description="Greet someone with multiple variations.",
action=group, action=group,
auto_args=True,
arg_metadata={ arg_metadata={
"name": { "name": {
"help": "The name of the person to greet.", "help": "The name of the person to greet.",

View File

@ -1,14 +1,18 @@
import asyncio import asyncio
from falyx import Action, Falyx from falyx import Action, ChainedAction, Falyx
from falyx.utils import setup_logging
setup_logging()
async def deploy(service: str, region: str = "us-east-1", verbose: bool = False): async def deploy(service: str, region: str = "us-east-1", verbose: bool = False) -> str:
if verbose: if verbose:
print(f"Deploying {service} to {region}...") print(f"Deploying {service} to {region}...")
await asyncio.sleep(2) await asyncio.sleep(2)
if verbose: if verbose:
print(f"{service} deployed successfully!") print(f"{service} deployed successfully!")
return f"{service} deployed to {region}"
flx = Falyx("Deployment CLI") flx = Falyx("Deployment CLI")
@ -21,7 +25,6 @@ flx.add_command(
name="deploy_service", name="deploy_service",
action=deploy, action=deploy,
), ),
auto_args=True,
arg_metadata={ arg_metadata={
"service": "Service name", "service": "Service name",
"region": {"help": "Deployment region", "choices": ["us-east-1", "us-west-2"]}, "region": {"help": "Deployment region", "choices": ["us-east-1", "us-west-2"]},
@ -29,4 +32,23 @@ flx.add_command(
}, },
) )
deploy_chain = ChainedAction(
name="DeployChain",
actions=[
Action(name="deploy_service", action=deploy),
Action(
name="notify",
action=lambda last_result: print(f"Notification: {last_result}"),
),
],
auto_inject=True,
)
flx.add_command(
key="N",
aliases=["notify"],
description="Deploy a service and notify.",
action=deploy_chain,
)
asyncio.run(flx.run()) asyncio.run(flx.run())

View File

@ -47,6 +47,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.parsers.utils import same_argument_definitions
from falyx.retry import RetryHandler, RetryPolicy from falyx.retry import RetryHandler, RetryPolicy
from falyx.themes import OneColors from falyx.themes import OneColors
from falyx.utils import ensure_async from falyx.utils import ensure_async
@ -101,6 +102,14 @@ class BaseAction(ABC):
async def preview(self, parent: Tree | None = None): async def preview(self, parent: Tree | None = None):
raise NotImplementedError("preview must be implemented by subclasses") raise NotImplementedError("preview must be implemented by subclasses")
@abstractmethod
def get_infer_target(self) -> Callable[..., Any] | None:
"""
Returns the callable to be used for argument inference.
By default, it returns None.
"""
raise NotImplementedError("get_infer_target must be implemented by subclasses")
def set_options_manager(self, options_manager: OptionsManager) -> None: def set_options_manager(self, options_manager: OptionsManager) -> None:
self.options_manager = options_manager self.options_manager = options_manager
@ -246,6 +255,13 @@ class Action(BaseAction):
if policy.enabled: if policy.enabled:
self.enable_retry() self.enable_retry()
def get_infer_target(self) -> Callable[..., Any]:
"""
Returns the callable to be used for argument inference.
By default, it returns the action itself.
"""
return self.action
async def _run(self, *args, **kwargs) -> Any: async def _run(self, *args, **kwargs) -> Any:
combined_args = args + self.args combined_args = args + self.args
combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs}) combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs})
@ -477,6 +493,14 @@ class ChainedAction(BaseAction, ActionListMixin):
if hasattr(action, "register_teardown") and callable(action.register_teardown): if hasattr(action, "register_teardown") and callable(action.register_teardown):
action.register_teardown(self.hooks) action.register_teardown(self.hooks)
def get_infer_target(self) -> Callable[..., Any] | None:
if self.actions:
return self.actions[0].get_infer_target()
return None
def _clear_args(self):
return (), {}
async def _run(self, *args, **kwargs) -> list[Any]: async def _run(self, *args, **kwargs) -> list[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.")
@ -505,12 +529,8 @@ class ChainedAction(BaseAction, ActionListMixin):
continue continue
shared_context.current_index = index shared_context.current_index = index
prepared = action.prepare(shared_context, self.options_manager) prepared = action.prepare(shared_context, self.options_manager)
last_result = shared_context.last_result()
try: try:
if self.requires_io_injection() and last_result is not None: result = await prepared(*args, **updated_kwargs)
result = await prepared(**{prepared.inject_into: last_result})
else:
result = await prepared(*args, **updated_kwargs)
except Exception as error: except Exception as error:
if index + 1 < len(self.actions) and isinstance( if index + 1 < len(self.actions) and isinstance(
self.actions[index + 1], FallbackAction self.actions[index + 1], FallbackAction
@ -529,6 +549,7 @@ class ChainedAction(BaseAction, ActionListMixin):
fallback._skip_in_chain = True fallback._skip_in_chain = True
else: else:
raise raise
args, updated_kwargs = self._clear_args()
shared_context.add_result(result) shared_context.add_result(result)
context.extra["results"].append(result) context.extra["results"].append(result)
context.extra["rollback_stack"].append(prepared) context.extra["rollback_stack"].append(prepared)
@ -669,6 +690,16 @@ class ActionGroup(BaseAction, ActionListMixin):
if hasattr(action, "register_teardown") and callable(action.register_teardown): if hasattr(action, "register_teardown") and callable(action.register_teardown):
action.register_teardown(self.hooks) action.register_teardown(self.hooks)
def get_infer_target(self) -> Callable[..., Any] | None:
arg_defs = same_argument_definitions(self.actions)
if arg_defs:
return self.actions[0].get_infer_target()
logger.debug(
"[%s] auto_args disabled: mismatched ActionGroup arguments",
self.name,
)
return None
async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]: async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
shared_context = SharedContext(name=self.name, action=self, is_parallel=True) shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
if self.shared_context: if self.shared_context:
@ -787,8 +818,11 @@ class ProcessAction(BaseAction):
self.executor = executor or ProcessPoolExecutor() self.executor = executor or ProcessPoolExecutor()
self.is_retryable = True self.is_retryable = True
async def _run(self, *args, **kwargs): def get_infer_target(self) -> Callable[..., Any] | None:
if self.inject_last_result: return self.action
async def _run(self, *args, **kwargs) -> Any:
if self.inject_last_result and self.shared_context:
last_result = self.shared_context.last_result() last_result = self.shared_context.last_result()
if not self._validate_pickleable(last_result): if not self._validate_pickleable(last_result):
raise ValueError( raise ValueError(

View File

@ -1,6 +1,6 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""action_factory.py""" """action_factory.py"""
from typing import Any from typing import Any, Callable
from rich.tree import Tree from rich.tree import Tree
@ -55,6 +55,9 @@ class ActionFactoryAction(BaseAction):
def factory(self, value: ActionFactoryProtocol): def factory(self, value: ActionFactoryProtocol):
self._factory = ensure_async(value) self._factory = ensure_async(value)
def get_infer_target(self) -> Callable[..., Any]:
return self.factory
async def _run(self, *args, **kwargs) -> Any: async def _run(self, *args, **kwargs) -> Any:
updated_kwargs = self._maybe_inject_last_result(kwargs) updated_kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext( context = ExecutionContext(

View File

@ -19,7 +19,7 @@ import asyncio
import shlex import shlex
import subprocess import subprocess
import sys import sys
from typing import Any from typing import Any, Callable
from rich.tree import Tree from rich.tree import Tree
@ -81,15 +81,15 @@ class BaseIOAction(BaseAction):
def to_output(self, result: Any) -> str | bytes: def to_output(self, result: Any) -> str | bytes:
raise NotImplementedError raise NotImplementedError
async def _resolve_input(self, kwargs: dict[str, Any]) -> str | bytes: async def _resolve_input(
last_result = kwargs.pop(self.inject_into, None) self, args: tuple[Any], kwargs: dict[str, Any]
) -> str | bytes:
data = await self._read_stdin() data = await self._read_stdin()
if data: if data:
return self.from_input(data) return self.from_input(data)
if last_result is not None: if len(args) == 1:
return last_result return self.from_input(args[0])
if self.inject_last_result and self.shared_context: if self.inject_last_result and self.shared_context:
return self.shared_context.last_result() return self.shared_context.last_result()
@ -99,6 +99,9 @@ class BaseIOAction(BaseAction):
) )
raise FalyxError("No input provided and no last result to inject.") raise FalyxError("No input provided and no last result to inject.")
def get_infer_target(self) -> Callable[..., Any] | None:
return None
async def __call__(self, *args, **kwargs): async def __call__(self, *args, **kwargs):
context = ExecutionContext( context = ExecutionContext(
name=self.name, name=self.name,
@ -117,8 +120,8 @@ class BaseIOAction(BaseAction):
pass pass
result = getattr(self, "_last_result", None) result = getattr(self, "_last_result", None)
else: else:
parsed_input = await self._resolve_input(kwargs) parsed_input = await self._resolve_input(args, kwargs)
result = await self._run(parsed_input, *args, **kwargs) result = await self._run(parsed_input)
output = self.to_output(result) output = self.to_output(result)
await self._write_stdout(output) await self._write_stdout(output)
context.result = result context.result = result
@ -220,6 +223,11 @@ class ShellAction(BaseIOAction):
) )
return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip() return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip()
def get_infer_target(self) -> Callable[..., Any] | None:
if sys.stdin.isatty():
return self._run
return None
async def _run(self, parsed_input: str) -> str: async def _run(self, parsed_input: str) -> str:
# Replace placeholder in template, or use raw input as full command # Replace placeholder in template, or use raw input as full command
command = self.command_template.format(parsed_input) command = self.command_template.format(parsed_input)

View File

@ -73,6 +73,9 @@ class MenuAction(BaseAction):
table.add_row(*row) table.add_row(*row)
return table return table
def get_infer_target(self) -> None:
return None
async def _run(self, *args, **kwargs) -> Any: async def _run(self, *args, **kwargs) -> Any:
kwargs = self._maybe_inject_last_result(kwargs) kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext( context = ExecutionContext(

View File

@ -121,6 +121,9 @@ class SelectFileAction(BaseAction):
logger.warning("[ERROR] Failed to parse %s: %s", file.name, error) logger.warning("[ERROR] Failed to parse %s: %s", file.name, error)
return options return options
def get_infer_target(self) -> None:
return None
async def _run(self, *args, **kwargs) -> Any: async def _run(self, *args, **kwargs) -> Any:
context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self) context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self)
context.start_timer() context.start_timer()

View File

@ -85,6 +85,9 @@ class SelectionAction(BaseAction):
f"got {type(value).__name__}" f"got {type(value).__name__}"
) )
def get_infer_target(self) -> None:
return None
async def _run(self, *args, **kwargs) -> Any: async def _run(self, *args, **kwargs) -> Any:
kwargs = self._maybe_inject_last_result(kwargs) kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext( context = ExecutionContext(

View File

@ -43,6 +43,9 @@ class UserInputAction(BaseAction):
self.console = console or Console(color_system="auto") self.console = console or Console(color_system="auto")
self.prompt_session = prompt_session or PromptSession() self.prompt_session = prompt_session or PromptSession()
def get_infer_target(self) -> None:
return None
async def _run(self, *args, **kwargs) -> str: async def _run(self, *args, **kwargs) -> str:
context = ExecutionContext( context = ExecutionContext(
name=self.name, name=self.name,

View File

@ -27,13 +27,7 @@ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
from rich.console import Console from rich.console import Console
from rich.tree import Tree from rich.tree import Tree
from falyx.action.action import ( from falyx.action.action import Action, ActionGroup, BaseAction, ChainedAction
Action,
ActionGroup,
BaseAction,
ChainedAction,
ProcessAction,
)
from falyx.action.io_action import BaseIOAction from falyx.action.io_action import BaseIOAction
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.debug import register_debug_hooks from falyx.debug import register_debug_hooks
@ -41,11 +35,8 @@ from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType from falyx.hook_manager import 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.parsers import ( from falyx.parsers.argparse import CommandArgumentParser
CommandArgumentParser, from falyx.parsers.signature import infer_args_from_func
infer_args_from_func,
same_argument_definitions,
)
from falyx.prompt_utils import confirm_async, should_prompt_user from falyx.prompt_utils import confirm_async, should_prompt_user
from falyx.protocols import ArgParserProtocol from falyx.protocols import ArgParserProtocol
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
@ -116,7 +107,7 @@ class Command(BaseModel):
key: str key: str
description: str description: str
action: BaseAction | Callable[[Any], Any] action: BaseAction | Callable[..., Any]
args: tuple = () args: tuple = ()
kwargs: dict[str, Any] = Field(default_factory=dict) kwargs: dict[str, Any] = Field(default_factory=dict)
hidden: bool = False hidden: bool = False
@ -145,7 +136,7 @@ class Command(BaseModel):
argument_config: Callable[[CommandArgumentParser], None] | None = None argument_config: Callable[[CommandArgumentParser], None] | None = None
custom_parser: ArgParserProtocol | None = None custom_parser: ArgParserProtocol | None = None
custom_help: Callable[[], str | None] | None = None custom_help: Callable[[], str | None] | None = None
auto_args: bool = False auto_args: bool = True
arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict) arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
_context: ExecutionContext | None = PrivateAttr(default=None) _context: ExecutionContext | None = PrivateAttr(default=None)
@ -195,24 +186,9 @@ class Command(BaseModel):
elif self.argument_config: elif self.argument_config:
self.argument_config(self.arg_parser) self.argument_config(self.arg_parser)
elif self.auto_args: elif self.auto_args:
if isinstance(self.action, (Action, ProcessAction)): if isinstance(self.action, BaseAction):
return infer_args_from_func(self.action.action, self.arg_metadata) return infer_args_from_func(
elif isinstance(self.action, ChainedAction): self.action.get_infer_target(), self.arg_metadata
if self.action.actions:
action = self.action.actions[0]
if isinstance(action, Action):
return infer_args_from_func(action.action, self.arg_metadata)
elif callable(action):
return infer_args_from_func(action, self.arg_metadata)
elif isinstance(self.action, ActionGroup):
arg_defs = same_argument_definitions(
self.action.actions, self.arg_metadata
)
if arg_defs:
return arg_defs
logger.debug(
"[Command:%s] auto_args disabled: mismatched ActionGroup arguments",
self.key,
) )
elif callable(self.action): elif callable(self.action):
return infer_args_from_func(self.action, self.arg_metadata) return infer_args_from_func(self.action, self.arg_metadata)

View File

@ -63,7 +63,7 @@ from falyx.protocols import ArgParserProtocol
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
from falyx.signals import BackSignal, CancelSignal, FlowSignal, HelpSignal, QuitSignal from falyx.signals import BackSignal, CancelSignal, FlowSignal, HelpSignal, QuitSignal
from falyx.themes import OneColors, get_nord_theme from falyx.themes import OneColors, get_nord_theme
from falyx.utils import CaseInsensitiveDict, _noop, chunks, get_program_invocation from falyx.utils import CaseInsensitiveDict, _noop, chunks
from falyx.version import __version__ from falyx.version import __version__
@ -158,8 +158,8 @@ class Falyx:
force_confirm: bool = False, force_confirm: bool = False,
cli_args: Namespace | None = None, cli_args: Namespace | None = None,
options: OptionsManager | None = None, options: OptionsManager | None = None,
render_menu: Callable[["Falyx"], None] | None = None, render_menu: Callable[[Falyx], None] | None = None,
custom_table: Callable[["Falyx"], Table] | Table | None = None, custom_table: Callable[[Falyx], Table] | Table | None = None,
) -> None: ) -> None:
"""Initializes the Falyx object.""" """Initializes the Falyx object."""
self.title: str | Markdown = title self.title: str | Markdown = title
@ -183,8 +183,8 @@ class Falyx:
self._never_prompt: bool = never_prompt self._never_prompt: bool = never_prompt
self._force_confirm: bool = force_confirm self._force_confirm: bool = force_confirm
self.cli_args: Namespace | None = cli_args self.cli_args: Namespace | None = cli_args
self.render_menu: Callable[["Falyx"], None] | None = render_menu self.render_menu: Callable[[Falyx], None] | None = render_menu
self.custom_table: Callable[["Falyx"], Table] | Table | None = custom_table self.custom_table: Callable[[Falyx], Table] | Table | None = custom_table
self.validate_options(cli_args, options) self.validate_options(cli_args, options)
self._prompt_session: PromptSession | None = None self._prompt_session: PromptSession | None = None
self.mode = FalyxMode.MENU self.mode = FalyxMode.MENU
@ -526,7 +526,7 @@ class Falyx:
key: str = "X", key: str = "X",
description: str = "Exit", description: str = "Exit",
aliases: list[str] | None = None, aliases: list[str] | None = None,
action: Callable[[Any], Any] | None = None, action: Callable[..., Any] | None = None,
style: str = OneColors.DARK_RED, style: str = OneColors.DARK_RED,
confirm: bool = False, confirm: bool = False,
confirm_message: str = "Are you sure?", confirm_message: str = "Are you sure?",
@ -580,7 +580,7 @@ class Falyx:
self, self,
key: str, key: str,
description: str, description: str,
action: BaseAction | Callable[[Any], Any], action: BaseAction | Callable[..., Any],
*, *,
args: tuple = (), args: tuple = (),
kwargs: dict[str, Any] | None = None, kwargs: dict[str, Any] | None = None,
@ -614,7 +614,7 @@ class Falyx:
argument_config: Callable[[CommandArgumentParser], None] | None = None, argument_config: Callable[[CommandArgumentParser], None] | None = None,
custom_parser: ArgParserProtocol | None = None, custom_parser: ArgParserProtocol | None = None,
custom_help: Callable[[], str | None] | None = None, custom_help: Callable[[], str | None] | None = None,
auto_args: bool = False, auto_args: bool = True,
arg_metadata: dict[str, str | dict[str, Any]] | None = None, arg_metadata: dict[str, str | dict[str, Any]] | None = None,
) -> Command: ) -> Command:
"""Adds an command to the menu, preventing duplicates.""" """Adds an command to the menu, preventing duplicates."""
@ -844,15 +844,6 @@ class Falyx:
await selected_command.preview() await selected_command.preview()
return True return True
if selected_command.requires_input:
program = get_program_invocation()
self.console.print(
f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires"
f" input and must be run via [{OneColors.MAGENTA}]'{program} run"
f"'[{OneColors.LIGHT_YELLOW}] with proper piping or arguments.[/]"
)
return True
self.last_run_command = selected_command self.last_run_command = selected_command
if selected_command == self.exit_command: if selected_command == self.exit_command:

View File

@ -7,8 +7,6 @@ Licensed under the MIT License. See LICENSE file for details.
from .argparse import Argument, ArgumentAction, CommandArgumentParser from .argparse import Argument, ArgumentAction, CommandArgumentParser
from .parsers import FalyxParsers, get_arg_parsers from .parsers import FalyxParsers, get_arg_parsers
from .signature import infer_args_from_func
from .utils import same_argument_definitions
__all__ = [ __all__ = [
"Argument", "Argument",
@ -16,6 +14,4 @@ __all__ = [
"CommandArgumentParser", "CommandArgumentParser",
"get_arg_parsers", "get_arg_parsers",
"FalyxParsers", "FalyxParsers",
"infer_args_from_func",
"same_argument_definitions",
] ]

View File

@ -1,17 +1,20 @@
import inspect import inspect
from typing import Any, Callable from typing import Any, Callable
from falyx import logger from falyx.logger import logger
def infer_args_from_func( def infer_args_from_func(
func: Callable[[Any], Any], func: Callable[[Any], Any] | None,
arg_metadata: dict[str, str | dict[str, Any]] | None = None, arg_metadata: dict[str, str | dict[str, Any]] | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """
Infer argument definitions from a callable's signature. Infer argument definitions from a callable's signature.
Returns a list of kwargs suitable for CommandArgumentParser.add_argument. Returns a list of kwargs suitable for CommandArgumentParser.add_argument.
""" """
if not callable(func):
logger.debug("Provided argument is not callable: %s", func)
return []
arg_metadata = arg_metadata or {} arg_metadata = arg_metadata or {}
signature = inspect.signature(func) signature = inspect.signature(func)
arg_defs = [] arg_defs = []

View File

@ -1,7 +1,6 @@
from typing import Any from typing import Any
from falyx import logger from falyx import logger
from falyx.action.action import Action, ChainedAction, ProcessAction
from falyx.parsers.signature import infer_args_from_func from falyx.parsers.signature import infer_args_from_func
@ -9,17 +8,12 @@ def same_argument_definitions(
actions: list[Any], actions: list[Any],
arg_metadata: dict[str, str | dict[str, Any]] | None = None, arg_metadata: dict[str, str | dict[str, Any]] | None = None,
) -> list[dict[str, Any]] | None: ) -> list[dict[str, Any]] | None:
from falyx.action.action import BaseAction
arg_sets = [] arg_sets = []
for action in actions: for action in actions:
if isinstance(action, (Action, ProcessAction)): if isinstance(action, BaseAction):
arg_defs = infer_args_from_func(action.action, arg_metadata) arg_defs = infer_args_from_func(action.get_infer_target(), arg_metadata)
elif isinstance(action, ChainedAction):
if action.actions:
action = action.actions[0]
if isinstance(action, Action):
arg_defs = infer_args_from_func(action.action, arg_metadata)
elif callable(action):
arg_defs = infer_args_from_func(action, arg_metadata)
elif callable(action): elif callable(action):
arg_defs = infer_args_from_func(action, arg_metadata) arg_defs = infer_args_from_func(action, arg_metadata)
else: else:

View File

@ -1 +1 @@
__version__ = "0.1.29" __version__ = "0.1.30"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.29" version = "0.1.30"
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"