[REF] format : passer l'outillage et les tests sous ruff

Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.

--- EN ---

This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-24 13:30:31 -04:00
parent e496b107a6
commit cfbdd9406e
155 changed files with 1522 additions and 1190 deletions

View file

@ -39,16 +39,12 @@ RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, RACINE) sys.path.insert(0, RACINE)
sys.path.insert(0, os.path.join(RACINE, "long_test")) sys.path.insert(0, os.path.join(RACINE, "long_test"))
from script.proxmox import proxmox_deploy as pve # noqa: E402
import descente # noqa: E402 import descente # noqa: E402
from descente import ( # noqa: E402,F401 from descente import ( # noqa: E402,F401
DELAIS, DELAIS,
mener,
_lance_une_descente,
Famille, Famille,
_lance_une_descente,
a_defaire, a_defaire,
alias_etage as _alias_etage,
autre_descente, autre_descente,
capacite_hote, capacite_hote,
cle_publique, cle_publique,
@ -58,10 +54,18 @@ from descente import ( # noqa: E402,F401
detruire_etage1, detruire_etage1,
dire, dire,
identite_de, identite_de,
mener,
module_qemu, module_qemu,
nom_etage as _nom_etage,
retirer_alias, retirer_alias,
) )
from descente import (
alias_etage as _alias_etage,
)
from descente import (
nom_etage as _nom_etage,
)
from script.proxmox import proxmox_deploy as pve # noqa: E402
# L'image des étages imbriqués. Debian parce que install_proxmox.sh s'installe # L'image des étages imbriqués. Debian parce que install_proxmox.sh s'installe
# SUR une Debian — Proxmox ne publie pas d'image cloud. # SUR une Debian — Proxmox ne publie pas d'image cloud.

View file

@ -42,15 +42,11 @@ RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, RACINE) sys.path.insert(0, RACINE)
sys.path.insert(0, os.path.join(RACINE, "long_test")) sys.path.insert(0, os.path.join(RACINE, "long_test"))
from script.proxmox import nesting # noqa: E402
from script.proxmox import proxmox_deploy as pve # noqa: E402
import descente # noqa: E402 import descente # noqa: E402
from descente import ( # noqa: E402,F401 from descente import ( # noqa: E402,F401
DELAIS, DELAIS,
Famille, Famille,
a_defaire, a_defaire,
alias_etage as _alias_etage,
autre_descente, autre_descente,
capacite_hote, capacite_hote,
cle_publique, cle_publique,
@ -62,9 +58,17 @@ from descente import ( # noqa: E402,F401
identite_de, identite_de,
mener, mener,
module_qemu, module_qemu,
nom_etage as _nom_etage,
retirer_alias, retirer_alias,
) )
from descente import (
alias_etage as _alias_etage,
)
from descente import (
nom_etage as _nom_etage,
)
from script.proxmox import nesting # noqa: E402
from script.proxmox import proxmox_deploy as pve # noqa: E402
# Une Debian nue : c'est elle qui recevra libvirt et qemu-kvm. # Une Debian nue : c'est elle qui recevra libvirt et qemu-kvm.
DISTRO = "debian" DISTRO = "debian"

View file

@ -756,8 +756,7 @@ class Descente:
) )
else: else:
self.dire( self.dire(
f" profondeur atteinte : {atteint}" f" profondeur atteinte : {atteint} / {self.plan['demandee']}"
f" / {self.plan['demandee']}"
) )
# Deux causes très différentes rendaient le même « 5 / 10 » : la # Deux causes très différentes rendaient le même « 5 / 10 » : la
# machine trop petite pour dix, ou un étage tombé en route. La # machine trop petite pour dix, ou un étage tombé en route. La
@ -1099,7 +1098,8 @@ def a_defaire(rapport, nom_base=""):
etages = [ etages = [
e e
for e in (rapport.get("etages") or []) for e in (rapport.get("etages") or [])
if identite_de(e) and e.get("parent_alias") if identite_de(e)
and e.get("parent_alias")
# « cree » est le seul champ qui dise que la machine est à NOUS. Les # « cree » est le seul champ qui dise que la machine est à NOUS. Les
# deux autres conditions ne protégeaient que par accident : elles # deux autres conditions ne protégeaient que par accident : elles
# tenaient parce que rien ne décrivait un hôte emprunté. Depuis qu'une # tenaient parce que rien ne décrivait un hôte emprunté. Depuis qu'une

View file

@ -396,8 +396,7 @@ def _table_block(lst_row, exact, source="database"):
) )
return lines return lines
lines = [ lines = [
f" {'table':<34}{'total':>10}{'heap':>10}{'index':>10}" f" {'table':<34}{'total':>10}{'heap':>10}{'index':>10}{t('rows'):>14}"
f"{t('rows'):>14}"
] ]
for row in lst_row: for row in lst_row:
count = row["exact_rows"] if exact else row["est_rows"] count = row["exact_rows"] if exact else row["est_rows"]

View file

@ -382,7 +382,7 @@ class ProjectManagement:
os.system(cmd) os.system(cmd)
if not self.keep_bd_alive: if not self.keep_bd_alive:
cmd = "./odoo_bin.sh db --drop --database" f" {bd_name_demo}" cmd = f"./odoo_bin.sh db --drop --database {bd_name_demo}"
_logger.info(cmd) _logger.info(cmd)
os.system(cmd) os.system(cmd)
@ -463,7 +463,7 @@ class ProjectManagement:
os.system(cmd) os.system(cmd)
if not self.keep_bd_alive: if not self.keep_bd_alive:
cmd = "./odoo_bin.sh db --drop --database" f" {bd_name_template}" cmd = f"./odoo_bin.sh db --drop --database {bd_name_template}"
_logger.info(cmd) _logger.info(cmd)
os.system(cmd) os.system(cmd)
@ -531,7 +531,7 @@ class ProjectManagement:
os.system(cmd) os.system(cmd)
if not self.keep_bd_alive: if not self.keep_bd_alive:
cmd = "./odoo_bin.sh db --drop --database" f" {bd_name_generator}" cmd = f"./odoo_bin.sh db --drop --database {bd_name_generator}"
_logger.info(cmd) _logger.info(cmd)
os.system(cmd) os.system(cmd)

View file

@ -152,7 +152,7 @@ def search_and_replace(
# new_file_content = ( # new_file_content = (
# f'{f_lines[:t_index_second_quote]}"{models_name}"{f_lines[t_index_third_quote + len(second_char):]}' # f'{f_lines[:t_index_second_quote]}"{models_name}"{f_lines[t_index_third_quote + len(second_char):]}'
# ) # )
new_file_content = f'{f_lines[:t_index_second_quote]}"{models_name}"{f_lines[t_index_third_quote + len(second_char):]}' new_file_content = f'{f_lines[:t_index_second_quote]}"{models_name}"{f_lines[t_index_third_quote + len(second_char) :]}'
return new_file_content return new_file_content

View file

@ -116,7 +116,7 @@ def main():
) )
module_name = get_module_name(config.source) module_name = get_module_name(config.source)
method_name = ( method_name = (
f'get_{"_".join(url.replace("-", "_").strip("/").split("/"))}' f"get_{'_'.join(url.replace('-', '_').strip('/').split('/'))}"
) )
# template = f""" # template = f"""

View file

@ -645,7 +645,9 @@ def add_line(
"with" "with"
f" cw.indent({4 + nb_space if nb_space else ''}):" f" cw.indent({4 + nb_space if nb_space else ''}):"
) )
with cw.indent(): with (
cw.indent()
):
cw.emit( cw.emit(
f'cw.emit("{line}")' f'cw.emit("{line}")'
) )
@ -723,7 +725,9 @@ def add_line(
f"with" f"with"
f" cw.indent():" f" cw.indent():"
) )
with cw.indent(): with (
cw.indent()
):
if ( if (
no_indent no_indent
!= init_no_intend != init_no_intend
@ -808,7 +812,9 @@ def add_line(
f"with" f"with"
f" cw.indent():" f" cw.indent():"
) )
with cw.indent(): with (
cw.indent()
):
if ( if (
nb_indent nb_indent
- 1 - 1
@ -902,7 +908,9 @@ def add_line(
f"with" f"with"
f" cw.indent():" f" cw.indent():"
) )
with cw.indent(): with (
cw.indent()
):
if ( if (
nb_indent nb_indent
- 1 - 1
@ -1007,7 +1015,9 @@ def add_line(
f"with" f"with"
f" cw.indent():" f" cw.indent():"
) )
with cw.indent(): with (
cw.indent()
):
if ( if (
nb_indent nb_indent
- 1 - 1
@ -1123,7 +1133,9 @@ def add_line(
f"with" f"with"
f" cw.indent():" f" cw.indent():"
) )
with cw.indent(): with (
cw.indent()
):
if ( if (
nb_indent nb_indent
- 1 - 1

View file

@ -19,7 +19,7 @@ Les formats en pur stdlib — CSV, JSON, XML, et la détection de macros qui
n'est qu'un `zipfile.namelist()` — tournent sous l'interpréteur du CLI. n'est qu'un `zipfile.namelist()` — tournent sous l'interpréteur du CLI.
Excel et Access exigent un venv dédié. Le module doit donc s'importer sous Excel et Access exigent un venv dédié. Le module doit donc s'importer sous
les deux : AUCUN import de bibliothèque tierce au niveau du module, chacun les deux : AUCUN import de bibliothèque tierce au niveau du module, chacun
vit dans la fonction qui en a besoin. vit dans la fonction qui en a besoin.
Le canal de sortie Le canal de sortie
------------------ ------------------
@ -57,8 +57,7 @@ ERREURS = {
"format_inconnu": "Format not recognised: ", "format_inconnu": "Format not recognised: ",
"illisible_ici": "Recognised format, unreadable here" "illisible_ici": "Recognised format, unreadable here"
" — re-save it as .xlsx.", " — re-save it as .xlsx.",
"protege": "Protected by a password, or not a workbook" "protege": "Protected by a password, or not a workbook — unreadable here.",
" — unreadable here.",
"vide": "Empty file.", "vide": "Empty file.",
"droits": "Not readable: check the permissions.", "droits": "Not readable: check the permissions.",
"pas_un_fichier": "Not an ordinary file.", "pas_un_fichier": "Not an ordinary file.",
@ -66,12 +65,11 @@ ERREURS = {
" nothing was written.", " nothing was written.",
"repertoire_non_vide": "The destination directory exists" "repertoire_non_vide": "The destination directory exists"
" and is not empty.", " and is not empty.",
"aucune_feuille": "The selection matches no sheet;" "aucune_feuille": "The selection matches no sheet; nothing was written.",
" nothing was written.",
"tout_exclu": "Nothing was anonymised: every region was excluded.", "tout_exclu": "Nothing was anonymised: every region was excluded.",
"rien_a_faire": "Nothing to anonymise in this file.", "rien_a_faire": "Nothing to anonymise in this file.",
"place": "Not enough room to write.", "place": "Not enough room to write.",
"conversion_impossible": "This target cannot hold the source's" " shape: ", "conversion_impossible": "This target cannot hold the source's shape: ",
"table_source": "The mapping table would overwrite the source" "table_source": "The mapping table would overwrite the source"
" or the copy; nothing was written.", " or the copy; nothing was written.",
"table_illisible": "The mapping table is unreadable," "table_illisible": "The mapping table is unreadable,"

View file

@ -2864,8 +2864,7 @@ def _avertissements(rapport, options):
hors = rapport.get("hors_cellules") or {} hors = rapport.get("hors_cellules") or {}
if rapport.get("format") == "xlsx": if rapport.get("format") == "xlsx":
dits.append( dits.append(
"Cached formula results are dropped;" "Cached formula results are dropped; the sheet recomputes on open."
" the sheet recomputes on open."
) )
dits.append("Document properties were cleared on the copy.") dits.append("Document properties were cleared on the copy.")
if hors.get("croises") or hors.get("graphiques"): if hors.get("croises") or hors.get("graphiques"):
@ -2884,8 +2883,7 @@ def _avertissements(rapport, options):
) )
if hors.get("liens_externes"): if hors.get("liens_externes"):
dits.append( dits.append(
"External links were dropped; formulas that used them" "External links were dropped; formulas that used them show #REF!."
" show #REF!."
) )
survivent = _noms_de_feuille_survivent(rapport, options) survivent = _noms_de_feuille_survivent(rapport, options)
if hors.get("plages_nommees") and survivent: if hors.get("plages_nommees") and survivent:

View file

@ -273,8 +273,7 @@ def restore_or_clone(config, arg_base, cache_database, lst_db_cache):
) )
else: else:
_logger.info( _logger.info(
f"## Clone cache {cache_database} to database" f"## Clone cache {cache_database} to database {config.database} ##"
f" {config.database} ##"
) )
arg = ( arg = (
f"{arg_base} --clone --from_database" f"{arg_base} --clone --from_database"

View file

@ -307,7 +307,7 @@ def main():
if not base_image_name or base_image_name == image_name_to_generate: if not base_image_name or base_image_name == image_name_to_generate:
with_demo = dct_config_image.get("with_demo") with_demo = dct_config_image.get("with_demo")
# Create a new one # Create a new one
cmd = f"./odoo_bin.sh db --create --database" f" {bd_temp_name}" cmd = f"./odoo_bin.sh db --create --database {bd_temp_name}"
if with_demo: if with_demo:
cmd += " --demo" cmd += " --demo"
else: else:

View file

@ -1,7 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
import xmlrpc.client
import sys import sys
import xmlrpc.client
import click import click

View file

@ -12,15 +12,14 @@ def process_zip(
): ):
# Ouvrir le zip d'entrée en lecture # Ouvrir le zip d'entrée en lecture
try: try:
with zipfile.ZipFile(path_backup_zip, "r") as zin, zipfile.ZipFile( with (
path_output_zip, "w" zipfile.ZipFile(path_backup_zip, "r") as zin,
) as zout: zipfile.ZipFile(path_output_zip, "w") as zout,
):
# Parcourir tous les fichiers du zip # Parcourir tous les fichiers du zip
for item in zin.infolist(): for item in zin.infolist():
data = zin.read(item.filename) data = zin.read(item.filename)
if item.filename == file_to_modify: if item.filename == file_to_modify:
try: try:
# On suppose un fichier texte en UTF-8 # On suppose un fichier texte en UTF-8
text = data.decode("utf-8") text = data.decode("utf-8")

View file

@ -88,7 +88,7 @@ def edit_text(config):
key = "image:" key = "image:"
value = lst_docker_info[i] value = lst_docker_info[i]
lst_docker_info[i] = ( lst_docker_info[i] = (
f"{value[:value.find(key) + len(key)]} {config.prod_version}\n" f"{value[: value.find(key) + len(key)]} {config.prod_version}\n"
) )
break break
if "ERPLibre" in docker_info: if "ERPLibre" in docker_info:

View file

@ -101,7 +101,7 @@ class Execute:
f"gnome-terminal -- bash -c 'source" f"gnome-terminal -- bash -c 'source"
f" ./{VENV_ERPLIBRE}/bin/activate;%s'" f" ./{VENV_ERPLIBRE}/bin/activate;%s'"
) )
self.cmd_source_default = "gnome-terminal -- bash -c '" f"%s'" self.cmd_source_default = f"gnome-terminal -- bash -c '%s'"
else: else:
exec_path_tell = shutil.which("osascript") exec_path_tell = shutil.which("osascript")
if exec_path_tell: if exec_path_tell:
@ -280,7 +280,7 @@ class Execute:
process.wait() process.wait()
exit_code = process.returncode exit_code = process.returncode
if process.returncode != 0 and not quiet: if process.returncode != 0 and not quiet:
print("Command returned error code:" f" {process.returncode}") print(f"Command returned error code: {process.returncode}")
# An exception MUST report a failure. exit_code stays None otherwise, # An exception MUST report a failure. exit_code stays None otherwise,
# and None is falsy: callers testing « if not status: » would mark the # and None is falsy: callers testing « if not status: » would mark the

View file

@ -17,6 +17,7 @@ le rendra.
Compté en CARACTÈRES et non en octets : « préchauffer » pèse 11 caractères et Compté en CARACTÈRES et non en octets : « préchauffer » pèse 11 caractères et
12 octets, et une limite en octets refuserait des sujets français conformes. 12 octets, et une limite en octets refuserait des sujets français conformes.
""" """
import re import re
import sys import sys
from pathlib import Path from pathlib import Path

View file

@ -81,9 +81,7 @@ Use --production-ready for /srv/git (requires root).
"-p", "-p",
"--path", "--path",
default=None, default=None,
help=( help=(f"Path for git server bare repos (default: {DEFAULT_GIT_PATH})"),
"Path for git server bare repos" f" (default: {DEFAULT_GIT_PATH})"
),
) )
parser.add_argument( parser.add_argument(
"--production-ready", "--production-ready",
@ -97,12 +95,12 @@ Use --production-ready for /srv/git (requires root).
"-m", "-m",
"--manifest", "--manifest",
default=DEFAULT_MANIFEST, default=DEFAULT_MANIFEST,
help="Manifest XML file" f" (default: {DEFAULT_MANIFEST})", help=f"Manifest XML file (default: {DEFAULT_MANIFEST})",
) )
parser.add_argument( parser.add_argument(
"--remote-name", "--remote-name",
default=DEFAULT_REMOTE_NAME, default=DEFAULT_REMOTE_NAME,
help="Remote name to add" f" (default: {DEFAULT_REMOTE_NAME})", help=f"Remote name to add (default: {DEFAULT_REMOTE_NAME})",
) )
parser.add_argument( parser.add_argument(
"--port", "--port",
@ -150,9 +148,7 @@ Use --production-ready for /srv/git (requires root).
"--jobs", "--jobs",
type=int, type=int,
default=DEFAULT_JOBS, default=DEFAULT_JOBS,
help=( help=(f"Parallel jobs for init/remote/push (default: {DEFAULT_JOBS})"),
"Parallel jobs for init/remote/push" f" (default: {DEFAULT_JOBS})"
),
) )
parser.add_argument( parser.add_argument(
"-v", "-v",
@ -367,8 +363,7 @@ async def _add_single_remote(
) )
if rc != 0: if rc != 0:
_logger.warning( _logger.warning(
f" set-url failed for" f" set-url failed for {project['path']}: {err.strip()}"
f" {project['path']}: {err.strip()}"
) )
return "error" return "error"
_logger.info(f" Updated: {project['path']}") _logger.info(f" Updated: {project['path']}")
@ -385,7 +380,7 @@ async def _add_single_remote(
) )
if rc != 0: if rc != 0:
_logger.warning( _logger.warning(
f" add failed for" f" {project['path']}: {err.strip()}" f" add failed for {project['path']}: {err.strip()}"
) )
return "error" return "error"
_logger.info(f" Added: {project['path']}") _logger.info(f" Added: {project['path']}")
@ -428,7 +423,7 @@ async def add_remotes(
added = results.count("added") added = results.count("added")
updated = results.count("updated") updated = results.count("updated")
errors = results.count("error") errors = results.count("error")
print(f"Remotes: {added} added, {updated} updated," f" {errors} errors") print(f"Remotes: {added} added, {updated} updated, {errors} errors")
# --- Async workers for push --- # --- Async workers for push ---
@ -568,7 +563,7 @@ async def _push_single_repo(
"receive.shallowUpdate", "receive.shallowUpdate",
"true", "true",
) )
_logger.info(f" Shallow push for" f" {project['path']}") _logger.info(f" Shallow push for {project['path']}")
# Handle detached HEAD: checkout manifest branch # Handle detached HEAD: checkout manifest branch
did_checkout = False did_checkout = False
@ -611,9 +606,7 @@ async def _push_single_repo(
_, err, rc = await _run_git(*cmd, timeout=120) _, err, rc = await _run_git(*cmd, timeout=120)
if rc != 0: if rc != 0:
_logger.warning( _logger.warning(
f" Push failed for" f" Push failed for {project['path']}: {err.strip()}"
f" {project['path']}:"
f" {err.strip()}"
) )
return "error", did_checkout return "error", did_checkout
else: else:
@ -656,8 +649,7 @@ async def push_to_local(
errors = sum(1 for s, _ in results if s == "error") errors = sum(1 for s, _ in results if s == "error")
checkouts = sum(1 for _, c in results if c) checkouts = sum(1 for _, c in results if c)
print( print(
f"Push: {pushed} pushed, {checkouts} branch" f"Push: {pushed} pushed, {checkouts} branch checkouts, {errors} errors"
f" checkouts, {errors} errors"
) )
@ -682,9 +674,7 @@ def print_clone_commands(git_path, projects, port):
clone_path = project["path"] clone_path = project["path"]
if clone_path == ".": if clone_path == ".":
clone_path = "erplibre" clone_path = "erplibre"
lines.append( lines.append(f" git clone {base_url}/{repo_name} {clone_path}")
f" git clone {base_url}/{repo_name}" f" {clone_path}"
)
lines.sort() lines.sort()
for line in lines: for line in lines:
print(line) print(line)

View file

@ -419,7 +419,7 @@ class GitTool:
# '"${EL_CONFIG_FILE}"\n' # '"${EL_CONFIG_FILE}"\n'
# ) # )
str_repo = ( str_repo = (
f' printf "{update_repo}," >> ' '"${EL_CONFIG_FILE}"\n' f' printf "{update_repo}," >> "${{EL_CONFIG_FILE}}"\n'
) )
# Ignore repo if not starting by addons # Ignore repo if not starting by addons
# if update_repo.startswith("addons"): # if update_repo.startswith("addons"):

View file

@ -35,7 +35,7 @@ def get_pull_request_repo(
user_name = user["login"] if not organization_name else organization_name user_name = user["login"] if not organization_name else organization_name
status, lst_pull = gh.repos[user_name][parsed_url.repo].pulls.get() status, lst_pull = gh.repos[user_name][parsed_url.repo].pulls.get()
if type(lst_pull) is dict: if type(lst_pull) is dict:
print(f"For url {upstream_url}," f" got {lst_pull.get('message')}") print(f"For url {upstream_url}, got {lst_pull.get('message')}")
return False return False
else: else:
for pull in lst_pull: for pull in lst_pull:
@ -106,9 +106,7 @@ def add_and_fetch_remote(
except git.NoSuchPathError: except git.NoSuchPathError:
print(f"New repo {repo_info.relative_path}") print(f"New repo {repo_info.relative_path}")
if not root_repo: if not root_repo:
print( print(f"Missing git repository to root for repo {repo_info.path}")
"Missing git repository to root for repo" f" {repo_info.path}"
)
return return
if branch_name: if branch_name:
submodule_repo = retry( submodule_repo = retry(

View file

@ -18,6 +18,7 @@ ce module.
Découpage voulu : TOUT ce qui construit une commande ou lit une sortie est une Découpage voulu : TOUT ce qui construit une commande ou lit une sortie est une
fonction PURE, vérifiable sans hôte Proxmox. Seul `run()` parle au réseau. fonction PURE, vérifiable sans hôte Proxmox. Seul `run()` parle au réseau.
""" """
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress

View file

@ -1116,8 +1116,10 @@ class SeleniumLib(object):
self.driver.refresh() self.driver.refresh()
if wait_ready: if wait_ready:
WebDriverWait(self.driver, 15).until( WebDriverWait(self.driver, 15).until(
lambda d: d.execute_script("return document.readyState") lambda d: (
== "complete" d.execute_script("return document.readyState")
== "complete"
)
) )
def check_bot_chat_and_close(self): def check_bot_chat_and_close(self):
@ -1824,10 +1826,13 @@ class SeleniumLib(object):
) )
) )
WebDriverWait(driver, 10).until( WebDriverWait(driver, 10).until(
lambda d: picker.find_element( lambda d: (
By.CSS_SELECTOR, ".o_datetime_picker_header .o_header_part" picker.find_element(
).text By.CSS_SELECTOR,
!= header_txt ".o_datetime_picker_header .o_header_part",
).text
!= header_txt
)
) )
else: else:
raise RuntimeError( raise RuntimeError(

View file

@ -556,7 +556,7 @@ async def test_exec(
new_hook_line = ( new_hook_line = (
hook_line[: first_index + len(f_key)] hook_line[: first_index + len(f_key)]
+ f'"{s_first_path}"\n' + f'"{s_first_path}"\n'
+ f'{nb_space_indentation * " "}template_dir =' + f"{nb_space_indentation * ' '}template_dir ="
f' "{s_first_path}/" + MODULE_NAME\n\n' f' "{s_first_path}/" + MODULE_NAME\n\n'
+ hook_line[index_end_string:] + hook_line[index_end_string:]
) )

View file

@ -29,6 +29,7 @@ l'identique : un seul client `openai` les couvre toutes, pointé sur ce que
`servers.base_url()` rend. C'est aussi ce client qu'un test injecte pour `servers.base_url()` rend. C'est aussi ce client qu'un test injecte pour
parler à un vrai serveur de boucle locale plutôt qu'à un double. parler à un vrai serveur de boucle locale plutôt qu'à un double.
""" """
from __future__ import annotations from __future__ import annotations
import json import json

View file

@ -33,6 +33,7 @@ d'où une échelle qui teste `is_loopback` d'abord, `is_private` ensuite. Et
ne résout pas se lit comme `global`, la lecture pessimiste, jamais comme ne résout pas se lit comme `global`, la lecture pessimiste, jamais comme
satisfait. satisfait.
""" """
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress

View file

@ -25,6 +25,7 @@ Les valeurs de `COMMANDS` SONT les clés i18n : `t()` rend une clé absente
inchangée, donc une commande non traduite s'affiche en anglais correct. La inchangée, donc une commande non traduite s'affiche en anglais correct. La
traduction se fait à l'affichage, dans le menu. traduction se fait à l'affichage, dans le menu.
""" """
from __future__ import annotations from __future__ import annotations
import re import re

View file

@ -35,6 +35,7 @@ deux dépôts voisins s'y confondent.
machine se compte en dizaines de mégaoctets ; seules les premières lignes sont machine se compte en dizaines de mégaoctets ; seules les premières lignes sont
lues, et le listage se garde de les relire à chaque affichage. lues, et le listage se garde de les relire à chaque affichage.
""" """
from __future__ import annotations from __future__ import annotations
import json import json

View file

@ -35,6 +35,7 @@ d'ordinaire. La moitié « noms » du filtre est donc inerte par défaut, et
`gate` le dit au lieu de laisser croire à un contrôle complet : une `gate` le dit au lieu de laisser croire à un contrôle complet : une
destination tierce est REFUSÉE tant que cette liste est vide. destination tierce est REFUSÉE tant que cette liste est vide.
""" """
from __future__ import annotations from __future__ import annotations
import os import os

View file

@ -41,6 +41,7 @@ D'où l'ordre des passes : une passe de CONNEXION SEULE sur tout le réseau
d'abord, puis le budget coûteux des GET de reconnaissance dépensé sur la d'abord, puis le budget coûteux des GET de reconnaissance dépensé sur la
poignée d'hôtes qui ont accepté. `sweep` fait la première, et rien d'autre. poignée d'hôtes qui ont accepté. `sweep` fait la première, et rien d'autre.
""" """
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress
@ -50,9 +51,8 @@ import shlex
import socket import socket
import subprocess import subprocess
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor, as_completed
from concurrent.futures import TimeoutError as PoolTimeout from concurrent.futures import TimeoutError as PoolTimeout
from concurrent.futures import as_completed
from dataclasses import dataclass from dataclasses import dataclass
from script.todo.assistant import fingerprint from script.todo.assistant import fingerprint

View file

@ -31,6 +31,7 @@ un 503 « starting » ou « Loading model » est vivant et identifié, un 401 es
un accord de reconnaissance et jamais une invitation à saisir une clé, et un un accord de reconnaissance et jamais une invitation à saisir une clé, et un
corps tronqué vaut ce qui en est arrivé. corps tronqué vaut ce qui en est arrivé.
""" """
from __future__ import annotations from __future__ import annotations
import functools import functools

View file

@ -38,6 +38,7 @@ d'où deux restrictions sur elle : `hosting` y est forcé à `loopback`, et un
gpt qui y déclarerait une COMMANDE est refusé. Un fichier qu'on n'a pas relu gpt qui y déclarerait une COMMANDE est refusé. Un fichier qu'on n'a pas relu
n'est pas une donnée, c'est de la configuration exécutable. n'est pas une donnée, c'est de la configuration exécutable.
""" """
from __future__ import annotations from __future__ import annotations
import os import os

View file

@ -26,6 +26,7 @@ dernier contact, ni rapport de balayage, ni résultat négatif. La liste de qui
a répondu parmi les 254 adresses d'un /24 décrit des machines que personne a répondu parmi les 254 adresses d'un /24 décrit des machines que personne
n'a désignées, là où un serveur retenu en désigne une seule, volontairement. n'a désignées, là où un serveur retenu en désigne une seule, volontairement.
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, replace from dataclasses import dataclass, replace

View file

@ -235,8 +235,7 @@ class DatabaseManager:
) )
commande = ( commande = (
f"python3 ./script/database/db_duplicate.py" f"python3 ./script/database/db_duplicate.py -s {source} -d {cible}"
f" -s {source} -d {cible}"
) )
if neutraliser: if neutraliser:
commande += " --neutralize" commande += " --neutralize"
@ -392,8 +391,7 @@ class DatabaseManager:
with zipfile.ZipFile(default_output_path, "r") as zip_ref: with zipfile.ZipFile(default_output_path, "r") as zip_ref:
manifest_file_1 = zip_ref.open("manifest.json") manifest_file_1 = zip_ref.open("manifest.json")
_logger.info( _logger.info(
f"Log file '{default_output_path}' is complete" f"Log file '{default_output_path}' is complete and validated."
" and validated."
) )
except Exception as e: except Exception as e:
_logger.error(e) _logger.error(e)

View file

@ -10,6 +10,7 @@ de demander l'information, mais doivent appeler exactement le même code une
fois qu'ils l'ont : sinon les deux copies dérivent. Ce module ne connaît ni fois qu'ils l'ont : sinon les deux copies dérivent. Ce module ne connaît ni
`input`, ni Textual, ni aucune bibliothèque d'interface. `input`, ni Textual, ni aucune bibliothèque d'interface.
""" """
from __future__ import annotations from __future__ import annotations
import os import os

View file

@ -12,6 +12,7 @@ Les préréglages `gmail`, `outlook` et `icloud` supposent un MOT DE PASSE
D'APPLICATION : l'authentification simple ne passe plus autrement chez ces D'APPLICATION : l'authentification simple ne passe plus autrement chez ces
fournisseurs. C'est la limite assumée de la phase 1 ; la phase 2 apporte OAuth. fournisseurs. C'est la limite assumée de la phase 1 ; la phase 2 apporte OAuth.
""" """
from __future__ import annotations from __future__ import annotations
import json import json

View file

@ -16,6 +16,7 @@ Cette fonction a été réinventée quatre fois dans ce paquet
un charset non fiable ne doit jamais faire tomber l'affichage ou la un charset non fiable ne doit jamais faire tomber l'affichage ou la
synchronisation d'un message entier. synchronisation d'un message entier.
""" """
from __future__ import annotations from __future__ import annotations

View file

@ -11,6 +11,7 @@ que de rendre du charabia.
clair b"P0" + donnees clair b"P0" + donnees
chiffre b"E1" + nonce(12) + AES-256-GCM(chiffre || tag) chiffre b"E1" + nonce(12) + AES-256-GCM(chiffre || tag)
""" """
from __future__ import annotations from __future__ import annotations
import os import os

View file

@ -15,6 +15,7 @@ plus rien dire, et garder l'ancien cache produirait des messages faux.
Les corps ne descendent JAMAIS pendant une passe : une boîte de 20 000 Les corps ne descendent JAMAIS pendant une passe : une boîte de 20 000
messages doit se synchroniser en secondes, pas en gigaoctets. messages doit se synchroniser en secondes, pas en gigaoctets.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging

View file

@ -12,6 +12,7 @@ sur un cas limite ou l'autre.
`BODY.PEEK` et non `BODY` : lire un message dans le TUI ne doit pas le marquer `BODY.PEEK` et non `BODY` : lire un message dans le TUI ne doit pas le marquer
lu sur le serveur à l'insu de l'utilisateur. lu sur le serveur à l'insu de l'utilisateur.
""" """
from __future__ import annotations from __future__ import annotations
import email import email

View file

@ -7,6 +7,7 @@ Ce module est le SEUL point de contact entre le paquet `mail` et le CLI :
`todo.py` importe `prompt_execute_mail` et rien d'autre. Le sens de la `todo.py` importe `prompt_execute_mail` et rien d'autre. Le sens de la
dépendance est volontaire — `mail` ne doit jamais importer `todo`. dépendance est volontaire — `mail` ne doit jamais importer `todo`.
""" """
from __future__ import annotations from __future__ import annotations
import getpass import getpass

View file

@ -16,6 +16,7 @@ Référence de secret : "<coffre>:<chemin>"
kdbx:ERPLibre/Mail/perso/cache-key -> ... entrée cache-key kdbx:ERPLibre/Mail/perso/cache-key -> ... entrée cache-key
keyring:perso -> service "erplibre-mail", user perso keyring:perso -> service "erplibre-mail", user perso
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging

View file

@ -11,6 +11,7 @@ d'être caché tout en restant destinataire d'enveloppe.
`date` et `msgid` sont injectables pour que les tests soient déterministes ; `date` et `msgid` sont injectables pour que les tests soient déterministes ;
en production on laisse la stdlib les produire. en production on laisse la stdlib les produire.
""" """
from __future__ import annotations from __future__ import annotations
import mimetypes import mimetypes

View file

@ -13,6 +13,7 @@ des personnes — expéditeur, destinataires, sujet, extrait, Message-ID — est
scellé. Le Message-ID a en plus un haché salé par la clé, pour qu'on puisse scellé. Le Message-ID a en plus un haché salé par la clé, pour qu'on puisse
recoller les fils de discussion sans le lire. recoller les fils de discussion sans le lire.
""" """
from __future__ import annotations from __future__ import annotations
import base64 import base64

View file

@ -11,6 +11,7 @@ Une session = un compte ouvert. Elle survit à une panne réseau : le cache
s'ouvre d'abord, la connexion est tentée ensuite, et son échec ne fait que s'ouvre d'abord, la connexion est tentée ensuite, et son échec ne fait que
poser un drapeau `online = False`. Une boîte hors ligne reste lisible. poser un drapeau `online = False`. Une boîte hors ligne reste lisible.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
@ -549,10 +550,9 @@ def deliver(session, msg, send_fn=None, connect_fn=None) -> str:
n'annule rien. Le message est déjà parti ; le signaler comme un échec n'annule rien. Le message est déjà parti ; le signaler comme un échec
pousserait l'utilisateur à l'envoyer deux fois. pousserait l'utilisateur à l'envoyer deux fois.
""" """
from script.todo.mail.smtp_send import SmtpError from script.todo.mail.smtp_send import SmtpError, without_bcc
from script.todo.mail.smtp_send import connect as smtp_connect from script.todo.mail.smtp_send import connect as smtp_connect
from script.todo.mail.smtp_send import send as smtp_send_fn from script.todo.mail.smtp_send import send as smtp_send_fn
from script.todo.mail.smtp_send import without_bcc
if not session.online: if not session.online:
raise SmtpError(t("mail_offline_cannot_send")) raise SmtpError(t("mail_offline_cannot_send"))
@ -706,9 +706,8 @@ def run_tui(
return return
from script.todo import todo_prefs from script.todo import todo_prefs
from script.todo.mail import account_setup from script.todo.mail import account_setup, tui_text
from script.todo.mail import accounts as mail_accounts from script.todo.mail import accounts as mail_accounts
from script.todo.mail import tui_text
from script.todo.mail.accounts import PRESETS from script.todo.mail.accounts import PRESETS
from script.todo.mail.secrets import SecretStore from script.todo.mail.secrets import SecretStore

View file

@ -12,6 +12,7 @@ charset menteur, pièce jointe sans nom. Aucune de ces fonctions ne lève ; au
pire elles rendent une chaîne vide. Un message illisible doit s'afficher mal, pire elles rendent une chaîne vide. Un message illisible doit s'afficher mal,
pas faire tomber la boîte de réception. pas faire tomber la boîte de réception.
""" """
from __future__ import annotations from __future__ import annotations
import datetime import datetime

View file

@ -15,6 +15,7 @@ en progression. La décision est donc écrite une seule fois.
`ctx` vient de `TodoUpgrade.resume_context()` : pure donnée, aucun accès à la `ctx` vient de `TodoUpgrade.resume_context()` : pure donnée, aucun accès à la
base ni au disque depuis l'affichage. base ni au disque depuis l'affichage.
""" """
from __future__ import annotations from __future__ import annotations
try: try:

View file

@ -13,6 +13,7 @@ autre session sans risquer de la perturber.
réimplémenter une seconde fois la lecture des clés « state_* », qui réimplémenter une seconde fois la lecture des clés « state_* », qui
divergerait de l'écran de reprise. divergerait de l'écran de reprise.
""" """
from __future__ import annotations from __future__ import annotations
import datetime import datetime

View file

@ -142,7 +142,7 @@ def pane_text(dct, row, colour=False, show_log=True):
if scan["errors"]: if scan["errors"]:
lignes.append("") lignes.append("")
lignes.append( lignes.append(
f"── {t('errors in the log')} :" f" {status.severe_count(scan)} ──" f"── {t('errors in the log')} : {status.severe_count(scan)} ──"
) )
for item in scan["errors"][:15]: for item in scan["errors"][:15]:
lignes.append( lignes.append(

View file

@ -23,6 +23,7 @@ Le formulaire ne touche à rien : il rend une spec. C'est l'appelant
import os import os
import re import re
from script.todo.deploy_form_extras import ExtrasMixin
from script.todo.deploy_form_lib import ( from script.todo.deploy_form_lib import (
CSS_BASE, CSS_BASE,
FREE, FREE,
@ -40,7 +41,6 @@ from script.todo.deploy_form_lib import (
res_row_widgets, res_row_widgets,
t, t,
) )
from script.todo.deploy_form_extras import ExtrasMixin
from script.todo.deploy_form_plan import PlanMixin, preview_screen from script.todo.deploy_form_plan import PlanMixin, preview_screen
# Aucun disque orphelin à craindre : les disques d'un Proxmox distant vivent # Aucun disque orphelin à craindre : les disques d'un Proxmox distant vivent
@ -833,9 +833,9 @@ def run_proxmox_form(ctx, run_app: bool = True):
self.profile = choix self.profile = choix
sur_mesure = choix == "custom" sur_mesure = choix == "custom"
for champ in RES_FIELDS: for champ in RES_FIELDS:
self.query_one(RES_FIELDS[champ][0], Select).disabled = ( self.query_one(
not sur_mesure RES_FIELDS[champ][0], Select
) ).disabled = not sur_mesure
if not sur_mesure: if not sur_mesure:
self._show_free(champ, False) self._show_free(champ, False)
# Un réglage commun reprend la main sur les VM non figées : # Un réglage commun reprend la main sur les VM non figées :

View file

@ -1824,8 +1824,7 @@ class ProxmoxMenuMixin:
return False return False
if fini.returncode: if fini.returncode:
print( print(
f" ⚠ {t('download cache bypass not set')}" f" ⚠ {t('download cache bypass not set')} ({fini.returncode})"
f" ({fini.returncode})"
) )
return False return False
self._pve_note( self._pve_note(
@ -1889,8 +1888,7 @@ class ProxmoxMenuMixin:
# certificate in certificate chain » — plus tard, dans la VM, # certificate in certificate chain » — plus tard, dans la VM,
# loin d'ici. C'est pourquoi il est nommé. # loin d'ici. C'est pourquoi il est nommé.
cle = ( cle = (
"no trust store for this distribution, its downloads " "no trust store for this distribution, its downloads will fail"
"will fail"
) )
self._pve_note(vm, f" ⚠ {t(cle)} : {vm.get('distro') or '?'}") self._pve_note(vm, f" ⚠ {t(cle)} : {vm.get('distro') or '?'}")
return False return False
@ -2009,8 +2007,7 @@ class ProxmoxMenuMixin:
groupes = list(getattr(mod, "GPU_GROUPS", ()) or ("render", "video")) groupes = list(getattr(mod, "GPU_GROUPS", ()) or ("render", "video"))
gestes = [f"sudo groupadd -f {g}" for g in groupes] gestes = [f"sudo groupadd -f {g}" for g in groupes]
gestes.append( gestes.append(
f"sudo usermod -aG {','.join(groupes)}" f"sudo usermod -aG {','.join(groupes)} {shlex.quote(utilisateur)}"
f" {shlex.quote(utilisateur)}"
) )
code, _o = self._pve_ssh(cible, " && ".join(gestes), timeout=60) code, _o = self._pve_ssh(cible, " && ".join(gestes), timeout=60)
if code: if code:

View file

@ -18,6 +18,7 @@ Le formulaire ne lance AUCUNE commande privilégiée ni réseau : tout appel
Textual. Les données coûteuses (domaines existants, branches distantes) sont Textual. Les données coûteuses (domaines existants, branches distantes) sont
préchargées par l'appelant et arrivent dans `ctx`. préchargées par l'appelant et arrivent dans `ctx`.
""" """
from __future__ import annotations from __future__ import annotations
import os import os

View file

@ -25,6 +25,7 @@ Deux pièges, appris sur l'hôte :
- « --add-device --graphics type=egl-headless » n'est PAS idempotent : appelé - « --add-device --graphics type=egl-headless » n'est PAS idempotent : appelé
deux fois, il pose deux affichages. D'où la lecture de l'état AVANT le plan. deux fois, il pose deux affichages. D'où la lecture de l'état AVANT le plan.
""" """
from __future__ import annotations from __future__ import annotations
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET

View file

@ -11,6 +11,7 @@ quitter le dashboard n'arrête rien, on peut le rouvrir pour ré-attacher.
- launch_installs(...) : lance les process détachés + écrit un manifeste JSON. - launch_installs(...) : lance les process détachés + écrit un manifeste JSON.
- run_monitor(manifest_path) : ouvre le dashboard Textual sur un manifeste. - run_monitor(manifest_path) : ouvre le dashboard Textual sur un manifeste.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@ -3282,9 +3283,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
return return
with self.suspend(): with self.suspend():
print(f"\n=== {title} — {vm['name']} ===") print(f"\n=== {title} — {vm['name']} ===")
os.system( os.system(f"{vm_ssh_prefix(vm)} {shlex.quote(cmd)} || true")
f"{vm_ssh_prefix(vm)} " f"{shlex.quote(cmd)} || true"
)
input("\nEntrée pour revenir au suivi… ") input("\nEntrée pour revenir au suivi… ")
def _run_update(self, vm, parts) -> None: def _run_update(self, vm, parts) -> None:

View file

@ -16,13 +16,14 @@ from script.todo import ssh_config, todo_install
from script.todo.qemu_cache_menu import bypass_menage from script.todo.qemu_cache_menu import bypass_menage
from script.todo.qemu_privilege import ( from script.todo.qemu_privilege import (
LIBVIRT_URI as URI, LIBVIRT_URI as URI,
)
from script.todo.qemu_privilege import (
sudo_prefix, sudo_prefix,
system_path, system_path,
virsh_argv, virsh_argv,
) )
from script.todo.todo_i18n import t from script.todo.todo_i18n import t
# Les fichiers d'état de dnsmasq, un par réseau libvirt. Sur une installation # Les fichiers d'état de dnsmasq, un par réseau libvirt. Sur une installation
# standard ils sont en 0644 dans un répertoire en 0755, donc lisibles sans # standard ils sont en 0644 dans un répertoire en 0755, donc lisibles sans
# privilège — le « .conf » posé à côté est en 0600, et c'est lui qui donne # privilège — le « .conf » posé à côté est en 0600, et c'est lui qui donne
@ -1682,10 +1683,7 @@ class QemuManageMixin:
# Arrêt gracieux trop long : proposer un arrêt forcé. # Arrêt gracieux trop long : proposer un arrêt forcé.
if self._is_yes( if self._is_yes(
input( input(
t( t("Graceful shutdown timed out. Force off (destroy)? (y/N): ")
"Graceful shutdown timed out. Force off (destroy)? "
"(y/N): "
)
) )
): ):
cmd = ( cmd = (
@ -2029,10 +2027,7 @@ class QemuManageMixin:
print(f"⚠ {t('Not enough free space for a full backup.')}") print(f"⚠ {t('Not enough free space for a full backup.')}")
return self._is_yes( return self._is_yes(
input( input(
t( t("Back up anyway, at the risk of filling the disk? (y/N): ")
"Back up anyway, at the risk of filling the disk?"
" (y/N): "
)
) )
) )
@ -2684,9 +2679,7 @@ class QemuManageMixin:
if proteges: if proteges:
print(f"\n{t('Kept (still attached to a VM):')}") print(f"\n{t('Kept (still attached to a VM):')}")
for size, path, porteur in sorted(proteges, key=lambda o: -o[0]): for size, path, porteur in sorted(proteges, key=lambda o: -o[0]):
print( print(f" {self._human_size(size):>9} {path} ← {porteur}")
f" {self._human_size(size):>9} {path}" f" ← {porteur}"
)
if orphans: if orphans:
total = sum(o[0] for o in orphans) total = sum(o[0] for o in orphans)
print(f"\n{t('Orphan files:')}") print(f"\n{t('Orphan files:')}")
@ -3171,9 +3164,8 @@ class QemuManageMixin:
`timeout` : délai max PAR VM (borne l'attente d'une VM sans IP). Un `timeout` : délai max PAR VM (borne l'attente d'une VM sans IP). Un
BATTEMENT toutes les 30 s liste les VM encore en attente -> jamais de BATTEMENT toutes les 30 s liste les VM encore en attente -> jamais de
silence prolongé qui donne l'impression d'un blocage.""" silence prolongé qui donne l'impression d'un blocage."""
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor, as_completed
from concurrent.futures import TimeoutError as _FTimeout from concurrent.futures import TimeoutError as _FTimeout
from concurrent.futures import as_completed
labels = labels or {} labels = labels or {}
print( print(

View file

@ -16,6 +16,7 @@ ligne, rien de plus. Un alias déclaré dans un fichier inclus reste donc
invisible aux trois lecteurs, alors même que `ssh -G` le résoudrait — la invisible aux trois lecteurs, alors même que `ssh -G` le résoudrait — la
source est incomplète sans être fausse. source est incomplète sans être fausse.
""" """
from __future__ import annotations from __future__ import annotations
import re import re

View file

@ -14,6 +14,7 @@ veut-on l'installer maintenant ?
Module à part, et non une méthode de `TODO` : `todo_upgrade` en a besoin Module à part, et non une méthode de `TODO` : `todo_upgrade` en a besoin
aussi, et il est importé PAR `todo` — le mettre là créerait un cycle. aussi, et il est importé PAR `todo` — le mettre là créerait un cycle.
""" """
from __future__ import annotations from __future__ import annotations
import importlib import importlib

View file

@ -13,6 +13,7 @@ télémétrie de navigation) et non dans un fichier versionné.
Tout est best-effort : une préférence illisible ou un disque plein ne doivent Tout est best-effort : une préférence illisible ou un disque plein ne doivent
JAMAIS empêcher le CLI de démarrer. JAMAIS empêcher le CLI de démarrer.
""" """
from __future__ import annotations from __future__ import annotations
import json import json

View file

@ -10,6 +10,7 @@ DIAGRAMME arborescent des fonctionnalités dans un TUI Textual, trié par usage.
dédupe les ré-affichages consécutifs pour ne compter que les TRANSITIONS. dédupe les ré-affichages consécutifs pour ne compter que les TRANSITIONS.
- run_tui() : ouvre l'arbre de navigation (compteurs par menu). - run_tui() : ouvre l'arbre de navigation (compteurs par menu).
""" """
from __future__ import annotations from __future__ import annotations
import ast import ast

View file

@ -13,7 +13,6 @@ import sys
import zipfile import zipfile
from uuid import uuid4 from uuid import uuid4
from script.todo import ( from script.todo import (
auto_ask, auto_ask,
migration_status, migration_status,
@ -1196,8 +1195,8 @@ class TodoUpgrade:
target_addons_path = dct_module.get("target_addons_path") target_addons_path = dct_module.get("target_addons_path")
source_addons_path = dct_module.get("source_addons_path") source_addons_path = dct_module.get("source_addons_path")
module_name = dct_module.get("module_name") module_name = dct_module.get("module_name")
source_version_odoo = f'{dct_module.get("source_version_odoo")}.0' source_version_odoo = f"{dct_module.get('source_version_odoo')}.0"
target_version_odoo = f'{dct_module.get("target_version_odoo")}.0' target_version_odoo = f"{dct_module.get('target_version_odoo')}.0"
source_module_path_to_copy = dct_module.get("source_module_path") source_module_path_to_copy = dct_module.get("source_module_path")
# Prepare git environment for target # Prepare git environment for target
if target_addons_path not in lst_path_git_clone_migrate: if target_addons_path not in lst_path_git_clone_migrate:
@ -1512,7 +1511,7 @@ class TodoUpgrade:
for next_version in range_version: for next_version in range_version:
print( print(
f"https://oca.github.io/OpenUpgrade/coverage_analysis/modules{next_version*10}-{(next_version+1)*10}.html" f"https://oca.github.io/OpenUpgrade/coverage_analysis/modules{next_version * 10}-{(next_version + 1) * 10}.html"
) )
# ⚠️ ℹ 💬 ❗ 🔷 ✨ 🟦 🔹 🔵 ⟳ ⧖ ⚙ ✔ ✅ ❌ ⏵ ⏸ ⏹ ◆ ◇ … ➤ ⚑ ★ ☆ ☰ ⬍ ⍟ ⊗ ⌘ ⏻ ⍰ # ⚠️ ℹ 💬 ❗ 🔷 ✨ 🟦 🔹 🔵 ⟳ ⧖ ⚙ ✔ ✅ ❌ ⏵ ⏸ ⏹ ◆ ◇ … ➤ ⚑ ★ ☆ ☰ ⬍ ⍟ ⊗ ⌘ ⏻ ⍰
@ -2041,8 +2040,7 @@ class TodoUpgrade:
print(f"✅ -> {t('Clone done for Odoo')}{next_version}") print(f"✅ -> {t('Clone done for Odoo')}{next_version}")
else: else:
print( print(
f"✅ -> {t('Clone already done for Odoo')}" f"✅ -> {t('Clone already done for Odoo')}{next_version}"
f"{next_version}"
) )
option_comment += 1 option_comment += 1
@ -2144,8 +2142,7 @@ class TodoUpgrade:
) )
else: else:
print( print(
f"✅ -> {t('Switch already done for Odoo')}" f"✅ -> {t('Switch already done for Odoo')}{next_version}"
f"{next_version}"
) )
lst_state_4_module_migrate_code = self.dct_progression.get( lst_state_4_module_migrate_code = self.dct_progression.get(
@ -2526,8 +2523,7 @@ class TodoUpgrade:
self.write_config() self.write_config()
print( print(
f"✅ -> {t('Module upgrade done for Odoo')}" f"✅ -> {t('Module upgrade done for Odoo')}{next_version}"
f"{next_version}"
) )
else: else:
print( print(
@ -4047,8 +4043,7 @@ class TodoUpgrade:
) )
if lst_detail: if lst_detail:
print( print(
f"✨ {t('Modules to uninstall before Odoo')}" f"✨ {t('Modules to uninstall before Odoo')}{next_version} :"
f"{next_version} :"
) )
self.print_uninstall_reason(lst_detail) self.print_uninstall_reason(lst_detail)
return list(dict.fromkeys(list(extra) + lst_file)) return list(dict.fromkeys(list(extra) + lst_file))
@ -4340,9 +4335,7 @@ class TodoUpgrade:
# Clone a project for next version # Clone a project for next version
# Get actual branch # Get actual branch
cmd_git_clone_migrate_source = ( cmd_git_clone_migrate_source = (
f"cd {source_addons_path} && " f"cd {source_addons_path} && git branch --show-current && cd ~-"
f"git branch --show-current && "
f"cd ~-"
) )
status, cmd_executed, lst_output = self.todo_upgrade_execute( status, cmd_executed, lst_output = self.todo_upgrade_execute(
cmd_git_clone_migrate_source, cmd_git_clone_migrate_source,
@ -4408,9 +4401,7 @@ class TodoUpgrade:
# TODO check config if path is added # TODO check config if path is added
# Get remote branch address # Get remote branch address
cmd_remote_address = ( cmd_remote_address = (
f"cd {source_addons_path} " f"cd {source_addons_path} && git remote get-url {remote} && cd ~-"
f"&& git remote get-url {remote} "
f"&& cd ~-"
) )
status, cmd_executed, lst_output = self.todo_upgrade_execute( status, cmd_executed, lst_output = self.todo_upgrade_execute(
cmd_remote_address, cmd_remote_address,

View file

@ -25,6 +25,7 @@ attend `{}` supprimerait le repli textuel en silence.
partir du JSON de `--report` déjà en main : aucune entrée-sortie et aucun partir du JSON de `--report` déjà en main : aucune entrée-sortie et aucun
sous-processus depuis l'affichage. sous-processus depuis l'affichage.
""" """
from __future__ import annotations from __future__ import annotations
try: try:
@ -256,9 +257,9 @@ def run_transform_form(ctx, run_app: bool = True):
# TOUS les imports textual ICI : le CLI importe ce module pour ses # TOUS les imports textual ICI : le CLI importe ce module pour ses
# fonctions pures, et les libellés des BINDINGS ne doivent être # fonctions pures, et les libellés des BINDINGS ne doivent être
# évalués qu'à l'appel, après le choix de la langue. # évalués qu'à l'appel, après le choix de la langue.
from rich.text import Text
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
from rich.text import Text
from textual.widgets import DataTable, Footer, Header, OptionList, Static from textual.widgets import DataTable, Footer, Header, OptionList, Static
from textual.widgets.option_list import Option from textual.widgets.option_list import Option

View file

@ -473,8 +473,7 @@ class TransformMenuMixin:
) )
if trouve is None: if trouve is None:
manque = t( manque = t(
"The selection matches no sheet;" "The selection matches no sheet; nothing was written."
" nothing was written."
) )
print(f"❌ {manque} {demandee}") print(f"❌ {manque} {demandee}")
return None return None
@ -685,8 +684,7 @@ class TransformMenuMixin:
) )
if apercu.get("hors_portee"): if apercu.get("hors_portee"):
print( print(
f" {apercu['hors_portee']}" f" {apercu['hors_portee']} {t('cell(s) left out of scope')}"
f" {t('cell(s) left out of scope')}"
) )
ecartees = apercu.get("colonnes_ecartees") or [] ecartees = apercu.get("colonnes_ecartees") or []
if ecartees: if ecartees:
@ -788,8 +786,7 @@ class TransformMenuMixin:
) )
) )
avis = t( avis = t(
"The file name is not anonymised;" "The file name is not anonymised; the default does not reuse it."
" the default does not reuse it."
) )
print(f" ⚠ {avis}") print(f" ⚠ {avis}")
reponse = self._transform_ask( reponse = self._transform_ask(
@ -870,10 +867,7 @@ class TransformMenuMixin:
print(f" {cible}") print(f" {cible}")
attendu = os.path.basename(existants[0]) attendu = os.path.basename(existants[0])
reponse = input( reponse = input(
t( t("This file already exists. Type its name in full to overwrite: ")
"This file already exists. Type its name in full to"
" overwrite: "
)
).strip() ).strip()
if reponse != attendu: if reponse != attendu:
print(f"❌ {t('Name does not match, nothing was written.')}") print(f"❌ {t('Name does not match, nothing was written.')}")
@ -997,8 +991,7 @@ class TransformMenuMixin:
print(f" {t('Left intact')} : {detail}") print(f" {t('Left intact')} : {detail}")
if bilan.get("hors_portee"): if bilan.get("hors_portee"):
print( print(
f" {bilan['hors_portee']}" f" {bilan['hors_portee']} {t('cell(s) left out of scope')}"
f" {t('cell(s) left out of scope')}"
) )
hors = bilan.get("hors_cellules") or {} hors = bilan.get("hors_cellules") or {}
# `isinstance(True, int)` vaut True : sans exclure les booléens, le # `isinstance(True, int)` vaut True : sans exclure les booléens, le

View file

@ -74,7 +74,9 @@ def set_version_installed(
) -> None: ) -> None:
"""Record that an Odoo version has been installed (or reinstalled).""" """Record that an Odoo version has been installed (or reinstalled)."""
state = read_state() state = read_state()
entry = state["odoo_versions"].get(odoo_version, _deep_copy(_EMPTY_VERSION_ENTRY)) entry = state["odoo_versions"].get(
odoo_version, _deep_copy(_EMPTY_VERSION_ENTRY)
)
entry["installed"] = True entry["installed"] = True
entry["extra"] = extra entry["extra"] = extra
if python: if python:
@ -94,7 +96,9 @@ def set_version_installed(
def set_version_switched(odoo_version: str) -> None: def set_version_switched(odoo_version: str) -> None:
"""Record that the workspace was switched to an Odoo version.""" """Record that the workspace was switched to an Odoo version."""
state = read_state() state = read_state()
entry = state["odoo_versions"].get(odoo_version, _deep_copy(_EMPTY_VERSION_ENTRY)) entry = state["odoo_versions"].get(
odoo_version, _deep_copy(_EMPTY_VERSION_ENTRY)
)
entry["switched_at"] = str(date.today()) entry["switched_at"] = str(date.today())
state["odoo_versions"][odoo_version] = entry state["odoo_versions"][odoo_version] = entry
state["current_odoo_version"] = odoo_version state["current_odoo_version"] = odoo_version

View file

@ -32,6 +32,7 @@ fichiers vus dans le parc déclarent
`xmlns="http://schemas.xmlsoap.org/encoding/"`, et un site peut en `xmlns="http://schemas.xmlsoap.org/encoding/"`, et un site peut en
distribuer un sans. Les balises sont donc cherchées sur leur nom local. distribuer un sans. Les balises sont donc cherchées sur leur nom local.
""" """
from __future__ import annotations from __future__ import annotations
import re import re

View file

@ -24,6 +24,7 @@ Où vivent les fichiers, pour tous les pilotes :
sans sudo : `status` en a besoin, et il sans sudo : `status` en a besoin, et il
tourne dans un autre processus que `up`. tourne dans un autre processus que `up`.
""" """
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress

View file

@ -37,6 +37,7 @@ Où vivent les fichiers, et pourquoi :
/etc/ipsec.conf, /etc/ipsec.secrets un bloc marqué, retiré au « down ». /etc/ipsec.conf, /etc/ipsec.secrets un bloc marqué, retiré au « down ».
/etc/strongswan.d/erplibre-vpn.conf le réglage install_routes. /etc/strongswan.d/erplibre-vpn.conf le réglage install_routes.
""" """
from __future__ import annotations from __future__ import annotations
import os import os

View file

@ -109,6 +109,7 @@ toutes deux à un moment où l'humain peut agir : le menu l'annonce avant la
saisie du secret, et le montage compare les longueurs si ce qui est déposé saisie du secret, et le montage compare les longueurs si ce qui est déposé
la dépasse. Zéro = aucune limite. la dépasse. Zéro = aucune limite.
""" """
from __future__ import annotations from __future__ import annotations
import json import json

View file

@ -26,6 +26,7 @@ Le tunnel scindé se demande à OpenVPN par `--route-nopull` : ignorer les
routes poussées, puis poser les nôtres. C'est un gros marteau — il ignore routes poussées, puis poser les nôtres. C'est un gros marteau — il ignore
aussi le DNS poussé — et le pilote le dit quand il le prend. aussi le DNS poussé — et le pilote le dit quand il le prend.
""" """
from __future__ import annotations from __future__ import annotations
import os import os

View file

@ -21,6 +21,7 @@ Deux différences qui changent le code, et pas seulement les commandes :
ouvrir la session SSH par root, avec les clés de root — c'est-à-dire aucune. ouvrir la session SSH par root, avec les clés de root — c'est-à-dire aucune.
D'où `sudo=False`, et un fichier de pid dans le home plutôt que dans /run. D'où `sudo=False`, et un fichier de pid dans le home plutôt que dans /run.
""" """
from __future__ import annotations from __future__ import annotations
import os import os

View file

@ -17,6 +17,7 @@ compris, en « tout le trafic », l'astuce de marquage (fwmark) qui garde
l'endpoint joignable. On ne double donc PAS son travail : un `ip route` de l'endpoint joignable. On ne double donc PAS son travail : un `ip route` de
plus ici entrerait en conflit avec le sien. plus ici entrerait en conflit avec le sien.
""" """
from __future__ import annotations from __future__ import annotations
import shlex import shlex

View file

@ -35,6 +35,7 @@ une liste d'erreurs que l'appelant AFFICHE. Un préréglage fautif rendrait
autrement tous les autres inatteignables, et la panne se lirait « aucun autrement tous les autres inatteignables, et la panne se lirait « aucun
préréglage » alors qu'il y en a dix. préréglage » alors qu'il y en a dix.
""" """
from __future__ import annotations from __future__ import annotations
import json import json

View file

@ -19,6 +19,7 @@ Toute valeur est VALIDÉE avant d'être écrite : elle finira dans un fichier de
configuration et dans une ligne de commande lancée par sudo. Un nom d'hôte configuration et dans une ligne de commande lancée par sudo. Un nom d'hôte
avec une espace ou un point-virgule n'y arrivera pas. avec une espace ou un point-virgule n'y arrivera pas.
""" """
from __future__ import annotations from __future__ import annotations
import json import json

View file

@ -20,6 +20,7 @@ utilisateur de la machine, `/proc/<pid>/environ` par le seul propriétaire du
processus. Un mot de passe en argument est visible de tous pendant toute la processus. Un mot de passe en argument est visible de tous pendant toute la
durée de la commande. durée de la commande.
""" """
from __future__ import annotations from __future__ import annotations
import shlex import shlex

View file

@ -14,6 +14,7 @@ refusé ICI, pas découvert par `sh`.
Chaque fonction NORMALISE en place (`profile[key]` reçoit la valeur propre) Chaque fonction NORMALISE en place (`profile[key]` reçoit la valeur propre)
et lève `ProfileError` avec un message destiné à l'humain. et lève `ProfileError` avec un message destiné à l'humain.
""" """
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress

View file

@ -22,6 +22,7 @@ tout seul : `ensure_vault` demande, et une réponse vide fait renoncer. Un
outil qui crée silencieusement un fichier de mots de passe dans un répertoire outil qui crée silencieusement un fichier de mots de passe dans un répertoire
qu'on n'a pas choisi est un outil qu'on n'ose plus lancer. qu'on n'a pas choisi est un outil qu'on n'ose plus lancer.
""" """
from __future__ import annotations from __future__ import annotations
import getpass import getpass

View file

@ -19,6 +19,7 @@ de l'utilisateur et son mot de passe maître est saisi par lui. Chaque étape
privilégiée appelle `sudo` séparément, et `--dry-run` les montre toutes sans privilégiée appelle `sudo` séparément, et `--dry-run` les montre toutes sans
en exécuter aucune. en exécuter aucune.
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse

View file

@ -26,6 +26,7 @@ Les valeurs y sont inventées — versions, noms de modèles, empreintes de
compilation. Un relevé pris sur une machine réelle figerait dans le dépôt le compilation. Un relevé pris sur une machine réelle figerait dans le dépôt le
nom d'un modèle et d'un hôte que personne n'a choisi d'y mettre. nom d'un modèle et d'un hôte que personne n'a choisi d'y mettre.
""" """
from __future__ import annotations from __future__ import annotations
import http.server import http.server
@ -163,8 +164,7 @@ FIXTURES: dict[str, dict[str, tuple[int, bytes]]] = {
"open_webui": { "open_webui": {
"/api/config": ( "/api/config": (
200, 200,
b'{"name":"Open WebUI","version":"0.0.0",' b'{"name":"Open WebUI","version":"0.0.0","deployment_id":"0000"}',
b'"deployment_id":"0000"}',
), ),
"/api/version": (200, b'{"version":"0.0.0"}'), "/api/version": (200, b'{"version":"0.0.0"}'),
}, },

View file

@ -40,6 +40,7 @@ Rien ici ne sort de la machine : on se lie à 127.0.0.1 sur le port 0 (l'OS
choisit), jamais sur un port fixe qui entrerait en collision avec ce qui choisit), jamais sur un port fixe qui entrerait en collision avec ce qui
écoute déjà. Aucun trousseau, aucun `~/.erplibre`, aucun identifiant réel. écoute déjà. Aucun trousseau, aucun `~/.erplibre`, aucun identifiant réel.
""" """
from __future__ import annotations from __future__ import annotations
import atexit import atexit

View file

@ -25,6 +25,7 @@ lisent aussi les tests de transport : deux tables se seraient contredites sans
que rien ne le montre. `/api/show` n'y est pas et se définit ici, parce que la que rien ne le montre. `/api/show` n'y est pas et se définit ici, parce que la
découverte n'émet que des GET et qu'elle n'a donc jamais eu à le connaître. découverte n'émet que des GET et qu'elle n'a donc jamais eu à le connaître.
""" """
import ipaddress import ipaddress
import os import os
import sys import sys

View file

@ -27,6 +27,7 @@ Aucun test ne lance `claude`, ne lit le registre de la machine, ni n'ouvre une
transcription réelle : le lanceur, le registre, l'état des processus et la transcription réelle : le lanceur, le registre, l'état des processus et la
lecture d'en-tête sont tous injectés. lecture d'en-tête sont tous injectés.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
@ -160,7 +161,7 @@ class LeListageDesVivantes(unittest.TestCase):
def _live(self, agents=AGENTS, registre=None, demarrage=DEMARRAGE): def _live(self, agents=AGENTS, registre=None, demarrage=DEMARRAGE):
return CS.live( return CS.live(
run=lambda argv: agents, run=lambda argv: agents,
read_registry=lambda: (REGISTRE if registre is None else registre), read_registry=lambda: REGISTRE if registre is None else registre,
read_stat=lambda pid: stat_avec(demarrage), read_stat=lambda pid: stat_avec(demarrage),
) )

View file

@ -22,6 +22,7 @@ valeurs identifiantes des cas sont INVENTÉES — une règle qui interdit de
nommer ne se cite pas elle-même en clair, et un test fige pour toujours ce nommer ne se cite pas elle-même en clair, et un test fige pour toujours ce
qu'il porte. qu'il porte.
""" """
from __future__ import annotations from __future__ import annotations
import os import os

View file

@ -25,6 +25,7 @@ Rien ici n'ouvre de socket sortante, ne lance de vrai `claude`, ni ne lit la
configuration de la machine : le serveur est lié à la boucle locale sur un configuration de la machine : le serveur est lié à la boucle locale sur un
port choisi par le système, et le lanceur de sous-processus est injecté. port choisi par le système, et le lanceur de sous-processus est injecté.
""" """
import json import json
import os import os
import sys import sys

View file

@ -21,6 +21,7 @@ l'ordre d'écriture : un chargeur se juge sur ce qu'il refuse.
Aucun test ne lit le disque de la machine : les racines sont des répertoires Aucun test ne lit le disque de la machine : les racines sont des répertoires
temporaires, et le répertoire personnel est détourné là où il compte. temporaires, et le répertoire personnel est détourné là où il compte.
""" """
from __future__ import annotations from __future__ import annotations
import os import os

View file

@ -18,6 +18,7 @@ vérifiée ici même — le paquet, lui, doit rester importable seul.
`_menu_header()` enregistre une télémétrie dans `~/.erplibre` : tout test qui `_menu_header()` enregistre une télémétrie dans `~/.erplibre` : tout test qui
appelle une méthode de menu la neutralise, sinon il écrit pour de vrai. appelle une méthode de menu la neutralise, sinon il écrit pour de vrai.
""" """
from __future__ import annotations from __future__ import annotations
import ast import ast
@ -97,10 +98,11 @@ class Cablage(unittest.TestCase):
from script.todo.todo import TODO from script.todo.todo import TODO
todo = TODO() todo = TODO()
with patch.object(TODO, "prompt_assistant_llm") as mock_llm, patch( with (
"script.todo.mail.menu.prompt_execute_mail" patch.object(TODO, "prompt_assistant_llm") as mock_llm,
) as mock_mail, patch("click.prompt", side_effect=["1", "0"]), patch( patch("script.todo.mail.menu.prompt_execute_mail") as mock_mail,
"script.todo.todo_telemetry.record" patch("click.prompt", side_effect=["1", "0"]),
patch("script.todo.todo_telemetry.record"),
): ):
todo.prompt_assistant() todo.prompt_assistant()
mock_llm.assert_called_once_with() mock_llm.assert_called_once_with()
@ -110,10 +112,11 @@ class Cablage(unittest.TestCase):
from script.todo.todo import TODO from script.todo.todo import TODO
todo = TODO() todo = TODO()
with patch.object(TODO, "prompt_assistant_llm") as mock_llm, patch( with (
"script.todo.mail.menu.prompt_execute_mail" patch.object(TODO, "prompt_assistant_llm") as mock_llm,
) as mock_mail, patch("click.prompt", side_effect=["2", "0"]), patch( patch("script.todo.mail.menu.prompt_execute_mail") as mock_mail,
"script.todo.todo_telemetry.record" patch("click.prompt", side_effect=["2", "0"]),
patch("script.todo.todo_telemetry.record"),
): ):
todo.prompt_assistant() todo.prompt_assistant()
mock_mail.assert_called_once() mock_mail.assert_called_once()
@ -128,12 +131,13 @@ class Cablage(unittest.TestCase):
from script.todo.todo import TODO from script.todo.todo import TODO
todo = TODO() todo = TODO()
with patch( with (
"script.todo.assistant.fingerprint.collect", return_value={} patch(
), patch("script.todo.assistant.servers.load", return_value=[]), patch( "script.todo.assistant.fingerprint.collect", return_value={}
"click.prompt", side_effect=["0"] ),
), patch( patch("script.todo.assistant.servers.load", return_value=[]),
"script.todo.todo_telemetry.record" patch("click.prompt", side_effect=["0"]),
patch("script.todo.todo_telemetry.record"),
): ):
todo.prompt_assistant_llm() todo.prompt_assistant_llm()
@ -244,16 +248,20 @@ class Balayage(unittest.TestCase):
todo = self._todo() todo = self._todo()
vus = {} vus = {}
with patch.object( with (
llm_disc, "local_networks", return_value=[] patch.object(llm_disc, "local_networks", return_value=[]),
), patch.object(llm_disc, "run_ip", return_value=""), patch.object( patch.object(llm_disc, "run_ip", return_value=""),
todo, "_qemu_host_addresses", staticmethod(lambda: set()) patch.object(
), patch.object( todo, "_qemu_host_addresses", staticmethod(lambda: set())
todo, ),
"_llm_probe_and_keep", patch.object(
lambda adresses, **kw: vus.update({"n": len(adresses), "kw": kw}), todo,
), patch( "_llm_probe_and_keep",
"click.prompt", side_effect=["198.51.100.0/24", "o"] lambda adresses, **kw: vus.update(
{"n": len(adresses), "kw": kw}
),
),
patch("click.prompt", side_effect=["198.51.100.0/24", "o"]),
): ):
todo._llm_search_cidr() todo._llm_search_cidr()
self.assertEqual(vus.get("n"), 254) self.assertEqual(vus.get("n"), 254)
@ -269,16 +277,19 @@ class Balayage(unittest.TestCase):
todo = self._todo() todo = self._todo()
vus = {} vus = {}
voisinage = "198.51.100.1 dev lien0 lladdr aa:bb:cc:dd:ee:01 REACHABLE" voisinage = "198.51.100.1 dev lien0 lladdr aa:bb:cc:dd:ee:01 REACHABLE"
with patch.object( with (
llm_disc, "run_ip", return_value=voisinage patch.object(llm_disc, "run_ip", return_value=voisinage),
), patch.object( patch.object(
todo, "_qemu_host_addresses", staticmethod(lambda: set()) todo, "_qemu_host_addresses", staticmethod(lambda: set())
), patch.object( ),
todo, patch.object(
"_llm_probe_and_keep", todo,
lambda adresses, **kw: vus.update({"n": len(adresses), "kw": kw}), "_llm_probe_and_keep",
), patch( lambda adresses, **kw: vus.update(
"click.prompt", side_effect=["o"] {"n": len(adresses), "kw": kw}
),
),
patch("click.prompt", side_effect=["o"]),
): ):
todo._llm_sweep_cidr("198.51.100.0/24") todo._llm_sweep_cidr("198.51.100.0/24")
self.assertEqual(vus.get("n"), 254) self.assertEqual(vus.get("n"), 254)
@ -290,16 +301,19 @@ class Balayage(unittest.TestCase):
todo = self._todo() todo = self._todo()
vus = {} vus = {}
voisinage = "198.51.100.1 dev lien0 lladdr aa:bb:cc:dd:ee:01 REACHABLE" voisinage = "198.51.100.1 dev lien0 lladdr aa:bb:cc:dd:ee:01 REACHABLE"
with patch.object( with (
llm_disc, "run_ip", return_value=voisinage patch.object(llm_disc, "run_ip", return_value=voisinage),
), patch.object( patch.object(
todo, "_qemu_host_addresses", staticmethod(lambda: set()) todo, "_qemu_host_addresses", staticmethod(lambda: set())
), patch.object( ),
todo, patch.object(
"_llm_probe_and_keep", todo,
lambda adresses, **kw: vus.update({"n": len(adresses), "kw": kw}), "_llm_probe_and_keep",
), patch( lambda adresses, **kw: vus.update(
"click.prompt", side_effect=["v"] {"n": len(adresses), "kw": kw}
),
),
patch("click.prompt", side_effect=["v"]),
): ):
todo._llm_sweep_cidr("198.51.100.0/24") todo._llm_sweep_cidr("198.51.100.0/24")
self.assertEqual(vus.get("n"), 1) self.assertEqual(vus.get("n"), 1)
@ -315,8 +329,9 @@ class Balayage(unittest.TestCase):
todo = self._todo() todo = self._todo()
sortie = io.StringIO() sortie = io.StringIO()
with patch.object(llm_disc, "sweep", return_value=[]), redirect_stdout( with (
sortie patch.object(llm_disc, "sweep", return_value=[]),
redirect_stdout(sortie),
): ):
todo._llm_probe_and_keep( todo._llm_probe_and_keep(
["198.51.100.1"], cible="198.51.100.0/24", restreint=True ["198.51.100.1"], cible="198.51.100.0/24", restreint=True
@ -332,8 +347,9 @@ class Balayage(unittest.TestCase):
todo = self._todo() todo = self._todo()
sortie = io.StringIO() sortie = io.StringIO()
with patch.object(llm_disc, "sweep", return_value=[]), redirect_stdout( with (
sortie patch.object(llm_disc, "sweep", return_value=[]),
redirect_stdout(sortie),
): ):
todo._llm_probe_and_keep(["198.51.100.1"], cible="198.51.100.0/24") todo._llm_probe_and_keep(["198.51.100.1"], cible="198.51.100.0/24")
self.assertNotIn( self.assertNotIn(
@ -345,12 +361,15 @@ class Balayage(unittest.TestCase):
todo = self._todo() todo = self._todo()
appels = [] appels = []
with patch.object( with (
todo, "_qemu_host_addresses", staticmethod(lambda: set()) patch.object(
), patch.object(llm_disc, "run_ip", return_value=""), patch.object( todo, "_qemu_host_addresses", staticmethod(lambda: set())
todo, "_llm_probe_and_keep", lambda *a, **k: appels.append(a) ),
), patch( patch.object(llm_disc, "run_ip", return_value=""),
"click.prompt", side_effect=[] patch.object(
todo, "_llm_probe_and_keep", lambda *a, **k: appels.append(a)
),
patch("click.prompt", side_effect=[]),
): ):
todo._llm_sweep_cidr("10.0.0.0/8") todo._llm_sweep_cidr("10.0.0.0/8")
self.assertEqual(appels, []) self.assertEqual(appels, [])
@ -445,14 +464,13 @@ class SessionsClaudeCode(unittest.TestCase):
from script.todo.todo import TODO from script.todo.todo import TODO
todo = TODO() todo = TODO()
with patch.object( with (
TODO, "prompt_claude_sessions" patch.object(TODO, "prompt_claude_sessions") as mock_sessions,
) as mock_sessions, patch.object( patch.object(
TODO, "prompt_execute_claude_plugins" TODO, "prompt_execute_claude_plugins"
) as mock_plugins, patch( ) as mock_plugins,
"click.prompt", side_effect=["6", "0"] patch("click.prompt", side_effect=["6", "0"]),
), patch( patch("script.todo.todo_telemetry.record"),
"script.todo.todo_telemetry.record"
): ):
todo.prompt_execute_gpt_code() todo.prompt_execute_gpt_code()
mock_sessions.assert_called_once_with() mock_sessions.assert_called_once_with()
@ -463,10 +481,12 @@ class SessionsClaudeCode(unittest.TestCase):
from script.todo.todo import TODO from script.todo.todo import TODO
todo = TODO() todo = TODO()
with patch( with (
"script.todo.assistant.claude_sessions.fleet", return_value=[] patch(
), patch("click.prompt", side_effect=["0"]), patch( "script.todo.assistant.claude_sessions.fleet", return_value=[]
"script.todo.todo_telemetry.record" ),
patch("click.prompt", side_effect=["0"]),
patch("script.todo.todo_telemetry.record"),
): ):
todo.prompt_claude_sessions() todo.prompt_claude_sessions()
@ -491,9 +511,11 @@ class SessionsClaudeCode(unittest.TestCase):
todo = TODO() todo = TODO()
sortie = io.StringIO() sortie = io.StringIO()
with patch("shutil.which", return_value="/usr/bin/claude"), patch( with (
"click.prompt", side_effect=["0"] patch("shutil.which", return_value="/usr/bin/claude"),
), redirect_stdout(sortie): patch("click.prompt", side_effect=["0"]),
redirect_stdout(sortie),
):
todo._claude_reprendre([]) todo._claude_reprendre([])
self.assertIn(t("No session on this machine."), sortie.getvalue()) self.assertIn(t("No session on this machine."), sortie.getvalue())

View file

@ -45,8 +45,10 @@ sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
) )
from script.todo.assistant import discover # noqa: E402 from script.todo.assistant import (
from script.todo.assistant import fingerprint # noqa: E402 discover, # noqa: E402
fingerprint, # noqa: E402
)
# Deux réseaux des blocs réservés à la documentation : ils ne peuvent # Deux réseaux des blocs réservés à la documentation : ils ne peuvent
# désigner aucune machine réelle. # désigner aucune machine réelle.

View file

@ -11,6 +11,7 @@ un chemin en gabarit doivent passer sans un mot.
La part qu'aucun motif ne juge — « cette phrase énonce-t-elle un fait durable La part qu'aucun motif ne juge — « cette phrase énonce-t-elle un fait durable
ou raconte-t-elle une journée » — n'est pas testée : elle n'est pas décidable. ou raconte-t-elle une journée » — n'est pas testée : elle n'est pas décidable.
""" """
import os import os
import subprocess import subprocess
import sys import sys

View file

@ -94,24 +94,32 @@ class TestGetConfig(unittest.TestCase):
base_path = self._write_json( base_path = self._write_json(
"base.json", {"instance": [{"name": "test"}]} "base.json", {"instance": [{"name": "test"}]}
) )
with patch("script.config.config_file.CONFIG_FILE", base_path), patch( with (
"script.config.config_file.CONFIG_OVERRIDE_FILE", patch("script.config.config_file.CONFIG_FILE", base_path),
os.path.join(self.tmpdir, "nonexistent1.json"), patch(
), patch( "script.config.config_file.CONFIG_OVERRIDE_FILE",
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", os.path.join(self.tmpdir, "nonexistent1.json"),
os.path.join(self.tmpdir, "nonexistent2.json"), ),
patch(
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE",
os.path.join(self.tmpdir, "nonexistent2.json"),
),
): ):
result = self.cfg.get_config("instance") result = self.cfg.get_config("instance")
self.assertEqual(result, [{"name": "test"}]) self.assertEqual(result, [{"name": "test"}])
def test_get_config_returns_none_for_missing_key(self): def test_get_config_returns_none_for_missing_key(self):
base_path = self._write_json("base.json", {"a": 1}) base_path = self._write_json("base.json", {"a": 1})
with patch("script.config.config_file.CONFIG_FILE", base_path), patch( with (
"script.config.config_file.CONFIG_OVERRIDE_FILE", patch("script.config.config_file.CONFIG_FILE", base_path),
os.path.join(self.tmpdir, "nonexistent1.json"), patch(
), patch( "script.config.config_file.CONFIG_OVERRIDE_FILE",
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", os.path.join(self.tmpdir, "nonexistent1.json"),
os.path.join(self.tmpdir, "nonexistent2.json"), ),
patch(
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE",
os.path.join(self.tmpdir, "nonexistent2.json"),
),
): ):
result = self.cfg.get_config("missing") result = self.cfg.get_config("missing")
self.assertIsNone(result) self.assertIsNone(result)
@ -125,12 +133,16 @@ class TestGetConfig(unittest.TestCase):
"override.json", "override.json",
{"instance": [{"name": "override"}]}, {"instance": [{"name": "override"}]},
) )
with patch("script.config.config_file.CONFIG_FILE", base_path), patch( with (
"script.config.config_file.CONFIG_OVERRIDE_FILE", patch("script.config.config_file.CONFIG_FILE", base_path),
override_path, patch(
), patch( "script.config.config_file.CONFIG_OVERRIDE_FILE",
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", override_path,
os.path.join(self.tmpdir, "nonexistent.json"), ),
patch(
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE",
os.path.join(self.tmpdir, "nonexistent.json"),
),
): ):
result = self.cfg.get_config("instance") result = self.cfg.get_config("instance")
# Lists with extend: base + override # Lists with extend: base + override
@ -148,12 +160,16 @@ class TestGetConfig(unittest.TestCase):
"private.json", "private.json",
{"data": {"key": "private_val"}}, {"data": {"key": "private_val"}},
) )
with patch("script.config.config_file.CONFIG_FILE", base_path), patch( with (
"script.config.config_file.CONFIG_OVERRIDE_FILE", patch("script.config.config_file.CONFIG_FILE", base_path),
os.path.join(self.tmpdir, "nonexistent.json"), patch(
), patch( "script.config.config_file.CONFIG_OVERRIDE_FILE",
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", os.path.join(self.tmpdir, "nonexistent.json"),
private_path, ),
patch(
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE",
private_path,
),
): ):
result = self.cfg.get_config("data") result = self.cfg.get_config("data")
self.assertEqual(result, {"key": "private_val"}) self.assertEqual(result, {"key": "private_val"})
@ -171,12 +187,16 @@ class TestGetConfig(unittest.TestCase):
"private.json", "private.json",
{"items": [3], "meta": {"a": "private"}}, {"items": [3], "meta": {"a": "private"}},
) )
with patch("script.config.config_file.CONFIG_FILE", base_path), patch( with (
"script.config.config_file.CONFIG_OVERRIDE_FILE", patch("script.config.config_file.CONFIG_FILE", base_path),
override_path, patch(
), patch( "script.config.config_file.CONFIG_OVERRIDE_FILE",
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", override_path,
private_path, ),
patch(
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE",
private_path,
),
): ):
result_items = self.cfg.get_config("items") result_items = self.cfg.get_config("items")
result_meta = self.cfg.get_config("meta") result_meta = self.cfg.get_config("meta")
@ -188,15 +208,19 @@ class TestGetConfig(unittest.TestCase):
self.assertEqual(result_meta, {"a": "private", "b": "override"}) self.assertEqual(result_meta, {"a": "private", "b": "override"})
def test_no_config_files_exist(self): def test_no_config_files_exist(self):
with patch( with (
"script.config.config_file.CONFIG_FILE", patch(
os.path.join(self.tmpdir, "nope1.json"), "script.config.config_file.CONFIG_FILE",
), patch( os.path.join(self.tmpdir, "nope1.json"),
"script.config.config_file.CONFIG_OVERRIDE_FILE", ),
os.path.join(self.tmpdir, "nope2.json"), patch(
), patch( "script.config.config_file.CONFIG_OVERRIDE_FILE",
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", os.path.join(self.tmpdir, "nope2.json"),
os.path.join(self.tmpdir, "nope3.json"), ),
patch(
"script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE",
os.path.join(self.tmpdir, "nope3.json"),
),
): ):
result = self.cfg.get_config("anything") result = self.cfg.get_config("anything")
self.assertIsNone(result) self.assertIsNone(result)
@ -284,12 +308,15 @@ class TestSetConfigValue(unittest.TestCase):
def test_round_trips_through_get_config_value(self): def test_round_trips_through_get_config_value(self):
self.cfg.set_config_value(["kdbx", "path"], "/round/trip.kdbx") self.cfg.set_config_value(["kdbx", "path"], "/round/trip.kdbx")
with patch( with (
"script.config.config_file.CONFIG_FILE", patch(
os.path.join(self.tmp.name, "nonexistent_base.json"), "script.config.config_file.CONFIG_FILE",
), patch( os.path.join(self.tmp.name, "nonexistent_base.json"),
"script.config.config_file.CONFIG_OVERRIDE_FILE", ),
os.path.join(self.tmp.name, "nonexistent_override.json"), patch(
"script.config.config_file.CONFIG_OVERRIDE_FILE",
os.path.join(self.tmp.name, "nonexistent_override.json"),
),
): ):
result = self.cfg.get_config_value(["kdbx", "path"]) result = self.cfg.get_config_value(["kdbx", "path"])
self.assertEqual(result, "/round/trip.kdbx") self.assertEqual(result, "/round/trip.kdbx")

View file

@ -449,9 +449,8 @@ class TestTheSilenceThatLookedLikeAHang(unittest.TestCase):
def test_the_relay_shows_the_elapsed_time(self): def test_the_relay_shows_the_elapsed_time(self):
# Ce qui distingue « ça avance lentement » de « ça ne bouge plus ». # Ce qui distingue « ça avance lentement » de « ça ne bouge plus ».
import io import io
from contextlib import redirect_stdout
import time as _time import time as _time
from contextlib import redirect_stdout
echo = cleanup.make_echo(_time.monotonic() - 42) echo = cleanup.make_echo(_time.monotonic() - 42)
out = io.StringIO() out = io.StringIO()

View file

@ -158,8 +158,8 @@ class TestTheVersionGuard(unittest.TestCase):
self.vraie = database_cleanup.require_matching_version self.vraie = database_cleanup.require_matching_version
self.vrai_shell = database_cleanup.run_shell self.vrai_shell = database_cleanup.run_shell
self.appels = [] self.appels = []
database_cleanup.run_shell = ( database_cleanup.run_shell = lambda *a, **k: (
lambda *a, **k: self.appels.append(a) or {} self.appels.append(a) or {}
) )
def tearDown(self): def tearDown(self):

View file

@ -21,7 +21,7 @@ class TestEditText(unittest.TestCase):
def test_updates_image_after_erplibre(self): def test_updates_image_after_erplibre(self):
path = self._write_compose( path = self._write_compose(
"services:\n" " ERPLibre:\n" " image: old:1.0\n" " ports:\n" "services:\n ERPLibre:\n image: old:1.0\n ports:\n"
) )
config = SimpleNamespace( config = SimpleNamespace(
docker_compose_file=path, docker_compose_file=path,

View file

@ -187,8 +187,7 @@ class TestWhatItRefusesToTouch(Base):
self.assertEqual( self.assertEqual(
"r", "r",
self.lire( self.lire(
"SELECT relkind FROM pg_class" "SELECT relkind FROM pg_class WHERE relname = 'account_root'"
" WHERE relname = 'account_root'"
), ),
) )

View file

@ -572,8 +572,8 @@ class TestTheToolIsCalledWithTheRightContract(unittest.TestCase):
def test_nothing_to_fix_asks_nothing(self): def test_nothing_to_fix_asks_nothing(self):
demandes = [] demandes = []
self.obj.ask = ( self.obj.ask = lambda prompt, default="": (
lambda prompt, default="": demandes.append(prompt) or "y" demandes.append(prompt) or "y"
) )
self.obj.todo_upgrade_execute = lambda cmd, **kw: (0, cmd) self.obj.todo_upgrade_execute = lambda cmd, **kw: (0, cmd)
with redirect_stdout(io.StringIO()): with redirect_stdout(io.StringIO()):

View file

@ -3,7 +3,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import unittest import unittest
from unittest.mock import patch, MagicMock from unittest.mock import MagicMock, patch
from script.maintenance.format_file_to_commit import ( from script.maintenance.format_file_to_commit import (
execute_shell, execute_shell,

View file

@ -15,6 +15,7 @@ Les messages sont traduits. Les assertions qui citent du texte fixent donc la
langue à « fr » pour la durée du module : sinon elles dépendraient de EL_LANG, langue à « fr » pour la durée du module : sinon elles dépendraient de EL_LANG,
et un poste en anglais les ferait toutes échouer. et un poste en anglais les ferait toutes échouer.
""" """
import os import os
import subprocess import subprocess
import sys import sys

View file

@ -203,9 +203,7 @@ class TestStrInsert(unittest.TestCase):
class TestGetProjectConfig(unittest.TestCase): class TestGetProjectConfig(unittest.TestCase):
def test_reads_github_token(self): def test_reads_github_token(self):
content = ( content = (
"#!/bin/bash\n" '#!/bin/bash\nEL_GITHUB_TOKEN="my_token_123"\nOTHER_VAR="value"\n'
'EL_GITHUB_TOKEN="my_token_123"\n'
'OTHER_VAR="value"\n'
) )
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
mode="w", suffix=".sh", delete=False, dir="/tmp" mode="w", suffix=".sh", delete=False, dir="/tmp"

View file

@ -48,7 +48,9 @@ PyObject *sonde(long v) { return PyInt_FromLong(v); }
def _drapeau(): def _drapeau():
"""Le -D tel qu'il est écrit dans le script — source unique.""" """Le -D tel qu'il est écrit dans le script — source unique."""
m = re.search(r"export CPPFLAGS=\"[^\"]*?(-DPyInt_FromLong[^\" ]*)\"", SOURCE) m = re.search(
r"export CPPFLAGS=\"[^\"]*?(-DPyInt_FromLong[^\" ]*)\"", SOURCE
)
return m.group(1) if m else "" return m.group(1) if m else ""
@ -65,7 +67,14 @@ def _compile(source, drapeaux):
fichier = Path(tmp, "sonde.cpp") fichier = Path(tmp, "sonde.cpp")
fichier.write_text(source, encoding="utf-8") fichier.write_text(source, encoding="utf-8")
res = subprocess.run( res = subprocess.run(
[_compilateur(), "-fsyntax-only", *drapeaux, "-I", inc, str(fichier)], [
_compilateur(),
"-fsyntax-only",
*drapeaux,
"-I",
inc,
str(fichier),
],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=120, timeout=120,
@ -81,13 +90,13 @@ class TestLeDrapeauDansLeScript(unittest.TestCase):
"""Posé après, il n'atteindrait rien : c'est « poetry install » qui """Posé après, il n'atteindrait rien : c'est « poetry install » qui
lance les compilations.""" lance les compilations."""
export = SOURCE.index("export CPPFLAGS=") export = SOURCE.index("export CPPFLAGS=")
install = SOURCE.index('install --no-root ${POETRY_VERBOSE}') install = SOURCE.index("install --no-root ${POETRY_VERBOSE}")
self.assertLess(export, install) self.assertLess(export, install)
def test_it_appends_instead_of_replacing(self): def test_it_appends_instead_of_replacing(self):
"""Écraser CPPFLAGS jetterait ce que l'appelant y avait mis — sur """Écraser CPPFLAGS jetterait ce que l'appelant y avait mis — sur
s390x et openSUSE, des chemins d'en-têtes y passent.""" s390x et openSUSE, des chemins d'en-têtes y passent."""
self.assertIn('${CPPFLAGS:+${CPPFLAGS} }', SOURCE) self.assertIn("${CPPFLAGS:+${CPPFLAGS} }", SOURCE)
def test_it_is_cppflags_and_not_cflags(self): def test_it_is_cppflags_and_not_cflags(self):
"""Le piège qui a fait échouer le premier correctif : le fichier fautif """Le piège qui a fait échouer le premier correctif : le fichier fautif
@ -145,9 +154,7 @@ class TestLeDrapeauCompile(unittest.TestCase):
"""Un hôte dont le SWIG écrit encore l'alias verrait, sinon, un """Un hôte dont le SWIG écrit encore l'alias verrait, sinon, un
avertissement de redéfinition par fichier compilé. En -Werror, ce avertissement de redéfinition par fichier compilé. En -Werror, ce
serait un échec.""" serait un échec."""
ok, sortie = _compile( ok, sortie = _compile(SWIG_42 + APPEL_SWIG_43, ["-Werror", _drapeau()])
SWIG_42 + APPEL_SWIG_43, ["-Werror", _drapeau()]
)
if ok is None: if ok is None:
self.skipTest(sortie) self.skipTest(sortie)
self.assertTrue(ok, sortie[-400:]) self.assertTrue(ok, sortie[-400:])

View file

@ -11,6 +11,7 @@ la clé i18n brute, sans nommer ce qu'elle demandait.
Un mot de passe refusé est le cas NORMAL de cette fonction : elle doit le Un mot de passe refusé est le cas NORMAL de cette fonction : elle doit le
dire, laisser recommencer, et laisser partir. dire, laisser recommencer, et laisser partir.
""" """
import os import os
import tempfile import tempfile
import unittest import unittest
@ -40,13 +41,16 @@ class KdbxCase(unittest.TestCase):
def _saisies(self, *reponses): def _saisies(self, *reponses):
"""Renvoie (résultat, texte affiché) pour une suite de saisies.""" """Renvoie (résultat, texte affiché) pour une suite de saisies."""
vues = [] vues = []
with patch("getpass.getpass", side_effect=list(reponses)), patch( with (
"builtins.print", patch("getpass.getpass", side_effect=list(reponses)),
# `**k` : un bouchon de `print` doit accepter la signature de patch(
# `print`. Sans lui, ajouter un `flush=True` dans le code "builtins.print",
# testé faisait échouer six tests sur une différence qui n'a # `**k` : un bouchon de `print` doit accepter la signature de
# rien à voir avec ce qu'ils vérifient. # `print`. Sans lui, ajouter un `flush=True` dans le code
side_effect=lambda *a, **k: vues.append(" ".join(map(str, a))), # testé faisait échouer six tests sur une différence qui n'a
# rien à voir avec ce qu'ils vérifient.
side_effect=lambda *a, **k: vues.append(" ".join(map(str, a))),
),
): ):
resultat = self._manager().get_kdbx() resultat = self._manager().get_kdbx()
return resultat, "\n".join(vues) return resultat, "\n".join(vues)
@ -86,9 +90,13 @@ class TestGivingUpIsPossible(KdbxCase):
def test_giving_up_asks_only_once(self): def test_giving_up_asks_only_once(self):
appels = [] appels = []
with patch( with (
"getpass.getpass", side_effect=lambda **k: appels.append(1) or "" patch(
), patch("builtins.print"): "getpass.getpass",
side_effect=lambda **k: appels.append(1) or "",
),
patch("builtins.print"),
):
self._manager().get_kdbx() self._manager().get_kdbx()
self.assertEqual(len(appels), 1) self.assertEqual(len(appels), 1)

View file

@ -530,8 +530,8 @@ class TestComposeScreenMounted(unittest.IsolatedAsyncioTestCase):
app = await self._mounted_app(self._FakeIMAPTransport()) app = await self._mounted_app(self._FakeIMAPTransport())
orig_connect, orig_send = smtp_send_mod.connect, smtp_send_mod.send orig_connect, orig_send = smtp_send_mod.connect, smtp_send_mod.send
smtp_send_mod.connect = ( smtp_send_mod.connect = lambda account, password: (
lambda account, password: self._FakeSMTPTransport() self._FakeSMTPTransport()
) )
def boom(account, msg, transport): def boom(account, msg, transport):
@ -568,8 +568,8 @@ class TestComposeScreenMounted(unittest.IsolatedAsyncioTestCase):
imap_transport = self._FakeIMAPTransport() imap_transport = self._FakeIMAPTransport()
app = await self._mounted_app(imap_transport) app = await self._mounted_app(imap_transport)
orig_connect, orig_send = smtp_send_mod.connect, smtp_send_mod.send orig_connect, orig_send = smtp_send_mod.connect, smtp_send_mod.send
smtp_send_mod.connect = ( smtp_send_mod.connect = lambda account, password: (
lambda account, password: self._FakeSMTPTransport() self._FakeSMTPTransport()
) )
smtp_send_mod.send = lambda account, msg, transport: [ smtp_send_mod.send = lambda account, msg, transport: [
"dest@example.com" "dest@example.com"
@ -637,8 +637,8 @@ class TestComposeScreenMounted(unittest.IsolatedAsyncioTestCase):
return ["dest@example.com"] return ["dest@example.com"]
orig_connect, orig_send = smtp_send_mod.connect, smtp_send_mod.send orig_connect, orig_send = smtp_send_mod.connect, smtp_send_mod.send
smtp_send_mod.connect = ( smtp_send_mod.connect = lambda account, password: (
lambda account, password: self._FakeSMTPTransport() self._FakeSMTPTransport()
) )
smtp_send_mod.send = capture_send smtp_send_mod.send = capture_send
try: try:
@ -803,9 +803,10 @@ class TestBrowseFilesButton(TestComposeScreenMounted):
async with app.run_test() as pilot: async with app.run_test() as pilot:
await pilot.press("c") await pilot.press("c")
await pilot.pause() await pilot.pause()
with patch.object( with (
type(app), "suspend", fake_suspend patch.object(type(app), "suspend", fake_suspend),
), patch.object(browser_mod, "FileBrowser", FakeFileBrowser): patch.object(browser_mod, "FileBrowser", FakeFileBrowser),
):
app.screen.query_one("#browse_files", Button).press() app.screen.query_one("#browse_files", Button).press()
await pilot.pause() await pilot.pause()

View file

@ -17,6 +17,7 @@ tout le fichier se saute proprement. Pour les lancer volontairement :
.venv.erplibre/bin/python -m unittest discover -s test \\ .venv.erplibre/bin/python -m unittest discover -s test \\
-p test_mail_live_server.py -v -p test_mail_live_server.py -v
""" """
import email.utils import email.utils
import threading import threading
import unittest import unittest

View file

@ -80,24 +80,24 @@ class TestAddAccountRollsBack(unittest.TestCase):
vault = MagicMock() vault = MagicMock()
vault.available_backends.return_value = ["kdbx"] vault.available_backends.return_value = ["kdbx"]
with patch.object( with (
menu, "secret_store_for", return_value=vault patch.object(menu, "secret_store_for", return_value=vault),
), patch.object( patch.object(
menu.mail_accounts, "save", side_effect=OSError("disque plein") menu.mail_accounts, "save", side_effect=OSError("disque plein")
), patch.object( ),
menu, "_load_accounts", return_value=[] patch.object(menu, "_load_accounts", return_value=[]),
), patch( patch(
"builtins.input", "builtins.input",
side_effect=[ side_effect=[
"perso", "perso",
"moi@x.ca", "moi@x.ca",
"", "",
"4", "4",
"imap.x.ca", "imap.x.ca",
"smtp.x.ca", "smtp.x.ca",
], ],
), patch( ),
"getpass.getpass", return_value="hunter2" patch("getpass.getpass", return_value="hunter2"),
): ):
menu._add_account(MagicMock()) menu._add_account(MagicMock())
@ -111,24 +111,24 @@ class TestAddAccountRollsBack(unittest.TestCase):
vault = MagicMock() vault = MagicMock()
vault.available_backends.return_value = ["kdbx"] vault.available_backends.return_value = ["kdbx"]
with patch.object( with (
menu, "secret_store_for", return_value=vault patch.object(menu, "secret_store_for", return_value=vault),
), patch.object( patch.object(
menu.mail_accounts, "save", side_effect=OSError("disque plein") menu.mail_accounts, "save", side_effect=OSError("disque plein")
), patch.object( ),
menu, "_load_accounts", return_value=[] patch.object(menu, "_load_accounts", return_value=[]),
), patch( patch(
"builtins.input", "builtins.input",
side_effect=[ side_effect=[
"perso", "perso",
"moi@x.ca", "moi@x.ca",
"", "",
"4", "4",
"imap.x.ca", "imap.x.ca",
"smtp.x.ca", "smtp.x.ca",
], ],
), patch( ),
"getpass.getpass", return_value="hunter2" patch("getpass.getpass", return_value="hunter2"),
): ):
menu._add_account(MagicMock()) # ne doit pas lever menu._add_account(MagicMock()) # ne doit pas lever
@ -144,9 +144,13 @@ class TestOpenTuiAllowsEmptyAccounts(unittest.TestCase):
import script.todo.mail.menu as menu import script.todo.mail.menu as menu
todo = MagicMock() todo = MagicMock()
with patch.object(menu, "_load_accounts", return_value=[]), patch( with (
"script.todo.mail.tui.open_sessions", return_value=[] patch.object(menu, "_load_accounts", return_value=[]),
) as mock_open, patch("script.todo.mail.tui.run_tui") as mock_run: patch(
"script.todo.mail.tui.open_sessions", return_value=[]
) as mock_open,
patch("script.todo.mail.tui.run_tui") as mock_run,
):
menu._open_tui(todo) menu._open_tui(todo)
mock_open.assert_called_once() mock_open.assert_called_once()
@ -161,13 +165,12 @@ class TestOpenTuiAllowsEmptyAccounts(unittest.TestCase):
todo = MagicMock() todo = MagicMock()
secrets = MagicMock() secrets = MagicMock()
with patch.object( with (
menu, "_load_accounts", return_value=[] patch.object(menu, "_load_accounts", return_value=[]),
), patch.object(menu, "secret_store_for", return_value=secrets), patch( patch.object(menu, "secret_store_for", return_value=secrets),
"script.todo.mail.tui.open_sessions", return_value=[] patch("script.todo.mail.tui.open_sessions", return_value=[]),
), patch( patch("script.todo.mail.tui.run_tui") as mock_run,
"script.todo.mail.tui.run_tui" ):
) as mock_run:
menu._open_tui(todo) menu._open_tui(todo)
_, kwargs = mock_run.call_args _, kwargs = mock_run.call_args
@ -205,13 +208,13 @@ class TestSyncNowSurfacesResync(unittest.TestCase):
pass pass
buf = io.StringIO() buf = io.StringIO()
with patch.object( with (
menu, "_load_accounts", return_value=[account] patch.object(menu, "_load_accounts", return_value=[account]),
), patch( patch(
"script.todo.mail.tui.open_sessions", "script.todo.mail.tui.open_sessions",
return_value=[FakeSession()], return_value=[FakeSession()],
), redirect_stdout( ),
buf redirect_stdout(buf),
): ):
menu._sync_now(MagicMock()) menu._sync_now(MagicMock())
@ -369,10 +372,10 @@ class TestCacheSizeAndPurge(unittest.TestCase):
ref, value ref, value
) )
with patch.object( with (
menu, "_load_accounts", return_value=[account] patch.object(menu, "_load_accounts", return_value=[account]),
), patch.object(menu, "secret_store_for", return_value=secrets), patch( patch.object(menu, "secret_store_for", return_value=secrets),
"builtins.input", side_effect=["1", "o"] patch("builtins.input", side_effect=["1", "o"]),
): ):
menu._cache_size_and_purge(MagicMock()) # ne doit pas lever menu._cache_size_and_purge(MagicMock()) # ne doit pas lever
@ -386,12 +389,10 @@ class TestCacheSizeAndPurge(unittest.TestCase):
store.root.mkdir(parents=True, exist_ok=True) store.root.mkdir(parents=True, exist_ok=True)
(store.root / "cache.db").write_bytes(b"pas une base sqlite" * 50) (store.root / "cache.db").write_bytes(b"pas une base sqlite" * 50)
with patch.object( with (
menu, "_load_accounts", return_value=[account] patch.object(menu, "_load_accounts", return_value=[account]),
), patch.object( patch.object(menu, "secret_store_for", return_value=MagicMock()),
menu, "secret_store_for", return_value=MagicMock() patch("builtins.input", side_effect=["1", "o"]),
), patch(
"builtins.input", side_effect=["1", "o"]
): ):
menu._cache_size_and_purge(MagicMock()) # ne doit pas lever menu._cache_size_and_purge(MagicMock()) # ne doit pas lever
@ -469,9 +470,10 @@ class TestEnsureKdbx(unittest.TestCase):
import script.todo.mail.menu as menu import script.todo.mail.menu as menu
kdbx_path = os.path.join(self.tmp.name, "new.kdbx") kdbx_path = os.path.join(self.tmp.name, "new.kdbx")
with mock_patch( with (
"builtins.input", side_effect=["1", kdbx_path] mock_patch("builtins.input", side_effect=["1", kdbx_path]),
), mock_patch("getpass.getpass", side_effect=["hunter2", "hunter2"]): mock_patch("getpass.getpass", side_effect=["hunter2", "hunter2"]),
):
result = menu._ensure_kdbx(self.todo) result = menu._ensure_kdbx(self.todo)
self.assertTrue(result) self.assertTrue(result)
self.assertTrue(os.path.isfile(kdbx_path)) self.assertTrue(os.path.isfile(kdbx_path))
@ -486,10 +488,11 @@ class TestEnsureKdbx(unittest.TestCase):
import script.todo.mail.menu as menu import script.todo.mail.menu as menu
kdbx_path = os.path.join(self.tmp.name, "new.kdbx") kdbx_path = os.path.join(self.tmp.name, "new.kdbx")
with mock_patch( with (
"builtins.input", side_effect=["1", kdbx_path] mock_patch("builtins.input", side_effect=["1", kdbx_path]),
), mock_patch( mock_patch(
"getpass.getpass", side_effect=["hunter2", "somethingelse"] "getpass.getpass", side_effect=["hunter2", "somethingelse"]
),
): ):
result = menu._ensure_kdbx(self.todo) result = menu._ensure_kdbx(self.todo)
self.assertFalse(result) self.assertFalse(result)
@ -537,9 +540,10 @@ class TestEnsureKdbx(unittest.TestCase):
import script.todo.mail.menu as menu import script.todo.mail.menu as menu
with mock_patch.object( with (
menu.mail_accounts, "save" mock_patch.object(menu.mail_accounts, "save") as mock_save,
) as mock_save, mock_patch("builtins.input", side_effect=["0"]): mock_patch("builtins.input", side_effect=["0"]),
):
menu._add_account(self.todo) menu._add_account(self.todo)
mock_save.assert_not_called() mock_save.assert_not_called()
# Le squelette du vrai `todo.json` donne "" (pas None) tant que # Le squelette du vrai `todo.json` donne "" (pas None) tant que
@ -609,14 +613,11 @@ class TestTodoWiring(unittest.TestCase):
from script.todo.todo import TODO from script.todo.todo import TODO
todo = TODO() todo = TODO()
with patch.object( with (
TODO, "prompt_assistant_llm" patch.object(TODO, "prompt_assistant_llm") as mock_question,
) as mock_question, patch( patch("script.todo.mail.menu.prompt_execute_mail") as mock_mail,
"script.todo.mail.menu.prompt_execute_mail" patch("click.prompt", side_effect=["1", "0"]),
) as mock_mail, patch( patch("script.todo.todo_telemetry.record"),
"click.prompt", side_effect=["1", "0"]
), patch(
"script.todo.todo_telemetry.record"
): ):
todo.prompt_assistant() todo.prompt_assistant()
@ -631,14 +632,11 @@ class TestTodoWiring(unittest.TestCase):
from script.todo.todo import TODO from script.todo.todo import TODO
todo = TODO() todo = TODO()
with patch.object( with (
TODO, "prompt_assistant_llm" patch.object(TODO, "prompt_assistant_llm") as mock_question,
) as mock_question, patch( patch("script.todo.mail.menu.prompt_execute_mail") as mock_mail,
"script.todo.mail.menu.prompt_execute_mail" patch("script.todo.todo_telemetry.record"),
) as mock_mail, patch( patch("click.prompt", side_effect=["2", "0"]),
"script.todo.todo_telemetry.record"
), patch(
"click.prompt", side_effect=["2", "0"]
): ):
todo.prompt_assistant() todo.prompt_assistant()
@ -674,10 +672,13 @@ class TestRetryPassword(unittest.TestCase):
from script.todo.mail.menu import retry_password from script.todo.mail.menu import retry_password
vues = [] vues = []
with patch( with (
"getpass.getpass", side_effect=lambda p="": vues.append(p) or "" patch(
), patch("script.todo.mail.menu.secret_store_for"), patch( "getpass.getpass",
"builtins.print" side_effect=lambda p="": vues.append(p) or "",
),
patch("script.todo.mail.menu.secret_store_for"),
patch("builtins.print"),
): ):
retry_password( retry_password(
self._todo(), self._todo(),
@ -742,11 +743,13 @@ class TestRetryPassword(unittest.TestCase):
compte = account_from_preset("essai", "a@x.ca", preset_key) compte = account_from_preset("essai", "a@x.ca", preset_key)
vues = [] vues = []
with patch("getpass.getpass", return_value=""), patch( with (
"script.todo.mail.menu.secret_store_for" patch("getpass.getpass", return_value=""),
), patch( patch("script.todo.mail.menu.secret_store_for"),
"builtins.print", patch(
side_effect=lambda *a: vues.append(" ".join(map(str, a))), "builtins.print",
side_effect=lambda *a: vues.append(" ".join(map(str, a))),
),
): ):
retry_password( retry_password(
self._todo(), self._todo(),
@ -782,9 +785,10 @@ class TestRetryPassword(unittest.TestCase):
def logout(self): def logout(self):
pass pass
with patch("getpass.getpass", return_value="bon"), patch( with (
"script.todo.mail.menu.secret_store_for" patch("getpass.getpass", return_value="bon"),
) as store: patch("script.todo.mail.menu.secret_store_for") as store,
):
store.return_value.set.side_effect = self.vault.__setitem__ store.return_value.set.side_effect = self.vault.__setitem__
ok = retry_password( ok = retry_password(
self._todo(), self._todo(),
@ -802,9 +806,10 @@ class TestRetryPassword(unittest.TestCase):
def refuse(account, password): def refuse(account, password):
raise OSError("530 refus") raise OSError("530 refus")
with patch("getpass.getpass", return_value="faux"), patch( with (
"script.todo.mail.menu.secret_store_for" patch("getpass.getpass", return_value="faux"),
) as store: patch("script.todo.mail.menu.secret_store_for") as store,
):
store.return_value.set.side_effect = self.vault.__setitem__ store.return_value.set.side_effect = self.vault.__setitem__
ok = retry_password( ok = retry_password(
self._todo(), self.account, attempts=2, connect_fn=refuse self._todo(), self.account, attempts=2, connect_fn=refuse

View file

@ -182,13 +182,18 @@ class TestKeyringBranch(unittest.TestCase):
def test_set_and_get_through_keyring(self): def test_set_and_get_through_keyring(self):
vault = {} vault = {}
with patch( with (
"script.todo.mail.secrets.keyring_is_safe", return_value=True patch(
), patch( "script.todo.mail.secrets.keyring_is_safe", return_value=True
"keyring.set_password", ),
side_effect=lambda s, u, p: vault.__setitem__((s, u), p), patch(
), patch( "keyring.set_password",
"keyring.get_password", side_effect=lambda s, u: vault.get((s, u)) side_effect=lambda s, u, p: vault.__setitem__((s, u), p),
),
patch(
"keyring.get_password",
side_effect=lambda s, u: vault.get((s, u)),
),
): ):
self.store.set("keyring:perso", "hunter2") self.store.set("keyring:perso", "hunter2")
self.assertEqual(self.store.get("keyring:perso"), "hunter2") self.assertEqual(self.store.get("keyring:perso"), "hunter2")
@ -196,9 +201,12 @@ class TestKeyringBranch(unittest.TestCase):
def test_refuses_unsafe_backend(self): def test_refuses_unsafe_backend(self):
# `keyring.get_keyring` est patché AUSSI : le message d'erreur passe par # `keyring.get_keyring` est patché AUSSI : le message d'erreur passe par
# keyring_backend_name(), qui interrogerait sinon le vrai trousseau. # keyring_backend_name(), qui interrogerait sinon le vrai trousseau.
with patch( with (
"script.todo.mail.secrets.keyring_is_safe", return_value=False patch(
), patch("keyring.get_keyring", return_value=MagicMock()): "script.todo.mail.secrets.keyring_is_safe", return_value=False
),
patch("keyring.get_keyring", return_value=MagicMock()),
):
with self.assertRaises(SecretError) as ctx: with self.assertRaises(SecretError) as ctx:
self.store.set("keyring:perso", "hunter2") self.store.set("keyring:perso", "hunter2")
# Traduit : on compare à la clé i18n elle-même, pas au mot français, # Traduit : on compare à la clé i18n elle-même, pas au mot français,

Some files were not shown because too many files have changed in this diff Show more