feat(help): add invocation-aware path rendering for nested CLI help
- introduce InvocationContext and InvocationSegment for styled invocation paths - thread invocation_context through command arg resolution and help/tldr rendering - render CLI and namespace help from routed context instead of static program formatting - support per-segment styling for nested namespaces and command paths - rebase help target context for `help -k` so usage matches the target command path - clean up context module docs and remove old invocation path formatting helper
This commit is contained in:
@@ -51,7 +51,7 @@ from rich.tree import Tree
|
||||
from falyx.action.action import Action
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.console import console
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.context import ExecutionContext, InvocationContext
|
||||
from falyx.debug import register_debug_hooks
|
||||
from falyx.exceptions import CommandArgumentError, NotAFalyxError
|
||||
from falyx.execution_option import ExecutionOption
|
||||
@@ -213,7 +213,10 @@ class Command(BaseModel):
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
async def resolve_args(
|
||||
self, raw_args: list[str] | str, from_validate: bool = False
|
||||
self,
|
||||
raw_args: list[str] | str,
|
||||
from_validate: bool = False,
|
||||
invocation_context: InvocationContext | None = None,
|
||||
) -> tuple[tuple, dict, dict]:
|
||||
"""Parse CLI arguments into execution-ready components.
|
||||
|
||||
@@ -292,7 +295,9 @@ class Command(BaseModel):
|
||||
)
|
||||
|
||||
return await self.arg_parser.parse_args_split(
|
||||
raw_args, from_validate=from_validate
|
||||
raw_args,
|
||||
from_validate=from_validate,
|
||||
invocation_context=invocation_context,
|
||||
)
|
||||
|
||||
@field_validator("action", mode="before")
|
||||
@@ -483,12 +488,15 @@ class Command(BaseModel):
|
||||
if not self.arg_parser:
|
||||
return "No arguments defined."
|
||||
|
||||
command_keys_text = self.arg_parser.get_command_keys_text(plain_text=True)
|
||||
options_text = self.arg_parser.get_options_text(plain_text=True)
|
||||
command_keys_text = self.arg_parser.get_command_keys_text()
|
||||
options_text = self.arg_parser.get_options_text()
|
||||
return f" {command_keys_text:<20} {options_text} "
|
||||
|
||||
@property
|
||||
def help_signature(self) -> tuple[str, str, str]:
|
||||
def help_signature(
|
||||
self,
|
||||
invocation_context: InvocationContext | None = None,
|
||||
) -> tuple[str, str, str]:
|
||||
"""Return a formatted help signature for display.
|
||||
|
||||
This property provides the core information used to render command help
|
||||
@@ -519,7 +527,7 @@ class Command(BaseModel):
|
||||
- Formatting may vary depending on CLI vs menu mode.
|
||||
"""
|
||||
if self.arg_parser and not self.simple_help_signature:
|
||||
usage = self.arg_parser.get_usage()
|
||||
usage = self.arg_parser.get_usage(invocation_context=invocation_context)
|
||||
description = f"[dim]{self.help_text or self.description}[/dim]"
|
||||
if self.tags:
|
||||
tags = f"[dim]Tags: {', '.join(self.tags)}[/dim]"
|
||||
@@ -541,7 +549,7 @@ class Command(BaseModel):
|
||||
if self._context:
|
||||
self._context.log_summary()
|
||||
|
||||
def render_help(self) -> bool:
|
||||
def render_help(self, invocation_context: InvocationContext | None = None) -> bool:
|
||||
"""Display the help message for the command."""
|
||||
if callable(self.custom_help):
|
||||
output = self.custom_help()
|
||||
@@ -549,11 +557,11 @@ class Command(BaseModel):
|
||||
console.print(output)
|
||||
return True
|
||||
if isinstance(self.arg_parser, CommandArgumentParser):
|
||||
self.arg_parser.render_help()
|
||||
self.arg_parser.render_help(invocation_context=invocation_context)
|
||||
return True
|
||||
return False
|
||||
|
||||
def render_tldr(self) -> bool:
|
||||
def render_tldr(self, invocation_context: InvocationContext | None = None) -> bool:
|
||||
"""Display the TLDR message for the command."""
|
||||
if callable(self.custom_tldr):
|
||||
output = self.custom_tldr()
|
||||
@@ -561,7 +569,7 @@ class Command(BaseModel):
|
||||
console.print(output)
|
||||
return True
|
||||
if isinstance(self.arg_parser, CommandArgumentParser):
|
||||
self.arg_parser.render_tldr()
|
||||
self.arg_parser.render_tldr(invocation_context=invocation_context)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
170
falyx/context.py
170
falyx/context.py
@@ -1,18 +1,24 @@
|
||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""Context management for Falyx CLI.
|
||||
"""Context models for Falyx execution and invocation state.
|
||||
|
||||
This module defines `ExecutionContext` and `SharedContext`, which are responsible for
|
||||
capturing per-action and cross-action metadata during CLI workflow execution. These
|
||||
context objects provide structured introspection, result tracking, error recording,
|
||||
and time-based performance metrics.
|
||||
This module defines the core context objects used throughout Falyx to track both
|
||||
runtime execution metadata and routed invocation-path state.
|
||||
|
||||
- `ExecutionContext`: Captures runtime information for a single action execution,
|
||||
including arguments, results, exceptions, timing, and logging.
|
||||
- `SharedContext`: Maintains shared state and result propagation across
|
||||
`ChainedAction` or `ActionGroup` executions.
|
||||
It provides:
|
||||
- `ExecutionContext` for per-action execution details such as arguments,
|
||||
results, exceptions, timing, and summary logging.
|
||||
- `SharedContext` for transient shared state across grouped or chained
|
||||
actions, including propagated results, indexed errors, and arbitrary
|
||||
shared data.
|
||||
- `InvocationSegment` for representing a single styled token within a
|
||||
rendered invocation path.
|
||||
- `InvocationContext` for capturing the current routed command path as an
|
||||
immutable value object that supports both plain-text and Rich-markup
|
||||
rendering.
|
||||
|
||||
These contexts enable rich introspection, traceability, and workflow coordination,
|
||||
supporting hook lifecycles, retries, and structured output generation.
|
||||
Together, these models support Falyx lifecycle hooks, execution tracing,
|
||||
history/introspection, and context-aware help and usage rendering across CLI
|
||||
and menu modes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -23,6 +29,7 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from falyx.console import console
|
||||
from falyx.mode import FalyxMode
|
||||
@@ -285,28 +292,161 @@ class SharedContext(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class InvocationSegment(BaseModel):
|
||||
"""Styled path segment used to build an invocation display path.
|
||||
|
||||
`InvocationSegment` represents a single token within an `InvocationContext`,
|
||||
such as a namespace key, command key, or alias. It stores the raw display
|
||||
text and an optional Rich style so invocation paths can be rendered either
|
||||
as plain text or styled markup.
|
||||
|
||||
Attributes:
|
||||
text (str): Display text for this path segment.
|
||||
style (str | None): Optional Rich style applied when rendering this
|
||||
segment in markup output.
|
||||
"""
|
||||
|
||||
text: str
|
||||
style: str | None = None
|
||||
|
||||
|
||||
class InvocationContext(BaseModel):
|
||||
"""Immutable invocation-path context for routed Falyx help and execution.
|
||||
|
||||
`InvocationContext` captures the current displayable command path as the router
|
||||
descends through namespaces and commands. It stores both the raw typed path
|
||||
(`typed_path`) and a styled segment representation (`segments`) so the same
|
||||
context can be rendered as plain text or Rich markup.
|
||||
|
||||
This model is intended to be treated as an immutable value object. Methods such
|
||||
as `with_path_segment()` and `without_last_path_segment()` return new context
|
||||
instances rather than mutating the existing one.
|
||||
|
||||
Attributes:
|
||||
program (str): Root program name used in CLI-mode help and usage output.
|
||||
program_style (str): Rich style applied to the program name when rendering
|
||||
`markup_path`.
|
||||
typed_path (list[str]): Raw invocation tokens collected during routing,
|
||||
excluding the root program name.
|
||||
segments (list[InvocationSegment]): Styled path segments used to render the
|
||||
invocation path with Rich markup.
|
||||
mode (FalyxMode): Active Falyx mode for this invocation context. This is
|
||||
used to determine whether the path should include the program name.
|
||||
is_preview (bool): Whether the current invocation is a preview flow rather
|
||||
than a normal execution flow.
|
||||
"""
|
||||
|
||||
program: str = ""
|
||||
program_style: str = ""
|
||||
typed_path: list[str] = Field(default_factory=list)
|
||||
segments: list[InvocationSegment] = Field(default_factory=list)
|
||||
mode: FalyxMode = FalyxMode.MENU
|
||||
is_preview: bool = False
|
||||
|
||||
@property
|
||||
def is_cli_mode(self) -> bool:
|
||||
"""Whether this context should render using CLI path semantics.
|
||||
|
||||
Returns:
|
||||
bool: `True` when the invocation is not in menu mode, meaning rendered
|
||||
paths should include the program name. `False` when in menu mode.
|
||||
"""
|
||||
return self.mode != FalyxMode.MENU
|
||||
|
||||
def child(self, token: str) -> InvocationContext:
|
||||
def with_path_segment(
|
||||
self,
|
||||
token: str,
|
||||
*,
|
||||
style: str | None = None,
|
||||
) -> InvocationContext:
|
||||
"""Return a new context with one additional path segment appended.
|
||||
|
||||
This method preserves the current context and creates a new
|
||||
`InvocationContext` with the provided token added to both `typed_path` and
|
||||
`segments`.
|
||||
|
||||
Args:
|
||||
token (str): Raw path token to append, such as a namespace key,
|
||||
command key, or alias.
|
||||
style (str | None): Optional Rich style for the appended segment.
|
||||
|
||||
Returns:
|
||||
InvocationContext: A new context containing the appended path segment.
|
||||
"""
|
||||
return InvocationContext(
|
||||
program=self.program,
|
||||
program_style=self.program_style,
|
||||
typed_path=[*self.typed_path, token],
|
||||
segments=[*self.segments, InvocationSegment(text=token, style=style)],
|
||||
mode=self.mode,
|
||||
is_preview=self.is_preview,
|
||||
)
|
||||
|
||||
def display_path(self) -> str:
|
||||
def without_last_path_segment(self) -> InvocationContext:
|
||||
"""Return a new context with the last path segment removed.
|
||||
|
||||
This method preserves the current context and creates a new
|
||||
`InvocationContext` with the last token removed from both `typed_path` and
|
||||
`segments`.
|
||||
|
||||
Returns:
|
||||
InvocationContext: A new context with the last path segment removed, or the
|
||||
current context if no path segments are present.
|
||||
"""
|
||||
if not self.typed_path:
|
||||
return self
|
||||
return InvocationContext(
|
||||
program=self.program,
|
||||
program_style=self.program_style,
|
||||
typed_path=self.typed_path[:-1],
|
||||
segments=self.segments[:-1],
|
||||
mode=self.mode,
|
||||
is_preview=self.is_preview,
|
||||
)
|
||||
|
||||
@property
|
||||
def plain_path(self) -> str:
|
||||
"""Render the invocation path as plain text.
|
||||
|
||||
In CLI mode, the rendered path includes the root program name followed by
|
||||
all collected path segments. In menu mode, only the collected path segments
|
||||
are rendered.
|
||||
|
||||
Returns:
|
||||
str: Plain-text invocation path suitable for logs, comparisons, or
|
||||
non-styled help output.
|
||||
"""
|
||||
parts = [seg.text for seg in self.segments]
|
||||
if self.is_cli_mode:
|
||||
return " ".join([self.program, *self.typed_path]).strip()
|
||||
return " ".join(self.typed_path).strip()
|
||||
return " ".join([self.program, *parts]).strip()
|
||||
return " ".join(parts).strip()
|
||||
|
||||
@property
|
||||
def markup_path(self) -> str:
|
||||
"""Render the invocation path as escaped Rich markup.
|
||||
|
||||
In CLI mode, the root program name is included and styled with
|
||||
`program_style` when provided. Each path segment is escaped and styled
|
||||
using its associated `InvocationSegment.style` value when present.
|
||||
|
||||
Returns:
|
||||
str: Rich-markup invocation path suitable for help and usage rendering.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
if self.is_cli_mode and self.program:
|
||||
if self.program_style:
|
||||
parts.append(
|
||||
f"[{self.program_style}]{escape(self.program)}[/{self.program_style}]"
|
||||
)
|
||||
else:
|
||||
parts.append(escape(self.program))
|
||||
|
||||
for seg in self.segments:
|
||||
if seg.style:
|
||||
parts.append(f"[{seg.style}]{escape(seg.text)}[/{seg.style}]")
|
||||
else:
|
||||
parts.append(escape(seg.text))
|
||||
return " ".join(parts).strip()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
145
falyx/falyx.py
145
falyx/falyx.py
@@ -255,17 +255,11 @@ class Falyx:
|
||||
"""Returns the current invocation context."""
|
||||
return InvocationContext(
|
||||
program=self.program,
|
||||
program_style=self.program_style,
|
||||
typed_path=[],
|
||||
mode=self.options.get("mode"),
|
||||
)
|
||||
|
||||
def format_invocation_path(
|
||||
self, program: str, typed_path: list[str], *, cli_mode: bool
|
||||
) -> str:
|
||||
if cli_mode:
|
||||
return " ".join([program, *typed_path]).strip()
|
||||
return " ".join(typed_path).strip()
|
||||
|
||||
@property
|
||||
def is_cli_mode(self) -> bool:
|
||||
"""Checks if the current mode is a CLI mode."""
|
||||
@@ -481,14 +475,18 @@ class Falyx:
|
||||
)
|
||||
return choice(tips)
|
||||
|
||||
async def _render_command_tldr(self, command: Command) -> None:
|
||||
async def _render_command_tldr(
|
||||
self,
|
||||
command: Command,
|
||||
context: InvocationContext | None = None,
|
||||
) -> None:
|
||||
"""Renders the TLDR examples for a command, if available."""
|
||||
if not isinstance(command, Command):
|
||||
self.console.print(
|
||||
f"Entry '{command.key}' is not a command.", style=OneColors.DARK_RED
|
||||
)
|
||||
return None
|
||||
if command.render_tldr():
|
||||
if command.render_tldr(invocation_context=context):
|
||||
if self.enable_help_tips:
|
||||
self.console.print(f"[bold]tip:[/bold] {self.get_tip()}")
|
||||
else:
|
||||
@@ -496,7 +494,12 @@ class Falyx:
|
||||
f"[bold]No TLDR examples available for '{command.description}'.[/bold]"
|
||||
)
|
||||
|
||||
async def _render_command_help(self, command: Command, tldr: bool = False) -> None:
|
||||
async def _render_command_help(
|
||||
self,
|
||||
command: Command,
|
||||
tldr: bool = False,
|
||||
context: InvocationContext | None = None,
|
||||
) -> None:
|
||||
"""Renders the detailed help for a command, if available."""
|
||||
if not isinstance(command, Command):
|
||||
self.console.print(
|
||||
@@ -504,8 +507,8 @@ class Falyx:
|
||||
)
|
||||
return None
|
||||
if tldr:
|
||||
await self._render_command_tldr(command)
|
||||
elif command.render_help():
|
||||
await self._render_command_tldr(command, context=context)
|
||||
elif command.render_help(invocation_context=context):
|
||||
if self.enable_help_tips:
|
||||
self.console.print(f"\n[bold]tip:[/bold] {self.get_tip()}")
|
||||
else:
|
||||
@@ -588,26 +591,25 @@ class Falyx:
|
||||
)
|
||||
return None
|
||||
|
||||
async def _render_namespace_tldr_help(self, context: InvocationContext) -> None:
|
||||
# TODO: Create namespace tldr
|
||||
console.print(context.markup_path)
|
||||
|
||||
async def render_namespace_help(
|
||||
self, context: InvocationContext, tldr: bool = False
|
||||
) -> None:
|
||||
if context.mode is FalyxMode.MENU:
|
||||
if tldr:
|
||||
await self._render_namespace_tldr_help(context)
|
||||
elif context.mode is FalyxMode.MENU:
|
||||
await self._render_menu_help()
|
||||
else:
|
||||
print(
|
||||
self.format_invocation_path(
|
||||
context.program,
|
||||
context.typed_path,
|
||||
cli_mode=True,
|
||||
)
|
||||
)
|
||||
await self._render_cli_help()
|
||||
await self._render_cli_help(context)
|
||||
|
||||
async def _render_cli_help(self) -> None:
|
||||
async def _render_cli_help(self, context: InvocationContext) -> None:
|
||||
"""Renders the CLI help menu with all available commands and options."""
|
||||
usage = self.usage or "[GLOBAL OPTIONS] [COMMAND] [OPTIONS]"
|
||||
self.console.print(
|
||||
f"[bold]usage:[/bold] [{self.program_style}]{self.program}[/{self.program_style}] [{self.usage_style}]{usage}[/{self.usage_style}]"
|
||||
f"[bold]usage:[/bold] {context.markup_path} [{self.usage_style}]{usage}[/{self.usage_style}]"
|
||||
)
|
||||
if self.description:
|
||||
self.console.print(
|
||||
@@ -653,13 +655,27 @@ class Falyx:
|
||||
if self.enable_help_tips:
|
||||
self.console.print(f"\n[bold]tip:[/bold] {self.get_tip()}")
|
||||
|
||||
def _help_target_base_context(self, context: InvocationContext) -> InvocationContext:
|
||||
if not context.typed_path:
|
||||
return context
|
||||
|
||||
last_token = context.typed_path[-1]
|
||||
entry, _ = self.resolve_entry(last_token)
|
||||
|
||||
if entry is self.help_command:
|
||||
return context.without_last_path_segment()
|
||||
|
||||
return context
|
||||
|
||||
async def render_help(
|
||||
self,
|
||||
tag: str = "",
|
||||
key: str | None = None,
|
||||
tldr: bool = False,
|
||||
invocation_context: InvocationContext | None = None,
|
||||
) -> None:
|
||||
"""Renders the help menu with command details, usage examples, and tips."""
|
||||
context = invocation_context or self.get_current_invocation_context()
|
||||
if key:
|
||||
entry, suggestions = self.resolve_entry(key)
|
||||
if suggestions:
|
||||
@@ -667,22 +683,38 @@ class Falyx:
|
||||
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown entry '{key}'. Did you mean:[/]"
|
||||
f"{', '.join(suggestions)[:10]}"
|
||||
)
|
||||
elif isinstance(entry, Command):
|
||||
await self._render_command_help(entry, tldr)
|
||||
return None
|
||||
|
||||
base_context = self._help_target_base_context(context)
|
||||
|
||||
if isinstance(entry, Command):
|
||||
await self._render_command_help(
|
||||
command=entry,
|
||||
tldr=tldr,
|
||||
context=base_context.with_path_segment(key, style=entry.style),
|
||||
)
|
||||
elif isinstance(entry, FalyxNamespace):
|
||||
await entry.namespace.render_namespace_help(
|
||||
self.get_current_invocation_context(), tldr
|
||||
context=base_context.with_path_segment(key, style=entry.style),
|
||||
tldr=tldr,
|
||||
)
|
||||
else:
|
||||
# TODO: Should print something helpful here
|
||||
self.console.print(
|
||||
f"[{OneColors.DARK_RED}]❌ No entry found for '{key}'.[/]"
|
||||
)
|
||||
elif tldr:
|
||||
await self._render_command_help(
|
||||
self.help_command,
|
||||
tldr,
|
||||
context=context,
|
||||
)
|
||||
elif tag:
|
||||
await self._render_tag_help(tag)
|
||||
elif self.options.get("mode") == FalyxMode.MENU:
|
||||
await self._render_menu_help()
|
||||
else:
|
||||
await self._render_cli_help()
|
||||
await self._render_cli_help(context)
|
||||
|
||||
def _get_help_command(self) -> Command:
|
||||
"""Returns the help command for the menu."""
|
||||
@@ -693,8 +725,8 @@ class Falyx:
|
||||
aliases=["HELP", "?"],
|
||||
program=self.program,
|
||||
options_manager=self.options,
|
||||
_is_help_command=True,
|
||||
)
|
||||
parser.mark_as_help_command()
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--tag",
|
||||
@@ -733,12 +765,24 @@ class Falyx:
|
||||
|
||||
async def _preview(self, key: str) -> None:
|
||||
"""Previews the execution of a command without actually running it."""
|
||||
command = await self.resolve_command(key)
|
||||
if not command:
|
||||
self.console.print(f"[{OneColors.DARK_RED}]❌ Command '{key}' not found.")
|
||||
entry, suggestions = self.resolve_entry(key)
|
||||
if suggestions:
|
||||
self.console.print(
|
||||
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown entry '{key}'. Did you mean:[/]"
|
||||
f"{', '.join(suggestions)[:10]}"
|
||||
)
|
||||
return None
|
||||
self.console.print(f"Preview of command '{command.key}': {command.description}")
|
||||
await command.preview()
|
||||
if isinstance(entry, FalyxNamespace):
|
||||
self.console.print(
|
||||
f"❌ Entry '{key}' is a namespace. Please specify a command to preview.",
|
||||
style=OneColors.DARK_RED,
|
||||
)
|
||||
return None
|
||||
if not isinstance(entry, Command):
|
||||
self.console.print(f"[{OneColors.DARK_RED}]❌ No entry found for '{key}'.[/]")
|
||||
return None
|
||||
self.console.print(f"Preview of command '{entry.key}': {entry.description}")
|
||||
await entry.preview()
|
||||
|
||||
def _get_preview_command(self) -> Command:
|
||||
"""Returns the preview command for Falyx."""
|
||||
@@ -987,7 +1031,7 @@ class Falyx:
|
||||
description: str,
|
||||
submenu: Falyx,
|
||||
*,
|
||||
style: str = OneColors.CYAN,
|
||||
style: str | None = None,
|
||||
aliases: list[str] | None = None,
|
||||
help_text: str = "",
|
||||
) -> None:
|
||||
@@ -1003,7 +1047,7 @@ class Falyx:
|
||||
namespace=submenu,
|
||||
aliases=aliases or [],
|
||||
help_text=help_text or f"Open the {description} namespace.",
|
||||
style=style,
|
||||
style=style or submenu.program_style,
|
||||
)
|
||||
|
||||
self.namespaces[key] = entry
|
||||
@@ -1289,6 +1333,7 @@ class Falyx:
|
||||
|
||||
context = InvocationContext(
|
||||
program=self.program,
|
||||
program_style=self.program_style,
|
||||
typed_path=[],
|
||||
mode=mode or self.options.get("mode"),
|
||||
is_preview=is_preview,
|
||||
@@ -1304,7 +1349,9 @@ class Falyx:
|
||||
assert route.command is not None
|
||||
try:
|
||||
args, kwargs, execution_args = await route.command.resolve_args(
|
||||
route.leaf_argv, from_validate=from_validate
|
||||
route.leaf_argv,
|
||||
from_validate=from_validate,
|
||||
invocation_context=route.context,
|
||||
)
|
||||
except CommandArgumentError as error:
|
||||
if from_validate:
|
||||
@@ -1312,7 +1359,7 @@ class Falyx:
|
||||
cursor_position=len(raw_arguments), message=str(error)
|
||||
) from error
|
||||
else:
|
||||
route.command.render_help()
|
||||
route.command.render_help(invocation_context=route.context)
|
||||
self.console.print(
|
||||
f"[{OneColors.DARK_RED}]❌ [{route.command.key}]: {error}"
|
||||
)
|
||||
@@ -1368,6 +1415,10 @@ class Falyx:
|
||||
await command.preview()
|
||||
return None
|
||||
|
||||
if command is route.namespace.help_command:
|
||||
kwargs = kwargs or {}
|
||||
kwargs["invocation_context"] = route.context
|
||||
|
||||
logger.debug(
|
||||
"Executing command '%s' with args=%s, kwargs=%s, execution_args=%s",
|
||||
route.command.description,
|
||||
@@ -1497,20 +1548,17 @@ class Falyx:
|
||||
suggestions=suggestions,
|
||||
)
|
||||
|
||||
child_context = context.child(head)
|
||||
route_context = context.with_path_segment(head, style=entry.style)
|
||||
|
||||
# 4. Namespace entry -> recurse with remaining tokens
|
||||
if isinstance(entry, FalyxNamespace):
|
||||
return await entry.namespace.resolve_route(
|
||||
tail,
|
||||
context=child_context,
|
||||
)
|
||||
return await entry.namespace.resolve_route(tail, context=route_context)
|
||||
|
||||
# 5. Leaf command -> stop routing; leave tail untouched for leaf parser
|
||||
return RouteResult(
|
||||
kind=RouteKind.COMMAND,
|
||||
namespace=self,
|
||||
context=child_context,
|
||||
context=route_context,
|
||||
command=entry,
|
||||
leaf_argv=tail,
|
||||
)
|
||||
@@ -1694,7 +1742,16 @@ class Falyx:
|
||||
logger.info("[asyncio.CancelledError]. <- Exiting run.")
|
||||
sys.exit(1)
|
||||
|
||||
if route.kind is RouteKind.NAMESPACE_MENU or not always_start_menu:
|
||||
if (
|
||||
route.kind
|
||||
in (
|
||||
RouteKind.NAMESPACE_MENU,
|
||||
RouteKind.NAMESPACE_TLDR,
|
||||
RouteKind.NAMESPACE_HELP,
|
||||
)
|
||||
or route.command is self.help_command
|
||||
or not always_start_menu
|
||||
):
|
||||
sys.exit(0)
|
||||
|
||||
await self.menu()
|
||||
|
||||
@@ -58,6 +58,7 @@ from rich.panel import Panel
|
||||
|
||||
from falyx.action.base_action import BaseAction
|
||||
from falyx.console import console
|
||||
from falyx.context import InvocationContext
|
||||
from falyx.exceptions import CommandArgumentError, NotAFalyxError
|
||||
from falyx.execution_option import ExecutionOption
|
||||
from falyx.mode import FalyxMode
|
||||
@@ -136,7 +137,6 @@ class CommandArgumentParser:
|
||||
tldr_examples: list[tuple[str, str]] | None = None,
|
||||
program: str | None = None,
|
||||
options_manager: OptionsManager | None = None,
|
||||
_is_help_command: bool = False,
|
||||
) -> None:
|
||||
"""Initialize the CommandArgumentParser."""
|
||||
self.console: Console = console
|
||||
@@ -162,11 +162,15 @@ class CommandArgumentParser:
|
||||
self._arg_group_by_dest: dict[str, str] = {}
|
||||
self._mutex_group_by_dest: dict[str, str] = {}
|
||||
self._tldr_examples: list[TLDRExample] = []
|
||||
self._is_help_command: bool = _is_help_command
|
||||
self._is_help_command: bool = False
|
||||
if tldr_examples:
|
||||
self.add_tldr_examples(tldr_examples)
|
||||
self.options_manager: OptionsManager = options_manager or OptionsManager()
|
||||
|
||||
def mark_as_help_command(self) -> None:
|
||||
"""Mark this parser as the help command parser."""
|
||||
self._is_help_command = True
|
||||
|
||||
def set_options_manager(self, options_manager: OptionsManager) -> None:
|
||||
"""Set the options manager for the parser."""
|
||||
if not isinstance(options_manager, OptionsManager):
|
||||
@@ -1129,6 +1133,7 @@ class CommandArgumentParser:
|
||||
consumed_indices: set[int],
|
||||
arg_states: dict[str, ArgumentState],
|
||||
from_validate: bool = False,
|
||||
invocation_context: InvocationContext | None = None,
|
||||
) -> int:
|
||||
"""Handle a single token in the command line arguments."""
|
||||
if token in self._keyword:
|
||||
@@ -1137,7 +1142,7 @@ class CommandArgumentParser:
|
||||
|
||||
if action == ArgumentAction.HELP:
|
||||
if not from_validate:
|
||||
self.render_help()
|
||||
self.render_help(invocation_context=invocation_context)
|
||||
arg_states[spec.dest].set_consumed()
|
||||
raise HelpSignal()
|
||||
elif action == ArgumentAction.TLDR:
|
||||
@@ -1147,7 +1152,7 @@ class CommandArgumentParser:
|
||||
consumed_indices.add(index)
|
||||
index += 1
|
||||
elif not from_validate:
|
||||
self.render_tldr()
|
||||
self.render_tldr(invocation_context=invocation_context)
|
||||
arg_states[spec.dest].set_consumed()
|
||||
raise HelpSignal()
|
||||
else:
|
||||
@@ -1344,7 +1349,10 @@ class CommandArgumentParser:
|
||||
)
|
||||
|
||||
async def parse_args(
|
||||
self, args: list[str] | None = None, from_validate: bool = False
|
||||
self,
|
||||
args: list[str] | None = None,
|
||||
from_validate: bool = False,
|
||||
invocation_context: InvocationContext | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Parse CLI arguments into a resolved mapping of values.
|
||||
|
||||
@@ -1416,6 +1424,7 @@ class CommandArgumentParser:
|
||||
consumed_indices,
|
||||
arg_states=arg_states,
|
||||
from_validate=from_validate,
|
||||
invocation_context=invocation_context,
|
||||
)
|
||||
|
||||
# Compare length of args with length of required positional arguments to catch missing required positionals
|
||||
@@ -1512,7 +1521,10 @@ class CommandArgumentParser:
|
||||
return result
|
||||
|
||||
async def parse_args_split(
|
||||
self, args: list[str], from_validate: bool = False
|
||||
self,
|
||||
args: list[str],
|
||||
from_validate: bool = False,
|
||||
invocation_context: InvocationContext | None = None,
|
||||
) -> tuple[tuple[Any, ...], dict[str, Any], dict[str, Any]]:
|
||||
"""Parse arguments and split them into execution-ready components.
|
||||
|
||||
@@ -1536,7 +1548,7 @@ class CommandArgumentParser:
|
||||
- dict[str, Any]: Keyword arguments for execution.
|
||||
- dict[str, Any]: Execution-specific arguments handled by Falyx.
|
||||
"""
|
||||
parsed = await self.parse_args(args, from_validate)
|
||||
parsed = await self.parse_args(args, from_validate, invocation_context)
|
||||
args_list = []
|
||||
kwargs_dict = {}
|
||||
execution_dict = {}
|
||||
@@ -1961,7 +1973,7 @@ class CommandArgumentParser:
|
||||
|
||||
return sorted(set(suggestions))
|
||||
|
||||
def get_options_text(self, plain_text=False) -> str:
|
||||
def get_options_text(self) -> str:
|
||||
"""
|
||||
Render all defined arguments as a help-style string.
|
||||
|
||||
@@ -1983,27 +1995,18 @@ class CommandArgumentParser:
|
||||
choice_text = arg.get_choice_text()
|
||||
if isinstance(arg.nargs, int):
|
||||
choice_text = " ".join([choice_text] * arg.nargs)
|
||||
if plain_text:
|
||||
options_list.append(choice_text)
|
||||
else:
|
||||
options_list.append(escape(choice_text))
|
||||
|
||||
return " ".join(options_list)
|
||||
|
||||
def get_command_keys_text(self, plain_text=False) -> str:
|
||||
"""
|
||||
Return formatted string showing the command key and aliases.
|
||||
def get_command_keys_text(self) -> str:
|
||||
"""Return formatted string showing the command key and aliases.
|
||||
|
||||
Used in help rendering and introspection.
|
||||
|
||||
Returns:
|
||||
str: The visual command selector line.
|
||||
"""
|
||||
if plain_text:
|
||||
command_keys = " | ".join(
|
||||
[f"{self.command_key}"] + [f"{alias}" for alias in self.aliases]
|
||||
)
|
||||
else:
|
||||
command_keys = " | ".join(
|
||||
[f"[{self.command_style}]{self.command_key}[/{self.command_style}]"]
|
||||
+ [
|
||||
@@ -2013,32 +2016,43 @@ class CommandArgumentParser:
|
||||
)
|
||||
return command_keys
|
||||
|
||||
def get_usage(self, plain_text=False) -> str:
|
||||
"""
|
||||
Render the usage string for this parser.
|
||||
|
||||
Returns:
|
||||
str: A formatted usage line showing syntax and argument structure.
|
||||
"""
|
||||
command_keys = self.get_command_keys_text(plain_text)
|
||||
options_text = self.get_options_text(plain_text)
|
||||
if options_text:
|
||||
def _get_invocation_prefix(
|
||||
self,
|
||||
invocation_context: InvocationContext | None = None,
|
||||
) -> str:
|
||||
if invocation_context is None:
|
||||
command_keys = self.get_command_keys_text()
|
||||
if self.options_manager.get("mode") == FalyxMode.MENU:
|
||||
return f"{command_keys} {options_text}"
|
||||
else:
|
||||
return command_keys
|
||||
|
||||
program = self.program or "falyx"
|
||||
program_style = (
|
||||
self.options_manager.get("program_style") or self.command_style
|
||||
)
|
||||
return f"[{program_style}]{program}[/{program_style}] {command_keys} {options_text}"
|
||||
return command_keys
|
||||
return f"[{program_style}]{program}[/{program_style}] {command_keys}"
|
||||
|
||||
if invocation_context.is_cli_mode:
|
||||
return invocation_context.markup_path
|
||||
|
||||
return invocation_context.markup_path
|
||||
|
||||
def get_usage(
|
||||
self,
|
||||
invocation_context: InvocationContext | None = None,
|
||||
) -> str:
|
||||
"""Render the usage string for this parser.
|
||||
|
||||
Returns:
|
||||
str: A formatted usage line showing syntax and argument structure.
|
||||
"""
|
||||
prefix = self._get_invocation_prefix(invocation_context)
|
||||
options_text = self.get_options_text()
|
||||
return f"{prefix} {options_text}".strip() if options_text else prefix
|
||||
|
||||
def _iter_keyword_help_sections(
|
||||
self,
|
||||
) -> Generator[tuple[str, str, list[Argument]], None, None]:
|
||||
"""
|
||||
Yields (title, description, arguments)
|
||||
"""
|
||||
"""Yields (title, description, arguments)"""
|
||||
assigned = set()
|
||||
|
||||
for group in self._argument_groups.values():
|
||||
@@ -2059,7 +2073,11 @@ class CommandArgumentParser:
|
||||
if ungrouped:
|
||||
yield "options", "", ungrouped
|
||||
|
||||
def render_help(self) -> None:
|
||||
def render_help(
|
||||
self,
|
||||
*,
|
||||
invocation_context: InvocationContext | None = None,
|
||||
) -> None:
|
||||
"""Render full help output for the command.
|
||||
|
||||
This method displays a complete help view for the command, including
|
||||
@@ -2076,7 +2094,7 @@ class CommandArgumentParser:
|
||||
- Supports argument grouping and mutually exclusive groups
|
||||
- Applies styling based on configured command style
|
||||
"""
|
||||
usage = self.get_usage()
|
||||
usage = self.get_usage(invocation_context)
|
||||
self.console.print(f"[bold]usage: {usage}[/bold]\n")
|
||||
|
||||
if self.help_text:
|
||||
@@ -2131,7 +2149,7 @@ class CommandArgumentParser:
|
||||
if self.help_epilog:
|
||||
self.console.print("\n" + self.help_epilog, style="dim")
|
||||
|
||||
def render_tldr(self) -> None:
|
||||
def render_tldr(self, *, invocation_context: InvocationContext | None = None) -> None:
|
||||
"""Render concise example usage (TLDR) for the command.
|
||||
|
||||
This method displays a minimal, example-driven view of how to invoke
|
||||
@@ -2148,18 +2166,8 @@ class CommandArgumentParser:
|
||||
f"[bold]No TLDR examples available for {self.command_key}.[/bold]"
|
||||
)
|
||||
return
|
||||
is_cli_mode = self.options_manager.get("mode") != FalyxMode.MENU
|
||||
program = self.program or "falyx"
|
||||
program_style = self.options_manager.get("program_style") or self.command_style
|
||||
command = self.aliases[0] if self.aliases else self.command_key
|
||||
if self._is_help_command and is_cli_mode:
|
||||
command = f"[{program_style}]{program}[/{program_style}] [{self.command_style}]help[/{self.command_style}]"
|
||||
elif is_cli_mode:
|
||||
command = f"[{program_style}]{program}[/{program_style}] [{self.command_style}]{command}[/{self.command_style}]"
|
||||
else:
|
||||
command = f"[{self.command_style}]{command}[/{self.command_style}]"
|
||||
|
||||
usage = self.get_usage()
|
||||
prefix = self._get_invocation_prefix(invocation_context)
|
||||
usage = self.get_usage(invocation_context)
|
||||
self.console.print(f"[bold]usage:[/] {usage}\n")
|
||||
|
||||
if self.help_text:
|
||||
@@ -2167,7 +2175,7 @@ class CommandArgumentParser:
|
||||
|
||||
self.console.print("[bold]examples:[/bold]")
|
||||
for example in self._tldr_examples:
|
||||
usage = f"{command} {example.usage.strip()}"
|
||||
usage = f"{prefix} {example.usage.strip()}"
|
||||
description = example.description.strip()
|
||||
block = f"[bold]{usage}[/bold]"
|
||||
self.console.print(
|
||||
|
||||
@@ -28,6 +28,5 @@ class RouteResult:
|
||||
command: "Command | None" = None
|
||||
namespace_entry: FalyxNamespace | None = None
|
||||
leaf_argv: list[str] = field(default_factory=list)
|
||||
typed_path: list[str] = field(default_factory=list)
|
||||
suggestions: list[str] = field(default_factory=list)
|
||||
is_preview: bool = False
|
||||
|
||||
Reference in New Issue
Block a user