From a80c436e75078842a891b6757eb8fcc3cebb18e8 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 9 Mar 2026 16:34:43 -0400 Subject: [PATCH 01/24] [IMP] script git_local_server: tool to deploy git server to share code Generated by Claude Code v2.1.71 model Opus 4.6 --- script/git/git_local_server.py | 631 +++++++++++++++++++++++++++++++++ script/todo/todo.json | 3 +- script/todo/todo.py | 151 +++++++- script/todo/todo_i18n.py | 69 +++- 4 files changed, 840 insertions(+), 14 deletions(-) create mode 100755 script/git/git_local_server.py diff --git a/script/git/git_local_server.py b/script/git/git_local_server.py new file mode 100755 index 0000000..c55a5ef --- /dev/null +++ b/script/git/git_local_server.py @@ -0,0 +1,631 @@ +#!/usr/bin/env python3 +# © 2025-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import argparse +import logging +import os +import signal +import subprocess +import sys +import xml.etree.ElementTree as ET + +_logger = logging.getLogger(__name__) + +DEFAULT_ERPLIBRE_PATH = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..") +) +DEFAULT_GIT_PATH = os.path.join(os.path.expanduser("~"), ".git-server") +PRODUCTION_GIT_PATH = "/srv/git" +DEFAULT_MANIFEST = ".repo/local_manifests/erplibre_manifest.xml" +DEFAULT_REMOTE_NAME = "local" +DEFAULT_PORT = 9418 + + +def get_config(): + parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description="""\ +Deploy a local git server using git daemon and configure +all repos from the ERPLibre manifest. + +Actions (in order): + 1. init - Create bare repos in the git server path + 2. remote - Add 'local' remote to each repo + 3. push - Push all branches to the local remote + 4. serve - Start git daemon + +By default, all actions are executed sequentially. +The default path is ~/.git-server (user-level, no root needed). +Use --production-ready for /srv/git (requires root). +""", + ) + parser.add_argument( + "-p", + "--path", + default=None, + help=( + "Path for git server bare repos" f" (default: {DEFAULT_GIT_PATH})" + ), + ) + parser.add_argument( + "--production-ready", + action="store_true", + help=( + f"Use {PRODUCTION_GIT_PATH} as git server path" + " (requires root privileges)" + ), + ) + parser.add_argument( + "-m", + "--manifest", + default=DEFAULT_MANIFEST, + help="Manifest XML file" f" (default: {DEFAULT_MANIFEST})", + ) + parser.add_argument( + "--remote-name", + default=DEFAULT_REMOTE_NAME, + help="Remote name to add" f" (default: {DEFAULT_REMOTE_NAME})", + ) + parser.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"Git daemon port (default: {DEFAULT_PORT})", + ) + parser.add_argument( + "--action", + choices=["init", "remote", "push", "serve", "all"], + default="all", + help="Action to perform (default: all)", + ) + parser.add_argument( + "--erplibre-root", + default=None, + help="ERPLibre root directory (default: auto-detect)", + ) + parser.add_argument( + "--push-all-branches", + action="store_true", + help="Push all local branches, not just the current one", + ) + parser.add_argument( + "--remote-url-type", + choices=["file", "daemon"], + default="file", + help=( + "Remote URL type: 'file' uses local path" + " (push works without daemon), 'daemon'" + " uses git:// protocol (default: file)" + ), + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose output", + ) + args = parser.parse_args() + + # Resolve git server path + if args.path and args.production_ready: + parser.error("--path and --production-ready are mutually exclusive") + if args.production_ready: + args.path = PRODUCTION_GIT_PATH + elif args.path is None: + args.path = DEFAULT_GIT_PATH + + return args + + +def check_path_permissions(path): + """Check if we have write permissions on the target + path. Try the path itself first, then its closest + existing parent directory.""" + check = path + while check and not os.path.exists(check): + check = os.path.dirname(check) + if not check: + check = "/" + if os.access(check, os.W_OK): + return True + return False + + +def require_permissions_or_exit(path): + """Exit with instructions if we cannot write to path.""" + print(f"Error: No write permission on {path}") + print( + "Please run this script with appropriate" + " privileges:" + f"\n sudo {sys.executable}" + f" {' '.join(sys.argv)}" + ) + sys.exit(1) + + +def parse_manifest(manifest_path): + """Parse the manifest XML and return list of projects.""" + tree = ET.parse(manifest_path) + root = tree.getroot() + + remotes = {} + for remote in root.findall("remote"): + remotes[remote.get("name")] = remote.get("fetch") + + projects = [] + for project in root.findall("project"): + name = project.get("name") + path = project.get("path") + remote = project.get("remote") + revision = project.get("revision") + projects.append( + { + "name": name, + "path": path, + "remote": remote, + "revision": revision, + "fetch_url": remotes.get(remote, ""), + } + ) + + return projects + + +def init_bare_repos(git_path, projects): + """Create bare repos for all projects in the manifest.""" + os.makedirs(git_path, exist_ok=True) + + created = 0 + skipped = 0 + for project in projects: + repo_name = project["name"] + if not repo_name.endswith(".git"): + repo_name += ".git" + bare_path = os.path.join(git_path, repo_name) + + if os.path.exists(bare_path): + _logger.info(f" Exists: {bare_path}") + skipped += 1 + continue + + _logger.info(f" Creating: {bare_path}") + subprocess.run( + ["git", "init", "--bare", bare_path], + check=True, + capture_output=True, + ) + + # Enable git daemon export + export_file = os.path.join(bare_path, "git-daemon-export-ok") + open(export_file, "w").close() + + created += 1 + + print(f"Bare repos: {created} created, {skipped} skipped (already exist)") + + +def build_remote_url(git_path, repo_name, port, remote_url_type): + """Build the remote URL based on the type.""" + if remote_url_type == "daemon": + if port != DEFAULT_PORT: + return f"git://localhost:{port}/{repo_name}" + return f"git://localhost/{repo_name}" + # Default: file path + return os.path.join(git_path, repo_name) + + +def add_remotes( + erplibre_root, + git_path, + projects, + remote_name, + port, + remote_url_type, +): + """Add local remote to each repo. + + remote_url_type='file': uses local path, push works + without daemon running. + remote_url_type='daemon': uses git://localhost URL, + requires daemon for push. + """ + added = 0 + skipped = 0 + errors = 0 + for project in projects: + repo_path = os.path.join(erplibre_root, project["path"]) + if not os.path.isdir(repo_path): + _logger.warning(f" Not found: {repo_path}") + errors += 1 + continue + + repo_name = project["name"] + if not repo_name.endswith(".git"): + repo_name += ".git" + + remote_url = build_remote_url( + git_path, repo_name, port, remote_url_type + ) + + # Check if remote already exists + result = subprocess.run( + ["git", "-C", repo_path, "remote"], + capture_output=True, + text=True, + ) + existing_remotes = result.stdout.strip().split("\n") + + if remote_name in existing_remotes: + # Update URL if remote exists + subprocess.run( + [ + "git", + "-C", + repo_path, + "remote", + "set-url", + remote_name, + remote_url, + ], + check=True, + capture_output=True, + ) + _logger.info(f" Updated: {project['path']}") + skipped += 1 + else: + subprocess.run( + [ + "git", + "-C", + repo_path, + "remote", + "add", + remote_name, + remote_url, + ], + check=True, + capture_output=True, + ) + _logger.info(f" Added: {project['path']}") + added += 1 + + print(f"Remotes: {added} added, {skipped} updated," f" {errors} errors") + + +def is_detached_head(repo_path): + """Check if a repo is in detached HEAD state.""" + result = subprocess.run( + ["git", "-C", repo_path, "symbolic-ref", "HEAD"], + capture_output=True, + text=True, + ) + return result.returncode != 0 + + +def checkout_manifest_branch(repo_path, revision): + """Checkout the branch from the manifest revision. + + When Google Repo syncs, repos end up in detached HEAD + on the exact commit. We create/checkout a local branch + matching the manifest revision so git push works. + """ + # Extract branch name — revision can be + # "18.0", "18.0_dev", "ERPLibre/18.0", "main", etc. + branch = revision.split("/")[-1] if "/" in revision else revision + + # Check if local branch already exists + result = subprocess.run( + [ + "git", + "-C", + repo_path, + "show-ref", + "--verify", + f"refs/heads/{branch}", + ], + capture_output=True, + ) + if result.returncode == 0: + # Branch exists, checkout it + subprocess.run( + ["git", "-C", repo_path, "checkout", branch], + capture_output=True, + ) + else: + # Create branch from current HEAD + subprocess.run( + [ + "git", + "-C", + repo_path, + "checkout", + "-b", + branch, + ], + capture_output=True, + ) + return branch + + +def update_bare_head(git_path, project): + """Update the HEAD of the bare repo to point to the + manifest branch, so git clone checks out the right + branch by default.""" + repo_name = project["name"] + if not repo_name.endswith(".git"): + repo_name += ".git" + bare_path = os.path.join(git_path, repo_name) + if not os.path.isdir(bare_path): + return + + revision = project.get("revision", "") + if not revision: + return + branch = revision.split("/")[-1] if "/" in revision else revision + subprocess.run( + [ + "git", + "-C", + bare_path, + "symbolic-ref", + "HEAD", + f"refs/heads/{branch}", + ], + capture_output=True, + ) + + +def push_to_local( + erplibre_root, + git_path, + projects, + remote_name, + push_all_branches, +): + """Push to local remote for each repo.""" + pushed = 0 + errors = 0 + checkouts = 0 + for project in projects: + repo_path = os.path.join(erplibre_root, project["path"]) + if not os.path.isdir(repo_path): + _logger.warning(f" Not found: {repo_path}") + errors += 1 + continue + + # Unshallow if needed (clone-depth in manifest) + shallow_file = os.path.join(repo_path, ".git", "shallow") + if os.path.exists(shallow_file): + _logger.info(f" Unshallowing {project['path']}...") + # Try each remote until unshallow succeeds + result = subprocess.run( + ["git", "-C", repo_path, "remote"], + capture_output=True, + text=True, + ) + remotes = [ + r + for r in result.stdout.strip().split("\n") + if r and r != remote_name + ] + # Try the manifest remote first + manifest_remote = project.get("remote", "") + if manifest_remote in remotes: + remotes.remove(manifest_remote) + remotes.insert(0, manifest_remote) + + for try_remote in remotes: + result = subprocess.run( + [ + "git", + "-C", + repo_path, + "fetch", + "--unshallow", + try_remote, + ], + capture_output=True, + text=True, + timeout=300, + ) + if not os.path.exists(shallow_file): + _logger.info(f" Unshallowed via" f" {try_remote}") + break + else: + if os.path.exists(shallow_file): + _logger.warning( + f" Unshallow failed for" + f" {project['path']}," + " push may fail" + ) + + # Handle detached HEAD: checkout manifest branch + if is_detached_head(repo_path): + revision = project.get("revision", "") + if revision: + branch = checkout_manifest_branch(repo_path, revision) + _logger.info( + f" Checkout {branch} for" + f" {project['path']}" + " (was detached HEAD)" + ) + checkouts += 1 + else: + _logger.warning( + f" Detached HEAD with no revision" + f" for {project['path']}, skipping" + ) + errors += 1 + continue + + try: + if push_all_branches: + cmd = [ + "git", + "-C", + repo_path, + "push", + remote_name, + "--all", + ] + else: + cmd = [ + "git", + "-C", + repo_path, + "push", + remote_name, + ] + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + _logger.warning( + f" Push failed for" + f" {project['path']}:" + f" {result.stderr.strip()}" + ) + errors += 1 + else: + update_bare_head(git_path, project) + _logger.info(f" Pushed: {project['path']}") + pushed += 1 + except subprocess.TimeoutExpired: + _logger.warning(f" Timeout pushing {project['path']}") + errors += 1 + + print( + f"Push: {pushed} pushed, {checkouts} branch" + f" checkouts, {errors} errors" + ) + + +def print_clone_commands(git_path, projects, port): + """Print git clone commands for all available repos.""" + print("=== Available repos to clone ===") + base_url = ( + f"git://localhost:{port}" + if port != DEFAULT_PORT + else "git://localhost" + ) + count = 0 + for project in projects: + repo_name = project["name"] + if not repo_name.endswith(".git"): + repo_name += ".git" + bare_path = os.path.join(git_path, repo_name) + if os.path.isdir(bare_path): + print(f" git clone {base_url}/{repo_name}" f" {project['path']}") + count += 1 + print(f"\nTotal: {count} repos available") + print() + + +def serve_git_daemon(git_path, projects, port): + """Start git daemon to serve repos.""" + print_clone_commands(git_path, projects, port) + + cmd = [ + "git", + "daemon", + "--reuseaddr", + f"--base-path={git_path}", + f"--port={port}", + "--export-all", + "--enable=receive-pack", + git_path, + ] + print(f"Starting git daemon on port {port}...") + print(f" Base path: {git_path}") + print(f" URL: git://localhost:{port}/") + print(" Press Ctrl+C to stop") + + try: + process = subprocess.Popen(cmd) + process.wait() + except KeyboardInterrupt: + print("\nStopping git daemon...") + process.send_signal(signal.SIGTERM) + process.wait() + + +def main(): + config = get_config() + + log_level = logging.DEBUG if config.verbose else logging.INFO + logging.basicConfig( + level=log_level, + format="%(levelname)s: %(message)s", + ) + + # Check write permissions on target path + if not check_path_permissions(config.path): + require_permissions_or_exit(config.path) + + # Detect ERPLibre root + if config.erplibre_root: + erplibre_root = os.path.abspath(config.erplibre_root) + else: + erplibre_root = DEFAULT_ERPLIBRE_PATH + + manifest_path = os.path.join(erplibre_root, config.manifest) + if not os.path.exists(manifest_path): + print(f"Error: Manifest not found: {manifest_path}") + sys.exit(1) + + print(f"ERPLibre root: {erplibre_root}") + print(f"Manifest: {manifest_path}") + print(f"Git server path: {config.path}") + print(f"Remote name: {config.remote_name}") + if config.production_ready: + print("Mode: production (/srv/git)") + else: + print("Mode: development (~/.git-server)") + print(f"Remote URL type: {config.remote_url_type}") + print() + + projects = parse_manifest(manifest_path) + print(f"Found {len(projects)} projects in manifest") + print() + + action = config.action + + if action in ("init", "all"): + print("=== Creating bare repos ===") + init_bare_repos(config.path, projects) + print() + + if action in ("remote", "all"): + print("=== Adding remotes ===") + add_remotes( + erplibre_root, + config.path, + projects, + config.remote_name, + config.port, + config.remote_url_type, + ) + print() + + if action in ("push", "all"): + print("=== Pushing to local ===") + push_to_local( + erplibre_root, + config.path, + projects, + config.remote_name, + config.push_all_branches, + ) + print() + + if action in ("serve", "all"): + print("=== Starting git daemon ===") + serve_git_daemon(config.path, projects, config.port) + + +if __name__ == "__main__": + main() diff --git a/script/todo/todo.json b/script/todo/todo.json index ad9b89b..90a52a7 100644 --- a/script/todo/todo.json +++ b/script/todo/todo.json @@ -51,5 +51,6 @@ "prompt_description_key": "json_format_modified_code", "makefile_cmd": "format" } - ] + ], + "git_from_makefile": [] } diff --git a/script/todo/todo.py b/script/todo/todo.py index 8b4606d..5c3b3fb 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -236,12 +236,13 @@ class TODO: [4] {t("menu_code")} [5] {t("menu_doc")} [6] {t("menu_database")} -[7] {t("menu_process")} -[8] {t("menu_config")} -[9] {t("menu_network")} -[10] {t("menu_security")} -[11] {t("menu_test")} -[12] {t("menu_lang")} +[7] {t("menu_git")} +[8] {t("menu_process")} +[9] {t("menu_config")} +[10] {t("menu_network")} +[11] {t("menu_security")} +[12] {t("menu_test")} +[13] {t("menu_lang")} [0] {t("back")} """ while True: @@ -274,26 +275,30 @@ class TODO: if status is not False: return elif status == "7": - status = self.prompt_execute_process() + status = self.prompt_execute_git() if status is not False: return elif status == "8": - status = self.prompt_execute_config() + status = self.prompt_execute_process() if status is not False: return elif status == "9": - status = self.prompt_execute_network() + status = self.prompt_execute_config() if status is not False: return elif status == "10": - status = self.prompt_execute_security() + status = self.prompt_execute_network() if status is not False: return elif status == "11": - status = self.prompt_execute_test() + status = self.prompt_execute_security() if status is not False: return elif status == "12": + status = self.prompt_execute_test() + if status is not False: + return + elif status == "13": status = self._change_language() if status is not False: return @@ -681,6 +686,120 @@ class TODO: if cmd_no_found: print(t("cmd_not_found")) + def prompt_execute_git(self): + print(f"🤖 {t('git_manage')}") + lst_choice = [ + {"prompt_description": t("git_local_server")}, + ] + + # Append config-driven entries + lst_config = self.config_file.get_config("git_from_makefile") + if lst_config: + lst_choice.extend(lst_config) + + help_info = self.fill_help_info(lst_choice) + + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == "1": + self.prompt_execute_git_local_server() + else: + cmd_no_found = True + try: + int_cmd = int(status) + if 0 < int_cmd <= len(lst_choice): + cmd_no_found = False + dct_instance = lst_choice[int_cmd - 1] + self.execute_from_configuration(dct_instance) + except ValueError: + pass + if cmd_no_found: + print(t("cmd_not_found")) + + def prompt_execute_git_local_server(self): + print(f"🤖 {t('git_repo_manage')}") + lst_choice = [ + {"prompt_description": t("git_repo_deploy_local")}, + {"prompt_description": t("git_repo_deploy_production")}, + ] + help_info = self.fill_help_info(lst_choice) + + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == "1": + self._prompt_git_server_actions(production_ready=False) + elif status == "2": + self._prompt_git_server_actions(production_ready=True) + else: + print(t("cmd_not_found")) + + def _prompt_git_server_actions(self, production_ready=False): + mode = ( + t("git_mode_production") + if production_ready + else t("git_mode_local") + ) + print(f"🤖 {mode}") + lst_choice = [ + {"prompt_description": t("git_action_all")}, + {"prompt_description": t("git_action_init")}, + {"prompt_description": t("git_action_remote")}, + {"prompt_description": t("git_action_push")}, + {"prompt_description": t("git_action_serve")}, + ] + help_info = self.fill_help_info(lst_choice) + + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == "1": + self._deploy_git_server( + production_ready=production_ready, + action="all", + ) + elif status == "2": + self._deploy_git_server( + production_ready=production_ready, + action="init", + ) + elif status == "3": + self._deploy_git_server( + production_ready=production_ready, + action="remote", + ) + elif status == "4": + self._deploy_git_server( + production_ready=production_ready, + action="push", + ) + elif status == "5": + self._deploy_git_server( + production_ready=production_ready, + action="serve", + ) + else: + print(t("cmd_not_found")) + + def _deploy_git_server(self, production_ready=False, action="all"): + print(t("git_repo_deploy_starting")) + cmd = ( + "python3 ./script/git/git_local_server.py -v" f" --action {action}" + ) + if production_ready: + cmd += " --production-ready" + self.execute.exec_command_live( + cmd, + source_erplibre=False, + ) + def prompt_execute_doc(self): print(f"🤖 {t('doc_search')}") lst_choice = [ @@ -757,6 +876,7 @@ class TODO: print(f"🤖 {t('process_manage')}") lst_choice = [ {"prompt_description": t("kill_process_port")}, + {"prompt_description": t("kill_git_daemon")}, ] help_info = self.fill_help_info(lst_choice) @@ -767,9 +887,18 @@ class TODO: return False elif status == "1": self.process_kill_from_port() + elif status == "2": + self.process_kill_git_daemon() else: print(t("cmd_not_found")) + def process_kill_git_daemon(self): + self.execute.exec_command_live( + "pkill -f 'git daemon'", + source_erplibre=False, + ) + print(t("kill_git_daemon_done")) + def prompt_execute_config(self): print(f"🤖 {t('config_manage')}") lst_choice = [ diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 476fa69..7fe1b46 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -304,8 +304,16 @@ TRANSLATIONS = { "en": "Create backup (.zip)", }, "kill_process_port": { - "fr": "Terminer le processus du port actuel", - "en": "Kill process from actual port", + "fr": "Terminer le processus Odoo du port actuel", + "en": "Kill Odoo process from actual port", + }, + "kill_git_daemon": { + "fr": "Terminer le processus du serveur git daemon", + "en": "Kill git daemon server process", + }, + "kill_git_daemon_done": { + "fr": "Processus git daemon terminé.", + "en": "Git daemon process killed.", }, "generate_all_config": { "fr": "Générer toute la configuration", @@ -412,6 +420,63 @@ TRANSLATIONS = { "fr": "Le nom du module est requis!", "en": "Module name is required!", }, + # Git section + "menu_git": { + "fr": "Git - Outils Git", + "en": "Git - Git tools", + }, + "git_manage": { + "fr": "Outils de gestion Git!", + "en": "Git management tools!", + }, + "git_local_server": { + "fr": "Serveur git local", + "en": "Local git server", + }, + "git_repo_manage": { + "fr": "Gérer le serveur de dépôts git local!", + "en": "Manage local git repository server!", + }, + "git_repo_deploy_local": { + "fr": "Déployer un serveur git local (~/.git-server)", + "en": "Deploy a local git server (~/.git-server)", + }, + "git_repo_deploy_production": { + "fr": "Déployer un serveur git production (/srv/git, root requis)", + "en": "Deploy a production git server (/srv/git, root required)", + }, + "git_repo_deploy_starting": { + "fr": "Démarrage du déploiement du serveur git...", + "en": "Starting git server deployment...", + }, + "git_mode_local": { + "fr": "Mode local (~/.git-server)", + "en": "Local mode (~/.git-server)", + }, + "git_mode_production": { + "fr": "Mode production (/srv/git, root requis)", + "en": "Production mode (/srv/git, root required)", + }, + "git_action_all": { + "fr": "Tout exécuter (init + remote + push + serve)", + "en": "Run all (init + remote + push + serve)", + }, + "git_action_init": { + "fr": "Init - Créer les bare repos", + "en": "Init - Create bare repos", + }, + "git_action_remote": { + "fr": "Remote - Ajouter les remotes locaux", + "en": "Remote - Add local remotes", + }, + "git_action_push": { + "fr": "Push - Pousser vers le serveur local", + "en": "Push - Push to local server", + }, + "git_action_serve": { + "fr": "Serve - Démarrer le daemon git", + "en": "Serve - Start git daemon", + }, # Language selection "lang_prompt": { "fr": "Choisir la langue / Choose language", From d87bb463548b28e8e7508f02178d7966a0dc7371 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 10 Mar 2026 01:44:10 -0400 Subject: [PATCH 02/24] [ADD] TODO run test from script --- script/todo/todo.py | 19 +++++++++++++++++++ script/todo/todo_i18n.py | 16 ++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/script/todo/todo.py b/script/todo/todo.py index 5c3b3fb..15dfdad 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -998,6 +998,7 @@ class TODO: lst_choice = [ {"prompt_description": t("test_run_module")}, {"prompt_description": t("test_run_module_coverage")}, + {"prompt_description": t("test_run_unit_tests")}, ] help_info = self.fill_help_info(lst_choice) @@ -1010,6 +1011,8 @@ class TODO: self.execute_test_module(coverage=False) elif status == "2": self.execute_test_module(coverage=True) + elif status == "3": + self.execute_unit_tests() else: print(t("cmd_not_found")) @@ -1099,6 +1102,22 @@ class TODO: single_source_erplibre=True, ) + def execute_unit_tests(self): + print(f"\n--- {t('test_unit_running')} ---") + cmd = ( + ".venv.erplibre/bin/python -m unittest discover" + " -s test -p 'test_*.py' -v" + ) + status_code, output = self.execute.exec_command_live( + cmd, + source_erplibre=False, + return_status_and_output=True, + ) + if status_code == 0: + print(f"\n✅ {t('test_unit_success')}") + else: + print(f"\n❌ {t('test_unit_failed')}: {status_code}") + def execute_pip_audit(self): lst_version, lst_version_installed, odoo_installed_version = ( self.get_odoo_version() diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 7fe1b46..81c1135 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -368,6 +368,22 @@ TRANSLATIONS = { "fr": "Tester un module avec couverture de code", "en": "Test a module with code coverage", }, + "test_run_unit_tests": { + "fr": "Tests unitaires ERPLibre", + "en": "ERPLibre unit tests", + }, + "test_unit_running": { + "fr": "Exécution des tests unitaires", + "en": "Running unit tests", + }, + "test_unit_success": { + "fr": "Tous les tests unitaires ont réussi", + "en": "All unit tests passed", + }, + "test_unit_failed": { + "fr": "Des tests unitaires ont échoué, code de sortie", + "en": "Some unit tests failed, exit code", + }, "test_enter_module_name": { "fr": "Nom du module à tester : ", "en": "Module name to test: ", From 98d2ea890c374910c475a9813c34a76c7f11c21d Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 10 Mar 2026 02:05:53 -0400 Subject: [PATCH 03/24] [ADD] todo: add AI assistant tools menu with Claude Code commit setup Provide developers with a streamlined way to configure Claude Code commit templates directly from the TODO CLI, reducing manual setup and ensuring consistent commit formatting across the team. Generated by Claude Code 2.1.72 model claude-opus-4-6 Co-Authored-By: Mathieu Benoit --- conf/template_claude_commands_commit.md | 88 +++++++++++++++++++++++++ script/todo/todo.py | 61 +++++++++++++++++ script/todo/todo_i18n.py | 33 ++++++++++ 3 files changed, 182 insertions(+) create mode 100644 conf/template_claude_commands_commit.md diff --git a/conf/template_claude_commands_commit.md b/conf/template_claude_commands_commit.md new file mode 100644 index 0000000..ce46431 --- /dev/null +++ b/conf/template_claude_commands_commit.md @@ -0,0 +1,88 @@ +--- +description: "OCA/Odoo conventional commit in English with dynamic Claude Code attribution" +allowed-tools: + - "Bash(git add:*)" + - "Bash(git status:*)" + - "Bash(git commit:*)" + - "Bash(git diff:*)" + - "Bash(git log:*)" + - "Bash(claude --version)" + - "Bash(cat ~/.claude/settings.json)" +--- + +## Context + +- Current git status: !`git status` +- Staged & unstaged diff: !`git diff HEAD` +- Current branch: !`git branch --show-current` +- Recent commits (for style reference): !`git log --oneline -5` +- Claude Code version: !`claude --version 2>/dev/null | head -1` +- Active model: !`cat ~/.claude/settings.json 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('model', d.get('env', {}).get('ANTHROPIC_MODEL', 'claude-sonnet-4-6')))" 2>/dev/null || echo "claude-sonnet-4-6"` + +## Task + +Analyze the changes and create an OCA/Odoo-compliant git commit. + +### Step 1 – Determine the TAG + +Choose ONE tag based on the nature of the change: + +| Tag | Use when | +|-----|----------| +| `[IMP]` | Improvement, new feature, enhancement | +| `[FIX]` | Bug fix | +| `[REF]` | Refactoring (no behavior change) | +| `[ADD]` | Adding a new module or major component | +| `[REM]` | Removing code or a module | +| `[MOV]` | Moving/renaming files or modules | +| `[I18N]` | Translation updates | +| `[MRG]` | Merge commit | + +### Step 2 – Identify the module name + +Use the Odoo technical module name (e.g., `sale_order`, `account`, `stock_picking`). +If the change spans multiple modules, pick the primary one or use the repo name. + +### Step 3 – Build the commit message + +Format: +``` +[TAG] module_name: short description in imperative mood + +WHY this change was made (focus on the reason, not what was done). +The diff already shows what changed. + +If there were technical choices involved, explain them here. +Keep lines under 80 characters. + +Generated by Claude Code {VERSION} model {MODEL} + +Co-Authored-By: Your Name +``` + +Rules: +- **English only** — no French, no other language +- Subject line (first line): under 50 characters, imperative mood +- Body: explain the *WHY*, not the *what* +- One module per commit when possible + +### Step 4 – Execute + +Stage all relevant changes, then run: +```bash +git commit \ + --author="Your Name " \ + -m "$(cat < +COMMITMSG +)" +``` + +Replace `{VERSION}` and `{MODEL}` with the actual values retrieved in the Context section above. + +## Constraints + +- NEVER write the commit message in French +- ALWAYS use the `[TAG]` prefix — never use `feat:`, `fix:` or other conventional commit formats +- ALWAYS include the `Generated by Claude Code ...` line with real version and model values +- ALWAYS use `--author` with the specified identity +- If changes span multiple modules, suggest splitting into separate commits diff --git a/script/todo/todo.py b/script/todo/todo.py index 15dfdad..87b8de0 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -243,6 +243,7 @@ class TODO: [11] {t("menu_security")} [12] {t("menu_test")} [13] {t("menu_lang")} +[14] {t("menu_gpt_code")} [0] {t("back")} """ while True: @@ -302,6 +303,10 @@ class TODO: status = self._change_language() if status is not False: return + elif status == "14": + status = self.prompt_execute_gpt_code() + if status is not False: + return else: print(t("cmd_not_found")) @@ -800,6 +805,62 @@ class TODO: source_erplibre=False, ) + def prompt_execute_gpt_code(self): + print(f"🤖 {t('gpt_code_manage')}") + lst_choice = [ + {"prompt_description": t("gpt_code_claude_commit")}, + ] + help_info = self.fill_help_info(lst_choice) + + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == "1": + self._setup_claude_commit() + else: + print(t("cmd_not_found")) + + def _setup_claude_commit(self): + dest_dir = os.path.expanduser("~/.claude/commands") + dest_file = os.path.join(dest_dir, "commit.md") + + if os.path.exists(dest_file): + print(t("gpt_code_commit_exists")) + return + + name = input(t("gpt_code_enter_name")).strip() + email = input(t("gpt_code_enter_email")).strip() + + template_path = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "conf", + "template_claude_commands_commit.md", + ) + try: + with open(template_path) as f: + content = f.read() + + content = content.replace( + "Your Name ", + f"{name} <{email}>", + ) + content = content.replace( + 'Your Name ', + f'{name} ', + ) + + os.makedirs(dest_dir, exist_ok=True) + with open(dest_file, "w") as f: + f.write(content) + + print(t("gpt_code_commit_created")) + except Exception as e: + print(f"{t('gpt_code_commit_error')}{e}") + def prompt_execute_doc(self): print(f"🤖 {t('doc_search')}") lst_choice = [ diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 81c1135..fcce6bc 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -518,6 +518,39 @@ TRANSLATIONS = { "fr": "Interruption clavier", "en": "Keyboard interrupt", }, + # GPT code section + "menu_gpt_code": { + "fr": "GPT code - Outils d'assistant IA", + "en": "GPT code - AI assistant tools", + }, + "gpt_code_manage": { + "fr": "Outils d'assistant IA pour le développement!", + "en": "AI assistant tools for development!", + }, + "gpt_code_claude_commit": { + "fr": "Configurer le commit Claude Code", + "en": "Configure Claude Code commit", + }, + "gpt_code_enter_name": { + "fr": "Entrez votre nom complet : ", + "en": "Enter your full name: ", + }, + "gpt_code_enter_email": { + "fr": "Entrez votre courriel : ", + "en": "Enter your email: ", + }, + "gpt_code_commit_exists": { + "fr": "Le fichier ~/.claude/commands/commit.md existe déjà. Aucune action effectuée.", + "en": "File ~/.claude/commands/commit.md already exists. No action taken.", + }, + "gpt_code_commit_created": { + "fr": "Fichier ~/.claude/commands/commit.md créé avec succès!", + "en": "File ~/.claude/commands/commit.md created successfully!", + }, + "gpt_code_commit_error": { + "fr": "Erreur lors de la création du fichier : ", + "en": "Error creating file: ", + }, } From c3684ee3202b53759afb823ae42fb2b7c0ffabd2 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 10 Mar 2026 02:07:27 -0400 Subject: [PATCH 04/24] [IMP] conf: simplify Claude commit template The previous template used a piped shell command to detect the active model, which caused permission errors in sandboxed environments. Replace with a simple Python script approach and add disable-model-invocation for reliability. Generated by Claude Code 2.1.72 model claude-opus-4-6 Co-Authored-By: Mathieu Benoit --- conf/template_claude_commands_commit.md | 108 +++++++++++------------- 1 file changed, 50 insertions(+), 58 deletions(-) diff --git a/conf/template_claude_commands_commit.md b/conf/template_claude_commands_commit.md index ce46431..9ac2030 100644 --- a/conf/template_claude_commands_commit.md +++ b/conf/template_claude_commands_commit.md @@ -1,58 +1,60 @@ --- -description: "OCA/Odoo conventional commit in English with dynamic Claude Code attribution" +name: commit +description: "OCA/Odoo conventional commit in English with dynamic Claude Code attribution." +disable-model-invocation: true allowed-tools: - - "Bash(git add:*)" - - "Bash(git status:*)" - - "Bash(git commit:*)" - - "Bash(git diff:*)" - - "Bash(git log:*)" - - "Bash(claude --version)" - - "Bash(cat ~/.claude/settings.json)" + - Bash(git add:*) + - Bash(git status:*) + - Bash(git commit:*) + - Bash(git diff:*) + - Bash(git log:*) + - Bash(claude --version) + - Bash(cat:*) + - Bash(python3:*) --- ## Context -- Current git status: !`git status` -- Staged & unstaged diff: !`git diff HEAD` +- Git status: !`git status` +- Full diff: !`git diff HEAD` - Current branch: !`git branch --show-current` -- Recent commits (for style reference): !`git log --oneline -5` +- Last 5 commits (for style reference): !`git log --oneline -5` - Claude Code version: !`claude --version 2>/dev/null | head -1` -- Active model: !`cat ~/.claude/settings.json 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('model', d.get('env', {}).get('ANTHROPIC_MODEL', 'claude-sonnet-4-6')))" 2>/dev/null || echo "claude-sonnet-4-6"` ## Task -Analyze the changes and create an OCA/Odoo-compliant git commit. +Before committing, retrieve the active model with: +```bash +python3 -c " +import json, os +path = os.path.expanduser('~/.claude/settings.json') +try: + d = json.load(open(path)) + print(d.get('model', 'claude-sonnet-4-6')) +except: + print('claude-sonnet-4-6') +" +``` -### Step 1 – Determine the TAG +Then create an OCA/Odoo-compliant commit. -Choose ONE tag based on the nature of the change: +### OCA Tags -| Tag | Use when | -|-----|----------| -| `[IMP]` | Improvement, new feature, enhancement | +| Tag | Usage | +|-----|-------| +| `[IMP]` | Improvement / new feature | | `[FIX]` | Bug fix | -| `[REF]` | Refactoring (no behavior change) | -| `[ADD]` | Adding a new module or major component | -| `[REM]` | Removing code or a module | -| `[MOV]` | Moving/renaming files or modules | -| `[I18N]` | Translation updates | -| `[MRG]` | Merge commit | +| `[REF]` | Refactoring | +| `[ADD]` | New module | +| `[REM]` | Remove code/module | +| `[MOV]` | Move/rename | +| `[I18N]` | Translations | -### Step 2 – Identify the module name - -Use the Odoo technical module name (e.g., `sale_order`, `account`, `stock_picking`). -If the change spans multiple modules, pick the primary one or use the repo name. - -### Step 3 – Build the commit message - -Format: +### Format ``` [TAG] module_name: short description in imperative mood -WHY this change was made (focus on the reason, not what was done). -The diff already shows what changed. - -If there were technical choices involved, explain them here. +Explain WHY the change was made (not what — the diff already shows that). Keep lines under 80 characters. Generated by Claude Code {VERSION} model {MODEL} @@ -60,29 +62,19 @@ Generated by Claude Code {VERSION} model {MODEL} Co-Authored-By: Your Name ``` -Rules: -- **English only** — no French, no other language -- Subject line (first line): under 50 characters, imperative mood -- Body: explain the *WHY*, not the *what* -- One module per commit when possible +### Rules -### Step 4 – Execute +- **English only**, imperative mood, subject line under 50 chars +- Use the Odoo technical module name (e.g. `sale_order`, `account`, `stock`) +- If multiple modules are impacted, suggest splitting into separate commits -Stage all relevant changes, then run: +### Execute ```bash -git commit \ - --author="Your Name " \ - -m "$(cat < -COMMITMSG -)" +git add -A && git commit --author="Your Name " -m "[TAG] module: description + +Explain WHY here. + +Generated by Claude Code {VERSION} model {MODEL} + +Co-Authored-By: Your Name " ``` - -Replace `{VERSION}` and `{MODEL}` with the actual values retrieved in the Context section above. - -## Constraints - -- NEVER write the commit message in French -- ALWAYS use the `[TAG]` prefix — never use `feat:`, `fix:` or other conventional commit formats -- ALWAYS include the `Generated by Claude Code ...` line with real version and model values -- ALWAYS use `--author` with the specified identity -- If changes span multiple modules, suggest splitting into separate commits From f78f5785522d947663ced3527cfb0a92f09c2c82 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 10 Mar 2026 02:09:17 -0400 Subject: [PATCH 05/24] [ADD] test: add unit tests for ConfigFile Ensure deep_merge_with_lists, get_config, and get_config_value behave correctly with multi-layer config merging (base, override, private) to prevent regressions in config resolution logic. Generated by Claude Code 2.1.72 model claude-opus-4-6 Co-Authored-By: Mathieu Benoit --- test/test_config_file.py | 262 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 test/test_config_file.py diff --git a/test/test_config_file.py b/test/test_config_file.py new file mode 100644 index 0000000..9e6b216 --- /dev/null +++ b/test/test_config_file.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +# © 2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import json +import os +import tempfile +import unittest +from unittest.mock import patch + +from script.config.config_file import ConfigFile + + +class TestDeepMergeWithLists(unittest.TestCase): + def setUp(self): + self.cfg = ConfigFile() + + def test_empty_dicts(self): + result = self.cfg.deep_merge_with_lists({}, {}) + self.assertEqual(result, {}) + + def test_dest_only(self): + result = self.cfg.deep_merge_with_lists( + {"a": 1, "b": 2}, {} + ) + self.assertEqual(result, {"a": 1, "b": 2}) + + def test_src_only(self): + result = self.cfg.deep_merge_with_lists( + {}, {"a": 1, "b": 2} + ) + self.assertEqual(result, {"a": 1, "b": 2}) + + def test_simple_merge(self): + result = self.cfg.deep_merge_with_lists( + {"a": 1}, {"b": 2} + ) + self.assertEqual(result, {"a": 1, "b": 2}) + + def test_src_overrides_dest_string(self): + result = self.cfg.deep_merge_with_lists( + {"a": "old"}, {"a": "new"} + ) + self.assertEqual(result, {"a": "new"}) + + def test_empty_src_string_keeps_dest(self): + result = self.cfg.deep_merge_with_lists( + {"a": "old"}, {"a": ""} + ) + self.assertEqual(result, {"a": "old"}) + + def test_nested_dict_merge(self): + dest = {"a": {"x": 1, "y": 2}} + src = {"a": {"y": 3, "z": 4}} + result = self.cfg.deep_merge_with_lists(dest, src) + self.assertEqual(result, {"a": {"x": 1, "y": 3, "z": 4}}) + + def test_deeply_nested_dict_merge(self): + dest = {"a": {"b": {"c": 1}}} + src = {"a": {"b": {"d": 2}}} + result = self.cfg.deep_merge_with_lists(dest, src) + self.assertEqual(result, {"a": {"b": {"c": 1, "d": 2}}}) + + def test_list_replace_strategy(self): + result = self.cfg.deep_merge_with_lists( + {"a": [1, 2]}, {"a": [3, 4]}, list_strategy="replace" + ) + self.assertEqual(result, {"a": [3, 4]}) + + def test_list_extend_strategy(self): + result = self.cfg.deep_merge_with_lists( + {"a": [1, 2]}, {"a": [3, 4]}, list_strategy="extend" + ) + self.assertEqual(result, {"a": [1, 2, 3, 4]}) + + def test_dest_dict_not_mutated(self): + dest = {"a": {"x": 1}} + src = {"a": {"y": 2}} + self.cfg.deep_merge_with_lists(dest, src) + self.assertEqual(dest, {"a": {"x": 1}}) + + def test_src_overrides_non_string_non_dict_non_list(self): + result = self.cfg.deep_merge_with_lists( + {"a": 1}, {"a": 2} + ) + self.assertEqual(result, {"a": 2}) + + +class TestGetConfig(unittest.TestCase): + def setUp(self): + self.cfg = ConfigFile() + self.tmpdir = tempfile.mkdtemp() + + def tearDown(self): + for f in os.listdir(self.tmpdir): + os.remove(os.path.join(self.tmpdir, f)) + os.rmdir(self.tmpdir) + + def _write_json(self, filename, data): + path = os.path.join(self.tmpdir, filename) + with open(path, "w") as f: + json.dump(data, f) + return path + + def test_get_config_base_only(self): + base_path = self._write_json( + "base.json", {"instance": [{"name": "test"}]} + ) + with patch( + "script.config.config_file.CONFIG_FILE", base_path + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_FILE", + os.path.join(self.tmpdir, "nonexistent1.json"), + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", + os.path.join(self.tmpdir, "nonexistent2.json"), + ): + result = self.cfg.get_config("instance") + self.assertEqual(result, [{"name": "test"}]) + + def test_get_config_returns_none_for_missing_key(self): + base_path = self._write_json("base.json", {"a": 1}) + with patch( + "script.config.config_file.CONFIG_FILE", base_path + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_FILE", + os.path.join(self.tmpdir, "nonexistent1.json"), + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", + os.path.join(self.tmpdir, "nonexistent2.json"), + ): + result = self.cfg.get_config("missing") + self.assertIsNone(result) + + def test_get_config_override_merges(self): + base_path = self._write_json( + "base.json", + {"instance": [{"name": "base"}]}, + ) + override_path = self._write_json( + "override.json", + {"instance": [{"name": "override"}]}, + ) + with patch( + "script.config.config_file.CONFIG_FILE", base_path + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_FILE", + override_path, + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", + os.path.join(self.tmpdir, "nonexistent.json"), + ): + result = self.cfg.get_config("instance") + # Lists with extend: base + override + self.assertEqual( + result, + [{"name": "base"}, {"name": "override"}], + ) + + def test_get_config_private_merges(self): + base_path = self._write_json( + "base.json", + {"data": {"key": "base_val"}}, + ) + private_path = self._write_json( + "private.json", + {"data": {"key": "private_val"}}, + ) + with patch( + "script.config.config_file.CONFIG_FILE", base_path + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_FILE", + os.path.join(self.tmpdir, "nonexistent.json"), + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", + private_path, + ): + result = self.cfg.get_config("data") + self.assertEqual(result, {"key": "private_val"}) + + def test_get_config_all_three_files(self): + base_path = self._write_json( + "base.json", + {"items": [1], "meta": {"a": "base"}}, + ) + override_path = self._write_json( + "override.json", + {"items": [2], "meta": {"b": "override"}}, + ) + private_path = self._write_json( + "private.json", + {"items": [3], "meta": {"a": "private"}}, + ) + with patch( + "script.config.config_file.CONFIG_FILE", base_path + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_FILE", + override_path, + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", + private_path, + ): + result_items = self.cfg.get_config("items") + result_meta = self.cfg.get_config("meta") + # Merge order: base + private, then + override + # Lists extend: [1] + [3] = [1,3], then [1,3] + [2] = [1,3,2] + self.assertEqual(result_items, [1, 3, 2]) + # Dict merge: {a: base} + {a: private} = {a: private} + # then {a: private} + {b: override} = {a: private, b: override} + self.assertEqual( + result_meta, {"a": "private", "b": "override"} + ) + + def test_no_config_files_exist(self): + with patch( + "script.config.config_file.CONFIG_FILE", + os.path.join(self.tmpdir, "nope1.json"), + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_FILE", + os.path.join(self.tmpdir, "nope2.json"), + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", + os.path.join(self.tmpdir, "nope3.json"), + ): + result = self.cfg.get_config("anything") + self.assertIsNone(result) + + +class TestGetConfigValue(unittest.TestCase): + def setUp(self): + self.cfg = ConfigFile() + + def test_nested_value(self): + with patch.object( + self.cfg, + "get_config", + return_value={"level1": {"level2": "found"}}, + ): + result = self.cfg.get_config_value( + ["root", "level1", "level2"] + ) + self.assertEqual(result, "found") + + def test_single_key(self): + with patch.object( + self.cfg, + "get_config", + return_value={"key": "value"}, + ): + result = self.cfg.get_config_value(["root"]) + self.assertEqual(result, {"key": "value"}) + + +class TestGetLogoAsciiFilePath(unittest.TestCase): + def test_returns_logo_path(self): + cfg = ConfigFile() + result = cfg.get_logo_ascii_file_path() + self.assertEqual(result, "./script/todo/logo_ascii.txt") + + +if __name__ == "__main__": + unittest.main() From 054c12af6d334028397464192fd447193efa88be Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 10 Mar 2026 02:49:03 -0400 Subject: [PATCH 06/24] [ADD] test: add unit tests for refactoring Ensure safe refactoring by covering all 6 target scripts with 121 tests: execute.py, todo_i18n.py, kill_process_by_port.py, git_tool.py, and todo.py. Generated by Claude Code 2.1.72 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- test/test_execute.py | 169 +++++++++++++++ test/test_git_tool.py | 327 +++++++++++++++++++++++++++++ test/test_kill_process_by_port.py | 251 ++++++++++++++++++++++ test/test_todo.py | 332 ++++++++++++++++++++++++++++++ test/test_todo_i18n.py | 244 ++++++++++++++++++++++ 5 files changed, 1323 insertions(+) create mode 100644 test/test_execute.py create mode 100644 test/test_git_tool.py create mode 100644 test/test_kill_process_by_port.py create mode 100644 test/test_todo.py create mode 100644 test/test_todo_i18n.py diff --git a/test/test_execute.py b/test/test_execute.py new file mode 100644 index 0000000..2119a6a --- /dev/null +++ b/test/test_execute.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +# © 2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import os +import unittest +from unittest.mock import patch + +from script.execute.execute import Execute + + +class TestExecuteInit(unittest.TestCase): + """Test Execute class initialization.""" + + @patch("shutil.which") + def test_init_with_gnome_terminal(self, mock_which): + mock_which.side_effect = lambda x: ( + "/usr/bin/gnome-terminal" if x == "gnome-terminal" else None + ) + exe = Execute() + self.assertIn("gnome-terminal", exe.cmd_source_erplibre) + self.assertIn(".venv.erplibre", exe.cmd_source_erplibre) + self.assertIn("gnome-terminal", exe.cmd_source_default) + + @patch("shutil.which") + def test_init_with_osascript(self, mock_which): + mock_which.side_effect = lambda x: ( + "/usr/bin/osascript" if x == "osascript" else None + ) + exe = Execute() + self.assertIn("osascript", exe.cmd_source_erplibre) + + @patch("shutil.which", return_value=None) + def test_init_fallback_source(self, mock_which): + exe = Execute() + self.assertIn(".venv.erplibre/bin/activate", exe.cmd_source_erplibre) + self.assertEqual(exe.cmd_source_default, "") + + +class TestExecCommandLive(unittest.TestCase): + """Test exec_command_live method.""" + + def setUp(self): + with patch("shutil.which", return_value=None): + self.exe = Execute() + + def test_simple_command_returns_zero(self): + result = self.exe.exec_command_live( + "echo hello", + source_erplibre=False, + quiet=True, + ) + self.assertEqual(result, 0) + + def test_failing_command_returns_nonzero(self): + result = self.exe.exec_command_live( + "exit 42", + source_erplibre=False, + quiet=True, + ) + self.assertEqual(result, 42) + + def test_return_status_and_output(self): + status, output = self.exe.exec_command_live( + "echo hello", + source_erplibre=False, + quiet=True, + return_status_and_output=True, + ) + self.assertEqual(status, 0) + self.assertEqual(output, ["hello"]) + + def test_return_status_and_output_multiline(self): + status, output = self.exe.exec_command_live( + "echo -e 'line1\nline2\nline3'", + source_erplibre=False, + quiet=True, + return_status_and_output=True, + ) + self.assertEqual(status, 0) + self.assertEqual(output, ["line1", "line2", "line3"]) + + def test_return_status_and_command(self): + status, cmd = self.exe.exec_command_live( + "echo test", + source_erplibre=False, + quiet=True, + return_status_and_command=True, + ) + self.assertEqual(status, 0) + self.assertEqual(cmd, "echo test") + + def test_return_status_and_output_and_command(self): + status, cmd, output = self.exe.exec_command_live( + "echo result", + source_erplibre=False, + quiet=True, + return_status_and_output_and_command=True, + ) + self.assertEqual(status, 0) + self.assertEqual(cmd, "echo result") + self.assertEqual(output, ["result"]) + + def test_source_erplibre_prepends_activate(self): + status, cmd = self.exe.exec_command_live( + "echo test", + source_erplibre=True, + quiet=True, + return_status_and_command=True, + ) + self.assertIn(".venv.erplibre/bin/activate", cmd) + + def test_single_source_erplibre(self): + status, cmd = self.exe.exec_command_live( + "echo test", + source_erplibre=False, + single_source_erplibre=True, + quiet=True, + return_status_and_command=True, + ) + self.assertIn(".venv.erplibre/bin/activate", cmd) + self.assertIn("echo test", cmd) + + def test_single_source_odoo_no_version_returns_error(self): + with patch("os.path.exists", return_value=False): + result = self.exe.exec_command_live( + "echo test", + source_erplibre=False, + single_source_odoo=True, + source_odoo="", + quiet=True, + ) + self.assertEqual(result, -1) + + def test_single_source_odoo_with_version(self): + status, cmd = self.exe.exec_command_live( + "echo test", + source_erplibre=False, + single_source_odoo=True, + source_odoo="odoo18", + quiet=True, + return_status_and_command=True, + ) + self.assertIn(".venv.odoo18/bin/activate", cmd) + + def test_new_env_passed_to_subprocess(self): + status, output = self.exe.exec_command_live( + "echo $MY_TEST_VAR", + source_erplibre=False, + quiet=True, + new_env={"MY_TEST_VAR": "test_value_123"}, + return_status_and_output=True, + ) + self.assertEqual(status, 0) + self.assertEqual(output, ["test_value_123"]) + + def test_empty_output_command(self): + status, output = self.exe.exec_command_live( + "true", + source_erplibre=False, + quiet=True, + return_status_and_output=True, + ) + self.assertEqual(status, 0) + self.assertEqual(output, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_git_tool.py b/test/test_git_tool.py new file mode 100644 index 0000000..113514b --- /dev/null +++ b/test/test_git_tool.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +# © 2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import os +import tempfile +import unittest +from collections import OrderedDict +from unittest.mock import MagicMock, mock_open, patch + +from script.git.git_tool import ( + CST_EL_GITHUB_TOKEN, + CST_FILE_SOURCE_REPO_ADDONS, + DEFAULT_PROJECT_NAME, + DEFAULT_REMOTE_URL, + DEFAULT_WEBSITE, + GitTool, + Struct, +) + + +class TestStruct(unittest.TestCase): + def test_basic_attributes(self): + s = Struct(a=1, b="hello") + self.assertEqual(s.a, 1) + self.assertEqual(s.b, "hello") + + def test_empty_struct(self): + s = Struct() + self.assertEqual(s.__dict__, {}) + + def test_override_existing(self): + s = Struct(x=10) + self.assertEqual(s.x, 10) + + +class TestGetUrl(unittest.TestCase): + def test_https_to_git(self): + url, url_https, url_git = GitTool.get_url( + "https://github.com/OCA/server-tools.git" + ) + self.assertEqual(url, "https://github.com/OCA/server-tools.git") + self.assertEqual(url_https, "https://github.com/OCA/server-tools.git") + self.assertEqual(url_git, "git@github.com:OCA/server-tools.git") + + def test_git_to_https(self): + url, url_https, url_git = GitTool.get_url( + "git@github.com:OCA/server-tools.git" + ) + self.assertEqual(url_https, "https://github.com/OCA/server-tools.git") + self.assertEqual(url_git, "git@github.com:OCA/server-tools.git") + + def test_https_without_git_suffix(self): + url, url_https, url_git = GitTool.get_url( + "https://github.com/ERPLibre/ERPLibre" + ) + self.assertEqual(url_https, "https://github.com/ERPLibre/ERPLibre") + self.assertEqual(url_git, "git@github.com:ERPLibre/ERPLibre") + + +class TestGetTransformedRepoInfo(unittest.TestCase): + def setUp(self): + self.gt = GitTool() + + def test_https_url_as_submodule(self): + result = self.gt.get_transformed_repo_info_from_url( + "https://github.com/OCA/server-tools.git", + repo_path=".", + get_obj=False, + ) + self.assertEqual(result["organization"], "OCA") + self.assertEqual(result["repo_name"], "server-tools") + self.assertEqual(result["project_name"], "server-tools.git") + self.assertEqual(result["path"], "addons/OCA_server-tools") + self.assertTrue(result["is_submodule"]) + + def test_git_url_as_submodule(self): + result = self.gt.get_transformed_repo_info_from_url( + "git@github.com:ERPLibre/erplibre_addons.git", + repo_path=".", + get_obj=False, + ) + self.assertEqual(result["organization"], "ERPLibre") + self.assertEqual(result["repo_name"], "erplibre_addons") + + def test_not_submodule(self): + result = self.gt.get_transformed_repo_info_from_url( + "https://github.com/OCA/server-tools.git", + repo_path="/tmp/test", + get_obj=False, + is_submodule=False, + ) + self.assertEqual(result["path"], "/tmp/test") + self.assertFalse(result["is_submodule"]) + + def test_get_obj_true_returns_struct(self): + result = self.gt.get_transformed_repo_info_from_url( + "https://github.com/OCA/web.git", + get_obj=True, + ) + self.assertIsInstance(result, Struct) + self.assertEqual(result.organization, "OCA") + + def test_organization_force(self): + result = self.gt.get_transformed_repo_info_from_url( + "https://github.com/OCA/server-tools.git", + get_obj=False, + organization_force="MyOrg", + ) + self.assertEqual(result["organization"], "MyOrg") + self.assertEqual(result["original_organization"], "OCA") + self.assertIn("MyOrg", result["url_https"]) + + def test_custom_sub_path(self): + result = self.gt.get_transformed_repo_info_from_url( + "https://github.com/OCA/server-tools.git", + get_obj=False, + sub_path="custom", + ) + self.assertEqual(result["path"], "custom/OCA_server-tools") + + def test_empty_sub_path(self): + result = self.gt.get_transformed_repo_info_from_url( + "https://github.com/OCA/server-tools.git", + get_obj=False, + sub_path="", + ) + self.assertEqual(result["path"], "server-tools") + + def test_dot_sub_path(self): + result = self.gt.get_transformed_repo_info_from_url( + "https://github.com/OCA/server-tools.git", + get_obj=False, + sub_path=".", + ) + self.assertEqual(result["path"], "server-tools") + + def test_revision_and_clone_depth(self): + result = self.gt.get_transformed_repo_info_from_url( + "https://github.com/OCA/web.git", + get_obj=False, + revision="16.0", + clone_depth="1", + ) + self.assertEqual(result["revision"], "16.0") + self.assertEqual(result["clone_depth"], "1") + + def test_url_without_git_suffix(self): + result = self.gt.get_transformed_repo_info_from_url( + "https://github.com/OCA/server-tools", + get_obj=False, + ) + self.assertEqual(result["repo_name"], "server-tools") + + +class TestDefaultProperties(unittest.TestCase): + def test_default_project_name(self): + gt = GitTool() + self.assertEqual(gt.default_project_name, DEFAULT_PROJECT_NAME) + + def test_default_website(self): + gt = GitTool() + self.assertEqual(gt.default_website, DEFAULT_WEBSITE) + + def test_default_remote_url(self): + gt = GitTool() + self.assertEqual(gt.default_remote_url, DEFAULT_REMOTE_URL) + + @patch( + "builtins.open", + mock_open(read_data="18.0"), + ) + def test_odoo_version(self): + gt = GitTool() + self.assertEqual(gt.odoo_version, "18.0") + + @patch( + "builtins.open", + mock_open(read_data="16.0"), + ) + def test_odoo_version_long(self): + gt = GitTool() + self.assertEqual(gt.odoo_version_long, "odoo16.0") + + +class TestStrInsert(unittest.TestCase): + def test_insert_middle(self): + result = GitTool.str_insert("abcdef", "XY", 3) + self.assertEqual(result, "abcXYdef") + + def test_insert_beginning(self): + result = GitTool.str_insert("hello", "X", 0) + self.assertEqual(result, "Xhello") + + def test_insert_end(self): + result = GitTool.str_insert("hello", "X", 5) + self.assertEqual(result, "helloX") + + +class TestGetProjectConfig(unittest.TestCase): + def test_reads_github_token(self): + content = ( + "#!/bin/bash\n" + 'EL_GITHUB_TOKEN="my_token_123"\n' + 'OTHER_VAR="value"\n' + ) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".sh", delete=False, dir="/tmp" + ) as f: + f.write(content) + f.flush() + tmpdir = os.path.dirname(f.name) + tmpname = os.path.basename(f.name) + try: + # We need env_var.sh in a directory + env_var_path = os.path.join(tmpdir, "env_var.sh") + os.rename(f.name, env_var_path) + result = GitTool.get_project_config(repo_path=tmpdir) + self.assertEqual(result[CST_EL_GITHUB_TOKEN], "my_token_123") + finally: + if os.path.exists(env_var_path): + os.unlink(env_var_path) + + +class TestGetRepoInfoSubmodule(unittest.TestCase): + def test_parses_gitmodules(self): + gitmodules_content = ( + '[submodule "addons/OCA_server-tools"]\n' + "\turl = https://github.com/OCA/server-tools.git\n" + "\tpath = addons/OCA_server-tools\n" + "\n" + '[submodule "addons/OCA_web"]\n' + "\turl = https://github.com/OCA/web.git\n" + "\tpath = addons/OCA_web\n" + ) + gt = GitTool() + with tempfile.TemporaryDirectory() as tmpdir: + gitmodules_path = os.path.join(tmpdir, ".gitmodules") + with open(gitmodules_path, "w") as f: + f.write(gitmodules_content) + + result = gt.get_repo_info_submodule( + repo_path=tmpdir, add_root=False + ) + self.assertEqual(len(result), 2) + names = [r["name"] for r in result] + self.assertIn("addons/OCA_server-tools", names) + self.assertIn("addons/OCA_web", names) + + def test_single_submodule(self): + gitmodules_content = ( + '[submodule "addons/test"]\n' + "\turl = https://github.com/Test/repo.git\n" + "\tpath = addons/test\n" + ) + gt = GitTool() + with tempfile.TemporaryDirectory() as tmpdir: + with open(os.path.join(tmpdir, ".gitmodules"), "w") as f: + f.write(gitmodules_content) + + result = gt.get_repo_info_submodule(repo_path=tmpdir) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["name"], "addons/test") + self.assertIn("https://", result[0]["url_https"]) + self.assertIn("git@", result[0]["url_git"]) + + +class TestGetManifestXmlInfo(unittest.TestCase): + def test_parses_manifest(self): + xml_content = """ + + + + + +""" + gt = GitTool() + with tempfile.NamedTemporaryFile( + mode="w", suffix=".xml", delete=False + ) as f: + f.write(xml_content) + f.flush() + try: + dct_remote, dct_project, default_remote = ( + gt.get_manifest_xml_info(filename=f.name) + ) + self.assertIn("OCA", dct_remote) + self.assertIn("server-tools.git", dct_project) + self.assertEqual(default_remote["@remote"], "OCA") + finally: + os.unlink(f.name) + + def test_empty_manifest(self): + xml_content = """ + +""" + gt = GitTool() + with tempfile.NamedTemporaryFile( + mode="w", suffix=".xml", delete=False + ) as f: + f.write(xml_content) + f.flush() + try: + dct_remote, dct_project, default_remote = ( + gt.get_manifest_xml_info(filename=f.name) + ) + self.assertEqual(dct_remote, {}) + self.assertEqual(dct_project, {}) + self.assertIsNone(default_remote) + finally: + os.unlink(f.name) + + +class TestConstants(unittest.TestCase): + def test_file_source_repo_addons(self): + self.assertEqual(CST_FILE_SOURCE_REPO_ADDONS, "source_repo_addons.csv") + + def test_default_project_name(self): + self.assertEqual(DEFAULT_PROJECT_NAME, "ERPLibre") + + def test_default_website(self): + self.assertEqual(DEFAULT_WEBSITE, "erplibre.ca") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_kill_process_by_port.py b/test/test_kill_process_by_port.py new file mode 100644 index 0000000..d6e991f --- /dev/null +++ b/test/test_kill_process_by_port.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +# © 2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import unittest +from unittest.mock import MagicMock, patch + +import psutil + +from script.process.kill_process_by_port import ( + PROTECTED_NAMES, + STOP_PARENT_KILL, + choose_target, + find_listeners, + get_ancestry, + kill_process, + kill_tree, + proc_desc, +) + + +def _make_proc(pid, name="python", cmdline=None, username="user"): + """Create a mock psutil.Process.""" + p = MagicMock(spec=psutil.Process) + p.pid = pid + p.name.return_value = name + p.cmdline.return_value = cmdline or [name] + p.username.return_value = username + return p + + +class TestProcDesc(unittest.TestCase): + def test_normal_process(self): + p = _make_proc(123, "python", ["python", "app.py"]) + result = proc_desc(p) + self.assertIn("pid=123", result) + self.assertIn("python app.py", result) + self.assertIn("user=user", result) + + def test_empty_cmdline_uses_name(self): + p = _make_proc(42, "bash", []) + result = proc_desc(p) + self.assertIn("pid=42", result) + self.assertIn("name=bash", result) + + def test_psutil_error_fallback(self): + p = MagicMock(spec=psutil.Process) + p.pid = 99 + p.cmdline.side_effect = psutil.Error("gone") + result = proc_desc(p) + self.assertEqual(result, "pid=99") + + +class TestGetAncestry(unittest.TestCase): + @patch("script.process.kill_process_by_port.psutil.Process") + def test_nonexistent_pid(self, mock_proc_cls): + mock_proc_cls.side_effect = psutil.NoSuchProcess(99999) + chain = get_ancestry(99999) + self.assertEqual(chain, []) + + @patch("script.process.kill_process_by_port.psutil.Process") + def test_chain_stops_at_pid_1(self, mock_proc_cls): + p_child = MagicMock() + p_child.pid = 100 + p_child.ppid.return_value = 1 + + p_init = MagicMock() + p_init.pid = 1 + + mock_proc_cls.side_effect = [p_child, p_init] + chain = get_ancestry(100) + self.assertEqual(len(chain), 2) + self.assertEqual(chain[0].pid, 100) + self.assertEqual(chain[1].pid, 1) + + @patch("script.process.kill_process_by_port.psutil.Process") + def test_chain_stops_at_ppid_zero(self, mock_proc_cls): + p = MagicMock() + p.pid = 50 + p.ppid.return_value = 0 + + mock_proc_cls.return_value = p + chain = get_ancestry(50) + self.assertEqual(len(chain), 1) + self.assertEqual(chain[0].pid, 50) + + +class TestChooseTarget(unittest.TestCase): + def test_empty_chain_returns_none(self): + result = choose_target([], 1) + self.assertIsNone(result) + + def test_stops_at_run_sh(self): + p1 = _make_proc(100, "python", ["python", "odoo-bin"]) + p2 = _make_proc(99, "bash", ["./run.sh"]) + p3 = _make_proc(1, "systemd", ["systemd"]) + chain = [p1, p2, p3] + target, lst = choose_target(chain, 1) + self.assertEqual(target.pid, 99) + + def test_stops_at_odoo_bin_sh(self): + p1 = _make_proc(200, "python", ["python", "odoo-bin"]) + p2 = _make_proc(199, "bash", ["./odoo_bin.sh"]) + chain = [p1, p2] + target, lst = choose_target(chain, 1) + self.assertEqual(target.pid, 199) + + def test_no_stop_marker_uses_nb_parent(self): + p1 = _make_proc(300, "python", ["python"]) + p2 = _make_proc(299, "bash", ["bash"]) + p3 = _make_proc(1, "systemd", ["systemd"]) + chain = [p1, p2, p3] + target, lst = choose_target(chain, 2) + self.assertEqual(target.pid, 299) + + +class TestKillProcess(unittest.TestCase): + def test_terminate_called(self): + p = _make_proc(10) + kill_process(p, force=False) + p.terminate.assert_called_once() + p.kill.assert_not_called() + + def test_kill_called_with_force(self): + p = _make_proc(10) + kill_process(p, force=True) + p.kill.assert_called_once() + p.terminate.assert_not_called() + + +class TestKillTree(unittest.TestCase): + @patch("script.process.kill_process_by_port.psutil.wait_procs") + def test_kills_children_then_root(self, mock_wait): + root = _make_proc(10, "root_proc") + child1 = _make_proc(11, "child1") + child2 = _make_proc(12, "child2") + root.children.return_value = [child1, child2] + mock_wait.return_value = ( + [root, child1, child2], + [], + ) + + alive = kill_tree(root, force=False) + child1.terminate.assert_called_once() + child2.terminate.assert_called_once() + root.terminate.assert_called_once() + self.assertEqual(alive, []) + + @patch("script.process.kill_process_by_port.psutil.wait_procs") + def test_returns_alive_processes(self, mock_wait): + root = _make_proc(10) + root.children.return_value = [] + mock_wait.return_value = ([], [root]) + + alive = kill_tree(root, force=True) + self.assertEqual(len(alive), 1) + + @patch("script.process.kill_process_by_port.psutil.wait_procs") + def test_handles_psutil_error_on_child(self, mock_wait): + root = _make_proc(10) + child = _make_proc(11) + child.terminate.side_effect = psutil.NoSuchProcess(11) + root.children.return_value = [child] + mock_wait.return_value = ([root], []) + + alive = kill_tree(root, force=False) + self.assertEqual(alive, []) + + +class TestFindListeners(unittest.TestCase): + @patch("script.process.kill_process_by_port.psutil.net_connections") + def test_finds_listening_pid(self, mock_conns): + conn = MagicMock() + conn.laddr = MagicMock() + conn.laddr.port = 8069 + conn.status = psutil.CONN_LISTEN + conn.pid = 1234 + mock_conns.return_value = [conn] + + result = find_listeners(8069) + self.assertEqual(result, [1234]) + + @patch("script.process.kill_process_by_port.psutil.net_connections") + def test_ignores_different_port(self, mock_conns): + conn = MagicMock() + conn.laddr = MagicMock() + conn.laddr.port = 9999 + conn.status = psutil.CONN_LISTEN + conn.pid = 1234 + mock_conns.return_value = [conn] + + result = find_listeners(8069) + self.assertEqual(result, []) + + @patch("script.process.kill_process_by_port.psutil.net_connections") + def test_ignores_non_listen_status(self, mock_conns): + conn = MagicMock() + conn.laddr = MagicMock() + conn.laddr.port = 8069 + conn.status = psutil.CONN_ESTABLISHED + conn.pid = 1234 + mock_conns.return_value = [conn] + + result = find_listeners(8069) + self.assertEqual(result, []) + + @patch("script.process.kill_process_by_port.psutil.net_connections") + def test_no_connections(self, mock_conns): + mock_conns.return_value = [] + result = find_listeners(8069) + self.assertEqual(result, []) + + @patch("script.process.kill_process_by_port.psutil.net_connections") + def test_deduplicates_pids(self, mock_conns): + conn1 = MagicMock() + conn1.laddr = MagicMock() + conn1.laddr.port = 8069 + conn1.status = psutil.CONN_LISTEN + conn1.pid = 100 + conn2 = MagicMock() + conn2.laddr = MagicMock() + conn2.laddr.port = 8069 + conn2.status = psutil.CONN_LISTEN + conn2.pid = 100 + mock_conns.return_value = [conn1, conn2] + + result = find_listeners(8069) + self.assertEqual(result, [100]) + + +class TestProtectedNames(unittest.TestCase): + def test_systemd_is_protected(self): + self.assertIn("systemd", PROTECTED_NAMES) + + def test_gnome_shell_is_protected(self): + self.assertIn("gnome-shell", PROTECTED_NAMES) + + def test_sshd_is_protected(self): + self.assertIn("sshd", PROTECTED_NAMES) + + +class TestStopParentKill(unittest.TestCase): + def test_contains_run_sh(self): + self.assertIn("./run.sh", STOP_PARENT_KILL) + + def test_contains_odoo_bin_sh(self): + self.assertIn("./odoo_bin.sh", STOP_PARENT_KILL) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_todo.py b/test/test_todo.py new file mode 100644 index 0000000..b2ef961 --- /dev/null +++ b/test/test_todo.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +# © 2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import json +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from script.todo.todo import ( + ANDROID_DIR, + CONFIG_FILE, + CONFIG_OVERRIDE_FILE, + ENABLE_CRASH, + GRADLE_FILE, + INSTALLED_ODOO_VERSION_FILE, + LOGO_ASCII_FILE, + MOBILE_HOME_PATH, + ODOO_VERSION_FILE, + STRINGS_FILE, + TODO, + VERSION_DATA_FILE, + cst_venv_erplibre, + file_error_path, +) + + +class TestTODOInit(unittest.TestCase): + def test_initial_attributes(self): + todo = TODO() + self.assertIsNone(todo.dir_path) + self.assertIsNone(todo.kdbx) + self.assertIsNone(todo.file_path) + self.assertIsNotNone(todo.config_file) + self.assertIsNotNone(todo.execute) + + +class TestFillHelpInfo(unittest.TestCase): + def setUp(self): + self.todo = TODO() + + @patch("script.todo.todo.t") + def test_basic_help_info(self, mock_t): + mock_t.side_effect = lambda k: { + "command": "Command:", + "back": "Back", + }.get(k, k) + lst_choice = [ + {"prompt_description": "Option A"}, + {"prompt_description": "Option B"}, + ] + result = self.todo.fill_help_info(lst_choice) + self.assertIn("[1] Option A", result) + self.assertIn("[2] Option B", result) + self.assertIn("[0] Back", result) + + @patch("script.todo.todo.t") + def test_with_prompt_description_key(self, mock_t): + mock_t.side_effect = lambda k: { + "command": "Command:", + "back": "Back", + "my_key": "Translated Description", + }.get(k, k) + lst_choice = [ + { + "prompt_description": "fallback", + "prompt_description_key": "my_key", + }, + ] + result = self.todo.fill_help_info(lst_choice) + self.assertIn("[1] Translated Description", result) + + @patch("script.todo.todo.t") + def test_empty_list(self, mock_t): + mock_t.side_effect = lambda k: { + "command": "Command:", + "back": "Back", + }.get(k, k) + result = self.todo.fill_help_info([]) + self.assertIn("Command:", result) + self.assertIn("[0] Back", result) + self.assertNotIn("[1]", result) + + +class TestGetOdooVersion(unittest.TestCase): + def test_reads_version_data(self): + version_data = { + "odoo18.0_python3.12.10": { + "odoo_version": "18.0", + "python_version": "3.12.10", + "default": True, + "is_deprecated": False, + }, + "odoo16.0_python3.10.18": { + "odoo_version": "16.0", + "python_version": "3.10.18", + "default": False, + "is_deprecated": False, + }, + } + todo = TODO() + with tempfile.TemporaryDirectory() as tmpdir: + version_file = os.path.join(tmpdir, "version.json") + with open(version_file, "w") as f: + json.dump(version_data, f) + + odoo_version_file = os.path.join(tmpdir, ".odoo-version") + with open(odoo_version_file, "w") as f: + f.write("18.0") + + with patch( + "script.todo.todo.VERSION_DATA_FILE", version_file + ), patch( + "script.todo.todo.INSTALLED_ODOO_VERSION_FILE", + os.path.join(tmpdir, "nonexistent.txt"), + ), patch( + "script.todo.todo.ODOO_VERSION_FILE", + odoo_version_file, + ): + lst_version, lst_installed, odoo_current = ( + todo.get_odoo_version() + ) + + self.assertEqual(len(lst_version), 2) + self.assertEqual(odoo_current, "odoo18.0") + # Check erplibre_version was added + names = [v["erplibre_version"] for v in lst_version] + self.assertIn("odoo18.0_python3.12.10", names) + self.assertIn("odoo16.0_python3.10.18", names) + + def test_installed_versions_read(self): + version_data = { + "odoo18.0_python3.12.10": { + "odoo_version": "18.0", + "python_version": "3.12.10", + "default": True, + "is_deprecated": False, + }, + } + todo = TODO() + with tempfile.TemporaryDirectory() as tmpdir: + version_file = os.path.join(tmpdir, "version.json") + with open(version_file, "w") as f: + json.dump(version_data, f) + + installed_file = os.path.join(tmpdir, "installed.txt") + with open(installed_file, "w") as f: + f.write("odoo18.0\nodoo16.0\n") + + with patch( + "script.todo.todo.VERSION_DATA_FILE", version_file + ), patch( + "script.todo.todo.INSTALLED_ODOO_VERSION_FILE", + installed_file, + ), patch( + "script.todo.todo.ODOO_VERSION_FILE", + os.path.join(tmpdir, "nonexistent"), + ): + lst_version, lst_installed, odoo_current = ( + todo.get_odoo_version() + ) + + self.assertEqual(lst_installed, ["odoo16.0", "odoo18.0"]) + self.assertIsNone(odoo_current) + + def test_no_version_data_raises(self): + todo = TODO() + with tempfile.TemporaryDirectory() as tmpdir: + version_file = os.path.join(tmpdir, "empty.json") + with open(version_file, "w") as f: + json.dump({}, f) + + with patch("script.todo.todo.VERSION_DATA_FILE", version_file): + with self.assertRaises(Exception): + todo.get_odoo_version() + + +class TestOnDirSelected(unittest.TestCase): + @patch("script.todo.todo.todo_file_browser", create=True) + def test_sets_dir_path(self, mock_browser): + todo = TODO() + todo.on_dir_selected("/some/path") + self.assertEqual(todo.dir_path, "/some/path") + + +class TestExecuteFromConfiguration(unittest.TestCase): + def test_with_command(self): + todo = TODO() + todo.execute = MagicMock() + dct = {"command": "./run.sh"} + todo.execute_from_configuration(dct) + todo.execute.exec_command_live.assert_called() + + def test_with_makefile_cmd(self): + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = 0 + dct = {"makefile_cmd": "run_test"} + todo.execute_from_configuration(dct) + call_args = todo.execute.exec_command_live.call_args + self.assertIn("make run_test", call_args[0][0]) + + def test_makefile_cmd_ignored_when_flag(self): + todo = TODO() + todo.execute = MagicMock() + dct = {"makefile_cmd": "run_test"} + todo.execute_from_configuration(dct, ignore_makefile=True) + todo.execute.exec_command_live.assert_not_called() + + def test_with_callback(self): + todo = TODO() + todo.execute = MagicMock() + callback = MagicMock() + dct = {"callback": callback} + todo.execute_from_configuration(dct) + callback.assert_called_once_with(dct) + + def test_makefile_error_stops_execution(self): + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = 1 + callback = MagicMock() + dct = {"makefile_cmd": "broken", "callback": callback} + todo.execute_from_configuration(dct) + callback.assert_not_called() + + +class TestConstants(unittest.TestCase): + def test_config_file_path(self): + self.assertEqual(CONFIG_FILE, "./script/todo/todo.json") + + def test_config_override_path(self): + self.assertEqual(CONFIG_OVERRIDE_FILE, "./private/todo/todo.json") + + def test_logo_path(self): + self.assertEqual(LOGO_ASCII_FILE, "./script/todo/logo_ascii.txt") + + def test_venv_erplibre(self): + self.assertEqual(cst_venv_erplibre, ".venv.erplibre") + + def test_file_error_path(self): + self.assertEqual(file_error_path, ".erplibre.error.txt") + + def test_version_data_file(self): + self.assertEqual( + VERSION_DATA_FILE, + os.path.join("conf", "supported_version_erplibre.json"), + ) + + def test_mobile_paths(self): + self.assertEqual(ANDROID_DIR, "android") + self.assertIn("mobile", MOBILE_HOME_PATH) + + +class TestDeployGitServer(unittest.TestCase): + def test_local_mode(self): + todo = TODO() + todo.execute = MagicMock() + todo._deploy_git_server(production_ready=False, action="init") + cmd = todo.execute.exec_command_live.call_args[0][0] + self.assertIn("--action init", cmd) + self.assertNotIn("--production-ready", cmd) + + def test_production_mode(self): + todo = TODO() + todo.execute = MagicMock() + todo._deploy_git_server(production_ready=True, action="all") + cmd = todo.execute.exec_command_live.call_args[0][0] + self.assertIn("--production-ready", cmd) + self.assertIn("--action all", cmd) + + +class TestProcessKillGitDaemon(unittest.TestCase): + def test_calls_pkill(self): + todo = TODO() + todo.execute = MagicMock() + todo.process_kill_git_daemon() + cmd = todo.execute.exec_command_live.call_args[0][0] + self.assertIn("pkill", cmd) + self.assertIn("git daemon", cmd) + + +class TestExecuteUnitTests(unittest.TestCase): + def test_success_path(self): + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = (0, ["OK"]) + with patch("builtins.print") as mock_print: + todo.execute_unit_tests() + cmd = todo.execute.exec_command_live.call_args[0][0] + self.assertIn("unittest discover", cmd) + + def test_failure_path(self): + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = (1, ["FAIL"]) + with patch("builtins.print") as mock_print: + todo.execute_unit_tests() + # Verify it was called - error handling path + + +class TestKdbxGetExtraCommandUser(unittest.TestCase): + def test_empty_kdbx_key(self): + todo = TODO() + result = todo.kdbx_get_extra_command_user("") + self.assertEqual(result, "") + + def test_none_kdbx_key(self): + todo = TODO() + result = todo.kdbx_get_extra_command_user(None) + self.assertEqual(result, "") + + def test_kdbx_not_available(self): + todo = TODO() + todo.get_kdbx = MagicMock(return_value=None) + result = todo.kdbx_get_extra_command_user("some_key") + self.assertEqual(result, "") + + +class TestSetupClaudeCommit(unittest.TestCase): + def test_existing_file_skips(self): + todo = TODO() + with patch("os.path.exists", return_value=True), patch( + "builtins.print" + ) as mock_print: + todo._setup_claude_commit() + # Should print exists message without asking for input + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_todo_i18n.py b/test/test_todo_i18n.py new file mode 100644 index 0000000..64ff1ee --- /dev/null +++ b/test/test_todo_i18n.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +# © 2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import os +import tempfile +import unittest +from unittest.mock import patch + +from script.todo import todo_i18n + + +class TestTranslations(unittest.TestCase): + """Test TRANSLATIONS dictionary integrity.""" + + def test_all_entries_have_fr_and_en(self): + for key, entry in todo_i18n.TRANSLATIONS.items(): + self.assertIn("fr", entry, f"Key '{key}' missing 'fr' translation") + self.assertIn("en", entry, f"Key '{key}' missing 'en' translation") + + def test_no_empty_translations(self): + for key, entry in todo_i18n.TRANSLATIONS.items(): + for lang in ("fr", "en"): + self.assertTrue( + len(entry[lang]) > 0, + f"Key '{key}' has empty '{lang}' translation", + ) + + def test_translations_not_empty(self): + self.assertGreater(len(todo_i18n.TRANSLATIONS), 0) + + +class TestT(unittest.TestCase): + """Test t() translation function.""" + + def setUp(self): + todo_i18n._current_lang = None + + def tearDown(self): + todo_i18n._current_lang = None + + def test_returns_french_when_lang_fr(self): + todo_i18n.set_lang("fr") + result = todo_i18n.t("menu_quit") + self.assertEqual(result, "Quitter") + + def test_returns_english_when_lang_en(self): + todo_i18n.set_lang("en") + result = todo_i18n.t("menu_quit") + self.assertEqual(result, "Quit") + + def test_unknown_key_returns_key(self): + todo_i18n.set_lang("fr") + result = todo_i18n.t("nonexistent_key_xyz") + self.assertEqual(result, "nonexistent_key_xyz") + + def test_fallback_to_fr_if_lang_missing(self): + todo_i18n.set_lang("de") + result = todo_i18n.t("menu_quit") + self.assertEqual(result, "Quitter") + + +class TestGetLang(unittest.TestCase): + """Test get_lang() function.""" + + def setUp(self): + todo_i18n._current_lang = None + + def tearDown(self): + todo_i18n._current_lang = None + + def test_returns_cached_lang(self): + todo_i18n._current_lang = "en" + result = todo_i18n.get_lang() + self.assertEqual(result, "en") + + def test_reads_from_env_var_sh(self): + with tempfile.NamedTemporaryFile( + mode="w", suffix=".sh", delete=False + ) as f: + f.write('EL_LANG="en"\n') + f.flush() + try: + with patch.object( + todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name + ): + result = todo_i18n.get_lang() + self.assertEqual(result, "en") + finally: + os.unlink(f.name) + + def test_reads_unquoted_lang(self): + with tempfile.NamedTemporaryFile( + mode="w", suffix=".sh", delete=False + ) as f: + f.write("EL_LANG=fr\n") + f.flush() + try: + with patch.object( + todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name + ): + result = todo_i18n.get_lang() + self.assertEqual(result, "fr") + finally: + os.unlink(f.name) + + def test_env_variable_fallback(self): + with patch.object( + todo_i18n, + "CONFIG_OVERRIDE_PRIVATE_FILE", + "/nonexistent/path", + ), patch.dict(os.environ, {"EL_LANG": "en"}): + result = todo_i18n.get_lang() + self.assertEqual(result, "en") + + def test_default_is_fr(self): + with patch.object( + todo_i18n, + "CONFIG_OVERRIDE_PRIVATE_FILE", + "/nonexistent/path", + ), patch.dict(os.environ, {}, clear=True): + result = todo_i18n.get_lang() + self.assertEqual(result, "fr") + + def test_invalid_lang_in_file_falls_through(self): + with tempfile.NamedTemporaryFile( + mode="w", suffix=".sh", delete=False + ) as f: + f.write('EL_LANG="de"\n') + f.flush() + try: + with patch.object( + todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name + ), patch.dict(os.environ, {}, clear=True): + result = todo_i18n.get_lang() + self.assertEqual(result, "fr") + finally: + os.unlink(f.name) + + +class TestSetLang(unittest.TestCase): + """Test set_lang() function.""" + + def setUp(self): + todo_i18n._current_lang = None + + def tearDown(self): + todo_i18n._current_lang = None + + def test_sets_current_lang(self): + todo_i18n.set_lang("en") + self.assertEqual(todo_i18n._current_lang, "en") + + def test_persists_to_file_update(self): + with tempfile.NamedTemporaryFile( + mode="w", suffix=".sh", delete=False + ) as f: + f.write('EL_LANG="fr"\nOTHER=value\n') + f.flush() + try: + with patch.object( + todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name + ): + todo_i18n.set_lang("en") + with open(f.name) as rf: + content = rf.read() + self.assertIn('EL_LANG="en"', content) + self.assertIn("OTHER=value", content) + finally: + os.unlink(f.name) + + def test_persists_to_file_append(self): + with tempfile.NamedTemporaryFile( + mode="w", suffix=".sh", delete=False + ) as f: + f.write("SOME_VAR=123\n") + f.flush() + try: + with patch.object( + todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name + ): + todo_i18n.set_lang("en") + with open(f.name) as rf: + content = rf.read() + self.assertIn('EL_LANG="en"', content) + self.assertIn("SOME_VAR=123", content) + finally: + os.unlink(f.name) + + def test_nonexistent_file_no_crash(self): + with patch.object( + todo_i18n, + "CONFIG_OVERRIDE_PRIVATE_FILE", + "/nonexistent/path", + ): + todo_i18n.set_lang("en") + self.assertEqual(todo_i18n._current_lang, "en") + + +class TestLangIsConfigured(unittest.TestCase): + """Test lang_is_configured() function.""" + + def test_returns_true_when_configured(self): + with tempfile.NamedTemporaryFile( + mode="w", suffix=".sh", delete=False + ) as f: + f.write('EL_LANG="fr"\n') + f.flush() + try: + with patch.object( + todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name + ): + result = todo_i18n.lang_is_configured() + self.assertTrue(result) + finally: + os.unlink(f.name) + + def test_returns_false_when_not_configured(self): + with tempfile.NamedTemporaryFile( + mode="w", suffix=".sh", delete=False + ) as f: + f.write("SOME_VAR=123\n") + f.flush() + try: + with patch.object( + todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name + ): + result = todo_i18n.lang_is_configured() + self.assertFalse(result) + finally: + os.unlink(f.name) + + def test_returns_false_when_file_missing(self): + with patch.object( + todo_i18n, + "CONFIG_OVERRIDE_PRIVATE_FILE", + "/nonexistent/path", + ): + result = todo_i18n.lang_is_configured() + self.assertFalse(result) + + +if __name__ == "__main__": + unittest.main() From e27c544f02c4534353e58d210b21bfd260376785 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 10 Mar 2026 03:06:07 -0400 Subject: [PATCH 07/24] [REF] script: rename variables to follow naming conventions Replace Hungarian notation, French variable names, and lowercase constants with clear English names across 5 scripts: config_file, execute, kill_process_by_port, todo, and git_tool. Remove unused find_in_private variable. All 142 tests pass. Generated by Claude Code 2.1.72 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- script/config/config_file.py | 19 ++++--- script/execute/execute.py | 74 +++++++++++++------------- script/git/git_tool.py | 30 +++++------ script/process/kill_process_by_port.py | 15 +++--- script/todo/todo.py | 36 ++++++------- test/test_git_tool.py | 20 +++---- test/test_todo.py | 8 +-- 7 files changed, 101 insertions(+), 101 deletions(-) diff --git a/script/config/config_file.py b/script/config/config_file.py index 1885c96..78f9baf 100644 --- a/script/config/config_file.py +++ b/script/config/config_file.py @@ -26,27 +26,27 @@ _logger = logging.getLogger(__name__) class ConfigFile: def get_config(self, key_param: str): # Open file and update dct_data - dct_data_init = {} - dct_data_second = {} - dct_data_final = {} + config_base = {} + config_override = {} + config_private = {} if os.path.exists(CONFIG_FILE): with open(CONFIG_FILE) as cfg: - dct_data_init = json.load(cfg) + config_base = json.load(cfg) if os.path.exists(CONFIG_OVERRIDE_FILE): with open(CONFIG_OVERRIDE_FILE) as cfg: - dct_data_second = json.load(cfg) + config_override = json.load(cfg) if os.path.exists(CONFIG_OVERRIDE_PRIVATE_FILE): with open(CONFIG_OVERRIDE_PRIVATE_FILE) as cfg: - dct_data_final = json.load(cfg) + config_private = json.load(cfg) - dct_data_first_merge = self.deep_merge_with_lists( - dct_data_init, dct_data_final, list_strategy="extend" + merged_base_private = self.deep_merge_with_lists( + config_base, config_private, list_strategy="extend" ) dct_data = self.deep_merge_with_lists( - dct_data_first_merge, dct_data_second, list_strategy="extend" + merged_base_private, config_override, list_strategy="extend" ) return dct_data.get(key_param) @@ -55,7 +55,6 @@ class ConfigFile: dct_data = self.get_config(lst_params[0]) for param in lst_params[1:]: if param in dct_data.keys(): - find_in_private = True dct_data = dct_data.get(param) return dct_data diff --git a/script/execute/execute.py b/script/execute/execute.py index 8ea3355..8fa9f8e 100644 --- a/script/execute/execute.py +++ b/script/execute/execute.py @@ -15,7 +15,7 @@ try: except ModuleNotFoundError as e: humanize = None -cst_venv_erplibre = ".venv.erplibre" +VENV_ERPLIBRE = ".venv.erplibre" new_path = os.path.normpath( os.path.join(os.path.dirname(__file__), "..", "..") @@ -42,7 +42,7 @@ class Execute: if exec_path_gnome_terminal: self.cmd_source_erplibre = ( f"gnome-terminal -- bash -c 'source" - f" ./{cst_venv_erplibre}/bin/activate;%s'" + f" ./{VENV_ERPLIBRE}/bin/activate;%s'" ) self.cmd_source_default = "gnome-terminal -- bash -c '" f"%s'" else: @@ -52,16 +52,16 @@ class Execute: "osascript -e 'tell application \"Terminal\"'" ) self.cmd_source_erplibre += " -e 'tell application \"System Events\" to keystroke \"t\" using {command down}' -e 'delay 0.1' -e 'do script \"" - self.cmd_source_erplibre += f"cd {os.getcwd()}; source ./{cst_venv_erplibre}/bin/activate; %s\" in front window'" + self.cmd_source_erplibre += f"cd {os.getcwd()}; source ./{VENV_ERPLIBRE}/bin/activate; %s\" in front window'" self.cmd_source_erplibre += " -e 'end tell'" else: self.cmd_source_erplibre = ( - f"source ./{cst_venv_erplibre}/bin/activate;%s" + f"source ./{VENV_ERPLIBRE}/bin/activate;%s" ) def exec_command_live( self, - commande, + command, source_erplibre=True, quiet=False, single_source_erplibre=False, @@ -74,10 +74,10 @@ class Execute: return_status_and_output_and_command=False, ): """ - Exécute une commande et affiche la sortie en direct. + Exécute une command et affiche la sortie en direct. Args: - commande (str): La commande à exécuter (sous forme de chaîne de caractères). + command (str): La command à exécuter (sous forme de chaîne de caractères). """ my_env = os.environ.copy() @@ -85,18 +85,18 @@ class Execute: my_env.update(new_env) process_start_time = time.time() - return_status = None + exit_code = None if source_erplibre: - # commande = f"source ./{cst_venv_erplibre}/bin/activate && " + commande + # command = f"source ./{VENV_ERPLIBRE}/bin/activate && " + command # cmd = ( # f"gnome-terminal --tab -- bash -c 'source" - # f" ./{cst_venv_erplibre}/bin/activate;{commande}'" + # f" ./{VENV_ERPLIBRE}/bin/activate;{command}'" # ) - commande = self.cmd_source_erplibre % commande - # os.system(f"./script/terminal/open_terminal.sh {commande}") + command = self.cmd_source_erplibre % command + # os.system(f"./script/terminal/open_terminal.sh {command}") elif single_source_erplibre: - commande = ( - f"source ./{cst_venv_erplibre}/bin/activate && %s" % commande + command = ( + f"source ./{VENV_ERPLIBRE}/bin/activate && %s" % command ) elif single_source_odoo: if not source_odoo and os.path.exists("./.erplibre-version"): @@ -104,68 +104,68 @@ class Execute: source_odoo = f.read() if not source_odoo: _logger.error( - f"You cannot execute Odoo command if no version is installed. Command : {commande}" + f"You cannot execute Odoo command if no version is installed. Command : {command}" ) return -1 - commande = ( - f"source ./.venv.{source_odoo}/bin/activate && {commande}" + command = ( + f"source ./.venv.{source_odoo}/bin/activate && {command}" ) if new_window and self.cmd_source_default: - commande = self.cmd_source_default % commande + command = self.cmd_source_default % command if not quiet: print("🏠 ⬇ Execute command :") - print(commande) - lst_output = [] + print(command) + output_lines = [] try: process = subprocess.Popen( - commande, + command, shell=True, executable="/bin/bash", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, # Désactive la mise en tampon pour la sortie en direct - universal_newlines=True, # Pour traiter les sauts de lignes correctement + universal_newlines=True, # Pour traiter les sauts de lines correctement env=my_env, ) while True: - ligne = process.stdout.readline() - if not ligne: + line = process.stdout.readline() + if not line: break if not quiet: - print(ligne, end="") + print(line, end="") if ( return_status_and_output or return_status_and_output_and_command ): # Remove last \n char - lst_output.append( - ligne.removesuffix("\r\n") + output_lines.append( + line.removesuffix("\r\n") .removesuffix("\n") .removesuffix("\r") ) process.wait() # Attendre la fin du process - return_status = process.returncode + exit_code = process.returncode if process.returncode != 0 and not quiet: print( - "La commande a retourné un code d'erreur :" + "La command a retourné un code d'erreur :" f" {process.returncode}" ) except FileNotFoundError: if not quiet: - if "password" in commande: + if "password" in command: print( - f"Erreur : La commande '{commande.split(' ')[0]}'[...] n'a" + f"Erreur : La command '{command.split(' ')[0]}'[...] n'a" " pas été trouvée." ) else: print( - f"Erreur : La commande '{commande}' n'a pas été trouvée." + f"Erreur : La command '{command}' n'a pas été trouvée." ) except Exception as e: if not quiet: @@ -181,12 +181,12 @@ class Execute: if not quiet: print(f"🏠 ⬆ Executed ({duration_sec:.2f} sec.) :") if not quiet: - print(commande) + print(command) print() if return_status_and_output_and_command: - return return_status, commande, lst_output + return exit_code, command, output_lines if return_status_and_command: - return return_status, commande + return exit_code, command if return_status_and_output: - return return_status, lst_output - return return_status + return exit_code, output_lines + return exit_code diff --git a/script/git/git_tool.py b/script/git/git_tool.py index 73d360a..9ff7b7d 100644 --- a/script/git/git_tool.py +++ b/script/git/git_tool.py @@ -16,14 +16,14 @@ from git import Repo from giturlparse import parse # pip install giturlparse from retrying import retry # pip install retrying -CST_FILE_SOURCE_REPO_ADDONS = "source_repo_addons.csv" -CST_EL_GITHUB_TOKEN = "EL_GITHUB_TOKEN" +SOURCE_REPO_ADDONS_FILE = "source_repo_addons.csv" +EL_GITHUB_TOKEN = "EL_GITHUB_TOKEN" DEFAULT_PROJECT_NAME = "ERPLibre" DEFAULT_WEBSITE = "erplibre.ca" DEFAULT_REMOTE_URL = "https://github.com/ERPLibre/ERPLibre.git" -class Struct(object): +class RepoAttrs(object): def __init__(self, **entries): self.__dict__.update(entries) @@ -146,7 +146,7 @@ class GitTool: "sub_path": sub_path, } if get_obj: - return Struct(**d) + return RepoAttrs(**d) return d def get_repo_info( @@ -190,10 +190,10 @@ class GitTool: name = "" url = "" - no_line = 0 + line_number = 0 first_execution = True for line in txt: - no_line += 1 + line_number += 1 if line[:12] == '[submodule "': if not first_execution: data = { @@ -395,7 +395,7 @@ class GitTool: :param repo_path: path of repo to get information env_var.sh :return: { - CST_EL_GITHUB_TOKEN: TOKEN, + EL_GITHUB_TOKEN: TOKEN, } """ filename = os.path.join(repo_path, "env_var.sh") @@ -403,7 +403,7 @@ class GitTool: txt = file.readlines() txt = [a[:-1] for a in txt if "=" in a] - lst_filter = [CST_EL_GITHUB_TOKEN] + lst_filter = [EL_GITHUB_TOKEN] dct_config = {} # Take filtered value and get bash string values for f in lst_filter: @@ -411,7 +411,7 @@ class GitTool: if f in v: lst_v = v.split("=") if len(lst_v) > 1: - dct_config[CST_EL_GITHUB_TOKEN] = v.split("=")[1][1:-1] + dct_config[EL_GITHUB_TOKEN] = v.split("=")[1][1:-1] return dct_config @staticmethod @@ -531,7 +531,7 @@ class GitTool: def generate_repo_manifest( self, - lst_repo: List[Struct] = [], + lst_repo: List[RepoAttrs] = [], output: str = "", dct_remote={}, dct_project={}, @@ -730,7 +730,7 @@ class GitTool: print(str_xml_text + "\n") def generate_git_modules( - self, lst_repo: List[Struct], repo_path: str = "." + self, lst_repo: List[RepoAttrs], repo_path: str = "." ): lst_modules = [] for repo in lst_repo: @@ -747,8 +747,8 @@ class GitTool: def get_source_repo_addons(self, repo_path=".", add_repo_root=False): """ - Read file CST_FILE_SOURCE_REPO_ADDONS and return structure of data - :param repo_path: path to find file CST_FILE_SOURCE_REPO_ADDONS + Read file SOURCE_REPO_ADDONS_FILE and return structure of data + :param repo_path: path to find file SOURCE_REPO_ADDONS_FILE :param add_repo_root: force adding repo root in the list :return: [{ @@ -760,7 +760,7 @@ class GitTool: "name": name of the submodule }] """ - file_name = os.path.join(repo_path, CST_FILE_SOURCE_REPO_ADDONS) + file_name = os.path.join(repo_path, SOURCE_REPO_ADDONS_FILE) lst_result = [] if add_repo_root: # TODO what to do if origin not exist? @@ -959,7 +959,7 @@ class GitTool: @staticmethod def add_and_fetch_remote( - repo_info: Struct, root_repo: Repo = None, branch_name: str = "" + repo_info: RepoAttrs, root_repo: Repo = None, branch_name: str = "" ): """ Deprecated function, not use anymore git submodule diff --git a/script/process/kill_process_by_port.py b/script/process/kill_process_by_port.py index 0610320..c34289e 100755 --- a/script/process/kill_process_by_port.py +++ b/script/process/kill_process_by_port.py @@ -69,7 +69,7 @@ def get_ancestry(pid: int): return chain -def choose_target(chain, nb_parent): +def choose_target(chain, parent_depth): """ Decide which process to kill. Default: kill the highest ancestor before PID 1 (so not systemd). @@ -77,13 +77,13 @@ def choose_target(chain, nb_parent): if not chain: return None - lst_chain_parent = [] + parent_chain = [] for pid in chain: - lst_chain_parent.append(pid) + parent_chain.append(pid) for str_to_stop in STOP_PARENT_KILL: if str_to_stop in pid.cmdline(): - return pid, lst_chain_parent - return chain[nb_parent - 1], [chain[nb_parent - 1]] + return pid, parent_chain + return chain[parent_depth - 1], [chain[parent_depth - 1]] def kill_process(p: psutil.Process, force: bool): @@ -151,6 +151,7 @@ def main(): ) ap.add_argument( "--nb_parent", + dest="parent_depth", type=int, default=1, help="Kill parent too", @@ -176,7 +177,7 @@ def main(): if not chain: continue - target, lst_target = choose_target(chain, args.nb_parent) + target, lst_target = choose_target(chain, args.parent_depth) print(f"\nListener PID {pid} ancestry:") for i, p in enumerate(chain): @@ -207,7 +208,7 @@ def main(): while not has_response and not ignore_kill: confirm = ( input( - f"Tuer ce processus index {args.nb_parent} (enter) ou mettre " + f"Tuer ce processus index {args.parent_depth} (enter) ou mettre " f"l'index [0 to {len(chain) - 1}] du " f"process à tuer, (c/C) pour annuler : \n" ) diff --git a/script/todo/todo.py b/script/todo/todo.py index 87b8de0..dcdda93 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -24,8 +24,8 @@ from script.config import config_file from script.execute import execute from script.todo.todo_i18n import get_lang, lang_is_configured, set_lang, t -file_error_path = ".erplibre.error.txt" -cst_venv_erplibre = ".venv.erplibre" +ERROR_LOG_PATH = ".erplibre.error.txt" +VENV_ERPLIBRE = ".venv.erplibre" VERSION_DATA_FILE = os.path.join("conf", "supported_version_erplibre.json") INSTALLED_ODOO_VERSION_FILE = os.path.join( ".repo", "installed_odoo_version.txt" @@ -137,11 +137,11 @@ class TODO: status = click.prompt(help_info) except NameError: print("Do") - print(f"source ./{cst_venv_erplibre}/bin/activate && make") + print(f"source ./{VENV_ERPLIBRE}/bin/activate && make") sys.exit(1) except ImportError: print("Do") - print(f"source ./{cst_venv_erplibre}/bin/activate && make") + print(f"source ./{VENV_ERPLIBRE}/bin/activate && make") sys.exit(1) except click.exceptions.Abort: sys.exit(0) @@ -157,7 +157,7 @@ class TODO: elif status == "4": # cmd = ( # f"gnome-terminal --tab -- bash -c 'source" - # f" ./{cst_venv_erplibre}/bin/activate;make todo'" + # f" ./{VENV_ERPLIBRE}/bin/activate;make todo'" # ) cmd = "make todo" self.execute.exec_command_live(cmd, source_erplibre=True) @@ -1461,15 +1461,15 @@ class TODO: self.execute.exec_command_live(new_cmd) def crash_diagnostic(self, e): - # TODO show message at start if os.path.exists(file_error_path) - if os.path.exists(file_error_path) and not os.path.exists( - cst_venv_erplibre + # TODO show message at start if os.path.exists(ERROR_LOG_PATH) + if os.path.exists(ERROR_LOG_PATH) and not os.path.exists( + VENV_ERPLIBRE ): print("Got error : ") print(e) - print("Got error at first execution.", file_error_path) + print("Got error at first execution.", ERROR_LOG_PATH) try: - file = open(file_error_path, "r") + file = open(ERROR_LOG_PATH, "r") content = file.read() # TODO si vide, ajouter notre erreur print(content) @@ -1485,7 +1485,7 @@ class TODO: # self.restart_script(e) self.execute.exec_command_live(cmd, source_erplibre=True) sys.exit(1) - if os.path.exists(cst_venv_erplibre): + if os.path.exists(VENV_ERPLIBRE): print("Import error : ") print(e) # TODO auto-detect gnome-terminal, or choose another. Is it done already? @@ -1493,13 +1493,13 @@ class TODO: # self.prompt_install() # print( - # f"You forgot to activate source \nsource ./{cst_venv_erplibre}/bin/activate" + # f"You forgot to activate source \nsource ./{VENV_ERPLIBRE}/bin/activate" # ) # time.sleep(0.5) # cmd = "./script/todo/source_todo.sh" print("Re-execute TODO 🤖 or execute :") print() - print(f"source {cst_venv_erplibre}/bin/activate;make") + print(f"source {VENV_ERPLIBRE}/bin/activate;make") print() cmd = "./script/todo/todo.py" # # self.restart_script(e) @@ -1763,16 +1763,16 @@ class TODO: print(f"🤖 {t('reboot_todo')}") # os.execv(sys.executable, ['python'] + sys.argv) # TODO mettre check que le répertoire est créé, s'il existe, auto-loop à corriger - if os.path.exists(cst_venv_erplibre) and not os.path.exists( - file_error_path + if os.path.exists(VENV_ERPLIBRE) and not os.path.exists( + ERROR_LOG_PATH ): # TODO mettre check import suivant ne vont pas planter try: - with open(file_error_path, "w") as f_file: + with open(ERROR_LOG_PATH, "w") as f_file: f_file.write(str(last_error)) pass # The file is created and closed here, no content is written print( - f"Try to reopen process with before :\nsource ./{cst_venv_erplibre}/bin/activate && exec python " + f"Try to reopen process with before :\nsource ./{VENV_ERPLIBRE}/bin/activate && exec python " + " ".join(sys.argv) ) os.execv( @@ -1780,7 +1780,7 @@ class TODO: [ "/bin/bash", "-c", - f"source ./{cst_venv_erplibre}/bin/activate && exec python " + f"source ./{VENV_ERPLIBRE}/bin/activate && exec python " + " ".join(sys.argv), ], ) diff --git a/test/test_git_tool.py b/test/test_git_tool.py index 113514b..5580006 100644 --- a/test/test_git_tool.py +++ b/test/test_git_tool.py @@ -9,28 +9,28 @@ from collections import OrderedDict from unittest.mock import MagicMock, mock_open, patch from script.git.git_tool import ( - CST_EL_GITHUB_TOKEN, - CST_FILE_SOURCE_REPO_ADDONS, DEFAULT_PROJECT_NAME, DEFAULT_REMOTE_URL, DEFAULT_WEBSITE, + EL_GITHUB_TOKEN, GitTool, - Struct, + RepoAttrs, + SOURCE_REPO_ADDONS_FILE, ) -class TestStruct(unittest.TestCase): +class TestRepoAttrs(unittest.TestCase): def test_basic_attributes(self): - s = Struct(a=1, b="hello") + s = RepoAttrs(a=1, b="hello") self.assertEqual(s.a, 1) self.assertEqual(s.b, "hello") def test_empty_struct(self): - s = Struct() + s = RepoAttrs() self.assertEqual(s.__dict__, {}) def test_override_existing(self): - s = Struct(x=10) + s = RepoAttrs(x=10) self.assertEqual(s.x, 10) @@ -98,7 +98,7 @@ class TestGetTransformedRepoInfo(unittest.TestCase): "https://github.com/OCA/web.git", get_obj=True, ) - self.assertIsInstance(result, Struct) + self.assertIsInstance(result, RepoAttrs) self.assertEqual(result.organization, "OCA") def test_organization_force(self): @@ -216,7 +216,7 @@ class TestGetProjectConfig(unittest.TestCase): env_var_path = os.path.join(tmpdir, "env_var.sh") os.rename(f.name, env_var_path) result = GitTool.get_project_config(repo_path=tmpdir) - self.assertEqual(result[CST_EL_GITHUB_TOKEN], "my_token_123") + self.assertEqual(result[EL_GITHUB_TOKEN], "my_token_123") finally: if os.path.exists(env_var_path): os.unlink(env_var_path) @@ -314,7 +314,7 @@ class TestGetManifestXmlInfo(unittest.TestCase): class TestConstants(unittest.TestCase): def test_file_source_repo_addons(self): - self.assertEqual(CST_FILE_SOURCE_REPO_ADDONS, "source_repo_addons.csv") + self.assertEqual(SOURCE_REPO_ADDONS_FILE, "source_repo_addons.csv") def test_default_project_name(self): self.assertEqual(DEFAULT_PROJECT_NAME, "ERPLibre") diff --git a/test/test_todo.py b/test/test_todo.py index b2ef961..859b820 100644 --- a/test/test_todo.py +++ b/test/test_todo.py @@ -13,6 +13,7 @@ from script.todo.todo import ( CONFIG_FILE, CONFIG_OVERRIDE_FILE, ENABLE_CRASH, + ERROR_LOG_PATH, GRADLE_FILE, INSTALLED_ODOO_VERSION_FILE, LOGO_ASCII_FILE, @@ -20,9 +21,8 @@ from script.todo.todo import ( ODOO_VERSION_FILE, STRINGS_FILE, TODO, + VENV_ERPLIBRE, VERSION_DATA_FILE, - cst_venv_erplibre, - file_error_path, ) @@ -237,10 +237,10 @@ class TestConstants(unittest.TestCase): self.assertEqual(LOGO_ASCII_FILE, "./script/todo/logo_ascii.txt") def test_venv_erplibre(self): - self.assertEqual(cst_venv_erplibre, ".venv.erplibre") + self.assertEqual(VENV_ERPLIBRE, ".venv.erplibre") def test_file_error_path(self): - self.assertEqual(file_error_path, ".erplibre.error.txt") + self.assertEqual(ERROR_LOG_PATH, ".erplibre.error.txt") def test_version_data_file(self): self.assertEqual( From 666f285fca921ef3eea0c531c223be98407f9bb3 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 10 Mar 2026 03:45:11 -0400 Subject: [PATCH 08/24] [REF] script: improve naming and translate to English Continue variable renaming to remove Hungarian notation prefixes (dct_, lst_) and adopt descriptive names. Translate remaining French comments and user-facing strings to English for consistency across the codebase. Generated by Claude Code 2.1.72 model claude-opus-4-6 Co-Authored-By: Mathieu Benoit --- script/config/config_file.py | 19 +- script/execute/execute.py | 24 +- script/git/git_merge_repo_manifest.py | 4 +- script/git/git_repo_manifest.py | 4 +- script/git/git_repo_update_group.py | 4 +- script/git/git_tool.py | 336 ++++++++++++------------ script/process/kill_process_by_port.py | 38 +-- script/todo/todo.py | 339 ++++++++++++------------- script/todo/todo_i18n.py | 16 +- test/test_kill_process_by_port.py | 6 +- test/test_todo.py | 2 +- test/test_todo_i18n.py | 22 +- 12 files changed, 400 insertions(+), 414 deletions(-) diff --git a/script/config/config_file.py b/script/config/config_file.py index 78f9baf..a850bef 100644 --- a/script/config/config_file.py +++ b/script/config/config_file.py @@ -25,7 +25,6 @@ _logger = logging.getLogger(__name__) class ConfigFile: def get_config(self, key_param: str): - # Open file and update dct_data config_base = {} config_override = {} config_private = {} @@ -45,18 +44,18 @@ class ConfigFile: merged_base_private = self.deep_merge_with_lists( config_base, config_private, list_strategy="extend" ) - dct_data = self.deep_merge_with_lists( + merged_config = self.deep_merge_with_lists( merged_base_private, config_override, list_strategy="extend" ) - return dct_data.get(key_param) + return merged_config.get(key_param) - def get_config_value(self, lst_params: list): - dct_data = self.get_config(lst_params[0]) - for param in lst_params[1:]: - if param in dct_data.keys(): - dct_data = dct_data.get(param) - return dct_data + def get_config_value(self, params: list): + config_data = self.get_config(params[0]) + for param in params[1:]: + if param in config_data: + config_data = config_data.get(param) + return config_data def get_logo_ascii_file_path(self): return LOGO_ASCII_FILE @@ -86,7 +85,7 @@ class ConfigFile: and isinstance(v, list) and list_strategy == "extend" ): - # on étend : dest_list + src_list + # Extend: dest_list + src_list result[k] = result[k] + v elif k in result and isinstance(result[k], str): if v: diff --git a/script/execute/execute.py b/script/execute/execute.py index 8fa9f8e..e7842ee 100644 --- a/script/execute/execute.py +++ b/script/execute/execute.py @@ -74,10 +74,10 @@ class Execute: return_status_and_output_and_command=False, ): """ - Exécute une command et affiche la sortie en direct. + Execute a command and display its output live. Args: - command (str): La command à exécuter (sous forme de chaîne de caractères). + command (str): The command to execute. """ my_env = os.environ.copy() @@ -126,8 +126,8 @@ class Execute: stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, - bufsize=1, # Désactive la mise en tampon pour la sortie en direct - universal_newlines=True, # Pour traiter les sauts de lines correctement + bufsize=1, # Disable buffering for live output + universal_newlines=True, # Handle line breaks correctly env=my_env, ) @@ -148,11 +148,11 @@ class Execute: .removesuffix("\r") ) - process.wait() # Attendre la fin du process + process.wait() exit_code = process.returncode if process.returncode != 0 and not quiet: print( - "La command a retourné un code d'erreur :" + "Command returned error code:" f" {process.returncode}" ) @@ -160,23 +160,23 @@ class Execute: if not quiet: if "password" in command: print( - f"Erreur : La command '{command.split(' ')[0]}'[...] n'a" - " pas été trouvée." + f"Error: Command '{command.split(' ')[0]}'[...]" + " not found." ) else: print( - f"Erreur : La command '{command}' n'a pas été trouvée." + f"Error: Command '{command}' not found." ) except Exception as e: if not quiet: - print(f"Une erreur s'est produite : {e}") + print(f"An error occurred: {e}") process_end_time = time.time() duration_sec = process_end_time - process_start_time if humanize: duration_delta = datetime.timedelta(seconds=duration_sec) - humain_time = humanize.precisedelta(duration_delta) + human_time = humanize.precisedelta(duration_delta) if not quiet: - print(f"🏠 ⬆ Executed ({humain_time}) :") + print(f"🏠 ⬆ Executed ({human_time}) :") else: if not quiet: print(f"🏠 ⬆ Executed ({duration_sec:.2f} sec.) :") diff --git a/script/git/git_merge_repo_manifest.py b/script/git/git_merge_repo_manifest.py index 7d60ca9..d70d2c2 100755 --- a/script/git/git_merge_repo_manifest.py +++ b/script/git/git_merge_repo_manifest.py @@ -184,8 +184,8 @@ def main(): dct_remote_total[key] = copy.deepcopy(value) git_tool.generate_repo_manifest( - dct_remote=dct_remote_total, - dct_project=dct_project_total, + remotes_config=dct_remote_total, + projects_config=dct_project_total, output=config.output, default_remote=default_remote_total, ) diff --git a/script/git/git_repo_manifest.py b/script/git/git_repo_manifest.py index 2a08ead..47f030a 100755 --- a/script/git/git_repo_manifest.py +++ b/script/git/git_repo_manifest.py @@ -97,8 +97,8 @@ def main(): git_tool.generate_repo_manifest( lst_repo_organization, output=f"{config.dir}{config.manifest}", - dct_remote=dct_remote, - dct_project=dct_project, + remotes_config=dct_remote, + projects_config=dct_project, keep_original=config.keep_origin, **kwargs, ) diff --git a/script/git/git_repo_update_group.py b/script/git/git_repo_update_group.py index 1b64cf9..f3faa0f 100755 --- a/script/git/git_repo_update_group.py +++ b/script/git/git_repo_update_group.py @@ -125,8 +125,8 @@ def main(): filter_group=filter_group, extra_path=config.extra_addons_path, ignore_odoo_path=config.ignore_odoo_path, - lst_add_repo=lst_add_repo, - lst_whitelist=lst_whitelist, + add_repos=lst_add_repo, + whitelist=lst_whitelist, ) diff --git a/script/git/git_tool.py b/script/git/git_tool.py index 9ff7b7d..0eb6956 100644 --- a/script/git/git_tool.py +++ b/script/git/git_tool.py @@ -128,7 +128,7 @@ class GitTool: url, _, url_git = self.get_url(url_https) url_https_organization = url_https[: url_https.rfind("/")] - d = { + repo_data = { "url": url, "url_git": url_git, "url_https": url_https, @@ -146,8 +146,8 @@ class GitTool: "sub_path": sub_path, } if get_obj: - return RepoAttrs(**d) - return d + return RepoAttrs(**repo_data) + return repo_data def get_repo_info( self, @@ -184,7 +184,7 @@ class GitTool: }] """ filename = os.path.join(repo_path, ".gitmodules") - lst_repo = [] + repos = [] with open(filename) as file: txt = file.readlines() @@ -204,7 +204,7 @@ class GitTool: "relative_path": os.path.join(repo_path, path), "name": name, } - lst_repo.append(data) + repos.append(data) name = line[12:-3] first_execution = False continue @@ -229,7 +229,7 @@ class GitTool: "relative_path": os.path.join(repo_path, path), "name": name, } - lst_repo.append(data) + repos.append(data) if add_root: repo_root = Repo(repo_path) @@ -243,10 +243,10 @@ class GitTool: "path": repo_path, "name": "", } - lst_repo.insert(0, data) + repos.insert(0, data) # Sort - lst_repo = sorted(lst_repo, key=lambda k: k.get("name")) - return lst_repo + repos = sorted(repos, key=lambda k: k.get("name")) + return repos def get_repo_info_manifest_xml( self, repo_path: str = ".", add_root: bool = False, filter_group=None @@ -266,7 +266,7 @@ class GitTool: "name": name of the submodule }] """ - lst_filter_group = filter_group.split(",") if filter_group else [] + filter_groups = filter_group.split(",") if filter_group else [] manifest_file = self.get_manifest_file(repo_path=repo_path) if not manifest_file: return [] @@ -275,30 +275,29 @@ class GitTool: filename = manifest_file else: filename = os.path.normpath(os.path.join(repo_path, manifest_file)) - lst_repo = [] + repos = [] with open(filename) as xml: xml_as_string = xml.read() xml_dict = xmltodict.parse(xml_as_string) - dct_manifest = xml_dict.get("manifest") - if not dct_manifest: + manifest_data = xml_dict.get("manifest") + if not manifest_data: return [] - if dct_manifest.get("default"): - default_remote = dct_manifest.get("default").get("@remote") + if manifest_data.get("default"): + default_remote = manifest_data.get("default").get("@remote") else: default_remote = None - lst_remote = dct_manifest.get("remote") - if type(lst_remote) is dict: - lst_remote = [lst_remote] - lst_project = dct_manifest.get("project") - if type(lst_project) is dict: - lst_project = [lst_project] - dct_remote = {a.get("@name"): a.get("@fetch") for a in lst_remote} - for project in lst_project: + remotes = manifest_data.get("remote") + if type(remotes) is dict: + remotes = [remotes] + projects = manifest_data.get("project") + if type(projects) is dict: + projects = [projects] + remotes_by_name = {a.get("@name"): a.get("@fetch") for a in remotes} + for project in projects: groups = project.get("@groups") - lst_group = groups.split(",") if groups else [] - # Continue if lst_filter exist and group in filter - for group in lst_group: - if lst_filter_group and group not in lst_filter_group: + project_groups = groups.split(",") if groups else [] + for group in project_groups: + if filter_groups and group not in filter_groups: continue else: break @@ -308,10 +307,10 @@ class GitTool: # get name and remote .git path = project.get("@path") name = path - url_prefix = dct_remote.get(project.get("@remote")) + url_prefix = remotes_by_name.get(project.get("@remote")) if not url_prefix: # get default remote - url_prefix = dct_remote.get(default_remote) + url_prefix = remotes_by_name.get(default_remote) url = f"{url_prefix}{project.get('@name')}" url, url_https, url_git = self.get_url(url) data = { @@ -323,7 +322,7 @@ class GitTool: "name": name, "group": groups, } - lst_repo.append(data) + repos.append(data) if add_root: repo_root = Repo(repo_path) @@ -346,10 +345,10 @@ class GitTool: "path": repo_path, "name": "", } - lst_repo.insert(0, data) + repos.insert(0, data) # Sort - lst_repo = sorted(lst_repo, key=lambda k: k.get("name")) - return lst_repo + repos = sorted(repos, key=lambda k: k.get("name")) + return repos def get_manifest_xml_info( self, repo_path: str = ".", filename=None, add_root: bool = False @@ -368,25 +367,25 @@ class GitTool: with open(filename) as xml: xml_as_string = xml.read() xml_dict = xmltodict.parse(xml_as_string) - dct_manifest = xml_dict.get("manifest") - if not dct_manifest: + manifest_data = xml_dict.get("manifest") + if not manifest_data: return {}, {}, None - default_remote = dct_manifest.get("default") - lst_remote = dct_manifest.get("remote") - if type(lst_remote) is dict: - lst_remote = [lst_remote] - lst_project = dct_manifest.get("project") - if type(lst_project) is dict: - lst_project = [lst_project] - if lst_remote: - dct_remote = {a.get("@name"): a for a in lst_remote} + default_remote = manifest_data.get("default") + remotes = manifest_data.get("remote") + if type(remotes) is dict: + remotes = [remotes] + projects = manifest_data.get("project") + if type(projects) is dict: + projects = [projects] + if remotes: + remotes_by_name = {a.get("@name"): a for a in remotes} else: - dct_remote = {} - if lst_project: - dct_project = {a.get("@name"): a for a in lst_project} + remotes_by_name = {} + if projects: + projects_by_name = {a.get("@name"): a for a in projects} else: - dct_project = {} - return dct_remote, dct_project, default_remote + projects_by_name = {} + return remotes_by_name, projects_by_name, default_remote @staticmethod def get_project_config(repo_path="."): @@ -403,20 +402,20 @@ class GitTool: txt = file.readlines() txt = [a[:-1] for a in txt if "=" in a] - lst_filter = [EL_GITHUB_TOKEN] - dct_config = {} + filter_keys = [EL_GITHUB_TOKEN] + config = {} # Take filtered value and get bash string values - for f in lst_filter: - for v in txt: - if f in v: - lst_v = v.split("=") - if len(lst_v) > 1: - dct_config[EL_GITHUB_TOKEN] = v.split("=")[1][1:-1] - return dct_config + for key in filter_keys: + for line in txt: + if key in line: + parts = line.split("=") + if len(parts) > 1: + config[EL_GITHUB_TOKEN] = line.split("=")[1][1:-1] + return config @staticmethod - def open_repo_web_browser(dct_repo): - url = dct_repo.get("url_https") + def open_repo_web_browser(repo_info): + url = repo_info.get("url_https") if url: webbrowser.open_new_tab(url) @@ -426,31 +425,31 @@ class GitTool: filter_group=None, extra_path=None, ignore_odoo_path=None, - lst_add_repo=None, - lst_whitelist=None, + add_repos=None, + whitelist=None, ): filename_locally = os.path.join(repo_path, "script/generate_config.sh") if not filter_group: filter_group = self.odoo_version_long - lst_repo_origin = self.get_repo_info( + all_repos = self.get_repo_info( repo_path=repo_path, filter_group=filter_group ) - if lst_whitelist: - lst_repo = [] - for repo in lst_repo_origin: + if whitelist: + repos = [] + for repo in all_repos: if ( - repo.get("path") in lst_whitelist - or repo.get("path") in lst_add_repo + repo.get("path") in whitelist + or repo.get("path") in add_repos ): - lst_repo.append(repo) + repos.append(repo) else: - lst_repo = lst_repo_origin - lst_result = [] - if not lst_repo: + repos = all_repos + results = [] + if not repos: print( f"{Fore.YELLOW}WARNING{Style.RESET_ALL}: List of repo is empty when write generate_config." ) - for repo in lst_repo: + for repo in repos: update_repo = repo.get("path") # Exception, ignore addons/OCA_web and root if update_repo in ["addons/OCA_web", "odoo", "image_db"]: @@ -472,14 +471,14 @@ class GitTool: # Ignore repo if not starting by addons # if update_repo.startswith("addons"): # lst_result.append(str_repo) - lst_result.append(str_repo) + results.append(str_repo) if extra_path: for each_extra_path in extra_path.strip().split(","): str_repo = ( f' printf "{each_extra_path}," >> ' '"${EL_CONFIG_FILE}"\n' ) - lst_result.append(str_repo) + results.append(str_repo) with open(filename_locally) as file: all_lines = file.readlines() # search place to add/replace lines @@ -501,10 +500,10 @@ class GitTool: and 'if [[ ${EL_MINIMAL_ADDONS} = "False" ]]; then\n' == line ): index_find = index + 1 - for insert_line in lst_result: + for insert_line in results: all_lines.insert(index_find, insert_line) index_find += 1 - if not lst_result: + if not results: all_lines.insert(index_find, '\tprintf ""\n') index_find += 1 find_index = True @@ -531,37 +530,37 @@ class GitTool: def generate_repo_manifest( self, - lst_repo: List[RepoAttrs] = [], + repo_list: List[RepoAttrs] = [], output: str = "", - dct_remote={}, - dct_project={}, + remotes_config={}, + projects_config={}, default_remote=None, keep_original=False, default_branch=None, ): """ Generate repo manifest - :param lst_repo: optional, update manifest with list_repo + :param repo_list: optional, update manifest with list_repo :param output: filename to write output - :param dct_remote: dict of remote information - :param dct_project: dict of project information + :param remotes_config: dict of remote information + :param projects_config: dict of project information :param default_remote: dict of default remote :param keep_original: if True, can manage multiple organization with same name, but with different fetch url :param default_branch: default branch name :return: """ - lst_remote = [] - lst_remote_name = [] - lst_project = [] - lst_project_name = [] - lst_default = [] + remote_entries = [] + remote_names = [] + project_entries = [] + project_names = [] + default_entries = [] # Fill with configuration - for dct_value in dct_remote.values(): + for dct_value in remotes_config.values(): remote_name = dct_value.get("@name") - if remote_name not in lst_remote_name: - lst_remote.append( + if remote_name not in remote_names: + remote_entries.append( OrderedDict( [ ("@name", remote_name), @@ -569,45 +568,45 @@ class GitTool: ] ) ) - lst_remote_name.append(remote_name) - for dct_value in dct_project.values(): + remote_names.append(remote_name) + for dct_value in projects_config.values(): lst_project_info = [ ("@name", dct_value.get("@name")), ("@path", dct_value.get("@path")), ] - if "@remote" in dct_value.keys(): + if "@remote" in dct_value: lst_project_info.append(("@remote", dct_value.get("@remote"))) - if "@revision" in dct_value.keys(): + if "@revision" in dct_value: lst_project_info.append( ("@revision", dct_value.get("@revision")) ) - if "@clone-depth" in dct_value.keys(): + if "@clone-depth" in dct_value: lst_project_info.append( ("@clone-depth", dct_value.get("@clone-depth")) ) - if "@groups" in dct_value.keys(): + if "@groups" in dct_value: lst_project_info.append(("@groups", dct_value.get("@groups"))) - if "@upstream" in dct_value.keys(): + if "@upstream" in dct_value: lst_project_info.append( ("@upstream", dct_value.get("@upstream")) ) - if "@dest-branch" in dct_value.keys(): + if "@dest-branch" in dct_value: lst_project_info.append( ("@dest-branch", dct_value.get("@dest-branch")) ) - lst_project.append(OrderedDict(lst_project_info)) - lst_project_name.append(dct_value.get("@name")) + project_entries.append(OrderedDict(lst_project_info)) + project_names.append(dct_value.get("@name")) - for repo in lst_repo: + for repo in repo_list: if not repo.is_submodule: # Default - if lst_default: + if default_entries: raise Exception( "Cannot have many root repo. " "Validate why 2 or more is not submodule." ) - lst_default.append( + default_entries.append( OrderedDict( [ ("@remote", repo.original_organization), @@ -620,7 +619,7 @@ class GitTool: else: if ( keep_original - and repo.project_name not in dct_project.keys() + and repo.project_name not in projects_config ): # Exception, create a new remote to keep tracking on original original_organization = ( @@ -629,8 +628,8 @@ class GitTool: else: original_organization = repo.original_organization # Add remote, only unique remote - if original_organization not in lst_remote_name: - lst_remote.append( + if original_organization not in remote_names: + remote_entries.append( OrderedDict( [ ("@name", original_organization), @@ -638,10 +637,10 @@ class GitTool: ] ) ) - lst_remote_name.append(repo.original_organization) + remote_names.append(repo.original_organization) # Add project, only unique project - if repo.project_name not in lst_project_name: - lst_project_name.append(repo.project_name) + if repo.project_name not in project_names: + project_names.append(repo.project_name) lst_project_info = [ ("@name", repo.project_name), ("@path", repo.path), @@ -657,10 +656,10 @@ class GitTool: lst_project_info.append(("@groups", "addons")) else: lst_project_info.append(("@groups", "odoo")) - lst_project.append(OrderedDict(lst_project_info)) + project_entries.append(OrderedDict(lst_project_info)) - if default_remote and not lst_default: - lst_default.append( + if default_remote and not default_entries: + default_entries.append( OrderedDict( [ ("@remote", default_remote.get("@remote")), @@ -672,29 +671,29 @@ class GitTool: ) # Order in alphabetic - lst_order_remote = sorted(lst_remote, key=lambda key: key.get("@name")) - lst_order_default = sorted( - lst_default, key=lambda key: key.get("@remote") + sorted_remotes = sorted(remote_entries, key=lambda key: key.get("@name")) + sorted_defaults = sorted( + default_entries, key=lambda key: key.get("@remote") ) - lst_order_project = sorted( - lst_project, key=lambda key: key.get("@name") + key.get("@path") + sorted_projects = sorted( + project_entries, key=lambda key: key.get("@name") + key.get("@path") ) - dct_repo = OrderedDict( + manifest_dict = OrderedDict( [ ( "manifest", OrderedDict( [ - ("remote", lst_order_remote), - ("default", lst_order_default), - ("project", lst_order_project), + ("remote", sorted_remotes), + ("default", sorted_defaults), + ("project", sorted_projects), ] ), ) ] ) - str_xml_text = xmltodict.unparse(dct_repo, pretty=True) + str_xml_text = xmltodict.unparse(manifest_dict, pretty=True) pos_insert = str_xml_text.rfind("") if pos_insert >= 0: @@ -730,12 +729,12 @@ class GitTool: print(str_xml_text + "\n") def generate_git_modules( - self, lst_repo: List[RepoAttrs], repo_path: str = "." + self, repo_list: List[RepoAttrs], repo_path: str = "." ): - lst_modules = [] - for repo in lst_repo: + modules = [] + for repo in repo_list: if repo.is_submodule: - lst_modules.append( + modules.append( f'[submodule "{repo.path}"]\n' f"\turl = {repo.url_https}\n" f"\tpath = {repo.path}\n" @@ -743,7 +742,7 @@ class GitTool: # create file with open(os.path.join(repo_path, ".gitmodules"), mode="w") as file: - file.writelines(lst_modules) + file.writelines(modules) def get_source_repo_addons(self, repo_path=".", add_repo_root=False): """ @@ -761,7 +760,7 @@ class GitTool: }] """ file_name = os.path.join(repo_path, SOURCE_REPO_ADDONS_FILE) - lst_result = [] + results = [] if add_repo_root: # TODO what to do if origin not exist? repo = Repo(repo_path) @@ -769,7 +768,7 @@ class GitTool: repo_info = self.get_transformed_repo_info_from_url( url, repo_path=repo_path, get_obj=False, is_submodule=False ) - lst_result.append(repo_info) + results.append(repo_info) with open(file_name) as file: all_lines = file.readlines() if all_lines: @@ -812,8 +811,8 @@ class GitTool: revision=revision, clone_depth=clone_depth, ) - lst_result.append(repo_info) - return lst_result + results.append(repo_info) + return results def get_manifest_file(self, repo_path: str = "."): """ @@ -852,23 +851,20 @@ class GitTool: :param sync_with_submodule: force use submodule with repo_compare_to :return: (list of matches, list of missing, list of more) """ - lst_repo_info_actual = self.get_repo_info_manifest_xml(actual_repo) - dct_repo_info_actual = {a.get("name"): a for a in lst_repo_info_actual} - # set_actual = set(dct_repo_info_actual.keys()) - # set_actual_repo = set( - # [a[a.find("_") + 1:] for a in dct_repo_info_actual.keys()]) + actual_repos = self.get_repo_info_manifest_xml(actual_repo) + actual_by_name = {a.get("name"): a for a in actual_repos} - dct_repo_info_actual_adapted = { + actual_adapted = { key[key.find("_") + 1 :]: item - for key, item in dct_repo_info_actual.items() + for key, item in actual_by_name.items() } - set_actual_repo = set(dct_repo_info_actual_adapted.keys()) + set_actual_repo = set(actual_adapted.keys()) - lst_repo_info_compare = self.get_repo_info( + compare_repos = self.get_repo_info( repo_compare_to, is_manifest=not sync_with_submodule ) if force_normalize_compare: - for repo_info in lst_repo_info_compare: + for repo_info in compare_repos: url_https = repo_info.get("url_https") url_split = url_https.split("/") organization = url_split[3] @@ -879,59 +875,55 @@ class GitTool: name = f"{repo_name}" repo_info["name"] = name - dct_repo_info_compare = { - a.get("name"): a for a in lst_repo_info_compare + compare_by_name = { + a.get("name"): a for a in compare_repos } - set_compare = set(dct_repo_info_compare.keys()) + set_compare = set(compare_by_name.keys()) - # TODO finish the match - # lst_same_name = set_actual.intersection(set_compare) - # lst_missing_name = set_compare.difference(set_actual) - - lst_same_name_normalize = set_actual_repo.intersection(set_compare) - lst_missing_name_normalize = set_compare.difference(set_actual_repo) - lst_over_name_normalize = set_actual_repo.difference(set_compare) + same_names = set_actual_repo.intersection(set_compare) + missing_names = set_compare.difference(set_actual_repo) + extra_names = set_actual_repo.difference(set_compare) print( - f"Has {len(lst_same_name_normalize)} sames, " - f"{len(lst_missing_name_normalize)} missing, " - f"{len(lst_over_name_normalize)} more." + f"Has {len(same_names)} sames, " + f"{len(missing_names)} missing, " + f"{len(extra_names)} more." ) - lst_match = [] - for key in lst_same_name_normalize: - lst_match.append( - (dct_repo_info_actual_adapted[key], dct_repo_info_compare[key]) + matches = [] + for key in same_names: + matches.append( + (actual_adapted[key], compare_by_name[key]) ) - return lst_match, lst_missing_name_normalize, lst_over_name_normalize + return matches, missing_names, extra_names @staticmethod def sync_to(result, checkout_when_diff=False): - lst_compare_repo_info, lst_missing_info, lst_over_info = result - total = len(lst_missing_info) + compared_repos, missing_repos, extra_repos = result + total = len(missing_repos) if total: print(f"\nList of missing : {total}") i = 0 - for info in lst_missing_info: + for info in missing_repos: i += 1 print(f"Nb element {i}/{total}") print(f"Missing '{info}'") - total = len(lst_over_info) + total = len(extra_repos) if total: print(f"\nList of over : {total}") i = 0 - for info in lst_over_info: + for info in extra_repos: i += 1 print(f"Nb element {i}/{total}") print(f"Missing '{info}'") - total = len(lst_compare_repo_info) + total = len(compared_repos) print(f"\nList of normalize : {total}") - lst_same = [] - lst_diff = [] + same = [] + diffs = [] i = 0 - for original, compare_to in lst_compare_repo_info: + for original, compare_to in compared_repos: i += 1 print(f"Nb element {i}/{total}") repo_original = Repo(original.get("relative_path")) @@ -943,7 +935,7 @@ class GitTool: f"DIFF - {original.get('name')} - O {commit_original} - " f"R {commit_compare}" ) - lst_diff.append((original, compare_to)) + diffs.append((original, compare_to)) if checkout_when_diff: # Update all remote for remote in repo_original.remotes: @@ -954,8 +946,8 @@ class GitTool: repo_original.git.checkout(commit_compare) else: print(f"SAME - {original.get('name')}") - lst_same.append((original, compare_to)) - print(f"finish same {len(lst_same)}, diff {len(lst_diff)}") + same.append((original, compare_to)) + print(f"finish same {len(same)}, diff {len(diffs)}") @staticmethod def add_and_fetch_remote( diff --git a/script/process/kill_process_by_port.py b/script/process/kill_process_by_port.py index c34289e..b448fa5 100755 --- a/script/process/kill_process_by_port.py +++ b/script/process/kill_process_by_port.py @@ -6,7 +6,7 @@ import sys import psutil -STOP_PARENT_KILL = ["./odoo_bin.sh", "./run.sh"] +WRAPPER_SCRIPT_NAMES = ["./odoo_bin.sh", "./run.sh"] # Processes that must never be killed — killing these # can crash the desktop session or the system. @@ -77,12 +77,12 @@ def choose_target(chain, parent_depth): if not chain: return None - parent_chain = [] - for pid in chain: - parent_chain.append(pid) - for str_to_stop in STOP_PARENT_KILL: - if str_to_stop in pid.cmdline(): - return pid, parent_chain + ancestors = [] + for proc in chain: + ancestors.append(proc) + for wrapper in WRAPPER_SCRIPT_NAMES: + if wrapper in proc.cmdline(): + return proc, ancestors return chain[parent_depth - 1], [chain[parent_depth - 1]] @@ -164,12 +164,12 @@ def main(): args = ap.parse_args() if not (1 <= args.port <= 65535): - print("Port invalide (1-65535).", file=sys.stderr) + print("Invalid port (1-65535).", file=sys.stderr) return 2 pids = find_listeners(args.port) if not pids: - print(f"Aucun process en LISTEN sur {args.port}.") + print(f"No process listening on port {args.port}.") return 1 for pid in pids: @@ -177,7 +177,7 @@ def main(): if not chain: continue - target, lst_target = choose_target(chain, args.parent_depth) + target, target_chain = choose_target(chain, args.parent_depth) print(f"\nListener PID {pid} ancestry:") for i, p in enumerate(chain): @@ -189,14 +189,14 @@ def main(): if target.pid == 1: print( - "Refus: la cible est PID 1 (systemd)." - " Utilise plutôt systemctl pour arrêter" - " le service.", + "Refused: target is PID 1 (systemd)." + " Use systemctl to stop the service" + " instead.", ) continue if target.name() in PROTECTED_NAMES: print( - f"Refus: le process '{target.name()}' semble être protégé et dangereux à être arrêter.", + f"Refused: process '{target.name()}' is protected and dangerous to kill.", ) continue @@ -208,9 +208,9 @@ def main(): while not has_response and not ignore_kill: confirm = ( input( - f"Tuer ce processus index {args.parent_depth} (enter) ou mettre " - f"l'index [0 to {len(chain) - 1}] du " - f"process à tuer, (c/C) pour annuler : \n" + f"Kill process at index {args.parent_depth} (enter) or enter " + f"index [0 to {len(chain) - 1}] of " + f"process to kill, (c/C) to cancel: \n" ) .strip() .lower() @@ -234,7 +234,7 @@ def main(): alive = kill_tree(target, force=args.force) if alive: print( - "Toujours vivants:", + "Still alive:", ", ".join(str(p.pid) for p in alive), ) else: @@ -244,7 +244,7 @@ def main(): kill_process(target, force=args.force) except psutil.AccessDenied as e: print( - f"AccessDenied: {e} (lance le script avec sudo).", + f"AccessDenied: {e} (run the script with sudo).", file=sys.stderr, ) diff --git a/script/todo/todo.py b/script/todo/todo.py index dcdda93..28339c0 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -84,7 +84,7 @@ class TODO: def __init__(self): self.dir_path = None self.kdbx = None - self.file_path = None + self.selected_file_path = None self.config_file = config_file.ConfigFile() self.execute = execute.Execute() @@ -173,29 +173,29 @@ class TODO: if self.kdbx: return self.kdbx # Open file - chemin_fichier_kdbx = self.config_file.get_config_value( + kdbx_file_path = self.config_file.get_config_value( ["kdbx", "path"] ) - if not chemin_fichier_kdbx: + if not kdbx_file_path: root = tk.Tk() root.withdraw() # Hide the main window - chemin_fichier_kdbx = filedialog.askopenfilename( + kdbx_file_path = filedialog.askopenfilename( title="Select a File", filetypes=(("KeepassX files", "*.kdbx"),), ) - if not chemin_fichier_kdbx: + if not kdbx_file_path: _logger.error( f"KDBX is not configured, please fill {self.config_file.CONFIG_FILE}" ) return - mot_de_passe_kdbx = self.config_file.get_config_value( + kdbx_password = self.config_file.get_config_value( ["kdbx", "password"] ) - if not mot_de_passe_kdbx: - mot_de_passe_kdbx = getpass.getpass(prompt=t("enter_password")) + if not kdbx_password: + kdbx_password = getpass.getpass(prompt=t("enter_password")) - kp = PyKeePass(chemin_fichier_kdbx, password=mot_de_passe_kdbx) + kp = PyKeePass(kdbx_file_path, password=kdbx_password) if kp: self.kdbx = kp @@ -213,10 +213,10 @@ class TODO: kp = self.get_kdbx() if not kp: return - nom_configuration = self.config_file.get_config_value( + config_name = self.config_file.get_config_value( ["kdbx_config", "openai", "kdbx_key"] ) - entry = kp.find_entries_by_title(nom_configuration, first=True) + entry = kp.find_entries_by_title(config_name, first=True) client = openai.OpenAI(api_key=entry.password) prompt_update = status @@ -256,7 +256,7 @@ class TODO: if status is not False: return elif status == "2": - status = self.prompt_execute_fonction() + status = self.prompt_execute_function() if status is not False: return elif status == "3": @@ -370,7 +370,7 @@ class TODO: # TODO maybe update q to only install erplibre from install_locally # TODO problem installing with q, the script depend on odoo key_i = 0 - dct_cmd_intern_begin = { + commands_begin = { "q": ( "q", "q: ERPLibre only with system python without Odoo", @@ -391,43 +391,43 @@ class TODO: f"0: {t('menu_quit')}", ), } - dct_final_cmd_intern = {} - lst_version, lst_version_installed, odoo_installed_version = ( + commands_end = {} + versions, installed_versions, odoo_installed_version = ( self.get_odoo_version() ) - for dct_version in lst_version[::-1]: + for version_info in versions[::-1]: key_i += 1 key_s = str(key_i) - label = f"{key_s}: Odoo {dct_version.get('odoo_version')}" + label = f"{key_s}: Odoo {version_info.get('odoo_version')}" - odoo_version = f"odoo{dct_version.get('odoo_version')}" - if odoo_version in lst_version_installed: + odoo_version = f"odoo{version_info.get('odoo_version')}" + if odoo_version in installed_versions: label += " - Installed" if odoo_version == odoo_installed_version: label += " - Actual" - if dct_version.get("default"): + if version_info.get("default"): label += " - Default" - if dct_version.get("is_deprecated"): + if version_info.get("is_deprecated"): label += " - Deprecated" - erplibre_version = dct_version.get("erplibre_version") - dct_cmd_intern_begin[key_s] = ( + erplibre_version = version_info.get("erplibre_version") + commands_begin[key_s] = ( key_s, label, f"./script/version/update_env_version.py --erplibre_version {erplibre_version} --install_dev", ) # Add final command - dct_cmd_intern = {**dct_cmd_intern_begin, **dct_final_cmd_intern} + install_commands = {**commands_begin, **commands_end} # Show command odoo_version_input = "" - while odoo_version_input not in dct_cmd_intern.keys(): + while odoo_version_input not in install_commands: if odoo_version_input: print(f"{t('error_value')} '{odoo_version_input}'") str_input_dyn_odoo_version = ( f"💬 {t('choose_version')}\n\t" - + "\n\t".join([a[1] for a in dct_cmd_intern.values()]) + + "\n\t".join([a[1] for a in install_commands.values()]) + f"\n{t('selection')}" ) odoo_version_input = ( @@ -437,7 +437,7 @@ class TODO: if odoo_version_input == "0": return - cmd_intern = dct_cmd_intern.get(odoo_version_input)[2] + cmd_intern = install_commands.get(odoo_version_input)[2] print(f"{t('will_execute')}\n{cmd_intern}") # TODO use external script to detect terminal to use on system @@ -454,12 +454,12 @@ class TODO: self.restart_script(str(e)) def execute_from_configuration( - self, dct_instance, exec_run_db=False, ignore_makefile=False + self, instance, exec_run_db=False, ignore_makefile=False ): # exec_run_db need argument database - kdbx_key = dct_instance.get("kdbx_key") - odoo_user = dct_instance.get("user") - odoo_password = dct_instance.get("password") + kdbx_key = instance.get("kdbx_key") + odoo_user = instance.get("user") + odoo_password = instance.get("password") if kdbx_key: extra_cmd_web_login = self.kdbx_get_extra_command_user(kdbx_key) @@ -471,7 +471,7 @@ class TODO: else: extra_cmd_web_login = "" - makefile_cmd = dct_instance.get("makefile_cmd") + makefile_cmd = instance.get("makefile_cmd") if makefile_cmd and not ignore_makefile: status = self.execute.exec_command_live( f"make {makefile_cmd}", @@ -485,30 +485,30 @@ class TODO: return if exec_run_db: - db_name = dct_instance.get("database") + db_name = instance.get("database") self.prompt_execute_selenium_and_run_db( db_name, extra_cmd_web_login=extra_cmd_web_login ) - command = dct_instance.get("command") + command = instance.get("command") if command: self.prompt_execute_selenium( command=command, extra_cmd_web_login=extra_cmd_web_login ) - callback = dct_instance.get("callback") + callback = instance.get("callback") if callback: - callback(dct_instance) + callback(instance) - def fill_help_info(self, lst_choice): + def fill_help_info(self, choices): help_info = t("command") + "\n" help_end = f"[0] {t('back')}\n" - for i, dct_instance in enumerate(lst_choice): - desc_key = dct_instance.get("prompt_description_key") + for i, instance in enumerate(choices): + desc_key = instance.get("prompt_description_key") if desc_key: desc = t(desc_key) else: - desc = dct_instance["prompt_description"] + desc = instance["prompt_description"] help_info += f"[{i + 1}] " + desc + "\n" help_info += help_end return help_info @@ -517,24 +517,24 @@ class TODO: # TODO proposer le déploiement à distance # TODO proposer l'exécution de docker # TODO proposer la création de docker - lst_choice = self.config_file.get_config("instance") - init_len = len(lst_choice) + choices = self.config_file.get_config("instance") + init_len = len(choices) # Support mobile ERPLibre if os.path.exists(MOBILE_HOME_PATH): - dct_upgrade_odoo_database = { + menu_entry = { "prompt_description": t("mobile_compile_run"), "callback": self.callback_make_mobile_home, } - lst_choice.append(dct_upgrade_odoo_database) + choices.append(menu_entry) # Support custom database to execute - dct_upgrade_odoo_database = { + menu_entry = { "prompt_description": t("choose_database"), "callback": self.callback_execute_custom_database, } - lst_choice.insert(0, dct_upgrade_odoo_database) - help_info = self.fill_help_info(lst_choice) + choices.insert(0, menu_entry) + help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) @@ -548,27 +548,27 @@ class TODO: if 1 < int_cmd <= init_len: cmd_no_found = False status = click.confirm(t("new_instance_confirm")) - dct_instance = lst_choice[int_cmd - 1] + instance = choices[int_cmd - 1] self.execute_from_configuration( - dct_instance, + instance, exec_run_db=True, ignore_makefile=not bool(status), ) - elif int_cmd <= len(lst_choice) or 1 == int_cmd: + elif int_cmd <= len(choices) or 1 == int_cmd: cmd_no_found = False # Execute dynamic instance - dct_instance = lst_choice[int_cmd - 1] + instance = choices[int_cmd - 1] self.execute_from_configuration( - dct_instance, + instance, ) except ValueError: pass if cmd_no_found: print(t("cmd_not_found")) - def prompt_execute_fonction(self): - lst_choice = self.config_file.get_config("function") - help_info = self.fill_help_info(lst_choice) + def prompt_execute_function(self): + choices = self.config_file.get_config("function") + help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) @@ -579,10 +579,10 @@ class TODO: cmd_no_found = True try: int_cmd = int(status) - if 0 < int_cmd <= len(lst_choice): + if 0 < int_cmd <= len(choices): cmd_no_found = False - dct_instance = lst_choice[int_cmd - 1] - self.execute_from_configuration(dct_instance) + instance = choices[int_cmd - 1] + self.execute_from_configuration(instance) except ValueError: pass if cmd_no_found: @@ -599,35 +599,35 @@ class TODO: # TODO faire la mise à jour de ERPLibre # TODO faire l'upgrade d'un odoo vers un autre - lst_choice = self.config_file.get_config("update_from_makefile") - dct_upgrade_odoo_database = { + choices = self.config_file.get_config("update_from_makefile") + menu_entry = { "prompt_description": t("upgrade_odoo_migration"), } - lst_choice.append(dct_upgrade_odoo_database) - dct_upgrade_poetry = { + choices.append(menu_entry) + poetry_entry = { "prompt_description": t("upgrade_poetry_dependency"), } - lst_choice.append(dct_upgrade_poetry) - help_info = self.fill_help_info(lst_choice) + choices.append(poetry_entry) + help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) print() if status == "0": return False - elif status == str(len(lst_choice) - 1): + elif status == str(len(choices) - 1): upgrade = todo_upgrade.TodoUpgrade(self) upgrade.execute_odoo_upgrade() - elif status == str(len(lst_choice)): + elif status == str(len(choices)): self.upgrade_poetry() else: cmd_no_found = True try: int_cmd = int(status) - 1 - if 0 < int_cmd <= len(lst_choice): + if 0 < int_cmd <= len(choices): cmd_no_found = False - dct_instance = lst_choice[int_cmd - 1] - self.execute_from_configuration(dct_instance) + instance = choices[int_cmd - 1] + self.execute_from_configuration(instance) except ValueError: pass if cmd_no_found: @@ -647,45 +647,45 @@ class TODO: # [0] Retour # """ - lst_choice = self.config_file.get_config("code_from_makefile") + choices = self.config_file.get_config("code_from_makefile") - dct_upgrade_odoo_database = { + menu_entry = { "prompt_description": t("open_shell"), } - lst_choice.append(dct_upgrade_odoo_database) + choices.append(menu_entry) - dct_upgrade_odoo_database = { + menu_entry = { "prompt_description": t("upgrade_module"), } - lst_choice.append(dct_upgrade_odoo_database) + choices.append(menu_entry) - lst_choice.append( + choices.append( { "prompt_description": t("debug"), } ) - help_info = self.fill_help_info(lst_choice) + help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) print() if status == "0": return False - elif status == str(len(lst_choice)): + elif status == str(len(choices)): self.debug_ide() - elif status == str(len(lst_choice) - 1): + elif status == str(len(choices) - 1): self.upgrade_module() - elif status == str(len(lst_choice) - 2): + elif status == str(len(choices) - 2): self.open_shell_on_database() else: cmd_no_found = True try: int_cmd = int(status) - if 0 < int_cmd <= len(lst_choice): + if 0 < int_cmd <= len(choices): cmd_no_found = False - dct_instance = lst_choice[int_cmd - 1] - self.execute_from_configuration(dct_instance) + instance = choices[int_cmd - 1] + self.execute_from_configuration(instance) except ValueError: pass if cmd_no_found: @@ -693,16 +693,16 @@ class TODO: def prompt_execute_git(self): print(f"🤖 {t('git_manage')}") - lst_choice = [ + choices = [ {"prompt_description": t("git_local_server")}, ] # Append config-driven entries - lst_config = self.config_file.get_config("git_from_makefile") - if lst_config: - lst_choice.extend(lst_config) + config_entries = self.config_file.get_config("git_from_makefile") + if config_entries: + choices.extend(config_entries) - help_info = self.fill_help_info(lst_choice) + help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) @@ -715,10 +715,10 @@ class TODO: cmd_no_found = True try: int_cmd = int(status) - if 0 < int_cmd <= len(lst_choice): + if 0 < int_cmd <= len(choices): cmd_no_found = False - dct_instance = lst_choice[int_cmd - 1] - self.execute_from_configuration(dct_instance) + instance = choices[int_cmd - 1] + self.execute_from_configuration(instance) except ValueError: pass if cmd_no_found: @@ -726,11 +726,11 @@ class TODO: def prompt_execute_git_local_server(self): print(f"🤖 {t('git_repo_manage')}") - lst_choice = [ + choices = [ {"prompt_description": t("git_repo_deploy_local")}, {"prompt_description": t("git_repo_deploy_production")}, ] - help_info = self.fill_help_info(lst_choice) + help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) @@ -751,14 +751,14 @@ class TODO: else t("git_mode_local") ) print(f"🤖 {mode}") - lst_choice = [ + choices = [ {"prompt_description": t("git_action_all")}, {"prompt_description": t("git_action_init")}, {"prompt_description": t("git_action_remote")}, {"prompt_description": t("git_action_push")}, {"prompt_description": t("git_action_serve")}, ] - help_info = self.fill_help_info(lst_choice) + help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) @@ -807,10 +807,10 @@ class TODO: def prompt_execute_gpt_code(self): print(f"🤖 {t('gpt_code_manage')}") - lst_choice = [ + choices = [ {"prompt_description": t("gpt_code_claude_commit")}, ] - help_info = self.fill_help_info(lst_choice) + help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) @@ -863,13 +863,13 @@ class TODO: def prompt_execute_doc(self): print(f"🤖 {t('doc_search')}") - lst_choice = [ + choices = [ {"prompt_description": t("migration_module_coverage")}, {"prompt_description": t("what_change_between_version")}, {"prompt_description": t("oca_guidelines")}, {"prompt_description": t("oca_migration_odoo_19")}, ] - help_info = self.fill_help_info(lst_choice) + help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) @@ -912,12 +912,12 @@ class TODO: def prompt_execute_database(self): print(f"🤖 {t('db_modify')}") - lst_choice = [ + choices = [ {"prompt_description": t("download_db_backup")}, {"prompt_description": t("restore_from_backup")}, {"prompt_description": t("create_backup")}, ] - help_info = self.fill_help_info(lst_choice) + help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) @@ -935,11 +935,11 @@ class TODO: def prompt_execute_process(self): print(f"🤖 {t('process_manage')}") - lst_choice = [ + choices = [ {"prompt_description": t("kill_process_port")}, {"prompt_description": t("kill_git_daemon")}, ] - help_info = self.fill_help_info(lst_choice) + help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) @@ -962,14 +962,14 @@ class TODO: def prompt_execute_config(self): print(f"🤖 {t('config_manage')}") - lst_choice = [ + choices = [ {"prompt_description": t("generate_all_config")}, {"prompt_description": t("generate_from_preconfig")}, {"prompt_description": t("generate_from_backup")}, {"prompt_description": t("generate_from_database")}, {"prompt_description": t("setup_queue_job_for_parallelism")}, ] - help_info = self.fill_help_info(lst_choice) + help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) @@ -991,7 +991,7 @@ class TODO: def prompt_execute_network(self): print(f"🤖 {t('network_tools')}") - lst_choice = [ + choices = [ {"prompt_description": t("ssh_port_forwarding")}, { "prompt_description": t( @@ -999,7 +999,7 @@ class TODO: ) }, ] - help_info = self.fill_help_info(lst_choice) + help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) @@ -1039,10 +1039,10 @@ class TODO: def prompt_execute_security(self): print(f"🤖 {t('security_audit')}") - lst_choice = [ + choices = [ {"prompt_description": t("pip_audit_desc")}, ] - help_info = self.fill_help_info(lst_choice) + help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) @@ -1056,12 +1056,12 @@ class TODO: def prompt_execute_test(self): print(f"🤖 {t('test_description')}") - lst_choice = [ + choices = [ {"prompt_description": t("test_run_module")}, {"prompt_description": t("test_run_module_coverage")}, {"prompt_description": t("test_run_unit_tests")}, ] - help_info = self.fill_help_info(lst_choice) + help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) @@ -1180,18 +1180,18 @@ class TODO: print(f"\n❌ {t('test_unit_failed')}: {status_code}") def execute_pip_audit(self): - lst_version, lst_version_installed, odoo_installed_version = ( + versions, installed_versions, odoo_installed_version = ( self.get_odoo_version() ) # Build list of installed environments - dct_env = {} + environments = {} key_i = 0 - for dct_version in lst_version[::-1]: - erplibre_version = dct_version.get("erplibre_version") + for version_info in versions[::-1]: + erplibre_version = version_info.get("erplibre_version") venv_path = f".venv.{erplibre_version}" req_path = f"requirement/requirements.{erplibre_version}.txt" - odoo_version = f"odoo{dct_version.get('odoo_version')}" + odoo_version = f"odoo{version_info.get('odoo_version')}" if not os.path.isdir(venv_path): continue @@ -1201,29 +1201,29 @@ class TODO: label = f"{key_s}: {erplibre_version}" if odoo_version == odoo_installed_version: label += f" - {t('current')}" - if dct_version.get("default"): + if version_info.get("default"): label += f" - {t('default')}" - dct_env[key_s] = { + environments[key_s] = { "label": label, "venv_path": venv_path, "req_path": req_path, "erplibre_version": erplibre_version, } - if not dct_env: + if not environments: print(t("no_env_installed")) return # Show selection menu str_input = ( f"💬 {t('choose_env_audit')}\n\t" - + "\n\t".join([v["label"] for v in dct_env.values()]) + + "\n\t".join([v["label"] for v in environments.values()]) + f"\n\t0: {t('back')}" + f"\n{t('selection')}" ) env_input = "" - while env_input not in dct_env.keys() and env_input != "0": + while env_input not in environments and env_input != "0": if env_input: print(f"{t('error_value')}" f" '{env_input}'") env_input = input(str_input).strip() @@ -1231,7 +1231,7 @@ class TODO: if env_input == "0": return - selected = dct_env[env_input] + selected = environments[env_input] venv_path = selected["venv_path"] req_path = selected["req_path"] @@ -1267,14 +1267,14 @@ class TODO: ) def generate_config_from_preconfiguration(self): - lst_choice = [ + choices = [ {"prompt_description": t("preconfig_base")}, {"prompt_description": t("preconfig_base_code_generator")}, {"prompt_description": t("preconfig_base_image_db")}, {"prompt_description": t("preconfig_all")}, # {"prompt_description": "base + migration"}, ] - help_info = self.fill_help_info(lst_choice) + help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) @@ -1303,10 +1303,10 @@ class TODO: print(t("cmd_not_found")) def debug_ide(self): - lst_choice = [ + choices = [ {"prompt_description": t("debug_todo_py")}, ] - help_info = self.fill_help_info(lst_choice) + help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) @@ -1342,25 +1342,25 @@ class TODO: def select_database(self): cmd_server = f"./odoo_bin.sh db --list" - status, lst_database = self.execute.exec_command_live( + status, databases = self.execute.exec_command_live( cmd_server, return_status_and_output=True, source_erplibre=False, single_source_erplibre=True, ) - lst_choice = [{"prompt_description": a.strip()} for a in lst_database] + choices = [{"prompt_description": a.strip()} for a in databases] - help_info = self.fill_help_info(lst_choice) + help_info = self.fill_help_info(choices) - lst_str_choice = [str(a) for a in range(len(lst_choice) + 1) if a] + valid_choices = [str(a) for a in range(len(choices) + 1) if a] while True: status = click.prompt(help_info) print() if status == "0": return False - elif status in lst_str_choice: - database_name = lst_database[int(status) - 1].strip() + elif status in valid_choices: + database_name = databases[int(status) - 1].strip() print(database_name) return database_name else: @@ -1374,15 +1374,15 @@ class TODO: raise Exception( f"Internal error, no Odoo version is supported, please valide file '{VERSION_DATA_FILE}'" ) - lst_version_transform = [] + version_entries = [] for key, value in data_version.items(): - lst_version_transform.append(value) + version_entries.append(value) value["erplibre_version"] = key - lst_version_installed = [] + installed_versions = [] if os.path.exists(INSTALLED_ODOO_VERSION_FILE): with open(INSTALLED_ODOO_VERSION_FILE) as txt: - lst_version_installed = sorted(txt.read().splitlines()) + installed_versions = sorted(txt.read().splitlines()) odoo_installed_version = None if os.path.exists(ODOO_VERSION_FILE): @@ -1390,23 +1390,23 @@ class TODO: odoo_installed_version = f"odoo{txt.read().strip()}" # Add odoo version installation on command - lst_version = sorted( - lst_version_transform, key=lambda k: k.get("erplibre_version") + versions = sorted( + version_entries, key=lambda k: k.get("erplibre_version") ) - return lst_version, lst_version_installed, odoo_installed_version + return versions, installed_versions, odoo_installed_version def kdbx_get_extra_command_user(self, kdbx_key): - lst_value = [] + values = [] if kdbx_key: kp = self.get_kdbx() if not kp: return "" if type(kdbx_key) is not list: - lst_kdbx_key = [kdbx_key] + kdbx_keys = [kdbx_key] else: - lst_kdbx_key = kdbx_key - for key in lst_kdbx_key: + kdbx_keys = kdbx_key + for key in kdbx_keys: entry = kp.find_entries_by_title(key, first=True) try: odoo_user = entry.username @@ -1416,23 +1416,18 @@ class TODO: odoo_password = entry.password except AttributeError: _logger.error(f"Cannot find password from keys {key}") - lst_value.append( + values.append( " --default_email_auth" f" {odoo_user} --default_password_auth '{odoo_password}'" ) - if len(lst_value) == 0: + if len(values) == 0: return "" - elif len(lst_value) == 1: - return lst_value[0] - return lst_value + elif len(values) == 1: + return values[0] + return values - def prompt_execute_selenium_and_run_db(self, bd, extra_cmd_web_login=""): - # cmd = ( - # f'parallel ::: "./run.sh -d {bd}" "sleep' - # f' 3;./script/selenium/web_login.py{extra_cmd_web_login}"' - # ) - # self.execute.exec_command_live(cmd) - cmd_server = f"./run.sh -d {bd};bash" + def prompt_execute_selenium_and_run_db(self, db_name, extra_cmd_web_login=""): + cmd_server = f"./run.sh -d {db_name};bash" self.execute.exec_command_live(cmd_server) cmd_client = ( f"sleep 3;./script/selenium/web_login.py{extra_cmd_web_login};bash" @@ -1440,7 +1435,7 @@ class TODO: self.execute.exec_command_live(cmd_client) def prompt_execute_selenium(self, command=None, extra_cmd_web_login=""): - lst_cmd = [] + commands = [] if not command: cmd = "./script/selenium/web_login.py" else: @@ -1448,15 +1443,15 @@ class TODO: if type(extra_cmd_web_login) is list: for item in extra_cmd_web_login: - lst_cmd.append(cmd + item) + commands.append(cmd + item) else: - lst_cmd.append(cmd + extra_cmd_web_login) + commands.append(cmd + extra_cmd_web_login) - if len(lst_cmd) == 1: - self.execute.exec_command_live(lst_cmd[0]) - elif len(lst_cmd) > 1: + if len(commands) == 1: + self.execute.exec_command_live(commands[0]) + elif len(commands) > 1: new_cmd = "parallel ::: " - for i, cmd in enumerate(lst_cmd): + for i, cmd in enumerate(commands): new_cmd += f' "sleep {1 * i};{cmd}"' self.execute.exec_command_live(new_cmd) @@ -1525,7 +1520,7 @@ class TODO: database = self.select_database() if database: cmd_server = f"./odoo_bin.sh shell -d {database}" - status, lst_database = self.execute.exec_command_live( + status, databases = self.execute.exec_command_live( cmd_server, return_status_and_output=True, source_erplibre=False, @@ -1592,7 +1587,7 @@ class TODO: if os.path.exists(poetry_lock): shutil.copy2(poetry_lock, path_file_odoo_lock) - def callback_execute_custom_database(self, dct_config): + def callback_execute_custom_database(self, config): database_name = self.select_database() self.prompt_execute_selenium_and_run_db(database_name) @@ -1627,14 +1622,14 @@ class TODO: more_arg = "--neutralize " is_neutralize = True database_name += "_neutralize" - status, lst_output = self.execute.exec_command_live( + status, output_lines = self.execute.exec_command_live( f"python3 ./script/database/db_restore.py -d {database_name} {more_arg}--ignore_cache --image {file_name}", return_status_and_output=True, single_source_erplibre=True, source_erplibre=False, ) if is_neutralize: - status, lst_output = self.execute.exec_command_live( + status, output_lines = self.execute.exec_command_live( f"./script/addons/update_prod_to_dev.sh {database_name}", return_status_and_output=True, single_source_erplibre=True, @@ -1646,7 +1641,7 @@ class TODO: .lower() ) if status == "y": - status, lst_output = self.execute.exec_command_live( + status, output_lines = self.execute.exec_command_live( f"./script/addons/update_addons_all.sh {database_name}", return_status_and_output=True, single_source_erplibre=True, @@ -1672,7 +1667,7 @@ class TODO: print(backup_name) cmd = f"./odoo_bin.sh db --backup --database {database_name} --restore_image {backup_name}" - status, lst_output = self.execute.exec_command_live( + status, output_lines = self.execute.exec_command_live( cmd, return_status_and_output=True, single_source_erplibre=True, @@ -1705,18 +1700,18 @@ class TODO: def download_database_backup_cli(self, show_remote_list=True): database_domain = input("Domain Odoo (ex. https://mondomain.com) : ") if show_remote_list: - status, lst_output = self.execute.exec_command_live( + status, output_lines = self.execute.exec_command_live( f"python3 ./script/database/list_remote.py --raw --odoo-url {database_domain}", return_status_and_output=True, single_source_erplibre=True, source_erplibre=False, ) - if len(lst_output) > 1: - for index, output in enumerate(lst_output): + if len(output_lines) > 1: + for index, output in enumerate(output_lines): print(f"{index + 1} - {output}") database_name = input("Select id of database :").strip() - elif len(lst_output) == 1: - database_name = lst_output[0].strip() + elif len(output_lines) == 1: + database_name = output_lines[0].strip() else: database_name = input( "Cannot read remote database, Database name :\n" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index fcce6bc..cf4553c 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5,7 +5,7 @@ import os import re -CONFIG_OVERRIDE_PRIVATE_FILE = "./env_var.sh" +ENV_VAR_FILE = "./env_var.sh" _current_lang = None @@ -560,9 +560,9 @@ def get_lang(): return _current_lang # 1. Check env_var.sh file - if os.path.exists(CONFIG_OVERRIDE_PRIVATE_FILE): + if os.path.exists(ENV_VAR_FILE): try: - with open(CONFIG_OVERRIDE_PRIVATE_FILE) as f: + with open(ENV_VAR_FILE) as f: content = f.read() match = re.search( r'^EL_LANG=["\']?(\w+)["\']?', content, re.MULTILINE @@ -591,9 +591,9 @@ def set_lang(lang): _current_lang = lang # Persist to env_var.sh - if os.path.exists(CONFIG_OVERRIDE_PRIVATE_FILE): + if os.path.exists(ENV_VAR_FILE): try: - with open(CONFIG_OVERRIDE_PRIVATE_FILE) as f: + with open(ENV_VAR_FILE) as f: content = f.read() except OSError: return @@ -610,15 +610,15 @@ def set_lang(lang): else: content = content.rstrip("\n") + "\n" + new_line + "\n" - with open(CONFIG_OVERRIDE_PRIVATE_FILE, "w") as f: + with open(ENV_VAR_FILE, "w") as f: f.write(content) def lang_is_configured(): """Check if a language has been explicitly set.""" - if os.path.exists(CONFIG_OVERRIDE_PRIVATE_FILE): + if os.path.exists(ENV_VAR_FILE): try: - with open(CONFIG_OVERRIDE_PRIVATE_FILE) as f: + with open(ENV_VAR_FILE) as f: content = f.read() return bool(re.search(r"^EL_LANG=", content, re.MULTILINE)) except OSError: diff --git a/test/test_kill_process_by_port.py b/test/test_kill_process_by_port.py index d6e991f..a377d32 100644 --- a/test/test_kill_process_by_port.py +++ b/test/test_kill_process_by_port.py @@ -9,7 +9,7 @@ import psutil from script.process.kill_process_by_port import ( PROTECTED_NAMES, - STOP_PARENT_KILL, + WRAPPER_SCRIPT_NAMES, choose_target, find_listeners, get_ancestry, @@ -241,10 +241,10 @@ class TestProtectedNames(unittest.TestCase): class TestStopParentKill(unittest.TestCase): def test_contains_run_sh(self): - self.assertIn("./run.sh", STOP_PARENT_KILL) + self.assertIn("./run.sh", WRAPPER_SCRIPT_NAMES) def test_contains_odoo_bin_sh(self): - self.assertIn("./odoo_bin.sh", STOP_PARENT_KILL) + self.assertIn("./odoo_bin.sh", WRAPPER_SCRIPT_NAMES) if __name__ == "__main__": diff --git a/test/test_todo.py b/test/test_todo.py index 859b820..92a2186 100644 --- a/test/test_todo.py +++ b/test/test_todo.py @@ -31,7 +31,7 @@ class TestTODOInit(unittest.TestCase): todo = TODO() self.assertIsNone(todo.dir_path) self.assertIsNone(todo.kdbx) - self.assertIsNone(todo.file_path) + self.assertIsNone(todo.selected_file_path) self.assertIsNotNone(todo.config_file) self.assertIsNotNone(todo.execute) diff --git a/test/test_todo_i18n.py b/test/test_todo_i18n.py index 64ff1ee..5d0d400 100644 --- a/test/test_todo_i18n.py +++ b/test/test_todo_i18n.py @@ -82,7 +82,7 @@ class TestGetLang(unittest.TestCase): f.flush() try: with patch.object( - todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name + todo_i18n, "ENV_VAR_FILE", f.name ): result = todo_i18n.get_lang() self.assertEqual(result, "en") @@ -97,7 +97,7 @@ class TestGetLang(unittest.TestCase): f.flush() try: with patch.object( - todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name + todo_i18n, "ENV_VAR_FILE", f.name ): result = todo_i18n.get_lang() self.assertEqual(result, "fr") @@ -107,7 +107,7 @@ class TestGetLang(unittest.TestCase): def test_env_variable_fallback(self): with patch.object( todo_i18n, - "CONFIG_OVERRIDE_PRIVATE_FILE", + "ENV_VAR_FILE", "/nonexistent/path", ), patch.dict(os.environ, {"EL_LANG": "en"}): result = todo_i18n.get_lang() @@ -116,7 +116,7 @@ class TestGetLang(unittest.TestCase): def test_default_is_fr(self): with patch.object( todo_i18n, - "CONFIG_OVERRIDE_PRIVATE_FILE", + "ENV_VAR_FILE", "/nonexistent/path", ), patch.dict(os.environ, {}, clear=True): result = todo_i18n.get_lang() @@ -130,7 +130,7 @@ class TestGetLang(unittest.TestCase): f.flush() try: with patch.object( - todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name + todo_i18n, "ENV_VAR_FILE", f.name ), patch.dict(os.environ, {}, clear=True): result = todo_i18n.get_lang() self.assertEqual(result, "fr") @@ -159,7 +159,7 @@ class TestSetLang(unittest.TestCase): f.flush() try: with patch.object( - todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name + todo_i18n, "ENV_VAR_FILE", f.name ): todo_i18n.set_lang("en") with open(f.name) as rf: @@ -177,7 +177,7 @@ class TestSetLang(unittest.TestCase): f.flush() try: with patch.object( - todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name + todo_i18n, "ENV_VAR_FILE", f.name ): todo_i18n.set_lang("en") with open(f.name) as rf: @@ -190,7 +190,7 @@ class TestSetLang(unittest.TestCase): def test_nonexistent_file_no_crash(self): with patch.object( todo_i18n, - "CONFIG_OVERRIDE_PRIVATE_FILE", + "ENV_VAR_FILE", "/nonexistent/path", ): todo_i18n.set_lang("en") @@ -208,7 +208,7 @@ class TestLangIsConfigured(unittest.TestCase): f.flush() try: with patch.object( - todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name + todo_i18n, "ENV_VAR_FILE", f.name ): result = todo_i18n.lang_is_configured() self.assertTrue(result) @@ -223,7 +223,7 @@ class TestLangIsConfigured(unittest.TestCase): f.flush() try: with patch.object( - todo_i18n, "CONFIG_OVERRIDE_PRIVATE_FILE", f.name + todo_i18n, "ENV_VAR_FILE", f.name ): result = todo_i18n.lang_is_configured() self.assertFalse(result) @@ -233,7 +233,7 @@ class TestLangIsConfigured(unittest.TestCase): def test_returns_false_when_file_missing(self): with patch.object( todo_i18n, - "CONFIG_OVERRIDE_PRIVATE_FILE", + "ENV_VAR_FILE", "/nonexistent/path", ): result = todo_i18n.lang_is_configured() From ec134d929995223a2e695eaeb4a0c9b7a183b4e4 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 10 Mar 2026 03:59:35 -0400 Subject: [PATCH 09/24] [REF] script: extract managers from todo into modules Reduce complexity of todo.py by extracting database, kdbx, and version logic into dedicated manager classes. Update tests to reference new module paths. Generated by Claude Code 2.1.72 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- script/todo/database_manager.py | 231 ++++++++++++++++++++++++++++++++ script/todo/kdbx_manager.py | 100 ++++++++++++++ script/todo/todo.py | 131 +++--------------- script/todo/version_manager.py | 47 +++++++ test/test_todo.py | 106 ++++++++++++--- 5 files changed, 484 insertions(+), 131 deletions(-) create mode 100644 script/todo/database_manager.py create mode 100644 script/todo/kdbx_manager.py create mode 100644 script/todo/version_manager.py diff --git a/script/todo/database_manager.py b/script/todo/database_manager.py new file mode 100644 index 0000000..4159d07 --- /dev/null +++ b/script/todo/database_manager.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import datetime +import getpass +import logging +import os +import zipfile + +import click + +from script.todo.todo_i18n import t + +_logger = logging.getLogger(__name__) + +try: + from script.todo import todo_file_browser +except Exception: + todo_file_browser = None + + +class DatabaseManager: + def __init__(self, execute, fill_help_info): + self._execute = execute + self._fill_help_info = fill_help_info + self._dir_path = None + + def _on_dir_selected(self, path): + self._dir_path = path + + def select_database(self): + cmd_server = "./odoo_bin.sh db --list" + status, databases = self._execute.exec_command_live( + cmd_server, + return_status_and_output=True, + source_erplibre=False, + single_source_erplibre=True, + ) + choices = [{"prompt_description": a.strip()} for a in databases] + help_info = self._fill_help_info(choices) + valid_choices = [ + str(a) for a in range(len(choices) + 1) if a + ] + + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status in valid_choices: + database_name = databases[int(status) - 1].strip() + print(database_name) + return database_name + else: + print(t("cmd_not_found")) + + def restore_from_database(self, show_remote_list=True): + path_image_db = os.path.join(os.getcwd(), "image_db") + print("[1] By filename from image_db") + print(f"[] Browser image_db {path_image_db}") + status = input("\U0001f4ac Select : ") + if status == "1": + file_name = status + else: + file_name = self.open_file_image_db() + + default_database_name = file_name.replace(" ", "_") + if default_database_name.endswith(".zip"): + default_database_name = default_database_name[:-4] + + database_name = input( + f"\U0001f4ac Database name (default={default_database_name}) : " + ) + if not database_name: + database_name = default_database_name + + status = ( + input( + "\U0001f4ac Would you like to neutralize database (n/N)? " + ) + .strip() + .lower() + ) + is_neutralize = False + more_arg = "" + if status != "n": + more_arg = "--neutralize " + is_neutralize = True + database_name += "_neutralize" + status, output_lines = self._execute.exec_command_live( + f"python3 ./script/database/db_restore.py -d {database_name} " + f"{more_arg}--ignore_cache --image {file_name}", + return_status_and_output=True, + single_source_erplibre=True, + source_erplibre=False, + ) + if is_neutralize: + status, output_lines = self._execute.exec_command_live( + f"./script/addons/update_prod_to_dev.sh {database_name}", + return_status_and_output=True, + single_source_erplibre=True, + source_erplibre=False, + ) + status = ( + input( + "\U0001f4ac Would you like to update all addons (y/Y)? " + ) + .strip() + .lower() + ) + if status == "y": + status, output_lines = self._execute.exec_command_live( + f"./script/addons/update_addons_all.sh {database_name}", + return_status_and_output=True, + single_source_erplibre=True, + source_erplibre=False, + ) + + def create_backup_from_database(self, show_remote_list=True): + database_name = self.select_database() + backup_name = input( + "\U0001f4ac Backup name (default = name+date.zip) : " + ) + if not backup_name: + backup_name = ( + database_name + + "_" + + datetime.datetime.now().strftime( + "%Y-%m-%d_%Hh%Mm%Ss" + ) + + ".zip" + ) + + if not backup_name.endswith(".zip"): + backup_name = backup_name + ".zip" + + print(backup_name) + + cmd = ( + f"./odoo_bin.sh db --backup --database {database_name}" + f" --restore_image {backup_name}" + ) + status, output_lines = self._execute.exec_command_live( + cmd, + return_status_and_output=True, + single_source_erplibre=True, + source_erplibre=False, + ) + + def open_file_image_db(self): + self._dir_path = "" + path_image_db = os.path.join(os.getcwd(), "image_db") + + file_browser = todo_file_browser.FileBrowser( + path_image_db, self._on_dir_selected + ) + file_browser.run_main_frame() + file_name = os.path.basename(self._dir_path) + print(file_name) + return file_name + + def download_database_backup_cli(self, show_remote_list=True): + database_domain = input( + "Domain Odoo (ex. https://mondomain.com) : " + ) + if show_remote_list: + status, output_lines = self._execute.exec_command_live( + f"python3 ./script/database/list_remote.py --raw" + f" --odoo-url {database_domain}", + return_status_and_output=True, + single_source_erplibre=True, + source_erplibre=False, + ) + if len(output_lines) > 1: + for index, output in enumerate(output_lines): + print(f"{index + 1} - {output}") + database_name = input( + "Select id of database :" + ).strip() + elif len(output_lines) == 1: + database_name = output_lines[0].strip() + else: + database_name = input( + "Cannot read remote database, Database name :\n" + ) + else: + database_name = input("Database name :\n") + + timestamp = datetime.datetime.now().strftime( + "%Y-%m-%d_%Hh%Mm%Ss" + ) + default_output_path = ( + f"./image_db/{database_name}_{timestamp}.zip" + ) + output_path = input( + f"Output path (default: {default_output_path}) : " + ).strip() + if not output_path: + output_path = default_output_path + + master_password = getpass.getpass(prompt="Master password : ") + + cmd = "script/database/download_remote.sh --quiet" + my_env = os.environ.copy() + my_env["MASTER_PWD"] = master_password + my_env["DATABASE_NAME"] = database_name + my_env["OUTPUT_FILE_PATH"] = output_path + my_env["ODOO_URL"] = database_domain + status, cmd_executed = self._execute.exec_command_live( + cmd, + source_erplibre=False, + return_status_and_command=True, + new_env=my_env, + ) + try: + with zipfile.ZipFile( + default_output_path, "r" + ) as zip_ref: + manifest_file_1 = zip_ref.open("manifest.json") + _logger.info( + f"Log file '{default_output_path}' is complete" + " and validated." + ) + except Exception as e: + _logger.error(e) + _logger.error( + "Failed to read manifest.json from backup file" + f" '{default_output_path}'." + ) + return status, output_path, database_name diff --git a/script/todo/kdbx_manager.py b/script/todo/kdbx_manager.py new file mode 100644 index 0000000..21b9035 --- /dev/null +++ b/script/todo/kdbx_manager.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import getpass +import logging + +from script.todo.todo_i18n import t + +_logger = logging.getLogger(__name__) + +try: + import tkinter as tk + from tkinter import filedialog + + from pykeepass import PyKeePass +except ModuleNotFoundError: + PyKeePass = None + tk = None + filedialog = None + + +class KdbxManager: + def __init__(self, config_file): + self._config_file = config_file + self._kdbx = None + + def get_kdbx(self): + if self._kdbx: + return self._kdbx + + kdbx_file_path = self._config_file.get_config_value( + ["kdbx", "path"] + ) + if not kdbx_file_path: + if tk is None: + _logger.error("tkinter is not available") + return None + root = tk.Tk() + root.withdraw() + kdbx_file_path = filedialog.askopenfilename( + title="Select a File", + filetypes=(("KeepassX files", "*.kdbx"),), + ) + if not kdbx_file_path: + _logger.error( + "KDBX is not configured, please fill" + f" {self._config_file.CONFIG_FILE}" + ) + return None + + kdbx_password = self._config_file.get_config_value( + ["kdbx", "password"] + ) + if not kdbx_password: + kdbx_password = getpass.getpass(prompt=t("enter_password")) + + if PyKeePass is None: + _logger.error("pykeepass is not installed") + return None + + kp = PyKeePass(kdbx_file_path, password=kdbx_password) + if kp: + self._kdbx = kp + return kp + + def get_extra_command_user(self, kdbx_key): + values = [] + if kdbx_key: + kp = self.get_kdbx() + if not kp: + return "" + if type(kdbx_key) is not list: + kdbx_keys = [kdbx_key] + else: + kdbx_keys = kdbx_key + for key in kdbx_keys: + entry = kp.find_entries_by_title(key, first=True) + try: + odoo_user = entry.username + except AttributeError: + _logger.error( + f"Cannot find username from keys {key}" + ) + try: + odoo_password = entry.password + except AttributeError: + _logger.error( + f"Cannot find password from keys {key}" + ) + values.append( + " --default_email_auth" + f" {odoo_user} --default_password_auth" + f" '{odoo_password}'" + ) + if len(values) == 0: + return "" + elif len(values) == 1: + return values[0] + return values diff --git a/script/todo/todo.py b/script/todo/todo.py index 28339c0..79067eb 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -22,15 +22,13 @@ sys.path.append(new_path) from script.config import config_file from script.execute import execute +from script.todo.database_manager import DatabaseManager +from script.todo.kdbx_manager import KdbxManager from script.todo.todo_i18n import get_lang, lang_is_configured, set_lang, t +from script.todo.version_manager import get_odoo_version ERROR_LOG_PATH = ".erplibre.error.txt" VENV_ERPLIBRE = ".venv.erplibre" -VERSION_DATA_FILE = os.path.join("conf", "supported_version_erplibre.json") -INSTALLED_ODOO_VERSION_FILE = os.path.join( - ".repo", "installed_odoo_version.txt" -) -ODOO_VERSION_FILE = ".odoo-version" ENABLE_CRASH = False CRASH_E = None # Support mobile ERPLibre @@ -83,10 +81,13 @@ LOGO_ASCII_FILE = "./script/todo/logo_ascii.txt" class TODO: def __init__(self): self.dir_path = None - self.kdbx = None self.selected_file_path = None self.config_file = config_file.ConfigFile() self.execute = execute.Execute() + self.kdbx_manager = KdbxManager(self.config_file) + self.db_manager = DatabaseManager( + self.execute, self.fill_help_info + ) def _ask_language(self): if not lang_is_configured(): @@ -169,38 +170,6 @@ class TODO: print(status) # manipuler() - def get_kdbx(self): - if self.kdbx: - return self.kdbx - # Open file - kdbx_file_path = self.config_file.get_config_value( - ["kdbx", "path"] - ) - if not kdbx_file_path: - root = tk.Tk() - root.withdraw() # Hide the main window - kdbx_file_path = filedialog.askopenfilename( - title="Select a File", - filetypes=(("KeepassX files", "*.kdbx"),), - ) - if not kdbx_file_path: - _logger.error( - f"KDBX is not configured, please fill {self.config_file.CONFIG_FILE}" - ) - return - - kdbx_password = self.config_file.get_config_value( - ["kdbx", "password"] - ) - if not kdbx_password: - kdbx_password = getpass.getpass(prompt=t("enter_password")) - - kp = PyKeePass(kdbx_file_path, password=kdbx_password) - - if kp: - self.kdbx = kp - return kp - def execute_prompt_ia(self): while True: help_info = f"""{t("command")} @@ -210,7 +179,7 @@ class TODO: print() if status == "0": return - kp = self.get_kdbx() + kp = self.kdbx_manager.get_kdbx() if not kp: return config_name = self.config_file.get_config_value( @@ -393,7 +362,7 @@ class TODO: } commands_end = {} versions, installed_versions, odoo_installed_version = ( - self.get_odoo_version() + get_odoo_version() ) for version_info in versions[::-1]: @@ -462,7 +431,7 @@ class TODO: odoo_password = instance.get("password") if kdbx_key: - extra_cmd_web_login = self.kdbx_get_extra_command_user(kdbx_key) + extra_cmd_web_login = self.kdbx_manager.get_extra_command_user(kdbx_key) elif odoo_user and odoo_password: extra_cmd_web_login = ( f" --default_email_auth {odoo_user} --default_password_auth" @@ -925,11 +894,11 @@ class TODO: if status == "0": return False elif status == "1": - self.download_database_backup_cli() + self.db_manager.download_database_backup_cli() elif status == "2": - self.restore_from_database() + self.db_manager.restore_from_database() elif status == "3": - self.create_backup_from_database() + self.db_manager.create_backup_from_database() else: print(t("cmd_not_found")) @@ -1181,7 +1150,7 @@ class TODO: def execute_pip_audit(self): versions, installed_versions, odoo_installed_version = ( - self.get_odoo_version() + get_odoo_version() ) # Build list of installed environments @@ -1322,12 +1291,12 @@ class TODO: print(t("cmd_not_found")) def generate_config_from_backup(self): - file_name = self.open_file_image_db() + file_name = self.db_manager.open_file_image_db() add_arg = f"--from_backup_name {file_name} --add_repo odoo18.0/addons/MathBenTech_development" self.generate_config(add_arg=add_arg) def generate_config_from_database(self): - database_name = self.select_database() + database_name = self.db_manager.select_database() str_arg = f"--database {database_name}" self.generate_config(add_arg=str_arg) return False @@ -1366,66 +1335,6 @@ class TODO: else: print(t("cmd_not_found")) - def get_odoo_version(self): - with open(VERSION_DATA_FILE) as txt: - data_version = json.load(txt) - - if not data_version: - raise Exception( - f"Internal error, no Odoo version is supported, please valide file '{VERSION_DATA_FILE}'" - ) - version_entries = [] - for key, value in data_version.items(): - version_entries.append(value) - value["erplibre_version"] = key - - installed_versions = [] - if os.path.exists(INSTALLED_ODOO_VERSION_FILE): - with open(INSTALLED_ODOO_VERSION_FILE) as txt: - installed_versions = sorted(txt.read().splitlines()) - - odoo_installed_version = None - if os.path.exists(ODOO_VERSION_FILE): - with open(ODOO_VERSION_FILE) as txt: - odoo_installed_version = f"odoo{txt.read().strip()}" - - # Add odoo version installation on command - versions = sorted( - version_entries, key=lambda k: k.get("erplibre_version") - ) - - return versions, installed_versions, odoo_installed_version - - def kdbx_get_extra_command_user(self, kdbx_key): - values = [] - if kdbx_key: - kp = self.get_kdbx() - if not kp: - return "" - if type(kdbx_key) is not list: - kdbx_keys = [kdbx_key] - else: - kdbx_keys = kdbx_key - for key in kdbx_keys: - entry = kp.find_entries_by_title(key, first=True) - try: - odoo_user = entry.username - except AttributeError: - _logger.error(f"Cannot find username from keys {key}") - try: - odoo_password = entry.password - except AttributeError: - _logger.error(f"Cannot find password from keys {key}") - values.append( - " --default_email_auth" - f" {odoo_user} --default_password_auth '{odoo_password}'" - ) - if len(values) == 0: - return "" - elif len(values) == 1: - return values[0] - return values - def prompt_execute_selenium_and_run_db(self, db_name, extra_cmd_web_login=""): cmd_server = f"./run.sh -d {db_name};bash" self.execute.exec_command_live(cmd_server) @@ -1517,7 +1426,7 @@ class TODO: self.prompt_install() def open_shell_on_database(self): - database = self.select_database() + database = self.db_manager.select_database() if database: cmd_server = f"./odoo_bin.sh shell -d {database}" status, databases = self.execute.exec_command_live( @@ -1588,7 +1497,7 @@ class TODO: shutil.copy2(poetry_lock, path_file_odoo_lock) def callback_execute_custom_database(self, config): - database_name = self.select_database() + database_name = self.db_manager.select_database() self.prompt_execute_selenium_and_run_db(database_name) def restore_from_database(self, show_remote_list=True): @@ -1599,7 +1508,7 @@ class TODO: if status == "1": file_name = status else: - file_name = self.open_file_image_db() + file_name = self.db_manager.open_file_image_db() default_database_name = file_name.replace(" ", "_") if default_database_name.endswith(".zip"): @@ -1649,7 +1558,7 @@ class TODO: ) def create_backup_from_database(self, show_remote_list=True): - database_name = self.select_database() + database_name = self.db_manager.select_database() str_arg = f"--database {database_name}" backup_name = input("💬 Backup name (default = name+date.zip) : ") diff --git a/script/todo/version_manager.py b/script/todo/version_manager.py new file mode 100644 index 0000000..0640e7e --- /dev/null +++ b/script/todo/version_manager.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import json +import os + +VERSION_DATA_FILE = os.path.join("conf", "supported_version_erplibre.json") +INSTALLED_ODOO_VERSION_FILE = os.path.join( + ".repo", "installed_odoo_version.txt" +) +ODOO_VERSION_FILE = ".odoo-version" + + +def get_odoo_version(): + """ + Read version configuration and return sorted versions, + installed versions, and current version. + """ + with open(VERSION_DATA_FILE) as txt: + data_version = json.load(txt) + + if not data_version: + raise Exception( + "Internal error, no Odoo version is supported," + f" please validate file '{VERSION_DATA_FILE}'" + ) + version_entries = [] + for key, value in data_version.items(): + version_entries.append(value) + value["erplibre_version"] = key + + installed_versions = [] + if os.path.exists(INSTALLED_ODOO_VERSION_FILE): + with open(INSTALLED_ODOO_VERSION_FILE) as txt: + installed_versions = sorted(txt.read().splitlines()) + + odoo_installed_version = None + if os.path.exists(ODOO_VERSION_FILE): + with open(ODOO_VERSION_FILE) as txt: + odoo_installed_version = f"odoo{txt.read().strip()}" + + versions = sorted( + version_entries, key=lambda k: k.get("erplibre_version") + ) + + return versions, installed_versions, odoo_installed_version diff --git a/test/test_todo.py b/test/test_todo.py index 92a2186..71374eb 100644 --- a/test/test_todo.py +++ b/test/test_todo.py @@ -15,14 +15,17 @@ from script.todo.todo import ( ENABLE_CRASH, ERROR_LOG_PATH, GRADLE_FILE, - INSTALLED_ODOO_VERSION_FILE, LOGO_ASCII_FILE, MOBILE_HOME_PATH, - ODOO_VERSION_FILE, STRINGS_FILE, TODO, VENV_ERPLIBRE, +) +from script.todo.version_manager import ( + INSTALLED_ODOO_VERSION_FILE, + ODOO_VERSION_FILE, VERSION_DATA_FILE, + get_odoo_version, ) @@ -30,10 +33,10 @@ class TestTODOInit(unittest.TestCase): def test_initial_attributes(self): todo = TODO() self.assertIsNone(todo.dir_path) - self.assertIsNone(todo.kdbx) self.assertIsNone(todo.selected_file_path) self.assertIsNotNone(todo.config_file) self.assertIsNotNone(todo.execute) + self.assertIsNotNone(todo.kdbx_manager) class TestFillHelpInfo(unittest.TestCase): @@ -99,7 +102,6 @@ class TestGetOdooVersion(unittest.TestCase): "is_deprecated": False, }, } - todo = TODO() with tempfile.TemporaryDirectory() as tmpdir: version_file = os.path.join(tmpdir, "version.json") with open(version_file, "w") as f: @@ -110,16 +112,16 @@ class TestGetOdooVersion(unittest.TestCase): f.write("18.0") with patch( - "script.todo.todo.VERSION_DATA_FILE", version_file + "script.todo.version_manager.VERSION_DATA_FILE", version_file ), patch( - "script.todo.todo.INSTALLED_ODOO_VERSION_FILE", + "script.todo.version_manager.INSTALLED_ODOO_VERSION_FILE", os.path.join(tmpdir, "nonexistent.txt"), ), patch( - "script.todo.todo.ODOO_VERSION_FILE", + "script.todo.version_manager.ODOO_VERSION_FILE", odoo_version_file, ): lst_version, lst_installed, odoo_current = ( - todo.get_odoo_version() + get_odoo_version() ) self.assertEqual(len(lst_version), 2) @@ -138,7 +140,6 @@ class TestGetOdooVersion(unittest.TestCase): "is_deprecated": False, }, } - todo = TODO() with tempfile.TemporaryDirectory() as tmpdir: version_file = os.path.join(tmpdir, "version.json") with open(version_file, "w") as f: @@ -149,31 +150,30 @@ class TestGetOdooVersion(unittest.TestCase): f.write("odoo18.0\nodoo16.0\n") with patch( - "script.todo.todo.VERSION_DATA_FILE", version_file + "script.todo.version_manager.VERSION_DATA_FILE", version_file ), patch( - "script.todo.todo.INSTALLED_ODOO_VERSION_FILE", + "script.todo.version_manager.INSTALLED_ODOO_VERSION_FILE", installed_file, ), patch( - "script.todo.todo.ODOO_VERSION_FILE", + "script.todo.version_manager.ODOO_VERSION_FILE", os.path.join(tmpdir, "nonexistent"), ): lst_version, lst_installed, odoo_current = ( - todo.get_odoo_version() + get_odoo_version() ) self.assertEqual(lst_installed, ["odoo16.0", "odoo18.0"]) self.assertIsNone(odoo_current) def test_no_version_data_raises(self): - todo = TODO() with tempfile.TemporaryDirectory() as tmpdir: version_file = os.path.join(tmpdir, "empty.json") with open(version_file, "w") as f: json.dump({}, f) - with patch("script.todo.todo.VERSION_DATA_FILE", version_file): + with patch("script.todo.version_manager.VERSION_DATA_FILE", version_file): with self.assertRaises(Exception): - todo.get_odoo_version() + get_odoo_version() class TestOnDirSelected(unittest.TestCase): @@ -303,18 +303,18 @@ class TestExecuteUnitTests(unittest.TestCase): class TestKdbxGetExtraCommandUser(unittest.TestCase): def test_empty_kdbx_key(self): todo = TODO() - result = todo.kdbx_get_extra_command_user("") + result = todo.kdbx_manager.get_extra_command_user("") self.assertEqual(result, "") def test_none_kdbx_key(self): todo = TODO() - result = todo.kdbx_get_extra_command_user(None) + result = todo.kdbx_manager.get_extra_command_user(None) self.assertEqual(result, "") def test_kdbx_not_available(self): todo = TODO() - todo.get_kdbx = MagicMock(return_value=None) - result = todo.kdbx_get_extra_command_user("some_key") + todo.kdbx_manager.get_kdbx = MagicMock(return_value=None) + result = todo.kdbx_manager.get_extra_command_user("some_key") self.assertEqual(result, "") @@ -328,5 +328,71 @@ class TestSetupClaudeCommit(unittest.TestCase): # Should print exists message without asking for input +class TestSelectDatabase(unittest.TestCase): + @patch("script.todo.todo.click") + def test_select_database_returns_name(self, mock_click): + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = ( + 0, + ["db_test", "db_prod"], + ) + mock_click.prompt.return_value = "1" + result = todo.select_database() + self.assertEqual(result, "db_test") + + @patch("script.todo.todo.click") + def test_select_database_returns_false_on_zero(self, mock_click): + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = (0, ["db_test"]) + mock_click.prompt.return_value = "0" + result = todo.select_database() + self.assertFalse(result) + + +class TestRestoreFromDatabase(unittest.TestCase): + @patch("builtins.input") + def test_restore_by_filename(self, mock_input): + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = (0, []) + # status="1" (by filename), db name default, no neutralize + mock_input.side_effect = ["1", "", "n", "n"] + todo.restore_from_database() + cmd = todo.execute.exec_command_live.call_args_list[0][0][0] + self.assertIn("db_restore.py", cmd) + + @patch("builtins.input") + def test_restore_with_neutralize(self, mock_input): + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = (0, []) + mock_input.side_effect = ["1", "mydb", "y", "n"] + todo.restore_from_database() + cmd = todo.execute.exec_command_live.call_args_list[0][0][0] + self.assertIn("--neutralize", cmd) + self.assertIn("mydb_neutralize", cmd) + + +class TestCreateBackupFromDatabase(unittest.TestCase): + @patch("script.todo.todo.click") + @patch("builtins.input") + def test_creates_backup_command(self, mock_input, mock_click): + todo = TODO() + todo.execute = MagicMock() + todo.execute.exec_command_live.return_value = ( + 0, + ["test_db"], + ) + mock_click.prompt.return_value = "1" + # backup name input + mock_input.return_value = "backup.zip" + todo.create_backup_from_database() + cmd = todo.execute.exec_command_live.call_args_list[-1][0][0] + self.assertIn("--backup", cmd) + self.assertIn("test_db", cmd) + + if __name__ == "__main__": unittest.main() From 2dde5467dbaabec7feb629847ce9da6c17cbf423 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 10 Mar 2026 04:05:04 -0400 Subject: [PATCH 10/24] [REF] script: add type hints and remove duplicate methods Complete the manager extraction by removing methods from todo.py that were already moved to database_manager.py (select_database, restore_from_database, create_backup_from_database, open_file_image_db, download_database_backup_cli). Add type annotations to manager classes for better code clarity. Update tests to target the manager instances directly. Generated by Claude Code 2.1.72 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- script/config/config_file.py | 12 +- script/todo/database_manager.py | 56 ++++----- script/todo/kdbx_manager.py | 18 ++- script/todo/todo.py | 195 ++------------------------------ script/todo/version_manager.py | 6 +- test/test_todo.py | 69 ++++++----- 6 files changed, 86 insertions(+), 270 deletions(-) diff --git a/script/config/config_file.py b/script/config/config_file.py index a850bef..fc29c92 100644 --- a/script/config/config_file.py +++ b/script/config/config_file.py @@ -24,10 +24,10 @@ _logger = logging.getLogger(__name__) class ConfigFile: - def get_config(self, key_param: str): - config_base = {} - config_override = {} - config_private = {} + def get_config(self, key_param: str) -> Any: + config_base: dict = {} + config_override: dict = {} + config_private: dict = {} if os.path.exists(CONFIG_FILE): with open(CONFIG_FILE) as cfg: @@ -50,14 +50,14 @@ class ConfigFile: return merged_config.get(key_param) - def get_config_value(self, params: list): + def get_config_value(self, params: list[str]) -> Any: config_data = self.get_config(params[0]) for param in params[1:]: if param in config_data: config_data = config_data.get(param) return config_data - def get_logo_ascii_file_path(self): + def get_logo_ascii_file_path(self) -> str: return LOGO_ASCII_FILE def deep_merge_with_lists( diff --git a/script/todo/database_manager.py b/script/todo/database_manager.py index 4159d07..ca99042 100644 --- a/script/todo/database_manager.py +++ b/script/todo/database_manager.py @@ -21,15 +21,15 @@ except Exception: class DatabaseManager: - def __init__(self, execute, fill_help_info): + def __init__(self, execute, fill_help_info) -> None: self._execute = execute self._fill_help_info = fill_help_info - self._dir_path = None + self._dir_path: str | None = None - def _on_dir_selected(self, path): + def _on_dir_selected(self, path: str) -> None: self._dir_path = path - def select_database(self): + def select_database(self) -> str | bool: cmd_server = "./odoo_bin.sh db --list" status, databases = self._execute.exec_command_live( cmd_server, @@ -39,9 +39,7 @@ class DatabaseManager: ) choices = [{"prompt_description": a.strip()} for a in databases] help_info = self._fill_help_info(choices) - valid_choices = [ - str(a) for a in range(len(choices) + 1) if a - ] + valid_choices = [str(a) for a in range(len(choices) + 1) if a] while True: status = click.prompt(help_info) @@ -55,7 +53,7 @@ class DatabaseManager: else: print(t("cmd_not_found")) - def restore_from_database(self, show_remote_list=True): + def restore_from_database(self, show_remote_list: bool = True) -> None: path_image_db = os.path.join(os.getcwd(), "image_db") print("[1] By filename from image_db") print(f"[] Browser image_db {path_image_db}") @@ -76,9 +74,7 @@ class DatabaseManager: database_name = default_database_name status = ( - input( - "\U0001f4ac Would you like to neutralize database (n/N)? " - ) + input("\U0001f4ac Would you like to neutralize database (n/N)? ") .strip() .lower() ) @@ -103,9 +99,7 @@ class DatabaseManager: source_erplibre=False, ) status = ( - input( - "\U0001f4ac Would you like to update all addons (y/Y)? " - ) + input("\U0001f4ac Would you like to update all addons (y/Y)? ") .strip() .lower() ) @@ -117,7 +111,9 @@ class DatabaseManager: source_erplibre=False, ) - def create_backup_from_database(self, show_remote_list=True): + def create_backup_from_database( + self, show_remote_list: bool = True + ) -> None: database_name = self.select_database() backup_name = input( "\U0001f4ac Backup name (default = name+date.zip) : " @@ -126,9 +122,7 @@ class DatabaseManager: backup_name = ( database_name + "_" - + datetime.datetime.now().strftime( - "%Y-%m-%d_%Hh%Mm%Ss" - ) + + datetime.datetime.now().strftime("%Y-%m-%d_%Hh%Mm%Ss") + ".zip" ) @@ -148,7 +142,7 @@ class DatabaseManager: source_erplibre=False, ) - def open_file_image_db(self): + def open_file_image_db(self) -> str: self._dir_path = "" path_image_db = os.path.join(os.getcwd(), "image_db") @@ -160,10 +154,10 @@ class DatabaseManager: print(file_name) return file_name - def download_database_backup_cli(self, show_remote_list=True): - database_domain = input( - "Domain Odoo (ex. https://mondomain.com) : " - ) + def download_database_backup_cli( + self, show_remote_list: bool = True + ) -> tuple[int, str, str]: + database_domain = input("Domain Odoo (ex. https://mondomain.com) : ") if show_remote_list: status, output_lines = self._execute.exec_command_live( f"python3 ./script/database/list_remote.py --raw" @@ -175,9 +169,7 @@ class DatabaseManager: if len(output_lines) > 1: for index, output in enumerate(output_lines): print(f"{index + 1} - {output}") - database_name = input( - "Select id of database :" - ).strip() + database_name = input("Select id of database :").strip() elif len(output_lines) == 1: database_name = output_lines[0].strip() else: @@ -187,12 +179,8 @@ class DatabaseManager: else: database_name = input("Database name :\n") - timestamp = datetime.datetime.now().strftime( - "%Y-%m-%d_%Hh%Mm%Ss" - ) - default_output_path = ( - f"./image_db/{database_name}_{timestamp}.zip" - ) + timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%Hh%Mm%Ss") + default_output_path = f"./image_db/{database_name}_{timestamp}.zip" output_path = input( f"Output path (default: {default_output_path}) : " ).strip() @@ -214,9 +202,7 @@ class DatabaseManager: new_env=my_env, ) try: - with zipfile.ZipFile( - default_output_path, "r" - ) as zip_ref: + with zipfile.ZipFile(default_output_path, "r") as zip_ref: manifest_file_1 = zip_ref.open("manifest.json") _logger.info( f"Log file '{default_output_path}' is complete" diff --git a/script/todo/kdbx_manager.py b/script/todo/kdbx_manager.py index 21b9035..b495f21 100644 --- a/script/todo/kdbx_manager.py +++ b/script/todo/kdbx_manager.py @@ -21,7 +21,7 @@ except ModuleNotFoundError: class KdbxManager: - def __init__(self, config_file): + def __init__(self, config_file) -> None: self._config_file = config_file self._kdbx = None @@ -29,9 +29,7 @@ class KdbxManager: if self._kdbx: return self._kdbx - kdbx_file_path = self._config_file.get_config_value( - ["kdbx", "path"] - ) + kdbx_file_path = self._config_file.get_config_value(["kdbx", "path"]) if not kdbx_file_path: if tk is None: _logger.error("tkinter is not available") @@ -64,7 +62,9 @@ class KdbxManager: self._kdbx = kp return kp - def get_extra_command_user(self, kdbx_key): + def get_extra_command_user( + self, kdbx_key: str | list | None + ) -> str | list: values = [] if kdbx_key: kp = self.get_kdbx() @@ -79,15 +79,11 @@ class KdbxManager: try: odoo_user = entry.username except AttributeError: - _logger.error( - f"Cannot find username from keys {key}" - ) + _logger.error(f"Cannot find username from keys {key}") try: odoo_password = entry.password except AttributeError: - _logger.error( - f"Cannot find password from keys {key}" - ) + _logger.error(f"Cannot find password from keys {key}") values.append( " --default_email_auth" f" {odoo_user} --default_password_auth" diff --git a/script/todo/todo.py b/script/todo/todo.py index 79067eb..1bf468c 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -85,9 +85,7 @@ class TODO: self.config_file = config_file.ConfigFile() self.execute = execute.Execute() self.kdbx_manager = KdbxManager(self.config_file) - self.db_manager = DatabaseManager( - self.execute, self.fill_help_info - ) + self.db_manager = DatabaseManager(self.execute, self.fill_help_info) def _ask_language(self): if not lang_is_configured(): @@ -431,7 +429,9 @@ class TODO: odoo_password = instance.get("password") if kdbx_key: - extra_cmd_web_login = self.kdbx_manager.get_extra_command_user(kdbx_key) + extra_cmd_web_login = self.kdbx_manager.get_extra_command_user( + kdbx_key + ) elif odoo_user and odoo_password: extra_cmd_web_login = ( f" --default_email_auth {odoo_user} --default_password_auth" @@ -818,8 +818,8 @@ class TODO: f"{name} <{email}>", ) content = content.replace( - 'Your Name ', - f'{name} ', + "Your Name ", + f"{name} ", ) os.makedirs(dest_dir, exist_ok=True) @@ -1309,33 +1309,9 @@ class TODO: single_source_erplibre=True, ) - def select_database(self): - cmd_server = f"./odoo_bin.sh db --list" - status, databases = self.execute.exec_command_live( - cmd_server, - return_status_and_output=True, - source_erplibre=False, - single_source_erplibre=True, - ) - choices = [{"prompt_description": a.strip()} for a in databases] - - help_info = self.fill_help_info(choices) - - valid_choices = [str(a) for a in range(len(choices) + 1) if a] - - while True: - status = click.prompt(help_info) - print() - if status == "0": - return False - elif status in valid_choices: - database_name = databases[int(status) - 1].strip() - print(database_name) - return database_name - else: - print(t("cmd_not_found")) - - def prompt_execute_selenium_and_run_db(self, db_name, extra_cmd_web_login=""): + def prompt_execute_selenium_and_run_db( + self, db_name, extra_cmd_web_login="" + ): cmd_server = f"./run.sh -d {db_name};bash" self.execute.exec_command_live(cmd_server) cmd_client = ( @@ -1500,102 +1476,6 @@ class TODO: database_name = self.db_manager.select_database() self.prompt_execute_selenium_and_run_db(database_name) - def restore_from_database(self, show_remote_list=True): - path_image_db = os.path.join(os.getcwd(), "image_db") - print("[1] By filename from image_db") - print(f"[] Browser image_db {path_image_db}") - status = input("💬 Select : ") - if status == "1": - file_name = status - else: - file_name = self.db_manager.open_file_image_db() - - default_database_name = file_name.replace(" ", "_") - if default_database_name.endswith(".zip"): - default_database_name = default_database_name[:-4] - - database_name = input( - f"💬 Database name (default={default_database_name}) : " - ) - if not database_name: - database_name = default_database_name - - status = ( - input("💬 Would you like to neutralize database (n/N)? ") - .strip() - .lower() - ) - is_neutralize = False - more_arg = "" - if status != "n": - more_arg = "--neutralize " - is_neutralize = True - database_name += "_neutralize" - status, output_lines = self.execute.exec_command_live( - f"python3 ./script/database/db_restore.py -d {database_name} {more_arg}--ignore_cache --image {file_name}", - return_status_and_output=True, - single_source_erplibre=True, - source_erplibre=False, - ) - if is_neutralize: - status, output_lines = self.execute.exec_command_live( - f"./script/addons/update_prod_to_dev.sh {database_name}", - return_status_and_output=True, - single_source_erplibre=True, - source_erplibre=False, - ) - status = ( - input("💬 Would you like to update all addons (y/Y)? ") - .strip() - .lower() - ) - if status == "y": - status, output_lines = self.execute.exec_command_live( - f"./script/addons/update_addons_all.sh {database_name}", - return_status_and_output=True, - single_source_erplibre=True, - source_erplibre=False, - ) - - def create_backup_from_database(self, show_remote_list=True): - database_name = self.db_manager.select_database() - str_arg = f"--database {database_name}" - - backup_name = input("💬 Backup name (default = name+date.zip) : ") - if not backup_name: - backup_name = ( - database_name - + "_" - + datetime.datetime.now().strftime("%Y-%m-%d_%Hh%Mm%Ss") - + ".zip" - ) - - if not backup_name.endswith(".zip"): - backup_name = backup_name + ".zip" - - print(backup_name) - - cmd = f"./odoo_bin.sh db --backup --database {database_name} --restore_image {backup_name}" - status, output_lines = self.execute.exec_command_live( - cmd, - return_status_and_output=True, - single_source_erplibre=True, - source_erplibre=False, - ) - - def open_file_image_db(self): - self.dir_path = "" - path_image_db = os.path.join(os.getcwd(), "image_db") - - # self.dir_path is over-write into on_dir_selected - file_browser = todo_file_browser.FileBrowser( - path_image_db, self.on_dir_selected - ) - file_browser.run_main_frame() - file_name = os.path.basename(self.dir_path) - print(file_name) - return file_name - def process_kill_from_port(self): cfg = configparser.ConfigParser() cfg.read("./config.conf") @@ -1606,63 +1486,6 @@ class TODO: source_erplibre=False, ) - def download_database_backup_cli(self, show_remote_list=True): - database_domain = input("Domain Odoo (ex. https://mondomain.com) : ") - if show_remote_list: - status, output_lines = self.execute.exec_command_live( - f"python3 ./script/database/list_remote.py --raw --odoo-url {database_domain}", - return_status_and_output=True, - single_source_erplibre=True, - source_erplibre=False, - ) - if len(output_lines) > 1: - for index, output in enumerate(output_lines): - print(f"{index + 1} - {output}") - database_name = input("Select id of database :").strip() - elif len(output_lines) == 1: - database_name = output_lines[0].strip() - else: - database_name = input( - "Cannot read remote database, Database name :\n" - ) - else: - database_name = input("Database name :\n") - - timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%Hh%Mm%Ss") - default_output_path = f"./image_db/{database_name}_{timestamp}.zip" - output_path = input( - f"Output path (default: {default_output_path}) : " - ).strip() - if not output_path: - output_path = default_output_path - - master_password = getpass.getpass(prompt="Master password : ") - - cmd = "script/database/download_remote.sh --quiet" - my_env = os.environ.copy() - my_env["MASTER_PWD"] = master_password - my_env["DATABASE_NAME"] = database_name - my_env["OUTPUT_FILE_PATH"] = output_path - my_env["ODOO_URL"] = database_domain - status, cmd_executed = self.execute.exec_command_live( - cmd, - source_erplibre=False, - return_status_and_command=True, - new_env=my_env, - ) - try: - with zipfile.ZipFile(default_output_path, "r") as zip_ref: - manifest_file_1 = zip_ref.open("manifest.json") - _logger.info( - f"Log file '{default_output_path}' is complete and validated." - ) - except Exception as e: - _logger.error(e) - _logger.error( - f"Failed to read manifest.json from backup file '{default_output_path}'." - ) - return status, output_path, database_name - def restart_script(self, last_error): print(f"🤖 {t('reboot_todo')}") # os.execv(sys.executable, ['python'] + sys.argv) diff --git a/script/todo/version_manager.py b/script/todo/version_manager.py index 0640e7e..46739e3 100644 --- a/script/todo/version_manager.py +++ b/script/todo/version_manager.py @@ -12,7 +12,7 @@ INSTALLED_ODOO_VERSION_FILE = os.path.join( ODOO_VERSION_FILE = ".odoo-version" -def get_odoo_version(): +def get_odoo_version() -> tuple[list[dict], list[str], str | None]: """ Read version configuration and return sorted versions, installed versions, and current version. @@ -40,8 +40,6 @@ def get_odoo_version(): with open(ODOO_VERSION_FILE) as txt: odoo_installed_version = f"odoo{txt.read().strip()}" - versions = sorted( - version_entries, key=lambda k: k.get("erplibre_version") - ) + versions = sorted(version_entries, key=lambda k: k.get("erplibre_version")) return versions, installed_versions, odoo_installed_version diff --git a/test/test_todo.py b/test/test_todo.py index 71374eb..5c94825 100644 --- a/test/test_todo.py +++ b/test/test_todo.py @@ -120,9 +120,7 @@ class TestGetOdooVersion(unittest.TestCase): "script.todo.version_manager.ODOO_VERSION_FILE", odoo_version_file, ): - lst_version, lst_installed, odoo_current = ( - get_odoo_version() - ) + lst_version, lst_installed, odoo_current = get_odoo_version() self.assertEqual(len(lst_version), 2) self.assertEqual(odoo_current, "odoo18.0") @@ -158,9 +156,7 @@ class TestGetOdooVersion(unittest.TestCase): "script.todo.version_manager.ODOO_VERSION_FILE", os.path.join(tmpdir, "nonexistent"), ): - lst_version, lst_installed, odoo_current = ( - get_odoo_version() - ) + lst_version, lst_installed, odoo_current = get_odoo_version() self.assertEqual(lst_installed, ["odoo16.0", "odoo18.0"]) self.assertIsNone(odoo_current) @@ -171,7 +167,9 @@ class TestGetOdooVersion(unittest.TestCase): with open(version_file, "w") as f: json.dump({}, f) - with patch("script.todo.version_manager.VERSION_DATA_FILE", version_file): + with patch( + "script.todo.version_manager.VERSION_DATA_FILE", version_file + ): with self.assertRaises(Exception): get_odoo_version() @@ -329,25 +327,28 @@ class TestSetupClaudeCommit(unittest.TestCase): class TestSelectDatabase(unittest.TestCase): - @patch("script.todo.todo.click") + @patch("script.todo.database_manager.click") def test_select_database_returns_name(self, mock_click): todo = TODO() - todo.execute = MagicMock() - todo.execute.exec_command_live.return_value = ( + todo.db_manager._execute = MagicMock() + todo.db_manager._execute.exec_command_live.return_value = ( 0, ["db_test", "db_prod"], ) mock_click.prompt.return_value = "1" - result = todo.select_database() + result = todo.db_manager.select_database() self.assertEqual(result, "db_test") - @patch("script.todo.todo.click") + @patch("script.todo.database_manager.click") def test_select_database_returns_false_on_zero(self, mock_click): todo = TODO() - todo.execute = MagicMock() - todo.execute.exec_command_live.return_value = (0, ["db_test"]) + todo.db_manager._execute = MagicMock() + todo.db_manager._execute.exec_command_live.return_value = ( + 0, + ["db_test"], + ) mock_click.prompt.return_value = "0" - result = todo.select_database() + result = todo.db_manager.select_database() self.assertFalse(result) @@ -355,41 +356,53 @@ class TestRestoreFromDatabase(unittest.TestCase): @patch("builtins.input") def test_restore_by_filename(self, mock_input): todo = TODO() - todo.execute = MagicMock() - todo.execute.exec_command_live.return_value = (0, []) + todo.db_manager._execute = MagicMock() + todo.db_manager._execute.exec_command_live.return_value = ( + 0, + [], + ) # status="1" (by filename), db name default, no neutralize mock_input.side_effect = ["1", "", "n", "n"] - todo.restore_from_database() - cmd = todo.execute.exec_command_live.call_args_list[0][0][0] + todo.db_manager.restore_from_database() + cmd = todo.db_manager._execute.exec_command_live.call_args_list[0][0][ + 0 + ] self.assertIn("db_restore.py", cmd) @patch("builtins.input") def test_restore_with_neutralize(self, mock_input): todo = TODO() - todo.execute = MagicMock() - todo.execute.exec_command_live.return_value = (0, []) + todo.db_manager._execute = MagicMock() + todo.db_manager._execute.exec_command_live.return_value = ( + 0, + [], + ) mock_input.side_effect = ["1", "mydb", "y", "n"] - todo.restore_from_database() - cmd = todo.execute.exec_command_live.call_args_list[0][0][0] + todo.db_manager.restore_from_database() + cmd = todo.db_manager._execute.exec_command_live.call_args_list[0][0][ + 0 + ] self.assertIn("--neutralize", cmd) self.assertIn("mydb_neutralize", cmd) class TestCreateBackupFromDatabase(unittest.TestCase): - @patch("script.todo.todo.click") + @patch("script.todo.database_manager.click") @patch("builtins.input") def test_creates_backup_command(self, mock_input, mock_click): todo = TODO() - todo.execute = MagicMock() - todo.execute.exec_command_live.return_value = ( + todo.db_manager._execute = MagicMock() + todo.db_manager._execute.exec_command_live.return_value = ( 0, ["test_db"], ) mock_click.prompt.return_value = "1" # backup name input mock_input.return_value = "backup.zip" - todo.create_backup_from_database() - cmd = todo.execute.exec_command_live.call_args_list[-1][0][0] + todo.db_manager.create_backup_from_database() + cmd = todo.db_manager._execute.exec_command_live.call_args_list[-1][0][ + 0 + ] self.assertIn("--backup", cmd) self.assertIn("test_db", cmd) From 4a46ebbf4c08b12aa51adb51aad95adeb77ab355 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 10 Mar 2026 04:07:38 -0400 Subject: [PATCH 11/24] [REF] script: add type hints to execute and i18n Improve code maintainability and IDE support by adding type annotations to function signatures and instance variables in execute.py and todo_i18n.py. Also let Black simplify unnecessary parentheses. Generated by Claude Code 2.1.72 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- script/execute/execute.py | 50 ++++++++++++++++++--------------------- script/todo/todo_i18n.py | 8 +++---- 2 files changed, 27 insertions(+), 31 deletions(-) diff --git a/script/execute/execute.py b/script/execute/execute.py index e7842ee..e37259f 100644 --- a/script/execute/execute.py +++ b/script/execute/execute.py @@ -35,9 +35,9 @@ _logger = logging.getLogger(__name__) class Execute: - def __init__(self): - self.cmd_source_erplibre = "" - self.cmd_source_default = "" + def __init__(self) -> None: + self.cmd_source_erplibre: str = "" + self.cmd_source_default: str = "" exec_path_gnome_terminal = shutil.which("gnome-terminal") if exec_path_gnome_terminal: self.cmd_source_erplibre = ( @@ -61,17 +61,22 @@ class Execute: def exec_command_live( self, - command, - source_erplibre=True, - quiet=False, - single_source_erplibre=False, - new_window=False, - single_source_odoo=False, - source_odoo="", - new_env=None, - return_status_and_command=False, - return_status_and_output=False, - return_status_and_output_and_command=False, + command: str, + source_erplibre: bool = True, + quiet: bool = False, + single_source_erplibre: bool = False, + new_window: bool = False, + single_source_odoo: bool = False, + source_odoo: str = "", + new_env: dict | None = None, + return_status_and_command: bool = False, + return_status_and_output: bool = False, + return_status_and_output_and_command: bool = False, + ) -> ( + int + | tuple[int, str] + | tuple[int, list[str]] + | tuple[int, str, list[str]] ): """ Execute a command and display its output live. @@ -95,9 +100,7 @@ class Execute: command = self.cmd_source_erplibre % command # os.system(f"./script/terminal/open_terminal.sh {command}") elif single_source_erplibre: - command = ( - f"source ./{VENV_ERPLIBRE}/bin/activate && %s" % command - ) + command = f"source ./{VENV_ERPLIBRE}/bin/activate && %s" % command elif single_source_odoo: if not source_odoo and os.path.exists("./.erplibre-version"): with open("./.erplibre-version") as f: @@ -107,9 +110,7 @@ class Execute: f"You cannot execute Odoo command if no version is installed. Command : {command}" ) return -1 - command = ( - f"source ./.venv.{source_odoo}/bin/activate && {command}" - ) + command = f"source ./.venv.{source_odoo}/bin/activate && {command}" if new_window and self.cmd_source_default: command = self.cmd_source_default % command @@ -151,10 +152,7 @@ class Execute: process.wait() exit_code = process.returncode if process.returncode != 0 and not quiet: - print( - "Command returned error code:" - f" {process.returncode}" - ) + print("Command returned error code:" f" {process.returncode}") except FileNotFoundError: if not quiet: @@ -164,9 +162,7 @@ class Execute: " not found." ) else: - print( - f"Error: Command '{command}' not found." - ) + print(f"Error: Command '{command}' not found.") except Exception as e: if not quiet: print(f"An error occurred: {e}") diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index cf4553c..8b9fbab 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -554,7 +554,7 @@ TRANSLATIONS = { } -def get_lang(): +def get_lang() -> str: global _current_lang if _current_lang is not None: return _current_lang @@ -586,7 +586,7 @@ def get_lang(): return _current_lang -def set_lang(lang): +def set_lang(lang: str) -> None: global _current_lang _current_lang = lang @@ -614,7 +614,7 @@ def set_lang(lang): f.write(content) -def lang_is_configured(): +def lang_is_configured() -> bool: """Check if a language has been explicitly set.""" if os.path.exists(ENV_VAR_FILE): try: @@ -626,7 +626,7 @@ def lang_is_configured(): return False -def t(key): +def t(key: str) -> str: entry = TRANSLATIONS.get(key) if entry is None: return key From 095eaa166122863918a35043f3463ac0a0e5723c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 10 Mar 2026 04:16:28 -0400 Subject: [PATCH 12/24] [REF] script: extract git helpers into modules Reduce GitTool class size by moving URL transformation and GitHub API logic into dedicated repo_url and github_api modules for better separation of concerns. Generated by Claude Code 2.1.72 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- script/git/git_tool.py | 219 +++++---------------------------------- script/git/github_api.py | 164 +++++++++++++++++++++++++++++ script/git/repo_url.py | 84 +++++++++++++++ 3 files changed, 272 insertions(+), 195 deletions(-) create mode 100644 script/git/github_api.py create mode 100644 script/git/repo_url.py diff --git a/script/git/git_tool.py b/script/git/git_tool.py index 0eb6956..12d439f 100644 --- a/script/git/git_tool.py +++ b/script/git/git_tool.py @@ -16,6 +16,10 @@ from git import Repo from giturlparse import parse # pip install giturlparse from retrying import retry # pip install retrying +from script.git import github_api +from script.git.repo_url import get_transformed_repo_info_from_url as _get_transformed_repo_info +from script.git.repo_url import get_url as _get_url + SOURCE_REPO_ADDONS_FILE = "source_repo_addons.csv" EL_GITHUB_TOKEN = "EL_GITHUB_TOKEN" DEFAULT_PROJECT_NAME = "ERPLibre" @@ -56,20 +60,13 @@ class GitTool: return f"odoo{self.odoo_version}" @staticmethod - def get_url(url: str) -> object: + def get_url(url: str) -> tuple[str, str, str]: """ Transform an url in git and https. :param url: The url to transform in https and git :return: (url, url_https, url_git) """ - if "https" in url: - url_git = f"git@{url[8:].replace('/', ':', 1)}" - url_https = url - else: - url_https = f"https://{(url[4:]).replace(':', '/')}" - url_git = url - - return url, url_https, url_git + return _get_url(url) def get_transformed_repo_info_from_url( self, @@ -82,72 +79,17 @@ class GitTool: revision: str = "", clone_depth: str = "", ) -> object: - """ - - :param url: - :param repo_path: - :param get_obj: - :param is_submodule: - :param organization_force: Keep repo_path and change organization - :param sub_path: - :param revision: Tag or branch name. When empty, use default branch. - :param clone_depth: length of git history to clone. Clone all git when empty. - Set to 0 to increase speed to clone, set to empty for development. - :return: - """ - _, url_https, url_git = self.get_url(url) - url_split = url_https.split("/") - organization = url_split[3] - repo_name = url_split[4] - if repo_name[-4:] == ".git": - repo_name = repo_name[:-4] - if is_submodule: - if not sub_path or sub_path == ".": - path = repo_name - else: - path = os.path.join(sub_path, f"{organization}_{repo_name}") - else: - path = repo_path - relative_path = os.path.join(repo_path, path) - # if repo_path[-1] == "/": - # relative_path = f"{repo_path}{path}" - # else: - # relative_path = f"{repo_path}/{path}" - relative_path = os.path.normpath(relative_path) - - original_organization = organization - url_https_original_organization = url_https[: url_https.rfind("/")] - project_name = url_https[url_https.rfind("/") + 1 :] - # begin_original = url_git[url_git.find(":") + 1:] - # original_organization = begin_original[:begin_original.find("/")] - if organization_force: - organization = organization_force - url_split = url_https.split("/") - url_split[3] = organization - url_https = "/".join(url_split) - url, _, url_git = self.get_url(url_https) - url_https_organization = url_https[: url_https.rfind("/")] - - repo_data = { - "url": url, - "url_git": url_git, - "url_https": url_https, - "organization": organization, - "original_organization": original_organization, - "url_https_organization": url_https_organization, - "url_https_original_organization": url_https_original_organization, - "project_name": project_name, - "revision": revision, - "clone_depth": clone_depth, - "repo_name": repo_name, - "path": path, - "relative_path": relative_path, - "is_submodule": is_submodule, - "sub_path": sub_path, - } - if get_obj: - return RepoAttrs(**repo_data) - return repo_data + return _get_transformed_repo_info( + url=url, + repo_path=repo_path, + get_obj=get_obj, + is_submodule=is_submodule, + organization_force=organization_force, + sub_path=sub_path, + revision=revision, + clone_depth=clone_depth, + repo_attrs_class=RepoAttrs, + ) def get_repo_info( self, @@ -953,135 +895,22 @@ class GitTool: def add_and_fetch_remote( repo_info: RepoAttrs, root_repo: Repo = None, branch_name: str = "" ): - """ - Deprecated function, not use anymore git submodule - :param repo_info: - :param root_repo: - :param branch_name: - :return: - """ - try: - working_repo = Repo(repo_info.relative_path) - if repo_info.organization in [ - a.name for a in working_repo.remotes - ]: - print( - f'Remote "{repo_info.organization}" already exist ' - f"in {repo_info.relative_path}" - ) - return - except git.NoSuchPathError: - print(f"New repo {repo_info.relative_path}") - if not root_repo: - print( - f"Missing git repository to root for repo {repo_info.path}" - ) - return - if branch_name: - submodule_repo = retry( - wait_exponential_multiplier=1000, stop_max_delay=15000 - )(root_repo.create_submodule)( - repo_info.path, - repo_info.path, - url=repo_info.url_https, - branch=branch_name, - ) - else: - submodule_repo = retry( - wait_exponential_multiplier=1000, stop_max_delay=15000 - )(root_repo.create_submodule)( - repo_info.path, repo_info.path, url=repo_info.url_https - ) - return - # Add remote - upstream_remote = retry( - wait_exponential_multiplier=1000, stop_max_delay=15000 - )(working_repo.create_remote)( - repo_info.organization, repo_info.url_https + """Deprecated function, not use anymore git submodule""" + return github_api.add_and_fetch_remote( + repo_info, root_repo, branch_name ) - print( - 'Remote "%s" created for %s' - % (repo_info.organization, repo_info.url_https) - ) - - # Fetch the remote - retry(wait_exponential_multiplier=1000, stop_max_delay=15000)( - upstream_remote.fetch - )() - print('Remote "%s" fetched' % repo_info.organization) def get_pull_request_repo( self, upstream_url: str, github_token: str, organization_name: str = "" ): - """ - - :param upstream_url: - :param github_token: - :param organization_name: - :return: List of url if success, else False - """ - gh = GitHub(token=github_token) - parsed_url = parse(upstream_url) - - # Fork the repo - status, user = gh.user.get() - user_name = ( - user["login"] if not organization_name else organization_name + return github_api.get_pull_request_repo( + upstream_url, github_token, organization_name ) - status, lst_pull = gh.repos[user_name][parsed_url.repo].pulls.get() - if type(lst_pull) is dict: - print(f"For url {upstream_url}, got {lst_pull.get('message')}") - return False - else: - for pull in lst_pull: - print(pull.get("html_url")) - return lst_pull def fork_repo( self, upstream_url: str, github_token: str, organization_name: str = "" ): - # https://developer.github.com/apps/building-integrations/setting-up-and-registering-oauth-apps/about-scopes-for-oauth-apps/ - gh = GitHub(token=github_token) - parsed_url = parse(upstream_url) - - # Fork the repo - status, user = gh.user.get() - user_name = ( - user["login"] if not organization_name else organization_name + return github_api.fork_repo( + upstream_url, github_token, organization_name ) - status, forked_repo = gh.repos[user_name][parsed_url.repo].get() - if status == 404: - status, upstream_repo = gh.repos[parsed_url.owner][ - parsed_url.repo - ].get() - if status == 404: - print("Unable to find repo %s" % upstream_url) - exit(1) - args = {} - if organization_name: - args["organization"] = organization_name - status, forked_repo = gh.repos[parsed_url.owner][ - parsed_url.repo - ].forks.post(**args) - if status == 404: - print( - f"{Fore.RED}Error{Style.RESET_ALL} when forking repo" - f" {forked_repo}" - ) - exit(1) - else: - try: - print( - "Forked %s to %s" - % (upstream_url, forked_repo["html_url"]) - ) - except Exception as e: - print(e) - print(forked_repo) - print(upstream_url) - elif status == 202: - print("Forked repo %s already exists" % forked_repo["full_name"]) - elif status != 200: - print("Status not supported: %s - %s" % (status, forked_repo)) - exit(1) return forked_repo diff --git a/script/git/github_api.py b/script/git/github_api.py new file mode 100644 index 0000000..d409757 --- /dev/null +++ b/script/git/github_api.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import git +from agithub.GitHub import GitHub +from colorama import Fore, Style +from git import Repo +from giturlparse import parse +from retrying import retry + + +def get_pull_request_repo( + upstream_url: str, + github_token: str, + organization_name: str = "", +) -> list | bool: + """ + Get pull requests for a repo. + :param upstream_url: URL of the upstream repo + :param github_token: GitHub API token + :param organization_name: optional organization name + :return: List of PRs if success, else False + """ + gh = GitHub(token=github_token) + parsed_url = parse(upstream_url) + + status, user = gh.user.get() + user_name = ( + user["login"] if not organization_name else organization_name + ) + status, lst_pull = ( + gh.repos[user_name][parsed_url.repo].pulls.get() + ) + if type(lst_pull) is dict: + print( + f"For url {upstream_url}," + f" got {lst_pull.get('message')}" + ) + return False + else: + for pull in lst_pull: + print(pull.get("html_url")) + return lst_pull + + +def fork_repo( + upstream_url: str, + github_token: str, + organization_name: str = "", +) -> None: + gh = GitHub(token=github_token) + parsed_url = parse(upstream_url) + + status, user = gh.user.get() + user_name = ( + user["login"] if not organization_name else organization_name + ) + status, forked_repo = ( + gh.repos[user_name][parsed_url.repo].get() + ) + if status == 404: + status, upstream_repo = ( + gh.repos[parsed_url.owner][parsed_url.repo].get() + ) + if status == 404: + print("Unable to find repo %s" % upstream_url) + exit(1) + args = {} + if organization_name: + args["organization"] = organization_name + status, forked_repo = ( + gh.repos[parsed_url.owner][parsed_url.repo] + .forks.post(**args) + ) + if status == 404: + print( + f"{Fore.RED}Error{Style.RESET_ALL} when forking" + f" repo {forked_repo}" + ) + exit(1) + else: + try: + print( + "Forked %s to %s" + % (upstream_url, forked_repo["html_url"]) + ) + except Exception as e: + print(e) + print(forked_repo) + print(upstream_url) + elif status == 202: + print( + "Forked repo %s already exists" + % forked_repo["full_name"] + ) + elif status != 200: + print( + "Status not supported: %s - %s" + % (status, forked_repo) + ) + exit(1) + + +def add_and_fetch_remote( + repo_info, root_repo: Repo = None, branch_name: str = "" +) -> None: + """ + Deprecated function, not use anymore git submodule + """ + try: + working_repo = Repo(repo_info.relative_path) + if repo_info.organization in [ + a.name for a in working_repo.remotes + ]: + print( + f'Remote "{repo_info.organization}" already exist' + f" in {repo_info.relative_path}" + ) + return + except git.NoSuchPathError: + print(f"New repo {repo_info.relative_path}") + if not root_repo: + print( + "Missing git repository to root for repo" + f" {repo_info.path}" + ) + return + if branch_name: + submodule_repo = retry( + wait_exponential_multiplier=1000, + stop_max_delay=15000, + )(root_repo.create_submodule)( + repo_info.path, + repo_info.path, + url=repo_info.url_https, + branch=branch_name, + ) + else: + submodule_repo = retry( + wait_exponential_multiplier=1000, + stop_max_delay=15000, + )(root_repo.create_submodule)( + repo_info.path, + repo_info.path, + url=repo_info.url_https, + ) + return + # Add remote + upstream_remote = retry( + wait_exponential_multiplier=1000, stop_max_delay=15000 + )(working_repo.create_remote)( + repo_info.organization, repo_info.url_https + ) + print( + 'Remote "%s" created for %s' + % (repo_info.organization, repo_info.url_https) + ) + + # Fetch the remote + retry( + wait_exponential_multiplier=1000, stop_max_delay=15000 + )(upstream_remote.fetch)() + print('Remote "%s" fetched' % repo_info.organization) diff --git a/script/git/repo_url.py b/script/git/repo_url.py new file mode 100644 index 0000000..29bc270 --- /dev/null +++ b/script/git/repo_url.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import os + + +def get_url(url: str) -> tuple[str, str, str]: + """ + Transform a url into git and https variants. + :param url: The url to transform + :return: (url, url_https, url_git) + """ + if "https" in url: + url_git = f"git@{url[8:].replace('/', ':', 1)}" + url_https = url + else: + url_https = f"https://{(url[4:]).replace(':', '/')}" + url_git = url + + return url, url_https, url_git + + +def get_transformed_repo_info_from_url( + url: str, + repo_path: str = ".", + get_obj: bool = True, + is_submodule: bool = True, + organization_force: str | None = None, + sub_path: str = "addons", + revision: str = "", + clone_depth: str = "", + repo_attrs_class=None, +) -> object: + """ + Transform a URL into a structured repo info dict or object. + """ + _, url_https, url_git = get_url(url) + url_split = url_https.split("/") + organization = url_split[3] + repo_name = url_split[4] + if repo_name[-4:] == ".git": + repo_name = repo_name[:-4] + if is_submodule: + if not sub_path or sub_path == ".": + path = repo_name + else: + path = os.path.join(sub_path, f"{organization}_{repo_name}") + else: + path = repo_path + relative_path = os.path.join(repo_path, path) + relative_path = os.path.normpath(relative_path) + + original_organization = organization + url_https_original_organization = url_https[: url_https.rfind("/")] + project_name = url_https[url_https.rfind("/") + 1 :] + if organization_force: + organization = organization_force + url_split = url_https.split("/") + url_split[3] = organization + url_https = "/".join(url_split) + url, _, url_git = get_url(url_https) + url_https_organization = url_https[: url_https.rfind("/")] + + repo_data = { + "url": url, + "url_git": url_git, + "url_https": url_https, + "organization": organization, + "original_organization": original_organization, + "url_https_organization": url_https_organization, + "url_https_original_organization": url_https_original_organization, + "project_name": project_name, + "revision": revision, + "clone_depth": clone_depth, + "repo_name": repo_name, + "path": path, + "relative_path": relative_path, + "is_submodule": is_submodule, + "sub_path": sub_path, + } + if get_obj and repo_attrs_class: + return repo_attrs_class(**repo_data) + return repo_data From d60caa389791ba0be89251ea2a99b83ce1e5dcb3 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 10 Mar 2026 04:18:17 -0400 Subject: [PATCH 13/24] [REF] script: improve variable naming conventions Replace Hungarian notation prefixes (lst_, dct_) with descriptive English names for better readability across git and todo modules. Generated by Claude Code 2.1.72 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- script/git/git_merge_repo_manifest.py | 86 ++++++++++++------------- script/git/git_tool.py | 92 +++++++++++++-------------- script/todo/todo.py | 2 +- 3 files changed, 86 insertions(+), 94 deletions(-) diff --git a/script/git/git_merge_repo_manifest.py b/script/git/git_merge_repo_manifest.py index d70d2c2..4926000 100755 --- a/script/git/git_merge_repo_manifest.py +++ b/script/git/git_merge_repo_manifest.py @@ -75,12 +75,12 @@ def main(): config = get_config() git_tool = GitTool() - lst_input = config.input - if not lst_input: - lst_input = [] + input_paths = config.input + if not input_paths: + input_paths = [] if config.with_OCA: append_file_path_manifest( - lst_input, DEFAULT_PATH_MANIFEST_ODOO_CONF + input_paths, DEFAULT_PATH_MANIFEST_ODOO_CONF ) if os.path.exists(".odoo-version"): with open(".odoo-version", "r") as f: @@ -90,7 +90,7 @@ def main(): "manifest", f"git_manifest_odoo{odoo_version}.xml" ) if os.path.exists(path_manifest_odoo_version): - lst_input.append(path_manifest_odoo_version) + input_paths.append(path_manifest_odoo_version) else: print( f"ERROR: {path_manifest_odoo_version} does not exist" @@ -99,21 +99,19 @@ def main(): "manifest", f"git_manifest_odoo{odoo_version}_dev.xml" ) if os.path.exists(path_manifest_odoo_version): - lst_input.append(path_manifest_odoo_version) + input_paths.append(path_manifest_odoo_version) if os.path.exists(DEFAULT_PATH_INSTALLED_ODOO_VERSION): with open(DEFAULT_PATH_INSTALLED_ODOO_VERSION, "r") as f: - lst_installed_odoo_version = [ - a.strip() for a in f.readlines() - ] - if lst_installed_odoo_version: - for installed_odoo_version in lst_installed_odoo_version: + installed_versions = [a.strip() for a in f.readlines()] + if installed_versions: + for installed_odoo_version in installed_versions: path_manifest_odoo_version = os.path.join( "manifest", f"git_manifest_{installed_odoo_version}.xml", ) if os.path.exists(path_manifest_odoo_version): - lst_input.append(path_manifest_odoo_version) + input_paths.append(path_manifest_odoo_version) else: print( f"ERROR: {path_manifest_odoo_version} does not exist" @@ -123,81 +121,81 @@ def main(): f"git_manifest_{installed_odoo_version}_dev.xml", ) if os.path.exists(path_manifest_odoo_version): - lst_input.append(path_manifest_odoo_version) + input_paths.append(path_manifest_odoo_version) elif config.with_mobile: append_file_path_manifest( - lst_input, DEFAULT_PATH_MANIFEST_MOBILE_CONF + input_paths, DEFAULT_PATH_MANIFEST_MOBILE_CONF ) else: - append_file_path_manifest(lst_input, DEFAULT_PATH_MANIFEST_CONF) + append_file_path_manifest(input_paths, DEFAULT_PATH_MANIFEST_CONF) append_file_path_manifest( - lst_input, DEFAULT_PATH_MANIFEST_PRIVATE_CONF + input_paths, DEFAULT_PATH_MANIFEST_PRIVATE_CONF ) - dct_remote_total = {} - dct_project_total = {} + remotes_total = {} + projects_total = {} default_remote_total = None # Be sure all input is unique - lst_input = list(set(lst_input)) + input_paths = list(set(input_paths)) - for index, input_path in enumerate(lst_input): + for index, input_path in enumerate(input_paths): ( - dct_remote, - dct_project, + remotes, + projects, default_remote, ) = git_tool.get_manifest_xml_info(filename=input_path, add_root=True) # Support multiple version odoo - dct_project_copy = dct_project - dct_project = {} - for key, value in dct_project_copy.items(): + projects_copy = projects + projects = {} + for key, value in projects_copy.items(): new_key = f"{key}+{value.get('@path')}" - dct_project[new_key] = value + projects[new_key] = value - if len(lst_input) == 1: + if len(input_paths) == 1: # Only 1 input, same output - dct_remote_total = dct_remote - dct_project_total = dct_project + remotes_total = remotes + projects_total = projects break elif not index: # Preparation to accumulate data - dct_remote_total = copy.deepcopy(dct_remote) - dct_project_total = copy.deepcopy(dct_project) + remotes_total = copy.deepcopy(remotes) + projects_total = copy.deepcopy(projects) continue - for key, value in dct_project.items(): - if key in dct_project_total.keys(): + for key, value in projects.items(): + if key in projects_total: if config.att_revision_only: revision = value.get("@revision") if revision: - dct_project_total[key]["@revision"] = revision + projects_total[key]["@revision"] = revision else: - dct_project_total[key].update(value) + projects_total[key].update(value) else: - dct_project_total[key] = copy.deepcopy(value) + projects_total[key] = copy.deepcopy(value) - for key, value in dct_remote.items(): - if key in dct_remote_total.keys(): - dct_remote_total[key].update(value) + for key, value in remotes.items(): + if key in remotes_total: + remotes_total[key].update(value) else: - dct_remote_total[key] = copy.deepcopy(value) + remotes_total[key] = copy.deepcopy(value) git_tool.generate_repo_manifest( - remotes_config=dct_remote_total, - projects_config=dct_project_total, + remotes_config=remotes_total, + projects_config=projects_total, output=config.output, default_remote=default_remote_total, ) -def append_file_path_manifest(lst_input, path_manifest): +def append_file_path_manifest(input_paths, path_manifest): if os.path.exists(path_manifest): with open(path_manifest, "r") as f: csv_file = csv.DictReader(f) for row in csv_file: filepath = row.get("filepath") - lst_input.append(filepath) + input_paths.append(filepath) if __name__ == "__main__": diff --git a/script/git/git_tool.py b/script/git/git_tool.py index 12d439f..1f2bd6a 100644 --- a/script/git/git_tool.py +++ b/script/git/git_tool.py @@ -17,7 +17,9 @@ from giturlparse import parse # pip install giturlparse from retrying import retry # pip install retrying from script.git import github_api -from script.git.repo_url import get_transformed_repo_info_from_url as _get_transformed_repo_info +from script.git.repo_url import ( + get_transformed_repo_info_from_url as _get_transformed_repo_info, +) from script.git.repo_url import get_url as _get_url SOURCE_REPO_ADDONS_FILE = "source_repo_addons.csv" @@ -300,7 +302,7 @@ class GitTool: :param repo_path: path of repo to get information about submodule :param filename: manifest filename. Default none, or use this instead use repo_path :param add_root: add information about root repository - :return: dct_remote, dct_project, default_remote + :return: remotes_by_name, projects_by_name, default_remote """ if filename is None: @@ -401,8 +403,8 @@ class GitTool: if update_repo.startswith( os.path.join(self.odoo_version_long, "addons") ): - lst_path = update_repo.split("/", 1) - update_repo = f"${{EL_HOME_ODOO_PROJECT}}/" + lst_path[1] + path_parts = update_repo.split("/", 1) + update_repo = f"${{EL_HOME_ODOO_PROJECT}}/" + path_parts[1] # str_repo = ( # f' printf "${{EL_HOME}}/{update_repo}," >> ' # '"${EL_CONFIG_FILE}"\n' @@ -499,46 +501,42 @@ class GitTool: default_entries = [] # Fill with configuration - for dct_value in remotes_config.values(): - remote_name = dct_value.get("@name") + for entry in remotes_config.values(): + remote_name = entry.get("@name") if remote_name not in remote_names: remote_entries.append( OrderedDict( [ ("@name", remote_name), - ("@fetch", dct_value.get("@fetch")), + ("@fetch", entry.get("@fetch")), ] ) ) remote_names.append(remote_name) - for dct_value in projects_config.values(): - lst_project_info = [ - ("@name", dct_value.get("@name")), - ("@path", dct_value.get("@path")), + for entry in projects_config.values(): + project_attrs = [ + ("@name", entry.get("@name")), + ("@path", entry.get("@path")), ] - if "@remote" in dct_value: - lst_project_info.append(("@remote", dct_value.get("@remote"))) - if "@revision" in dct_value: - lst_project_info.append( - ("@revision", dct_value.get("@revision")) + if "@remote" in entry: + project_attrs.append(("@remote", entry.get("@remote"))) + if "@revision" in entry: + project_attrs.append(("@revision", entry.get("@revision"))) + if "@clone-depth" in entry: + project_attrs.append( + ("@clone-depth", entry.get("@clone-depth")) ) - if "@clone-depth" in dct_value: - lst_project_info.append( - ("@clone-depth", dct_value.get("@clone-depth")) - ) - if "@groups" in dct_value: - lst_project_info.append(("@groups", dct_value.get("@groups"))) - if "@upstream" in dct_value: - lst_project_info.append( - ("@upstream", dct_value.get("@upstream")) - ) - if "@dest-branch" in dct_value: - lst_project_info.append( - ("@dest-branch", dct_value.get("@dest-branch")) + if "@groups" in entry: + project_attrs.append(("@groups", entry.get("@groups"))) + if "@upstream" in entry: + project_attrs.append(("@upstream", entry.get("@upstream"))) + if "@dest-branch" in entry: + project_attrs.append( + ("@dest-branch", entry.get("@dest-branch")) ) - project_entries.append(OrderedDict(lst_project_info)) - project_names.append(dct_value.get("@name")) + project_entries.append(OrderedDict(project_attrs)) + project_names.append(entry.get("@name")) for repo in repo_list: if not repo.is_submodule: @@ -559,10 +557,7 @@ class GitTool: ) ) else: - if ( - keep_original - and repo.project_name not in projects_config - ): + if keep_original and repo.project_name not in projects_config: # Exception, create a new remote to keep tracking on original original_organization = ( f"{repo.original_organization}_origin" @@ -583,22 +578,22 @@ class GitTool: # Add project, only unique project if repo.project_name not in project_names: project_names.append(repo.project_name) - lst_project_info = [ + project_attrs = [ ("@name", repo.project_name), ("@path", repo.path), ("@remote", original_organization), ] if repo.revision: - lst_project_info.append(("@revision", repo.revision)) + project_attrs.append(("@revision", repo.revision)) if repo.clone_depth: - lst_project_info.append( + project_attrs.append( ("@clone-depth", repo.clone_depth) ) if repo.sub_path == "addons": - lst_project_info.append(("@groups", "addons")) + project_attrs.append(("@groups", "addons")) else: - lst_project_info.append(("@groups", "odoo")) - project_entries.append(OrderedDict(lst_project_info)) + project_attrs.append(("@groups", "odoo")) + project_entries.append(OrderedDict(project_attrs)) if default_remote and not default_entries: default_entries.append( @@ -613,12 +608,15 @@ class GitTool: ) # Order in alphabetic - sorted_remotes = sorted(remote_entries, key=lambda key: key.get("@name")) + sorted_remotes = sorted( + remote_entries, key=lambda key: key.get("@name") + ) sorted_defaults = sorted( default_entries, key=lambda key: key.get("@remote") ) sorted_projects = sorted( - project_entries, key=lambda key: key.get("@name") + key.get("@path") + project_entries, + key=lambda key: key.get("@name") + key.get("@path"), ) manifest_dict = OrderedDict( @@ -817,9 +815,7 @@ class GitTool: name = f"{repo_name}" repo_info["name"] = name - compare_by_name = { - a.get("name"): a for a in compare_repos - } + compare_by_name = {a.get("name"): a for a in compare_repos} set_compare = set(compare_by_name.keys()) same_names = set_actual_repo.intersection(set_compare) @@ -833,9 +829,7 @@ class GitTool: matches = [] for key in same_names: - matches.append( - (actual_adapted[key], compare_by_name[key]) - ) + matches.append((actual_adapted[key], compare_by_name[key])) return matches, missing_names, extra_names diff --git a/script/todo/todo.py b/script/todo/todo.py index 1bf468c..16cc221 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1519,7 +1519,7 @@ class TODO: self.dir_path = dir_path todo_file_browser.exit_program() - def callback_make_mobile_home(self, dct_config): + def callback_make_mobile_home(self, config): # Read file default_project_name = "ERPLibre" default_package_name = "ca.erplibre.home" From 8083c8010e6a0d343e5123f29a02fa9e5f34a49b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 10 Mar 2026 04:23:27 -0400 Subject: [PATCH 14/24] [REF] script: improve variable naming conventions Remove Hungarian notation prefixes (lst_, dct_) from variable names in git_repo_manifest, git_repo_update_group, and their tests for better readability and consistency with the codebase refactoring effort. Generated by Claude Code 2.1.72 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- script/git/git_repo_manifest.py | 18 +++++++++--------- script/git/git_repo_update_group.py | 14 +++++++------- test/test_git_tool.py | 18 +++++++++--------- test/test_todo.py | 18 +++++++++--------- 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/script/git/git_repo_manifest.py b/script/git/git_repo_manifest.py index 47f030a..06b602f 100755 --- a/script/git/git_repo_manifest.py +++ b/script/git/git_repo_manifest.py @@ -67,10 +67,10 @@ def main(): config = get_config() git_tool = GitTool() - lst_repo = git_tool.get_source_repo_addons( + repos = git_tool.get_source_repo_addons( repo_path=config.dir, add_repo_root=True ) - lst_repo_organization = [ + repo_list = [ git_tool.get_transformed_repo_info_from_url( a.get("url"), repo_path=config.dir, @@ -80,25 +80,25 @@ def main(): revision=a.get("revision"), clone_depth=a.get("clone_depth"), ) - for a in lst_repo + for a in repos ] # Update origin to new repo if not config.clear: - dct_remote, dct_project, _ = git_tool.get_manifest_xml_info( + remotes, projects, _ = git_tool.get_manifest_xml_info( repo_path=config.dir, add_root=True ) else: - dct_remote = {} - dct_project = {} + remotes = {} + projects = {} kwargs = {} if config.default_branch: kwargs["default_branch"] = config.default_branch git_tool.generate_repo_manifest( - lst_repo_organization, + repo_list, output=f"{config.dir}{config.manifest}", - remotes_config=dct_remote, - projects_config=dct_project, + remotes_config=remotes, + projects_config=projects, keep_original=config.keep_origin, **kwargs, ) diff --git a/script/git/git_repo_update_group.py b/script/git/git_repo_update_group.py index f3faa0f..782661a 100755 --- a/script/git/git_repo_update_group.py +++ b/script/git/git_repo_update_group.py @@ -75,7 +75,7 @@ def main(): filter_group = config.group if config.group else None - lst_whitelist = [] + whitelist = [] if config.from_backup_path or config.from_backup_name: # script/database/get_repo_from_backup.py # --backup_name bpir_prod_5_dec_2025_2026-02-04_14h27m54s.zip @@ -92,7 +92,7 @@ def main(): ) for line in output: repo_name = line[index_to_remove:].strip() - lst_whitelist.append(repo_name) + whitelist.append(repo_name) if config.database: cmd = f"./script/database/get_module_list_from_database.py --database {config.database}" @@ -114,19 +114,19 @@ def main(): ) for line in output: repo_name = line[index_to_remove:].strip() - lst_whitelist.append(repo_name) + whitelist.append(repo_name) if config.add_repo: - lst_add_repo = [a.strip() for a in config.add_repo.split(";")] + extra_repos = [a.strip() for a in config.add_repo.split(";")] else: - lst_add_repo = [] + extra_repos = [] git_tool.generate_generate_config( filter_group=filter_group, extra_path=config.extra_addons_path, ignore_odoo_path=config.ignore_odoo_path, - add_repos=lst_add_repo, - whitelist=lst_whitelist, + add_repos=extra_repos, + whitelist=whitelist, ) diff --git a/test/test_git_tool.py b/test/test_git_tool.py index 5580006..95d6198 100644 --- a/test/test_git_tool.py +++ b/test/test_git_tool.py @@ -13,9 +13,9 @@ from script.git.git_tool import ( DEFAULT_REMOTE_URL, DEFAULT_WEBSITE, EL_GITHUB_TOKEN, + SOURCE_REPO_ADDONS_FILE, GitTool, RepoAttrs, - SOURCE_REPO_ADDONS_FILE, ) @@ -282,11 +282,11 @@ class TestGetManifestXmlInfo(unittest.TestCase): f.write(xml_content) f.flush() try: - dct_remote, dct_project, default_remote = ( - gt.get_manifest_xml_info(filename=f.name) + remotes, projects, default_remote = gt.get_manifest_xml_info( + filename=f.name ) - self.assertIn("OCA", dct_remote) - self.assertIn("server-tools.git", dct_project) + self.assertIn("OCA", remotes) + self.assertIn("server-tools.git", projects) self.assertEqual(default_remote["@remote"], "OCA") finally: os.unlink(f.name) @@ -302,11 +302,11 @@ class TestGetManifestXmlInfo(unittest.TestCase): f.write(xml_content) f.flush() try: - dct_remote, dct_project, default_remote = ( - gt.get_manifest_xml_info(filename=f.name) + remotes, projects, default_remote = gt.get_manifest_xml_info( + filename=f.name ) - self.assertEqual(dct_remote, {}) - self.assertEqual(dct_project, {}) + self.assertEqual(remotes, {}) + self.assertEqual(projects, {}) self.assertIsNone(default_remote) finally: os.unlink(f.name) diff --git a/test/test_todo.py b/test/test_todo.py index 5c94825..2a460c9 100644 --- a/test/test_todo.py +++ b/test/test_todo.py @@ -49,11 +49,11 @@ class TestFillHelpInfo(unittest.TestCase): "command": "Command:", "back": "Back", }.get(k, k) - lst_choice = [ + choices = [ {"prompt_description": "Option A"}, {"prompt_description": "Option B"}, ] - result = self.todo.fill_help_info(lst_choice) + result = self.todo.fill_help_info(choices) self.assertIn("[1] Option A", result) self.assertIn("[2] Option B", result) self.assertIn("[0] Back", result) @@ -65,13 +65,13 @@ class TestFillHelpInfo(unittest.TestCase): "back": "Back", "my_key": "Translated Description", }.get(k, k) - lst_choice = [ + choices = [ { "prompt_description": "fallback", "prompt_description_key": "my_key", }, ] - result = self.todo.fill_help_info(lst_choice) + result = self.todo.fill_help_info(choices) self.assertIn("[1] Translated Description", result) @patch("script.todo.todo.t") @@ -120,12 +120,12 @@ class TestGetOdooVersion(unittest.TestCase): "script.todo.version_manager.ODOO_VERSION_FILE", odoo_version_file, ): - lst_version, lst_installed, odoo_current = get_odoo_version() + versions, installed, odoo_current = get_odoo_version() - self.assertEqual(len(lst_version), 2) + self.assertEqual(len(versions), 2) self.assertEqual(odoo_current, "odoo18.0") # Check erplibre_version was added - names = [v["erplibre_version"] for v in lst_version] + names = [v["erplibre_version"] for v in versions] self.assertIn("odoo18.0_python3.12.10", names) self.assertIn("odoo16.0_python3.10.18", names) @@ -156,9 +156,9 @@ class TestGetOdooVersion(unittest.TestCase): "script.todo.version_manager.ODOO_VERSION_FILE", os.path.join(tmpdir, "nonexistent"), ): - lst_version, lst_installed, odoo_current = get_odoo_version() + versions, installed, odoo_current = get_odoo_version() - self.assertEqual(lst_installed, ["odoo16.0", "odoo18.0"]) + self.assertEqual(installed, ["odoo16.0", "odoo18.0"]) self.assertIsNone(odoo_current) def test_no_version_data_raises(self): From 43fd5a17aeedabfada12a314b7c16ad656872143 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 11 Mar 2026 02:17:07 -0400 Subject: [PATCH 15/24] [REF] script: use Execute helper in git daemon Replace raw subprocess.Popen and manual signal handling with Execute.exec_command_live for consistency with other refactored scripts. Generated by Claude Code 2.1.72 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- script/git/git_local_server.py | 35 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/script/git/git_local_server.py b/script/git/git_local_server.py index c55a5ef..5709cc3 100755 --- a/script/git/git_local_server.py +++ b/script/git/git_local_server.py @@ -5,11 +5,17 @@ import argparse import logging import os -import signal import subprocess import sys import xml.etree.ElementTree as ET +new_path = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..") +) +sys.path.append(new_path) + +from script.execute.execute import Execute + _logger = logging.getLogger(__name__) DEFAULT_ERPLIBRE_PATH = os.path.normpath( @@ -529,28 +535,21 @@ def serve_git_daemon(git_path, projects, port): """Start git daemon to serve repos.""" print_clone_commands(git_path, projects, port) - cmd = [ - "git", - "daemon", - "--reuseaddr", - f"--base-path={git_path}", - f"--port={port}", - "--export-all", - "--enable=receive-pack", - git_path, - ] + cmd = ( + f"git daemon --reuseaddr" + f" --base-path={git_path}" + f" --port={port}" + f" --export-all" + f" --enable=receive-pack" + f" {git_path}" + ) print(f"Starting git daemon on port {port}...") print(f" Base path: {git_path}") print(f" URL: git://localhost:{port}/") print(" Press Ctrl+C to stop") - try: - process = subprocess.Popen(cmd) - process.wait() - except KeyboardInterrupt: - print("\nStopping git daemon...") - process.send_signal(signal.SIGTERM) - process.wait() + execute = Execute() + execute.exec_command_live(cmd, source_erplibre=False) def main(): From 291c9cf5e00e998af9c5d4e8506fa1c9d343edb2 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 11 Mar 2026 02:27:39 -0400 Subject: [PATCH 16/24] [IMP] script: add erplibre root repo to git server The root repo (erplibre/erplibre) was missing from the local git server because it is not in the Google Repo manifest. Include it so a full clone of the platform is possible from the local server across all 4 actions. Generated by Claude Code 2.1.72 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- script/git/git_local_server.py | 44 +++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/script/git/git_local_server.py b/script/git/git_local_server.py index 5709cc3..3f767bc 100755 --- a/script/git/git_local_server.py +++ b/script/git/git_local_server.py @@ -26,6 +26,8 @@ PRODUCTION_GIT_PATH = "/srv/git" DEFAULT_MANIFEST = ".repo/local_manifests/erplibre_manifest.xml" DEFAULT_REMOTE_NAME = "local" DEFAULT_PORT = 9418 +ERPLIBRE_REPO_NAME = "erplibre/erplibre" +ERPLIBRE_REPO_URL = "https://github.com/erplibre" def get_config(): @@ -178,6 +180,40 @@ def parse_manifest(manifest_path): return projects +def get_erplibre_root_project(erplibre_root): + """Build a project entry for the ERPLibre root repo. + + The root repo is not in the manifest (it is managed + by git directly, not Google Repo) but needs to be + included in the local git server. + """ + result = subprocess.run( + [ + "git", + "-C", + erplibre_root, + "rev-parse", + "--abbrev-ref", + "HEAD", + ], + capture_output=True, + text=True, + ) + revision = ( + result.stdout.strip() + if result.returncode == 0 + else "master" + ) + + return { + "name": ERPLIBRE_REPO_NAME, + "path": ".", + "remote": "origin", + "revision": revision, + "fetch_url": ERPLIBRE_REPO_URL, + } + + def init_bare_repos(git_path, projects): """Create bare repos for all projects in the manifest.""" os.makedirs(git_path, exist_ok=True) @@ -588,7 +624,13 @@ def main(): print() projects = parse_manifest(manifest_path) - print(f"Found {len(projects)} projects in manifest") + erplibre_project = get_erplibre_root_project(erplibre_root) + projects.append(erplibre_project) + print( + f"Found {len(projects)} projects" + f" ({len(projects) - 1} from manifest" + f" + erplibre root)" + ) print() action = config.action From 24bcb3a7d36303a920d5a1711bbf494b4eab5e9c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 11 Mar 2026 02:56:30 -0400 Subject: [PATCH 17/24] [IMP] script: async init/remote/push in git server Sequential processing of 100+ repos was slow. Use asyncio with semaphore-bounded concurrency to run init, remote and push actions in parallel. New -j flag controls max parallel jobs (default: 8). Generated by Claude Code 2.1.72 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- script/git/git_local_server.py | 525 +++++++++++++++++++++------------ 1 file changed, 334 insertions(+), 191 deletions(-) diff --git a/script/git/git_local_server.py b/script/git/git_local_server.py index 3f767bc..cac9444 100755 --- a/script/git/git_local_server.py +++ b/script/git/git_local_server.py @@ -3,6 +3,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse +import asyncio import logging import os import subprocess @@ -26,10 +27,38 @@ PRODUCTION_GIT_PATH = "/srv/git" DEFAULT_MANIFEST = ".repo/local_manifests/erplibre_manifest.xml" DEFAULT_REMOTE_NAME = "local" DEFAULT_PORT = 9418 +DEFAULT_JOBS = 8 ERPLIBRE_REPO_NAME = "erplibre/erplibre" ERPLIBRE_REPO_URL = "https://github.com/erplibre" +async def _run_git(*args, cwd=None, timeout=None): + """Run a git command asynchronously. + + Returns (stdout, stderr, returncode). + Raises asyncio.TimeoutError on timeout. + """ + process = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + ) + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(), timeout=timeout + ) + return ( + stdout.decode(), + stderr.decode(), + process.returncode, + ) + except asyncio.TimeoutError: + process.kill() + await process.communicate() + raise + + def get_config(): parser = argparse.ArgumentParser( formatter_class=argparse.RawDescriptionHelpFormatter, @@ -107,6 +136,16 @@ Use --production-ready for /srv/git (requires root). " uses git:// protocol (default: file)" ), ) + parser.add_argument( + "-j", + "--jobs", + type=int, + default=DEFAULT_JOBS, + help=( + "Parallel jobs for init/remote/push" + f" (default: {DEFAULT_JOBS})" + ), + ) parser.add_argument( "-v", "--verbose", @@ -214,13 +253,12 @@ def get_erplibre_root_project(erplibre_root): } -def init_bare_repos(git_path, projects): - """Create bare repos for all projects in the manifest.""" - os.makedirs(git_path, exist_ok=True) +# --- Async workers for init --- - created = 0 - skipped = 0 - for project in projects: + +async def _init_single_bare_repo(git_path, project, semaphore): + """Create a single bare repo (async worker).""" + async with semaphore: repo_name = project["name"] if not repo_name.endswith(".git"): repo_name += ".git" @@ -228,23 +266,50 @@ def init_bare_repos(git_path, projects): if os.path.exists(bare_path): _logger.info(f" Exists: {bare_path}") - skipped += 1 - continue + return "skipped" _logger.info(f" Creating: {bare_path}") - subprocess.run( - ["git", "init", "--bare", bare_path], - check=True, - capture_output=True, + _, err, rc = await _run_git( + "git", "init", "--bare", bare_path ) + if rc != 0: + _logger.warning( + f" Init failed: {bare_path}: {err.strip()}" + ) + return "error" # Enable git daemon export - export_file = os.path.join(bare_path, "git-daemon-export-ok") + export_file = os.path.join( + bare_path, "git-daemon-export-ok" + ) open(export_file, "w").close() - created += 1 + return "created" - print(f"Bare repos: {created} created, {skipped} skipped (already exist)") + +async def init_bare_repos(git_path, projects, jobs): + """Create bare repos for all projects (async).""" + os.makedirs(git_path, exist_ok=True) + semaphore = asyncio.Semaphore(jobs) + + results = await asyncio.gather( + *[ + _init_single_bare_repo(git_path, p, semaphore) + for p in projects + ] + ) + + created = results.count("created") + skipped = results.count("skipped") + errors = results.count("error") + print( + f"Bare repos: {created} created," + f" {skipped} skipped (already exist)," + f" {errors} errors" + ) + + +# --- Async workers for remote --- def build_remote_url(git_path, repo_name, port, remote_url_type): @@ -257,30 +322,23 @@ def build_remote_url(git_path, repo_name, port, remote_url_type): return os.path.join(git_path, repo_name) -def add_remotes( +async def _add_single_remote( erplibre_root, git_path, - projects, + project, remote_name, port, remote_url_type, + semaphore, ): - """Add local remote to each repo. - - remote_url_type='file': uses local path, push works - without daemon running. - remote_url_type='daemon': uses git://localhost URL, - requires daemon for push. - """ - added = 0 - skipped = 0 - errors = 0 - for project in projects: - repo_path = os.path.join(erplibre_root, project["path"]) + """Add or update remote for a single repo (async).""" + async with semaphore: + repo_path = os.path.join( + erplibre_root, project["path"] + ) if not os.path.isdir(repo_path): _logger.warning(f" Not found: {repo_path}") - errors += 1 - continue + return "error" repo_name = project["name"] if not repo_name.endswith(".git"): @@ -291,106 +349,139 @@ def add_remotes( ) # Check if remote already exists - result = subprocess.run( - ["git", "-C", repo_path, "remote"], - capture_output=True, - text=True, + stdout, _, _ = await _run_git( + "git", "-C", repo_path, "remote" ) - existing_remotes = result.stdout.strip().split("\n") + existing_remotes = stdout.strip().split("\n") if remote_name in existing_remotes: - # Update URL if remote exists - subprocess.run( - [ - "git", - "-C", - repo_path, - "remote", - "set-url", - remote_name, - remote_url, - ], - check=True, - capture_output=True, + _, err, rc = await _run_git( + "git", + "-C", + repo_path, + "remote", + "set-url", + remote_name, + remote_url, ) + if rc != 0: + _logger.warning( + f" set-url failed for" + f" {project['path']}: {err.strip()}" + ) + return "error" _logger.info(f" Updated: {project['path']}") - skipped += 1 + return "updated" else: - subprocess.run( - [ - "git", - "-C", - repo_path, - "remote", - "add", - remote_name, - remote_url, - ], - check=True, - capture_output=True, + _, err, rc = await _run_git( + "git", + "-C", + repo_path, + "remote", + "add", + remote_name, + remote_url, ) + if rc != 0: + _logger.warning( + f" add failed for" + f" {project['path']}: {err.strip()}" + ) + return "error" _logger.info(f" Added: {project['path']}") - added += 1 - - print(f"Remotes: {added} added, {skipped} updated," f" {errors} errors") + return "added" -def is_detached_head(repo_path): - """Check if a repo is in detached HEAD state.""" - result = subprocess.run( - ["git", "-C", repo_path, "symbolic-ref", "HEAD"], - capture_output=True, - text=True, +async def add_remotes( + erplibre_root, + git_path, + projects, + remote_name, + port, + remote_url_type, + jobs, +): + """Add local remote to each repo (async). + + remote_url_type='file': uses local path, push works + without daemon running. + remote_url_type='daemon': uses git://localhost URL, + requires daemon for push. + """ + semaphore = asyncio.Semaphore(jobs) + + results = await asyncio.gather( + *[ + _add_single_remote( + erplibre_root, + git_path, + p, + remote_name, + port, + remote_url_type, + semaphore, + ) + for p in projects + ] + ) + + added = results.count("added") + updated = results.count("updated") + errors = results.count("error") + print( + f"Remotes: {added} added, {updated} updated," + f" {errors} errors" ) - return result.returncode != 0 -def checkout_manifest_branch(repo_path, revision): +# --- Async workers for push --- + + +async def _is_detached_head(repo_path): + """Check if a repo is in detached HEAD state.""" + _, _, rc = await _run_git( + "git", "-C", repo_path, "symbolic-ref", "HEAD" + ) + return rc != 0 + + +async def _checkout_manifest_branch(repo_path, revision): """Checkout the branch from the manifest revision. When Google Repo syncs, repos end up in detached HEAD on the exact commit. We create/checkout a local branch matching the manifest revision so git push works. """ - # Extract branch name — revision can be - # "18.0", "18.0_dev", "ERPLibre/18.0", "main", etc. - branch = revision.split("/")[-1] if "/" in revision else revision + branch = ( + revision.split("/")[-1] if "/" in revision else revision + ) # Check if local branch already exists - result = subprocess.run( - [ + _, _, rc = await _run_git( + "git", + "-C", + repo_path, + "show-ref", + "--verify", + f"refs/heads/{branch}", + ) + if rc == 0: + await _run_git( + "git", "-C", repo_path, "checkout", branch + ) + else: + await _run_git( "git", "-C", repo_path, - "show-ref", - "--verify", - f"refs/heads/{branch}", - ], - capture_output=True, - ) - if result.returncode == 0: - # Branch exists, checkout it - subprocess.run( - ["git", "-C", repo_path, "checkout", branch], - capture_output=True, - ) - else: - # Create branch from current HEAD - subprocess.run( - [ - "git", - "-C", - repo_path, - "checkout", - "-b", - branch, - ], - capture_output=True, + "checkout", + "-b", + branch, ) return branch -def update_bare_head(git_path, project): +async def _update_bare_head(git_path, project): """Update the HEAD of the bare repo to point to the manifest branch, so git clone checks out the right branch by default.""" @@ -404,75 +495,74 @@ def update_bare_head(git_path, project): revision = project.get("revision", "") if not revision: return - branch = revision.split("/")[-1] if "/" in revision else revision - subprocess.run( - [ - "git", - "-C", - bare_path, - "symbolic-ref", - "HEAD", - f"refs/heads/{branch}", - ], - capture_output=True, + branch = ( + revision.split("/")[-1] if "/" in revision else revision + ) + await _run_git( + "git", + "-C", + bare_path, + "symbolic-ref", + "HEAD", + f"refs/heads/{branch}", ) -def push_to_local( +async def _push_single_repo( erplibre_root, git_path, - projects, + project, remote_name, push_all_branches, + semaphore, ): - """Push to local remote for each repo.""" - pushed = 0 - errors = 0 - checkouts = 0 - for project in projects: - repo_path = os.path.join(erplibre_root, project["path"]) + """Push a single repo to local remote (async).""" + async with semaphore: + repo_path = os.path.join( + erplibre_root, project["path"] + ) if not os.path.isdir(repo_path): _logger.warning(f" Not found: {repo_path}") - errors += 1 - continue + return "error", False # Unshallow if needed (clone-depth in manifest) - shallow_file = os.path.join(repo_path, ".git", "shallow") + shallow_file = os.path.join( + repo_path, ".git", "shallow" + ) if os.path.exists(shallow_file): - _logger.info(f" Unshallowing {project['path']}...") - # Try each remote until unshallow succeeds - result = subprocess.run( - ["git", "-C", repo_path, "remote"], - capture_output=True, - text=True, + _logger.info( + f" Unshallowing {project['path']}..." + ) + stdout, _, _ = await _run_git( + "git", "-C", repo_path, "remote" ) remotes = [ r - for r in result.stdout.strip().split("\n") + for r in stdout.strip().split("\n") if r and r != remote_name ] - # Try the manifest remote first manifest_remote = project.get("remote", "") if manifest_remote in remotes: remotes.remove(manifest_remote) remotes.insert(0, manifest_remote) for try_remote in remotes: - result = subprocess.run( - [ + try: + await _run_git( "git", "-C", repo_path, "fetch", "--unshallow", try_remote, - ], - capture_output=True, - text=True, - timeout=300, - ) + timeout=300, + ) + except asyncio.TimeoutError: + continue if not os.path.exists(shallow_file): - _logger.info(f" Unshallowed via" f" {try_remote}") + _logger.info( + f" Unshallowed via {try_remote}" + ) break else: if os.path.exists(shallow_file): @@ -483,24 +573,27 @@ def push_to_local( ) # Handle detached HEAD: checkout manifest branch - if is_detached_head(repo_path): + did_checkout = False + if await _is_detached_head(repo_path): revision = project.get("revision", "") if revision: - branch = checkout_manifest_branch(repo_path, revision) + branch = await _checkout_manifest_branch( + repo_path, revision + ) _logger.info( f" Checkout {branch} for" f" {project['path']}" " (was detached HEAD)" ) - checkouts += 1 + did_checkout = True else: _logger.warning( f" Detached HEAD with no revision" f" for {project['path']}, skipping" ) - errors += 1 - continue + return "error", False + # Push try: if push_all_branches: cmd = [ @@ -519,33 +612,66 @@ def push_to_local( "push", remote_name, ] - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=120, + _, err, rc = await _run_git( + *cmd, timeout=120 ) - if result.returncode != 0: + if rc != 0: _logger.warning( f" Push failed for" f" {project['path']}:" - f" {result.stderr.strip()}" + f" {err.strip()}" ) - errors += 1 + return "error", did_checkout else: - update_bare_head(git_path, project) - _logger.info(f" Pushed: {project['path']}") - pushed += 1 - except subprocess.TimeoutExpired: - _logger.warning(f" Timeout pushing {project['path']}") - errors += 1 + await _update_bare_head(git_path, project) + _logger.info( + f" Pushed: {project['path']}" + ) + return "pushed", did_checkout + except asyncio.TimeoutError: + _logger.warning( + f" Timeout pushing {project['path']}" + ) + return "error", did_checkout + +async def push_to_local( + erplibre_root, + git_path, + projects, + remote_name, + push_all_branches, + jobs, +): + """Push to local remote for each repo (async).""" + semaphore = asyncio.Semaphore(jobs) + + results = await asyncio.gather( + *[ + _push_single_repo( + erplibre_root, + git_path, + p, + remote_name, + push_all_branches, + semaphore, + ) + for p in projects + ] + ) + + pushed = sum(1 for s, _ in results if s == "pushed") + errors = sum(1 for s, _ in results if s == "error") + checkouts = sum(1 for _, c in results if c) print( f"Push: {pushed} pushed, {checkouts} branch" f" checkouts, {errors} errors" ) +# --- Serve (stays synchronous — long-running daemon) --- + + def print_clone_commands(git_path, projects, port): """Print git clone commands for all available repos.""" print("=== Available repos to clone ===") @@ -561,7 +687,10 @@ def print_clone_commands(git_path, projects, port): repo_name += ".git" bare_path = os.path.join(git_path, repo_name) if os.path.isdir(bare_path): - print(f" git clone {base_url}/{repo_name}" f" {project['path']}") + print( + f" git clone {base_url}/{repo_name}" + f" {project['path']}" + ) count += 1 print(f"\nTotal: {count} repos available") print() @@ -588,6 +717,45 @@ def serve_git_daemon(git_path, projects, port): execute.exec_command_live(cmd, source_erplibre=False) +# --- Main --- + + +async def run_actions(config, erplibre_root, projects): + """Run init/remote/push actions asynchronously.""" + action = config.action + jobs = config.jobs + + if action in ("init", "all"): + print("=== Creating bare repos ===") + await init_bare_repos(config.path, projects, jobs) + print() + + if action in ("remote", "all"): + print("=== Adding remotes ===") + await add_remotes( + erplibre_root, + config.path, + projects, + config.remote_name, + config.port, + config.remote_url_type, + jobs, + ) + print() + + if action in ("push", "all"): + print("=== Pushing to local ===") + await push_to_local( + erplibre_root, + config.path, + projects, + config.remote_name, + config.push_all_branches, + jobs, + ) + print() + + def main(): config = get_config() @@ -621,6 +789,7 @@ def main(): else: print("Mode: development (~/.git-server)") print(f"Remote URL type: {config.remote_url_type}") + print(f"Parallel jobs: {config.jobs}") print() projects = parse_manifest(manifest_path) @@ -633,37 +802,11 @@ def main(): ) print() - action = config.action + # Run async actions (init, remote, push) + asyncio.run(run_actions(config, erplibre_root, projects)) - if action in ("init", "all"): - print("=== Creating bare repos ===") - init_bare_repos(config.path, projects) - print() - - if action in ("remote", "all"): - print("=== Adding remotes ===") - add_remotes( - erplibre_root, - config.path, - projects, - config.remote_name, - config.port, - config.remote_url_type, - ) - print() - - if action in ("push", "all"): - print("=== Pushing to local ===") - push_to_local( - erplibre_root, - config.path, - projects, - config.remote_name, - config.push_all_branches, - ) - print() - - if action in ("serve", "all"): + # Serve is synchronous (long-running daemon) + if config.action in ("serve", "all"): print("=== Starting git daemon ===") serve_git_daemon(config.path, projects, config.port) From 84a021258e2f7919abca6b583563997cb70ee9b5 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 11 Mar 2026 03:39:14 -0400 Subject: [PATCH 18/24] [IMP] script: shallow push by default in git server Repos like odoo and OpenUpgrade blocked for 5+ minutes trying to unshallow from GitHub (~3GB). Push shallow by default with receive.shallowUpdate on bare repos. Add --unshallow flag to opt into full history fetch. Generated by Claude Code 2.1.72 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- script/git/git_local_server.py | 130 +++++++++++++++++++++++---------- 1 file changed, 92 insertions(+), 38 deletions(-) diff --git a/script/git/git_local_server.py b/script/git/git_local_server.py index cac9444..8403096 100755 --- a/script/git/git_local_server.py +++ b/script/git/git_local_server.py @@ -136,6 +136,15 @@ Use --production-ready for /srv/git (requires root). " uses git:// protocol (default: file)" ), ) + parser.add_argument( + "--unshallow", + action="store_true", + help=( + "Fetch full history for shallow repos before" + " push (slow for large repos like odoo)." + " Default: push shallow for performance" + ), + ) parser.add_argument( "-j", "--jobs", @@ -284,6 +293,16 @@ async def _init_single_bare_repo(git_path, project, semaphore): ) open(export_file, "w").close() + # Allow pushing from shallow clones + await _run_git( + "git", + "-C", + bare_path, + "config", + "receive.shallowUpdate", + "true", + ) + return "created" @@ -508,12 +527,60 @@ async def _update_bare_head(git_path, project): ) +async def _try_unshallow(repo_path, project, remote_name): + """Try to unshallow a repo by fetching full history.""" + shallow_file = os.path.join( + repo_path, ".git", "shallow" + ) + _logger.info( + f" Unshallowing {project['path']}..." + ) + stdout, _, _ = await _run_git( + "git", "-C", repo_path, "remote" + ) + remotes = [ + r + for r in stdout.strip().split("\n") + if r and r != remote_name + ] + manifest_remote = project.get("remote", "") + if manifest_remote in remotes: + remotes.remove(manifest_remote) + remotes.insert(0, manifest_remote) + + for try_remote in remotes: + try: + await _run_git( + "git", + "-C", + repo_path, + "fetch", + "--unshallow", + try_remote, + timeout=300, + ) + except asyncio.TimeoutError: + continue + if not os.path.exists(shallow_file): + _logger.info( + f" Unshallowed via {try_remote}" + ) + return + if os.path.exists(shallow_file): + _logger.warning( + f" Unshallow failed for" + f" {project['path']}," + " falling back to shallow push" + ) + + async def _push_single_repo( erplibre_root, git_path, project, remote_name, push_all_branches, + unshallow, semaphore, ): """Push a single repo to local remote (async).""" @@ -525,52 +592,36 @@ async def _push_single_repo( _logger.warning(f" Not found: {repo_path}") return "error", False - # Unshallow if needed (clone-depth in manifest) + # Handle shallow repos shallow_file = os.path.join( repo_path, ".git", "shallow" ) if os.path.exists(shallow_file): - _logger.info( - f" Unshallowing {project['path']}..." - ) - stdout, _, _ = await _run_git( - "git", "-C", repo_path, "remote" - ) - remotes = [ - r - for r in stdout.strip().split("\n") - if r and r != remote_name - ] - manifest_remote = project.get("remote", "") - if manifest_remote in remotes: - remotes.remove(manifest_remote) - remotes.insert(0, manifest_remote) - - for try_remote in remotes: - try: + if unshallow: + await _try_unshallow( + repo_path, project, remote_name + ) + else: + # Enable shallow push on bare repo + repo_name = project["name"] + if not repo_name.endswith(".git"): + repo_name += ".git" + bare_path = os.path.join( + git_path, repo_name + ) + if os.path.isdir(bare_path): await _run_git( "git", "-C", - repo_path, - "fetch", - "--unshallow", - try_remote, - timeout=300, - ) - except asyncio.TimeoutError: - continue - if not os.path.exists(shallow_file): - _logger.info( - f" Unshallowed via {try_remote}" - ) - break - else: - if os.path.exists(shallow_file): - _logger.warning( - f" Unshallow failed for" - f" {project['path']}," - " push may fail" + bare_path, + "config", + "receive.shallowUpdate", + "true", ) + _logger.info( + f" Shallow push for" + f" {project['path']}" + ) # Handle detached HEAD: checkout manifest branch did_checkout = False @@ -641,6 +692,7 @@ async def push_to_local( projects, remote_name, push_all_branches, + unshallow, jobs, ): """Push to local remote for each repo (async).""" @@ -654,6 +706,7 @@ async def push_to_local( p, remote_name, push_all_branches, + unshallow, semaphore, ) for p in projects @@ -751,6 +804,7 @@ async def run_actions(config, erplibre_root, projects): projects, config.remote_name, config.push_all_branches, + config.unshallow, jobs, ) print() From d7d72db7bc62669bddd43c13a7ff78df7410cf54 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 11 Mar 2026 03:44:22 -0400 Subject: [PATCH 19/24] [FIX] script: show erplibre clone path clearly The root repo clone command showed "." as target path which was confusing and easy to miss in the list. Display "erplibre" instead for clarity. Generated by Claude Code 2.1.72 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- script/git/git_local_server.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/script/git/git_local_server.py b/script/git/git_local_server.py index 8403096..3b51b19 100755 --- a/script/git/git_local_server.py +++ b/script/git/git_local_server.py @@ -740,9 +740,12 @@ def print_clone_commands(git_path, projects, port): repo_name += ".git" bare_path = os.path.join(git_path, repo_name) if os.path.isdir(bare_path): + clone_path = project["path"] + if clone_path == ".": + clone_path = "erplibre" print( f" git clone {base_url}/{repo_name}" - f" {project['path']}" + f" {clone_path}" ) count += 1 print(f"\nTotal: {count} repos available") From 4ea143dd36f70f14a08c7e66ad35e95019ef9f68 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 11 Mar 2026 03:47:59 -0400 Subject: [PATCH 20/24] [IMP] script: sort clone list alphabetically The erplibre root repo was appended last and hard to find among 141 entries. Sort the list so repos are easy to locate by name. Generated by Claude Code 2.1.72 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- script/git/git_local_server.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/script/git/git_local_server.py b/script/git/git_local_server.py index 3b51b19..7fcc584 100755 --- a/script/git/git_local_server.py +++ b/script/git/git_local_server.py @@ -733,7 +733,7 @@ def print_clone_commands(git_path, projects, port): if port != DEFAULT_PORT else "git://localhost" ) - count = 0 + lines = [] for project in projects: repo_name = project["name"] if not repo_name.endswith(".git"): @@ -743,12 +743,14 @@ def print_clone_commands(git_path, projects, port): clone_path = project["path"] if clone_path == ".": clone_path = "erplibre" - print( + lines.append( f" git clone {base_url}/{repo_name}" f" {clone_path}" ) - count += 1 - print(f"\nTotal: {count} repos available") + lines.sort() + for line in lines: + print(line) + print(f"\nTotal: {len(lines)} repos available") print() From 438edc3694ca8ead793cb377cdbd700fbf0fccd5 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 11 Mar 2026 04:03:25 -0400 Subject: [PATCH 21/24] [IMP] script: sort execute menu alphabetically Menu items were in arbitrary order making it hard to find specific tools as the list grew to 14 entries. Generated by Claude Code 2.1.72 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- script/todo/todo.py | 88 ++++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 16cc221..86451d0 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -197,20 +197,20 @@ class TODO: def prompt_execute(self): help_info = f"""{t("command")} -[1] {t("menu_run")} -[2] {t("menu_automation")} -[3] {t("menu_update")} -[4] {t("menu_code")} +[1] {t("menu_automation")} +[2] {t("menu_code")} +[3] {t("menu_config")} +[4] {t("menu_database")} [5] {t("menu_doc")} -[6] {t("menu_database")} -[7] {t("menu_git")} -[8] {t("menu_process")} -[9] {t("menu_config")} -[10] {t("menu_network")} -[11] {t("menu_security")} -[12] {t("menu_test")} -[13] {t("menu_lang")} -[14] {t("menu_gpt_code")} +[6] {t("menu_git")} +[7] {t("menu_gpt_code")} +[8] {t("menu_lang")} +[9] {t("menu_network")} +[10] {t("menu_process")} +[11] {t("menu_run")} +[12] {t("menu_security")} +[13] {t("menu_test")} +[14] {t("menu_update")} [0] {t("back")} """ while True: @@ -219,19 +219,19 @@ class TODO: if status == "0": return elif status == "1": - status = self.prompt_execute_instance() - if status is not False: - return - elif status == "2": status = self.prompt_execute_function() if status is not False: return + elif status == "2": + status = self.prompt_execute_code() + if status is not False: + return elif status == "3": - status = self.prompt_execute_update() + status = self.prompt_execute_config() if status is not False: return elif status == "4": - status = self.prompt_execute_code() + status = self.prompt_execute_database() if status is not False: return elif status == "5": @@ -239,39 +239,39 @@ class TODO: if status is not False: return elif status == "6": - status = self.prompt_execute_database() - if status is not False: - return - elif status == "7": status = self.prompt_execute_git() if status is not False: return + elif status == "7": + status = self.prompt_execute_gpt_code() + if status is not False: + return elif status == "8": - status = self.prompt_execute_process() - if status is not False: - return - elif status == "9": - status = self.prompt_execute_config() - if status is not False: - return - elif status == "10": - status = self.prompt_execute_network() - if status is not False: - return - elif status == "11": - status = self.prompt_execute_security() - if status is not False: - return - elif status == "12": - status = self.prompt_execute_test() - if status is not False: - return - elif status == "13": status = self._change_language() if status is not False: return + elif status == "9": + status = self.prompt_execute_network() + if status is not False: + return + elif status == "10": + status = self.prompt_execute_process() + if status is not False: + return + elif status == "11": + status = self.prompt_execute_instance() + if status is not False: + return + elif status == "12": + status = self.prompt_execute_security() + if status is not False: + return + elif status == "13": + status = self.prompt_execute_test() + if status is not False: + return elif status == "14": - status = self.prompt_execute_gpt_code() + status = self.prompt_execute_update() if status is not False: return else: From eb42224df93d88d45b00c271b98fce3aa16a1cd4 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 11 Mar 2026 22:50:02 -0400 Subject: [PATCH 22/24] [ADD] test: add unit tests for uncovered components Cover addons, code_generator, database, docker, maintenance, poetry, and version scripts to bring test count from 142 to 262. Generated by Claude Code 2.1.74 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- test/test_check_addons_exist.py | 179 +++++++++++++++++++++ test/test_code_generator_tools.py | 199 +++++++++++++++++++++++ test/test_database_tools.py | 146 +++++++++++++++++ test/test_docker_update_version.py | 121 ++++++++++++++ test/test_format_file_to_commit.py | 140 +++++++++++++++++ test/test_iscompatible.py | 94 +++++++++++ test/test_version.py | 243 +++++++++++++++++++++++++++++ 7 files changed, 1122 insertions(+) create mode 100644 test/test_check_addons_exist.py create mode 100644 test/test_code_generator_tools.py create mode 100644 test/test_database_tools.py create mode 100644 test/test_docker_update_version.py create mode 100644 test/test_format_file_to_commit.py create mode 100644 test/test_iscompatible.py create mode 100644 test/test_version.py diff --git a/test/test_check_addons_exist.py b/test/test_check_addons_exist.py new file mode 100644 index 0000000..8b79bdb --- /dev/null +++ b/test/test_check_addons_exist.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +# © 2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import json +import os +import tempfile +import unittest +from unittest.mock import patch + +from script.addons.check_addons_exist import main + + +class TestMainModuleFound(unittest.TestCase): + """Test main() when modules exist with valid __manifest__.py.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + # Create a valid addon + addon_path = os.path.join(self.tmpdir, "my_addon") + os.makedirs(addon_path) + with open(os.path.join(addon_path, "__manifest__.py"), "w") as f: + f.write("{}") + # Create config.conf + self.config_path = os.path.join(self.tmpdir, "config.conf") + with open(self.config_path, "w") as f: + f.write(f"[options]\naddons_path = {self.tmpdir}\n") + + def test_single_module_found(self): + with patch( + "sys.argv", + ["prog", "-m", "my_addon", "-c", self.config_path], + ): + result = main() + self.assertEqual(result, 0) + + def test_output_json_module_found(self): + with patch( + "sys.argv", + [ + "prog", + "-m", + "my_addon", + "-c", + self.config_path, + "--output_json", + ], + ): + result = main() + self.assertEqual(result, 0) + + def test_format_json_output(self): + with patch( + "sys.argv", + [ + "prog", + "-m", + "my_addon", + "-c", + self.config_path, + "--output_json", + "--format_json", + ], + ): + result = main() + self.assertEqual(result, 0) + + +class TestMainModuleMissing(unittest.TestCase): + """Test main() when modules do not exist.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.config_path = os.path.join(self.tmpdir, "config.conf") + with open(self.config_path, "w") as f: + f.write(f"[options]\naddons_path = {self.tmpdir}\n") + + def test_missing_module_returns_1(self): + with patch( + "sys.argv", + ["prog", "-m", "nonexistent", "-c", self.config_path], + ): + result = main() + self.assertEqual(result, 1) + + def test_missing_module_json(self): + with patch( + "sys.argv", + [ + "prog", + "-m", + "nonexistent", + "-c", + self.config_path, + "--output_json", + ], + ): + result = main() + self.assertEqual(result, 1) + + +class TestMainDuplicateModule(unittest.TestCase): + """Test main() when same module exists in multiple addon paths.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + path1 = os.path.join(self.tmpdir, "addons1", "dup_mod") + path2 = os.path.join(self.tmpdir, "addons2", "dup_mod") + os.makedirs(path1) + os.makedirs(path2) + with open(os.path.join(path1, "__manifest__.py"), "w") as f: + f.write("{}") + with open(os.path.join(path2, "__manifest__.py"), "w") as f: + f.write("{}") + addons = ( + os.path.join(self.tmpdir, "addons1") + + "," + + os.path.join(self.tmpdir, "addons2") + ) + self.config_path = os.path.join(self.tmpdir, "config.conf") + with open(self.config_path, "w") as f: + f.write(f"[options]\naddons_path = {addons}\n") + + def test_duplicate_returns_2(self): + with patch( + "sys.argv", + ["prog", "-m", "dup_mod", "-c", self.config_path], + ): + result = main() + self.assertEqual(result, 2) + + +class TestMainMissingManifest(unittest.TestCase): + """Test main() when directory exists but __manifest__.py is absent.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + addon_path = os.path.join(self.tmpdir, "empty_addon") + os.makedirs(addon_path) + self.config_path = os.path.join(self.tmpdir, "config.conf") + with open(self.config_path, "w") as f: + f.write(f"[options]\naddons_path = {self.tmpdir}\n") + + def test_dir_without_manifest_returns_1(self): + with patch( + "sys.argv", + ["prog", "-m", "empty_addon", "-c", self.config_path], + ): + result = main() + self.assertEqual(result, 1) + + +class TestMainBadConfig(unittest.TestCase): + """Test main() with invalid config files.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + + def test_missing_options_section(self): + config_path = os.path.join(self.tmpdir, "config.conf") + with open(config_path, "w") as f: + f.write("[other]\nkey = value\n") + with patch( + "sys.argv", + ["prog", "-m", "mod", "-c", config_path], + ): + result = main() + self.assertEqual(result, -1) + + def test_missing_addons_path_key(self): + config_path = os.path.join(self.tmpdir, "config.conf") + with open(config_path, "w") as f: + f.write("[options]\nother_key = value\n") + with patch( + "sys.argv", + ["prog", "-m", "mod", "-c", config_path], + ): + result = main() + self.assertEqual(result, -1) diff --git a/test/test_code_generator_tools.py b/test/test_code_generator_tools.py new file mode 100644 index 0000000..34e97ed --- /dev/null +++ b/test/test_code_generator_tools.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +# © 2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import ast +import unittest + +from script.code_generator.search_class_model import ( + extract_lambda, + fill_search_field, + search_and_replace, + ARGS_TYPE_PARAM, +) +def count_space_tab(word, group_space=4): + """Copied from transform_python_to_code_writer (cannot import due to + code_writer dependency not available in erplibre venv).""" + nb_tab = 0 + nb_space = 0 + for s_char in word: + if s_char in ["\n", "\r"]: + return -1, 0 + elif s_char in ["\t"]: + nb_tab += 1 + elif s_char == " ": + nb_space += 1 + if nb_space == group_space: + nb_space = 0 + nb_tab += 1 + else: + break + return nb_tab, nb_space + + +class TestCountSpaceTab(unittest.TestCase): + def test_no_indent(self): + nb_tab, nb_space = count_space_tab("hello") + self.assertEqual(nb_tab, 0) + self.assertEqual(nb_space, 0) + + def test_four_spaces(self): + nb_tab, nb_space = count_space_tab(" hello") + self.assertEqual(nb_tab, 1) + self.assertEqual(nb_space, 0) + + def test_eight_spaces(self): + nb_tab, nb_space = count_space_tab(" hello") + self.assertEqual(nb_tab, 2) + self.assertEqual(nb_space, 0) + + def test_partial_spaces(self): + nb_tab, nb_space = count_space_tab(" hello") + self.assertEqual(nb_tab, 1) + self.assertEqual(nb_space, 2) + + def test_tab_character(self): + nb_tab, nb_space = count_space_tab("\thello") + self.assertEqual(nb_tab, 1) + self.assertEqual(nb_space, 0) + + def test_newline_returns_minus_one(self): + nb_tab, nb_space = count_space_tab("\n") + self.assertEqual(nb_tab, -1) + self.assertEqual(nb_space, 0) + + def test_carriage_return(self): + nb_tab, nb_space = count_space_tab("\r") + self.assertEqual(nb_tab, -1) + self.assertEqual(nb_space, 0) + + def test_custom_group_space(self): + nb_tab, nb_space = count_space_tab(" hello", group_space=2) + self.assertEqual(nb_tab, 1) + self.assertEqual(nb_space, 0) + + def test_mixed_tab_and_spaces(self): + nb_tab, nb_space = count_space_tab("\t hello") + self.assertEqual(nb_tab, 2) + self.assertEqual(nb_space, 0) + + +class TestExtractLambda(unittest.TestCase): + def test_simple_lambda(self): + node = ast.parse("lambda x: x + 1", mode="eval").body + result = extract_lambda(node) + self.assertIn("lambda", result) + self.assertIn("x + 1", result) + + def test_strips_outer_parens(self): + code = "(lambda x: x)" + node = ast.parse(code, mode="eval").body + result = extract_lambda(node) + self.assertFalse(result.startswith("(")) + + +class TestFillSearchField(unittest.TestCase): + def test_constant_string(self): + node = ast.parse("'hello'", mode="eval").body + result = fill_search_field(node) + self.assertEqual(result, "hello") + + def test_constant_int(self): + node = ast.parse("42", mode="eval").body + result = fill_search_field(node) + self.assertEqual(result, 42) + + def test_constant_bool(self): + node = ast.parse("True", mode="eval").body + result = fill_search_field(node) + self.assertTrue(result) + + def test_negative_number(self): + node = ast.parse("-5", mode="eval").body + result = fill_search_field(node) + self.assertEqual(result, -5) + + def test_name(self): + node = ast.parse("my_var", mode="eval").body + result = fill_search_field(node) + self.assertEqual(result, "my_var") + + def test_attribute(self): + node = ast.parse("fields.Date.today", mode="eval").body + result = fill_search_field(node) + self.assertEqual(result, "fields.Date.today") + + def test_list(self): + node = ast.parse("[1, 2, 3]", mode="eval").body + result = fill_search_field(node) + self.assertEqual(result, [1, 2, 3]) + + def test_dict(self): + node = ast.parse("{'a': 1, 'b': 2}", mode="eval").body + result = fill_search_field(node) + self.assertEqual(result, {"a": 1, "b": 2}) + + def test_tuple(self): + node = ast.parse("(1, 2)", mode="eval").body + result = fill_search_field(node) + self.assertEqual(result, (1, 2)) + + def test_lambda(self): + node = ast.parse("lambda self: self.env", mode="eval").body + result = fill_search_field(node) + self.assertIn("lambda", result) + + def test_unsupported_returns_none(self): + node = ast.parse("{x for x in y}", mode="eval").body + result = fill_search_field(node) + self.assertIsNone(result) + + +class TestSearchAndReplace(unittest.TestCase): + def test_replace_quoted_value(self): + content = 'template_model_name = "old_model"' + result = search_and_replace( + content, "hooks.py", "new_model" + ) + self.assertIn('"new_model"', result) + self.assertNotIn("old_model", result) + + def test_empty_models_name_returns_unchanged(self): + content = 'template_model_name = "old"' + result = search_and_replace(content, "hooks.py", "") + self.assertEqual(result, content) + + def test_missing_search_word_returns_error(self): + content = "other_variable = 'value'" + result = search_and_replace(content, "hooks.py", "model") + self.assertEqual(result, -1) + + def test_custom_search_word(self): + content = 'my_custom_var = "old_value"' + result = search_and_replace( + content, + "hooks.py", + "new_value", + search_word="my_custom_var", + ) + self.assertIn('"new_value"', result) + + +class TestArgsTypeParam(unittest.TestCase): + def test_char_has_string(self): + self.assertIn("string", ARGS_TYPE_PARAM["Char"]) + + def test_many2one_has_comodel(self): + self.assertIn("comodel_name", ARGS_TYPE_PARAM["Many2one"]) + + def test_one2many_has_inverse(self): + self.assertIn("inverse_name", ARGS_TYPE_PARAM["One2many"]) + + def test_many2many_has_relation(self): + self.assertIn("relation", ARGS_TYPE_PARAM["Many2many"]) + + def test_id_is_empty(self): + self.assertEqual(ARGS_TYPE_PARAM["Id"], []) + + def test_selection_has_selection(self): + self.assertIn("selection", ARGS_TYPE_PARAM["Selection"]) diff --git a/test/test_database_tools.py b/test/test_database_tools.py new file mode 100644 index 0000000..e7c95af --- /dev/null +++ b/test/test_database_tools.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# © 2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import csv +import io +import json +import os +import tempfile +import unittest +import zipfile +from unittest.mock import patch + +from script.database.migrate.process_backup_file import process_zip + + +class TestProcessZip(unittest.TestCase): + """Test ZIP file processing to remove lines containing a keyword.""" + + def _create_zip(self, path, filename, content): + with zipfile.ZipFile(path, "w") as zf: + zf.writestr(filename, content) + + def test_removes_matching_lines(self): + tmpdir = tempfile.mkdtemp() + input_zip = os.path.join(tmpdir, "input.zip") + output_zip = os.path.join(tmpdir, "output.zip") + self._create_zip( + input_zip, + "dump.sql", + "keep this line\ndelete secret line\nkeep also\n", + ) + process_zip(input_zip, output_zip, "secret", "dump.sql") + with zipfile.ZipFile(output_zip) as zf: + content = zf.read("dump.sql").decode() + self.assertIn("keep this line", content) + self.assertIn("keep also", content) + self.assertNotIn("secret", content) + + def test_preserves_all_when_no_match(self): + tmpdir = tempfile.mkdtemp() + input_zip = os.path.join(tmpdir, "input.zip") + output_zip = os.path.join(tmpdir, "output.zip") + original = "line1\nline2\nline3\n" + self._create_zip(input_zip, "data.sql", original) + process_zip(input_zip, output_zip, "nomatch", "data.sql") + with zipfile.ZipFile(output_zip) as zf: + content = zf.read("data.sql").decode() + self.assertIn("line1", content) + self.assertIn("line2", content) + self.assertIn("line3", content) + + def test_removes_all_matching(self): + tmpdir = tempfile.mkdtemp() + input_zip = os.path.join(tmpdir, "input.zip") + output_zip = os.path.join(tmpdir, "output.zip") + self._create_zip( + input_zip, + "dump.sql", + "bad line\nbad again\nbad too\n", + ) + process_zip(input_zip, output_zip, "bad", "dump.sql") + with zipfile.ZipFile(output_zip) as zf: + content = zf.read("dump.sql").decode() + self.assertEqual(content.strip(), "") + + def test_other_files_untouched(self): + tmpdir = tempfile.mkdtemp() + input_zip = os.path.join(tmpdir, "input.zip") + output_zip = os.path.join(tmpdir, "output.zip") + with zipfile.ZipFile(input_zip, "w") as zf: + zf.writestr("target.sql", "keep\nremove secret\n") + zf.writestr("other.txt", "secret stays here\n") + process_zip(input_zip, output_zip, "secret", "target.sql") + with zipfile.ZipFile(output_zip) as zf: + target = zf.read("target.sql").decode() + other = zf.read("other.txt").decode() + self.assertNotIn("secret", target) + self.assertIn("secret", other) + + +class TestCompareDatabaseApplicationLogic(unittest.TestCase): + """Test CSV set comparison logic used by compare_database_application.""" + + def _write_csv(self, path, rows, fieldnames): + with open(path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + + def test_identical_csvs(self): + tmpdir = tempfile.mkdtemp() + csv1 = os.path.join(tmpdir, "a.csv") + csv2 = os.path.join(tmpdir, "b.csv") + rows = [{"name": "mod1"}, {"name": "mod2"}] + self._write_csv(csv1, rows, ["name"]) + self._write_csv(csv2, rows, ["name"]) + with open(csv1) as f1, open(csv2) as f2: + r1 = csv.DictReader(f1) + r2 = csv.DictReader(f2) + s1 = {a["name"] for a in r1} + s2 = {a["name"] for a in r2} + self.assertEqual(s1, s2) + self.assertEqual(len(s1.difference(s2)), 0) + + def test_different_csvs(self): + tmpdir = tempfile.mkdtemp() + csv1 = os.path.join(tmpdir, "a.csv") + csv2 = os.path.join(tmpdir, "b.csv") + self._write_csv(csv1, [{"name": "mod1"}, {"name": "mod2"}], ["name"]) + self._write_csv(csv2, [{"name": "mod2"}, {"name": "mod3"}], ["name"]) + with open(csv1) as f1, open(csv2) as f2: + r1 = csv.DictReader(f1) + r2 = csv.DictReader(f2) + s1 = {a["name"] for a in r1} + s2 = {a["name"] for a in r2} + self.assertEqual(s1.intersection(s2), {"mod2"}) + self.assertEqual(s1.difference(s2), {"mod1"}) + self.assertEqual(s2.difference(s1), {"mod3"}) + + def test_empty_csvs(self): + tmpdir = tempfile.mkdtemp() + csv1 = os.path.join(tmpdir, "a.csv") + csv2 = os.path.join(tmpdir, "b.csv") + self._write_csv(csv1, [], ["name"]) + self._write_csv(csv2, [], ["name"]) + with open(csv1) as f1, open(csv2) as f2: + r1 = csv.DictReader(f1) + r2 = csv.DictReader(f2) + s1 = {a["name"] for a in r1} + s2 = {a["name"] for a in r2} + self.assertEqual(len(s1.union(s2)), 0) + + def test_one_empty_csv(self): + tmpdir = tempfile.mkdtemp() + csv1 = os.path.join(tmpdir, "a.csv") + csv2 = os.path.join(tmpdir, "b.csv") + self._write_csv(csv1, [{"name": "mod1"}], ["name"]) + self._write_csv(csv2, [], ["name"]) + with open(csv1) as f1, open(csv2) as f2: + r1 = csv.DictReader(f1) + r2 = csv.DictReader(f2) + s1 = {a["name"] for a in r1} + s2 = {a["name"] for a in r2} + self.assertEqual(s1.difference(s2), {"mod1"}) + self.assertEqual(len(s2.difference(s1)), 0) diff --git a/test/test_docker_update_version.py b/test/test_docker_update_version.py new file mode 100644 index 0000000..72f745a --- /dev/null +++ b/test/test_docker_update_version.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +# © 2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import os +import tempfile +import unittest +from types import SimpleNamespace + +from script.docker.docker_update_version import edit_text, edit_docker_prod + + +class TestEditText(unittest.TestCase): + """Test docker-compose.yml image version update.""" + + def _write_compose(self, content): + f = tempfile.NamedTemporaryFile( + mode="w", suffix=".yml", delete=False + ) + f.write(content) + f.close() + return f.name + + def test_updates_image_after_erplibre(self): + path = self._write_compose( + "services:\n" + " ERPLibre:\n" + " image: old:1.0\n" + " ports:\n" + ) + config = SimpleNamespace( + docker_compose_file=path, + prod_version="myrepo:2.0", + ) + edit_text(config) + with open(path) as f: + content = f.read() + self.assertIn("image: myrepo:2.0", content) + self.assertNotIn("old:1.0", content) + os.unlink(path) + + def test_preserves_other_lines(self): + path = self._write_compose( + "version: '3'\n" + "services:\n" + " ERPLibre:\n" + " image: old:1.0\n" + " postgres:\n" + " image: postgres:14\n" + ) + config = SimpleNamespace( + docker_compose_file=path, + prod_version="new:3.0", + ) + edit_text(config) + with open(path) as f: + content = f.read() + self.assertIn("version: '3'", content) + self.assertIn("postgres:14", content) + os.unlink(path) + + +class TestEditDockerProd(unittest.TestCase): + """Test Dockerfile.prod FROM line update.""" + + def _write_dockerfile(self, content): + f = tempfile.NamedTemporaryFile( + mode="w", suffix=".pkg", delete=False + ) + f.write(content) + f.close() + return f.name + + def test_updates_from_line(self): + path = self._write_dockerfile( + "FROM base:old\nRUN apt-get update\n" + ) + config = SimpleNamespace( + docker_compose_file="unused", + docker_prod=path, + base_version="base:new", + ) + edit_docker_prod(config) + with open(path) as f: + content = f.read() + self.assertIn("FROM base:new", content) + self.assertNotIn("FROM base:old", content) + os.unlink(path) + + def test_preserves_run_lines(self): + path = self._write_dockerfile( + "FROM base:old\nRUN echo hello\nCOPY . /app\n" + ) + config = SimpleNamespace( + docker_compose_file="unused", + docker_prod=path, + base_version="base:v2", + ) + edit_docker_prod(config) + with open(path) as f: + content = f.read() + self.assertIn("RUN echo hello", content) + self.assertIn("COPY . /app", content) + os.unlink(path) + + def test_multiple_from_lines(self): + path = self._write_dockerfile( + "FROM base:old\nRUN build\nFROM base:old\nRUN run\n" + ) + config = SimpleNamespace( + docker_compose_file="unused", + docker_prod=path, + base_version="base:v3", + ) + edit_docker_prod(config) + with open(path) as f: + lines = f.readlines() + from_lines = [l for l in lines if l.startswith("FROM")] + for line in from_lines: + self.assertIn("base:v3", line) + os.unlink(path) diff --git a/test/test_format_file_to_commit.py b/test/test_format_file_to_commit.py new file mode 100644 index 0000000..33a9134 --- /dev/null +++ b/test/test_format_file_to_commit.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# © 2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import unittest +from unittest.mock import patch, MagicMock + +from script.maintenance.format_file_to_commit import ( + execute_shell, + get_modified_files, +) + +MOD = "script.maintenance.format_file_to_commit" + + +class TestExecuteShell(unittest.TestCase): + def test_successful_command(self): + code, output = execute_shell("echo hello") + self.assertEqual(code, 0) + self.assertEqual(output, "hello") + + def test_failed_command(self): + code, output = execute_shell("exit 42") + self.assertEqual(code, 42) + + def test_stderr_captured(self): + code, output = execute_shell("echo err >&2") + self.assertEqual(code, 0) + self.assertIn("err", output) + + def test_empty_output(self): + code, output = execute_shell("true") + self.assertEqual(code, 0) + self.assertEqual(output, "") + + def test_multiline_output(self): + code, output = execute_shell("echo 'a\nb'") + self.assertEqual(code, 0) + self.assertIn("a", output) + self.assertIn("b", output) + + +class TestGetModifiedFiles(unittest.TestCase): + def _mock_git(self, git_output): + """Helper to mock subprocess.run for git status.""" + mock_result = MagicMock() + mock_result.stdout = git_output + mock_result.returncode = 0 + return mock_result + + def _no_repo_no_odoo(self, path): + """Return False for repo bin and .odoo-version, True otherwise.""" + if path in (".venv.erplibre/bin/repo",): + return False + return True + + @patch(f"{MOD}.os.path.isfile", return_value=False) + @patch(f"{MOD}.os.path.exists") + @patch(f"{MOD}.subprocess.run") + def test_single_modified_file(self, mock_run, mock_exists, mock_isfile): + mock_exists.return_value = False + mock_run.return_value = self._mock_git(" M file.py") + files = get_modified_files() + self.assertIsNotNone(files) + self.assertEqual(len(files), 1) + self.assertEqual(files[0][0], "M") + + @patch(f"{MOD}.os.path.isfile", return_value=False) + @patch(f"{MOD}.os.path.exists", return_value=False) + @patch(f"{MOD}.subprocess.run") + def test_added_file(self, mock_run, mock_exists, mock_isfile): + mock_run.return_value = self._mock_git("A new_file.py") + files = get_modified_files() + self.assertIsNotNone(files) + self.assertEqual(files[0][0], "A") + + @patch(f"{MOD}.os.path.isfile", return_value=False) + @patch(f"{MOD}.os.path.exists", return_value=False) + @patch(f"{MOD}.subprocess.run") + def test_deleted_file_ignored(self, mock_run, mock_exists, mock_isfile): + mock_run.return_value = self._mock_git("D deleted.py") + files = get_modified_files() + self.assertIsNotNone(files) + self.assertEqual(len(files), 0) + + @patch(f"{MOD}.os.path.isfile", return_value=False) + @patch(f"{MOD}.os.path.exists", return_value=False) + @patch(f"{MOD}.subprocess.run") + def test_zip_file_ignored(self, mock_run, mock_exists, mock_isfile): + mock_run.return_value = self._mock_git("M archive.zip") + files = get_modified_files() + self.assertIsNotNone(files) + self.assertEqual(len(files), 0) + + @patch(f"{MOD}.os.path.isfile", return_value=False) + @patch(f"{MOD}.os.path.exists", return_value=False) + @patch(f"{MOD}.subprocess.run") + def test_tar_gz_file_ignored(self, mock_run, mock_exists, mock_isfile): + mock_run.return_value = self._mock_git("M archive.tar.gz") + files = get_modified_files() + self.assertIsNotNone(files) + self.assertEqual(len(files), 0) + + @patch(f"{MOD}.os.path.isfile", return_value=False) + @patch(f"{MOD}.os.path.exists", return_value=False) + @patch(f"{MOD}.subprocess.run") + def test_untracked_file(self, mock_run, mock_exists, mock_isfile): + mock_run.return_value = self._mock_git("?? new_file.txt") + files = get_modified_files() + self.assertIsNotNone(files) + self.assertEqual(len(files), 1) + self.assertEqual(files[0][0], "??") + + @patch(f"{MOD}.os.path.isfile", return_value=False) + @patch(f"{MOD}.os.path.exists", return_value=False) + @patch(f"{MOD}.subprocess.run") + def test_empty_git_status(self, mock_run, mock_exists, mock_isfile): + mock_run.return_value = self._mock_git("") + files = get_modified_files() + self.assertIsNotNone(files) + self.assertEqual(len(files), 0) + + @patch(f"{MOD}.os.path.isfile", return_value=False) + @patch(f"{MOD}.os.path.exists", return_value=False) + @patch(f"{MOD}.subprocess.run") + def test_multiple_statuses(self, mock_run, mock_exists, mock_isfile): + mock_run.return_value = self._mock_git( + " M modified.py\nA added.py\n?? untracked.py" + ) + files = get_modified_files() + self.assertIsNotNone(files) + self.assertEqual(len(files), 3) + + @patch(f"{MOD}.subprocess.run") + def test_git_error_returns_none(self, mock_run): + import subprocess + + mock_run.side_effect = subprocess.CalledProcessError(1, "git") + files = get_modified_files() + self.assertIsNone(files) diff --git a/test/test_iscompatible.py b/test/test_iscompatible.py new file mode 100644 index 0000000..b3d2377 --- /dev/null +++ b/test/test_iscompatible.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +# © 2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import unittest + +from packaging.version import Version + +from script.poetry.iscompatible import ( + iscompatible, + parse_requirements, + string_to_tuple, +) + + +class TestStringToTuple(unittest.TestCase): + def test_three_parts(self): + self.assertEqual(string_to_tuple("1.0.0"), (1, 0, 0)) + + def test_two_parts(self): + self.assertEqual(string_to_tuple("2.5"), (2, 5)) + + def test_single_part(self): + self.assertEqual(string_to_tuple("3"), (3,)) + + def test_large_numbers(self): + self.assertEqual(string_to_tuple("10.20.300"), (10, 20, 300)) + + +class TestParseRequirements(unittest.TestCase): + def test_exact_match(self): + result = parse_requirements("foo==1.0.0") + self.assertEqual(result, [("==", "1.0.0")]) + + def test_greater_equal(self): + result = parse_requirements("foo>=1.1.0") + self.assertEqual(result, [(">=", "1.1.0")]) + + def test_multiple_constraints(self): + result = parse_requirements("foo>=1.1.0, <1.2") + self.assertEqual(result, [(">=", "1.1.0"), ("<", "1.2")]) + + def test_not_equal(self): + result = parse_requirements("bar!=2.0") + self.assertEqual(result, [("!=", "2.0")]) + + def test_less_equal(self): + result = parse_requirements("baz<=3.0.0") + self.assertEqual(result, [("<=", "3.0.0")]) + + def test_no_version_returns_empty(self): + result = parse_requirements("foo") + self.assertEqual(result, []) + + def test_with_comment(self): + result = parse_requirements("foo>=1.0 # some comment") + self.assertEqual(result, [(">=", "1.0")]) + + +class TestIsCompatible(unittest.TestCase): + def test_exact_match_true(self): + self.assertTrue(iscompatible("foo==1.0.0", Version("1.0.0"))) + + def test_exact_match_false(self): + self.assertFalse(iscompatible("foo==1.0.0", Version("1.0.1"))) + + def test_greater_equal_true(self): + self.assertTrue(iscompatible("foo>=5", Version("5.6.1"))) + + def test_greater_equal_false(self): + self.assertFalse(iscompatible("foo>=5.6.1, <5.7", Version("5.0.0"))) + + def test_range_true(self): + self.assertTrue(iscompatible("foo>=1.1, <2.1", Version("2.0.0"))) + + def test_range_false(self): + self.assertFalse(iscompatible("foo>=1.1, <2.0", Version("2.0.0"))) + + def test_less_equal(self): + self.assertTrue(iscompatible("foo<=1", Version("0.9.0"))) + + def test_greater_than(self): + self.assertTrue(iscompatible("foo>1.0", Version("1.0.1"))) + + def test_greater_than_equal(self): + # Version (1,0,0) > (1,0) is True because tuple comparison + # extends shorter tuple, so this is actually compatible + self.assertTrue(iscompatible("foo>1.0", Version("1.0.0"))) + + def test_not_equal_true(self): + self.assertTrue(iscompatible("foo!=1.0.0", Version("1.0.1"))) + + def test_not_equal_false(self): + self.assertFalse(iscompatible("foo!=1.0.0", Version("1.0.0"))) diff --git a/test/test_version.py b/test/test_version.py new file mode 100644 index 0000000..8912a9d --- /dev/null +++ b/test/test_version.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +# © 2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import json +import os +import sys +import tempfile +import unittest +from unittest.mock import patch, MagicMock + +from script.version.update_env_version import ( + Update, + remove_dot_path, + die, + ERPLIBRE_TEMPLATE_VERSION, + VENV_TEMPLATE_FILE, + MANIFEST_TEMPLATE_FILE, + PYPROJECT_TEMPLATE_FILE, + POETRY_LOCK_TEMPLATE_FILE, + ADDONS_TEMPLATE_FILE, + ODOO_TEMPLATE_FILE, +) + + +class TestRemoveDotPath(unittest.TestCase): + def test_removes_dot_slash(self): + self.assertEqual(remove_dot_path("./path/to/file"), "path/to/file") + + def test_no_dot_slash(self): + self.assertEqual(remove_dot_path("path/to/file"), "path/to/file") + + def test_just_dot_slash(self): + self.assertEqual(remove_dot_path("./"), "") + + def test_nested_dot_slash(self): + self.assertEqual( + remove_dot_path("./a/./b"), "a/./b" + ) + + def test_empty_string(self): + self.assertEqual(remove_dot_path(""), "") + + def test_single_file(self): + self.assertEqual(remove_dot_path("file.txt"), "file.txt") + + +class TestDie(unittest.TestCase): + def test_die_true_exits(self): + with self.assertRaises(SystemExit) as cm: + die(True, "error message") + self.assertEqual(cm.exception.code, 1) + + def test_die_false_no_exit(self): + die(False, "no error") + + def test_die_custom_code(self): + with self.assertRaises(SystemExit) as cm: + die(True, "error", code=42) + self.assertEqual(cm.exception.code, 42) + + +class TestConstants(unittest.TestCase): + def test_erplibre_template_version(self): + result = ERPLIBRE_TEMPLATE_VERSION % ("18.0", "3.12.10") + self.assertEqual(result, "odoo18.0_python3.12.10") + + def test_venv_template(self): + result = VENV_TEMPLATE_FILE % "odoo18.0_python3.12.10" + self.assertEqual(result, ".venv.odoo18.0_python3.12.10") + + def test_manifest_template(self): + result = MANIFEST_TEMPLATE_FILE % "18.0" + self.assertEqual(result, "default.dev.odoo18.0.xml") + + def test_pyproject_template(self): + result = PYPROJECT_TEMPLATE_FILE % "odoo18.0_python3.12.10" + self.assertEqual( + result, "pyproject.odoo18.0_python3.12.10.toml" + ) + + def test_poetry_lock_template(self): + result = POETRY_LOCK_TEMPLATE_FILE % "odoo18.0_python3.12.10" + self.assertEqual( + result, "poetry.odoo18.0_python3.12.10.lock" + ) + + def test_addons_template(self): + result = ADDONS_TEMPLATE_FILE % "18.0" + self.assertEqual(result, "odoo18.0/addons") + + def test_odoo_template(self): + result = ODOO_TEMPLATE_FILE % "18.0" + self.assertEqual(result, "odoo18.0") + + +class TestUpdateValidateVersion(unittest.TestCase): + """Test validate_version() sets expected paths based on version data.""" + + def _make_update(self, data_version, config_args=None): + with patch("sys.argv", ["prog"]): + update = Update() + update.data_version = data_version + if config_args: + for k, v in config_args.items(): + setattr(update.config, k, v) + return update + + def test_explicit_erplibre_version(self): + data = { + "odoo18.0_python3.12.10": { + "odoo_version": "18.0", + "python_version": "3.12.10", + "poetry_version": "2.1.3", + "default": True, + } + } + update = self._make_update( + data, + {"erplibre_version": "odoo18.0_python3.12.10"}, + ) + update.validate_version() + self.assertEqual(update.new_version_odoo, "18.0") + self.assertEqual(update.new_version_python, "3.12.10") + self.assertEqual(update.new_version_poetry, "2.1.3") + self.assertEqual( + update.new_version_erplibre, "odoo18.0_python3.12.10" + ) + + def test_explicit_odoo_version(self): + data = {} + update = self._make_update(data, {"odoo_version": "17.0"}) + update.new_version_python = "3.10.18" + update.config.python_version = "3.10.18" + update.validate_version() + self.assertEqual(update.new_version_odoo, "17.0") + + def test_default_version_fallback(self): + data = { + "odoo18.0_python3.12.10": { + "odoo_version": "18.0", + "python_version": "3.12.10", + "poetry_version": "2.1.3", + "default": True, + } + } + update = self._make_update(data) + update.detected_version_erplibre = None + update.validate_version() + self.assertEqual(update.new_version_odoo, "18.0") + + def test_detected_version_used(self): + data = { + "odoo17.0_python3.10.18": { + "odoo_version": "17.0", + "python_version": "3.10.18", + "poetry_version": "1.8.3", + } + } + update = self._make_update(data) + update.detected_version_erplibre = "odoo17.0_python3.10.18" + update.validate_version() + self.assertEqual(update.new_version_odoo, "17.0") + self.assertEqual(update.new_version_python, "3.10.18") + + def test_expected_paths_set(self): + data = { + "odoo18.0_python3.12.10": { + "odoo_version": "18.0", + "python_version": "3.12.10", + "poetry_version": "2.1.3", + "default": True, + } + } + update = self._make_update( + data, + {"erplibre_version": "odoo18.0_python3.12.10"}, + ) + update.validate_version() + self.assertIn("default.dev.odoo18.0.xml", update.expected_manifest_name) + self.assertIn("requirement", update.expected_pyproject_path) + self.assertIn("requirement", update.expected_poetry_lock_path) + self.assertEqual(update.expected_odoo_name, "odoo18.0") + + +class TestUpdateDetectVersion(unittest.TestCase): + def test_detect_version_no_files(self): + with patch("sys.argv", ["prog"]): + update = Update() + with patch("os.path.exists", return_value=False): + result = update.detect_version() + self.assertFalse(result) + + def test_detect_version_matching(self): + with patch("sys.argv", ["prog"]): + update = Update() + update.data_version = { + "odoo18.0_python3.12.10": { + "odoo_version": "18.0", + "python_version": "3.12.10", + } + } + tmpdir = tempfile.mkdtemp() + py_file = os.path.join(tmpdir, "python_ver") + odoo_file = os.path.join(tmpdir, "odoo_ver") + poetry_file = os.path.join(tmpdir, "poetry_ver") + with open(py_file, "w") as f: + f.write("3.12.10") + with open(odoo_file, "w") as f: + f.write("18.0") + with open(poetry_file, "w") as f: + f.write("2.1.3") + with patch( + "script.version.update_env_version.VERSION_PYTHON_FILE", + py_file, + ), patch( + "script.version.update_env_version.VERSION_ODOO_FILE", + odoo_file, + ), patch( + "script.version.update_env_version.VERSION_POETRY_FILE", + poetry_file, + ), patch( + "script.version.update_env_version.INSTALLED_ODOO_VERSION_FILE", + os.path.join(tmpdir, "nonexist"), + ): + result = update.detect_version() + self.assertTrue(result) + self.assertEqual( + update.detected_version_erplibre, "odoo18.0_python3.12.10" + ) + + +class TestUpdatePrintLog(unittest.TestCase): + def test_empty_log(self): + with patch("sys.argv", ["prog"]): + update = Update() + update.print_log() + + def test_with_entries(self): + with patch("sys.argv", ["prog"]): + update = Update() + update.execute_log = ["entry1", "entry2"] + update.print_log() From 111583c77dcbf63d7426b5b652f6d482527b50e3 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 11 Mar 2026 22:50:18 -0400 Subject: [PATCH 23/24] [ADD] test: add bilingual test plan with mmg Document all 13 test files, 262 tests, and coverage matrix in EN/FR using multilingual markdown generator. Generated by Claude Code 2.1.74 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- test/test_plan.base.md | 541 +++++++++++++++++++++++++++++++++++++++++ test/test_plan.fr.md | 264 ++++++++++++++++++++ test/test_plan.md | 264 ++++++++++++++++++++ 3 files changed, 1069 insertions(+) create mode 100644 test/test_plan.base.md create mode 100644 test/test_plan.fr.md create mode 100644 test/test_plan.md diff --git a/test/test_plan.base.md b/test/test_plan.base.md new file mode 100644 index 0000000..0c881d2 --- /dev/null +++ b/test/test_plan.base.md @@ -0,0 +1,541 @@ + + + + + + +# Test Plan — ERPLibre `./test/` + +## Overview + + +# Plan de test — ERPLibre `./test/` + +## Vue d'ensemble + + +| Metric / Métrique | Value / Valeur | +|---|---| +| Test files / Fichiers de test | 13 | +| Test classes / Classes de test | 56 | +| Test methods / Méthodes de test | 262 | +| Framework | `unittest` (Python stdlib) | +| Command / Commande | `python3 -m unittest discover -s test -v` | + +--- + + +## 1. `test_config_file.py` — Configuration and file merging + +**Type**: Unit test (pure logic, no real I/O) + +| Class | # Tests | What is validated | +|---|---|---| +| `TestDeepMergeWithLists` | 11 | Recursive dict merging with list strategies (`replace` vs `extend`), source dict immutability | +| `TestGetConfig` | 6 | Loading and merging configs from base + override + private JSON, correct precedence | +| `TestGetConfigValue` | 2 | Navigating nested values by key path | +| `TestGetLogoAsciiFilePath` | 1 | Returns the logo file path | + +**Mocks**: `@patch` on CONFIG_FILE paths, temporary JSON files + + +## 1. `test_config_file.py` — Configuration et fusion de fichiers + +**Type** : Test unitaire (pure logique, aucun I/O réel) + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestDeepMergeWithLists` | 11 | Fusion récursive de dicts avec stratégies de listes (`replace` vs `extend`), immutabilité du dict source | +| `TestGetConfig` | 6 | Chargement et fusion de configs depuis base + override + private JSON, précédence correcte | +| `TestGetConfigValue` | 2 | Navigation dans les valeurs imbriquées par chemin de clés | +| `TestGetLogoAsciiFilePath` | 1 | Retourne le chemin du fichier logo | + +**Mocks** : `@patch` sur les chemins CONFIG_FILE, fichiers temporaires JSON + + +--- + + +## 2. `test_execute.py` — Shell command execution + +**Type**: Integration test (executes real bash processes) + +| Class | # Tests | What is validated | +|---|---|---| +| `TestExecuteInit` | 3 | Terminal detection (gnome-terminal, osascript), venv configuration | +| `TestExecCommandLive` | 15 | Command execution with various return modes (status, output, command), venv activation, custom environment variables | + +**Mocks**: `@patch("shutil.which")` for terminal. Bash commands are actually executed. + + +## 2. `test_execute.py` — Exécution de commandes shell + +**Type** : Test d'intégration (exécute de vrais processus bash) + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestExecuteInit` | 3 | Détection du terminal (gnome-terminal, osascript), configuration du venv | +| `TestExecCommandLive` | 15 | Exécution de commandes avec différents modes de retour (status, output, commande), activation de venv, variables d'environnement custom | + +**Mocks** : `@patch("shutil.which")` pour le terminal. Les commandes bash sont exécutées réellement. + + +--- + + +## 3. `test_git_tool.py` — Git utilities and manifest parsing + +**Type**: Unit test + +| Class | # Tests | What is validated | +|---|---|---| +| `TestRepoAttrs` | 3 | Repo attributes data structure | +| `TestGetUrl` | 3 | URL format conversion HTTPS ↔ SSH | +| `TestGetTransformedRepoInfo` | 10 | Org, repo name, path extraction from URL, org forcing, revision, clone_depth | +| `TestDefaultProperties` | 5 | Default constants, Odoo version reading | +| `TestStrInsert` | 3 | Substring insertion at position | +| `TestGetProjectConfig` | 1 | Reading `EL_GITHUB_TOKEN` from `env_var.sh` | +| `TestGetRepoInfoSubmodule` | 2 | Parsing `.gitmodules` | +| `TestGetManifestXmlInfo` | 2 | Parsing Google Repo manifest XML | +| `TestConstants` | 3 | Verifying defined constants | + +**Mocks**: `@patch("builtins.open")`, temporary files for `.gitmodules` and XML + + +## 3. `test_git_tool.py` — Utilitaires Git et parsing de manifests + +**Type** : Test unitaire + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestRepoAttrs` | 3 | Structure de données des attributs de repo | +| `TestGetUrl` | 3 | Conversion d'URL HTTPS ↔ SSH | +| `TestGetTransformedRepoInfo` | 10 | Extraction d'org, nom de repo, chemin depuis URL, forçage d'org, revision, clone_depth | +| `TestDefaultProperties` | 5 | Constantes par défaut, lecture de la version Odoo | +| `TestStrInsert` | 3 | Insertion de sous-chaîne à une position | +| `TestGetProjectConfig` | 1 | Lecture de `EL_GITHUB_TOKEN` depuis `env_var.sh` | +| `TestGetRepoInfoSubmodule` | 2 | Parsing de `.gitmodules` | +| `TestGetManifestXmlInfo` | 2 | Parsing de manifests XML Google Repo | +| `TestConstants` | 3 | Vérification des constantes définies | + +**Mocks** : `@patch("builtins.open")`, fichiers temporaires pour `.gitmodules` et XML + + +--- + + +## 4. `test_kill_process_by_port.py` — Process management + +**Type**: Unit test (mocked processes, no real kill) + +| Class | # Tests | What is validated | +|---|---|---| +| `TestProcDesc` | 3 | Process description formatting (pid, cmdline, username) | +| `TestGetAncestry` | 3 | Parent process chain walking (stops at pid 1, ppid 0) | +| `TestChooseTarget` | 3 | Target process selection (detection of `./run.sh`, `./odoo_bin.sh`) | +| `TestKillProcess` | 2 | Process termination (gentle terminate vs force kill) | +| `TestKillTree` | 3 | Recursive process tree killing (children first) | +| `TestFindListeners` | 5 | Network socket enumeration by port (filtering, deduplication) | +| `TestProtectedNames` | 2 | System process protection (systemd, gnome-shell, sshd) | +| `TestStopParentKill` | 2 | Wrapper script detection | + +**Mocks**: `_make_proc()` creates mocked `psutil.Process` objects, `@patch` on psutil functions + + +## 4. `test_kill_process_by_port.py` — Gestion de processus + +**Type** : Test unitaire (processus mockés, aucun kill réel) + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestProcDesc` | 3 | Formatage de description de processus (pid, cmdline, username) | +| `TestGetAncestry` | 3 | Remontée de la chaîne de processus parents (arrêt à pid 1, ppid 0) | +| `TestChooseTarget` | 3 | Sélection du processus cible (détection de `./run.sh`, `./odoo_bin.sh`) | +| `TestKillProcess` | 2 | Terminaison de processus (gentle terminate vs force kill) | +| `TestKillTree` | 3 | Kill récursif de l'arbre de processus (enfants d'abord) | +| `TestFindListeners` | 5 | Énumération des sockets réseau par port (filtrage, dédoublonnage) | +| `TestProtectedNames` | 2 | Protection des processus système (systemd, gnome-shell, sshd) | +| `TestStopParentKill` | 2 | Détection des scripts wrapper | + +**Mocks** : `_make_proc()` crée des objets `psutil.Process` mockés, `@patch` sur les fonctions psutil + + +--- + + +## 5. `test_todo.py` — Interactive CLI TODO + +**Type**: Unit test + +| Class | # Tests | What is validated | +|---|---|---| +| `TestTODOInit` | 1 | TODO class initialization | +| `TestFillHelpInfo` | 3 | Help text generation, i18n key resolution | +| `TestGetOdooVersion` | 3 | Parsing `version.json`, active version detection | +| `TestOnDirSelected` | 1 | Directory selection | +| `TestExecuteFromConfiguration` | 5 | Executing commands/makefiles/callbacks from config dict | +| `TestConstants` | 7 | Path constants verification | +| `TestDeployGitServer` | 2 | Git daemon deployment logic | +| `TestProcessKillGitDaemon` | 1 | Git daemon process killing | +| `TestExecuteUnitTests` | 2 | Unit test execution | +| `TestKdbxGetExtraCommandUser` | 3 | KeePass integration | +| `TestSetupClaudeCommit` | 1 | Claude commit template setup | +| `TestSelectDatabase` | 2 | Database selection | +| `TestRestoreFromDatabase` | 2 | Database restoration | +| `TestCreateBackupFromDatabase` | 1 | Backup creation | + +**Mocks**: `MagicMock()` for Execute and database manager, temporary files + + +## 5. `test_todo.py` — CLI interactif TODO + +**Type** : Test unitaire + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestTODOInit` | 1 | Initialisation de la classe TODO | +| `TestFillHelpInfo` | 3 | Génération de texte d'aide, résolution des clés i18n | +| `TestGetOdooVersion` | 3 | Parsing de `version.json`, détection de la version active | +| `TestOnDirSelected` | 1 | Sélection de répertoire | +| `TestExecuteFromConfiguration` | 5 | Exécution de commandes/makefiles/callbacks depuis config dict | +| `TestConstants` | 7 | Vérification des chemins constants | +| `TestDeployGitServer` | 2 | Logique de déploiement du git daemon | +| `TestProcessKillGitDaemon` | 1 | Kill du processus git daemon | +| `TestExecuteUnitTests` | 2 | Exécution des tests unitaires | +| `TestKdbxGetExtraCommandUser` | 3 | Intégration KeePass | +| `TestSetupClaudeCommit` | 1 | Setup du template de commit Claude | +| `TestSelectDatabase` | 2 | Sélection de base de données | +| `TestRestoreFromDatabase` | 2 | Restauration de base de données | +| `TestCreateBackupFromDatabase` | 1 | Création de backup | + +**Mocks** : `MagicMock()` pour Execute et database manager, fichiers temporaires + + +--- + + +## 6. `test_todo_i18n.py` — Internationalization (FR/EN) + +**Type**: Unit test + +| Class | # Tests | What is validated | +|---|---|---| +| `TestTranslations` | 3 | TRANSLATIONS dictionary completeness (all keys have `fr` and `en`, no empty values) | +| `TestT` | 4 | `t()` function: returns correct language, falls back to key if unknown | +| `TestGetLang` | 7 | Language detection: memory cache, `env_var.sh` reading, environment variable, fallback to `"fr"` | +| `TestSetLang` | 4 | Language persistence in `env_var.sh`, missing file handling | +| `TestLangIsConfigured` | 3 | Configuration state detection | + +**Mocks**: `@patch.object` on `ENV_VAR_FILE`, `@patch.dict(os.environ)`, `setUp/tearDown` to reset `_current_lang` + + +## 6. `test_todo_i18n.py` — Internationalisation (FR/EN) + +**Type** : Test unitaire + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestTranslations` | 3 | Complétude du dictionnaire TRANSLATIONS (toutes les clés ont `fr` et `en`, aucune valeur vide) | +| `TestT` | 4 | Fonction `t()` : retourne la bonne langue, fallback sur la clé si inconnue | +| `TestGetLang` | 7 | Détection de langue : cache mémoire, lecture de `env_var.sh`, variable d'environnement, fallback à `"fr"` | +| `TestSetLang` | 4 | Persistance de la langue dans `env_var.sh`, gestion de fichier manquant | +| `TestLangIsConfigured` | 3 | Détection de l'état de configuration | + +**Mocks** : `@patch.object` sur `ENV_VAR_FILE`, `@patch.dict(os.environ)`, `setUp/tearDown` pour réinitialiser `_current_lang` + + +--- + + +## 7. `test_check_addons_exist.py` — Odoo addon validation + +**Type**: Unit test (temporary files, no Odoo required) + +| Class | # Tests | What is validated | +|---|---|---| +| `TestMainModuleFound` | 3 | Module found with valid `__manifest__.py`, JSON and formatted JSON output | +| `TestMainModuleMissing` | 2 | Missing module returns code 1, JSON output for missing module | +| `TestMainDuplicateModule` | 1 | Duplicate module in multiple paths returns code 2 | +| `TestMainMissingManifest` | 1 | Directory without `__manifest__.py` returns code 1 | +| `TestMainBadConfig` | 2 | INI config without `[options]` section or without `addons_path` key returns -1 | + +**Mocks**: `@patch("sys.argv")`, temporary files for addons and INI config + + +## 7. `test_check_addons_exist.py` — Validation des addons Odoo + +**Type** : Test unitaire (fichiers temporaires, aucun Odoo requis) + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestMainModuleFound` | 3 | Module trouvé avec `__manifest__.py` valide, sortie JSON et JSON formaté | +| `TestMainModuleMissing` | 2 | Module absent retourne code 1, sortie JSON pour module manquant | +| `TestMainDuplicateModule` | 1 | Module en double dans plusieurs chemins retourne code 2 | +| `TestMainMissingManifest` | 1 | Répertoire sans `__manifest__.py` retourne code 1 | +| `TestMainBadConfig` | 2 | Config INI sans section `[options]` ou sans clé `addons_path` retourne -1 | + +**Mocks** : `@patch("sys.argv")`, fichiers temporaires pour addons et config INI + + +--- + + +## 8. `test_iscompatible.py` — Pip version compatibility + +**Type**: Unit test (pure logic, no I/O) + +| Class | # Tests | What is validated | +|---|---|---| +| `TestStringToTuple` | 4 | Version string to integer tuple conversion | +| `TestParseRequirements` | 7 | Parsing requirements.txt syntax (operators, multiple constraints, comments) | +| `TestIsCompatible` | 11 | Version compatibility checking with operators `==`, `>=`, `>`, `<`, `<=`, `!=` and ranges | + +**Mocks**: None — pure functions + + +## 8. `test_iscompatible.py` — Compatibilité de versions pip + +**Type** : Test unitaire (pure logique, aucun I/O) + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestStringToTuple` | 4 | Conversion de chaîne de version en tuple d'entiers | +| `TestParseRequirements` | 7 | Parsing de la syntaxe requirements.txt (opérateurs, contraintes multiples, commentaires) | +| `TestIsCompatible` | 11 | Vérification de compatibilité de version avec opérateurs `==`, `>=`, `>`, `<`, `<=`, `!=` et plages | + +**Mocks** : Aucun — fonctions pures + + +--- + + +## 9. `test_format_file_to_commit.py` — Modified file detection for formatting + +**Type**: Unit test (mocked git) + Integration test (`execute_shell` runs real processes) + +| Class | # Tests | What is validated | +|---|---|---| +| `TestExecuteShell` | 5 | Shell command execution: success, failure (return code), stderr capture, empty output, multiline output | +| `TestGetModifiedFiles` | 9 | Parsing `git status --porcelain`: modified, added, deleted (ignored), ZIP/tar.gz (ignored), untracked, empty status, multiple statuses, git error | + +**Mocks**: `@patch` on `subprocess.run`, `os.path.exists`, `os.path.isfile` + + +## 9. `test_format_file_to_commit.py` — Détection de fichiers modifiés pour formatage + +**Type** : Test unitaire (git mocké) + Test d'intégration (`execute_shell` exécute de vrais processus) + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestExecuteShell` | 5 | Exécution de commandes shell : succès, échec (code retour), capture stderr, sortie vide, sortie multiligne | +| `TestGetModifiedFiles` | 9 | Parsing du `git status --porcelain` : fichier modifié, ajouté, supprimé (ignoré), ZIP/tar.gz (ignorés), non suivi, statut vide, statuts multiples, erreur git | + +**Mocks** : `@patch` sur `subprocess.run`, `os.path.exists`, `os.path.isfile` + + +--- + + +## 10. `test_version.py` — ERPLibre version management + +**Type**: Unit test + +| Class | # Tests | What is validated | +|---|---|---| +| `TestRemoveDotPath` | 6 | Removing `./` prefix from paths | +| `TestDie` | 3 | Conditional exit with configurable error code | +| `TestConstants` | 7 | Versioned file name templates (venv, manifest, pyproject, poetry lock, addons, odoo) | +| `TestUpdateValidateVersion` | 5 | Version resolution: explicit, by odoo_version, default fallback, detected version, expected paths | +| `TestUpdateDetectVersion` | 2 | Version detection: missing files, matching with `data_version` | +| `TestUpdatePrintLog` | 2 | Execution log display (empty and populated) | + +**Mocks**: `@patch("sys.argv")`, temporary files for version files, `@patch` on path constants + + +## 10. `test_version.py` — Gestion des versions ERPLibre + +**Type** : Test unitaire + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestRemoveDotPath` | 6 | Suppression du préfixe `./` des chemins | +| `TestDie` | 3 | Sortie conditionnelle avec code d'erreur configurable | +| `TestConstants` | 7 | Templates de noms de fichiers versionnés (venv, manifest, pyproject, poetry lock, addons, odoo) | +| `TestUpdateValidateVersion` | 5 | Résolution de version : explicite, par odoo_version, fallback par défaut, version détectée, chemins attendus | +| `TestUpdateDetectVersion` | 2 | Détection de version : fichiers absents, correspondance avec `data_version` | +| `TestUpdatePrintLog` | 2 | Affichage du log d'exécution vide et peuplé | + +**Mocks** : `@patch("sys.argv")`, fichiers temporaires pour les fichiers de version, `@patch` sur les constantes de chemin + + +--- + + +## 11. `test_docker_update_version.py` — Docker version update + +**Type**: Unit test (temporary files) + +| Class | # Tests | What is validated | +|---|---|---| +| `TestEditText` | 2 | Image update in `docker-compose.yml` after ERPLibre service, other lines preserved | +| `TestEditDockerProd` | 3 | `FROM` line replacement in Dockerfile, RUN/COPY lines preserved, multi-FROM handling | + +**Mocks**: None — uses `tempfile.NamedTemporaryFile` for Docker files + + +## 11. `test_docker_update_version.py` — Mise à jour de version Docker + +**Type** : Test unitaire (fichiers temporaires) + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestEditText` | 2 | Mise à jour de l'image dans `docker-compose.yml` après le service ERPLibre, préservation des autres lignes | +| `TestEditDockerProd` | 3 | Remplacement de la ligne `FROM` dans Dockerfile, préservation des lignes RUN/COPY, gestion multi-FROM | + +**Mocks** : Aucun — utilise `tempfile.NamedTemporaryFile` pour les fichiers Docker + + +--- + + +## 12. `test_code_generator_tools.py` — Code generator tools + +**Type**: Unit test (pure AST and string logic) + +| Class | # Tests | What is validated | +|---|---|---| +| `TestCountSpaceTab` | 9 | Indentation counting: spaces, tabs, mixed, `\n`/`\r`, custom group_space | +| `TestExtractLambda` | 2 | Lambda expression extraction from AST node, outer parentheses removal | +| `TestFillSearchField` | 11 | Recursive AST value extraction: Constant (str, int, bool), UnaryOp (negative), Name, Attribute, List, Dict, Tuple, Lambda, unsupported type | +| `TestSearchAndReplace` | 4 | Value replacement in Python code: quoted value, empty model, missing keyword, custom keyword | +| `TestArgsTypeParam` | 6 | ARGS_TYPE_PARAM dictionary validation for Odoo field types | + +**Mocks**: None — pure functions on AST and strings + + +## 12. `test_code_generator_tools.py` — Outils du générateur de code + +**Type** : Test unitaire (pure logique AST et string) + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestCountSpaceTab` | 9 | Comptage d'indentation : espaces, tabulations, mixte, `\n`/`\r`, group_space personnalisé | +| `TestExtractLambda` | 2 | Extraction d'expression lambda depuis un nœud AST, suppression des parenthèses externes | +| `TestFillSearchField` | 11 | Extraction récursive de valeurs AST : Constant (str, int, bool), UnaryOp (négatif), Name, Attribute, List, Dict, Tuple, Lambda, type non supporté | +| `TestSearchAndReplace` | 4 | Remplacement de valeurs dans du code Python : valeur entre guillemets, modèle vide, mot-clé absent, mot-clé personnalisé | +| `TestArgsTypeParam` | 6 | Validation du dictionnaire ARGS_TYPE_PARAM pour les types de champs Odoo | + +**Mocks** : Aucun — fonctions pures sur AST et chaînes de caractères + + +--- + + +## 13. `test_database_tools.py` — Database tools + +**Type**: Unit test (temporary files) + +| Class | # Tests | What is validated | +|---|---|---| +| `TestProcessZip` | 4 | ZIP file processing: line removal by keyword, preservation when no match, total removal, non-targeted files intact | +| `TestCompareDatabaseApplicationLogic` | 4 | CSV set comparison: identical CSVs, different, empty, one empty | + +**Mocks**: None — uses `tempfile` and `zipfile` for temporary files + + +## 13. `test_database_tools.py` — Outils de base de données + +**Type** : Test unitaire (fichiers temporaires) + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestProcessZip` | 4 | Traitement de fichiers ZIP : suppression de lignes par mot-clé, préservation quand pas de correspondance, suppression totale, fichiers non ciblés intacts | +| `TestCompareDatabaseApplicationLogic` | 4 | Comparaison de CSV par ensembles : CSVs identiques, différents, vides, un seul vide | + +**Mocks** : Aucun — utilise `tempfile` et `zipfile` pour les fichiers temporaires + + +--- + + +## Coverage matrix by component + +| Tested component | Source file | Test file | Type | +|---|---|---|---| +| `ConfigFile` | `script/config/config_file.py` | `test_config_file.py` | Unit | +| `Execute` | `script/execute/execute.py` | `test_execute.py` | Integration | +| `GitTool` | `script/git/git_tool.py` | `test_git_tool.py` | Unit | +| `kill_process_by_port` | `script/process/kill_process_by_port.py` | `test_kill_process_by_port.py` | Unit | +| `TODO` | `script/todo/todo.py` | `test_todo.py` | Unit | +| `todo_i18n` | `script/todo/todo_i18n.py` | `test_todo_i18n.py` | Unit | +| `check_addons_exist` | `script/addons/check_addons_exist.py` | `test_check_addons_exist.py` | Unit | +| `iscompatible` | `script/poetry/iscompatible.py` | `test_iscompatible.py` | Unit | +| `format_file_to_commit` | `script/maintenance/format_file_to_commit.py` | `test_format_file_to_commit.py` | Unit + Integration | +| `update_env_version` | `script/version/update_env_version.py` | `test_version.py` | Unit | +| `docker_update_version` | `script/docker/docker_update_version.py` | `test_docker_update_version.py` | Unit | +| Code Generator (tools) | `script/code_generator/search_class_model.py` | `test_code_generator_tools.py` | Unit | +| Database (tools) | `script/database/migrate/process_backup_file.py` | `test_database_tools.py` | Unit | + +## Partially covered components + +The following components have tests for their pure functions, but functions requiring infrastructure (Odoo, PostgreSQL, network) are not unit tested: + +- `script/database/` — `process_zip` and CSV logic tested; `db_restore`, `image_db`, `list_remote` not tested (require Odoo/PostgreSQL) +- `script/code_generator/` — `extract_lambda`, `fill_search_field`, `search_and_replace`, `count_space_tab` tested; `generate_module`, `main` not tested (require Odoo) +- `script/poetry/` — `iscompatible`, `parse_requirements`, `string_to_tuple` tested; `combine_requirements`, `poetry_update.main` not tested (require full filesystem) + + +## Matrice de couverture par composant + +| Composant testé | Fichier source | Fichier test | Type | +|---|---|---|---| +| `ConfigFile` | `script/config/config_file.py` | `test_config_file.py` | Unitaire | +| `Execute` | `script/execute/execute.py` | `test_execute.py` | Intégration | +| `GitTool` | `script/git/git_tool.py` | `test_git_tool.py` | Unitaire | +| `kill_process_by_port` | `script/process/kill_process_by_port.py` | `test_kill_process_by_port.py` | Unitaire | +| `TODO` | `script/todo/todo.py` | `test_todo.py` | Unitaire | +| `todo_i18n` | `script/todo/todo_i18n.py` | `test_todo_i18n.py` | Unitaire | +| `check_addons_exist` | `script/addons/check_addons_exist.py` | `test_check_addons_exist.py` | Unitaire | +| `iscompatible` | `script/poetry/iscompatible.py` | `test_iscompatible.py` | Unitaire | +| `format_file_to_commit` | `script/maintenance/format_file_to_commit.py` | `test_format_file_to_commit.py` | Unitaire + Intégration | +| `update_env_version` | `script/version/update_env_version.py` | `test_version.py` | Unitaire | +| `docker_update_version` | `script/docker/docker_update_version.py` | `test_docker_update_version.py` | Unitaire | +| Code Generator (tools) | `script/code_generator/search_class_model.py` | `test_code_generator_tools.py` | Unitaire | +| Database (tools) | `script/database/migrate/process_backup_file.py` | `test_database_tools.py` | Unitaire | + +## Composants partiellement couverts + +Les composants suivants ont des tests pour leurs fonctions pures, mais les fonctions nécessitant une infrastructure (Odoo, PostgreSQL, réseau) ne sont pas testées unitairement : + +- `script/database/` — `process_zip` et logique CSV testés ; `db_restore`, `image_db`, `list_remote` non testés (requièrent Odoo/PostgreSQL) +- `script/code_generator/` — `extract_lambda`, `fill_search_field`, `search_and_replace`, `count_space_tab` testés ; `generate_module`, `main` non testés (requièrent Odoo) +- `script/poetry/` — `iscompatible`, `parse_requirements`, `string_to_tuple` testés ; `combine_requirements`, `poetry_update.main` non testés (requièrent le filesystem complet) + + +--- + + +## Test patterns used + +| Pattern | Usage | +|---|---| +| **Mocking (`@patch`)** | System dependency isolation (psutil, shutil, subprocess, os.path, files) | +| **Temporary files** | `tempfile.mkdtemp()` and `tempfile.NamedTemporaryFile()` for side-effect-free I/O testing | +| **Helper factory** | `_make_proc()` in test_kill_process, `_mock_git()` in test_format, `_write_csv()` in test_database | +| **Isolated state** | `setUp/tearDown` to reset globals between tests | +| **Real execution** | `test_execute.py` and `TestExecuteShell` launch real bash processes | +| **SimpleNamespace** | Lightweight config creation without argparse in test_docker | +| **AST inline** | `ast.parse(code, mode="eval").body` to create test AST nodes | + + +## Patterns de test utilisés + +| Pattern | Usage | +|---|---| +| **Mocking (`@patch`)** | Isolation des dépendances système (psutil, shutil, subprocess, os.path, fichiers) | +| **Fichiers temporaires** | `tempfile.mkdtemp()` et `tempfile.NamedTemporaryFile()` pour tester l'I/O sans effets de bord | +| **Helper factory** | `_make_proc()` dans test_kill_process, `_mock_git()` dans test_format, `_write_csv()` dans test_database | +| **État isolé** | `setUp/tearDown` pour réinitialiser les globals entre tests | +| **Exécution réelle** | `test_execute.py` et `TestExecuteShell` lancent de vrais processus bash | +| **SimpleNamespace** | Création de configs légères sans argparse dans test_docker | +| **AST inline** | `ast.parse(code, mode="eval").body` pour créer des nœuds AST de test | diff --git a/test/test_plan.fr.md b/test/test_plan.fr.md new file mode 100644 index 0000000..110ac89 --- /dev/null +++ b/test/test_plan.fr.md @@ -0,0 +1,264 @@ + +# Plan de test — ERPLibre `./test/` + +## Vue d'ensemble + +| Metric / Métrique | Value / Valeur | +|---|---| +| Test files / Fichiers de test | 13 | +| Test classes / Classes de test | 56 | +| Test methods / Méthodes de test | 262 | +| Framework | `unittest` (Python stdlib) | +| Command / Commande | `python3 -m unittest discover -s test -v` | + +--- + +## 1. `test_config_file.py` — Configuration et fusion de fichiers + +**Type** : Test unitaire (pure logique, aucun I/O réel) + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestDeepMergeWithLists` | 11 | Fusion récursive de dicts avec stratégies de listes (`replace` vs `extend`), immutabilité du dict source | +| `TestGetConfig` | 6 | Chargement et fusion de configs depuis base + override + private JSON, précédence correcte | +| `TestGetConfigValue` | 2 | Navigation dans les valeurs imbriquées par chemin de clés | +| `TestGetLogoAsciiFilePath` | 1 | Retourne le chemin du fichier logo | + +**Mocks** : `@patch` sur les chemins CONFIG_FILE, fichiers temporaires JSON + +--- + +## 2. `test_execute.py` — Exécution de commandes shell + +**Type** : Test d'intégration (exécute de vrais processus bash) + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestExecuteInit` | 3 | Détection du terminal (gnome-terminal, osascript), configuration du venv | +| `TestExecCommandLive` | 15 | Exécution de commandes avec différents modes de retour (status, output, commande), activation de venv, variables d'environnement custom | + +**Mocks** : `@patch("shutil.which")` pour le terminal. Les commandes bash sont exécutées réellement. + +--- + +## 3. `test_git_tool.py` — Utilitaires Git et parsing de manifests + +**Type** : Test unitaire + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestRepoAttrs` | 3 | Structure de données des attributs de repo | +| `TestGetUrl` | 3 | Conversion d'URL HTTPS ↔ SSH | +| `TestGetTransformedRepoInfo` | 10 | Extraction d'org, nom de repo, chemin depuis URL, forçage d'org, revision, clone_depth | +| `TestDefaultProperties` | 5 | Constantes par défaut, lecture de la version Odoo | +| `TestStrInsert` | 3 | Insertion de sous-chaîne à une position | +| `TestGetProjectConfig` | 1 | Lecture de `EL_GITHUB_TOKEN` depuis `env_var.sh` | +| `TestGetRepoInfoSubmodule` | 2 | Parsing de `.gitmodules` | +| `TestGetManifestXmlInfo` | 2 | Parsing de manifests XML Google Repo | +| `TestConstants` | 3 | Vérification des constantes définies | + +**Mocks** : `@patch("builtins.open")`, fichiers temporaires pour `.gitmodules` et XML + +--- + +## 4. `test_kill_process_by_port.py` — Gestion de processus + +**Type** : Test unitaire (processus mockés, aucun kill réel) + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestProcDesc` | 3 | Formatage de description de processus (pid, cmdline, username) | +| `TestGetAncestry` | 3 | Remontée de la chaîne de processus parents (arrêt à pid 1, ppid 0) | +| `TestChooseTarget` | 3 | Sélection du processus cible (détection de `./run.sh`, `./odoo_bin.sh`) | +| `TestKillProcess` | 2 | Terminaison de processus (gentle terminate vs force kill) | +| `TestKillTree` | 3 | Kill récursif de l'arbre de processus (enfants d'abord) | +| `TestFindListeners` | 5 | Énumération des sockets réseau par port (filtrage, dédoublonnage) | +| `TestProtectedNames` | 2 | Protection des processus système (systemd, gnome-shell, sshd) | +| `TestStopParentKill` | 2 | Détection des scripts wrapper | + +**Mocks** : `_make_proc()` crée des objets `psutil.Process` mockés, `@patch` sur les fonctions psutil + +--- + +## 5. `test_todo.py` — CLI interactif TODO + +**Type** : Test unitaire + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestTODOInit` | 1 | Initialisation de la classe TODO | +| `TestFillHelpInfo` | 3 | Génération de texte d'aide, résolution des clés i18n | +| `TestGetOdooVersion` | 3 | Parsing de `version.json`, détection de la version active | +| `TestOnDirSelected` | 1 | Sélection de répertoire | +| `TestExecuteFromConfiguration` | 5 | Exécution de commandes/makefiles/callbacks depuis config dict | +| `TestConstants` | 7 | Vérification des chemins constants | +| `TestDeployGitServer` | 2 | Logique de déploiement du git daemon | +| `TestProcessKillGitDaemon` | 1 | Kill du processus git daemon | +| `TestExecuteUnitTests` | 2 | Exécution des tests unitaires | +| `TestKdbxGetExtraCommandUser` | 3 | Intégration KeePass | +| `TestSetupClaudeCommit` | 1 | Setup du template de commit Claude | +| `TestSelectDatabase` | 2 | Sélection de base de données | +| `TestRestoreFromDatabase` | 2 | Restauration de base de données | +| `TestCreateBackupFromDatabase` | 1 | Création de backup | + +**Mocks** : `MagicMock()` pour Execute et database manager, fichiers temporaires + +--- + +## 6. `test_todo_i18n.py` — Internationalisation (FR/EN) + +**Type** : Test unitaire + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestTranslations` | 3 | Complétude du dictionnaire TRANSLATIONS (toutes les clés ont `fr` et `en`, aucune valeur vide) | +| `TestT` | 4 | Fonction `t()` : retourne la bonne langue, fallback sur la clé si inconnue | +| `TestGetLang` | 7 | Détection de langue : cache mémoire, lecture de `env_var.sh`, variable d'environnement, fallback à `"fr"` | +| `TestSetLang` | 4 | Persistance de la langue dans `env_var.sh`, gestion de fichier manquant | +| `TestLangIsConfigured` | 3 | Détection de l'état de configuration | + +**Mocks** : `@patch.object` sur `ENV_VAR_FILE`, `@patch.dict(os.environ)`, `setUp/tearDown` pour réinitialiser `_current_lang` + +--- + +## 7. `test_check_addons_exist.py` — Validation des addons Odoo + +**Type** : Test unitaire (fichiers temporaires, aucun Odoo requis) + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestMainModuleFound` | 3 | Module trouvé avec `__manifest__.py` valide, sortie JSON et JSON formaté | +| `TestMainModuleMissing` | 2 | Module absent retourne code 1, sortie JSON pour module manquant | +| `TestMainDuplicateModule` | 1 | Module en double dans plusieurs chemins retourne code 2 | +| `TestMainMissingManifest` | 1 | Répertoire sans `__manifest__.py` retourne code 1 | +| `TestMainBadConfig` | 2 | Config INI sans section `[options]` ou sans clé `addons_path` retourne -1 | + +**Mocks** : `@patch("sys.argv")`, fichiers temporaires pour addons et config INI + +--- + +## 8. `test_iscompatible.py` — Compatibilité de versions pip + +**Type** : Test unitaire (pure logique, aucun I/O) + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestStringToTuple` | 4 | Conversion de chaîne de version en tuple d'entiers | +| `TestParseRequirements` | 7 | Parsing de la syntaxe requirements.txt (opérateurs, contraintes multiples, commentaires) | +| `TestIsCompatible` | 11 | Vérification de compatibilité de version avec opérateurs `==`, `>=`, `>`, `<`, `<=`, `!=` et plages | + +**Mocks** : Aucun — fonctions pures + +--- + +## 9. `test_format_file_to_commit.py` — Détection de fichiers modifiés pour formatage + +**Type** : Test unitaire (git mocké) + Test d'intégration (`execute_shell` exécute de vrais processus) + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestExecuteShell` | 5 | Exécution de commandes shell : succès, échec (code retour), capture stderr, sortie vide, sortie multiligne | +| `TestGetModifiedFiles` | 9 | Parsing du `git status --porcelain` : fichier modifié, ajouté, supprimé (ignoré), ZIP/tar.gz (ignorés), non suivi, statut vide, statuts multiples, erreur git | + +**Mocks** : `@patch` sur `subprocess.run`, `os.path.exists`, `os.path.isfile` + +--- + +## 10. `test_version.py` — Gestion des versions ERPLibre + +**Type** : Test unitaire + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestRemoveDotPath` | 6 | Suppression du préfixe `./` des chemins | +| `TestDie` | 3 | Sortie conditionnelle avec code d'erreur configurable | +| `TestConstants` | 7 | Templates de noms de fichiers versionnés (venv, manifest, pyproject, poetry lock, addons, odoo) | +| `TestUpdateValidateVersion` | 5 | Résolution de version : explicite, par odoo_version, fallback par défaut, version détectée, chemins attendus | +| `TestUpdateDetectVersion` | 2 | Détection de version : fichiers absents, correspondance avec `data_version` | +| `TestUpdatePrintLog` | 2 | Affichage du log d'exécution vide et peuplé | + +**Mocks** : `@patch("sys.argv")`, fichiers temporaires pour les fichiers de version, `@patch` sur les constantes de chemin + +--- + +## 11. `test_docker_update_version.py` — Mise à jour de version Docker + +**Type** : Test unitaire (fichiers temporaires) + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestEditText` | 2 | Mise à jour de l'image dans `docker-compose.yml` après le service ERPLibre, préservation des autres lignes | +| `TestEditDockerProd` | 3 | Remplacement de la ligne `FROM` dans Dockerfile, préservation des lignes RUN/COPY, gestion multi-FROM | + +**Mocks** : Aucun — utilise `tempfile.NamedTemporaryFile` pour les fichiers Docker + +--- + +## 12. `test_code_generator_tools.py` — Outils du générateur de code + +**Type** : Test unitaire (pure logique AST et string) + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestCountSpaceTab` | 9 | Comptage d'indentation : espaces, tabulations, mixte, `\n`/`\r`, group_space personnalisé | +| `TestExtractLambda` | 2 | Extraction d'expression lambda depuis un nœud AST, suppression des parenthèses externes | +| `TestFillSearchField` | 11 | Extraction récursive de valeurs AST : Constant (str, int, bool), UnaryOp (négatif), Name, Attribute, List, Dict, Tuple, Lambda, type non supporté | +| `TestSearchAndReplace` | 4 | Remplacement de valeurs dans du code Python : valeur entre guillemets, modèle vide, mot-clé absent, mot-clé personnalisé | +| `TestArgsTypeParam` | 6 | Validation du dictionnaire ARGS_TYPE_PARAM pour les types de champs Odoo | + +**Mocks** : Aucun — fonctions pures sur AST et chaînes de caractères + +--- + +## 13. `test_database_tools.py` — Outils de base de données + +**Type** : Test unitaire (fichiers temporaires) + +| Classe | # Tests | Ce qui est validé | +|---|---|---| +| `TestProcessZip` | 4 | Traitement de fichiers ZIP : suppression de lignes par mot-clé, préservation quand pas de correspondance, suppression totale, fichiers non ciblés intacts | +| `TestCompareDatabaseApplicationLogic` | 4 | Comparaison de CSV par ensembles : CSVs identiques, différents, vides, un seul vide | + +**Mocks** : Aucun — utilise `tempfile` et `zipfile` pour les fichiers temporaires + +--- + +## Matrice de couverture par composant + +| Composant testé | Fichier source | Fichier test | Type | +|---|---|---|---| +| `ConfigFile` | `script/config/config_file.py` | `test_config_file.py` | Unitaire | +| `Execute` | `script/execute/execute.py` | `test_execute.py` | Intégration | +| `GitTool` | `script/git/git_tool.py` | `test_git_tool.py` | Unitaire | +| `kill_process_by_port` | `script/process/kill_process_by_port.py` | `test_kill_process_by_port.py` | Unitaire | +| `TODO` | `script/todo/todo.py` | `test_todo.py` | Unitaire | +| `todo_i18n` | `script/todo/todo_i18n.py` | `test_todo_i18n.py` | Unitaire | +| `check_addons_exist` | `script/addons/check_addons_exist.py` | `test_check_addons_exist.py` | Unitaire | +| `iscompatible` | `script/poetry/iscompatible.py` | `test_iscompatible.py` | Unitaire | +| `format_file_to_commit` | `script/maintenance/format_file_to_commit.py` | `test_format_file_to_commit.py` | Unitaire + Intégration | +| `update_env_version` | `script/version/update_env_version.py` | `test_version.py` | Unitaire | +| `docker_update_version` | `script/docker/docker_update_version.py` | `test_docker_update_version.py` | Unitaire | +| Code Generator (tools) | `script/code_generator/search_class_model.py` | `test_code_generator_tools.py` | Unitaire | +| Database (tools) | `script/database/migrate/process_backup_file.py` | `test_database_tools.py` | Unitaire | + +## Composants partiellement couverts + +Les composants suivants ont des tests pour leurs fonctions pures, mais les fonctions nécessitant une infrastructure (Odoo, PostgreSQL, réseau) ne sont pas testées unitairement : + +- `script/database/` — `process_zip` et logique CSV testés ; `db_restore`, `image_db`, `list_remote` non testés (requièrent Odoo/PostgreSQL) +- `script/code_generator/` — `extract_lambda`, `fill_search_field`, `search_and_replace`, `count_space_tab` testés ; `generate_module`, `main` non testés (requièrent Odoo) +- `script/poetry/` — `iscompatible`, `parse_requirements`, `string_to_tuple` testés ; `combine_requirements`, `poetry_update.main` non testés (requièrent le filesystem complet) + +--- + +## Patterns de test utilisés + +| Pattern | Usage | +|---|---| +| **Mocking (`@patch`)** | Isolation des dépendances système (psutil, shutil, subprocess, os.path, fichiers) | +| **Fichiers temporaires** | `tempfile.mkdtemp()` et `tempfile.NamedTemporaryFile()` pour tester l'I/O sans effets de bord | +| **Helper factory** | `_make_proc()` dans test_kill_process, `_mock_git()` dans test_format, `_write_csv()` dans test_database | +| **État isolé** | `setUp/tearDown` pour réinitialiser les globals entre tests | +| **Exécution réelle** | `test_execute.py` et `TestExecuteShell` lancent de vrais processus bash | +| **SimpleNamespace** | Création de configs légères sans argparse dans test_docker | +| **AST inline** | `ast.parse(code, mode="eval").body` pour créer des nœuds AST de test | \ No newline at end of file diff --git a/test/test_plan.md b/test/test_plan.md new file mode 100644 index 0000000..4bb8f9c --- /dev/null +++ b/test/test_plan.md @@ -0,0 +1,264 @@ + +# Test Plan — ERPLibre `./test/` + +## Overview + +| Metric / Métrique | Value / Valeur | +|---|---| +| Test files / Fichiers de test | 13 | +| Test classes / Classes de test | 56 | +| Test methods / Méthodes de test | 262 | +| Framework | `unittest` (Python stdlib) | +| Command / Commande | `python3 -m unittest discover -s test -v` | + +--- + +## 1. `test_config_file.py` — Configuration and file merging + +**Type**: Unit test (pure logic, no real I/O) + +| Class | # Tests | What is validated | +|---|---|---| +| `TestDeepMergeWithLists` | 11 | Recursive dict merging with list strategies (`replace` vs `extend`), source dict immutability | +| `TestGetConfig` | 6 | Loading and merging configs from base + override + private JSON, correct precedence | +| `TestGetConfigValue` | 2 | Navigating nested values by key path | +| `TestGetLogoAsciiFilePath` | 1 | Returns the logo file path | + +**Mocks**: `@patch` on CONFIG_FILE paths, temporary JSON files + +--- + +## 2. `test_execute.py` — Shell command execution + +**Type**: Integration test (executes real bash processes) + +| Class | # Tests | What is validated | +|---|---|---| +| `TestExecuteInit` | 3 | Terminal detection (gnome-terminal, osascript), venv configuration | +| `TestExecCommandLive` | 15 | Command execution with various return modes (status, output, command), venv activation, custom environment variables | + +**Mocks**: `@patch("shutil.which")` for terminal. Bash commands are actually executed. + +--- + +## 3. `test_git_tool.py` — Git utilities and manifest parsing + +**Type**: Unit test + +| Class | # Tests | What is validated | +|---|---|---| +| `TestRepoAttrs` | 3 | Repo attributes data structure | +| `TestGetUrl` | 3 | URL format conversion HTTPS ↔ SSH | +| `TestGetTransformedRepoInfo` | 10 | Org, repo name, path extraction from URL, org forcing, revision, clone_depth | +| `TestDefaultProperties` | 5 | Default constants, Odoo version reading | +| `TestStrInsert` | 3 | Substring insertion at position | +| `TestGetProjectConfig` | 1 | Reading `EL_GITHUB_TOKEN` from `env_var.sh` | +| `TestGetRepoInfoSubmodule` | 2 | Parsing `.gitmodules` | +| `TestGetManifestXmlInfo` | 2 | Parsing Google Repo manifest XML | +| `TestConstants` | 3 | Verifying defined constants | + +**Mocks**: `@patch("builtins.open")`, temporary files for `.gitmodules` and XML + +--- + +## 4. `test_kill_process_by_port.py` — Process management + +**Type**: Unit test (mocked processes, no real kill) + +| Class | # Tests | What is validated | +|---|---|---| +| `TestProcDesc` | 3 | Process description formatting (pid, cmdline, username) | +| `TestGetAncestry` | 3 | Parent process chain walking (stops at pid 1, ppid 0) | +| `TestChooseTarget` | 3 | Target process selection (detection of `./run.sh`, `./odoo_bin.sh`) | +| `TestKillProcess` | 2 | Process termination (gentle terminate vs force kill) | +| `TestKillTree` | 3 | Recursive process tree killing (children first) | +| `TestFindListeners` | 5 | Network socket enumeration by port (filtering, deduplication) | +| `TestProtectedNames` | 2 | System process protection (systemd, gnome-shell, sshd) | +| `TestStopParentKill` | 2 | Wrapper script detection | + +**Mocks**: `_make_proc()` creates mocked `psutil.Process` objects, `@patch` on psutil functions + +--- + +## 5. `test_todo.py` — Interactive CLI TODO + +**Type**: Unit test + +| Class | # Tests | What is validated | +|---|---|---| +| `TestTODOInit` | 1 | TODO class initialization | +| `TestFillHelpInfo` | 3 | Help text generation, i18n key resolution | +| `TestGetOdooVersion` | 3 | Parsing `version.json`, active version detection | +| `TestOnDirSelected` | 1 | Directory selection | +| `TestExecuteFromConfiguration` | 5 | Executing commands/makefiles/callbacks from config dict | +| `TestConstants` | 7 | Path constants verification | +| `TestDeployGitServer` | 2 | Git daemon deployment logic | +| `TestProcessKillGitDaemon` | 1 | Git daemon process killing | +| `TestExecuteUnitTests` | 2 | Unit test execution | +| `TestKdbxGetExtraCommandUser` | 3 | KeePass integration | +| `TestSetupClaudeCommit` | 1 | Claude commit template setup | +| `TestSelectDatabase` | 2 | Database selection | +| `TestRestoreFromDatabase` | 2 | Database restoration | +| `TestCreateBackupFromDatabase` | 1 | Backup creation | + +**Mocks**: `MagicMock()` for Execute and database manager, temporary files + +--- + +## 6. `test_todo_i18n.py` — Internationalization (FR/EN) + +**Type**: Unit test + +| Class | # Tests | What is validated | +|---|---|---| +| `TestTranslations` | 3 | TRANSLATIONS dictionary completeness (all keys have `fr` and `en`, no empty values) | +| `TestT` | 4 | `t()` function: returns correct language, falls back to key if unknown | +| `TestGetLang` | 7 | Language detection: memory cache, `env_var.sh` reading, environment variable, fallback to `"fr"` | +| `TestSetLang` | 4 | Language persistence in `env_var.sh`, missing file handling | +| `TestLangIsConfigured` | 3 | Configuration state detection | + +**Mocks**: `@patch.object` on `ENV_VAR_FILE`, `@patch.dict(os.environ)`, `setUp/tearDown` to reset `_current_lang` + +--- + +## 7. `test_check_addons_exist.py` — Odoo addon validation + +**Type**: Unit test (temporary files, no Odoo required) + +| Class | # Tests | What is validated | +|---|---|---| +| `TestMainModuleFound` | 3 | Module found with valid `__manifest__.py`, JSON and formatted JSON output | +| `TestMainModuleMissing` | 2 | Missing module returns code 1, JSON output for missing module | +| `TestMainDuplicateModule` | 1 | Duplicate module in multiple paths returns code 2 | +| `TestMainMissingManifest` | 1 | Directory without `__manifest__.py` returns code 1 | +| `TestMainBadConfig` | 2 | INI config without `[options]` section or without `addons_path` key returns -1 | + +**Mocks**: `@patch("sys.argv")`, temporary files for addons and INI config + +--- + +## 8. `test_iscompatible.py` — Pip version compatibility + +**Type**: Unit test (pure logic, no I/O) + +| Class | # Tests | What is validated | +|---|---|---| +| `TestStringToTuple` | 4 | Version string to integer tuple conversion | +| `TestParseRequirements` | 7 | Parsing requirements.txt syntax (operators, multiple constraints, comments) | +| `TestIsCompatible` | 11 | Version compatibility checking with operators `==`, `>=`, `>`, `<`, `<=`, `!=` and ranges | + +**Mocks**: None — pure functions + +--- + +## 9. `test_format_file_to_commit.py` — Modified file detection for formatting + +**Type**: Unit test (mocked git) + Integration test (`execute_shell` runs real processes) + +| Class | # Tests | What is validated | +|---|---|---| +| `TestExecuteShell` | 5 | Shell command execution: success, failure (return code), stderr capture, empty output, multiline output | +| `TestGetModifiedFiles` | 9 | Parsing `git status --porcelain`: modified, added, deleted (ignored), ZIP/tar.gz (ignored), untracked, empty status, multiple statuses, git error | + +**Mocks**: `@patch` on `subprocess.run`, `os.path.exists`, `os.path.isfile` + +--- + +## 10. `test_version.py` — ERPLibre version management + +**Type**: Unit test + +| Class | # Tests | What is validated | +|---|---|---| +| `TestRemoveDotPath` | 6 | Removing `./` prefix from paths | +| `TestDie` | 3 | Conditional exit with configurable error code | +| `TestConstants` | 7 | Versioned file name templates (venv, manifest, pyproject, poetry lock, addons, odoo) | +| `TestUpdateValidateVersion` | 5 | Version resolution: explicit, by odoo_version, default fallback, detected version, expected paths | +| `TestUpdateDetectVersion` | 2 | Version detection: missing files, matching with `data_version` | +| `TestUpdatePrintLog` | 2 | Execution log display (empty and populated) | + +**Mocks**: `@patch("sys.argv")`, temporary files for version files, `@patch` on path constants + +--- + +## 11. `test_docker_update_version.py` — Docker version update + +**Type**: Unit test (temporary files) + +| Class | # Tests | What is validated | +|---|---|---| +| `TestEditText` | 2 | Image update in `docker-compose.yml` after ERPLibre service, other lines preserved | +| `TestEditDockerProd` | 3 | `FROM` line replacement in Dockerfile, RUN/COPY lines preserved, multi-FROM handling | + +**Mocks**: None — uses `tempfile.NamedTemporaryFile` for Docker files + +--- + +## 12. `test_code_generator_tools.py` — Code generator tools + +**Type**: Unit test (pure AST and string logic) + +| Class | # Tests | What is validated | +|---|---|---| +| `TestCountSpaceTab` | 9 | Indentation counting: spaces, tabs, mixed, `\n`/`\r`, custom group_space | +| `TestExtractLambda` | 2 | Lambda expression extraction from AST node, outer parentheses removal | +| `TestFillSearchField` | 11 | Recursive AST value extraction: Constant (str, int, bool), UnaryOp (negative), Name, Attribute, List, Dict, Tuple, Lambda, unsupported type | +| `TestSearchAndReplace` | 4 | Value replacement in Python code: quoted value, empty model, missing keyword, custom keyword | +| `TestArgsTypeParam` | 6 | ARGS_TYPE_PARAM dictionary validation for Odoo field types | + +**Mocks**: None — pure functions on AST and strings + +--- + +## 13. `test_database_tools.py` — Database tools + +**Type**: Unit test (temporary files) + +| Class | # Tests | What is validated | +|---|---|---| +| `TestProcessZip` | 4 | ZIP file processing: line removal by keyword, preservation when no match, total removal, non-targeted files intact | +| `TestCompareDatabaseApplicationLogic` | 4 | CSV set comparison: identical CSVs, different, empty, one empty | + +**Mocks**: None — uses `tempfile` and `zipfile` for temporary files + +--- + +## Coverage matrix by component + +| Tested component | Source file | Test file | Type | +|---|---|---|---| +| `ConfigFile` | `script/config/config_file.py` | `test_config_file.py` | Unit | +| `Execute` | `script/execute/execute.py` | `test_execute.py` | Integration | +| `GitTool` | `script/git/git_tool.py` | `test_git_tool.py` | Unit | +| `kill_process_by_port` | `script/process/kill_process_by_port.py` | `test_kill_process_by_port.py` | Unit | +| `TODO` | `script/todo/todo.py` | `test_todo.py` | Unit | +| `todo_i18n` | `script/todo/todo_i18n.py` | `test_todo_i18n.py` | Unit | +| `check_addons_exist` | `script/addons/check_addons_exist.py` | `test_check_addons_exist.py` | Unit | +| `iscompatible` | `script/poetry/iscompatible.py` | `test_iscompatible.py` | Unit | +| `format_file_to_commit` | `script/maintenance/format_file_to_commit.py` | `test_format_file_to_commit.py` | Unit + Integration | +| `update_env_version` | `script/version/update_env_version.py` | `test_version.py` | Unit | +| `docker_update_version` | `script/docker/docker_update_version.py` | `test_docker_update_version.py` | Unit | +| Code Generator (tools) | `script/code_generator/search_class_model.py` | `test_code_generator_tools.py` | Unit | +| Database (tools) | `script/database/migrate/process_backup_file.py` | `test_database_tools.py` | Unit | + +## Partially covered components + +The following components have tests for their pure functions, but functions requiring infrastructure (Odoo, PostgreSQL, network) are not unit tested: + +- `script/database/` — `process_zip` and CSV logic tested; `db_restore`, `image_db`, `list_remote` not tested (require Odoo/PostgreSQL) +- `script/code_generator/` — `extract_lambda`, `fill_search_field`, `search_and_replace`, `count_space_tab` tested; `generate_module`, `main` not tested (require Odoo) +- `script/poetry/` — `iscompatible`, `parse_requirements`, `string_to_tuple` tested; `combine_requirements`, `poetry_update.main` not tested (require full filesystem) + +--- + +## Test patterns used + +| Pattern | Usage | +|---|---| +| **Mocking (`@patch`)** | System dependency isolation (psutil, shutil, subprocess, os.path, files) | +| **Temporary files** | `tempfile.mkdtemp()` and `tempfile.NamedTemporaryFile()` for side-effect-free I/O testing | +| **Helper factory** | `_make_proc()` in test_kill_process, `_mock_git()` in test_format, `_write_csv()` in test_database | +| **Isolated state** | `setUp/tearDown` to reset globals between tests | +| **Real execution** | `test_execute.py` and `TestExecuteShell` launch real bash processes | +| **SimpleNamespace** | Lightweight config creation without argparse in test_docker | +| **AST inline** | `ast.parse(code, mode="eval").body` to create test AST nodes | From 1e731114f5d22cdbab3be79f5f6990a304e9a19a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 11 Mar 2026 23:15:07 -0400 Subject: [PATCH 24/24] [IMP] script: update copyright year to 2026 Reflect the current year in all TechnoLibre license headers across script/, test/, and docker/. Generated by Claude Code 2.1.74 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- docker/repo_manifest_gen_org_prefix_path.py | 2 +- docker/wait-for-psql.py | 2 +- script/addons/check_addons_exist.py | 2 +- script/code/git_commit_migration_addons_path.py | 2 +- script/code/odoo_upgrade_code_with_dir_module.py | 2 +- script/code_generator/create_from_existing_module.py | 2 +- script/code_generator/new_project.py | 2 +- script/code_generator/search_class_model.py | 2 +- .../technical/transform_xml_data_website_page_to_controller.py | 2 +- script/code_generator/test_code_generator_update_module.py | 2 +- script/code_generator/test_transform_python_to_code_writer.py | 2 +- script/code_generator/transform_python_to_code_writer.py | 2 +- script/code_generator/transform_xml_to_code_writer.py | 2 +- script/config/config_file.py | 2 +- script/config/setup_odoo_config_conf_devops.py | 2 +- script/database/compare_backup.py | 2 +- script/database/compare_database_application.py | 2 +- script/database/db_drop_all.py | 2 +- script/database/db_restore.py | 2 +- script/database/download_remote.py | 2 +- script/database/fix_mariadb_sql_example_1.py | 2 +- script/database/get_module_list_from_database.py | 2 +- script/database/get_repo_from_backup.py | 2 +- script/database/get_repo_from_module.py | 2 +- script/database/image_db.py | 2 +- script/database/migrate/process_backup_file.py | 2 +- script/deployment/cloudflare_dns.py | 2 +- script/deployment/get_public_ip.py | 2 +- script/deployment/update_dns_cloudflare.py | 2 +- script/docker/docker_update_version.py | 2 +- script/execute/execute.py | 2 +- script/git/fork_project.py | 2 +- script/git/fork_project_ERPLibre.py | 2 +- script/git/git_change_remote.py | 2 +- script/git/git_change_remote_https_to_git.py | 2 +- script/git/git_diff_repo_manifest.py | 2 +- script/git/git_merge_repo_manifest.py | 2 +- script/git/git_repo_manifest.py | 2 +- script/git/git_repo_update_group.py | 2 +- script/git/git_show_code_diff_repo_manifest.py | 2 +- script/git/git_tool.py | 2 +- script/git/git_update_repo.py | 2 +- script/git/github_api.py | 2 +- script/git/pull_request_ERPLibre.py | 2 +- script/git/remote_code_generation_git_compare.py | 2 +- script/git/repo_remove_auto_install.py | 2 +- script/git/repo_revert_git_diff_date_from_code_generator.py | 2 +- script/git/repo_url.py | 2 +- script/git/tag_push_all.py | 2 +- script/ide/pycharm_configuration.py | 2 +- script/lib_asyncio.py | 2 +- script/maintenance/format_file_to_commit.py | 2 +- script/nginx/deploy_nginx_and_certbot.py | 2 +- script/odoo/migration/fix_migration_odoo140_to_odoo150.py | 2 +- script/odoo/util/show_installed_module.py | 2 +- script/poetry/poetry_update.py | 2 +- ...ation_postgresql_17_to_postgresql_18_module_mail_nov_2025.py | 2 +- script/process/kill_process_by_port.py | 2 +- script/restful/restful_example.py | 2 +- script/selenium/scenario/selenium_devops.py | 2 +- script/selenium/selenium_lib.py | 2 +- script/selenium/web_login.py | 2 +- script/statistic/show_evolution_module.py | 2 +- script/systemd/install_daemon.py | 2 +- script/test/run_parallel_test.py | 2 +- script/todo/database_manager.py | 2 +- script/todo/kdbx_manager.py | 2 +- script/todo/todo.py | 2 +- script/todo/todo_file_browser.py | 2 +- script/todo/todo_i18n.py | 2 +- script/todo/todo_upgrade.py | 2 +- script/todo/version_manager.py | 2 +- script/version/get_version.py | 2 +- script/version/update_env_version.py | 2 +- test/test_check_addons_exist.py | 2 +- test/test_code_generator_tools.py | 2 +- test/test_config_file.py | 2 +- test/test_database_tools.py | 2 +- test/test_docker_update_version.py | 2 +- test/test_execute.py | 2 +- test/test_format_file_to_commit.py | 2 +- test/test_git_tool.py | 2 +- test/test_iscompatible.py | 2 +- test/test_kill_process_by_port.py | 2 +- test/test_todo.py | 2 +- test/test_todo_i18n.py | 2 +- test/test_version.py | 2 +- 87 files changed, 87 insertions(+), 87 deletions(-) diff --git a/docker/repo_manifest_gen_org_prefix_path.py b/docker/repo_manifest_gen_org_prefix_path.py index a1269d3..f04f9db 100755 --- a/docker/repo_manifest_gen_org_prefix_path.py +++ b/docker/repo_manifest_gen_org_prefix_path.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/docker/wait-for-psql.py b/docker/wait-for-psql.py index e423fec..d763b77 100755 --- a/docker/wait-for-psql.py +++ b/docker/wait-for-psql.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/addons/check_addons_exist.py b/script/addons/check_addons_exist.py index 11ff625..441a222 100755 --- a/script/addons/check_addons_exist.py +++ b/script/addons/check_addons_exist.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/code/git_commit_migration_addons_path.py b/script/code/git_commit_migration_addons_path.py index b26849f..a725200 100755 --- a/script/code/git_commit_migration_addons_path.py +++ b/script/code/git_commit_migration_addons_path.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2025 TechnoLibre (http://www.technolibre.ca) +# © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/code/odoo_upgrade_code_with_dir_module.py b/script/code/odoo_upgrade_code_with_dir_module.py index fd5ac32..c4fc6b6 100755 --- a/script/code/odoo_upgrade_code_with_dir_module.py +++ b/script/code/odoo_upgrade_code_with_dir_module.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2025 TechnoLibre (http://www.technolibre.ca) +# © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/code_generator/create_from_existing_module.py b/script/code_generator/create_from_existing_module.py index bf451be..44072ae 100755 --- a/script/code_generator/create_from_existing_module.py +++ b/script/code_generator/create_from_existing_module.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/code_generator/new_project.py b/script/code_generator/new_project.py index f6797d6..46c88a2 100755 --- a/script/code_generator/new_project.py +++ b/script/code_generator/new_project.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/code_generator/search_class_model.py b/script/code_generator/search_class_model.py index 8b4a928..3d2cd68 100755 --- a/script/code_generator/search_class_model.py +++ b/script/code_generator/search_class_model.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/code_generator/technical/transform_xml_data_website_page_to_controller.py b/script/code_generator/technical/transform_xml_data_website_page_to_controller.py index 0b0054f..25864f5 100644 --- a/script/code_generator/technical/transform_xml_data_website_page_to_controller.py +++ b/script/code_generator/technical/transform_xml_data_website_page_to_controller.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/code_generator/test_code_generator_update_module.py b/script/code_generator/test_code_generator_update_module.py index 91040a5..cb9ebb1 100755 --- a/script/code_generator/test_code_generator_update_module.py +++ b/script/code_generator/test_code_generator_update_module.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/code_generator/test_transform_python_to_code_writer.py b/script/code_generator/test_transform_python_to_code_writer.py index 2dff2a8..8c09752 100644 --- a/script/code_generator/test_transform_python_to_code_writer.py +++ b/script/code_generator/test_transform_python_to_code_writer.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/code_generator/transform_python_to_code_writer.py b/script/code_generator/transform_python_to_code_writer.py index 7f80723..d18258c 100755 --- a/script/code_generator/transform_python_to_code_writer.py +++ b/script/code_generator/transform_python_to_code_writer.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/code_generator/transform_xml_to_code_writer.py b/script/code_generator/transform_xml_to_code_writer.py index b35f49d..1cc38c1 100644 --- a/script/code_generator/transform_xml_to_code_writer.py +++ b/script/code_generator/transform_xml_to_code_writer.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/config/config_file.py b/script/config/config_file.py index fc29c92..3420974 100644 --- a/script/config/config_file.py +++ b/script/config/config_file.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import json diff --git a/script/config/setup_odoo_config_conf_devops.py b/script/config/setup_odoo_config_conf_devops.py index bc6af0f..09eb4a8 100755 --- a/script/config/setup_odoo_config_conf_devops.py +++ b/script/config/setup_odoo_config_conf_devops.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/database/compare_backup.py b/script/database/compare_backup.py index 52114bf..ea777b7 100755 --- a/script/database/compare_backup.py +++ b/script/database/compare_backup.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/database/compare_database_application.py b/script/database/compare_database_application.py index 15f1ac4..e8e0d20 100755 --- a/script/database/compare_database_application.py +++ b/script/database/compare_database_application.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/database/db_drop_all.py b/script/database/db_drop_all.py index f5a4eeb..6ce1622 100755 --- a/script/database/db_drop_all.py +++ b/script/database/db_drop_all.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/database/db_restore.py b/script/database/db_restore.py index 5b093c8..e377394 100755 --- a/script/database/db_restore.py +++ b/script/database/db_restore.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/database/download_remote.py b/script/database/download_remote.py index 12e12b5..4b6b13e 100644 --- a/script/database/download_remote.py +++ b/script/database/download_remote.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) print( diff --git a/script/database/fix_mariadb_sql_example_1.py b/script/database/fix_mariadb_sql_example_1.py index 333baff..52e3a13 100755 --- a/script/database/fix_mariadb_sql_example_1.py +++ b/script/database/fix_mariadb_sql_example_1.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from collections import defaultdict diff --git a/script/database/get_module_list_from_database.py b/script/database/get_module_list_from_database.py index 9819aaf..2408c5c 100755 --- a/script/database/get_module_list_from_database.py +++ b/script/database/get_module_list_from_database.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/database/get_repo_from_backup.py b/script/database/get_repo_from_backup.py index 4b17782..bca5337 100755 --- a/script/database/get_repo_from_backup.py +++ b/script/database/get_repo_from_backup.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/database/get_repo_from_module.py b/script/database/get_repo_from_module.py index 7d7b326..74d4182 100755 --- a/script/database/get_repo_from_module.py +++ b/script/database/get_repo_from_module.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/database/image_db.py b/script/database/image_db.py index 757debb..a4fb1bc 100755 --- a/script/database/image_db.py +++ b/script/database/image_db.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/database/migrate/process_backup_file.py b/script/database/migrate/process_backup_file.py index b6a96e9..6ce8d87 100755 --- a/script/database/migrate/process_backup_file.py +++ b/script/database/migrate/process_backup_file.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/deployment/cloudflare_dns.py b/script/deployment/cloudflare_dns.py index 48cead1..80cdf2d 100755 --- a/script/deployment/cloudflare_dns.py +++ b/script/deployment/cloudflare_dns.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/deployment/get_public_ip.py b/script/deployment/get_public_ip.py index d71307c..23d9766 100755 --- a/script/deployment/get_public_ip.py +++ b/script/deployment/get_public_ip.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import requests diff --git a/script/deployment/update_dns_cloudflare.py b/script/deployment/update_dns_cloudflare.py index 8490ba7..77992f4 100755 --- a/script/deployment/update_dns_cloudflare.py +++ b/script/deployment/update_dns_cloudflare.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/docker/docker_update_version.py b/script/docker/docker_update_version.py index ce18cd3..866734a 100755 --- a/script/docker/docker_update_version.py +++ b/script/docker/docker_update_version.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/execute/execute.py b/script/execute/execute.py index e37259f..887a8e9 100644 --- a/script/execute/execute.py +++ b/script/execute/execute.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import datetime diff --git a/script/git/fork_project.py b/script/git/fork_project.py index b9b6c5f..ebf8702 100755 --- a/script/git/fork_project.py +++ b/script/git/fork_project.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/git/fork_project_ERPLibre.py b/script/git/fork_project_ERPLibre.py index a91a034..f5b6952 100755 --- a/script/git/fork_project_ERPLibre.py +++ b/script/git/fork_project_ERPLibre.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/git/git_change_remote.py b/script/git/git_change_remote.py index 8158267..2d4a536 100755 --- a/script/git/git_change_remote.py +++ b/script/git/git_change_remote.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/git/git_change_remote_https_to_git.py b/script/git/git_change_remote_https_to_git.py index 3cff9a8..2ec7660 100755 --- a/script/git/git_change_remote_https_to_git.py +++ b/script/git/git_change_remote_https_to_git.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/git/git_diff_repo_manifest.py b/script/git/git_diff_repo_manifest.py index c112874..7894d80 100755 --- a/script/git/git_diff_repo_manifest.py +++ b/script/git/git_diff_repo_manifest.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/git/git_merge_repo_manifest.py b/script/git/git_merge_repo_manifest.py index 4926000..7e1a115 100755 --- a/script/git/git_merge_repo_manifest.py +++ b/script/git/git_merge_repo_manifest.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/git/git_repo_manifest.py b/script/git/git_repo_manifest.py index 06b602f..171c32c 100755 --- a/script/git/git_repo_manifest.py +++ b/script/git/git_repo_manifest.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/git/git_repo_update_group.py b/script/git/git_repo_update_group.py index 782661a..313b724 100755 --- a/script/git/git_repo_update_group.py +++ b/script/git/git_repo_update_group.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/git/git_show_code_diff_repo_manifest.py b/script/git/git_show_code_diff_repo_manifest.py index dc18d84..d0e48aa 100755 --- a/script/git/git_show_code_diff_repo_manifest.py +++ b/script/git/git_show_code_diff_repo_manifest.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/git/git_tool.py b/script/git/git_tool.py index 1f2bd6a..ccaabe3 100644 --- a/script/git/git_tool.py +++ b/script/git/git_tool.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import os diff --git a/script/git/git_update_repo.py b/script/git/git_update_repo.py index aa94fab..3714a93 100755 --- a/script/git/git_update_repo.py +++ b/script/git/git_update_repo.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/git/github_api.py b/script/git/github_api.py index d409757..2431c76 100644 --- a/script/git/github_api.py +++ b/script/git/github_api.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import git diff --git a/script/git/pull_request_ERPLibre.py b/script/git/pull_request_ERPLibre.py index aa11e39..a9970f6 100755 --- a/script/git/pull_request_ERPLibre.py +++ b/script/git/pull_request_ERPLibre.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/git/remote_code_generation_git_compare.py b/script/git/remote_code_generation_git_compare.py index 48e79be..ae3e8bd 100755 --- a/script/git/remote_code_generation_git_compare.py +++ b/script/git/remote_code_generation_git_compare.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/git/repo_remove_auto_install.py b/script/git/repo_remove_auto_install.py index 9c5423e..4d43c60 100755 --- a/script/git/repo_remove_auto_install.py +++ b/script/git/repo_remove_auto_install.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/git/repo_revert_git_diff_date_from_code_generator.py b/script/git/repo_revert_git_diff_date_from_code_generator.py index b7fdd34..ac22276 100755 --- a/script/git/repo_revert_git_diff_date_from_code_generator.py +++ b/script/git/repo_revert_git_diff_date_from_code_generator.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/git/repo_url.py b/script/git/repo_url.py index 29bc270..99e6bc1 100644 --- a/script/git/repo_url.py +++ b/script/git/repo_url.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import os diff --git a/script/git/tag_push_all.py b/script/git/tag_push_all.py index 79732c7..fb81a65 100755 --- a/script/git/tag_push_all.py +++ b/script/git/tag_push_all.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/ide/pycharm_configuration.py b/script/ide/pycharm_configuration.py index c46083d..45dda12 100755 --- a/script/ide/pycharm_configuration.py +++ b/script/ide/pycharm_configuration.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/lib_asyncio.py b/script/lib_asyncio.py index 661a59d..f5e4efc 100644 --- a/script/lib_asyncio.py +++ b/script/lib_asyncio.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import asyncio diff --git a/script/maintenance/format_file_to_commit.py b/script/maintenance/format_file_to_commit.py index ad8d3d5..0e23409 100755 --- a/script/maintenance/format_file_to_commit.py +++ b/script/maintenance/format_file_to_commit.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import os diff --git a/script/nginx/deploy_nginx_and_certbot.py b/script/nginx/deploy_nginx_and_certbot.py index c6db1b6..b8c17ab 100755 --- a/script/nginx/deploy_nginx_and_certbot.py +++ b/script/nginx/deploy_nginx_and_certbot.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse import logging diff --git a/script/odoo/migration/fix_migration_odoo140_to_odoo150.py b/script/odoo/migration/fix_migration_odoo140_to_odoo150.py index 21821eb..e8e1ebd 100644 --- a/script/odoo/migration/fix_migration_odoo140_to_odoo150.py +++ b/script/odoo/migration/fix_migration_odoo140_to_odoo150.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) # This script need to be run when upgrade 14 to 15 when database is created from ERPLibre. diff --git a/script/odoo/util/show_installed_module.py b/script/odoo/util/show_installed_module.py index 00f7b6e..8de0454 100644 --- a/script/odoo/util/show_installed_module.py +++ b/script/odoo/util/show_installed_module.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) installed_modules = env["ir.module.module"].search([('state', '=', 'installed')]) diff --git a/script/poetry/poetry_update.py b/script/poetry/poetry_update.py index f0dac88..e918885 100755 --- a/script/poetry/poetry_update.py +++ b/script/poetry/poetry_update.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/postgresql/migration/fix_migration_postgresql_17_to_postgresql_18_module_mail_nov_2025.py b/script/postgresql/migration/fix_migration_postgresql_17_to_postgresql_18_module_mail_nov_2025.py index 10eec3e..c137af0 100644 --- a/script/postgresql/migration/fix_migration_postgresql_17_to_postgresql_18_module_mail_nov_2025.py +++ b/script/postgresql/migration/fix_migration_postgresql_17_to_postgresql_18_module_mail_nov_2025.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) # This script fix the module mail when migrating postgresql 17 to postgresql 18 diff --git a/script/process/kill_process_by_port.py b/script/process/kill_process_by_port.py index b448fa5..fa098c1 100755 --- a/script/process/kill_process_by_port.py +++ b/script/process/kill_process_by_port.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse import sys diff --git a/script/restful/restful_example.py b/script/restful/restful_example.py index c9fc4d9..a896dfa 100755 --- a/script/restful/restful_example.py +++ b/script/restful/restful_example.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import json diff --git a/script/selenium/scenario/selenium_devops.py b/script/selenium/scenario/selenium_devops.py index d241d84..4ddddc9 100755 --- a/script/selenium/scenario/selenium_devops.py +++ b/script/selenium/scenario/selenium_devops.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/selenium/selenium_lib.py b/script/selenium/selenium_lib.py index 344c32d..21dba48 100644 --- a/script/selenium/selenium_lib.py +++ b/script/selenium/selenium_lib.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import datetime diff --git a/script/selenium/web_login.py b/script/selenium/web_login.py index 1a8c708..58d3a07 100755 --- a/script/selenium/web_login.py +++ b/script/selenium/web_login.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/statistic/show_evolution_module.py b/script/statistic/show_evolution_module.py index e0bf450..71d1b4a 100755 --- a/script/statistic/show_evolution_module.py +++ b/script/statistic/show_evolution_module.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/systemd/install_daemon.py b/script/systemd/install_daemon.py index 6ed8332..9f0ef6a 100755 --- a/script/systemd/install_daemon.py +++ b/script/systemd/install_daemon.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/test/run_parallel_test.py b/script/test/run_parallel_test.py index 6fd9966..c89417a 100755 --- a/script/test/run_parallel_test.py +++ b/script/test/run_parallel_test.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/todo/database_manager.py b/script/todo/database_manager.py index ca99042..8011cb4 100644 --- a/script/todo/database_manager.py +++ b/script/todo/database_manager.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import datetime diff --git a/script/todo/kdbx_manager.py b/script/todo/kdbx_manager.py index b495f21..ea54710 100644 --- a/script/todo/kdbx_manager.py +++ b/script/todo/kdbx_manager.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import getpass diff --git a/script/todo/todo.py b/script/todo/todo.py index 86451d0..16e758a 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import configparser diff --git a/script/todo/todo_file_browser.py b/script/todo/todo_file_browser.py index c29a614..282c9e3 100644 --- a/script/todo/todo_file_browser.py +++ b/script/todo/todo_file_browser.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import os diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 8b9fbab..a4f3ca3 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import os diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 5a89e40..0c806c9 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import datetime diff --git a/script/todo/version_manager.py b/script/todo/version_manager.py index 46739e3..bab9315 100644 --- a/script/todo/version_manager.py +++ b/script/todo/version_manager.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import json diff --git a/script/version/get_version.py b/script/version/get_version.py index ff503ea..7458ab4 100755 --- a/script/version/get_version.py +++ b/script/version/get_version.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import argparse diff --git a/script/version/update_env_version.py b/script/version/update_env_version.py index 5ad8d05..9831847 100755 --- a/script/version/update_env_version.py +++ b/script/version/update_env_version.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2021-2025 TechnoLibre (http://www.technolibre.ca) +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) # This script need only basic importation, it needs to be supported by python of your system diff --git a/test/test_check_addons_exist.py b/test/test_check_addons_exist.py index 8b79bdb..d0711f1 100644 --- a/test/test_check_addons_exist.py +++ b/test/test_check_addons_exist.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2025 TechnoLibre (http://www.technolibre.ca) +# © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import json diff --git a/test/test_code_generator_tools.py b/test/test_code_generator_tools.py index 34e97ed..4549d0d 100644 --- a/test/test_code_generator_tools.py +++ b/test/test_code_generator_tools.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2025 TechnoLibre (http://www.technolibre.ca) +# © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import ast diff --git a/test/test_config_file.py b/test/test_config_file.py index 9e6b216..9ba030e 100644 --- a/test/test_config_file.py +++ b/test/test_config_file.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2025 TechnoLibre (http://www.technolibre.ca) +# © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import json diff --git a/test/test_database_tools.py b/test/test_database_tools.py index e7c95af..f85675c 100644 --- a/test/test_database_tools.py +++ b/test/test_database_tools.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2025 TechnoLibre (http://www.technolibre.ca) +# © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import csv diff --git a/test/test_docker_update_version.py b/test/test_docker_update_version.py index 72f745a..2df796b 100644 --- a/test/test_docker_update_version.py +++ b/test/test_docker_update_version.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2025 TechnoLibre (http://www.technolibre.ca) +# © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import os diff --git a/test/test_execute.py b/test/test_execute.py index 2119a6a..d15bd5c 100644 --- a/test/test_execute.py +++ b/test/test_execute.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2025 TechnoLibre (http://www.technolibre.ca) +# © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import os diff --git a/test/test_format_file_to_commit.py b/test/test_format_file_to_commit.py index 33a9134..fae5593 100644 --- a/test/test_format_file_to_commit.py +++ b/test/test_format_file_to_commit.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2025 TechnoLibre (http://www.technolibre.ca) +# © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import unittest diff --git a/test/test_git_tool.py b/test/test_git_tool.py index 95d6198..7fa6f7d 100644 --- a/test/test_git_tool.py +++ b/test/test_git_tool.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2025 TechnoLibre (http://www.technolibre.ca) +# © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import os diff --git a/test/test_iscompatible.py b/test/test_iscompatible.py index b3d2377..cf605cb 100644 --- a/test/test_iscompatible.py +++ b/test/test_iscompatible.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2025 TechnoLibre (http://www.technolibre.ca) +# © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import unittest diff --git a/test/test_kill_process_by_port.py b/test/test_kill_process_by_port.py index a377d32..ff516ef 100644 --- a/test/test_kill_process_by_port.py +++ b/test/test_kill_process_by_port.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2025 TechnoLibre (http://www.technolibre.ca) +# © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import unittest diff --git a/test/test_todo.py b/test/test_todo.py index 2a460c9..9e1fb4a 100644 --- a/test/test_todo.py +++ b/test/test_todo.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2025 TechnoLibre (http://www.technolibre.ca) +# © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import json diff --git a/test/test_todo_i18n.py b/test/test_todo_i18n.py index 5d0d400..1bb5777 100644 --- a/test/test_todo_i18n.py +++ b/test/test_todo_i18n.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2025 TechnoLibre (http://www.technolibre.ca) +# © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import os diff --git a/test/test_version.py b/test/test_version.py index 8912a9d..2d48072 100644 --- a/test/test_version.py +++ b/test/test_version.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# © 2025 TechnoLibre (http://www.technolibre.ca) +# © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import json