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.py b/src/rodoo/cli.py deleted file mode 100644 index 89e1c4a..0000000 --- a/src/rodoo/cli.py +++ /dev/null @@ -1,215 +0,0 @@ -""" -Desired behaviors of cli -1. No profile, no args → look for config in cwd → if none, exit with help -2. Profile passed → load config → if missing, error -3. Profile + args → load config, prompt to update -4. If no --profile but config exists → prompt to update -5. Args only, no profile, no config → run directly -""" - -from pathlib import Path -import typer -from typing import Optional -from rodoo.runner import Runner -from rodoo.output import Output -from rodoo.exceptions import UserError -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: - profile_name_to_use = typer.prompt( - f"Which profile to run? ({', '.join(all_profiles.keys())})", - default="", - show_default=False, - ) - 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}?"): - 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: - profile_to_update = typer.prompt( - f"Which profile to update? ({', '.join(profiles_in_cwd.keys())}) [leave blank for none]", - default="", - ) - - 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.command() -def start( - 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, help="Database name"), -): - """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) - - -if __name__ == "__main__": - app() 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/main.py b/src/rodoo/cli/main.py new file mode 100644 index 0000000..03297d5 --- /dev/null +++ b/src/rodoo/cli/main.py @@ -0,0 +1,201 @@ +""" +Desired behaviors of cli +1. No profile, no args → look for config in cwd → if none, exit with help +2. Profile passed → load config → if missing, error +3. Profile + args → load config, prompt to update +4. If no --profile but config exists → prompt to update +5. Args only, no profile, no config → run directly +""" + +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, + 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" + ), + 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 instance""" + args = {k: v for k, v in locals().items() if k != "profile" and v is not None} + config = process_cli_args(profile, args) + runner = construct_runner(config, args) + runner.run() + + +@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() +@handle_exceptions +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) + runner = construct_runner(config, args) + runner.export_translation(language) + + +@app.command() +@handle_exceptions +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 = APP_HOME + 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/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() diff --git a/src/rodoo/config.py b/src/rodoo/config.py index ed6ecfe..8956afe 100644 --- a/src/rodoo/config.py +++ b/src/rodoo/config.py @@ -5,13 +5,20 @@ 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 - +from rodoo.utils.exceptions import ConfigurationError 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) +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): modules: list[str] @@ -199,15 +206,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 +225,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 @@ -278,8 +287,9 @@ 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/distro_dependency.py b/src/rodoo/distro_dependency.py index 3848630..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: - subprocess.run( - cmd, - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception as e: - Output.error(f"Failed to execute command: {e}") - - if aur_pkgs: - cmd = ["yay", "-S", "--noconfirm"] + aur_pkgs - try: - subprocess.run( - cmd, - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception 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/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/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) diff --git a/src/rodoo/runner.py b/src/rodoo/runner.py index 78858da..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 @@ -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 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 .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 - -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 @@ -62,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" @@ -115,21 +108,32 @@ 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 = odoo_utils.build_run_command(self) + cmd = [ + "uv", + "run", + "--python", + self.python_version, + "odoo", + ] + self.odoo_cli_params + + self._foreground_run(cmd) def upgrade(self): - pass + 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): - pass - - # TODO: implement detach mode - def _background_run(self): + self.odoo_cli_params = odoo_utils.build_test_command(self) cmd = [ "uv", "run", @@ -137,56 +141,65 @@ def _background_run(self): self.python_version, "odoo", ] + self.odoo_cli_params + return self._foreground_run(cmd) - 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}" - ) - Output.info("You can stop the server using the 'stop' command.") - - 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 - - def _foreground_run(self): + def run_shell(self): + self.odoo_cli_params = odoo_utils.build_shell_command(self) 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) + 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." + ) + continue + + i18n_path = module_path / "i18n" + i18n_path.mkdir(exist_ok=True) + translation_file = i18n_path / f"{language}.po" + self.odoo_cli_params = odoo_utils.build_translate_command( + self, module_name, language, translation_file + ) + 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): try: - subprocess.run(cmd, env=process_env) + 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: + raise UserError("Odoo command execution failed.") except Exception as e: raise UserError(f"Odoo failed to start. Details:\n{e}") from e @@ -197,6 +210,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: @@ -256,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: @@ -270,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 @@ -322,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( @@ -399,16 +413,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(): @@ -437,57 +441,9 @@ def _install_extra_python_packages(self): if not packages: return - env = os.environ.copy() - env["VIRTUAL_ENV"] = str(self.venv_path) - - subprocess.run( - ["uv", "pip", "install"] + packages, - check=True, - 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 + with in_virtual_env(self.venv_path): + subprocess.run( + ["uv", "pip", "install"] + packages, + check=True, + 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..292b90a --- /dev/null +++ b/src/rodoo/utils/exceptions.py @@ -0,0 +1,32 @@ +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/misc.py b/src/rodoo/utils/misc.py new file mode 100644 index 0000000..d18cd89 --- /dev/null +++ b/src/rodoo/utils/misc.py @@ -0,0 +1,328 @@ +from pathlib import Path +from typing import List, Optional +import subprocess +import typer +from rodoo.runner import Runner +from rodoo.config import ( + ConfigFile, + load_and_merge_profiles, + create_profile, + ODOO_URL, + ENT_ODOO_URL, + BARE_REPO, + ENT_BARE_REPO, +) +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": (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, 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 + ) + 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, (_, repo_path) in repos.items(): + worktree_path = source_path / version / repo_name + + if worktree_path.exists(): + Output.info(f" Updating {repo_name} worktree for version {version}...") + try: + 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}" + ) + 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}" + ) + + +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_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) + + +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..9130a63 --- /dev/null +++ b/src/rodoo/utils/venv.py @@ -0,0 +1,17 @@ +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_cli.py b/tests/test_cli.py index 38bed5b..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 -from rodoo.cli import ( +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 0c9b926..38245f5 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: @@ -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"