[UPD] invites : 15 secondes pour décider, et le fichier nomme la base

Cinq secondes ne suffisaient pas à LIRE la question. Le compte à rebours
n'existe pas pour aller vite mais pour qu'on puisse s'absenter ; trop
court, il fait l'inverse — on répond par réflexe, ou l'on subit un défaut
non lu. Et « test » ne disait rien de ce qu'on migrait : des migrations
successives portaient toutes le même nom, quand le fichier de sauvegarde
en porte déjà un parlant. Le nom est assaini — il finit dans un createdb —
et borné à 41 caractères : le pilote ajoute « _neutralize_upgrade_18 » et
PostgreSQL tronque à 63, ce qui ferait finir deux paliers sur le même nom.
Un téléchargement distant garde celui que le serveur a donné.

--- EN ---

Five seconds were not enough to READ the question. The countdown does not
exist to go fast but so you can step away; too short, it does the
opposite — you answer by reflex, or you get a default you never read. And
"test" said nothing about what was being migrated: successive migrations
all carried the same name, when the backup file already carries a telling
one. The name is sanitised — it ends up in a createdb — and capped at 41
characters: the driver appends "_neutralize_upgrade_18" and PostgreSQL
truncates at 63, which would land two steps on one name. A remote
download keeps the name the server gave.

Assisted-by: Claude Opus 5
(cherry picked from commit dad0f6b0ef4551898fe2964e177ff5c1c888b5dc)
This commit is contained in:
Mathieu Benoit 2026-08-27 06:26:26 -04:00
parent f6aa2575c3
commit 7ba3c6504c
3 changed files with 146 additions and 1 deletions

View file

@ -37,7 +37,7 @@ except Exception: # pragma: no cover - repli si i18n indisponible
ENV_ENABLED = "ERPLIBRE_AUTO_EXECUTE"
ENV_DELAY = "ERPLIBRE_AUTO_DELAY"
DEFAULT_DELAY = 5
DEFAULT_DELAY = 15
def enabled():

View file

@ -164,6 +164,39 @@ class MigrationRewind(Exception):
"""
# Le pilote suffixe le nom choisi : « _neutralize » à l'étape 1, puis
# « _upgrade_<version> » à chaque palier. PostgreSQL tronque un
# identifiant à 63 octets — deux paliers finiraient alors sur le MÊME
# nom, et le second écraserait le premier sans rien dire.
SUFFIXE_MAX = len("_neutralize_upgrade_18")
NOM_BASE_MAX = 63 - SUFFIXE_MAX
def database_name_from_file(chemin, defaut="test", limite=NOM_BASE_MAX):
"""Le nom de base que suggère le fichier de sauvegarde.
« test » ne disait rien de ce qu'on migrait : trois migrations de
suite portaient le même nom, et retrouver laquelle avait échoué
demandait de relire le journal. Le fichier, lui, porte déjà le nom du
client — c'est la seule information qu'on ait à ce moment-là.
Le nom est ASSAINI, car il finit dans un `createdb` et dans des noms
de fichiers : minuscules, et tout ce qui n'est ni lettre ni chiffre
devient un « _ ». Un nom qui commence par un chiffre reçoit un
préfixe — PostgreSQL refuse un identifiant nu qui en commence un.
"""
nom = os.path.basename(chemin or "")
if nom.lower().endswith(".zip"):
nom = nom[: -len(".zip")]
nom = re.sub(r"[^a-z0-9]+", "_", nom.lower())
nom = re.sub(r"_{2,}", "_", nom).strip("_")
if not nom:
return defaut
if nom[0].isdigit():
nom = "db_" + nom
return nom[:limite].rstrip("_") or defaut
class TodoUpgrade:
def __init__(self, todo):
self.file_path = None
@ -1370,6 +1403,13 @@ class TodoUpgrade:
self.dct_progression["migration_file"] = self.file_path
self.write_config()
# Le téléchargement distant a déjà nommé la base — le serveur sait
# mieux que le nom du fichier. Sinon, c'est le fichier qui parle.
if default_database_name == "test" and self.file_path:
default_database_name = database_name_from_file(
self.file_path, default_database_name
)
print(f"✅ {t('Open file')} {self.file_path}")
with zipfile.ZipFile(self.file_path, "r") as zip_ref:
manifest_file_1 = zip_ref.open("manifest.json")

View file

@ -224,6 +224,111 @@ class TestTheCountdownSpeaksThePromptsLanguage(EnvCase):
self.assertIn("Entrée", texte)
class TestHowLongTheCountdownLasts(EnvCase):
"""Cinq secondes ne suffisaient pas à LIRE la question.
Le compte à rebours n'existe pas pour aller vite : il existe pour
qu'on puisse s'absenter. Trop court, il fait l'inverse — on répond par
réflexe, ou l'on subit un défaut qu'on n'a pas eu le temps de lire.
"""
def test_the_default_leaves_time_to_read_and_decide(self):
self.assertGreaterEqual(auto_ask.DEFAULT_DELAY, 15)
def test_the_environment_still_wins(self):
# C'est le seul canal qui traverse un fork : une valeur posée par
# le pilote doit primer sur celle écrite ici.
os.environ[auto_ask.ENV_DELAY] = "3"
self.assertEqual(3, auto_ask.delay())
class TestTheDatabaseNameTheFileSuggests(unittest.TestCase):
"""« test » ne disait rien de ce qu'on migrait.
Trois migrations de suite portaient le même nom, et retrouver
laquelle avait échoué demandait de relire le journal. Le fichier de
sauvegarde porte déjà le nom du client — c'est la seule information
disponible au moment où la question se pose.
"""
def nom(self, chemin, **kw):
from script.todo.todo_upgrade import database_name_from_file
return database_name_from_file(chemin, **kw)
def test_the_zip_extension_goes_away(self):
self.assertEqual("chezlepro3", self.nom("image_db/chezlepro3.zip"))
def test_an_uppercase_extension_goes_away_too(self):
self.assertEqual("client", self.nom("image_db/CLIENT.ZIP"))
def test_a_real_backup_name_survives_whole(self):
self.assertEqual(
"technolibre_2026_08_26_02h41m23s",
self.nom("image_db/technolibre_2026-08-26_02h41m23s.zip"),
)
def test_a_file_without_extension_still_names_a_base(self):
# Une sauvegarde peut être posée sans « .zip » ; elle s'ouvre
# quand même comme une archive.
self.assertEqual("technolibre", self.nom("image_db/technolibre"))
def test_spaces_and_punctuation_become_underscores(self):
# Le nom finit dans un createdb et dans des noms de fichiers.
self.assertEqual(
"client_final_2026", self.nom("image_db/Client Final (2026).zip")
)
def test_it_never_starts_with_a_digit(self):
# PostgreSQL refuse un identifiant nu qui commence par un chiffre.
nom = self.nom("image_db/2026-backup.zip")
self.assertFalse(nom[0].isdigit(), nom)
def test_nothing_usable_falls_back_to_the_default(self):
for chemin in ("", None, "image_db/.zip", "image_db/---.zip"):
self.assertEqual("test", self.nom(chemin))
def test_a_long_name_leaves_room_for_the_suffixes(self):
# Le pilote ajoute « _neutralize » puis « _upgrade_<version> ».
# PostgreSQL tronque à 63 octets : sans marge, deux paliers
# finiraient sur le MÊME nom et le second écraserait le premier.
from script.todo.todo_upgrade import database_name_from_file
nom = database_name_from_file("image_db/" + "a" * 90 + ".zip")
complet = nom + "_neutralize_upgrade_18"
self.assertLessEqual(len(complet), 63, complet)
def test_it_does_not_end_on_a_separator(self):
nom = self.nom("image_db/" + "a" * 40 + "-suite.zip")
self.assertFalse(nom.endswith("_"), nom)
class TestTheMigrationUsesThatName(unittest.TestCase):
def test_the_prompt_offers_what_the_file_suggests(self):
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
pose = source.index("database_name_from_file(")
invite = source.index("Which database name do you want to work with?")
# Il faut le calculer AVANT de le proposer.
self.assertLess(pose, invite)
def test_a_remote_download_keeps_the_name_the_server_gave(self):
# Le serveur sait mieux que le nom du fichier téléchargé.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
fenetre = source[
source.index("database_name_from_file(")
- 400 : source.index("database_name_from_file(")
]
self.assertIn('default_database_name == "test"', fenetre)
class TestTheQuestionThatCannotAnswerItself(EnvCase):
"""Celle-là engage toutes les suivantes : elle ne se prend pas seule.