From a99bf19e490deb1da696d664aafaa4dca38e9d9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Andreatta?= Date: Mon, 27 Apr 2026 20:06:52 +0200 Subject: [PATCH 1/3] [FIX] odev: sort help arguments and fix alignment - Arguments in `odev help ` are now sorted alphabetically (positionals first, optionals next, greedy catch-all last). - Fixed a long-standing alignment bug in the help output where the first line of an indented block (arguments, descriptions, command lists) was often double-indented or misaligned. - Refactored `string.normalize_indent` to use `inspect.cleandoc` for more robust docstring handling. - Unified indentation management in `HelpCommand` by using a consistent column-0 placeholder pattern. Assisted-by: antigravity --- odev/commands/database/run.py | 1 + odev/commands/utilities/help.py | 14 ++++++-------- odev/common/commands/base.py | 25 ++++++++++++++++++++----- odev/common/string.py | 11 +++++------ 4 files changed, 32 insertions(+), 19 deletions(-) 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..0077032d 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. + # Re-order the internal dictionary to ensure *... arguments are last, + # and all other arguments are sorted alphabetically. # 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, - ) + 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("-") + else: + sort_name = name.lstrip("-") + + return (greedy, positional, sort_name.lower()) + + sorted_arguments = sorted(cls._arguments.items(), key=argument_sort_key) cls._arguments = defaultdict(dict, sorted_arguments) @classmethod diff --git a/odev/common/string.py b/odev/common/string.py index d7c5df0a..bbde1f7d 100644 --- a/odev/common/string.py +++ b/odev/common/string.py @@ -36,10 +36,9 @@ 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() + import inspect # noqa: PLC0415 + + return inspect.cleandoc(text).strip() def short_help(name: str, description: str, indent_len: int = 0) -> str: @@ -60,14 +59,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: From 4fe648d40a17c4f0ecc634466c0d524075997d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Andreatta?= Date: Mon, 27 Apr 2026 21:52:32 +0200 Subject: [PATCH 2/3] fix: restore positional argument order and robust logging level parsing - Fix positional argument sorting in base.py to preserve declaration order, preventing regressions in commands like 'restore'. - Improve logging.py to avoid misinterpreting '-v' from tools like pytest as a log level. Assisted-by: antigravity --- odev/common/commands/base.py | 14 +++++++------- odev/common/logging.py | 18 +++++++++--------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/odev/common/commands/base.py b/odev/common/commands/base.py index 0077032d..f15407cf 100644 --- a/odev/common/commands/base.py +++ b/odev/common/commands/base.py @@ -150,10 +150,10 @@ def convert_arguments(cls) -> None: cls._arguments[argument_name].update(**argument_dict) # Re-order the internal dictionary to ensure *... arguments are last, - # and all other arguments are sorted alphabetically. - # 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. + # 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]) @@ -166,10 +166,10 @@ def argument_sort_key(item): # 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("-") - else: - sort_name = name.lstrip("-") + return (greedy, positional, sort_name.lower()) - 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) 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", From cbd4df52bf58b69e56918902eb7bf111ba0864f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Andreatta?= Date: Tue, 28 Apr 2026 12:21:56 +0200 Subject: [PATCH 3/3] [REF] common: move inspect import to top-level in string.py Assisted-by: gemini-3-flash --- odev/common/string.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/odev/common/string.py b/odev/common/string.py index bbde1f7d..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,8 +37,6 @@ def normalize_indent(text: str) -> str: if not text: return "" - import inspect # noqa: PLC0415 - return inspect.cleandoc(text).strip()