diff --git a/.coveragerc b/.coveragerc index 7091fa23..43f1bd27 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,5 +1,11 @@ +[run] +# Trace only odev/ so temp plugin files (e.g. test_16 cycle manifests) are not recorded. +source = odev + [report] omit = */tests/* +fail_under = 60 +show_missing = true exclude_lines = pragma: no cover raise NotImplementedError diff --git a/.github/workflows/odev.yml b/.github/workflows/odev.yml index a3ed95a9..48f4f7f7 100644 --- a/.github/workflows/odev.yml +++ b/.github/workflows/odev.yml @@ -6,6 +6,10 @@ on: - opened - reopened - synchronize + push: + branches: + - beta + workflow_dispatch: jobs: @@ -36,6 +40,14 @@ jobs: uses: actions/setup-python@v5.0.0 with: python-version: '3.10' + cache: pip + cache-dependency-path: .pre-commit-config.yaml + + - name: cache-pre-commit + uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} - name: run-pre-commit uses: pre-commit/action@v3.0.0 @@ -54,19 +66,33 @@ jobs: - "3.10" - "3.11" - "3.12" + - "3.13" steps: + - name: checkout-repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + - name: setup-python + id: setup-python uses: actions/setup-python@v5.0.0 with: + # Matrix Python runs pytest. Extra interpreters are available if tests or + # tooling need them; database command tests mock odoo-bin and do not clone Odoo. python-version: | - 3.10 ${{ matrix.python-version }} + 3.10 3.12 architecture: x64 + cache: pip + cache-dependency-path: | + requirements.txt + requirements-dev.txt - name: setup-system-dependencies - uses: awalsh128/cache-apt-pkgs-action@latest + uses: awalsh128/cache-apt-pkgs-action@v1.5.3 with: packages: postgresql postgresql-client python3-pip libldap2-dev libpq-dev libsasl2-dev build-essential python3-dev libffi-dev version: 1.1 @@ -75,46 +101,36 @@ jobs: run: | sudo service postgresql start sudo -u postgres createuser -s $USER + for i in {1..10}; do pg_isready -h localhost -p 5432 && exit 0; sleep 1; done + exit 1 - - name: checkout-repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} - - - name: restore-odoo-repositories - id: restore-odoo-repositories - uses: actions/cache/restore@v4 - with: - path: ~/odoo/repositories - key: odoo-repositories-${{ matrix.python-version }} - - - name: clone-odoo-repositories - if: steps.restore-odoo-repositories.outputs.cache-hit != 'true' - run: | - git clone --depth 1 https://github.com/odoo/odoo ~/odoo/repositories/odoo/odoo --branch master - cd ~/odoo/repositories/odoo/odoo - git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*" - git fetch --depth 1 origin 18.0 - - - name: save-odoo-repositories - if: steps.restore-odoo-repositories.outputs.cache-hit != 'true' - id: save-odoo-repositories - uses: actions/cache/save@v4 + - name: cache-python-venv + id: cache-venv + uses: actions/cache@v4 with: - path: ~/odoo/repositories - key: odoo-repositories-${{ matrix.python-version }} + path: .venv-ci + key: ${{ runner.os }}-py${{ steps.setup-python.outputs.python-version }}-venv-${{ hashFiles('requirements.txt', 'requirements-dev.txt') }} - name: setup-python-requirements run: | - python -m ensurepip --upgrade - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi + if [ "${{ steps.cache-venv.outputs.cache-hit }}" != 'true' ]; then + python -m venv .venv-ci + .venv-ci/bin/python -m pip install --upgrade pip + if [ -f requirements.txt ]; then .venv-ci/bin/pip install -r requirements.txt; fi + if [ -f requirements-dev.txt ]; then .venv-ci/bin/pip install -r requirements-dev.txt; fi + fi + echo "${{ github.workspace }}/.venv-ci/bin" >> "$GITHUB_PATH" - name: run-unit-tests id: unit-tests env: - POSTGRES_HOST: postgres + POSTGRES_HOST: localhost POSTGRES_PORT: 5432 + PYTHONPATH: ${{ github.workspace }} run: | - coverage run -m pytest ./tests --exitfirst + if [ "${{ matrix.python-version }}" = "3.12" ]; then + coverage run -m pytest + coverage report + else + pytest + fi diff --git a/.gitignore b/.gitignore index 9ed155b4..bf6a6572 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,10 @@ tests/plugins/* # --- Temporary and testing files _*/ tmp/ + +# --- AI files +.cursor/ +.agents/ +.gemini/ +GEMINI.md +AGENTS.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b803cb3c..4fa8d108 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -87,11 +87,6 @@ repos: - id: pretty-format-json args: [--autofix] - - repo: https://github.com/asottile/pyupgrade - rev: v3.20.0 - hooks: - - id: pyupgrade - - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: diff --git a/README.md b/README.md index 4ecbe1ef..f5969a1e 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,6 @@ Automate common tasks relative to working with Odoo development databases. - - -- [ODEV](#odev) - - [About](#about) - - [Requirements](#requirements) - - [Installation](#installation) - - [Contributing](#contributing) - - [Features](#features) - - [Commands](#commands) - - [Plugins](#plugins) - - [Known Plugins](#known-plugins) - - [Credentials](#credentials) - - - ## About Odev is a multi-purpose tool designed for making the life of Odoo developers and support analysts easier. @@ -24,6 +9,10 @@ Odev is a multi-purpose tool designed for making the life of Odoo developers and It provides wrapper scripts around common tasks, speeding up the whole process of working with databases and allowing shortcuts to otherwise lengthy commands. +## Documentation + +Full documentation for all commands and features is available in the [Odev Wiki](https://github.com/odoo-odev/odev/wiki). + ## Requirements Before you can run this tool, make sure the below requirements are set on your system: @@ -72,123 +61,3 @@ You have ideas to share but you don't want to dive in `odev`'s source code? No w feature. Check the [Contribution Guide](./docs/CONTRIBUTING.md) for more details about the contribution process. - -## Features - -### Commands - -Odev works with subcommands, each having specific effects. - -**Usage:** `odev ` - -Arguments in square brackets (`[arg]`) are optional and can be omitted, arguments in curvy brackets (`{arg}`) are -options to choose from, arguments without brackets (`arg`) are required. - -To see the list of all commands run `odev help`. - -To get help on a specific command and its usage, use `odev help `. - -### Plugins - -Odev can be extended with plugins that are loaded from external GitHub repositories, they could be public or private to -your organizations and allow to add new features and commands or modify existing ones. - -Plugins can be enabled with the predefined command `odev plugin --enable `. - -#### Known Plugins - -[plugin-ai-translation]: https://github.com/odoo-odev/odev-plugin-ai-translation -[plugin-ai-scaffold]: https://github.com/odoo-ps/odev-plugin-ai-scaffold -[plugin-editor-vscode]: https://github.com/odoo-odev/odev-plugin-editor-vscode -[plugin-export]: https://github.com/odoo-odev/odev-plugin-export -[plugin-project]: https://github.com/odoo-odev/odev-plugin-project -[plugin-hosted]: https://github.com/odoo-ps/odev-plugin-hosted - -| Name | Description | -| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| [odoo-odev/odev-plugin-ai-translation][plugin-ai-translation] | Generate Odoo module translations using AI. | -| [odoo-odev/odev-plugin-ai-scaffold][plugin-ai-scaffold] | Scaffold Odoo modules using AI, based on a formatted technical specification. | -| [odoo-odev/odev-plugin-editor-vscode][plugin-editor-vscode] | Interact with VSCode, open a debugger session and configure workspaces. | -| [odoo-odev/odev-plugin-export][plugin-export] | Export customizations from a database and convert Studio to code. | -| [odoo-odev/odev-plugin-project][plugin-project] | Follow-up on projects and setup working directories for creating new Odoo modules. | -| [odoo-ps/odev-plugin-hosted][plugin-hosted] | Interact with PaaS (odoo.sh) and SaaS (Odoo Online) databases, requires Odoo Technical Support access level. | - -### Credentials - -To avoid inputting the credentials every time `odev` is run, symmetric encryption is used to store them. This is done -"automagically" with the help of an [`ssh-agent`](https://esc.sh/blog/ssh-agent-windows10-wsl2/)-loaded key. This means -that `ssh-agent` needs to be available in the shell environment the command is being run from, otherwise a warning will -be logged and credentials will need to be inputted every time. If you don't already have a custom script to launch -`ssh-agent`, we recommend using `keychain`, that's an easy option to do that and manage the different keys available -through `ssh-agent`. - -After installing `keychain`, and depending on the shell of your choice, the following lines need to be added to the -`.bashrc`/`.zshrc`: - -```sh -/usr/bin/keychain -q --nogui $HOME/.ssh/id_rsa -source $HOME/.keychain/$HOST-sh -``` - -**Alternatively**, you can save the following script into a new file under `~/.profile.d/start_ssh_agent` and make it -run automatically at startup by adding the line `source ~/.profile.d/start_ssh_agent` to your `~/.bashrc` or `~/.zshrc` -file. - -```sh -#!/bin/sh - -# ============================================================================== -# This script loads declared SSH keys into the running ssh-agent, launching it -# if necessary. This should ideally be sourced in the user's shell profile. -# ============================================================================== - -env=~/.ssh/agent.env - -# --- Declare the path to the keys to add to the agent ------------------------- -declare -a keys=( - "$HOME/.ssh/id_ed25519" # <-- Edit this line to load your own SSH key(s) -) - -# --- Common methods and shortcuts --------------------------------------------- - -agent_load_env() { - test -f "$env" && . "$env" >| /dev/null -} - -agent_start() { - (umask 077; ssh-agent >| "$env") - . "$env" >| /dev/null 2>&1 -} - -agent_add_key() { - ssh-add $key >| /dev/null 2>&1 -} - -agent_add_keys() { - for i in "${keys[@]}"; do - ssh-add "$i" >| /dev/null 2>&1 - done -} - -# --- Load the agent ----------------------------------------------------------- - -agent_load_env - -# agent_run_state: -# 0: agent running with key -# 1: agent running without key -# 2: agent not running -agent_run_state=$(ssh-add -l >| /dev/null 2>&1; echo $?) - -# --- Load the keys to the agent ----------------------------------------------- - -if [ ! "$SSH_AUTH_SOCK" ] || [ $agent_run_state = 2 ]; then - agent_start - agent_add_keys -elif [ "$SSH_AUTH_SOCK" ] && [ $agent_run_state = 1 ]; then - agent_add_keys -fi - -unset env - -``` diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 91e5c4e2..a5cc2369 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -65,11 +65,11 @@ Start with your changes! ### Test your changes Happy with your modifications to the odev codebase? Then it's time to test it and make sure everything still works as -expected! Run `coverage run -m pytest tests` in your terminal, if any of the tests fails you will need to correct your -code until it passes. +expected! Run `coverage run -m pytest tests && coverage report` in your terminal, if any of the tests fails you will need +to correct your code until it passes. You implemented a brand new feature? Then it's probably good to implement new tests for it! Check what's inside the -[tests](./_tests/) directory for examples. +[tests](../tests/) directory for examples. If you want to check the coverage of your code, you can now run `coverage html` and open the file `./htmlcov/index.html` in your favorite browser. diff --git a/odev.sh b/odev.sh index cc212461..dc5227db 100755 --- a/odev.sh +++ b/odev.sh @@ -5,7 +5,8 @@ for interpreter in ~/.config/odev/venv/bin/python3 /usr/bin/python3; do if [ -x "$interpreter" ]; then - exec "$interpreter" $(readlink -m $(dirname "$0"/..)/../main.py) "$@" + SCRIPT_PATH=$(readlink -f "$0") + exec "$interpreter" "$(dirname "$SCRIPT_PATH")/main.py" "$@" exit $? fi done diff --git a/odev/__main__.py b/odev/__main__.py index 5df45d48..78a1afbd 100644 --- a/odev/__main__.py +++ b/odev/__main__.py @@ -35,7 +35,7 @@ def main(): odev = init_framework() odev.start(start_time) logger.debug(f"Framework started in {monotonic() - start_time:.3f} seconds") - odev.dispatch() + sys.exit(0 if odev.dispatch() else 1) except OdevError as error: logger.error(error) diff --git a/odev/_version.py b/odev/_version.py index 1c533a3d..3273ca43 100644 --- a/odev/_version.py +++ b/odev/_version.py @@ -22,4 +22,4 @@ # or merged change. # ------------------------------------------------------------------------------ -__version__ = "4.24.0" +__version__ = "4.29.3" diff --git a/odev/commands/database/cloc.py b/odev/commands/database/cloc.py index 1c4d4bba..7facb90b 100644 --- a/odev/commands/database/cloc.py +++ b/odev/commands/database/cloc.py @@ -40,7 +40,7 @@ def run(self): process = self.odoobin.run(args=self.args.odoo_args, subcommand=self._name, stream=False) - if process is None: + if process is None or process.returncode: raise self.error("Failed to fetch cloc result.") headers = [ diff --git a/odev/commands/database/create.py b/odev/commands/database/create.py index aae9971d..57432319 100644 --- a/odev/commands/database/create.py +++ b/odev/commands/database/create.py @@ -9,7 +9,6 @@ from odev.common.databases import LocalDatabase from odev.common.errors.odev import OdevError from odev.common.odev import logger -from odev.common.odoobin import OdoobinProcess from odev.common.version import OdooVersion @@ -39,24 +38,22 @@ class CreateCommand(OdoobinTemplateCommand): ) version_argument = args.String( name="version", + aliases=["-V", "--version"], description="""The Odoo version to use for the new database. If not specified and a template is provided, the version of the template database will be used. Otherwise, the version will default to "master". """, ) - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) + def __init__(self, args, **kwargs) -> None: + if args.new_template and not args.database.endswith(TEMPLATE_SUFFIX): + source_name = args.database + args.database += TEMPLATE_SUFFIX - if self.args.from_template and self.args.new_template: - raise self.error("The arguments `from_template` and `new_template` are mutually exclusive") + if not args.from_template and not args.odoo_args and LocalDatabase(source_name).exists: + args.from_template = source_name - if self.args.new_template: - self.args.from_template = self.args.database - self.args.database += TEMPLATE_SUFFIX - self._database = LocalDatabase(self.args.database) - - self.infer_template_instance() + super().__init__(args, **kwargs) @property def _database_exists_required(self) -> bool: @@ -66,8 +63,9 @@ def _database_exists_required(self) -> bool: @property def version(self) -> OdooVersion: """Odoo version to use for the new database.""" - if self.args.version: - return OdooVersion(self.args.version) + version = getattr(self.args, "version", None) or getattr(self, "version_argument", None) + if version: + return OdooVersion(version) if hasattr(self, "_template") and self._template: with self._template: @@ -177,14 +175,14 @@ def initialize_database(self) -> None: if not re.search(r"--st(op-after-init)?", joined_args): args.append("--stop-after-init") - process = self.odoobin or OdoobinProcess(self._database) + process = self.odoobin or self.odev.odoobin_process_class(self._database) process.with_edition("enterprise" if self.args.enterprise else "community") process.with_version(self.version) process.with_venv(self.venv) process.with_worktree(self.worktree) try: - run_process = process.run(args=args, progress=self.odoobin_progress) + run_process = process.run(args=args, stream_filter=self.odoobin_progress, prepare=True) self.console.print() except OdevError as error: logger.error(str(error)) diff --git a/odev/commands/database/delete.py b/odev/commands/database/delete.py index 5509d366..548ec6a4 100644 --- a/odev/commands/database/delete.py +++ b/odev/commands/database/delete.py @@ -76,7 +76,7 @@ def run(self): databases_list: str = string.join_and([f"{db!r}" for db in databases]) logger.warning(f"You are about to delete the following databases: {databases_list}") - if not self.console.confirm("Are you sure?", default=False): + if not self.console.confirm("Are you sure?", default=self.args.bypass_prompt): raise self.error("Command aborted") tracker = progress.Progress() @@ -84,7 +84,7 @@ def run(self): tracker.start() for database in databases: - with silence_loggers(__name__): + with silence_loggers(__name__), self.console.force_bypass_prompt(force=True): self.delete_one(LocalDatabase(database)) tracker.update(task, advance=1) diff --git a/odev/commands/database/deploy.py b/odev/commands/database/deploy.py index 9e96af41..925135e7 100644 --- a/odev/commands/database/deploy.py +++ b/odev/commands/database/deploy.py @@ -32,7 +32,7 @@ def run(self): odoobin: OdoobinProcess = ( self._database.process if isinstance(self._database, LocalDatabase) - else OdoobinProcess(LocalDatabase(self.odev.name), version=self._database.version) + else self.odev.odoobin_process_class(LocalDatabase(self.odev.name), version=self._database.version) ) url = self._database.url if isinstance(self._database, RemoteDatabase) else None diff --git a/odev/commands/database/run.py b/odev/commands/database/run.py index 764a3348..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 @@ -76,4 +77,7 @@ def run(self): if self.odoobin.is_running: raise self.error(f"Database {self._database.name!r} is already running") - self.odoobin.run(args=self.args.odoo_args, progress=self.odoobin_progress) + process = self.odoobin.run(args=self.args.odoo_args, stream_filter=self.odoobin_progress) + + if process and process.returncode: + raise self.error("Odoo process failed") diff --git a/odev/commands/database/test.py b/odev/commands/database/test.py index 4edae165..7ae664bf 100644 --- a/odev/commands/database/test.py +++ b/odev/commands/database/test.py @@ -1,12 +1,16 @@ """Run unit tests on an empty local Odoo database.""" +import os import re from collections import defaultdict from collections.abc import Mapping, MutableMapping from pathlib import Path from typing import cast +import requests + from odev.common import args, string +from odev.common.browsers import Chrome from odev.common.commands import OdoobinCommand from odev.common.console import TableHeader from odev.common.databases import LocalDatabase @@ -37,6 +41,16 @@ class TestCommand(OdoobinCommand): default=["base"], description="Comma-separated list of modules to install for testing. If not set, install the base module.", ) + no_auto_tags = args.Flag( + aliases=["--no-auto-tags"], + default=False, + description="Do not fetch auto-tags from Runbot. By default, auto-tags are fetched and used to exclude flaky tests.", + ) + + @property + def _database_exists_required(self) -> bool: + """Return True if a database has to exist for the command to work.""" + return not bool(self.args.version) def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) @@ -56,6 +70,9 @@ def __init__(self, *args, **kwargs) -> None: self.test_buffer: list[str] = [] """Buffer to store the output of odoo-bin running on the test database.""" + self.auto_tags: list[str] = [] + """List of auto-tags fetched from Runbot.""" + self.last_level = "" def generate_test_database_name(self) -> str: @@ -84,8 +101,8 @@ def create_test_database(self): """Return the arguments to pass to the create command.""" args = ["--bare"] - if self._database.version is not None: - args.extend(["--version", str(self._database.version)]) + if self.version is not None: + args.extend(["--version", str(self.version)]) args.append(self.test_database.name) self.odev.run_command("create", *args) @@ -108,16 +125,26 @@ def run_test_database(self): if not self.test_database.exists: self.create_test_database() - odoobin = self.test_database.process or OdoobinProcess(self.test_database) - odoobin.with_version(self._database.version) - odoobin.with_edition(self._database.edition) + odoobin = self.test_database.process or self.odev.odoobin_process_class(self.test_database) + odoobin.with_version(self.version) + + edition = ( + "enterprise" + if self.args.enterprise or (self._database.exists and self._database.edition == "enterprise") + else "community" + ) + odoobin.with_edition(edition) odoobin.with_venv(self.venv) odoobin.with_worktree(self.worktree) odoobin.additional_addons_paths = cast(OdoobinProcess, self.odoobin).additional_addons_paths try: - odoobin.run(args=args, progress=self.odoobin_progress) + process = odoobin.run(args=args, stream_filter=self.odoobin_progress) + + if process is not None and process.returncode != 0 and not self.test_buffer: + raise self.error("Odoo crashed during initialization. Check the logs above.") + self.print_tests_results() except RuntimeError as error: if self.test_database.process is not None: @@ -126,16 +153,20 @@ def run_test_database(self): def odoobin_progress(self, line: str): """Handle odoo-bin output and fetch information real-time.""" - if re.match(r"^(i?pu?db)?>+", line): + if re.match(r"^(?:ipdb|pudb|pdb)>+|^\(Pdb\)|(?:^>\s+.*\.(?:py|js)\(\d+\))", line): raise self.error("Debugger detected in odoo-bin output, remove breakpoints and try again") problematic_test_levels = ("warning", "error", "critical") match = self._parse_progress_log_line(line) - if match is None: - if self.last_level in problematic_test_levels: + if match is None or not self.args.pretty: + if match is None and self.last_level in problematic_test_levels: self.test_buffer.append(line) + if not self.args.pretty: + self.print(line, highlight=False, soft_wrap=False) + return + color = f"logging.level.{self.last_level}" if self.last_level in problematic_test_levels else "color.black" self.print(string.stylize(line, color), highlight=False, soft_wrap=False) return @@ -147,8 +178,30 @@ def odoobin_progress(self, line: str): self._print_progress_log_line(match) + def apply_auto_tags(self): + """Fetch and apply auto-tags from Runbot.""" + try: + logger.info("Fetching auto-tags from Runbot...") + response = requests.get("https://runbot.odoo.com/runbot/auto-tags", timeout=10) + self.auto_tags = [t.strip() for t in response.text.split(",") if t.strip()] + + for tag in self.auto_tags: + if tag not in self.test_tags: + self.test_tags.append(tag) + except requests.RequestException as error: + logger.warning("Could not fetch auto-tags: %s", error) + def run(self): """Run the command.""" + if not self.args.no_auto_tags: + self.apply_auto_tags() + + if any(tag in self.test_tags for tag in ["clic_all", "click_all", "tours"]): + chrome = Chrome(self.odev) + chrome_bin = chrome.provision() + wrapper = chrome.get_wrapper(chrome_bin) + os.environ["ODOO_BROWSER_BIN"] = str(wrapper) + self.run_test_database() def cleanup(self): diff --git a/odev/commands/database/upgrade_code.py b/odev/commands/database/upgrade_code.py new file mode 100644 index 00000000..61f7e39f --- /dev/null +++ b/odev/commands/database/upgrade_code.py @@ -0,0 +1,198 @@ +import os +from pathlib import Path +from typing import TYPE_CHECKING + +from odev.common import args +from odev.common.commands import OdoobinCommand +from odev.common.logging import logging +from odev.common.version import OdooVersion + + +if TYPE_CHECKING: + from odev.common.commands.database import DatabaseType + + +logger = logging.getLogger(__name__) + + +class UpgradeCodeCommand(OdoobinCommand): + """Run Odoo's native upgrade_code tool (Odoo 18.0+). + + This command uses Odoo's internal upgrade_code scripts to automatically + refactor source code (e.g., renaming to ). + """ + + _name = "upgrade-code" + _database_arg_required = True + + # Standard Odoo environment arguments + version_arg = args.String( + name="version", + aliases=["-V", "--version"], + description="The Odoo version to use.", + ) + venv_arg = args.String( + name="venv", + aliases=["--venv"], + description="Python virtual environment to use.", + ) + worktree_arg = args.String( + name="worktree", + aliases=["-w", "--worktree"], + description="Git worktree to use.", + ) + + # Upgrade specific flags + from_version = args.String( + aliases=["--from"], + description="Run all scripts starting from this version, inclusive.", + ) + to_version = args.String( + aliases=["--to"], + description="Run all scripts until this version, inclusive.", + ) + script = args.String( + aliases=["--script"], + description="Run this single script name.", + ) + glob = args.String( + aliases=["--glob"], + description="Select the files to rewrite (default: **/*).", + ) + dry_run = args.Flag( + aliases=["--dry-run"], + description="Preview changes without writing.", + ) + + # Catch-all for extra arguments + odoo_args = args.String( + nargs="*", + description="Additional arguments to pass to odoo-bin.", + ) + + def infer_database_instance(self) -> "DatabaseType": + """Enforce database name and provide helpful error for paths.""" + if self.database_name and ( + "/" in self.database_name or "\\" in self.database_name or "." in self.database_name + ): + raise self.error( + f"Invalid database name: {self.database_name!r}. " + "odev upgrade-code requires a DATABASE name to resolve the environment context (addons-path, etc.), not a directory path. " + "Use the designated target database for the upgrade." + ) + return super().infer_database_instance() + + @property + def version(self) -> "OdooVersion": + """Default to target version if specified, as upgrade_code belongs to the target version.""" + if not self.args.version and self.args.to_version: + return OdooVersion(self.args.to_version) + return super().version + + def run(self): + """Run the odoo-bin upgrade_code process.""" + if self.odoobin is None: + raise self.error(f"No odoo-bin process could be instantiated for version {self.version!r}") + + # odoo-bin upgrade_code is a special CLI that doesn't support --database or --log-level. + # 1. Subcommand + cmd_args = ["upgrade_code"] + + # 2. Addons path (resolved from OdoobinProcess) + addons_path_str = self._prepare_addons_path() + cmd_args.extend(["--addons-path", addons_path_str]) + + # 3. Upgrade specific flags + self._add_upgrade_flags(cmd_args) + + # 4. Extra arguments & positional path handling + self._handle_extra_args(cmd_args, addons_path_str) + + # 5. Shell expansion protection + self._reconstruct_glob_if_expanded(cmd_args) + + # Execute using the virtual environment directly. + result = self.odoobin.venv.run_script( + self.odoobin.odoobin_path, + cmd_args, + stream=True, + stream_filter=lambda line: (self.odoobin.console.print(line, end=""), line)[1], + ) + if result.returncode not in (0, 1): + raise self.error(f"Odoo exited with code {result.returncode}") + + def _prepare_addons_path(self) -> str: + """Resolve absolute addon paths.""" + custom_paths = list(dict.fromkeys(p.resolve().as_posix() for p in self.odoobin.addons_paths)) + return ",".join(custom_paths) + + def _add_upgrade_flags(self, cmd_args: list[str]): + """Append upgrade-specific flags to command arguments.""" + if self.args.script: + cmd_args.extend(["--script", self.args.script]) + else: + if self.args.from_version: + cmd_args.extend(["--from", self.args.from_version]) + if self.args.to_version: + cmd_args.extend(["--to", self.args.to_version]) + + if self.args.glob: + cmd_args.extend(["--glob", self.args.glob]) + + if self.args.dry_run: + cmd_args.append("--dry-run") + + def _handle_extra_args(self, cmd_args: list[str], addons_path_str: str): + """Handle additional arguments, detecting directories to add to scan paths.""" + if not self.combined_odoo_args: + return + + for arg in self.combined_odoo_args: + path = Path(arg).resolve() + if path.exists() and path.is_dir(): + if path.as_posix() not in addons_path_str: + # Update addons-path argument + addons_path_str += f",{path.as_posix()}" + try: + idx = cmd_args.index("--addons-path") + 1 + cmd_args[idx] = addons_path_str + except (ValueError, IndexError): + pass + elif not (path.exists() and path.is_file()): + cmd_args.append(arg) + + def _reconstruct_glob_if_expanded(self, cmd_args: list[str]): + """Reconstruct glob pattern if it was expanded by the shell.""" + potential_glob_files = [] + if self.args.glob and Path(self.args.glob).exists() and Path(self.args.glob).is_file(): + potential_glob_files.append(Path(self.args.glob).resolve()) + + for arg in self.combined_odoo_args or []: + p = Path(arg).resolve() + if p.exists() and p.is_file(): + potential_glob_files.append(p) + + if len(potential_glob_files) > 1: + common_parent = Path(os.path.commonpath([f.parent for f in potential_glob_files])) + for ap in self.odoobin.addons_paths: + try: + rel_parent = common_parent.relative_to(ap.resolve()) + reconstructed_glob = (rel_parent / "**" / "*").as_posix() + self._update_or_append_arg(cmd_args, "--glob", reconstructed_glob) + + logger.warning( + f"Detected shell expansion of your glob pattern. " + f'Automatically reconstructed target as --glob "{reconstructed_glob}". ' + 'To avoid this in the future, please quote your glob patterns: --glob "**/*"' + ) + break + except ValueError: + continue + + def _update_or_append_arg(self, cmd_args: list[str], flag: str, value: str): + """Update existing flag or append it if not found.""" + try: + idx = cmd_args.index(flag) + 1 + cmd_args[idx] = value + except (ValueError, IndexError): + cmd_args.extend([flag, value]) diff --git a/odev/commands/git/fetch.py b/odev/commands/git/fetch.py index 846fb55b..02598e5a 100644 --- a/odev/commands/git/fetch.py +++ b/odev/commands/git/fetch.py @@ -64,6 +64,10 @@ def grouped_changes(self) -> dict[str, list[tuple[str, int, int]]]: changes: dict[str, list[tuple[str, int, int]]] = {} for repository in self.repositories: + try: + repository.prune_worktrees() + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to prune worktrees for {repository.name!r}: {e}") repository.fetch(detached=False) for name, worktrees in self.grouped_worktrees.items(): @@ -75,7 +79,7 @@ def grouped_changes(self) -> dict[str, list[tuple[str, int, int]]]: if not changes: if self.args.worktree: - raise self.error(f"Worktree with name {self.args.name!r} does not exist") + raise self.error(f"Worktree with name {self.args.worktree!r} does not exist") raise self.error("No worktrees found") return changes diff --git a/odev/commands/git/pull.py b/odev/commands/git/pull.py index 08688c1f..40d310ba 100644 --- a/odev/commands/git/pull.py +++ b/odev/commands/git/pull.py @@ -1,5 +1,7 @@ """Pull changes in local worktrees.""" +from datetime import datetime + from odev.commands.git.fetch import FetchCommand from odev.common import progress from odev.common.logging import logging @@ -12,6 +14,7 @@ class PullCommand(FetchCommand): """Pull changes in local worktrees managed by odev.""" _name = "pull" + _help = "Pull changes in local worktrees managed by odev." def run_hook(self, name: str, changes: list[tuple[str, int, int]]): """Print a summary of the pending changes for a worktree.""" @@ -44,3 +47,5 @@ def run_hook(self, name: str, changes: list[tuple[str, int, int]]): ): worktree.connector.pull_worktrees([worktree], force=True) logger.info(f"Pulled {behind} commits in {worktree.connector.name!r} for version {worktree.branch!r}") + + self.odev.config.repositories.set_date(name, datetime.today()) diff --git a/odev/commands/git/worktree.py b/odev/commands/git/worktree.py index 9711c68b..1fda8292 100644 --- a/odev/commands/git/worktree.py +++ b/odev/commands/git/worktree.py @@ -90,7 +90,8 @@ def create_worktree(self): self.__check_name() if self.args.name in self.grouped_worktrees: - raise self.error(f"Worktree with name '{self.args.name}' already exists") + logger.info(f"Worktree with name '{self.args.name}' already exists") + return with progress.spinner(f"Creating worktree {self.args.name}"): for repository in self.repositories: 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/bash.py b/odev/common/bash.py index 1546fa7f..a46c3b7d 100644 --- a/odev/common/bash.py +++ b/odev/common/bash.py @@ -5,7 +5,7 @@ import os import pty import select -import shlex +import signal import sys import termios import tty @@ -36,7 +36,13 @@ # --- Helpers ------------------------------------------------------------------ -def __run_command(command: str, capture: bool = True, sudo_password: str | None = None) -> CompletedProcess[bytes]: +def __run_command( + command: str, + capture: bool = True, + sudo_password: str | None = None, + env: dict[str, str] | None = None, + input_data: bytes | None = None, +) -> CompletedProcess[bytes]: """Execute a command as a subprocess. If `sudo_password` is provided and not `None`, the command will be executed with elevated privileges. @@ -45,6 +51,7 @@ def __run_command(command: str, capture: bool = True, sudo_password: str | None :param bool capture: Whether to capture the output of the command. :param str sudo_password: The password to use when executing the command with elevated privileges. + :param dict env: The environment variables to use when executing the command. :return: The result of the command execution. :rtype: CompletedProcess """ @@ -57,7 +64,8 @@ def __run_command(command: str, capture: bool = True, sudo_password: str | None shell=True, check=True, capture_output=capture, - input=sudo_password.encode() if sudo_password is not None else None, + input=input_data or (sudo_password.encode() if sudo_password is not None else None), + env=env, ) @@ -80,7 +88,13 @@ def __raise_or_log(exception: CalledProcessError, do_raise: bool) -> None: # --- Public API --------------------------------------------------------------- -def execute(command: str, sudo: bool = False, raise_on_error: bool = True) -> CompletedProcess[bytes] | None: +def execute( + command: str, + sudo: bool = False, + raise_on_error: bool = True, + env: dict[str, str] | None = None, + input_data: str | bytes | None = None, +) -> CompletedProcess[bytes] | None: """Execute a command in the operating system and wait for it to complete. Output of the command will be captured and returned after the execution completes. @@ -96,8 +110,10 @@ def execute(command: str, sudo: bool = False, raise_on_error: bool = True) -> Co :rtype: Optional[CompletedProcess] """ try: - logger.debug(f"Running process: {shlex.quote(command)}") - process_result = __run_command(command) + logger.debug(f"Running process: {command}") + if isinstance(input_data, str): + input_data = input_data.encode() + process_result = __run_command(command, env=env, input_data=input_data) except CalledProcessError as exception: # If already running as root, sudo will not work if not sudo or not os.geteuid(): @@ -112,7 +128,7 @@ def execute(command: str, sudo: bool = False, raise_on_error: bool = True) -> Co return None try: - process_result = __run_command(command, sudo_password=sudo_password) + process_result = __run_command(command, sudo_password=sudo_password, env=env) except CalledProcessError as exception: sudo_password = None __raise_or_log(exception, raise_on_error) @@ -121,15 +137,19 @@ def execute(command: str, sudo: bool = False, raise_on_error: bool = True) -> Co return process_result -def run(command: str) -> CompletedProcess: +def run(command: str, env: dict[str, str] | None = None, input_data: str | bytes | None = None) -> CompletedProcess: """Execute a command in the operating system and wait for it to complete. Output of the command will not be captured and will be printed to the console in real-time. :param str command: The command to execute. + :param dict env: The environment variables to use when executing the command. + :param input_data: The data to pass to the command as stdin. """ - logger.debug(f"Running process: {shlex.quote(command)}") - return __run_command(command, capture=False) + logger.debug(f"Running process: {command}") + if isinstance(input_data, str): + input_data = input_data.encode() + return __run_command(command, capture=False, env=env, input_data=input_data) def detached(command: str) -> Popen[bytes]: @@ -137,42 +157,74 @@ def detached(command: str) -> Popen[bytes]: :param str command: The command to execute. """ - logger.debug(f"Running detached process: {shlex.quote(command)}") + logger.debug(f"Running detached process: {command}") return Popen(command, shell=True, start_new_session=True, stdout=DEVNULL, stderr=DEVNULL) # noqa: S602 - intentional use of shell=True -def stream(command: str) -> Generator[str, None, None]: # noqa: PLR0912 +def _stream_no_tty( + command: str, env: dict[str, str] | None = None, input_data: str | bytes | None = None +) -> Generator[str, None, None]: + """Execute a command in non-interactive mode and yield its output.""" + logger.warning("STDIN is not a TTY, running command in non-interactive mode") + exec_process = execute(command, env=env, input_data=input_data) + + if not exec_process: + yield "" + return + + yield from exec_process.stdout.decode().splitlines() + + +def _write_to_stdout(data: str | bytes) -> None: + """Write data to stdout, handling both bytes and strings and falling back to buffer if necessary.""" + try: + if isinstance(data, str): + sys.stdout.write(data) + else: + sys.stdout.buffer.write(data) + except OSError: + if hasattr(sys.stdout, "buffer"): + sys.stdout.buffer.write(data if isinstance(data, bytes) else data.encode()) + else: + sys.stdout.write(data if isinstance(data, str) else data.decode()) + + +def stream( + command: str, env: dict[str, str] | None = None, input_data: str | bytes | None = None +) -> Generator[str, None, None]: """Execute a command in the operating system and stream its output line by line. :param str command: The command to execute. + :param dict env: The environment variables to use when executing the command. + :param input_data: The data to pass to the command as stdin. """ - logger.debug(f"Streaming process: {shlex.quote(command)}") + logger.debug(f"Streaming process: {command}") if not sys.stdin.isatty(): - logger.warning("STDIN is not a TTY, running command in non-interactive mode") - exec_process = execute(command) - - if not exec_process: - yield "" - return - - yield from exec_process.stdout.decode().splitlines() - + yield from _stream_no_tty(command, env, input_data) return original_tty = termios.tcgetattr(sys.stdin) tty.setraw(sys.stdin.fileno()) master, slave = pty.openpty() + process: Popen | None = None + try: - process = Popen( # noqa: S603 - shlex.split(command), + process = Popen( # noqa: S602 + command, stdout=slave, stderr=slave, stdin=slave, start_new_session=True, - universal_newlines=True, + shell=True, + env=env, ) + if input_data: + if isinstance(input_data, str): + input_data = input_data.encode() + os.write(master, input_data) + received_buffer: bytes = b"" while process.poll() is None: @@ -185,7 +237,8 @@ def stream(command: str) -> Generator[str, None, None]: # noqa: PLR0912 char = os.read(sys.stdin.fileno(), 1) if char == CTRL_C: - process.terminate() + # Kill the entire process group + os.killpg(process.pid, signal.SIGTERM) if char in (CTRL_C, CTRL_D): os.write(master, char) @@ -202,20 +255,17 @@ def stream(command: str) -> Generator[str, None, None]: # noqa: PLR0912 received_buffer += received continue - yield received_buffer.decode() + line = received_buffer.decode().rstrip("\r") received_buffer = b"" - try: - os.write(sys.stdout.fileno(), b"\r") - except OSError: - if hasattr(sys.stdout, "buffer"): - sys.stdout.buffer.write(b"\r") - else: - sys.stdout.write("\r") + _write_to_stdout(b"\r") + + yield line finally: os.close(slave) os.close(master) termios.tcsetattr(sys.stdin, termios.TCSADRAIN, original_tty) - if process.returncode: + + if process is not None and process.returncode: raise CalledProcessError(process.returncode, command) diff --git a/odev/common/browsers.py b/odev/common/browsers.py new file mode 100644 index 00000000..1255cce6 --- /dev/null +++ b/odev/common/browsers.py @@ -0,0 +1,120 @@ +"""Browser provisioning utilities.""" + +import re +import shutil +import subprocess +from functools import lru_cache +from pathlib import Path + +import requests + +from odev.common.logging import logging + + +logger = logging.getLogger(__name__) + + +class Chrome: + """Manages Chrome provisioning and wrapper generation.""" + + VERSION = "145.0.7632.116" + """Fallback version if Runbot fetching fails.""" + + @classmethod + @lru_cache(maxsize=1) + def fetch_version(cls) -> str: + """Fetch the latest Chrome version used by Runbot. + + :return: The Chrome version string (e.g., "145.0.7632.116"). + """ + url = "https://runbot.odoo.com/runbot/dockerfile/tag/odoo:DockerMaster" + try: + logger.debug(f"Fetching Chrome version from {url}") + response = requests.get(url, timeout=10) + response.raise_for_status() + # Parse version from: # Install chrome with values {"chrome_version": "145.0.7632.116-1"} + match = re.search(r'chrome_version": "([\d\.]+)', response.text) + if match: + return match.group(1) + except requests.RequestException as e: + logger.warning(f"Could not fetch Chrome version from Runbot: {e}") + + return cls.VERSION + + def __init__(self, odev): + self.odev = odev + self.version = self.fetch_version() + self.base_path = self.odev.home_path / "browsers" / "chrome" / self.version + # Puppeteer structure: /chrome/linux-/chrome-linux64/chrome + self.executable = self.base_path / "chrome" / f"linux-{self.version}" / "chrome-linux64" / "chrome" + + def provision(self) -> Path | None: + """Ensure the specific version of Chrome is installed. + + :return: Path to the Chrome executable, or None if provisioning failed. + """ + if not self.executable.exists(): + logger.info(f"Provisioning Chrome {self.version} for tours...") + self.base_path.mkdir(parents=True, exist_ok=True) + npx = shutil.which("npx") + if not npx: + logger.warning("npx not found, skipping Chrome provisioning") + return None + + try: + subprocess.run( # noqa: S603 + [ + npx, + "-y", + "@puppeteer/browsers", + "install", + f"chrome@{self.version}", + "--path", + str(self.base_path), + ], + check=True, + capture_output=True, + ) + except subprocess.CalledProcessError as e: + logger.warning(f"Failed to provision Chrome {self.version}: {e.stderr.decode()}") + return None + except OSError as e: + logger.warning(f"OS error provisioning Chrome {self.version}: {e}") + return None + + return self.executable if self.executable.exists() else None + + def get_wrapper(self, chrome_bin: Path | None = None) -> Path: + """Create a Chrome wrapper script with consistent rendering flags. + + :param chrome_bin: Optional path to the Chrome binary to use. + :return: Path to the generated wrapper script. + """ + tmp_dir = self.odev.home_path / "tmp" + tmp_dir.mkdir(parents=True, exist_ok=True) + wrapper = tmp_dir / "odoo-chrome-wrapper" + + search_bins = "google-chrome chromium chromium-browser google-chrome-stable" + if chrome_bin: + search_bins = f"{chrome_bin} {search_bins}" + + wrapper.write_text( + "#!/bin/bash\n" + f"for bin in {search_bins}; do\n" + ' real=$(command -v "$bin" 2>/dev/null)\n' + ' if [ -n "$real" ]; then\n' + ' exec "$real" \\\n' + " --font-render-hinting=none \\\n" + " --force-device-scale-factor=1 \\\n" + " --disable-font-subpixel-positioning \\\n" + " --hide-scrollbars \\\n" + " --window-size=1366,768 \\\n" + " --no-sandbox \\\n" + ' "$@"\n' + " fi\n" + "done\n" + 'echo "Chrome not found" >&2\n' + "exit 1\n" + ) + wrapper.chmod(0o755) + return wrapper diff --git a/odev/common/cache.py b/odev/common/cache.py new file mode 100644 index 00000000..c63dea5d --- /dev/null +++ b/odev/common/cache.py @@ -0,0 +1,32 @@ +"""Caching implementations for various purposes.""" + +from datetime import datetime +from typing import Any + + +class TTLCache: + def __init__(self, ttl: int): + self.ttl = ttl + self.cache = {} + + def get(self, key: str) -> Any | None: + if key not in self.cache: + return None + + if (datetime.now() - self.cache[key]["timestamp"]).total_seconds() > self.ttl: + del self.cache[key] + return None + + return self.cache[key]["value"] + + def set(self, key: str, value: Any) -> None: + self.cache[key] = {"value": value, "timestamp": datetime.now()} + + def __contains__(self, key: str) -> bool: + return self.get(key) is not None + + def __len__(self) -> int: + return len(self.cache) + + def __repr__(self) -> str: + return f"TTLCache(ttl={self.ttl}, cached={len(self.cache)})" diff --git a/odev/common/commands/base.py b/odev/common/commands/base.py index 47c22f40..43ccd961 100644 --- a/odev/common/commands/base.py +++ b/odev/common/commands/base.py @@ -101,10 +101,6 @@ def __init__(self, arguments: Namespace): self._bypass_prompt_orig = self.console.bypass_prompt self.console.bypass_prompt = self.args.bypass_prompt - def __del__(self): - """Reset the bypass prompt flag.""" - self.console.bypass_prompt = self._bypass_prompt_orig - def __repr__(self) -> str: arguments = ", ".join(f"{k}={v!r}" for k, v in self.args.__dict__.items()) return f"{self.__class__.__name__}({arguments})" @@ -133,17 +129,50 @@ def convert_arguments(cls) -> None: """ cls._arguments = defaultdict(dict) + # Collect all arguments from the reversed MRO. + # Arguments with `*...` nargs must be registered last to avoid greedy capture of optional arguments + # defined in sub-commands. + arguments_definitions = [] for parent_cls in cls.__reversed_mro(): - for argument in parent_cls.ordered_arguments_definitions(): - argument_dict = argument[1].to_dict(argument[0]) - argument_name = argument_dict["name"] - argument_dict.setdefault("dest", cls._arguments[argument_name].get("dest", argument_name)) - argument_dict.setdefault("aliases", cls._arguments[argument_name].get("aliases", [argument_name])) + arguments_definitions.extend(parent_cls.ordered_arguments_definitions()) + + arguments_definitions.sort(key=lambda argument: 1 if getattr(argument[1], "nargs", None) == "*..." else 0) + + for argument in arguments_definitions: + argument_dict = argument[1].to_dict(argument[0]) + argument_name = argument_dict["name"] + argument_dict.setdefault("dest", cls._arguments[argument_name].get("dest", argument_name)) + argument_dict.setdefault("aliases", cls._arguments[argument_name].get("aliases", [argument_name])) + + if argument_name not in argument_dict["aliases"] and not argument_dict["aliases"][0].startswith("-"): + argument_dict["aliases"].insert(0, argument_name) + + cls._arguments[argument_name].update(**argument_dict) + + # 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)} - if argument_name not in argument_dict["aliases"] and not argument_dict["aliases"][0].startswith("-"): - argument_dict["aliases"].insert(0, argument_name) + def argument_sort_key(item): + name, arg_def = item + aliases = arg_def.get("aliases", [name]) + is_optional = any(a.startswith("-") for a in aliases) - cls._arguments[argument_name].update(**argument_dict) + 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 def ordered_arguments_definitions(cls) -> list[tuple[str, args.Argument]]: @@ -234,11 +263,7 @@ def prepare_arguments(cls, parser: ArgumentParser) -> None: if params.get("nargs") == "*...": cls._unknown_arguments_dest = aliases[0] - - # A bug in standard library argparse before python 3.12.7 causes `...` to not work as expected - # when used in conjunction with positional and optional arguments. - # See: https://github.com/python/cpython/issues/59317 - params["nargs"] = "*" if sys.version_info < (3, 12, 7) else "..." + continue if "action" in params: params["action"] = ACTIONS_MAPPING.get(params["action"], params["action"]) @@ -264,6 +289,48 @@ def prepare_parser(cls) -> ArgumentParser: cls.prepare_arguments(parser) return parser + @classmethod + def _rescue_positional_from_unknown_flag( + cls, arguments: Namespace, unknown: list[str], argv: Sequence[str] + ) -> None: + """Rescue values captured by optional positional arguments that are actually arguments + to unknown flags. When using :meth:`parse_known_args`, argparse does not know the arity + of unknown flags. If an unknown flag takes a value (e.g. ``--without-demo all``), argparse + puts the flag in ``unknown`` but the value ``all`` is consumed by the next registered + optional positional (e.g. ``addons``). We detect this by checking whether the captured + positional value appears immediately after one of the unknown flags in the original argv. + + :param arguments: the parsed namespace to inspect and patch. + :param unknown: the list of unrecognized argument strings (modified in place). + :param argv: the original argument list passed to the parser. + """ + argv_list = list(argv) + for arg_name, arg_def in cls._arguments.items(): + if arg_def.get("nargs") != "?": + continue + if any(a.startswith("-") for a in arg_def.get("aliases", [arg_name])): + continue + + captured = getattr(arguments, arg_name, None) + if captured is None: + continue + + if isinstance(captured, list): + raw_val = ",".join(map(str, captured)) + else: + raw_val = str(captured) + + if not raw_val: + continue + + indices = [i for i, x in enumerate(argv_list) if x == raw_val] + for val_idx in indices: + if val_idx > 0 and argv_list[val_idx - 1] in unknown: + flag_idx = unknown.index(argv_list[val_idx - 1]) + unknown.insert(flag_idx + 1, raw_val) + setattr(arguments, arg_name, None) + break + @classmethod def parse_arguments(cls, argv: Sequence[str]) -> Namespace: """Parse arguments for the command subclass. @@ -281,6 +348,7 @@ def parse_arguments(cls, argv: Sequence[str]) -> Namespace: arguments = parser.parse_args(argv) else: arguments, unknown = parser.parse_known_args(argv) + cls._rescue_positional_from_unknown_flag(arguments, unknown, argv) setattr( arguments, cls._unknown_arguments_dest, diff --git a/odev/common/commands/database.py b/odev/common/commands/database.py index 3d873605..eb1abf82 100644 --- a/odev/common/commands/database.py +++ b/odev/common/commands/database.py @@ -145,7 +145,7 @@ def infer_database_instance(self) -> DatabaseType: ) if LocalDatabase in allowed_database_classes and re.match( - r"^[a-z0-9][a-z0-9$_.-]+$", self.database_name, re.IGNORECASE + r"^[a-z0-9][a-z0-9$_.:-]+$", self.database_name, re.IGNORECASE ): logger.debug( f"Falling back to non-existing {LocalDatabase._platform_display} database {self.database_name!r}" diff --git a/odev/common/commands/git.py b/odev/common/commands/git.py index 811562c9..c6570509 100644 --- a/odev/common/commands/git.py +++ b/odev/common/commands/git.py @@ -4,8 +4,11 @@ from odev.common import args from odev.common.commands import Command from odev.common.connectors import GitConnector, GitWorktree +from odev.common.logging import logging from odev.common.odoobin import odoo_repositories -from odev.common.version import OdooVersion + + +logger = logging.getLogger(__name__) class GitCommand(Command, ABC): @@ -23,10 +26,12 @@ def worktrees(self) -> Generator[GitWorktree, None, None]: """Iterate over worktrees in Odoo repositories.""" for repository in self.repositories: for worktree in repository.worktrees(): - if not worktree.detached and ( - not self.args.version or OdooVersion(worktree.branch) == OdooVersion(self.args.version) - ): - yield worktree + if not worktree.path.exists(): + logger.debug(f"Skipping missing worktree {worktree.name!r} at {worktree.path!s}") + continue + if hasattr(self, "args") and self.args.version and worktree.name != self.args.version: + continue + yield worktree @property def grouped_worktrees(self) -> dict[str, list[GitWorktree]]: diff --git a/odev/common/commands/odoobin.py b/odev/common/commands/odoobin.py index b3f88405..a6088f56 100644 --- a/odev/common/commands/odoobin.py +++ b/odev/common/commands/odoobin.py @@ -1,5 +1,4 @@ import re -import shlex from abc import ABC from argparse import Namespace from collections.abc import Mapping @@ -41,13 +40,6 @@ class OdoobinCommand(LocalDatabaseCommand, ABC): and no additional addons are specified, the current directory will be added to the list of addons. """, ) - odoo_args = args.String( - nargs="*...", - description="""Additional arguments to pass to odoo-bin; Check the documentation at - https://www.odoo.com/documentation/17.0/fr/developer/cli.html - for the list of available arguments. - """, - ) enterprise = args.Flag( aliases=["-c", "--community"], description="Force running the database without enterprise addons.", @@ -79,46 +71,18 @@ class OdoobinCommand(LocalDatabaseCommand, ABC): description="Do not pretty print the output of odoo-bin but rather display logs as output by the subprocess.", default=True, ) + odoo_args = args.String( + nargs="*...", + description="""Additional arguments to pass to odoo-bin; Check the documentation at + https://www.odoo.com/documentation/17.0/fr/developer/cli.html + for the list of available arguments. + """, + ) # -------------------------------------------------------------------------- # Properties # -------------------------------------------------------------------------- - ODOO_LOG_REGEX: re.Pattern = re.compile( - r""" - (?: - (?P\d{4}-\d{2}-\d{2})\s - (?P