[FIX] qemu setup-host : demander avant de redémarrer l'hôte
Accepter d'installer les paquets QEMU redémarrait la machine sans autre question : --assume-yes couvrait le gestionnaire de paquets, et la commande y ajoutait --reboot-if-needed, qui ne demandait rien. Une seule constante servait la VM qu'on vient de créer et le poste qui la crée. --reboot-if-needed PROPOSE désormais, sur /dev/tty pour rester visible quand la sortie est un tuyau, et vaut non par défaut ; un refus laisse les paquets posés et dit quoi faire. --assume-yes-reboot est le seul consentement muet, que seul le profil invité porte. Vérifié : 7 tests, rougis par deux mutations — assume_yes rouvrant la porte, le menu hôte reprenant le drapeau. --- EN --- Accepting the QEMU package install rebooted the machine with no further question: --assume-yes covered the package manager, and the command added --reboot-if-needed, which asked nothing. One constant served both the VM just created and the workstation creating it. --reboot-if-needed now OFFERS, on /dev/tty so it stays visible when output is a pipe, and defaults to no; a refusal leaves the packages in place and says what to do. --assume-yes-reboot is the only silent consent, carried by the guest profile alone. Checked: 7 tests, turned red by two mutations — assume_yes reopening the door, the host menu taking the flag back. Assisted-by: Claude Opus 5
This commit is contained in:
parent
2435f0f6d3
commit
736ab3b676
4 changed files with 263 additions and 27 deletions
|
|
@ -563,9 +563,7 @@ def default_image_name(distro: str, code: str, arch: str, version: str) -> str:
|
||||||
# de cache fait qu'un déploiement Debian 13 et un Proxmox se
|
# de cache fait qu'un déploiement Debian 13 et un Proxmox se
|
||||||
# PARTAGENT le téléchargement (325 Mio) au lieu d'en faire deux. Sans
|
# PARTAGENT le téléchargement (325 Mio) au lieu d'en faire deux. Sans
|
||||||
# cette branche, le repli de fin nommait l'image « fedora-cloud-9 ».
|
# cette branche, le repli de fin nommait l'image « fedora-cloud-9 ».
|
||||||
return (
|
return f"debian-{PROXMOX_DEBIAN_BASE[version]}-genericcloud-{a}.qcow2"
|
||||||
f"debian-{PROXMOX_DEBIAN_BASE[version]}-genericcloud-{a}.qcow2"
|
|
||||||
)
|
|
||||||
if distro == "arch":
|
if distro == "arch":
|
||||||
return f"arch-linux-{a}-cloudimg.qcow2"
|
return f"arch-linux-{a}-cloudimg.qcow2"
|
||||||
if distro == "opensuse":
|
if distro == "opensuse":
|
||||||
|
|
@ -1072,6 +1070,7 @@ def setup_host(
|
||||||
assume_yes: bool,
|
assume_yes: bool,
|
||||||
no_install: bool,
|
no_install: bool,
|
||||||
reboot_if_needed: bool = False,
|
reboot_if_needed: bool = False,
|
||||||
|
assume_yes_reboot: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Prépare l'hôte à faire tourner des VM : paquets, démon, groupe, réseau.
|
"""Prépare l'hôte à faire tourner des VM : paquets, démon, groupe, réseau.
|
||||||
|
|
||||||
|
|
@ -1114,14 +1113,25 @@ def setup_host(
|
||||||
# monte tout seul avec les modules du nouveau noyau. Un seul reboot
|
# monte tout seul avec les modules du nouveau noyau. Un seul reboot
|
||||||
# suffit donc à rendre l'hôte utilisable, sans repasser par ici.
|
# suffit donc à rendre l'hôte utilisable, sans repasser par ici.
|
||||||
if reboot_if_needed:
|
if reboot_if_needed:
|
||||||
print(
|
# Le consentement à installer des paquets ne vaut PAS consentement
|
||||||
"\n↻ Redémarrage programmé (dans quelques secondes) : c'est la"
|
# à redémarrer : assume_yes couvre pacman, jamais la machine de
|
||||||
" SEULE façon de retrouver les modules du noyau.\n"
|
# celui qui l'a tapé. Une provision sans personne devant l'écran
|
||||||
" Au retour, le réseau « default » démarrera seul"
|
# passe --assume-yes-reboot, qui dit explicitement l'autre chose.
|
||||||
" (autostart déjà actif)."
|
if assume_yes_reboot or prompt_yes_no(
|
||||||
|
"\n↻ Redémarrer MAINTENANT ? C'est la seule façon de"
|
||||||
|
" retrouver les modules du noyau. Au retour, le réseau"
|
||||||
|
" « default » démarrera seul (autostart déjà actif).",
|
||||||
|
default=False,
|
||||||
|
):
|
||||||
|
print("\n↻ Redémarrage programmé (dans quelques secondes).")
|
||||||
|
schedule_reboot(runner)
|
||||||
|
return
|
||||||
|
sys.exit(
|
||||||
|
"Erreur : l'hôte n'est pas prêt, redémarrage refusé.\n"
|
||||||
|
f" {stale}\n"
|
||||||
|
" Redémarrez quand vous le voudrez, puis relancez"
|
||||||
|
" --setup-host."
|
||||||
)
|
)
|
||||||
schedule_reboot(runner)
|
|
||||||
return
|
|
||||||
sys.exit(f"Erreur : l'hôte n'est pas prêt.\n {stale}")
|
sys.exit(f"Erreur : l'hôte n'est pas prêt.\n {stale}")
|
||||||
|
|
||||||
if not (ok and active):
|
if not (ok and active):
|
||||||
|
|
@ -2468,18 +2478,23 @@ def _ip_taken(ip: str) -> bool:
|
||||||
except (OSError, subprocess.SubprocessError):
|
except (OSError, subprocess.SubprocessError):
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
if subprocess.run(
|
if (
|
||||||
["ping", "-c", "1", "-W", "1", ip],
|
subprocess.run(
|
||||||
capture_output=True,
|
["ping", "-c", "1", "-W", "1", ip],
|
||||||
timeout=5,
|
capture_output=True,
|
||||||
).returncode == 0:
|
timeout=5,
|
||||||
|
).returncode
|
||||||
|
== 0
|
||||||
|
):
|
||||||
return True
|
return True
|
||||||
except (OSError, subprocess.SubprocessError):
|
except (OSError, subprocess.SubprocessError):
|
||||||
pass
|
pass
|
||||||
return _ip_reachable(ip, port=22, timeout=1.5)
|
return _ip_reachable(ip, port=22, timeout=1.5)
|
||||||
|
|
||||||
|
|
||||||
def static_net_plan(net: str | None, use_sudo: bool, name: str) -> dict[str, str] | None:
|
def static_net_plan(
|
||||||
|
net: str | None, use_sudo: bool, name: str
|
||||||
|
) -> dict[str, str] | None:
|
||||||
"""Adresse fixe libre pour une VM installée par debian-installer.
|
"""Adresse fixe libre pour une VM installée par debian-installer.
|
||||||
|
|
||||||
L'initrd s390x ne contient QUE « netcfg-static » : le journal de d-i
|
L'initrd s390x ne contient QUE « netcfg-static » : le journal de d-i
|
||||||
|
|
@ -3019,9 +3034,11 @@ def virt_install(
|
||||||
# VM que personne ne regarde, et il ne reste RIEN à lire ensuite —
|
# VM que personne ne regarde, et il ne reste RIEN à lire ensuite —
|
||||||
# exactement « l'installation a échoué, pas de sortie pertinente ».
|
# exactement « l'installation a échoué, pas de sortie pertinente ».
|
||||||
# Le fichier, lui, survit à l'arrêt du domaine.
|
# Le fichier, lui, survit à l'arrêt du domaine.
|
||||||
f"pty,target_type={console_target},log.file={console_log}"
|
(
|
||||||
if installer
|
f"pty,target_type={console_target},log.file={console_log}"
|
||||||
else f"pty,target_type={console_target}",
|
if installer
|
||||||
|
else f"pty,target_type={console_target}"
|
||||||
|
),
|
||||||
# Canal virtio de l'agent invité (org.qemu.guest_agent.0) : permet à
|
# Canal virtio de l'agent invité (org.qemu.guest_agent.0) : permet à
|
||||||
# virsh de piloter la VM SANS réseau (ex. étendre le FS invité après
|
# virsh de piloter la VM SANS réseau (ex. étendre le FS invité après
|
||||||
# un redimensionnement de disque). Inoffensif si l'agent est absent.
|
# un redimensionnement de disque). Inoffensif si l'agent est absent.
|
||||||
|
|
@ -3495,8 +3512,15 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
g_run.add_argument(
|
g_run.add_argument(
|
||||||
"--reboot-if-needed",
|
"--reboot-if-needed",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Avec --setup-host : redémarre si le noyau a été mis à jour "
|
help="Avec --setup-host : PROPOSE un redémarrage si le noyau a été "
|
||||||
"depuis le démarrage (sinon libvirt ne peut pas créer virbr0).",
|
"mis à jour depuis le démarrage (sinon libvirt ne peut pas créer "
|
||||||
|
"virbr0). La question est posée sur /dev/tty et vaut non par défaut.",
|
||||||
|
)
|
||||||
|
g_run.add_argument(
|
||||||
|
"--assume-yes-reboot",
|
||||||
|
action="store_true",
|
||||||
|
help="Redémarre sans poser la question. Réservé à une provision "
|
||||||
|
"sans personne devant l'écran ; --assume-yes ne l'implique pas.",
|
||||||
)
|
)
|
||||||
g_run.add_argument(
|
g_run.add_argument(
|
||||||
"--list-images",
|
"--list-images",
|
||||||
|
|
@ -3555,6 +3579,7 @@ def main() -> None:
|
||||||
args.assume_yes,
|
args.assume_yes,
|
||||||
args.no_install_deps,
|
args.no_install_deps,
|
||||||
args.reboot_if_needed,
|
args.reboot_if_needed,
|
||||||
|
args.assume_yes_reboot,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -3707,11 +3732,15 @@ def main() -> None:
|
||||||
network_name(args.network), not args.dry_run, args.name
|
network_name(args.network), not args.dry_run, args.name
|
||||||
)
|
)
|
||||||
if static:
|
if static:
|
||||||
print(f" Adresse fixe retenue : {static['ip']}"
|
print(
|
||||||
f" (passerelle {static['gateway']})")
|
f" Adresse fixe retenue : {static['ip']}"
|
||||||
|
f" (passerelle {static['gateway']})"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print(" ⚠ Aucune adresse fixe déterminée : netcfg-static posera"
|
print(
|
||||||
" la question à l'écran et l'installation s'arrêtera.")
|
" ⚠ Aucune adresse fixe déterminée : netcfg-static posera"
|
||||||
|
" la question à l'écran et l'installation s'arrêtera."
|
||||||
|
)
|
||||||
build_installer_initrd(
|
build_installer_initrd(
|
||||||
build_preseed(args, pw_hash, ssh_keys, static),
|
build_preseed(args, pw_hash, ssh_keys, static),
|
||||||
initrd_src,
|
initrd_src,
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,13 @@ class QemuInstallMixin:
|
||||||
# Sans le groupe, virt-install retombe sur qemu:///session où « default »
|
# Sans le groupe, virt-install retombe sur qemu:///session où « default »
|
||||||
# n'existe pas : la VM échoue alors que tous les paquets sont installés.
|
# n'existe pas : la VM échoue alors que tous les paquets sont installés.
|
||||||
# L'ancien one-liner finissait par « || true » et masquait ses erreurs.
|
# L'ancien one-liner finissait par « || true » et masquait ses erreurs.
|
||||||
|
# Le redémarrage est consenti ICI et nulle part ailleurs : la VM vient
|
||||||
|
# d'être créée, personne ne la regarde, et le noyau fraîchement installé
|
||||||
|
# doit être chargé avant que libvirt puisse monter virbr0. Sur un poste de
|
||||||
|
# travail, la question se pose — voir _qemu_ensure_tools.
|
||||||
_QEMU_QEMU_PKGS = (
|
_QEMU_QEMU_PKGS = (
|
||||||
"./script/qemu/deploy_qemu.py --setup-host --assume-yes"
|
"./script/qemu/deploy_qemu.py --setup-host --assume-yes"
|
||||||
" --reboot-if-needed"
|
" --reboot-if-needed --assume-yes-reboot"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _qemu_ask_prod(self):
|
def _qemu_ask_prod(self):
|
||||||
|
|
|
||||||
|
|
@ -253,7 +253,14 @@ class QemuMenuMixin:
|
||||||
input(t("Install the QEMU/libvirt tools now? (Y/n): "))
|
input(t("Install the QEMU/libvirt tools now? (Y/n): "))
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
cmd = f"sudo {self._QEMU_QEMU_PKGS}"
|
# Sans --assume-yes-reboot : accepter d'installer des paquets n'est
|
||||||
|
# pas accepter de perdre ce qui tourne sur la machine. Quand le noyau
|
||||||
|
# a été remplacé depuis le démarrage, deploy_qemu.py pose la question
|
||||||
|
# sur /dev/tty, et un refus laisse l'hôte avec ses paquets posés.
|
||||||
|
cmd = (
|
||||||
|
"sudo ./script/qemu/deploy_qemu.py --setup-host --assume-yes"
|
||||||
|
" --reboot-if-needed"
|
||||||
|
)
|
||||||
print(f"{t('Will execute:')} {cmd}")
|
print(f"{t('Will execute:')} {cmd}")
|
||||||
self.execute.exec_command_live(cmd, source_erplibre=False)
|
self.execute.exec_command_live(cmd, source_erplibre=False)
|
||||||
if shutil.which("virsh"):
|
if shutil.which("virsh"):
|
||||||
|
|
|
||||||
196
test/test_qemu_setup_host_reboot.py
Normal file
196
test/test_qemu_setup_host_reboot.py
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
"""Le redémarrage de l'hôte se demande, il ne se déduit pas.
|
||||||
|
|
||||||
|
« --setup-host » installe des paquets, et sur une distribution à noyau
|
||||||
|
roulant il peut avoir besoin d'un redémarrage pour que libvirt monte virbr0.
|
||||||
|
Ces deux actes n'ont pas le même prix : le premier s'annule, le second emporte
|
||||||
|
tout ce qui tourne sur la machine.
|
||||||
|
|
||||||
|
Ce que ces tests gardent :
|
||||||
|
|
||||||
|
- « --assume-yes » couvre le gestionnaire de paquets, JAMAIS le redémarrage.
|
||||||
|
- « --reboot-if-needed » PROPOSE ; un refus laisse la machine debout et sort
|
||||||
|
en erreur, sans jamais programmer le redémarrage.
|
||||||
|
- « --assume-yes-reboot » est le seul consentement qui se passe de question,
|
||||||
|
et il est réservé à la provision d'une VM que personne ne regarde.
|
||||||
|
- La commande du menu hôte ne le porte pas ; celle du profil invité le porte.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.argv = ["todo.py"]
|
||||||
|
|
||||||
|
RACINE = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _deploy_qemu():
|
||||||
|
"""deploy_qemu.py chargé comme module, comme le fait todo.py."""
|
||||||
|
path = RACINE / "script/qemu/deploy_qemu.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("deploy_qemu", path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
DQ = _deploy_qemu()
|
||||||
|
|
||||||
|
# L'état exact qui déclenche la question : le noyau a été remplacé depuis le
|
||||||
|
# démarrage, donc le réseau « default » ne peut pas monter.
|
||||||
|
NOYAU_PERIME = "Le noyau en cours n'a plus ses modules."
|
||||||
|
|
||||||
|
|
||||||
|
class SetupHostReboot(unittest.TestCase):
|
||||||
|
def _lancer(self, reponse=False, **kwargs):
|
||||||
|
"""setup_host sur un hôte au noyau périmé et au réseau inactif.
|
||||||
|
|
||||||
|
Rend le triplet (redémarrages, questions, SystemExit ou None). Tout
|
||||||
|
ce qui touche au système est neutralisé : seul l'enchaînement des
|
||||||
|
décisions est sous test. « reponse » est ce que l'utilisateur répond
|
||||||
|
si la question lui est posée.
|
||||||
|
"""
|
||||||
|
runner = mock.MagicMock()
|
||||||
|
runner.dry_run = False
|
||||||
|
runner.use_sudo = False
|
||||||
|
reboots = []
|
||||||
|
questions = []
|
||||||
|
|
||||||
|
def question(texte, default=True):
|
||||||
|
questions.append(texte)
|
||||||
|
return reponse
|
||||||
|
|
||||||
|
with mock.patch.object(DQ, "ensure_tools"), mock.patch.object(
|
||||||
|
DQ, "ensure_libvirt_service"
|
||||||
|
), mock.patch.object(
|
||||||
|
DQ, "ensure_libvirt_group", return_value=True
|
||||||
|
), mock.patch.object(
|
||||||
|
DQ, "ensure_ssh_key"
|
||||||
|
), mock.patch.object(
|
||||||
|
DQ, "ensure_network"
|
||||||
|
), mock.patch.object(
|
||||||
|
DQ, "kernel_modules_stale", return_value=NOYAU_PERIME
|
||||||
|
), mock.patch.object(
|
||||||
|
DQ, "libvirt_ready", return_value=True
|
||||||
|
), mock.patch.object(
|
||||||
|
DQ, "network_state", return_value=(False, True)
|
||||||
|
), mock.patch.object(
|
||||||
|
DQ, "schedule_reboot", side_effect=lambda r: reboots.append(r)
|
||||||
|
), mock.patch.object(
|
||||||
|
DQ, "prompt_yes_no", side_effect=question
|
||||||
|
):
|
||||||
|
with redirect_stdout(io.StringIO()):
|
||||||
|
try:
|
||||||
|
DQ.setup_host(runner, **kwargs)
|
||||||
|
sortie = None
|
||||||
|
except SystemExit as exc:
|
||||||
|
sortie = exc
|
||||||
|
return reboots, questions, sortie
|
||||||
|
|
||||||
|
def test_assume_yes_alone_never_reboots(self):
|
||||||
|
"""La régression même : accepter l'installation des paquets ne
|
||||||
|
redémarrait pas la machine, mais l'appelant, lui, ajoutait le drapeau
|
||||||
|
qui le faisait. Sans le drapeau, rien ne redémarre et rien n'est
|
||||||
|
demandé."""
|
||||||
|
reboots, questions, sortie = self._lancer(
|
||||||
|
assume_yes=True, no_install=False, reboot_if_needed=False
|
||||||
|
)
|
||||||
|
self.assertEqual(reboots, [])
|
||||||
|
self.assertEqual(questions, [])
|
||||||
|
self.assertIsInstance(sortie, SystemExit)
|
||||||
|
|
||||||
|
def test_reboot_if_needed_asks_and_a_refusal_stops(self):
|
||||||
|
reboots, questions, sortie = self._lancer(
|
||||||
|
assume_yes=True,
|
||||||
|
no_install=False,
|
||||||
|
reboot_if_needed=True,
|
||||||
|
reponse=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(questions), 1, questions)
|
||||||
|
self.assertEqual(reboots, [])
|
||||||
|
self.assertIsInstance(sortie, SystemExit)
|
||||||
|
# Le refus doit se lire dans le message : un « pas prêt » sec laisse
|
||||||
|
# croire à une panne alors que la machine a obéi.
|
||||||
|
self.assertIn("refusé", str(sortie))
|
||||||
|
|
||||||
|
def test_reboot_if_needed_reboots_when_accepted(self):
|
||||||
|
reboots, questions, sortie = self._lancer(
|
||||||
|
assume_yes=True,
|
||||||
|
no_install=False,
|
||||||
|
reboot_if_needed=True,
|
||||||
|
reponse=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(questions), 1)
|
||||||
|
self.assertEqual(len(reboots), 1)
|
||||||
|
self.assertIsNone(sortie)
|
||||||
|
|
||||||
|
def test_assume_yes_reboot_skips_the_question(self):
|
||||||
|
"""La provision d'une VM neuve n'a personne pour répondre : sans ce
|
||||||
|
drapeau, la question tomberait sur un EOF et la VM resterait sur un
|
||||||
|
noyau sans modules."""
|
||||||
|
reboots, questions, sortie = self._lancer(
|
||||||
|
assume_yes=True,
|
||||||
|
no_install=False,
|
||||||
|
reboot_if_needed=True,
|
||||||
|
assume_yes_reboot=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(questions, [])
|
||||||
|
self.assertEqual(len(reboots), 1)
|
||||||
|
self.assertIsNone(sortie)
|
||||||
|
|
||||||
|
|
||||||
|
class ConsentInTheCallers(unittest.TestCase):
|
||||||
|
"""Le drapeau se lit dans les commandes que TODO fabrique."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _commande_hote():
|
||||||
|
"""La chaîne assignée à « cmd » dans _qemu_ensure_tools.
|
||||||
|
|
||||||
|
Lue par l'arbre syntaxique et non par le texte : un commentaire qui
|
||||||
|
NOMME le drapeau pour expliquer son absence est légitime, et une
|
||||||
|
recherche textuelle le prendrait pour la commande.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
|
||||||
|
source = (RACINE / "script/todo/qemu_menu.py").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
for node in ast.walk(ast.parse(source)):
|
||||||
|
if (
|
||||||
|
isinstance(node, ast.FunctionDef)
|
||||||
|
and node.name == "_qemu_ensure_tools"
|
||||||
|
):
|
||||||
|
for stmt in ast.walk(node):
|
||||||
|
if (
|
||||||
|
isinstance(stmt, ast.Assign)
|
||||||
|
and getattr(stmt.targets[0], "id", "") == "cmd"
|
||||||
|
):
|
||||||
|
return ast.literal_eval(stmt.value)
|
||||||
|
raise AssertionError("cmd introuvable dans _qemu_ensure_tools")
|
||||||
|
|
||||||
|
def test_the_host_menu_never_assumes_the_reboot(self):
|
||||||
|
cmd = self._commande_hote()
|
||||||
|
self.assertIn("--setup-host", cmd)
|
||||||
|
self.assertIn("--reboot-if-needed", cmd)
|
||||||
|
self.assertNotIn("--assume-yes-reboot", cmd)
|
||||||
|
|
||||||
|
def test_the_guest_profile_carries_the_explicit_consent(self):
|
||||||
|
source = (RACINE / "script/todo/qemu_install.py").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
self.assertIn("--assume-yes-reboot", source)
|
||||||
|
|
||||||
|
def test_the_flag_exists_in_the_parser(self):
|
||||||
|
parser = DQ.build_parser()
|
||||||
|
rendu = parser.format_help()
|
||||||
|
self.assertIn("--assume-yes-reboot", rendu)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Reference in a new issue