diff --git a/README.md b/README.md index 1d9a17d..0693599 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ import sys from omegaconf import DictConfig from hydraclick import hydra_command -@hydra_command(config_path="configs", config_name="app_config", run_mode="kwargs") +@hydra_command(config_path="configs", config_name="app_config", as_kwargs=True) def main(**kwargs): print(f"Running with config: {kwargs}") @@ -66,16 +66,20 @@ if __name__ == "__main__": Decorator to create CLI commands. ```python +import omegaconf + + def hydra_command( - config_path: str | Path | None = None, - config_name: str | None = "config", - version_base: str | None = None, - run_mode: str = "config", - preprocess_config: Callable[[DictConfig], DictConfig] | None = None, - print_config: bool = True, - resolve: bool = True, - use_flogging: bool = True, - **flogging_kwargs: Any, + config_path: str | Path | None = None, + config_name: str | None = "config", + version_base: str | None = None, + as_kwargs: bool = False, + preprocess_config: Callable[[DictConfig], DictConfig] | None = None, + print_config: bool = True, + resolve: bool = True, + use_flogging: bool = True, + terminal_effect: Callable | None = omegaconf.MISSING, + **flogging_kwargs: Any, ) -> Callable: ``` @@ -95,6 +99,7 @@ Defaults to `False`. - `resolve`: Whether to resolve the configuration. - `use_flogging`: Whether to use flogging for structured logging. - `**flogging_kwargs`: Additional keyword arguments for flogging. +- `terminal_effect`: The terminal effect function to use when rendering the command help. ## Logging with Flogging diff --git a/src/hydraclick/__init__.py b/src/hydraclick/__init__.py index 7b9d442..5893bbc 100644 --- a/src/hydraclick/__init__.py +++ b/src/hydraclick/__init__.py @@ -1,2 +1,2 @@ -from hydraclick.terminal_effects import set_terminal_effect, NO_TERMINAL_EFFECTS +from hydraclick.terminal_effects import set_terminal_effect from hydraclick.core import hydra_command diff --git a/src/hydraclick/core.py b/src/hydraclick/core.py index 5dcf6b8..630c412 100644 --- a/src/hydraclick/core.py +++ b/src/hydraclick/core.py @@ -5,10 +5,11 @@ from typing import Callable, Any import hydra +import omegaconf from omegaconf import DictConfig, OmegaConf from unittest.mock import patch - +from hydraclick import set_terminal_effect from hydraclick.display_config import display_config from hydraclick.options import ( hydra_args_argument, @@ -25,6 +26,7 @@ config_name_option, shell_completion_option, ) +from hydraclick.terminal_effects import display_terminal_effect _logger = logging.getLogger(__name__) @@ -171,6 +173,7 @@ def command_api( print_config: bool = True, resolve: bool = True, use_flogging: bool = True, + terminal_effect: Callable | None = omegaconf.MISSING, **flogging_kwargs: Any, ) -> Callable: """Integrate Hydra's configuration management capabilities with a Click-based CLI. @@ -197,6 +200,8 @@ def command_api( function. Defaults to `True`. use_flogging (bool, optional): Whether to use the `flogging` library for structured \ logging. Defaults to `True`. + terminal_effect(Callable | None, optional): The terminal effect function to use when \ + rendering the command help. **flogging_kwargs (Any, optional): Additional keyword arguments to pass to the \ `flogging.setup` function. @@ -237,6 +242,10 @@ def my_function(config: DictConfig): configuration before it is passed to the main function. """ + if terminal_effect == omegaconf.MISSING: + terminal_effect = display_terminal_effect + if terminal_effect is not None: + set_terminal_effect(terminal_effect) config_path = get_default_dir() if config_path is None else str(config_path) if config_name is not None: config_name = str(config_name).replace(".yaml", "").replace(".yml", "") @@ -329,6 +338,7 @@ def hydra_command( print_config: bool = True, resolve: bool = True, use_flogging: bool = True, + terminal_effect: Callable | None = omegaconf.MISSING, **flogging_kwargs: Any, ) -> Callable: """Integrate Hydra's configuration management capabilities with a Click-based CLI. @@ -353,6 +363,8 @@ def hydra_command( the function. Defaults to `True`. use_flogging (bool, optional): Whether to use the `flogging` library for structured \ logging. Defaults to `True`. + terminal_effect(Callable | None, optional): The terminal effect function to use when \ + rendering the command help. **flogging_kwargs (Any, optional): Additional keyword arguments to pass to the \ `flogging.setup` function. @@ -380,6 +392,7 @@ def decorator(function: Callable): print_config=print_config, preprocess_config=preprocess_config, resolve=resolve, + terminal_effect=terminal_effect, **flogging_kwargs, ) diff --git a/src/hydraclick/terminal_effects.py b/src/hydraclick/terminal_effects.py index 12efb7f..363af2c 100644 --- a/src/hydraclick/terminal_effects.py +++ b/src/hydraclick/terminal_effects.py @@ -7,41 +7,88 @@ from click import Context, Option -def get_no_terminal_efects() -> bool: - """Get the no terminal effects environment variable.""" - val = os.environ.get( - "OMEGACLICK_NO_TERMINAL_EFFECTS", os.environ.get("OMEGACLICK_NO_TERMINAL_EFFECTS") - ) +def get_no_terminal_effects() -> bool: + """Check if terminal effects should be disabled by looking at environment variables. + + This function checks the environment variables `OMEGACLICK_NO_TERMINAL_EFFECTS` + and `NO_TERMINAL_EFFECTS` to determine if terminal effects should be disabled. + If no environment variable is set, it returns `False`. + + Returns: + bool: `True` if terminal effects should be disabled, `False` otherwise. + + Example: + >>> os.environ["NO_TERMINAL_EFFECTS"] = "true" + >>> get_no_terminal_effects() + True + + """ + val = os.environ.get("OMEGACLICK_NO_TERMINAL_EFFECTS", os.environ.get("NO_TERMINAL_EFFECTS")) if val is None: return False return val.lower() in {"true", "1", "yes"} -NO_TERMINAL_EFFECTS = get_no_terminal_efects() +def config_effect(effect): + """Configure terminal effects such as print speed and gradient colors. + + This function adjusts the terminal effect's configuration, such as print speed, + return speed, and gradient colors for rendering. + Args: + effect: The terminal effect object to be configured. -def config_effect(effect): - """Configure the terminal effect.""" + Returns: + The modified terminal effect object. + + Example: + >>> effect = SomeEffect() + >>> config_effect(effect) + + + """ from terminaltexteffects.utils.graphics import Color # noqa: PLC0415 - effect.effect_config.print_speed = 5 - effect.effect_config.print_head_return_speed = 3 + effect.effect_config.print_speed = 15 + effect.effect_config.print_head_return_speed = 5 effect.effect_config.final_gradient_stops = (Color("00ffae"), Color("00D1FF"), Color("FFFFFF")) return effect def remove_lines(num_lines: int): - """Remove the last `num_lines` printed lines from the terminal.""" + """Remove the last `num_lines` lines printed in the terminal. + + This function sends ANSI escape codes to move the terminal cursor up and clear + the last `num_lines` lines from the terminal. + + Args: + num_lines (int): The number of lines to remove. + + Example: + >>> remove_lines(3) # Removes the last 3 printed lines + + """ for _ in range(num_lines): - # Move the cursor up one line - sys.stdout.write("\x1b[1A") - # Clear the entire line - sys.stdout.write("\x1b[2K") + sys.stdout.write("\x1b[1A") # Move the cursor up one line + sys.stdout.write("\x1b[2K") # Clear the entire line sys.stdout.flush() -def count_wrapped_lines(text: str, terminal_width: int): - """Count the number of lines that the text will take when wrapped.""" +def count_wrapped_lines(text: str, terminal_width: int) -> int: + """Calculate the number of lines the given text will take when wrapped in the terminal. + + Args: + text (str): The text to be wrapped. + terminal_width (int): The width of the terminal in characters. + + Returns: + int: The number of lines the text will occupy in the terminal. + + Example: + >>> count_wrapped_lines("This is a long line of text.", 10) + 3 + + """ lines = text.splitlines() total_lines = 0 for line in lines: @@ -53,81 +100,129 @@ def count_wrapped_lines(text: str, terminal_width: int): return total_lines -def display_terminal_effect(value, effect_cls=None): - """Display the terminal effect.""" +def display_terminal_effect(value: str, effect_cls=None): + """Display a terminal effect animation for a given text. + + This function displays a text-based terminal effect using the provided effect class. + The effect is rendered with custom configurations, and once the animation is complete, + the effect is cleaned up from the terminal. + + Args: + value (str): The text to display with the terminal effect. + effect_cls (optional): The class of the terminal effect to use. Defaults to `Print`. + + Example: + >>> display_terminal_effect("Hello World!") + + """ from terminaltexteffects.effects.effect_print import Print # noqa: PLC0415 effect_cls = effect_cls or Print effect = effect_cls(value) effect = config_effect(effect) + with effect.terminal_output() as terminal: for frame in effect: terminal.print(frame) + terminal_width = shutil.get_terminal_size().columns n_lines_last_rendered_frame = count_wrapped_lines(frame, terminal_width) remove_lines(n_lines_last_rendered_frame) + last_effect = effect_cls(value) last_effect = config_effect(last_effect) last_effect.terminal_config.ignore_terminal_dimensions = True last_frame = list(last_effect)[-1] + sys.stdout.write(last_frame.lstrip()) sys.stdout.write("\n") sys.stdout.flush() -_TERMINAL_EFFECT = display_terminal_effect +def patch_parse_args(terminal_effect: Callable): + """Patch the Click `parse_args` function to display a terminal effect. + This function overrides the `parse_args` method of Click's `MultiCommand` to + display a custom terminal effect for the help message when no arguments are passed. -def set_terminal_effect(value: Callable | None = None): - """Set the terminal effect.""" - global _TERMINAL_EFFECT # noqa: PLW0603 - if value is None: - value = display_terminal_effect - _TERMINAL_EFFECT = value + Args: + terminal_effect (Callable): A callable that renders the terminal effect. + Example: + >>> patch_parse_args(display_terminal_effect) -def parse_args(self, ctx: Context, args: list[str]) -> list[str]: - """Display the help message when no arguments are provided.""" - if not args and self.no_args_is_help and not ctx.resilient_parsing: - _TERMINAL_EFFECT(ctx.get_help()) - ctx.exit() + """ - rest = super(click.core.MultiCommand, self).parse_args(ctx, args) + def parse_args(self, ctx: Context, args: list[str]) -> list[str]: + """Display the help message with terminal effects when no arguments are provided.""" + if not args and self.no_args_is_help and not ctx.resilient_parsing: + terminal_effect(ctx.get_help()) + ctx.exit() - if self.chain: - ctx.protected_args = rest - ctx.args = [] - elif rest: - ctx.protected_args, ctx.args = rest[:1], rest[1:] + rest = super(click.core.MultiCommand, self).parse_args(ctx, args) + if self.chain: + ctx.protected_args = rest + ctx.args = [] + elif rest: + ctx.protected_args, ctx.args = rest[:1], rest[1:] + return ctx.args - return ctx.args + click.core.MultiCommand.parse_args = parse_args -def get_help_option(self, ctx: Context) -> Optional["Option"]: - """Return the help option object.""" - from gettext import gettext # noqa: PLC0415 +def patch_get_help_option(terminal_effect: Callable): + """Patch the Click `get_help_option` function to display a terminal effect for the help option. - help_options = self.get_help_option_names(ctx) + This function overrides Click's `get_help_option` method to display a terminal + effect whenever the help message is requested. - if not help_options or not self.add_help_option: - return None + Args: + terminal_effect (Callable): A callable that renders the terminal effect. - def show_help(ctx: Context, param: "click.Parameter", value: str) -> None: # noqa: ARG001 - if value and not ctx.resilient_parsing: - display_terminal_effect(ctx.get_help()) - ctx.exit() + Example: + >>> patch_get_help_option(display_terminal_effect) + + """ + + def get_help_option(self, ctx: Context) -> Optional["Option"]: + """Return the help option with a terminal effect callback.""" + from gettext import gettext # noqa: PLC0415 - return Option( - help_options, - is_flag=True, - is_eager=True, - expose_value=False, - callback=show_help, - help=gettext("Show this message and exit."), - ) + help_options = self.get_help_option_names(ctx) + if not help_options or not self.add_help_option: + return None + + def show_help(ctx: Context, param: "click.Parameter", value: str) -> None: # noqa: ARG001 + if value and not ctx.resilient_parsing: + terminal_effect(ctx.get_help()) + ctx.exit() + + return Option( + help_options, + is_flag=True, + is_eager=True, + expose_value=False, + callback=show_help, + help=gettext("Show this message and exit."), + ) -if not NO_TERMINAL_EFFECTS: - set_terminal_effect(display_terminal_effect) click.core.Command.get_help_option = get_help_option - click.core.MultiCommand.parse_args = parse_args + + +def set_terminal_effect(terminal_effect: Callable): + """Set a terminal effect animation for displaying help in Click commands. + + This function applies a patch to the Click `parse_args` and `get_help_option` + methods, so the help message is displayed with the specified terminal effect. + + Args: + terminal_effect (Callable): A callable that renders the terminal effect. + + Example: + >>> set_terminal_effect(display_terminal_effect) + + """ + if not get_no_terminal_effects(): + patch_parse_args(terminal_effect) + patch_get_help_option(terminal_effect)