From 6d246deb940ae0c4448a49a3fdfd53b5bed9a437 Mon Sep 17 00:00:00 2001 From: trisdoan Date: Mon, 22 Sep 2025 12:16:27 +0700 Subject: [PATCH 01/12] chore: add next-step to improve pulling code strategy --- src/rodoo/runner.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/rodoo/runner.py b/src/rodoo/runner.py index 78858da..fd644f1 100644 --- a/src/rodoo/runner.py +++ b/src/rodoo/runner.py @@ -197,6 +197,7 @@ def _create_progress(self): transient=True, ) + # TODO: how about take advantage of git-autoshare def _setup_odoo_source(self): if not self.odoo_src_path.exists(): with self._create_progress() as progress: From 3df09a1d1cded7df0d7a0b738e04f112de4476dd Mon Sep 17 00:00:00 2001 From: trisdoan Date: Fri, 5 Sep 2025 10:06:57 +0700 Subject: [PATCH 02/12] feat: add update command Introduce update command: used to clone/fetch given odoo version src codes --- src/rodoo/cli.py | 45 ++++++++++++++++++++++++++++++- src/rodoo/utils.py | 67 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 src/rodoo/utils.py diff --git a/src/rodoo/cli.py b/src/rodoo/cli.py index 89e1c4a..5692cd6 100644 --- a/src/rodoo/cli.py +++ b/src/rodoo/cli.py @@ -9,10 +9,11 @@ from pathlib import Path import typer -from typing import Optional +from typing import Optional, List from rodoo.runner import Runner from rodoo.output import Output from rodoo.exceptions import UserError +from rodoo.utils import perform_update from rodoo.config import ( ConfigFile, load_and_merge_profiles, @@ -211,5 +212,47 @@ def start( raise typer.Exit(1) +@app.command() +def update( + versions: Optional[str] = typer.Option( + None, "--versions", "-v", help="Odoo version(s) to update, comma-separated" + ), +): + """ + Clone and update Odoo src code + """ + source_path = Path.home() / ".rodoo" / "src" + source_path.mkdir(parents=True, exist_ok=True) + + versions_to_update: List[str] = [] + if versions: + versions_to_update = [v.strip() for v in versions.split(",")] + else: + # scan the source directory to find all existing versions to update. + Output.info( + f"No versions specified. Scanning {source_path} for existing versions..." + ) + existing_versions = [] + for item in source_path.iterdir(): + if item.is_dir(): + try: + float(item.name) + existing_versions.append(item.name) + except ValueError: + # This ignores non-version directories like the 'odoo' and 'enterprise' repos. + continue + + versions_to_update = sorted(existing_versions) + + if versions_to_update: + perform_update(versions_to_update, source_path) + Output.success("Odoo sources updated successfully.") + else: + Output.error( + f"No installed Odoo versions found in {source_path} to update. " + "To install a new version, use the --versions flag (e.g., rodoo update --versions 17.0)." + ) + + if __name__ == "__main__": app() diff --git a/src/rodoo/utils.py b/src/rodoo/utils.py new file mode 100644 index 0000000..618d2e3 --- /dev/null +++ b/src/rodoo/utils.py @@ -0,0 +1,67 @@ +from pathlib import Path +from typing import List +import subprocess +from rodoo.output import Output + + +def perform_update(versions_to_update: List[str], source_path: Path): + repos = { + "odoo": "https://github.com/odoo/odoo.git", + "enterprise": "https://github.com/odoo/enterprise.git", + } + + # First, ensure the main 'odoo' and 'enterprise' repos are cloned and up-to-date. + for repo_name, repo_url in repos.items(): + repo_path = source_path / repo_name + if not repo_path.exists(): + Output.info(f"Cloning {repo_name} repository from {repo_url}...") + subprocess.run(["git", "clone", repo_url, str(repo_path)], check=True) + else: + Output.info(f"Fetching updates for {repo_name} repository...") + subprocess.run(["git", "fetch", "--prune"], cwd=str(repo_path), check=True) + + # update/create their worktrees. + for version in versions_to_update: + Output.info(f"Processing Odoo version {version}...") + for repo_name in repos: + repo_path = source_path / repo_name + worktree_path = source_path / version / repo_name + + if worktree_path.exists(): + Output.info(f" Updating {repo_name} worktree for version {version}...") + try: + subprocess.run(["git", "pull"], cwd=str(worktree_path), check=True) + except subprocess.CalledProcessError as e: + Output.error( + f"Failed to update {repo_name} for version {version}: {e}" + ) + else: + Output.info(f" Creating {repo_name} worktree for version {version}...") + worktree_path.parent.mkdir(parents=True, exist_ok=True) + try: + branch_exists_cmd = subprocess.run( + [ + "git", + "show-ref", + "--verify", + f"refs/remotes/origin/{version}", + ], + cwd=str(repo_path), + capture_output=True, + text=True, + ) + if branch_exists_cmd.returncode != 0: + Output.warning( + f" Branch '{version}' does not exist in {repo_name} remote. Skipping." + ) + continue + + subprocess.run( + ["git", "worktree", "add", str(worktree_path), version], + cwd=str(repo_path), + check=True, + ) + except subprocess.CalledProcessError as e: + Output.error( + f"Failed to create worktree for {repo_name} version {version}: {e}" + ) From 8443afdc2aa306e9645d1f9dbf868a67aae6dd3e Mon Sep 17 00:00:00 2001 From: trisdoan Date: Fri, 5 Sep 2025 10:14:59 +0700 Subject: [PATCH 03/12] fix: modify behavior of prompt --- src/rodoo/cli.py | 31 +++++++++++++++++++++++-------- src/rodoo/output.py | 4 ++-- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/rodoo/cli.py b/src/rodoo/cli.py index 5692cd6..a2ccc2d 100644 --- a/src/rodoo/cli.py +++ b/src/rodoo/cli.py @@ -57,11 +57,17 @@ def _handle_no_cli_params(profile: Optional[str]) -> dict: profile_name_to_use = profile if not profile_name_to_use: if len(all_profiles) > 1: - profile_name_to_use = typer.prompt( - f"Which profile to run? ({', '.join(all_profiles.keys())})", - default="", - show_default=False, + profiles_list = list(all_profiles.keys()) + profile_display = "\n".join( + [f"[{i+1}] {name}" for i, name in enumerate(profiles_list)] ) + prompt_message = f"Which profile to run:\n{profile_display}\n" + choice = typer.prompt(prompt_message, default="", show_default=False) + + if choice.isdigit() and 1 <= int(choice) <= len(profiles_list): + profile_name_to_use = profiles_list[int(choice) - 1] + else: + profile_name_to_use = choice elif len(all_profiles) == 1: profile_name_to_use = next(iter(all_profiles)) @@ -73,7 +79,7 @@ def _handle_no_cli_params(profile: Optional[str]) -> dict: raise typer.Exit(1) config_path = profile_sources[profile_name_to_use] - if Output.confirm(f"Run with profile '{profile_name_to_use}' from {config_path}?"): + if Output.confirm(f"Run with profile '{profile_name_to_use}' from {config_path}?", default=True): config = all_profiles[profile_name_to_use] else: raise typer.Exit(1) @@ -99,10 +105,19 @@ def _handle_cli_params_present(profile: Optional[str], cli_params: dict) -> dict elif len(profiles_in_cwd) == 1: profile_to_update = next(iter(profiles_in_cwd)) elif len(profiles_in_cwd) > 1: - profile_to_update = typer.prompt( - f"Which profile to update? ({', '.join(profiles_in_cwd.keys())}) [leave blank for none]", - default="", + profiles_list = list(profiles_in_cwd.keys()) + profile_display = "\n".join( + [f"[{i+1}] {name}" for i, name in enumerate(profiles_list)] + ) + prompt_message = ( + f"Which profile to update:\n{profile_display}\n[leave blank for none]" ) + choice = typer.prompt(prompt_message, default="", show_default=False) + + if choice.isdigit() and 1 <= int(choice) <= len(profiles_list): + profile_to_update = profiles_list[int(choice) - 1] + else: + profile_to_update = choice if profile_to_update and profile_to_update in profiles_in_cwd: if Output.confirm( diff --git a/src/rodoo/output.py b/src/rodoo/output.py index 082b8ce..bd29395 100644 --- a/src/rodoo/output.py +++ b/src/rodoo/output.py @@ -19,5 +19,5 @@ def error(message: str): typer.secho(f"✖ {message}", fg=typer.colors.RED, err=True) @staticmethod - def confirm(message: str) -> bool: - return typer.confirm(message) + def confirm(message: str, default: bool = False) -> bool: + return typer.confirm(message, default=default) From cc193473f2b5f51d8598f143348a8c94078b15bb Mon Sep 17 00:00:00 2001 From: trisdoan Date: Fri, 5 Sep 2025 12:25:12 +0700 Subject: [PATCH 04/12] chore: extract util method to separate file --- src/rodoo/cli.py | 189 +-------------------------------------------- src/rodoo/utils.py | 188 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 2 +- 3 files changed, 192 insertions(+), 187 deletions(-) diff --git a/src/rodoo/cli.py b/src/rodoo/cli.py index a2ccc2d..e7c9dc6 100644 --- a/src/rodoo/cli.py +++ b/src/rodoo/cli.py @@ -10,195 +10,12 @@ from pathlib import Path import typer from typing import Optional, List -from rodoo.runner import Runner from rodoo.output import Output from rodoo.exceptions import UserError -from rodoo.utils import perform_update -from rodoo.config import ( - ConfigFile, - load_and_merge_profiles, - create_profile, -) - -app = typer.Typer(pretty_exceptions_enable=False) - - -def _parse_cli_params(args: dict) -> dict: - cli_params = {} - for arg, val in args.items(): - if val is not None: - if arg == "module": - cli_params["modules"] = [m.strip() for m in val.split(",")] - elif arg != "profile": - cli_params[arg] = val - return cli_params - - -def _validate_required_cli_params(cli_params: dict): - if "modules" not in cli_params or "version" not in cli_params: - Output.error( - "Module and version arguments are required when running without a profile or existing configuration." - ) - raise typer.Exit(1) - - -def _handle_no_cli_params(profile: Optional[str]) -> dict: - all_profiles, profile_sources = load_and_merge_profiles() - config = {} - - if not all_profiles: - if Output.confirm("No modules to run. Would you like to create a new profile?"): - profile_name, new_profile, _ = create_profile() - Output.success(f"Created profile '{profile_name}'.") - return new_profile - else: - raise typer.Exit(1) - - profile_name_to_use = profile - if not profile_name_to_use: - if len(all_profiles) > 1: - profiles_list = list(all_profiles.keys()) - profile_display = "\n".join( - [f"[{i+1}] {name}" for i, name in enumerate(profiles_list)] - ) - prompt_message = f"Which profile to run:\n{profile_display}\n" - choice = typer.prompt(prompt_message, default="", show_default=False) - - if choice.isdigit() and 1 <= int(choice) <= len(profiles_list): - profile_name_to_use = profiles_list[int(choice) - 1] - else: - profile_name_to_use = choice - elif len(all_profiles) == 1: - profile_name_to_use = next(iter(all_profiles)) - - if not profile_name_to_use: - raise typer.Exit(1) - - if profile_name_to_use not in all_profiles: - Output.error(f"Profile '{profile_name_to_use}' not found.") - raise typer.Exit(1) - - config_path = profile_sources[profile_name_to_use] - if Output.confirm(f"Run with profile '{profile_name_to_use}' from {config_path}?", default=True): - config = all_profiles[profile_name_to_use] - else: - raise typer.Exit(1) - - return config +from rodoo.utils import perform_update, process_cli_args, construct_runner -def _handle_cli_params_present(profile: Optional[str], cli_params: dict) -> dict: - all_profiles, profile_sources = load_and_merge_profiles() - cwd = str(Path.cwd()) - - profiles_in_cwd = { - name: all_profiles[name] - for name, path in profile_sources.items() - if str(Path(path).parent) == cwd - } - - if profiles_in_cwd: - profile_to_update = None - - if profile: - profile_to_update = profile - elif len(profiles_in_cwd) == 1: - profile_to_update = next(iter(profiles_in_cwd)) - elif len(profiles_in_cwd) > 1: - profiles_list = list(profiles_in_cwd.keys()) - profile_display = "\n".join( - [f"[{i+1}] {name}" for i, name in enumerate(profiles_list)] - ) - prompt_message = ( - f"Which profile to update:\n{profile_display}\n[leave blank for none]" - ) - choice = typer.prompt(prompt_message, default="", show_default=False) - - if choice.isdigit() and 1 <= int(choice) <= len(profiles_list): - profile_to_update = profiles_list[int(choice) - 1] - else: - profile_to_update = choice - - if profile_to_update and profile_to_update in profiles_in_cwd: - if Output.confirm( - f"Update profile '{profile_to_update}' with provided arguments?" - ): - config_path = profile_sources[profile_to_update] - config_file = ConfigFile(config_path) - profiles = config_file.configs.get("profile", {}) - profiles[profile_to_update].update(cli_params) - config_file.update(profile_to_update, profiles[profile_to_update]) - Output.success(f"Profile '{profile_to_update}' updated.") - - # After updating, load the updated config for execution - config = profiles[profile_to_update] - else: - # decline to update profile, run with CLI params directly - _validate_required_cli_params(cli_params) - config = cli_params - else: - # No profile to update or profile not found, run with CLI params directly - _validate_required_cli_params(cli_params) - config = cli_params - else: - # No config file found, run with CLI params directly - _validate_required_cli_params(cli_params) - config = cli_params - - return config - - -def process_cli_args(profile: Optional[str], args: dict) -> dict: - cli_params = _parse_cli_params(args) - - # No CLI arguments provided (except possibly --profile) - if not cli_params: - config = _handle_no_cli_params(profile) - else: - config = _handle_cli_params_present(profile, cli_params) - - if not config.get("modules") or not config.get("version"): - Output.error("No Odoo modules/version specified to run Odoo") - raise typer.Exit(1) - - return config - - -def _construct_runner(config: dict, cli_args: dict) -> Runner: - runner_modules = config.get("modules") - if runner_modules is None and cli_args.get("module") is not None: - runner_modules = [m.strip() for m in cli_args["module"].split(",")] - - runner_kwargs = { - "modules": runner_modules, - "version": config.get("version", cli_args.get("version")), - "python_version": config.get("python_version", cli_args.get("python_version")), - } - - optional_params = { - "force_install": config.get("force_install", cli_args.get("force_install")), - "force_update": config.get("force_update", cli_args.get("force_update")), - "db": config.get("db", cli_args.get("db")), - "paths": config.get("paths"), - "enterprise": config.get("enterprise"), - "extra_params": config.get("extra_params"), - "python_packages": config.get("python_packages"), - "db_host": config.get("db_host"), - "db_user": config.get("db_user"), - "db_password": config.get("db_password"), - "load": config.get("load"), - "workers": config.get("workers"), - "max_cron_threads": config.get("max_cron_threads"), - "limit_time_cpu": config.get("limit_time_cpu"), - "limit_time_real": config.get("limit_time_real"), - "http_interface": config.get("http_interface"), - } - - for key, value in optional_params.items(): - if value is not None: - runner_kwargs[key] = value - - return Runner(**runner_kwargs) +app = typer.Typer(pretty_exceptions_enable=False) @app.command() @@ -220,7 +37,7 @@ def start( config = process_cli_args(profile, args) try: - runner = _construct_runner(config, args) + runner = construct_runner(config, args) runner.run() except UserError as e: Output.error(str(e)) diff --git a/src/rodoo/utils.py b/src/rodoo/utils.py index 618d2e3..6c209fb 100644 --- a/src/rodoo/utils.py +++ b/src/rodoo/utils.py @@ -2,6 +2,14 @@ from typing import List import subprocess from rodoo.output import Output +from rodoo.runner import Runner +import typer +from typing import Optional +from rodoo.config import ( + ConfigFile, + load_and_merge_profiles, + create_profile, +) def perform_update(versions_to_update: List[str], source_path: Path): @@ -65,3 +73,183 @@ def perform_update(versions_to_update: List[str], source_path: Path): Output.error( f"Failed to create worktree for {repo_name} version {version}: {e}" ) + + +def _parse_cli_params(args: dict) -> dict: + cli_params = {} + for arg, val in args.items(): + if val is not None: + if arg == "module": + cli_params["modules"] = [m.strip() for m in val.split(",")] + elif arg != "profile": + cli_params[arg] = val + return cli_params + + +def _validate_required_cli_params(cli_params: dict): + if "modules" not in cli_params or "version" not in cli_params: + Output.error( + "Module and version arguments are required when running without a profile or existing configuration." + ) + raise typer.Exit(1) + + +def _handle_no_cli_params(profile: Optional[str]) -> dict: + all_profiles, profile_sources = load_and_merge_profiles() + config = {} + + if not all_profiles: + if Output.confirm("No modules to run. Would you like to create a new profile?"): + profile_name, new_profile, _ = create_profile() + Output.success(f"Created profile '{profile_name}'.") + return new_profile + else: + raise typer.Exit(1) + + profile_name_to_use = profile + if not profile_name_to_use: + if len(all_profiles) > 1: + profiles_list = list(all_profiles.keys()) + profile_display = "\n".join( + [f"[{i + 1}] {name}" for i, name in enumerate(profiles_list)] + ) + prompt_message = f"Which profile to run:\n{profile_display}\n" + choice = typer.prompt(prompt_message, default="", show_default=False) + + if choice.isdigit() and 1 <= int(choice) <= len(profiles_list): + profile_name_to_use = profiles_list[int(choice) - 1] + else: + profile_name_to_use = choice + elif len(all_profiles) == 1: + profile_name_to_use = next(iter(all_profiles)) + + if not profile_name_to_use: + raise typer.Exit(1) + + if profile_name_to_use not in all_profiles: + Output.error(f"Profile '{profile_name_to_use}' not found.") + raise typer.Exit(1) + + config_path = profile_sources[profile_name_to_use] + if Output.confirm( + f"Run with profile '{profile_name_to_use}' from {config_path}?", default=True + ): + config = all_profiles[profile_name_to_use] + else: + raise typer.Exit(1) + + return config + + +def _handle_cli_params_present(profile: Optional[str], cli_params: dict) -> dict: + all_profiles, profile_sources = load_and_merge_profiles() + cwd = str(Path.cwd()) + + profiles_in_cwd = { + name: all_profiles[name] + for name, path in profile_sources.items() + if str(Path(path).parent) == cwd + } + + if profiles_in_cwd: + profile_to_update = None + + if profile: + profile_to_update = profile + elif len(profiles_in_cwd) == 1: + profile_to_update = next(iter(profiles_in_cwd)) + elif len(profiles_in_cwd) > 1: + profiles_list = list(profiles_in_cwd.keys()) + profile_display = "\n".join( + [f"[{i + 1}] {name}" for i, name in enumerate(profiles_list)] + ) + prompt_message = ( + f"Which profile to update:\n{profile_display}\n[leave blank for none]" + ) + choice = typer.prompt(prompt_message, default="", show_default=False) + + if choice.isdigit() and 1 <= int(choice) <= len(profiles_list): + profile_to_update = profiles_list[int(choice) - 1] + else: + profile_to_update = choice + + if profile_to_update and profile_to_update in profiles_in_cwd: + if Output.confirm( + f"Update profile '{profile_to_update}' with provided arguments?" + ): + config_path = profile_sources[profile_to_update] + config_file = ConfigFile(config_path) + profiles = config_file.configs.get("profile", {}) + profiles[profile_to_update].update(cli_params) + config_file.update(profile_to_update, profiles[profile_to_update]) + Output.success(f"Profile '{profile_to_update}' updated.") + + # After updating, load the updated config for execution + config = profiles[profile_to_update] + else: + # decline to update profile, run with CLI params directly + _validate_required_cli_params(cli_params) + config = cli_params + else: + # No profile to update or profile not found, run with CLI params directly + _validate_required_cli_params(cli_params) + config = cli_params + else: + # No config file found, run with CLI params directly + _validate_required_cli_params(cli_params) + config = cli_params + + return config + + +def process_cli_args(profile: Optional[str], args: dict) -> dict: + cli_params = _parse_cli_params(args) + + # No CLI arguments provided (except possibly --profile) + if not cli_params: + config = _handle_no_cli_params(profile) + else: + config = _handle_cli_params_present(profile, cli_params) + + if not config.get("modules") or not config.get("version"): + Output.error("No Odoo modules/version specified to run Odoo") + raise typer.Exit(1) + + return config + + +def construct_runner(config: dict, cli_args: dict) -> Runner: + runner_modules = config.get("modules") + if runner_modules is None and cli_args.get("module") is not None: + runner_modules = [m.strip() for m in cli_args["module"].split(",")] + + runner_kwargs = { + "modules": runner_modules, + "version": config.get("version", cli_args.get("version")), + "python_version": config.get("python_version", cli_args.get("python_version")), + } + + optional_params = { + "force_install": config.get("force_install", cli_args.get("force_install")), + "force_update": config.get("force_update", cli_args.get("force_update")), + "db": config.get("db", cli_args.get("db")), + "paths": config.get("paths"), + "enterprise": config.get("enterprise"), + "extra_params": config.get("extra_params"), + "python_packages": config.get("python_packages"), + "db_host": config.get("db_host"), + "db_user": config.get("db_user"), + "db_password": config.get("db_password"), + "load": config.get("load"), + "workers": config.get("workers"), + "max_cron_threads": config.get("max_cron_threads"), + "limit_time_cpu": config.get("limit_time_cpu"), + "limit_time_real": config.get("limit_time_real"), + "http_interface": config.get("http_interface"), + } + + for key, value in optional_params.items(): + if value is not None: + runner_kwargs[key] = value + + return Runner(**runner_kwargs) diff --git a/tests/test_cli.py b/tests/test_cli.py index 38bed5b..7d1a1a9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,7 +2,7 @@ from unittest.mock import patch, MagicMock from pathlib import Path from click.exceptions import Exit -from rodoo.cli import ( +from rodoo.utils.misc import ( _parse_cli_params, _validate_required_cli_params, _handle_no_cli_params, From 78e78f51f6d4f201c9cc32fe21c7f2d3067d672a Mon Sep 17 00:00:00 2001 From: trisdoan Date: Mon, 8 Sep 2025 07:59:54 +0700 Subject: [PATCH 05/12] feat: add upgrade command --- src/rodoo/cli.py | 120 ++++++++++++++++++++++++++++++++++- src/rodoo/runner.py | 151 +++++++++++++++----------------------------- 2 files changed, 169 insertions(+), 102 deletions(-) diff --git a/src/rodoo/cli.py b/src/rodoo/cli.py index e7c9dc6..168b6cd 100644 --- a/src/rodoo/cli.py +++ b/src/rodoo/cli.py @@ -30,7 +30,7 @@ def start( None, "--version", "-v", help="Odoo version" ), python_version: Optional[str] = typer.Option(None, "--python", "-py"), - db: Optional[str] = typer.Option(None, help="Database name"), + db: Optional[str] = typer.Option(None, "--db", "-d", help="Database name"), ): """Running Odoo instance""" args = {k: v for k, v in locals().items() if k != "profile" and v is not None} @@ -44,6 +44,124 @@ def start( raise typer.Exit(1) +@app.command() +def upgrade( + profile: Optional[str] = typer.Option( + None, "--profile", "-p", help="Profile name to run Odoo" + ), + module: Optional[str] = typer.Option( + None, "--module", "-m", help="Odoo Module name(s), comma-separated" + ), + version: Optional[float] = typer.Option( + None, "--version", "-v", help="Odoo version" + ), + python_version: Optional[str] = typer.Option(None, "--python", "-py"), + db: Optional[str] = typer.Option(None, "--db", "-d", help="Database name"), +): + """ + Running update Odoo and exist + """ + args = {k: v for k, v in locals().items() if k != "profile" and v is not None} + config = process_cli_args(profile, args) + try: + runner = construct_runner(config, args) + runner.upgrade() + except UserError as e: + Output.error(str(e)) + raise typer.Exit(1) + + +@app.command() +def test( + profile: Optional[str] = typer.Option( + None, "--profile", "-p", help="Profile name to run Odoo" + ), + module: Optional[str] = typer.Option( + None, "--module", "-m", help="Odoo Module name(s), comma-separated" + ), + version: Optional[float] = typer.Option( + None, "--version", "-v", help="Odoo version" + ), + python_version: Optional[str] = typer.Option(None, "--python", "-py"), + db: Optional[str] = typer.Option(None, "--db", "-d", help="Database name"), +): + """ + Running tests + """ + args = {k: v for k, v in locals().items() if k != "profile" and v is not None} + config = process_cli_args(profile, args) + try: + runner = construct_runner(config, args) + runner.run_test() + except UserError as e: + Output.error(str(e)) + raise typer.Exit(1) + + +@app.command() +def shell( + profile: Optional[str] = typer.Option( + None, "--profile", "-p", help="Profile name to run Odoo" + ), + module: Optional[str] = typer.Option( + None, "--module", "-m", help="Odoo Module name(s), comma-separated" + ), + version: Optional[float] = typer.Option( + None, "--version", "-v", help="Odoo version" + ), + python_version: Optional[str] = typer.Option(None, "--python", "-py"), + db: Optional[str] = typer.Option(None, "--db", "-d", help="Database name"), +): + """ + Running Odoo shell + """ + args = {k: v for k, v in locals().items() if k != "profile" and v is not None} + config = process_cli_args(profile, args) + try: + runner = construct_runner(config, args) + runner.run_shell() + except UserError as e: + Output.error(str(e)) + raise typer.Exit(1) + + +@app.command() +def translate( + language: str = typer.Option(..., "--language", "-l", help="Language to translate"), + profile: Optional[str] = typer.Option( + None, "--profile", "-p", help="Profile name to run Odoo" + ), + module: Optional[str] = typer.Option( + None, "--module", "-m", help="Odoo Module name(s), comma-separated" + ), + version: Optional[float] = typer.Option( + None, "--version", "-v", help="Odoo version" + ), + python_version: Optional[str] = typer.Option(None, "--python", "-py"), + db: Optional[str] = typer.Option(None, "--db", "-d", help="Database name"), +): + """ + Export translation file for a module + """ + args = { + k: v + for k, v in locals().items() + if k not in ["profile", "language"] and v is not None + } + config = process_cli_args(profile, args) + try: + runner = construct_runner(config, args) + runner.export_translation(language) + except UserError as e: + Output.error(str(e)) + raise typer.Exit(1) + + +@app.command() +def deps_tree(): + pass + + @app.command() def update( versions: Optional[str] = typer.Option( diff --git a/src/rodoo/runner.py b/src/rodoo/runner.py index fd644f1..981b5f4 100644 --- a/src/rodoo/runner.py +++ b/src/rodoo/runner.py @@ -21,15 +21,15 @@ import ast from rich.progress import Progress, SpinnerColumn, TextColumn import subprocess -import shlex + import os -import time import typer import json from .distro_dependency import get_distro from .config import APP_NAME from .exceptions import UserError +from .odoo_cli import RunCLI, UpgradeCLI, TestCLI, ShellCLI, TranslateCLI from .output import Output ODOO_URL = "git@github.com:odoo/odoo.git" @@ -115,78 +115,82 @@ def __post_init__(self) -> None: module_name = "_".join(self.modules) if self.modules else "nan" self.db = f"v{version_major}_{module_name}" - # prepare odoo cli arguments - self.odoo_cli_params = self._prepare_odoo_cli_params() - ### main API ### def run(self): - self._foreground_run() + self.odoo_cli_params = RunCLI(self).build_command() + cmd = [ + "uv", + "run", + "--python", + self.python_version, + "odoo", + ] + self.odoo_cli_params + + self._foreground_run(cmd) def upgrade(self): - pass + self.odoo_cli_params = UpgradeCLI(self).build_command() + self._foreground_run() def run_test(self): - pass + self.odoo_cli_params = TestCLI(self).build_command() + return self._foreground_run() - # TODO: implement detach mode - def _background_run(self): + def run_shell(self): + self.odoo_cli_params = ShellCLI(self).build_command() cmd = [ "uv", "run", "--python", self.python_version, "odoo", + "shell", ] + self.odoo_cli_params process_env = os.environ.copy() process_env["VIRTUAL_ENV"] = str(self.venv_path) - try: - process = subprocess.Popen( - cmd, - env=process_env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - # Wait for a few seconds to see if it fails - time.sleep(3) - # Check if the process has already terminated - poll_result = process.poll() - if poll_result is not None: - if poll_result != 0: - stdout, stderr = process.communicate() - raise UserError( - f"Odoo failed to start with exit code {poll_result}.\n--- STDERR ---\n{stderr}\n--- STDOUT ---\n{stdout}" - ) - else: - # Assume success after 3s - Output.success( - f"Odoo server started in the background with PID: {process.pid}" + self._foreground_run(cmd) + + def export_translation(self, language: str): + if not self.modules: + raise UserError("At least one module is required for translation export.") + + for module_name in self.modules: + module_path = None + for path in self.modules_paths: + if (path / module_name).exists(): + module_path = path / module_name + break + + if not module_path: + Output.warning( + f"Could not find path for module '{module_name}', skipping." ) - Output.info("You can stop the server using the 'stop' command.") + continue - except FileNotFoundError: - raise UserError(f"Command not found: {cmd[0]}") - except Exception as e: - raise UserError(f"Odoo failed to start. Details:\n{e}") from e + i18n_path = module_path / "i18n" + i18n_path.mkdir(exist_ok=True) + translation_file = i18n_path / f"{language}.po" - def _foreground_run(self): - cmd = [ - "uv", - "run", - "--python", - self.python_version, - "odoo", - ] + self.odoo_cli_params + self.odoo_cli_params = TranslateCLI( + self, module_name, language, translation_file + ).build_command() + self._foreground_run() + Output.success( + f"Translation file for '{module_name}' exported to {translation_file}" + ) + def _foreground_run(self, cmd): process_env = os.environ.copy() process_env["VIRTUAL_ENV"] = str(self.venv_path) try: - subprocess.run(cmd, env=process_env) + subprocess.run(cmd, env=process_env, check=True) except FileNotFoundError: raise UserError(f"Command not found: {cmd[0]}") + except subprocess.CalledProcessError: + raise UserError("Odoo command execution failed.") except Exception as e: raise UserError(f"Odoo failed to start. Details:\n{e}") from e @@ -400,16 +404,6 @@ def _sanity_check(self): error_msg += f"The following transitive dependencies were not found: {', '.join(missing_transitive)}." raise UserError(error_msg) - # TODO: workaround to fix failed buid - def _patch_odoo_requirements(self): - # requirements_file = self.odoo_root_dir / "odoo" / "requirements.txt" - # if not requirements_file.is_file(): - # return - # - # if self.version == 16.0: - # content = requirements_file.read_text() - pass - def _get_module_paths(self): paths = [] if (path := self.odoo_src_path / "addons").exists(): @@ -447,48 +441,3 @@ def _install_extra_python_packages(self): env=env, capture_output=True, ) - - def _prepare_odoo_cli_params(self): - options = [] - - options.extend(["-d", self.db]) - options.extend(["--addons-path", ",".join(str(p) for p in self.modules_paths)]) - - if self.force_install: - options.extend(["-i", ",".join(self.modules)]) - if self.force_update: - options.extend(["-u", ",".join(self.modules)]) - - if self.load: - options.extend(["--load", ",".join(self.load)]) - - if self.extra_params: - options.extend(shlex.split(self.extra_params)) - - managed_params = { - "db_host": self.db_host, - "db_user": self.db_user, - "db_password": self.db_password, - "workers": self.workers, - "max-cron-threads": self.max_cron_threads, - "limit-time-cpu": self.limit_time_cpu, - "limit-time-real": self.limit_time_real, - "http-interface": self.http_interface, - } - - existing_flags = {opt.split("=")[0] for opt in options if opt.startswith("--")} - - for key, value in managed_params.items(): - cli_key = f"--{key}" - if value and cli_key not in existing_flags: - options.extend([cli_key, str(value)]) - - # path to store server pid, used to identify active odoo process - options.extend( - [ - "--pidfile", - "=", - ] - ) - - return options From 6cd9690fe3119023f7dc382838c4c1dd94902d3f Mon Sep 17 00:00:00 2001 From: trisdoan Date: Wed, 10 Sep 2025 15:48:35 +0700 Subject: [PATCH 06/12] chore: clean code --- src/rodoo/cli.py | 32 +++---- src/rodoo/config.py | 16 +++- src/rodoo/distro_dependency.py | 8 +- src/rodoo/exceptions.py | 6 -- src/rodoo/runner.py | 121 ++++++++++++----------- src/rodoo/utils/__init__.py | 0 src/rodoo/utils/exceptions.py | 33 +++++++ src/rodoo/{utils.py => utils/misc.py} | 89 +++++++++++++++-- src/rodoo/utils/odoo.py | 132 ++++++++++++++++++++++++++ src/rodoo/utils/venv.py | 18 ++++ tests/test_runner.py | 2 +- 11 files changed, 359 insertions(+), 98 deletions(-) delete mode 100644 src/rodoo/exceptions.py create mode 100644 src/rodoo/utils/__init__.py create mode 100644 src/rodoo/utils/exceptions.py rename src/rodoo/{utils.py => utils/misc.py} (78%) create mode 100644 src/rodoo/utils/odoo.py create mode 100644 src/rodoo/utils/venv.py diff --git a/src/rodoo/cli.py b/src/rodoo/cli.py index 168b6cd..695bda7 100644 --- a/src/rodoo/cli.py +++ b/src/rodoo/cli.py @@ -10,15 +10,20 @@ from pathlib import Path import typer from typing import Optional, List -from rodoo.output import Output -from rodoo.exceptions import UserError -from rodoo.utils import perform_update, process_cli_args, construct_runner - +from rodoo.utils.exceptions import UserError +from rodoo.utils.misc import ( + Output, + perform_update, + process_cli_args, + construct_runner, + handle_exceptions, +) app = typer.Typer(pretty_exceptions_enable=False) @app.command() +@handle_exceptions def start( profile: Optional[str] = typer.Option( None, "--profile", "-p", help="Profile name to run Odoo" @@ -35,13 +40,8 @@ def start( """Running Odoo instance""" args = {k: v for k, v in locals().items() if k != "profile" and v is not None} config = process_cli_args(profile, args) - - try: - runner = construct_runner(config, args) - runner.run() - except UserError as e: - Output.error(str(e)) - raise typer.Exit(1) + runner = construct_runner(config, args) + runner.run() @app.command() @@ -126,6 +126,7 @@ def shell( @app.command() +@handle_exceptions def translate( language: str = typer.Option(..., "--language", "-l", help="Language to translate"), profile: Optional[str] = typer.Option( @@ -149,12 +150,8 @@ def translate( if k not in ["profile", "language"] and v is not None } config = process_cli_args(profile, args) - try: - runner = construct_runner(config, args) - runner.export_translation(language) - except UserError as e: - Output.error(str(e)) - raise typer.Exit(1) + runner = construct_runner(config, args) + runner.export_translation(language) @app.command() @@ -163,6 +160,7 @@ def deps_tree(): @app.command() +@handle_exceptions def update( versions: Optional[str] = typer.Option( None, "--versions", "-v", help="Odoo version(s) to update, comma-separated" diff --git a/src/rodoo/config.py b/src/rodoo/config.py index ed6ecfe..5e20e9e 100644 --- a/src/rodoo/config.py +++ b/src/rodoo/config.py @@ -12,6 +12,12 @@ FILENAMES = [".rodoo.toml", "rodoo.toml"] APP_NAME = "rodoo" +ODOO_URL = "git@github.com:odoo/odoo.git" +ENT_ODOO_URL = "git@github.com:odoo/enterprise.git" +CONFIG_DIR = user_config_path(appname=APP_NAME, appauthor=False, ensure_exists=True) +BARE_REPO = CONFIG_DIR / "odoo.git" +ENT_BARE_REPO = CONFIG_DIR / "enterprise.git" + class Profile(TypedDict, total=False): modules: list[str] @@ -199,15 +205,17 @@ def load_and_merge_profiles() -> tuple[dict[str, Profile], dict[str, Path]]: def _sanity_check(config: Config) -> None: if not isinstance(config, dict): - Output.error("Configuration must be a dictionary") + raise ConfigurationError("Configuration must be a dictionary") if "profile" in config: if not isinstance(config["profile"], dict): - Output.error("Profiles must be a dictionary") + raise ConfigurationError("Profiles must be a dictionary") for profile_name, profile_config in config["profile"].items(): if not isinstance(profile_config, dict): - Output.error(f"Profile '{profile_name}' must be a dictionary") + raise ConfigurationError( + f"Profile '{profile_name}' must be a dictionary" + ) # TODO: validate if odoo modules found in path if "modules" in profile_config: @@ -216,7 +224,7 @@ def _sanity_check(config: Config) -> None: if "version" in profile_config: version = profile_config["version"] if not isinstance(version, (int, float)): - Output.error( + raise ConfigurationError( f"Version in profile '{profile_name}' must be a number" ) # TODO: a general check for other key in correct data types diff --git a/src/rodoo/distro_dependency.py b/src/rodoo/distro_dependency.py index 3848630..639443c 100644 --- a/src/rodoo/distro_dependency.py +++ b/src/rodoo/distro_dependency.py @@ -298,25 +298,25 @@ def install_dependencies(self, packages: List[str]): if pacman_pkgs: cmd = ["sudo", "pacman", "-S", "--noconfirm"] + pacman_pkgs try: - subprocess.run( + run_subprocess( cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - except Exception as e: + except SubprocessError as e: Output.error(f"Failed to execute command: {e}") if aur_pkgs: cmd = ["yay", "-S", "--noconfirm"] + aur_pkgs try: - subprocess.run( + run_subprocess( cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - except Exception as e: + except SubprocessError as e: Output.error(f"Failed to execute command: {e}") def _get_install_cmd(self, packages: List[str]) -> List[str]: diff --git a/src/rodoo/exceptions.py b/src/rodoo/exceptions.py deleted file mode 100644 index b08d921..0000000 --- a/src/rodoo/exceptions.py +++ /dev/null @@ -1,6 +0,0 @@ -class UserError(Exception): - pass - - -class UserWarning(Exception): - pass diff --git a/src/rodoo/runner.py b/src/rodoo/runner.py index 981b5f4..d8f80d6 100644 --- a/src/rodoo/runner.py +++ b/src/rodoo/runner.py @@ -15,28 +15,21 @@ """ from dataclasses import dataclass -from platformdirs import user_config_path from pathlib import Path from typing import Optional, List import ast from rich.progress import Progress, SpinnerColumn, TextColumn import subprocess -import os import typer import json from .distro_dependency import get_distro -from .config import APP_NAME -from .exceptions import UserError -from .odoo_cli import RunCLI, UpgradeCLI, TestCLI, ShellCLI, TranslateCLI +from .config import CONFIG_DIR, BARE_REPO, ODOO_URL, ENT_ODOO_URL, ENT_BARE_REPO +from rodoo.utils.exceptions import UserError +from rodoo.utils import odoo as odoo_utils from .output import Output - -ODOO_URL = "git@github.com:odoo/odoo.git" -ENT_ODOO_URL = "git@github.com:odoo/enterprise.git" -CONFIG_DIR = user_config_path(appname=APP_NAME, appauthor=False, ensure_exists=True) -BARE_REPO = CONFIG_DIR / "odoo.git" -ENT_BARE_REPO = CONFIG_DIR / "enterprise.git" +from rodoo.utils.venv import in_virtual_env @dataclass @@ -117,7 +110,7 @@ def __post_init__(self) -> None: ### main API ### def run(self): - self.odoo_cli_params = RunCLI(self).build_command() + self.odoo_cli_params = odoo_utils.build_run_command(self) cmd = [ "uv", "run", @@ -129,15 +122,29 @@ def run(self): self._foreground_run(cmd) def upgrade(self): - self.odoo_cli_params = UpgradeCLI(self).build_command() - self._foreground_run() + self.odoo_cli_params = odoo_utils.build_upgrade_command(self) + cmd = [ + "uv", + "run", + "--python", + self.python_version, + "odoo", + ] + self.odoo_cli_params + self._foreground_run(cmd) def run_test(self): - self.odoo_cli_params = TestCLI(self).build_command() - return self._foreground_run() + self.odoo_cli_params = odoo_utils.build_test_command(self) + cmd = [ + "uv", + "run", + "--python", + self.python_version, + "odoo", + ] + self.odoo_cli_params + return self._foreground_run(cmd) def run_shell(self): - self.odoo_cli_params = ShellCLI(self).build_command() + self.odoo_cli_params = odoo_utils.build_shell_command(self) cmd = [ "uv", "run", @@ -147,9 +154,6 @@ def run_shell(self): "shell", ] + self.odoo_cli_params - process_env = os.environ.copy() - process_env["VIRTUAL_ENV"] = str(self.venv_path) - self._foreground_run(cmd) def export_translation(self, language: str): @@ -173,20 +177,25 @@ def export_translation(self, language: str): i18n_path.mkdir(exist_ok=True) translation_file = i18n_path / f"{language}.po" - self.odoo_cli_params = TranslateCLI( + self.odoo_cli_params = odoo_utils.build_translate_command( self, module_name, language, translation_file - ).build_command() - self._foreground_run() + ) + cmd = [ + "uv", + "run", + "--python", + self.python_version, + "odoo", + ] + self.odoo_cli_params + self._foreground_run(cmd) Output.success( f"Translation file for '{module_name}' exported to {translation_file}" ) def _foreground_run(self, cmd): - process_env = os.environ.copy() - process_env["VIRTUAL_ENV"] = str(self.venv_path) - try: - subprocess.run(cmd, env=process_env, check=True) + with in_virtual_env(self.venv_path): + subprocess.run(cmd, check=True) except FileNotFoundError: raise UserError(f"Command not found: {cmd[0]}") except subprocess.CalledProcessError: @@ -275,14 +284,12 @@ def _is_env_ready(self): return False # Check if odoo is installed in the venv - env = os.environ.copy() - env["VIRTUAL_ENV"] = str(self.venv_path) - result = subprocess.run( - ["uv", "pip", "list", "--format", "json"], - env=env, - capture_output=True, - text=True, - ) + with in_virtual_env(self.venv_path): + result = subprocess.run( + ["uv", "pip", "list", "--format", "json"], + capture_output=True, + text=True, + ) if result.returncode != 0: return False @@ -327,25 +334,27 @@ def _setup_python_env(self): ) # install odoo as editable named package - env = os.environ.copy() - env["VIRTUAL_ENV"] = str(self.venv_path) - - subprocess.run( - ["uv", "pip", "install", "-e", f"file://{self.odoo_src_path}#egg=odoo"], - check=True, - env=env, - capture_output=True, - ) - - requirements_file = self.odoo_src_path / "requirements.txt" - if requirements_file.exists(): + with in_virtual_env(self.venv_path): subprocess.run( - ["uv", "pip", "install", "-r", str(requirements_file)], + [ + "uv", + "pip", + "install", + "-e", + f"file://{self.odoo_src_path}#egg=odoo", + ], check=True, - env=env, capture_output=True, ) + requirements_file = self.odoo_src_path / "requirements.txt" + if requirements_file.exists(): + subprocess.run( + ["uv", "pip", "install", "-r", str(requirements_file)], + check=True, + capture_output=True, + ) + def _sanity_check(self): if not self.python_version: raise UserError( @@ -432,12 +441,10 @@ def _install_extra_python_packages(self): if not packages: return - env = os.environ.copy() - env["VIRTUAL_ENV"] = str(self.venv_path) + with in_virtual_env(self.venv_path): + subprocess.run( + ["uv", "pip", "install"] + packages, + check=True, + capture_output=True, + ) - subprocess.run( - ["uv", "pip", "install"] + packages, - check=True, - env=env, - capture_output=True, - ) diff --git a/src/rodoo/utils/__init__.py b/src/rodoo/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/rodoo/utils/exceptions.py b/src/rodoo/utils/exceptions.py new file mode 100644 index 0000000..193bff6 --- /dev/null +++ b/src/rodoo/utils/exceptions.py @@ -0,0 +1,33 @@ +class UserError(Exception): + pass + + +class UserWarning(Exception): + pass + + +class ConfigurationError(UserError): + pass + + +class SubprocessError(UserError): + def __init__(self, message, command, exit_code, stdout, stderr): + super().__init__(message) + self.command = command + self.exit_code = exit_code + self.stdout = stdout + self.stderr = stderr + + def __str__(self): + return f"""{super().__str__()} + Command: {" ".join(str(c) for c in self.command)} + Exit Code: {self.exit_code} + Stdout: {self.stdout} + Stderr: {self.stderr}""" + + +class EnvironmentError(UserError): + """Exception for environment-related errors (e.g., missing dependencies).""" + + pass + diff --git a/src/rodoo/utils.py b/src/rodoo/utils/misc.py similarity index 78% rename from src/rodoo/utils.py rename to src/rodoo/utils/misc.py index 6c209fb..bb31211 100644 --- a/src/rodoo/utils.py +++ b/src/rodoo/utils/misc.py @@ -1,21 +1,24 @@ from pathlib import Path -from typing import List +from typing import List, Optional import subprocess -from rodoo.output import Output -from rodoo.runner import Runner import typer -from typing import Optional +from rodoo.runner import Runner from rodoo.config import ( ConfigFile, load_and_merge_profiles, create_profile, + ODOO_URL, + ENT_ODOO_URL, ) +import functools +from rodoo.utils.exceptions import UserError, SubprocessError +from rodoo.output import Output def perform_update(versions_to_update: List[str], source_path: Path): repos = { - "odoo": "https://github.com/odoo/odoo.git", - "enterprise": "https://github.com/odoo/enterprise.git", + "odoo": ODOO_URL, + "enterprise": ENT_ODOO_URL, } # First, ensure the main 'odoo' and 'enterprise' repos are cloned and up-to-date. @@ -38,8 +41,8 @@ def perform_update(versions_to_update: List[str], source_path: Path): if worktree_path.exists(): Output.info(f" Updating {repo_name} worktree for version {version}...") try: - subprocess.run(["git", "pull"], cwd=str(worktree_path), check=True) - except subprocess.CalledProcessError as e: + run_subprocess(["git", "pull"], cwd=str(worktree_path), check=True) + except SubprocessError as e: Output.error( f"Failed to update {repo_name} for version {version}: {e}" ) @@ -218,7 +221,7 @@ def process_cli_args(profile: Optional[str], args: dict) -> dict: return config -def construct_runner(config: dict, cli_args: dict) -> Runner: +def construct_runner(config: dict, cli_args: dict): runner_modules = config.get("modules") if runner_modules is None and cli_args.get("module") is not None: runner_modules = [m.strip() for m in cli_args["module"].split(",")] @@ -253,3 +256,71 @@ def construct_runner(config: dict, cli_args: dict) -> Runner: runner_kwargs[key] = value return Runner(**runner_kwargs) + + +def run_subprocess( + command: List[str], + check: bool = True, + **kwargs, +) -> subprocess.CompletedProcess: + """ + A wrapper around subprocess.run with standardized error handling. + Args: + command: The command to execute. + check: If True, raise SubprocessError on non-zero exit codes. + **kwargs: Additional arguments to pass to subprocess.run. + Returns: + A subprocess.CompletedProcess instance. + Raises: + SubprocessError: If the command fails and check is True. + """ + # Set text=True by default if not provided and output is captured + if kwargs.get("capture_output") and "text" not in kwargs: + kwargs["text"] = True + + try: + return subprocess.run( + command, + check=check, + **kwargs, + ) + except subprocess.CalledProcessError as e: + raise SubprocessError( + message=f"Command '{' '.join(str(c) for c in command)}' failed.", + command=command, + exit_code=e.returncode, + stdout=e.stdout or "", + stderr=e.stderr or "", + ) from e + except FileNotFoundError as e: + raise SubprocessError( + message=f"Command not found: {command[0]}", + command=command, + exit_code=127, + stdout="", + stderr=str(e), + ) from e + + +def handle_exceptions(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except UserError as e: + if isinstance(e, SubprocessError): + Output.error(f"A command failed to run: {e.args[0]}") + Output.info(f"Command: {' '.join(str(c) for c in e.command)}") + if e.stdout: + Output.info(f"Stdout: {e.stdout}") + if e.stderr: + Output.error(f"Stderr: {e.stderr}") + else: + Output.error(str(e)) + raise typer.Exit(1) + except Exception as e: + Output.error(str(e)) + # TODO: for unexpected errors, log the full traceback for debugging + raise typer.Exit(1) + + return wrapper diff --git a/src/rodoo/utils/odoo.py b/src/rodoo/utils/odoo.py new file mode 100644 index 0000000..2215720 --- /dev/null +++ b/src/rodoo/utils/odoo.py @@ -0,0 +1,132 @@ +import shlex +from typing import Any, Dict, List + + +def _add_params( + options: List[str], params: Dict[str, Any], replace_underscore: bool = True +): + """ + Adds parameters to the options list, avoiding duplicates for --flags. + """ + existing_flags = {opt.split("=")[0] for opt in options if opt.startswith("--")} + for key, value in params.items(): + if replace_underscore: + cli_key = f"--{key.replace('_', '-')}" + else: + cli_key = f"--{key}" + + if value and cli_key not in existing_flags: + options.extend([cli_key, str(value)]) + + +def _get_common_options(runner) -> List[str]: + options: List[str] = [] + options.extend(["-d", runner.db]) + options.extend(["--addons-path", ",".join(str(p) for p in runner.modules_paths)]) + + common_params = { + "db_host": runner.db_host, + "db_user": runner.db_user, + "db_password": runner.db_password, + } + _add_params(options, common_params, replace_underscore=False) + return options + + +def build_run_command(runner) -> List[str]: + """ + Builds the command for running Odoo. + """ + options = _get_common_options(runner) + + if runner.force_install: + options.extend(["-i", ",".join(runner.modules)]) + if runner.force_update: + options.extend(["-u", ",".join(runner.modules)]) + + if runner.load: + options.extend(["--load", ",".join(runner.load)]) + + run_params = { + "workers": runner.workers, + "max_cron_threads": runner.max_cron_threads, + "limit_time_cpu": runner.limit_time_cpu, + "limit_time_real": runner.limit_time_real, + "http_interface": runner.http_interface, + } + _add_params(options, run_params, replace_underscore=True) + + if runner.extra_params: + options.extend(shlex.split(runner.extra_params)) + + return options + + +def build_upgrade_command(runner) -> List[str]: + """ + Builds the command for upgrading Odoo modules. + """ + options = _get_common_options(runner) + options.extend(["--stop-after-init"]) + options.extend(["-u", ",".join(runner.modules)]) + + if runner.extra_params: + options.extend(shlex.split(runner.extra_params)) + + return options + + +def build_test_command(runner) -> List[str]: + """ + Builds the command for running Odoo tests. + """ + options = _get_common_options(runner) + options.extend(["--test-enable"]) + options.extend(["--stop-after-init"]) + options.extend(["-i", ",".join(runner.modules)]) + options.extend(["-u", ",".join(runner.modules)]) + + if runner.extra_params: + options.extend(shlex.split(runner.extra_params)) + + return options + + +def build_shell_command(runner) -> List[str]: + """ + Builds the command for starting an Odoo shell. + """ + options = _get_common_options(runner) + options.extend(["--no-http"]) + + if runner.extra_params: + options.extend(shlex.split(runner.extra_params)) + + return options + + +def build_translate_command(runner, modules, language, translation_file) -> List[str]: + """ + Builds the command for exporting translations. + """ + options: List[str] = [] + options.extend(["-d", runner.db]) + + db_params = { + "db_host": runner.db_host, + "db_user": runner.db_user, + "db_password": runner.db_password, + } + for key, value in db_params.items(): + cli_key = f"--{key}" + options.extend([cli_key, str(value)]) + + options.extend(["--stop-after-init"]) + options.extend(["--modules", modules]) + options.extend(["--i18n-export", str(translation_file)]) + options.extend(["--language", language]) + + if runner.extra_params: + options.extend(shlex.split(runner.extra_params)) + + return options diff --git a/src/rodoo/utils/venv.py b/src/rodoo/utils/venv.py new file mode 100644 index 0000000..7e90001 --- /dev/null +++ b/src/rodoo/utils/venv.py @@ -0,0 +1,18 @@ +import os +from contextlib import contextmanager +from pathlib import Path + + +@contextmanager +def in_virtual_env(venv_path: Path): + original_virtual_env = os.environ.get("VIRTUAL_ENV") + os.environ["VIRTUAL_ENV"] = str(venv_path) + try: + yield + finally: + if original_virtual_env: + os.environ["VIRTUAL_ENV"] = original_virtual_env + else: + if "VIRTUAL_ENV" in os.environ: + del os.environ["VIRTUAL_ENV"] + diff --git a/tests/test_runner.py b/tests/test_runner.py index 0c9b926..672a127 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -2,7 +2,7 @@ from unittest.mock import patch, MagicMock from pathlib import Path from rodoo.runner import Runner -from rodoo.exceptions import UserError +from rodoo.utils.exceptions import UserError class TestRunnerInit: From c692010bffd9c7f6a566bbb805213bdae18aec71 Mon Sep 17 00:00:00 2001 From: trisdoan Date: Tue, 16 Sep 2025 15:13:51 +0700 Subject: [PATCH 07/12] fix: simplify distroDependency --- src/rodoo/distro_dependency.py | 224 +++++---------------------------- src/rodoo/runner.py | 3 +- 2 files changed, 33 insertions(+), 194 deletions(-) diff --git a/src/rodoo/distro_dependency.py b/src/rodoo/distro_dependency.py index 639443c..275d8ea 100644 --- a/src/rodoo/distro_dependency.py +++ b/src/rodoo/distro_dependency.py @@ -1,7 +1,6 @@ from abc import ABC, abstractmethod import distro from typing import List, Optional -from pathlib import Path from rodoo.output import Output import subprocess @@ -18,6 +17,9 @@ def _get_install_cmd(self, packages: List[str]) -> List[str]: pass def install_dependencies(self, packages: List[str]): + if not packages: + return + cmd = self._get_install_cmd(packages) try: subprocess.run( @@ -32,6 +34,7 @@ def install_dependencies(self, packages: List[str]): class Fedora(DistroDependency): packages = [ + "gcc", "createrepo", "libsass", "postgresql", @@ -39,52 +42,7 @@ class Fedora(DistroDependency): "postgresql-devel", "postgresql-libs", "postgresql-server", - "python3-PyPDF2", - "python3-asn1crypto", - "python3-babel", - "python3-cbor2", - "python3-chardet", - "python3-cryptography", - "python3-dateutil", - "python3-decorator", "python3-devel", - "python3-docutils", - "python3-freezegun", - "python3-geoip2", - "python3-gevent", - "python3-greenlet", - "python3-idna", - "python3-jinja2", - "python3-libsass", - "python3-lxml", - "python3-markupsafe", - "python3-mock", - "python3-num2words", - "python3-ofxparse", - "python3-openpyxl", - "python3-passlib", - "python3-pillow", - "python3-polib", - "python3-psutil", - "python3-psycopg2", - "python3-ldap", - "python3-pyOpenSSL", - "python3-pyserial", - "python3-pytz", - "python3-pyusb", - "python3-qrcode", - "python3-reportlab", - "python3-requests", - "python3-rjsmin", - "python3-six", - "python3-stdnum", - "python3-vobject", - "python3-werkzeug", - "python3-wheel", - "python3-xlrd", - "python3-xlsxwriter", - "python3-xlwt", - "python3-zeep", "rpmdevtools", ] @@ -120,65 +78,24 @@ def _get_install_cmd(self, packages: List[str]) -> List[str]: class Debian(DistroDependency): - packages = [] - - def __init__(self, odoo_src_path: Optional[Path] = None): - self.odoo_src_path = odoo_src_path + packages = [ + "gcc", + "libsasl2-dev", + "libldap2-dev", + "libssl-dev", + "libffi-dev", + "libxml2-dev", + "libxslt1-dev", + "libjpeg-dev", + "libpq-dev", + "libsass-dev", + "postgresql", + "postgresql-client", + "postgresql-contrib", + ] def get_packages(self) -> List[str]: - if not self.odoo_src_path: - Output.warning( - "Odoo source path not available for Debian dependency check." - ) - return [] - - control_file = self.odoo_src_path / "debian" / "control" - if not control_file.exists(): - Output.warning(f"Debian control file not found at {control_file}") - return [] - - content = control_file.read_text() - return self._parse_dependencies(content) - - def _parse_dependencies(self, content: str) -> List[str]: - all_deps = [] - in_deps = False - relevant_sections = ["Depends:", "Recommends:"] - - for line in content.splitlines(): - is_new_section = False - for section in relevant_sections: - if line.startswith(section): - in_deps = True - is_new_section = True - line = line.split(":", 1)[1] - break - - if not is_new_section: - stripped_line = line.strip() - if not (stripped_line and stripped_line.startswith("#")) and not ( - line and line[0].isspace() - ): - in_deps = False - - if in_deps: - line = line.split("#")[0].strip() - if not line: - continue - - deps = [ - p.strip() - for p in line.split(",") - if p and not p.strip().startswith("${") - ] - for dep in deps: - # take first alternative - pkg = dep.split("|")[0].strip() - # remove version spec - pkg = pkg.split(" ")[0].strip() - if pkg: - all_deps.append(pkg) - return list(set(all_deps)) + return self.packages def get_missing_installed_packages(self, packages: List[str]) -> List[str]: missing = [] @@ -208,7 +125,7 @@ def install_dependencies(self, packages: List[str]): stderr=subprocess.DEVNULL, ) except subprocess.CalledProcessError as e: - Output.error(f"Failed to run apt-get update: {e.stderr.decode()}") + Output.error(f"Failed to run apt-get update: {e}") return except Exception as e: Output.error(f"An unexpected error occurred during apt-get update: {e}") @@ -222,55 +139,18 @@ def _get_install_cmd(self, packages: List[str]) -> List[str]: class Arch(DistroDependency): packages = [ - "shadow", - "lsb-release", + "gcc", "postgresql", - "python-asn1crypto", - "python-babel", - "python-cbor2", - "python-chardet", - "python-cryptography", - "python-dateutil", - "python-decorator", - "python-docutils", - "python-freezegun", - "python-geoip2", - "python-gevent", - "python-greenlet", - "python-idna", - "python-pillow", - "python-jinja", - "python-libsass", - "python-lxml", - "python-markupsafe", - "python-openpyxl", - "python-passlib", - "python-polib", - "python-psutil", - "python-psycopg2", - "python-pyopenssl", - "python-pytest", # required by python-ofxparse - "python-rjsmin", - "python-qrcode", - "python-reportlab", - "python-requests", - "python-pytz", - "python-urllib3", - "python-vobject", - "python-werkzeug", - "python-xlsxwriter", - "python-xlrd", - "python-zeep", - ] - aur_packages = [ - "python-num2words", - "python-ofxparse", - "python-pypdf2", - "python-stdnum", + "postgresql-libs", + "libxml2", + "libxslt", + "libjpeg", + "libsass", + "python", ] def get_packages(self) -> List[str]: - return self.packages + self.aur_packages + return self.packages def get_missing_installed_packages(self, packages: List[str]) -> List[str]: not_installed = [] @@ -278,59 +158,19 @@ def get_missing_installed_packages(self, packages: List[str]) -> List[str]: result = subprocess.run(["pacman", "-Q", pkg], capture_output=True) if result.returncode != 0: not_installed.append(pkg) - - try: - subprocess.run(["yay", "-V"], check=True, capture_output=True) - for pkg in self.aur_packages: - result = subprocess.run(["yay", "-Q", pkg], capture_output=True) - if result.returncode != 0: - not_installed.append(pkg) - except (FileNotFoundError, subprocess.CalledProcessError): - # yay not available, assume AUR packages are missing - not_installed.extend(self.aur_packages) - return not_installed - def install_dependencies(self, packages: List[str]): - pacman_pkgs = [pkg for pkg in packages if pkg in self.packages] - aur_pkgs = [pkg for pkg in packages if pkg in self.aur_packages] - - if pacman_pkgs: - cmd = ["sudo", "pacman", "-S", "--noconfirm"] + pacman_pkgs - try: - run_subprocess( - cmd, - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except SubprocessError as e: - Output.error(f"Failed to execute command: {e}") - - if aur_pkgs: - cmd = ["yay", "-S", "--noconfirm"] + aur_pkgs - try: - run_subprocess( - cmd, - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except SubprocessError as e: - Output.error(f"Failed to execute command: {e}") - def _get_install_cmd(self, packages: List[str]) -> List[str]: - return [] + return ["sudo", "pacman", "-S", "--noconfirm"] + packages -def get_distro(odoo_src_path: Optional[Path] = None) -> Optional[DistroDependency]: +def get_distro() -> Optional[DistroDependency]: """Factory function to get the correct distro strategy.""" distro_id = distro.id() if distro_id == "fedora": return Fedora() elif distro_id in ["ubuntu", "debian"]: - # pass odoo_src_path to trigger Odoo install script - return Debian(odoo_src_path) + return Debian() elif distro_id == "arch": return Arch() else: diff --git a/src/rodoo/runner.py b/src/rodoo/runner.py index d8f80d6..e44315f 100644 --- a/src/rodoo/runner.py +++ b/src/rodoo/runner.py @@ -270,7 +270,7 @@ def _setup_enterprise_source(self): ) def _install_system_packages(self): - distro = get_distro(odoo_src_path=self.odoo_src_path) + distro = get_distro() if distro: need_to_install = distro.get_missing_installed_packages(distro.packages) if not need_to_install: @@ -447,4 +447,3 @@ def _install_extra_python_packages(self): check=True, capture_output=True, ) - From 44e3ad2ed842cdfaef830f66fd6ed31d423271d0 Mon Sep 17 00:00:00 2001 From: trisdoan Date: Wed, 17 Sep 2025 10:46:05 +0700 Subject: [PATCH 08/12] fix: relocation of odoo codebase --- src/rodoo/cli.py | 3 ++- src/rodoo/config.py | 10 +++++----- src/rodoo/runner.py | 6 +++--- src/rodoo/utils/misc.py | 14 +++++++------- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/rodoo/cli.py b/src/rodoo/cli.py index 695bda7..fbfa325 100644 --- a/src/rodoo/cli.py +++ b/src/rodoo/cli.py @@ -11,6 +11,7 @@ import typer from typing import Optional, List from rodoo.utils.exceptions import UserError +from rodoo.config import APP_HOME from rodoo.utils.misc import ( Output, perform_update, @@ -169,7 +170,7 @@ def update( """ Clone and update Odoo src code """ - source_path = Path.home() / ".rodoo" / "src" + source_path = APP_HOME source_path.mkdir(parents=True, exist_ok=True) versions_to_update: List[str] = [] diff --git a/src/rodoo/config.py b/src/rodoo/config.py index 5e20e9e..1e275ad 100644 --- a/src/rodoo/config.py +++ b/src/rodoo/config.py @@ -5,7 +5,7 @@ from tomlkit.toml_file import TOMLFile from tomlkit.toml_document import TOMLDocument from tomlkit.exceptions import TOMLKitError -from platformdirs import user_config_path +from platformdirs import user_config_path, user_data_path import tomlkit @@ -15,8 +15,9 @@ ODOO_URL = "git@github.com:odoo/odoo.git" ENT_ODOO_URL = "git@github.com:odoo/enterprise.git" CONFIG_DIR = user_config_path(appname=APP_NAME, appauthor=False, ensure_exists=True) -BARE_REPO = CONFIG_DIR / "odoo.git" -ENT_BARE_REPO = CONFIG_DIR / "enterprise.git" +APP_HOME = user_data_path(appname=APP_NAME, appauthor=False, ensure_exists=True) +BARE_REPO = APP_HOME / "odoo.git" +ENT_BARE_REPO = APP_HOME / "enterprise.git" class Profile(TypedDict, total=False): @@ -286,8 +287,7 @@ def create_profile() -> tuple[str, Profile, Path]: if save_in_cwd: config_path = Path.cwd() / "rodoo.toml" else: - config_dir = Path.home() / ".rodoo" - config_dir.mkdir(parents=True, exist_ok=True) + config_dir = user_config_path(appname=APP_NAME, appauthor=False, ensure_exists=True) config_path = config_dir / "rodoo.toml" config_file = ConfigFile(config_path) diff --git a/src/rodoo/runner.py b/src/rodoo/runner.py index e44315f..11fd268 100644 --- a/src/rodoo/runner.py +++ b/src/rodoo/runner.py @@ -1,7 +1,7 @@ """ Runner organizes Odoo source code and development environments in the following directory structure: -~/.config/rodoo/ +~/.local/share/rodoo/ ├── odoo.git/ # Bare repository for Odoo core ├── enterprise.git/ # Bare repository for Odoo Enterprise └── {version}/ # Version-specific directory @@ -25,7 +25,7 @@ import json from .distro_dependency import get_distro -from .config import CONFIG_DIR, BARE_REPO, ODOO_URL, ENT_ODOO_URL, ENT_BARE_REPO +from .config import APP_HOME, BARE_REPO, ODOO_URL, ENT_ODOO_URL, ENT_BARE_REPO from rodoo.utils.exceptions import UserError from rodoo.utils import odoo as odoo_utils from .output import Output @@ -55,7 +55,7 @@ class Runner: http_interface: Optional[str] = "localhost" def __post_init__(self) -> None: - self.app_dir = CONFIG_DIR + self.app_dir = APP_HOME if not self.python_version: venvs_dir = self.app_dir / "venvs" diff --git a/src/rodoo/utils/misc.py b/src/rodoo/utils/misc.py index bb31211..3338b70 100644 --- a/src/rodoo/utils/misc.py +++ b/src/rodoo/utils/misc.py @@ -9,6 +9,8 @@ create_profile, ODOO_URL, ENT_ODOO_URL, + BARE_REPO, + ENT_BARE_REPO, ) import functools from rodoo.utils.exceptions import UserError, SubprocessError @@ -17,16 +19,15 @@ def perform_update(versions_to_update: List[str], source_path: Path): repos = { - "odoo": ODOO_URL, - "enterprise": ENT_ODOO_URL, + "odoo": (ODOO_URL, BARE_REPO), + "enterprise": (ENT_ODOO_URL, ENT_BARE_REPO), } # First, ensure the main 'odoo' and 'enterprise' repos are cloned and up-to-date. - for repo_name, repo_url in repos.items(): - repo_path = source_path / repo_name + for repo_name, (repo_url, repo_path) in repos.items(): if not repo_path.exists(): Output.info(f"Cloning {repo_name} repository from {repo_url}...") - subprocess.run(["git", "clone", repo_url, str(repo_path)], check=True) + subprocess.run(["git", "clone", "--bare", repo_url, str(repo_path)], check=True) else: Output.info(f"Fetching updates for {repo_name} repository...") subprocess.run(["git", "fetch", "--prune"], cwd=str(repo_path), check=True) @@ -34,8 +35,7 @@ def perform_update(versions_to_update: List[str], source_path: Path): # update/create their worktrees. for version in versions_to_update: Output.info(f"Processing Odoo version {version}...") - for repo_name in repos: - repo_path = source_path / repo_name + for repo_name, (_, repo_path) in repos.items(): worktree_path = source_path / version / repo_name if worktree_path.exists(): From 1e26c4e5eea3cc75218de1ef623edeb7ffdd2935 Mon Sep 17 00:00:00 2001 From: trisdoan Date: Mon, 22 Sep 2025 12:36:06 +0700 Subject: [PATCH 09/12] chore: pre-commit --- src/rodoo/cli.py | 1 - src/rodoo/config.py | 6 ++++-- src/rodoo/utils/exceptions.py | 1 - src/rodoo/utils/misc.py | 4 +++- src/rodoo/utils/venv.py | 1 - 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/rodoo/cli.py b/src/rodoo/cli.py index fbfa325..71a8e55 100644 --- a/src/rodoo/cli.py +++ b/src/rodoo/cli.py @@ -7,7 +7,6 @@ 5. Args only, no profile, no config → run directly """ -from pathlib import Path import typer from typing import Optional, List from rodoo.utils.exceptions import UserError diff --git a/src/rodoo/config.py b/src/rodoo/config.py index 1e275ad..8956afe 100644 --- a/src/rodoo/config.py +++ b/src/rodoo/config.py @@ -7,7 +7,7 @@ from tomlkit.exceptions import TOMLKitError from platformdirs import user_config_path, user_data_path import tomlkit - +from rodoo.utils.exceptions import ConfigurationError FILENAMES = [".rodoo.toml", "rodoo.toml"] APP_NAME = "rodoo" @@ -287,7 +287,9 @@ def create_profile() -> tuple[str, Profile, Path]: if save_in_cwd: config_path = Path.cwd() / "rodoo.toml" else: - config_dir = user_config_path(appname=APP_NAME, appauthor=False, ensure_exists=True) + config_dir = user_config_path( + appname=APP_NAME, appauthor=False, ensure_exists=True + ) config_path = config_dir / "rodoo.toml" config_file = ConfigFile(config_path) diff --git a/src/rodoo/utils/exceptions.py b/src/rodoo/utils/exceptions.py index 193bff6..292b90a 100644 --- a/src/rodoo/utils/exceptions.py +++ b/src/rodoo/utils/exceptions.py @@ -30,4 +30,3 @@ class EnvironmentError(UserError): """Exception for environment-related errors (e.g., missing dependencies).""" pass - diff --git a/src/rodoo/utils/misc.py b/src/rodoo/utils/misc.py index 3338b70..d18cd89 100644 --- a/src/rodoo/utils/misc.py +++ b/src/rodoo/utils/misc.py @@ -27,7 +27,9 @@ def perform_update(versions_to_update: List[str], source_path: Path): for repo_name, (repo_url, repo_path) in repos.items(): if not repo_path.exists(): Output.info(f"Cloning {repo_name} repository from {repo_url}...") - subprocess.run(["git", "clone", "--bare", repo_url, str(repo_path)], check=True) + subprocess.run( + ["git", "clone", "--bare", repo_url, str(repo_path)], check=True + ) else: Output.info(f"Fetching updates for {repo_name} repository...") subprocess.run(["git", "fetch", "--prune"], cwd=str(repo_path), check=True) diff --git a/src/rodoo/utils/venv.py b/src/rodoo/utils/venv.py index 7e90001..9130a63 100644 --- a/src/rodoo/utils/venv.py +++ b/src/rodoo/utils/venv.py @@ -15,4 +15,3 @@ def in_virtual_env(venv_path: Path): else: if "VIRTUAL_ENV" in os.environ: del os.environ["VIRTUAL_ENV"] - From 18ef86619e3c9aee8850759c21286f5e7feff78d Mon Sep 17 00:00:00 2001 From: trisdoan Date: Thu, 18 Sep 2025 11:03:01 +0700 Subject: [PATCH 10/12] extract to separate folder --- src/rodoo/{cli.py => cli/main.py} | 5 ----- 1 file changed, 5 deletions(-) rename src/rodoo/{cli.py => cli/main.py} (99%) diff --git a/src/rodoo/cli.py b/src/rodoo/cli/main.py similarity index 99% rename from src/rodoo/cli.py rename to src/rodoo/cli/main.py index 71a8e55..03297d5 100644 --- a/src/rodoo/cli.py +++ b/src/rodoo/cli/main.py @@ -154,11 +154,6 @@ def translate( runner.export_translation(language) -@app.command() -def deps_tree(): - pass - - @app.command() @handle_exceptions def update( From 623ca7ef9f47af334d1c1c15de414f6e852ae295 Mon Sep 17 00:00:00 2001 From: trisdoan Date: Fri, 5 Sep 2025 17:18:27 +0700 Subject: [PATCH 11/12] feat: add oca-related commands --- pyproject.toml | 3 +- src/rodoo/cli/__init__.py | 0 src/rodoo/cli/oca.py | 110 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 src/rodoo/cli/__init__.py create mode 100644 src/rodoo/cli/oca.py diff --git a/pyproject.toml b/pyproject.toml index 622b077..66d0938 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,8 @@ build_command = """ """ [project.scripts] -rodoo = "rodoo.cli:app" +rodoo = "rodoo.cli.main:app" +rodoo-oca = "rodoo.cli.oca:app" [build-system] requires = ["uv_build>=0.8.4,<0.9.0"] diff --git a/src/rodoo/cli/__init__.py b/src/rodoo/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/rodoo/cli/oca.py b/src/rodoo/cli/oca.py new file mode 100644 index 0000000..854608c --- /dev/null +++ b/src/rodoo/cli/oca.py @@ -0,0 +1,110 @@ +""" +OCA repos are organized in the following directory structure: + +~/.local/share/rodoo/ +├── oca/ + ├── oca-repo.git/ +├── odoo.git/ # Bare repository for Odoo core +├── enterprise.git/ # Bare repository for Odoo Enterprise +└── {version}/ # Version-specific directory + ├── odoo/ # Odoo core worktree (from odoo.git) + └── enterprise/ # Odoo Enterprise worktree (from enterprise.git) + └── oca-repo/ # OCA repo worktree (from oca/oca-repo.git) +├── venvs/ # Python virtual environments +│ └── odoo-{version}-py{python_version}/ +├── pid/ # active Odoo process +│ └── + +""" + +import subprocess +from pathlib import Path + +import typer +from typing_extensions import Annotated + +from rodoo.config import APP_HOME +from rodoo.output import Output + + +app = typer.Typer(pretty_exceptions_enable=False) + + +# TODO: update Runner to take oca path into account when loading path + + +def _update_repo(repo_name: str, version: str, config_path: Path): + oca_base_path = config_path / "oca" + bare_repo_path = oca_base_path / f"{repo_name}.git" + repo_url = f"git@github.com:OCA/{repo_name}.git" + + if not bare_repo_path.exists(): + Output.info(f"Cloning bare repository for {repo_name}...") + subprocess.run( + ["git", "clone", "--bare", repo_url, str(bare_repo_path)], + check=True, + capture_output=True, + ) + else: + Output.info(f"Fetching updates for {repo_name}...") + subprocess.run(["git", "fetch", "--prune"], cwd=str(bare_repo_path), check=True) + + version_path = config_path / version + version_path.mkdir(exist_ok=True, parents=True) + worktree_path = version_path / repo_name + + if worktree_path.exists(): + Output.info(f"Updating {repo_name} worktree for version {version}...") + subprocess.run(["git", "pull"], cwd=str(worktree_path), check=True) + else: + Output.info(f"Creating worktree for {repo_name} at version {version}...") + subprocess.run( + [ + "git", + "worktree", + "add", + str(worktree_path), + str(version), + ], + check=True, + cwd=bare_repo_path, + capture_output=True, + ) + + +@app.command() +def update( + repos: Annotated[ + str, + typer.Argument( + help="Comma-separated list of repo names to update. E.g. 'web,social'" + ), + ], + versions: Annotated[ + str, + typer.Argument( + help="Comma-separated list of Odoo versions to update. E.g. '16.0,17.0'" + ), + ], +): + """Clone/Fetch OCA addons repositories.""" + repo_list = [r.strip() for r in repos.split(",")] + version_list = [v.strip() for v in versions.split(",")] + + config_path = APP_HOME + oca_base_path = config_path / "oca" + oca_base_path.mkdir(parents=True, exist_ok=True) + + Output.info( + f"Updating repos: {', '.join(repo_list)} for versions: {', '.join(version_list)}" + ) + + for repo in repo_list: + for version in version_list: + _update_repo(repo, version, config_path) + + Output.success("Finished updating OCA repositories.") + + +if __name__ == "__main__": + app() From 371adca205bffada4d0aa3823656a7cb45c3a486 Mon Sep 17 00:00:00 2001 From: trisdoan Date: Tue, 23 Sep 2025 09:57:09 +0700 Subject: [PATCH 12/12] fix: fix test --- tests/test_cli.py | 54 +++++++++++----------- tests/test_config.py | 16 ++++--- tests/test_runner.py | 104 ------------------------------------------- 3 files changed, 36 insertions(+), 138 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7d1a1a9..143e8f2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,14 +1,14 @@ import pytest from unittest.mock import patch, MagicMock from pathlib import Path -from click.exceptions import Exit +import typer from rodoo.utils.misc import ( _parse_cli_params, _validate_required_cli_params, _handle_no_cli_params, _handle_cli_params_present, process_cli_args, - _construct_runner, + construct_runner, ) @@ -43,7 +43,7 @@ def test_validate_required_params_missing_modules(self): """Test _validate_required_cli_params missing modules.""" cli_params = {"version": 16.0} with patch("rodoo.output.Output.error") as mock_error: - with pytest.raises(Exit): + with pytest.raises(typer.Exit): _validate_required_cli_params(cli_params) mock_error.assert_called_once() @@ -51,15 +51,15 @@ def test_validate_required_params_missing_version(self): """Test _validate_required_cli_params missing version.""" cli_params = {"modules": ["base"]} with patch("rodoo.output.Output.error") as mock_error: - with pytest.raises(Exit): + with pytest.raises(typer.Exit): _validate_required_cli_params(cli_params) mock_error.assert_called_once() class TestHandleNoCliParams: - @patch("rodoo.cli.load_and_merge_profiles") - @patch("rodoo.cli.Output.confirm") - @patch("rodoo.cli.create_profile") + @patch("rodoo.utils.misc.load_and_merge_profiles") + @patch("rodoo.utils.misc.Output.confirm") + @patch("rodoo.utils.misc.create_profile") def test_handle_no_cli_params_no_profiles_create_new( self, mock_create_profile, mock_confirm, mock_load_profiles ): @@ -75,8 +75,8 @@ def test_handle_no_cli_params_no_profiles_create_new( result = _handle_no_cli_params(None) assert result == {"modules": ["base"], "version": 16.0} - @patch("rodoo.cli.load_and_merge_profiles") - @patch("rodoo.cli.Output.confirm") + @patch("rodoo.utils.misc.load_and_merge_profiles") + @patch("rodoo.utils.misc.Output.confirm") def test_handle_no_cli_params_no_profiles_exit( self, mock_confirm, mock_load_profiles ): @@ -84,12 +84,12 @@ def test_handle_no_cli_params_no_profiles_exit( mock_load_profiles.return_value = ({}, {}) mock_confirm.return_value = False - with pytest.raises(Exit): + with pytest.raises(typer.Exit): _handle_no_cli_params(None) - @patch("rodoo.cli.load_and_merge_profiles") - @patch("rodoo.cli.typer.prompt") - @patch("rodoo.cli.Output.confirm") + @patch("rodoo.utils.misc.load_and_merge_profiles") + @patch("rodoo.utils.misc.typer.prompt") + @patch("rodoo.utils.misc.Output.confirm") def test_handle_no_cli_params_with_profiles( self, mock_confirm, mock_prompt, mock_load_profiles ): @@ -105,7 +105,7 @@ def test_handle_no_cli_params_with_profiles( class TestHandleCliParamsPresent: - @patch("rodoo.config.load_and_merge_profiles") + @patch("rodoo.utils.misc.load_and_merge_profiles") @patch("pathlib.Path.cwd") def test_handle_cli_params_present_no_profiles_in_cwd( self, mock_cwd, mock_load_profiles @@ -118,11 +118,11 @@ def test_handle_cli_params_present_no_profiles_in_cwd( result = _handle_cli_params_present(None, cli_params) assert result == cli_params - @patch("rodoo.cli.load_and_merge_profiles") + @patch("rodoo.utils.misc.load_and_merge_profiles") @patch("pathlib.Path.cwd") - @patch("rodoo.cli.Output.confirm") - @patch("rodoo.cli.ConfigFile") - @patch("rodoo.cli.typer.prompt") + @patch("rodoo.utils.misc.Output.confirm") + @patch("rodoo.utils.misc.ConfigFile") + @patch("rodoo.utils.misc.typer.prompt") def test_handle_cli_params_present_update_profile( self, mock_prompt, @@ -156,14 +156,14 @@ def test_handle_cli_params_present_update_profile( class TestProcessCliArgs: def test_process_cli_args_no_params(self): """Test process_cli_args with no parameters.""" - with patch("rodoo.cli._handle_no_cli_params") as mock_handler: + with patch("rodoo.utils.misc._handle_no_cli_params") as mock_handler: mock_handler.return_value = {"modules": ["base"], "version": 16.0} result = process_cli_args(None, {}) assert result == {"modules": ["base"], "version": 16.0} def test_process_cli_args_with_params(self): """Test process_cli_args with parameters.""" - with patch("rodoo.cli._handle_cli_params_present") as mock_handler: + with patch("rodoo.utils.misc._handle_cli_params_present") as mock_handler: mock_handler.return_value = {"modules": ["base"], "version": 16.0} result = process_cli_args(None, {"modules": ["base"], "version": 16.0}) assert result == {"modules": ["base"], "version": 16.0} @@ -171,22 +171,22 @@ def test_process_cli_args_with_params(self): def test_process_cli_args_missing_required(self): """Test process_cli_args with missing required parameters.""" with patch("rodoo.output.Output.error") as mock_error: - with pytest.raises(Exit): + with pytest.raises(typer.Exit): process_cli_args(None, {"modules": ["base"]}) mock_error.assert_called_once() class TestConstructRunner: def test_construct_runner_basic(self): - """Test _construct_runner with basic config.""" + """Test construct_runner with basic config.""" config = {"modules": ["base"], "version": 16.0, "python_version": "3.10"} args = {} - with patch("rodoo.cli.Runner") as mock_runner_class: + with patch("rodoo.utils.misc.Runner") as mock_runner_class: mock_runner = MagicMock() mock_runner_class.return_value = mock_runner - _construct_runner(config, args) + construct_runner(config, args) # Just check that Runner was called with the basic parameters call_args = mock_runner_class.call_args @@ -195,15 +195,15 @@ def test_construct_runner_basic(self): assert call_args[1]["python_version"] == "3.10" def test_construct_runner_with_module_in_args(self): - """Test _construct_runner with module in args.""" + """Test construct_runner with module in args.""" config = {"version": 16.0, "python_version": "3.10"} args = {"module": "base,sale"} - with patch("rodoo.cli.Runner") as mock_runner_class: + with patch("rodoo.utils.misc.Runner") as mock_runner_class: mock_runner = MagicMock() mock_runner_class.return_value = mock_runner - _construct_runner(config, args) + construct_runner(config, args) # Should use modules from args call_args = mock_runner_class.call_args[1] diff --git a/tests/test_config.py b/tests/test_config.py index 6afa334..f52053d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -13,6 +13,7 @@ create_profile, FILENAMES, ) +from rodoo.utils.exceptions import ConfigurationError class TestConfigFile: @@ -171,16 +172,16 @@ def test_sanity_check_valid_config(self): def test_sanity_check_invalid_config_type(self): """Test _sanity_check with invalid config type.""" - with patch("rodoo.output.Output.error") as mock_error: + with pytest.raises( + ConfigurationError, match="Configuration must be a dictionary" + ): _sanity_check("invalid") - mock_error.assert_called_once() def test_sanity_check_invalid_profile_type(self): """Test _sanity_check with invalid profile type.""" config = {"profile": "invalid"} - with patch("rodoo.output.Output.error"): - with pytest.raises(AttributeError): - _sanity_check(config) + with pytest.raises(ConfigurationError, match="Profiles must be a dictionary"): + _sanity_check(config) def test_sanity_check_invalid_version_type(self): """Test _sanity_check with invalid version type.""" @@ -191,9 +192,10 @@ def test_sanity_check_invalid_version_type(self): } } } - with patch("rodoo.output.Output.error") as mock_error: + with pytest.raises( + ConfigurationError, match="Version in profile 'test' must be a number" + ): _sanity_check(config) - mock_error.assert_called_once() class TestCreateProfile: diff --git a/tests/test_runner.py b/tests/test_runner.py index 672a127..38245f5 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -16,10 +16,8 @@ class TestRunnerInit: @patch("rodoo.runner.Runner._install_system_packages") @patch("rodoo.runner.Runner._setup_python_env") @patch("rodoo.runner.Runner._install_extra_python_packages") - @patch("rodoo.runner.Runner._prepare_odoo_cli_params") def test_runner_init_basic( self, - mock_prepare_cli, mock_install_extra, mock_setup_python, mock_install_system, @@ -55,10 +53,8 @@ def test_runner_init_basic( @patch("rodoo.runner.Runner._install_system_packages") @patch("rodoo.runner.Runner._setup_python_env") @patch("rodoo.runner.Runner._install_extra_python_packages") - @patch("rodoo.runner.Runner._prepare_odoo_cli_params") def test_runner_init_existing_venv( self, - mock_prepare_cli, mock_install_extra, mock_setup_python, mock_install_system, @@ -103,10 +99,8 @@ class TestRunnerSetupOdooSource: @patch("rodoo.runner.Runner._install_system_packages") @patch("rodoo.runner.Runner._setup_python_env") @patch("rodoo.runner.Runner._install_extra_python_packages") - @patch("rodoo.runner.Runner._prepare_odoo_cli_params") def test_setup_odoo_source_new( self, - mock_prepare_cli, mock_install_extra, mock_setup_python, mock_install_system, @@ -145,10 +139,8 @@ def test_setup_odoo_source_new( @patch("rodoo.runner.Runner._install_system_packages") @patch("rodoo.runner.Runner._setup_python_env") @patch("rodoo.runner.Runner._install_extra_python_packages") - @patch("rodoo.runner.Runner._prepare_odoo_cli_params") def test_setup_odoo_source_existing( self, - mock_prepare_cli, mock_install_extra, mock_setup_python, mock_install_system, @@ -182,10 +174,8 @@ class TestRunnerEnsureBareRepo: @patch("rodoo.runner.Runner._install_system_packages") @patch("rodoo.runner.Runner._setup_python_env") @patch("rodoo.runner.Runner._install_extra_python_packages") - @patch("rodoo.runner.Runner._prepare_odoo_cli_params") def test_ensure_bare_repo_new( self, - mock_prepare_cli, mock_install_extra, mock_setup_python, mock_install_system, @@ -227,10 +217,8 @@ def test_ensure_bare_repo_new( @patch("rodoo.runner.Runner._install_system_packages") @patch("rodoo.runner.Runner._setup_python_env") @patch("rodoo.runner.Runner._install_extra_python_packages") - @patch("rodoo.runner.Runner._prepare_odoo_cli_params") def test_ensure_bare_repo_existing( self, - mock_prepare_cli, mock_install_extra, mock_setup_python, mock_install_system, @@ -265,10 +253,8 @@ class TestRunnerIsEnvReady: @patch("rodoo.runner.Runner._install_system_packages") @patch("rodoo.runner.Runner._setup_python_env") @patch("rodoo.runner.Runner._install_extra_python_packages") - @patch("rodoo.runner.Runner._prepare_odoo_cli_params") def test_is_env_ready_venv_not_exists( self, - mock_prepare_cli, mock_install_extra, mock_setup_python, mock_install_system, @@ -302,10 +288,8 @@ def test_is_env_ready_venv_not_exists( @patch("rodoo.runner.Runner._install_system_packages") @patch("rodoo.runner.Runner._setup_python_env") @patch("rodoo.runner.Runner._install_extra_python_packages") - @patch("rodoo.runner.Runner._prepare_odoo_cli_params") def test_is_env_ready_venv_exists_odoo_installed( self, - mock_prepare_cli, mock_install_extra, mock_setup_python, mock_install_system, @@ -341,10 +325,8 @@ def test_is_env_ready_venv_exists_odoo_installed( @patch("rodoo.runner.Runner._install_system_packages") @patch("rodoo.runner.Runner._setup_python_env") @patch("rodoo.runner.Runner._install_extra_python_packages") - @patch("rodoo.runner.Runner._prepare_odoo_cli_params") def test_is_env_ready_venv_exists_odoo_not_installed( self, - mock_prepare_cli, mock_install_extra, mock_setup_python, mock_install_system, @@ -379,7 +361,6 @@ def test_sanity_check_missing_python_version(self): patch("rodoo.runner.Runner._install_system_packages"), patch("rodoo.runner.Runner._setup_python_env"), patch("rodoo.runner.Runner._install_extra_python_packages"), - patch("rodoo.runner.Runner._prepare_odoo_cli_params"), ): runner = Runner.__new__(Runner) runner.modules = ["base"] @@ -402,7 +383,6 @@ def test_sanity_check_no_modules(self): patch("rodoo.runner.Runner._install_system_packages"), patch("rodoo.runner.Runner._setup_python_env"), patch("rodoo.runner.Runner._install_extra_python_packages"), - patch("rodoo.runner.Runner._prepare_odoo_cli_params"), ): runner = Runner.__new__(Runner) runner.modules = [] @@ -422,7 +402,6 @@ def test_sanity_check_missing_module(self): patch("rodoo.runner.Runner._install_system_packages"), patch("rodoo.runner.Runner._setup_python_env"), patch("rodoo.runner.Runner._install_extra_python_packages"), - patch("rodoo.runner.Runner._prepare_odoo_cli_params"), patch("pathlib.Path.is_dir", return_value=True), patch("pathlib.Path.iterdir"), patch("pathlib.Path.exists", return_value=True), @@ -454,10 +433,8 @@ class TestRunnerGetModulePaths: @patch("rodoo.runner.Runner._install_system_packages") @patch("rodoo.runner.Runner._setup_python_env") @patch("rodoo.runner.Runner._install_extra_python_packages") - @patch("rodoo.runner.Runner._prepare_odoo_cli_params") def test_get_module_paths_basic( self, - mock_prepare_cli, mock_install_extra, mock_setup_python, mock_install_system, @@ -492,10 +469,8 @@ def test_get_module_paths_basic( @patch("rodoo.runner.Runner._install_system_packages") @patch("rodoo.runner.Runner._setup_python_env") @patch("rodoo.runner.Runner._install_extra_python_packages") - @patch("rodoo.runner.Runner._prepare_odoo_cli_params") def test_get_module_paths_with_enterprise( self, - mock_prepare_cli, mock_install_extra, mock_setup_python, mock_install_system, @@ -517,82 +492,3 @@ def test_get_module_paths_with_enterprise( assert len(paths) == 3 assert str(paths[2]) == str(runner.enterprise_src_path) - - -class TestRunnerPrepareOdooCliParams: - def test_prepare_odoo_cli_params_basic(self): - """Test _prepare_odoo_cli_params with basic parameters.""" - # Create a minimal runner instance - with ( - patch("rodoo.runner.Runner._setup_odoo_source"), - patch("rodoo.runner.Runner._is_env_ready"), - patch("rodoo.runner.Runner._install_system_packages"), - patch("rodoo.runner.Runner._setup_python_env"), - patch("rodoo.runner.Runner._install_extra_python_packages"), - patch("rodoo.runner.Runner._sanity_check"), - patch("rodoo.runner.Runner._get_module_paths", return_value=[]), - ): - runner = Runner.__new__(Runner) - runner.modules = ["base", "sale"] - runner.version = 16.0 - runner.python_version = "3.10" - runner.db = "test_db" - runner.force_install = True - runner.force_update = False - runner.extra_params = "--debug" - runner.load = None - runner.modules_paths = [] - runner.db_host = None - runner.db_user = None - runner.db_password = None - runner.workers = 0 - runner.max_cron_threads = 0 - runner.limit_time_cpu = 3600 - runner.limit_time_real = 3600 - runner.http_interface = "localhost" - - params = runner._prepare_odoo_cli_params() - - assert "-d" in params - assert "test_db" in params - assert "--addons-path" in params - assert "-i" in params - assert "base,sale" in params - assert "--debug" in params - - def test_prepare_odoo_cli_params_with_load(self): - """Test _prepare_odoo_cli_params with load parameter.""" - # Create a minimal runner instance - with ( - patch("rodoo.runner.Runner._setup_odoo_source"), - patch("rodoo.runner.Runner._is_env_ready"), - patch("rodoo.runner.Runner._install_system_packages"), - patch("rodoo.runner.Runner._setup_python_env"), - patch("rodoo.runner.Runner._install_extra_python_packages"), - patch("rodoo.runner.Runner._sanity_check"), - patch("rodoo.runner.Runner._get_module_paths", return_value=[]), - ): - runner = Runner.__new__(Runner) - runner.modules = ["base"] - runner.version = 16.0 - runner.python_version = "3.10" - runner.load = ["base", "web"] - runner.modules_paths = [] - runner.db = "test_db" - runner.force_install = False - runner.force_update = False - runner.extra_params = None - runner.db_host = None - runner.db_user = None - runner.db_password = None - runner.workers = 0 - runner.max_cron_threads = 0 - runner.limit_time_cpu = 3600 - runner.limit_time_real = 3600 - runner.http_interface = "localhost" - - params = runner._prepare_odoo_cli_params() - - assert "--load" in params - load_index = params.index("--load") - assert params[load_index + 1] == "base,web"