diff --git a/conf/template_claude_commands_commit.md b/conf/template_claude_commands_commit.md new file mode 100644 index 0000000..9ac2030 --- /dev/null +++ b/conf/template_claude_commands_commit.md @@ -0,0 +1,80 @@ +--- +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:*) + - Bash(python3:*) +--- + +## Context + +- Git status: !`git status` +- Full diff: !`git diff HEAD` +- Current branch: !`git branch --show-current` +- Last 5 commits (for style reference): !`git log --oneline -5` +- Claude Code version: !`claude --version 2>/dev/null | head -1` + +## Task + +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') +" +``` + +Then create an OCA/Odoo-compliant commit. + +### OCA Tags + +| Tag | Usage | +|-----|-------| +| `[IMP]` | Improvement / new feature | +| `[FIX]` | Bug fix | +| `[REF]` | Refactoring | +| `[ADD]` | New module | +| `[REM]` | Remove code/module | +| `[MOV]` | Move/rename | +| `[I18N]` | Translations | + +### Format +``` +[TAG] module_name: short description in imperative mood + +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} + +Co-Authored-By: Your Name +``` + +### Rules + +- **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 + +### Execute +```bash +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 " +``` 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 1885c96..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 @@ -24,42 +24,40 @@ _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 = {} + 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: - 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_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(): - find_in_private = True - dct_data = dct_data.get(param) - return dct_data + 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( @@ -87,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/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 8ea3355..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 @@ -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__), "..", "..") @@ -35,14 +35,14 @@ _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 = ( 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,32 +52,37 @@ 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, - 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]] ): """ - Exécute une commande et affiche la sortie en direct. + Execute a command and display its output live. Args: - commande (str): La commande à exécuter (sous forme de chaîne de caractères). + command (str): The command to execute. """ my_env = os.environ.copy() @@ -85,108 +90,99 @@ 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"): with open("./.erplibre-version") as f: 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 + bufsize=1, # Disable buffering for live output + universal_newlines=True, # Handle line breaks correctly 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 + process.wait() + exit_code = process.returncode if process.returncode != 0 and not quiet: - print( - "La commande a retourné un code d'erreur :" - f" {process.returncode}" - ) + print("Command returned error code:" 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" - " pas été trouvée." + f"Error: Command '{command.split(' ')[0]}'[...]" + " not found." ) else: - print( - f"Erreur : La commande '{commande}' n'a pas été trouvée." - ) + print(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.) :") 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/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_local_server.py b/script/git/git_local_server.py new file mode 100755 index 0000000..7fcc584 --- /dev/null +++ b/script/git/git_local_server.py @@ -0,0 +1,874 @@ +#!/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 asyncio +import logging +import os +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( + 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 +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, + 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( + "--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", + type=int, + default=DEFAULT_JOBS, + help=( + "Parallel jobs for init/remote/push" + f" (default: {DEFAULT_JOBS})" + ), + ) + 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 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, + } + + +# --- Async workers for init --- + + +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" + bare_path = os.path.join(git_path, repo_name) + + if os.path.exists(bare_path): + _logger.info(f" Exists: {bare_path}") + return "skipped" + + _logger.info(f" Creating: {bare_path}") + _, 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" + ) + open(export_file, "w").close() + + # Allow pushing from shallow clones + await _run_git( + "git", + "-C", + bare_path, + "config", + "receive.shallowUpdate", + "true", + ) + + return "created" + + +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): + """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) + + +async def _add_single_remote( + erplibre_root, + git_path, + project, + remote_name, + port, + remote_url_type, + semaphore, +): + """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}") + return "error" + + 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 + stdout, _, _ = await _run_git( + "git", "-C", repo_path, "remote" + ) + existing_remotes = stdout.strip().split("\n") + + if remote_name in existing_remotes: + _, 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']}") + return "updated" + else: + _, 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']}") + return "added" + + +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" + ) + + +# --- 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. + """ + branch = ( + revision.split("/")[-1] if "/" in revision else revision + ) + + # Check if local branch already exists + _, _, 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, + "checkout", + "-b", + branch, + ) + return branch + + +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.""" + 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 + ) + await _run_git( + "git", + "-C", + bare_path, + "symbolic-ref", + "HEAD", + f"refs/heads/{branch}", + ) + + +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).""" + 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}") + return "error", False + + # Handle shallow repos + shallow_file = os.path.join( + repo_path, ".git", "shallow" + ) + if os.path.exists(shallow_file): + 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", + bare_path, + "config", + "receive.shallowUpdate", + "true", + ) + _logger.info( + f" Shallow push for" + f" {project['path']}" + ) + + # Handle detached HEAD: checkout manifest branch + did_checkout = False + if await _is_detached_head(repo_path): + revision = project.get("revision", "") + if revision: + branch = await _checkout_manifest_branch( + repo_path, revision + ) + _logger.info( + f" Checkout {branch} for" + f" {project['path']}" + " (was detached HEAD)" + ) + did_checkout = True + else: + _logger.warning( + f" Detached HEAD with no revision" + f" for {project['path']}, skipping" + ) + return "error", False + + # Push + try: + if push_all_branches: + cmd = [ + "git", + "-C", + repo_path, + "push", + remote_name, + "--all", + ] + else: + cmd = [ + "git", + "-C", + repo_path, + "push", + remote_name, + ] + _, err, rc = await _run_git( + *cmd, timeout=120 + ) + if rc != 0: + _logger.warning( + f" Push failed for" + f" {project['path']}:" + f" {err.strip()}" + ) + return "error", did_checkout + else: + 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, + unshallow, + 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, + unshallow, + 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 ===") + base_url = ( + f"git://localhost:{port}" + if port != DEFAULT_PORT + else "git://localhost" + ) + lines = [] + 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): + clone_path = project["path"] + if clone_path == ".": + clone_path = "erplibre" + lines.append( + f" git clone {base_url}/{repo_name}" + f" {clone_path}" + ) + lines.sort() + for line in lines: + print(line) + print(f"\nTotal: {len(lines)} 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 = ( + 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") + + execute = Execute() + 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, + config.unshallow, + jobs, + ) + print() + + +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(f"Parallel jobs: {config.jobs}") + print() + + projects = parse_manifest(manifest_path) + 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() + + # Run async actions (init, remote, push) + asyncio.run(run_actions(config, erplibre_root, projects)) + + # Serve is synchronous (long-running daemon) + if config.action in ("serve", "all"): + print("=== Starting git daemon ===") + serve_git_daemon(config.path, projects, config.port) + + +if __name__ == "__main__": + main() diff --git a/script/git/git_merge_repo_manifest.py b/script/git/git_merge_repo_manifest.py index 7d60ca9..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 @@ -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( - dct_remote=dct_remote_total, - dct_project=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_repo_manifest.py b/script/git/git_repo_manifest.py index 2a08ead..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 @@ -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}", - dct_remote=dct_remote, - dct_project=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 1b64cf9..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 @@ -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, - lst_add_repo=lst_add_repo, - lst_whitelist=lst_whitelist, + add_repos=extra_repos, + whitelist=whitelist, ) 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 73d360a..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 @@ -16,14 +16,20 @@ 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" +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" 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) @@ -56,20 +62,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 +81,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("/")] - - d = { - "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 Struct(**d) - return d + 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, @@ -184,16 +128,16 @@ class GitTool: }] """ filename = os.path.join(repo_path, ".gitmodules") - lst_repo = [] + repos = [] with open(filename) as file: txt = file.readlines() 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 = { @@ -204,7 +148,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 +173,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 +187,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 +210,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 +219,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 +251,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 +266,7 @@ class GitTool: "name": name, "group": groups, } - lst_repo.append(data) + repos.append(data) if add_root: repo_root = Repo(repo_path) @@ -346,10 +289,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 @@ -359,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: @@ -368,25 +311,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="."): @@ -395,7 +338,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,20 +346,20 @@ class GitTool: txt = file.readlines() txt = [a[:-1] for a in txt if "=" in a] - lst_filter = [CST_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[CST_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 +369,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"]: @@ -460,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' @@ -472,14 +415,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 +444,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,83 +474,79 @@ class GitTool: def generate_repo_manifest( self, - lst_repo: List[Struct] = [], + 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(): - remote_name = dct_value.get("@name") - if remote_name not in lst_remote_name: - lst_remote.append( + 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")), ] ) ) - lst_remote_name.append(remote_name) - for dct_value in dct_project.values(): - lst_project_info = [ - ("@name", dct_value.get("@name")), - ("@path", dct_value.get("@path")), + remote_names.append(remote_name) + for entry in projects_config.values(): + project_attrs = [ + ("@name", entry.get("@name")), + ("@path", entry.get("@path")), ] - if "@remote" in dct_value.keys(): - lst_project_info.append(("@remote", dct_value.get("@remote"))) - if "@revision" in dct_value.keys(): - 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.keys(): - lst_project_info.append( - ("@clone-depth", dct_value.get("@clone-depth")) - ) - if "@groups" in dct_value.keys(): - lst_project_info.append(("@groups", dct_value.get("@groups"))) - if "@upstream" in dct_value.keys(): - lst_project_info.append( - ("@upstream", dct_value.get("@upstream")) - ) - if "@dest-branch" in dct_value.keys(): - 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")) ) - lst_project.append(OrderedDict(lst_project_info)) - lst_project_name.append(dct_value.get("@name")) + project_entries.append(OrderedDict(project_attrs)) + project_names.append(entry.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), @@ -618,10 +557,7 @@ class GitTool: ) ) else: - if ( - keep_original - and repo.project_name not in dct_project.keys() - ): + 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" @@ -629,8 +565,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,29 +574,29 @@ 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) - lst_project_info = [ + if repo.project_name not in project_names: + project_names.append(repo.project_name) + 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")) - lst_project.append(OrderedDict(lst_project_info)) + project_attrs.append(("@groups", "odoo")) + project_entries.append(OrderedDict(project_attrs)) - 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 +608,32 @@ 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") ) - lst_order_project = sorted( - lst_project, key=lambda key: key.get("@name") + key.get("@path") + 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"), ) - 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 +669,12 @@ class GitTool: print(str_xml_text + "\n") def generate_git_modules( - self, lst_repo: List[Struct], 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,12 +682,12 @@ 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): """ - 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,8 +699,8 @@ class GitTool: "name": name of the submodule }] """ - file_name = os.path.join(repo_path, CST_FILE_SOURCE_REPO_ADDONS) - lst_result = [] + file_name = os.path.join(repo_path, SOURCE_REPO_ADDONS_FILE) + results = [] if add_repo_root: # TODO what to do if origin not exist? repo = Repo(repo_path) @@ -769,7 +708,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 +751,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 +791,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 +815,51 @@ 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 - } - set_compare = set(dct_repo_info_compare.keys()) + compare_by_name = {a.get("name"): a for a in compare_repos} + 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 +871,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,142 +882,29 @@ 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( - 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 - :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/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 new file mode 100644 index 0000000..2431c76 --- /dev/null +++ b/script/git/github_api.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +# © 2021-2026 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/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 new file mode 100644 index 0000000..99e6bc1 --- /dev/null +++ b/script/git/repo_url.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# © 2021-2026 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 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 0610320..fa098c1 100755 --- a/script/process/kill_process_by_port.py +++ b/script/process/kill_process_by_port.py @@ -1,12 +1,12 @@ #!/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 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. @@ -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 = [] - for pid in chain: - lst_chain_parent.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]] + 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]] 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", @@ -163,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: @@ -176,7 +177,7 @@ def main(): if not chain: continue - target, lst_target = choose_target(chain, args.nb_parent) + target, target_chain = choose_target(chain, args.parent_depth) print(f"\nListener PID {pid} ancestry:") for i, p in enumerate(chain): @@ -188,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 @@ -207,9 +208,9 @@ def main(): while not has_response and not ignore_kill: confirm = ( input( - f"Tuer ce processus index {args.nb_parent} (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() @@ -233,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: @@ -243,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/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 new file mode 100644 index 0000000..8011cb4 --- /dev/null +++ b/script/todo/database_manager.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +# © 2021-2026 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) -> None: + self._execute = execute + self._fill_help_info = fill_help_info + self._dir_path: str | None = None + + def _on_dir_selected(self, path: str) -> None: + self._dir_path = path + + def select_database(self) -> str | bool: + 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: 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}") + 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: bool = True + ) -> None: + 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) -> str: + 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: 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" + 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..ea54710 --- /dev/null +++ b/script/todo/kdbx_manager.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# © 2021-2026 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) -> None: + 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: str | list | None + ) -> str | list: + 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.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..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 @@ -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 -file_error_path = ".erplibre.error.txt" -cst_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" +ERROR_LOG_PATH = ".erplibre.error.txt" +VENV_ERPLIBRE = ".venv.erplibre" ENABLE_CRASH = False CRASH_E = None # Support mobile ERPLibre @@ -83,10 +81,11 @@ LOGO_ASCII_FILE = "./script/todo/logo_ascii.txt" 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() + 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(): @@ -137,11 +136,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 +156,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) @@ -169,38 +168,6 @@ class TODO: print(status) # manipuler() - def get_kdbx(self): - if self.kdbx: - return self.kdbx - # Open file - chemin_fichier_kdbx = self.config_file.get_config_value( - ["kdbx", "path"] - ) - if not chemin_fichier_kdbx: - root = tk.Tk() - root.withdraw() # Hide the main window - chemin_fichier_kdbx = filedialog.askopenfilename( - title="Select a File", - filetypes=(("KeepassX files", "*.kdbx"),), - ) - if not chemin_fichier_kdbx: - _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"] - ) - if not mot_de_passe_kdbx: - mot_de_passe_kdbx = getpass.getpass(prompt=t("enter_password")) - - kp = PyKeePass(chemin_fichier_kdbx, password=mot_de_passe_kdbx) - - if kp: - self.kdbx = kp - return kp - def execute_prompt_ia(self): while True: help_info = f"""{t("command")} @@ -210,13 +177,13 @@ class TODO: print() if status == "0": return - kp = self.get_kdbx() + kp = self.kdbx_manager.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 @@ -230,18 +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_process")} -[8] {t("menu_config")} +[6] {t("menu_git")} +[7] {t("menu_gpt_code")} +[8] {t("menu_lang")} [9] {t("menu_network")} -[10] {t("menu_security")} -[11] {t("menu_test")} -[12] {t("menu_lang")} +[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: @@ -250,19 +219,19 @@ class TODO: if status == "0": return elif status == "1": - status = self.prompt_execute_instance() + status = self.prompt_execute_function() if status is not False: return elif status == "2": - status = self.prompt_execute_fonction() + 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": @@ -270,15 +239,15 @@ class TODO: if status is not False: return elif status == "6": - status = self.prompt_execute_database() + status = self.prompt_execute_git() if status is not False: return elif status == "7": - status = self.prompt_execute_process() + status = self.prompt_execute_gpt_code() if status is not False: return elif status == "8": - status = self.prompt_execute_config() + status = self._change_language() if status is not False: return elif status == "9": @@ -286,15 +255,23 @@ class TODO: if status is not False: return elif status == "10": - status = self.prompt_execute_security() + status = self.prompt_execute_process() if status is not False: return elif status == "11": - status = self.prompt_execute_test() + status = self.prompt_execute_instance() if status is not False: return elif status == "12": - status = self._change_language() + 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_update() if status is not False: return else: @@ -360,7 +337,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", @@ -381,43 +358,43 @@ class TODO: f"0: {t('menu_quit')}", ), } - dct_final_cmd_intern = {} - lst_version, lst_version_installed, odoo_installed_version = ( - self.get_odoo_version() + commands_end = {} + versions, installed_versions, odoo_installed_version = ( + 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 = ( @@ -427,7 +404,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 @@ -444,15 +421,17 @@ 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) + 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" @@ -461,7 +440,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}", @@ -475,30 +454,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 @@ -507,24 +486,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) @@ -538,27 +517,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) @@ -569,10 +548,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: @@ -589,35 +568,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: @@ -637,59 +616,229 @@ 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: print(t("cmd_not_found")) + def prompt_execute_git(self): + print(f"🤖 {t('git_manage')}") + choices = [ + {"prompt_description": t("git_local_server")}, + ] + + # Append config-driven entries + config_entries = self.config_file.get_config("git_from_makefile") + if config_entries: + choices.extend(config_entries) + + help_info = self.fill_help_info(choices) + + 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(choices): + cmd_no_found = False + instance = choices[int_cmd - 1] + self.execute_from_configuration(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')}") + choices = [ + {"prompt_description": t("git_repo_deploy_local")}, + {"prompt_description": t("git_repo_deploy_production")}, + ] + help_info = self.fill_help_info(choices) + + 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}") + 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(choices) + + 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_gpt_code(self): + print(f"🤖 {t('gpt_code_manage')}") + choices = [ + {"prompt_description": t("gpt_code_claude_commit")}, + ] + help_info = self.fill_help_info(choices) + + 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 = [ + 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) @@ -732,12 +881,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) @@ -745,20 +894,21 @@ 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")) 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) @@ -767,19 +917,28 @@ 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 = [ + 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) @@ -801,7 +960,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( @@ -809,7 +968,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) @@ -849,10 +1008,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) @@ -866,11 +1025,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) @@ -881,6 +1041,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")) @@ -970,19 +1132,35 @@ 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() + versions, installed_versions, odoo_installed_version = ( + 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 @@ -992,29 +1170,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() @@ -1022,7 +1200,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"] @@ -1058,14 +1236,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) @@ -1094,10 +1272,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) @@ -1113,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 @@ -1131,99 +1309,10 @@ class TODO: single_source_erplibre=True, ) - def select_database(self): - cmd_server = f"./odoo_bin.sh db --list" - status, lst_database = 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] - - help_info = self.fill_help_info(lst_choice) - - lst_str_choice = [str(a) for a in range(len(lst_choice) + 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() - print(database_name) - return database_name - 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}'" - ) - lst_version_transform = [] - for key, value in data_version.items(): - lst_version_transform.append(value) - value["erplibre_version"] = key - - lst_version_installed = [] - if os.path.exists(INSTALLED_ODOO_VERSION_FILE): - with open(INSTALLED_ODOO_VERSION_FILE) as txt: - lst_version_installed = 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 - lst_version = sorted( - lst_version_transform, key=lambda k: k.get("erplibre_version") - ) - - return lst_version, lst_version_installed, odoo_installed_version - - def kdbx_get_extra_command_user(self, kdbx_key): - lst_value = [] - if kdbx_key: - kp = self.get_kdbx() - if not kp: - return "" - if type(kdbx_key) is not list: - lst_kdbx_key = [kdbx_key] - else: - lst_kdbx_key = kdbx_key - for key in lst_kdbx_key: - 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}") - lst_value.append( - " --default_email_auth" - f" {odoo_user} --default_password_auth '{odoo_password}'" - ) - if len(lst_value) == 0: - return "" - elif len(lst_value) == 1: - return lst_value[0] - return lst_value - - 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" @@ -1231,7 +1320,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: @@ -1239,28 +1328,28 @@ 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) 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) @@ -1276,7 +1365,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? @@ -1284,13 +1373,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) @@ -1313,10 +1402,10 @@ 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, lst_database = self.execute.exec_command_live( + status, databases = self.execute.exec_command_live( cmd_server, return_status_and_output=True, source_erplibre=False, @@ -1383,106 +1472,10 @@ class TODO: if os.path.exists(poetry_lock): shutil.copy2(poetry_lock, path_file_odoo_lock) - def callback_execute_custom_database(self, dct_config): - database_name = self.select_database() + def callback_execute_custom_database(self, config): + 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.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, lst_output = 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( - 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, lst_output = 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() - 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, lst_output = 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") @@ -1493,77 +1486,20 @@ 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, lst_output = 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): - print(f"{index + 1} - {output}") - database_name = input("Select id of database :").strip() - elif len(lst_output) == 1: - database_name = lst_output[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) # 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( @@ -1571,7 +1507,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), ], ) @@ -1583,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" 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 476fa69..a4f3ca3 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1,11 +1,11 @@ #!/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 import re -CONFIG_OVERRIDE_PRIVATE_FILE = "./env_var.sh" +ENV_VAR_FILE = "./env_var.sh" _current_lang = None @@ -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", @@ -360,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: ", @@ -412,6 +436,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", @@ -437,18 +518,51 @@ 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: ", + }, } -def get_lang(): +def get_lang() -> str: global _current_lang if _current_lang is not None: 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 @@ -472,14 +586,14 @@ def get_lang(): return _current_lang -def set_lang(lang): +def set_lang(lang: str) -> None: global _current_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 @@ -496,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(): +def lang_is_configured() -> bool: """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: @@ -512,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 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 new file mode 100644 index 0000000..bab9315 --- /dev/null +++ b/script/todo/version_manager.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# © 2021-2026 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() -> tuple[list[dict], list[str], str | None]: + """ + 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/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 new file mode 100644 index 0000000..d0711f1 --- /dev/null +++ b/test/test_check_addons_exist.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +# © 2026 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..4549d0d --- /dev/null +++ b/test/test_code_generator_tools.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +# © 2026 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_config_file.py b/test/test_config_file.py new file mode 100644 index 0000000..9ba030e --- /dev/null +++ b/test/test_config_file.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +# © 2026 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() diff --git a/test/test_database_tools.py b/test/test_database_tools.py new file mode 100644 index 0000000..f85675c --- /dev/null +++ b/test/test_database_tools.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# © 2026 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..2df796b --- /dev/null +++ b/test/test_docker_update_version.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +# © 2026 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_execute.py b/test/test_execute.py new file mode 100644 index 0000000..d15bd5c --- /dev/null +++ b/test/test_execute.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +# © 2026 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_format_file_to_commit.py b/test/test_format_file_to_commit.py new file mode 100644 index 0000000..fae5593 --- /dev/null +++ b/test/test_format_file_to_commit.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# © 2026 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_git_tool.py b/test/test_git_tool.py new file mode 100644 index 0000000..7fa6f7d --- /dev/null +++ b/test/test_git_tool.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +# © 2026 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 ( + DEFAULT_PROJECT_NAME, + DEFAULT_REMOTE_URL, + DEFAULT_WEBSITE, + EL_GITHUB_TOKEN, + SOURCE_REPO_ADDONS_FILE, + GitTool, + RepoAttrs, +) + + +class TestRepoAttrs(unittest.TestCase): + def test_basic_attributes(self): + s = RepoAttrs(a=1, b="hello") + self.assertEqual(s.a, 1) + self.assertEqual(s.b, "hello") + + def test_empty_struct(self): + s = RepoAttrs() + self.assertEqual(s.__dict__, {}) + + def test_override_existing(self): + s = RepoAttrs(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, RepoAttrs) + 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[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: + remotes, projects, default_remote = gt.get_manifest_xml_info( + filename=f.name + ) + self.assertIn("OCA", remotes) + self.assertIn("server-tools.git", projects) + 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: + remotes, projects, default_remote = gt.get_manifest_xml_info( + filename=f.name + ) + self.assertEqual(remotes, {}) + self.assertEqual(projects, {}) + self.assertIsNone(default_remote) + finally: + os.unlink(f.name) + + +class TestConstants(unittest.TestCase): + def test_file_source_repo_addons(self): + self.assertEqual(SOURCE_REPO_ADDONS_FILE, "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_iscompatible.py b/test/test_iscompatible.py new file mode 100644 index 0000000..cf605cb --- /dev/null +++ b/test/test_iscompatible.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +# © 2026 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_kill_process_by_port.py b/test/test_kill_process_by_port.py new file mode 100644 index 0000000..ff516ef --- /dev/null +++ b/test/test_kill_process_by_port.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +# © 2026 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, + WRAPPER_SCRIPT_NAMES, + 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", WRAPPER_SCRIPT_NAMES) + + def test_contains_odoo_bin_sh(self): + self.assertIn("./odoo_bin.sh", WRAPPER_SCRIPT_NAMES) + + +if __name__ == "__main__": + unittest.main() 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 | diff --git a/test/test_todo.py b/test/test_todo.py new file mode 100644 index 0000000..9e1fb4a --- /dev/null +++ b/test/test_todo.py @@ -0,0 +1,411 @@ +#!/usr/bin/env python3 +# © 2026 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, + ERROR_LOG_PATH, + GRADLE_FILE, + LOGO_ASCII_FILE, + MOBILE_HOME_PATH, + STRINGS_FILE, + TODO, + VENV_ERPLIBRE, +) +from script.todo.version_manager import ( + INSTALLED_ODOO_VERSION_FILE, + ODOO_VERSION_FILE, + VERSION_DATA_FILE, + get_odoo_version, +) + + +class TestTODOInit(unittest.TestCase): + def test_initial_attributes(self): + todo = TODO() + self.assertIsNone(todo.dir_path) + self.assertIsNone(todo.selected_file_path) + self.assertIsNotNone(todo.config_file) + self.assertIsNotNone(todo.execute) + self.assertIsNotNone(todo.kdbx_manager) + + +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) + choices = [ + {"prompt_description": "Option A"}, + {"prompt_description": "Option B"}, + ] + result = self.todo.fill_help_info(choices) + 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) + choices = [ + { + "prompt_description": "fallback", + "prompt_description_key": "my_key", + }, + ] + result = self.todo.fill_help_info(choices) + 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, + }, + } + 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.version_manager.VERSION_DATA_FILE", version_file + ), patch( + "script.todo.version_manager.INSTALLED_ODOO_VERSION_FILE", + os.path.join(tmpdir, "nonexistent.txt"), + ), patch( + "script.todo.version_manager.ODOO_VERSION_FILE", + odoo_version_file, + ): + versions, installed, odoo_current = get_odoo_version() + + self.assertEqual(len(versions), 2) + self.assertEqual(odoo_current, "odoo18.0") + # Check erplibre_version was added + names = [v["erplibre_version"] for v in versions] + 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, + }, + } + 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.version_manager.VERSION_DATA_FILE", version_file + ), patch( + "script.todo.version_manager.INSTALLED_ODOO_VERSION_FILE", + installed_file, + ), patch( + "script.todo.version_manager.ODOO_VERSION_FILE", + os.path.join(tmpdir, "nonexistent"), + ): + versions, installed, odoo_current = get_odoo_version() + + self.assertEqual(installed, ["odoo16.0", "odoo18.0"]) + self.assertIsNone(odoo_current) + + def test_no_version_data_raises(self): + 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.version_manager.VERSION_DATA_FILE", version_file + ): + with self.assertRaises(Exception): + 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(VENV_ERPLIBRE, ".venv.erplibre") + + def test_file_error_path(self): + self.assertEqual(ERROR_LOG_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_manager.get_extra_command_user("") + self.assertEqual(result, "") + + def test_none_kdbx_key(self): + todo = TODO() + result = todo.kdbx_manager.get_extra_command_user(None) + self.assertEqual(result, "") + + def test_kdbx_not_available(self): + todo = TODO() + todo.kdbx_manager.get_kdbx = MagicMock(return_value=None) + result = todo.kdbx_manager.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 + + +class TestSelectDatabase(unittest.TestCase): + @patch("script.todo.database_manager.click") + def test_select_database_returns_name(self, mock_click): + todo = TODO() + 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.db_manager.select_database() + self.assertEqual(result, "db_test") + + @patch("script.todo.database_manager.click") + def test_select_database_returns_false_on_zero(self, mock_click): + todo = TODO() + 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.db_manager.select_database() + self.assertFalse(result) + + +class TestRestoreFromDatabase(unittest.TestCase): + @patch("builtins.input") + def test_restore_by_filename(self, mock_input): + todo = TODO() + 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.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.db_manager._execute = MagicMock() + todo.db_manager._execute.exec_command_live.return_value = ( + 0, + [], + ) + mock_input.side_effect = ["1", "mydb", "y", "n"] + 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.database_manager.click") + @patch("builtins.input") + def test_creates_backup_command(self, mock_input, mock_click): + todo = TODO() + 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.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) + + +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..1bb5777 --- /dev/null +++ b/test/test_todo_i18n.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +# © 2026 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, "ENV_VAR_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, "ENV_VAR_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, + "ENV_VAR_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, + "ENV_VAR_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, "ENV_VAR_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, "ENV_VAR_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, "ENV_VAR_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, + "ENV_VAR_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, "ENV_VAR_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, "ENV_VAR_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, + "ENV_VAR_FILE", + "/nonexistent/path", + ): + result = todo_i18n.lang_is_configured() + self.assertFalse(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_version.py b/test/test_version.py new file mode 100644 index 0000000..2d48072 --- /dev/null +++ b/test/test_version.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +# © 2026 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()