from __future__ import annotations # Ensures type hints are parsed correctly, good practice
import logging
from datetime import datetime
# Third-party imports
import click # Though Group and Command are from click_extra, BadParameter is from click
from click_extra import Command, Group # Using Command and Group from click_extra
from cloup import Context, HelpFormatter, HelpTheme, Style # For advanced help formatting
# Local application/library specific imports
from .logger_config import shared_console # For printing the header
# ===========================
# HELP CONTEXT & FORMATTING SETTINGS
# ===========================
# These settings define the appearance and behavior of the help screens
# generated by Cloup for commands and groups.
CONTEXT_SETTINGS = Context.settings(
help_option_names=["-h", "--help"], # Standard help option flags
ignore_unknown_options=False, # Raise an error for unknown options
align_option_groups=True, # Align option groups in help output
align_sections=True, # Align command sections in help output
show_constraints=True, # Show constraints (e.g., choices, ranges) in help
show_subcommand_aliases=True,
formatter_settings=HelpFormatter.settings(
max_width=120, # Maximum width of the help output
col1_max_width=35, # Max width for the first column (options/commands)
col2_min_width=60, # Min width for the second column (help text)
indent_increment=2, # Spaces for indentation levels
col_spacing=2, # Spaces between columns
row_sep=". "*60, # Separator between rows (None for default)
theme=HelpTheme.dark().with_( # Using a dark theme with custom style overrides
invoked_command=Style(fg="bright_yellow"), # Style for the invoked command itself
command_help=Style(fg="bright_cyan", bold=False), # Style for the command's short help
heading=Style(fg="bright_green", bold=True), # Style for section headings (e.g., "Options:", "Commands:")
constraint=Style(fg="magenta"), # Style for option constraints (e.g., "[required]")
section_help=Style(fg="cyan"), # Style for the help text of sections (e.g., group's help) - was red
col1=Style(fg="bright_blue"), # Style for the first column (options/command names) - was bright_cyan
col2=Style(fg="white"), # Style for the second column (help descriptions)
epilog=Style(fg="bright_white", italic=True), # Style for the epilog text at the end of help
alias=Style(fg="bright_blue", italic=True), # Le texte de l'alias lui-même
alias_secondary=Style(fg="bright_blue", dim=True), # Les parenthèses/virgules
# -------------------------------
),
),
)
# Vous pouvez mettre ceci comme une méthode statique ou une fonction utilitaire
# pour ne pas le recréer à chaque appel.
# C'est plus propre de le définir une seule fois.
RC_TRANS = str.maketrans("ACGTNacgtn", "TGCANtgcan")
[docs]
def reverse_complement_string(s: str) -> str:
"""Calcule rapidement le complément inverse d'une chaîne d'ADN."""
return s.translate(RC_TRANS)[::-1]
[docs]
class CustomCommand(Command): # Inherits from click_extra.Command
"""
Custom Command class that applies global context settings (for help formatting)
and prepends a specific header to the help message of individual commands.
"""
[docs]
def __init__(self, *args, **kwargs):
"""
Initializes the CustomCommand.
Ensures that the predefined CONTEXT_SETTINGS are applied by default
to this command.
"""
kwargs.setdefault("context_settings", CONTEXT_SETTINGS)
self.logger = logging.getLogger("GraTools")
super().__init__(*args, **kwargs)
[docs]
def invoke(self, ctx):
"""Mesure et affiche le temps d'exécution autour de l’appel réel."""
start = datetime.now()
try:
return super().invoke(ctx)
except Exception as e:
self.logger.error(f"Error during command execution: {e}")
finally:
elapsed = datetime.now() - start
self.logger.info(
f"Command '[green]{self.name}[/green]' completed in: [magenta]{elapsed}[/magenta]"
)
[docs]
def get_help(self, ctx: click.Context) -> str:
"""
Overrides the default help generation to prepend a custom header.
The header is printed directly to the console using `shared_console`
before the standard help text is generated and returned.
Args:
ctx (click.Context): The current Click context.
Returns:
str: The formatted help text, including the prepended header.
"""
# Dynamically import header_tool to avoid circular dependencies
# if __init__.py imports from this module.
try:
from .__init__ import header_tool # Assuming header_tool is defined in your __init__.py
shared_console.print(header_tool) # Print the header to the console
except ImportError:
shared_console.print("[bold red]Warning: GraTools header could not be loaded.[/bold red]")
original_help_text = super().get_help(ctx)
return f"{original_help_text}\n" # Add a newline for spacing after Cloup's help
[docs]
class CustomGroup(Group): # Inherits from click_extra.Group
"""
Custom Group class that applies global context settings, prepends a header
to its own help message, and ensures that all subcommands added via its
`command` decorator use `CustomCommand` by default.
"""
[docs]
def __init__(self, *args, **kwargs):
"""
Initializes the CustomGroup.
Ensures that the predefined CONTEXT_SETTINGS are applied by default
to this group and its subcommands (if they don't override).
"""
kwargs.setdefault("context_settings", CONTEXT_SETTINGS)
super().__init__(*args, **kwargs)
[docs]
def command(self, *args, **kwargs):
"""
Overrides the default `command` decorator registration.
This ensures that any command registered using this group's `command`
method will automatically use `CustomCommand` as its class, thereby
inheriting the custom help formatting and header.
Args:
*args: Positional arguments for the command decorator.
**kwargs: Keyword arguments for the command decorator.
Returns:
Callable: The decorator that registers the command.
"""
kwargs.setdefault("cls", CustomCommand) # Default to CustomCommand for subcommands
return super().command(*args, **kwargs)
[docs]
def get_help(self, ctx: click.Context) -> str:
"""
Overrides the default help generation for the group to prepend a custom header.
Args:
ctx (click.Context): The current Click context.
Returns:
str: The formatted help text for the group, including the prepended header.
"""
try:
from .__init__ import header_tool
shared_console.print(header_tool) # Print the header
except ImportError:
shared_console.print("[bold red]Warning: GraTools header could not be loaded.[/bold red]")
original_help_text = super().get_help(ctx)
return f"{original_help_text}\n" # Add a newline for spacing
[docs]
def validate_percentage_or_int(ctx: click.Context, param: click.Parameter, value: str | int | float) -> int | float:
"""
Click callback for validating that an option's value is either an integer
or a float representing a percentage (between 0.0 and 1.0 inclusive).
This function is intended to be used as a `callback` for a Click option.
Args:
ctx (click.Context): The current Click context.
param (click.Parameter): The Click parameter (option) being validated.
value (str | int | float): The input value provided by the user for the option.
Click might pass it as str, or already converted if type is set.
Returns:
int | float: The validated value, converted to `int` if it's a whole number,
or `float` if it's a percentage (0.0-1.0).
Raises:
click.BadParameter: If the value is not a valid integer or a float
between 0.0 and 1.0.
"""
if value is None: # Handle cases where the option might not be provided (e.g. if not required)
return value # Or raise BadParameter if it's always required to be non-None
try:
# Attempt to convert to float first, as int can be represented as float.
float_value = float(value) # This will raise ValueError if `value` is not number-like string
# Check if the float is effectively an integer (e.g., 5.0, -2.0)
if float_value.is_integer():
# It's a whole number, return as int.
# Further checks for integer range could be added here if needed.
return int(float_value)
# Check if it's a valid percentage (0.0 to 1.0)
elif 0.0 <= float_value <= 1.0:
return float_value # Return as float for percentages
else:
# It's a float, but not in the 0-1 range for percentages,
# and not a whole number. This case implies an invalid input if
# only integers or 0-1 floats are allowed.
# If non-0-1 floats that are not whole numbers are also invalid, this error is correct.
# If they *are* valid under some other condition, this logic needs adjustment.
# Based on the original docstring, it seems only integers or 0-1 floats are desired.
raise click.BadParameter(
f"Value '{value}' is not a whole number, nor a valid percentage (0.0 to 1.0)."
)
except ValueError:
# Conversion to float failed, so it's not a number.
raise click.BadParameter(
f"Value '{value}' is not a valid number (integer or percentage format)."
)
except TypeError: # If value is already a number but of unexpected type for some operations
raise click.BadParameter(
f"Unexpected type for value '{value}'. Expected a string or number."
)