Merge branch 'refactoring_script'

- update licence years TechnoLibre
- add unit test for script
- imp git_local_server
- todo support run test
- support claude command to commit from OCA style
- refactoring script to improve lisibility and quality
This commit is contained in:
Mathieu Benoit 2026-03-11 23:19:12 -04:00
commit 4762282796
93 changed files with 6373 additions and 1099 deletions

View file

@ -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 <your@email.com>
```
### 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 <your@email.com>" -m "[TAG] module: description
Explain WHY here.
Generated by Claude Code {VERSION} model {MODEL}
Co-Authored-By: Your Name <your@email.com>"
```

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

874
script/git/git_local_server.py Executable file
View file

@ -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()

View file

@ -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__":

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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

View file

@ -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("</remote>")
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

View file

@ -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

164
script/git/github_api.py Normal file
View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

84
script/git/repo_url.py Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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')])

View file

@ -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

View file

@ -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

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -51,5 +51,6 @@
"prompt_description_key": "json_format_modified_code",
"makefile_cmd": "format"
}
]
],
"git_from_makefile": []
}

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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"])

262
test/test_config_file.py Normal file
View file

@ -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()

146
test/test_database_tools.py Normal file
View file

@ -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)

View file

@ -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)

169
test/test_execute.py Normal file
View file

@ -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()

View file

@ -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)

327
test/test_git_tool.py Normal file
View file

@ -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 = """<?xml version="1.0" encoding="UTF-8"?>
<manifest>
<remote name="OCA" fetch="https://github.com/OCA/"/>
<default remote="OCA" revision="16.0"/>
<project name="server-tools.git" path="addons/OCA_server-tools"
remote="OCA" groups="odoo16.0"/>
</manifest>
"""
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 = """<?xml version="1.0" encoding="UTF-8"?>
<manifest/>
"""
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()

94
test/test_iscompatible.py Normal file
View file

@ -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")))

View file

@ -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()

541
test/test_plan.base.md Normal file
View file

@ -0,0 +1,541 @@
<!---------------------------->
<!-- multilingual suffix: en, fr -->
<!-- no suffix: en -->
<!---------------------------->
<!-- [en] -->
# Test Plan — ERPLibre `./test/`
## Overview
<!-- [fr] -->
# Plan de test — ERPLibre `./test/`
## Vue d'ensemble
<!-- [common] -->
| 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` |
---
<!-- [en] -->
## 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
<!-- [fr] -->
## 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
<!-- [common] -->
---
<!-- [en] -->
## 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.
<!-- [fr] -->
## 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.
<!-- [common] -->
---
<!-- [en] -->
## 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
<!-- [fr] -->
## 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
<!-- [common] -->
---
<!-- [en] -->
## 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
<!-- [fr] -->
## 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
<!-- [common] -->
---
<!-- [en] -->
## 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
<!-- [fr] -->
## 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
<!-- [common] -->
---
<!-- [en] -->
## 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`
<!-- [fr] -->
## 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`
<!-- [common] -->
---
<!-- [en] -->
## 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
<!-- [fr] -->
## 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
<!-- [common] -->
---
<!-- [en] -->
## 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
<!-- [fr] -->
## 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
<!-- [common] -->
---
<!-- [en] -->
## 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`
<!-- [fr] -->
## 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`
<!-- [common] -->
---
<!-- [en] -->
## 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
<!-- [fr] -->
## 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
<!-- [common] -->
---
<!-- [en] -->
## 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
<!-- [fr] -->
## 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
<!-- [common] -->
---
<!-- [en] -->
## 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
<!-- [fr] -->
## 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
<!-- [common] -->
---
<!-- [en] -->
## 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
<!-- [fr] -->
## 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
<!-- [common] -->
---
<!-- [en] -->
## 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)
<!-- [fr] -->
## 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)
<!-- [common] -->
---
<!-- [en] -->
## 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 |
<!-- [fr] -->
## 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 |

264
test/test_plan.fr.md Normal file
View file

@ -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 |

264
test/test_plan.md Normal file
View file

@ -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 |

411
test/test_todo.py Normal file
View file

@ -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()

244
test/test_todo_i18n.py Normal file
View file

@ -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()

243
test/test_version.py Normal file
View file

@ -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()