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:
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__":
|
||||
|
||||
Reference in New Issue
Block a user