erplibre/script/execute/execute.py

245 lines
9 KiB
Python
Raw Normal View History

#!/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 logging
import os
2026-08-07 03:35:20 -04:00
import re
import shutil
import subprocess
import sys
import time
2026-02-14 05:53:31 -05:00
try:
import humanize
except ModuleNotFoundError as e:
2026-02-14 05:53:31 -05:00
humanize = None
VENV_ERPLIBRE = ".venv.erplibre"
2026-08-07 03:35:20 -04:00
# Une commande construite ailleurs peut porter un secret en clair : todo.py et
# kdbx_manager.py y mettent « --default_password_auth '<mot de passe KeePass>' »,
# db_restore.py « --master_password=… ». Cette commande est affichée avant et
# après l'exécution, et journalisée en erreur : le secret finissait donc dans le
# terminal, dans les journaux et dans toute sortie CI qui les capture.
#
# On caviarde la VALEUR, jamais le nom de l'option : la commande reste lisible et
# reproductible, il ne manque que ce qui ne doit pas être lu.
_SECRET_OPTION = re.compile(
r"(?P<opt>--?[\w-]*"
r"(?:password|passwd|pwd|secret|token|api[-_]?key)[\w-]*"
r"(?:\s+|=))"
r"(?P<val>'[^']*'|\"[^\"]*\"|\S+)",
re.IGNORECASE,
)
_SECRET_ENV = re.compile(
r"(?P<var>\b\w*(?:PASSWORD|PASSWD|SECRET|TOKEN)\w*=)"
r"(?P<val>'[^']*'|\"[^\"]*\"|\S+)"
)
def redact_secrets(text):
"""Remplace la valeur des options et variables porteuses de secret.
Appliqué à CHAQUE affichage d'une commande. Filtrer au point d'affichage
plutôt qu'à la construction est ce qui rend la garantie tenable : il n'y a
qu'une poignée de sorties ici, alors que les commandes se construisent
partout dans le dépôt.
"""
if not text:
return text
text = _SECRET_OPTION.sub(lambda m: m.group("opt") + "'***'", text)
return _SECRET_ENV.sub(lambda m: m.group("var") + "'***'", text)
new_path = os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
sys.path.append(new_path)
logging.basicConfig(
format=(
"%(asctime)s,%(msecs)d %(levelname)-8s [%(filename)s:%(lineno)d]"
" %(message)s"
),
datefmt="%Y-%m-%d:%H:%M:%S",
level=logging.INFO,
)
_logger = logging.getLogger(__name__)
class Execute:
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" ./{VENV_ERPLIBRE}/bin/activate;%s'"
)
self.cmd_source_default = "gnome-terminal -- bash -c '" f"%s'"
else:
exec_path_tell = shutil.which("osascript")
if exec_path_tell:
self.cmd_source_erplibre = (
"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 ./{VENV_ERPLIBRE}/bin/activate; %s\" in front window'"
self.cmd_source_erplibre += " -e 'end tell'"
else:
self.cmd_source_erplibre = (
f"source ./{VENV_ERPLIBRE}/bin/activate;%s"
)
def exec_command_live(
self,
command: str,
source_erplibre: bool = True,
quiet: bool = False,
single_source_erplibre: bool = False,
new_window: bool = False,
single_source_odoo: bool = False,
source_odoo: str = "",
new_env: dict | None = None,
return_status_and_command: bool = False,
return_status_and_output: bool = False,
return_status_and_output_and_command: bool = False,
) -> (
int
| tuple[int, str]
| tuple[int, list[str]]
| tuple[int, str, list[str]]
):
"""
Execute a command and display its output live.
Args:
command (str): The command to execute.
"""
my_env = os.environ.copy()
if new_env:
my_env.update(new_env)
process_start_time = time.time()
exit_code = None
if source_erplibre:
# command = f"source ./{VENV_ERPLIBRE}/bin/activate && " + command
# cmd = (
# f"gnome-terminal --tab -- bash -c 'source"
# f" ./{VENV_ERPLIBRE}/bin/activate;{command}'"
# )
command = self.cmd_source_erplibre % command
# os.system(f"./script/terminal/open_terminal.sh {command}")
elif single_source_erplibre:
command = f"source ./{VENV_ERPLIBRE}/bin/activate && %s" % command
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(
2026-08-07 03:35:20 -04:00
"You cannot execute Odoo command if no version is"
f" installed. Command : {redact_secrets(command)}"
)
# Return the SAME shape the caller asked for. A bare int here
# made callers doing « status, cmd = exec_command_live(...) »
# crash with ValueError instead of seeing the failure.
if return_status_and_output_and_command:
return 1, command, []
if return_status_and_command:
return 1, command
if return_status_and_output:
return 1, []
return 1
command = f"source ./.venv.{source_odoo}/bin/activate && {command}"
if new_window and self.cmd_source_default:
command = self.cmd_source_default % command
if not quiet:
print("🏠 ⬇ Execute command :\n")
2026-08-07 03:35:20 -04:00
print(redact_secrets(command))
output_lines = []
try:
process = subprocess.Popen(
command,
shell=True,
executable="/bin/bash",
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1, # Disable buffering for live output
universal_newlines=True, # Handle line breaks correctly
env=my_env,
)
[ADD] migration: keep the logs on disk, one file per step The state screen showed « no tool has run yet » after closing and reopening. It was reading the progression file, and that file is archived and reset when a migration restarts — so everything it knew vanished at the exact moment one wants to understand why the restart was needed. Each step now has its own log file, appended never overwritten, and the failures and tool verdicts go to an append-only JSONL beside them. Both outlive the progression, and the screen reads disk first, memory second, without counting the overlap twice. The command output itself is captured where every line already passes, in the executor's read loop: the terminal still shows it live and nothing about the run changes. What goes through the real terminal cannot be captured — a pipe there makes full screens refuse, that lesson is paid — so those keep at least their command and their exit code. --- FR --- [ADD] migration : garder les journaux sur disque, un fichier par étape L'écran d'état affichait « aucun outil n'a encore tourné » après une fermeture. Il lisait le fichier de progression, or celui-ci est archivé puis remis à zéro quand on recommence : tout ce qu'il savait disparaissait au moment précis où l'on cherche pourquoi il a fallu recommencer. Chaque étape a désormais son fichier, en ajout et jamais en écrasement, et les échecs comme les verdicts d'outils vont dans un JSONL à côté. Les deux survivent à la progression, et l'écran lit le disque d'abord. La sortie des commandes est captée là où chaque ligne passe déjà, dans la boucle de lecture de l'exécuteur : le terminal la montre toujours en direct. Ce qui passe par le vrai terminal n'est pas captable — un tube y ferait renoncer les pleins écrans — et garde au moins son verdict. Assisted-by: Claude Opus 5
2026-08-19 04:32:25 -04:00
sink = getattr(self, "log_sink", None)
while True:
line = process.stdout.readline()
if not line:
break
if not quiet:
print(line, end="")
[ADD] migration: keep the logs on disk, one file per step The state screen showed « no tool has run yet » after closing and reopening. It was reading the progression file, and that file is archived and reset when a migration restarts — so everything it knew vanished at the exact moment one wants to understand why the restart was needed. Each step now has its own log file, appended never overwritten, and the failures and tool verdicts go to an append-only JSONL beside them. Both outlive the progression, and the screen reads disk first, memory second, without counting the overlap twice. The command output itself is captured where every line already passes, in the executor's read loop: the terminal still shows it live and nothing about the run changes. What goes through the real terminal cannot be captured — a pipe there makes full screens refuse, that lesson is paid — so those keep at least their command and their exit code. --- FR --- [ADD] migration : garder les journaux sur disque, un fichier par étape L'écran d'état affichait « aucun outil n'a encore tourné » après une fermeture. Il lisait le fichier de progression, or celui-ci est archivé puis remis à zéro quand on recommence : tout ce qu'il savait disparaissait au moment précis où l'on cherche pourquoi il a fallu recommencer. Chaque étape a désormais son fichier, en ajout et jamais en écrasement, et les échecs comme les verdicts d'outils vont dans un JSONL à côté. Les deux survivent à la progression, et l'écran lit le disque d'abord. La sortie des commandes est captée là où chaque ligne passe déjà, dans la boucle de lecture de l'exécuteur : le terminal la montre toujours en direct. Ce qui passe par le vrai terminal n'est pas captable — un tube y ferait renoncer les pleins écrans — et garde au moins son verdict. Assisted-by: Claude Opus 5
2026-08-19 04:32:25 -04:00
if sink:
# Chaque ligne passe DÉJÀ ici : c'est le seul endroit où
# journaliser sans rien changer à ce que le terminal
# montre. Une erreur d'écriture ne doit jamais faire
# échouer la commande qu'on est en train de suivre.
try:
sink.write(line)
except Exception:
sink = None
if (
return_status_and_output
or return_status_and_output_and_command
):
# Remove last \n char
output_lines.append(
line.removesuffix("\r\n")
.removesuffix("\n")
.removesuffix("\r")
)
process.wait()
exit_code = process.returncode
if process.returncode != 0 and not quiet:
print("Command returned error code:" f" {process.returncode}")
# An exception MUST report a failure. exit_code stays None otherwise,
# and None is falsy: callers testing « if not status: » would mark the
# step as done, and « if status and wait_at_error » would skip the error
# prompt. A crashed command was therefore recorded as a success.
except FileNotFoundError:
exit_code = 1
if not quiet:
2026-08-07 03:35:20 -04:00
print(f"Error: Command '{redact_secrets(command)}' not found.")
except Exception as e:
exit_code = 1
if not quiet:
2026-08-07 03:35:20 -04:00
print(f"An error occurred: {redact_secrets(str(e))}")
process_end_time = time.time()
duration_sec = process_end_time - process_start_time
if humanize:
duration_delta = datetime.timedelta(seconds=duration_sec)
human_time = humanize.precisedelta(duration_delta)
if not quiet:
print(f"🏠 ⬆ Executed ({human_time}) :\n")
else:
if not quiet:
print(f"🏠 ⬆ Executed ({duration_sec:.2f} sec.) :\n")
if not quiet:
2026-08-07 03:35:20 -04:00
print(redact_secrets(command))
print()
if return_status_and_output_and_command:
return exit_code, command, output_lines
if return_status_and_command:
return exit_code, command
if return_status_and_output:
return exit_code, output_lines
return exit_code