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:
commit
4762282796
93 changed files with 6373 additions and 1099 deletions
80
conf/template_claude_commands_commit.md
Normal file
80
conf/template_claude_commands_commit.md
Normal 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>"
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
874
script/git/git_local_server.py
Executable 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()
|
||||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
164
script/git/github_api.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
84
script/git/repo_url.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
217
script/todo/database_manager.py
Normal file
217
script/todo/database_manager.py
Normal 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
|
||||
96
script/todo/kdbx_manager.py
Normal file
96
script/todo/kdbx_manager.py
Normal 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
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
45
script/todo/version_manager.py
Normal file
45
script/todo/version_manager.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
179
test/test_check_addons_exist.py
Normal file
179
test/test_check_addons_exist.py
Normal 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)
|
||||
199
test/test_code_generator_tools.py
Normal file
199
test/test_code_generator_tools.py
Normal 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
262
test/test_config_file.py
Normal 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
146
test/test_database_tools.py
Normal 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)
|
||||
121
test/test_docker_update_version.py
Normal file
121
test/test_docker_update_version.py
Normal 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
169
test/test_execute.py
Normal 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()
|
||||
140
test/test_format_file_to_commit.py
Normal file
140
test/test_format_file_to_commit.py
Normal 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
327
test/test_git_tool.py
Normal 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
94
test/test_iscompatible.py
Normal 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")))
|
||||
251
test/test_kill_process_by_port.py
Normal file
251
test/test_kill_process_by_port.py
Normal 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
541
test/test_plan.base.md
Normal 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
264
test/test_plan.fr.md
Normal 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
264
test/test_plan.md
Normal 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
411
test/test_todo.py
Normal 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
244
test/test_todo_i18n.py
Normal 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
243
test/test_version.py
Normal 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()
|
||||
Loading…
Reference in a new issue