diff --git a/odev/commands/database/run.py b/odev/commands/database/run.py index a0b54ace..6cff540f 100644 --- a/odev/commands/database/run.py +++ b/odev/commands/database/run.py @@ -11,6 +11,7 @@ class RunCommand(OdoobinTemplateCommand): """Run the odoo-bin process for the selected database locally. + The process is run in a python virtual environment depending on the database's odoo version (as defined by the installed `base` module). The command takes care of installing and updating python requirements within the virtual environment and fetching the latest sources in the odoo standard repositories, cloning them diff --git a/odev/commands/utilities/help.py b/odev/commands/utilities/help.py index fbac50fb..3d3725b9 100644 --- a/odev/commands/utilities/help.py +++ b/odev/commands/utilities/help.py @@ -59,18 +59,16 @@ def single_command_help(self) -> str: parser = command.prepare_parser() usage = escape(parser.format_usage().replace("usage:", executable).strip()) + message_indent = 12 message = f""" [bold {Colors.PURPLE}]{executable.upper()} {command._name.upper()}[/bold {Colors.PURPLE}] - {{command._description}} +{string.indent(command._description, message_indent)} [bold][underline]Usage:[/underline] [{Colors.CYAN}]{usage}[/{Colors.CYAN}][/bold] """ - message_indent = string.min_indent(message) message_options_indent = message_indent + 4 - description = string.indent(command._description, message_indent)[message_indent:] - message = message.replace("{command._description}", description) if command._aliases: aliases = f""" @@ -87,7 +85,7 @@ def single_command_help(self) -> str: positionals = f""" [bold underline]Positional Arguments:[/bold underline] - {string.format_options_list(positional_arguments, message_options_indent)} +{string.indent(string.format_options_list(positional_arguments), message_options_indent)} """ message += string.dedent(positionals, message_options_indent - message_indent) @@ -100,7 +98,7 @@ def single_command_help(self) -> str: optionals = f""" [bold underline]Optional Arguments:[/bold underline] - {string.format_options_list(optional_arguments, message_options_indent)} +{string.indent(string.format_options_list(optional_arguments), message_options_indent)} """ message += string.dedent(optionals, message_options_indent - message_indent) @@ -147,14 +145,14 @@ def all_commands_help(self) -> str: blanks=1, ), message_indent, - )[message_indent:] + ) return f""" {message.rstrip()} [bold underline]The following commands are provided:[/bold underline] - {commands_list} +{commands_list} """ def command_names(self) -> str: diff --git a/odev/common/commands/base.py b/odev/common/commands/base.py index e85c20b2..f15407cf 100644 --- a/odev/common/commands/base.py +++ b/odev/common/commands/base.py @@ -149,14 +149,29 @@ def convert_arguments(cls) -> None: cls._arguments[argument_name].update(**argument_dict) - # Re-order the internal dictionary to ensure *... arguments are last. - # This is necessary because Python dictionaries preserve insertion order and - # any argument defined in a subclass would otherwise be registered after - # a greedy catch-all argument defined in a parent class. - sorted_arguments = sorted( - cls._arguments.items(), - key=lambda item: 1 if item[1].get("nargs") == "*..." else 0, - ) + # Re-order the internal dictionary to ensure *... arguments are last, + # all other arguments are sorted alphabetically for flags, but + # positional arguments MUST preserve their declaration order. + original_order = {name: i for i, name in enumerate(cls._arguments)} + + def argument_sort_key(item): + name, arg_def = item + aliases = arg_def.get("aliases", [name]) + is_optional = any(a.startswith("-") for a in aliases) + + greedy = 1 if arg_def.get("nargs") == "*..." else 0 + positional = 0 if not is_optional else 1 + + if is_optional: + # Prefer long aliases for sorting + long_aliases = [a.lstrip("-") for a in aliases if a.startswith("--")] + sort_name = min(long_aliases, key=len) if long_aliases else aliases[0].lstrip("-") + return (greedy, positional, sort_name.lower()) + + # For positional arguments, use the insertion order to preserve declaration sequence + return (greedy, positional, original_order[name]) + + sorted_arguments = sorted(cls._arguments.items(), key=argument_sort_key) cls._arguments = defaultdict(dict, sorted_arguments) @classmethod diff --git a/odev/common/logging.py b/odev/common/logging.py index d04b30cf..0f6af2dd 100644 --- a/odev/common/logging.py +++ b/odev/common/logging.py @@ -37,17 +37,17 @@ ) if __log_level: - LOG_LEVEL = str(__log_level.group(1)).upper().replace("-", "_") - remove = __log_level.group(0).strip().split() - remove_index = sys.argv.index(remove[0]) - del sys.argv[remove_index : remove_index + len(remove)] + potential_log_level = str(__log_level.group(1)).upper().replace("-", "_") - if LOG_LEVEL not in ("CRITICAL", "ERROR", "WARN", "INFO", "DEBUG", "DEBUG_SQL"): - raise ValueError(f"Invalid log level {LOG_LEVEL!r}") + if potential_log_level in ("CRITICAL", "ERROR", "WARN", "INFO", "DEBUG", "DEBUG_SQL"): + LOG_LEVEL = potential_log_level + remove = __log_level.group(0).strip().split() + remove_index = sys.argv.index(remove[0]) + del sys.argv[remove_index : remove_index + len(remove)] - if LOG_LEVEL == "DEBUG_SQL": - LOG_LEVEL = "DEBUG" - DEBUG_SQL = True + if LOG_LEVEL == "DEBUG_SQL": + LOG_LEVEL = "DEBUG" + DEBUG_SQL = True SILENCED_LOGGERS = [ "asyncio", diff --git a/odev/common/string.py b/odev/common/string.py index d7c5df0a..50f6dd60 100644 --- a/odev/common/string.py +++ b/odev/common/string.py @@ -1,6 +1,7 @@ """Shared methods for working with strings.""" import datetime +import inspect import random import re import string as string_module @@ -36,10 +37,7 @@ def normalize_indent(text: str) -> str: if not text: return "" - if "\n" in text.strip(): - min_indent = min(len(line) - len(line.lstrip()) for line in text.splitlines()[1:] if line.strip()) - text = " " * min_indent + text - return textwrap.dedent(text).strip() + return inspect.cleandoc(text).strip() def short_help(name: str, description: str, indent_len: int = 0) -> str: @@ -60,14 +58,14 @@ def format_options_list(elements: list[tuple[str, str]], indent_len: int = 0, bl :param elements: The list of elements to format. A list of tuples containing the name of the element and its description. - :param indent: The number of spaces to indent the list. + :param indent_len: The number of spaces to indent the list. :param blanks: The number of blank lines to add between elements of the list. :return: The list of elements formatted as a string. :rtype: str """ elements_indent = max(len(element[0]) for element in elements) elements_list: str = ("\n" * (blanks + 1)).join([short_help(*element, elements_indent) for element in elements]) - return indent(elements_list, indent_len + 4)[indent_len:] + return indent(elements_list, indent_len + 4) def indent(text: str, indent: int = 0) -> str: