[ADD] script todo : gérer les plugins Claude Code et leur liste ERPLibre
Rien ne posait un plugin depuis TODO ; la CLI seule le faisait, hors du menu. L'installation passe par « -y » : la sortie de TODO est un tuyau, pas un terminal, et la CLI refuse sans lui toute installation qui exécute une commande déclarée par un marketplace. La liste préférée s'affiche donc AVANT la confirmation, seule occasion de la lire ; ses quatre plugins travaillent sur le poste, sans service tiers ni compte. La recherche lit les manifestes sur le disque, donc hors ligne. Vérifié : 8 tests neufs, dont la frontière de mot qui sépare deux noms dont l'un contient l'autre — une recherche naïve en rate trois. --- EN --- Nothing installed a plugin from TODO; the CLI alone did, outside the menu. Installing goes through « -y »: TODO's output is a pipe, not a terminal, and without it the CLI refuses any install that runs a command declared by a marketplace. The preferred list is therefore shown BEFORE the confirmation, the only chance to read it; its four plugins run on the workstation, with no third party and no account. Search reads the manifests from disk, hence offline. Checked: 8 new tests, among them the word boundary separating two names where one contains the other — a naive search misses three of them. Assisted-by: Claude Opus 5
This commit is contained in:
parent
c32e7d87c9
commit
6847891c1b
3 changed files with 477 additions and 0 deletions
|
|
@ -3027,6 +3027,11 @@ class TODO(
|
|||
)
|
||||
},
|
||||
{"prompt_description": t("Show the context given to Claude")},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Claude Code plugins - marketplaces and ERPLibre list"
|
||||
)
|
||||
},
|
||||
]
|
||||
help_info = self.fill_help_info(choices)
|
||||
|
||||
|
|
@ -3043,6 +3048,8 @@ class TODO(
|
|||
self.prompt_execute_rtk()
|
||||
elif status == "4":
|
||||
self._show_claude_context()
|
||||
elif status == "5":
|
||||
self.prompt_execute_claude_plugins()
|
||||
else:
|
||||
print(t("Command not found !"))
|
||||
|
||||
|
|
@ -3334,6 +3341,231 @@ class TODO(
|
|||
except Exception as e:
|
||||
print(f"{t('Error adding automation: ')}{e}")
|
||||
|
||||
# Les plugins qu'ERPLibre pose par défaut, chacun avec la clé qui dit à
|
||||
# quoi il sert. Tous viennent du marketplace officiel et travaillent sur
|
||||
# le poste : aucun n'appelle un service tiers ni ne réclame de compte.
|
||||
_CLAUDE_PREFERRED_PLUGINS = (
|
||||
("superpowers", "brainstorming, subagent-driven development, TDD"),
|
||||
("pyright-lsp", "Python type checking and code intelligence"),
|
||||
("claude-security", "vulnerability scan run entirely in session"),
|
||||
(
|
||||
"skill-creator",
|
||||
"write, improve and evaluate the repository skills",
|
||||
),
|
||||
)
|
||||
_CLAUDE_MARKETPLACES_DIR = "~/.claude/plugins/marketplaces"
|
||||
|
||||
def prompt_execute_claude_plugins(self):
|
||||
print(f"🤖 {t('Manage Claude Code plugins and marketplaces!')}")
|
||||
choices = [
|
||||
{"section": t("Inventory")},
|
||||
{"prompt_description": t("List installed plugins")},
|
||||
{"prompt_description": t("List configured marketplaces")},
|
||||
{"prompt_description": t("Search a plugin in the marketplaces")},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Show a plugin detail and its token cost"
|
||||
)
|
||||
},
|
||||
{"section": t("Install")},
|
||||
{"prompt_description": t("Install the ERPLibre preferred list")},
|
||||
{"prompt_description": t("Install a plugin by name")},
|
||||
{"prompt_description": t("Add a marketplace")},
|
||||
{"section": t("Maintenance")},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Update the marketplaces and the plugins"
|
||||
)
|
||||
},
|
||||
{"prompt_description": t("Uninstall a plugin")},
|
||||
]
|
||||
help_info = self.fill_help_info(choices)
|
||||
|
||||
while True:
|
||||
status = click.prompt(help_info)
|
||||
print()
|
||||
if status == "0":
|
||||
return False
|
||||
elif status == "1":
|
||||
self._claude_plugin_exec("list")
|
||||
elif status == "2":
|
||||
self._claude_plugin_exec("marketplace list")
|
||||
elif status == "3":
|
||||
self._claude_plugin_search()
|
||||
elif status == "4":
|
||||
self._claude_plugin_details()
|
||||
elif status == "5":
|
||||
self._claude_install_preferred_plugins()
|
||||
elif status == "6":
|
||||
self._claude_plugin_install_by_name()
|
||||
elif status == "7":
|
||||
self._claude_marketplace_add()
|
||||
elif status == "8":
|
||||
self._claude_plugin_update()
|
||||
elif status == "9":
|
||||
self._claude_plugin_uninstall()
|
||||
else:
|
||||
print(t("Command not found !"))
|
||||
|
||||
def _claude_plugin_exec(self, args, quiet=False, capture=False):
|
||||
"""Lance « claude plugin <args> », ou signale que claude est absent.
|
||||
|
||||
Rend le code de sortie, ou le couple (code, lignes) quand capture est
|
||||
vrai. Le code 1 sans sortie signale l'absence de l'exécutable : rien
|
||||
n'a tourné, et l'appelant ne doit pas conclure à un échec de la
|
||||
commande elle-même.
|
||||
"""
|
||||
claude = shutil.which("claude")
|
||||
if claude is None:
|
||||
print(t("The claude command is not in the PATH."))
|
||||
return (1, []) if capture else 1
|
||||
return self.execute.exec_command_live(
|
||||
f"{shlex.quote(claude)} plugin {args}",
|
||||
source_erplibre=False,
|
||||
quiet=quiet,
|
||||
return_status_and_output=capture,
|
||||
)
|
||||
|
||||
def _claude_plugin_is_installed(self, name):
|
||||
"""Le plugin est-il déjà posé ?
|
||||
|
||||
La liste est lue telle que la CLI l'écrit, et le nom y est cherché
|
||||
comme un mot entier : « code-review » ne doit pas se reconnaître dans
|
||||
« pr-review-toolkit ». Un doute rend faux, et l'installation qui suit
|
||||
est de toute façon idempotente.
|
||||
"""
|
||||
result = self._claude_plugin_exec("list", quiet=True, capture=True)
|
||||
if not isinstance(result, tuple) or result[0] != 0:
|
||||
return False
|
||||
motif = re.compile(rf"(?<![\w-]){re.escape(name)}(?![\w-])")
|
||||
return any(motif.search(ligne) for ligne in result[1])
|
||||
|
||||
def _claude_install_preferred_plugins(self):
|
||||
"""Pose la liste préférée d'ERPLibre après confirmation.
|
||||
|
||||
L'installation passe par « -y » : la sortie de TODO est un tuyau, pas
|
||||
un terminal, et la CLI refuse sans lui toute installation qui exécute
|
||||
une commande déclarée par un marketplace. La liste est donc affichée
|
||||
AVANT la confirmation, qui est la seule occasion de la lire.
|
||||
"""
|
||||
print(t("ERPLibre preferred plugins:"))
|
||||
print("-" * 62)
|
||||
for nom, raison in self._CLAUDE_PREFERRED_PLUGINS:
|
||||
print(f" {nom:<18} {t(raison)}")
|
||||
print("-" * 62)
|
||||
if not self._is_yes(input(t("Install these plugins? (y/Y): "))):
|
||||
print(t("Nothing to do."))
|
||||
return
|
||||
for nom, _ in self._CLAUDE_PREFERRED_PLUGINS:
|
||||
print(f"\n📦 {nom}")
|
||||
if self._claude_plugin_is_installed(nom):
|
||||
print(t("Already installed, skipped."))
|
||||
continue
|
||||
self._claude_plugin_exec(f"install {shlex.quote(nom)} -y")
|
||||
print(f"\n{t('A restart of Claude Code applies the change.')}")
|
||||
|
||||
def _claude_plugin_install_by_name(self):
|
||||
nom = input(t("Plugin name: ")).strip()
|
||||
if not nom:
|
||||
print(t("Nothing to do."))
|
||||
return
|
||||
self._claude_plugin_exec(f"install {shlex.quote(nom)} -y")
|
||||
print(t("A restart of Claude Code applies the change."))
|
||||
|
||||
def _claude_plugin_details(self):
|
||||
nom = input(t("Plugin name: ")).strip()
|
||||
if not nom:
|
||||
print(t("Nothing to do."))
|
||||
return
|
||||
self._claude_plugin_exec(f"details {shlex.quote(nom)}")
|
||||
|
||||
def _claude_plugin_uninstall(self):
|
||||
nom = input(t("Plugin name: ")).strip()
|
||||
if not nom:
|
||||
print(t("Nothing to do."))
|
||||
return
|
||||
if not self._is_yes(input(t("Uninstall this plugin? (y/Y): "))):
|
||||
print(t("Nothing to do."))
|
||||
return
|
||||
self._claude_plugin_exec(f"uninstall {shlex.quote(nom)}")
|
||||
print(t("A restart of Claude Code applies the change."))
|
||||
|
||||
def _claude_marketplace_add(self):
|
||||
source = input(
|
||||
t("Marketplace source (URL, path or owner/repo): ")
|
||||
).strip()
|
||||
if not source:
|
||||
print(t("Nothing to do."))
|
||||
return
|
||||
self._claude_plugin_exec(f"marketplace add {shlex.quote(source)}")
|
||||
|
||||
def _claude_plugin_update(self):
|
||||
"""Met à jour les marketplaces, puis les plugins déjà posés.
|
||||
|
||||
Les catalogues passent d'abord : « plugin update » installe la version
|
||||
que le catalogue local annonce, et sur un catalogue périmé il ne fait
|
||||
rien tout en sortant en 0.
|
||||
"""
|
||||
self._claude_plugin_exec("marketplace update")
|
||||
for nom, _ in self._CLAUDE_PREFERRED_PLUGINS:
|
||||
if self._claude_plugin_is_installed(nom):
|
||||
self._claude_plugin_exec(f"update {shlex.quote(nom)}")
|
||||
print(t("A restart of Claude Code applies the change."))
|
||||
|
||||
def _claude_marketplace_catalog(self):
|
||||
"""Les plugins des marketplaces posés, en triplets (nom, source, mot).
|
||||
|
||||
Le catalogue est lu sur le disque plutôt que par la CLI : la recherche
|
||||
reste possible hors ligne, et un marketplace dont le manifeste est
|
||||
illisible est sauté sans faire échouer les autres.
|
||||
"""
|
||||
racine = os.path.expanduser(self._CLAUDE_MARKETPLACES_DIR)
|
||||
catalogue = []
|
||||
if not os.path.isdir(racine):
|
||||
return catalogue
|
||||
for nom_marche in sorted(os.listdir(racine)):
|
||||
manifeste = os.path.join(
|
||||
racine, nom_marche, ".claude-plugin", "marketplace.json"
|
||||
)
|
||||
try:
|
||||
with open(manifeste, encoding="utf-8") as fh:
|
||||
contenu = json.load(fh)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
for plugin in contenu.get("plugins", []):
|
||||
nom = plugin.get("name", "")
|
||||
if nom:
|
||||
catalogue.append(
|
||||
(nom, nom_marche, plugin.get("description", ""))
|
||||
)
|
||||
return catalogue
|
||||
|
||||
def _claude_plugin_search(self):
|
||||
"""Cherche un mot-clé dans le nom et la description des plugins."""
|
||||
catalogue = self._claude_marketplace_catalog()
|
||||
if not catalogue:
|
||||
print(t("No marketplace is configured."))
|
||||
return
|
||||
mot = input(t("Keyword to search: ")).strip().lower()
|
||||
if not mot:
|
||||
print(t("Nothing to do."))
|
||||
return
|
||||
trouves = [
|
||||
(nom, marche, desc)
|
||||
for nom, marche, desc in catalogue
|
||||
if mot in nom.lower() or mot in desc.lower()
|
||||
]
|
||||
if not trouves:
|
||||
print(t("No plugin matches this keyword."))
|
||||
return
|
||||
print("-" * 78)
|
||||
for nom, marche, desc in trouves:
|
||||
print(f" {nom}@{marche}")
|
||||
if desc:
|
||||
print(f" {desc[:70]}")
|
||||
print("-" * 78)
|
||||
print(f"{t('Total:')} {len(trouves)}")
|
||||
|
||||
def prompt_execute_doc(self):
|
||||
print(f"🤖 {t('Looking for documentation?')}")
|
||||
choices = [
|
||||
|
|
|
|||
|
|
@ -1196,6 +1196,123 @@ TRANSLATIONS = {
|
|||
"fr": "Déployer les commandes Claude Code!",
|
||||
"en": "Deploy Claude Code commands!",
|
||||
},
|
||||
# Claude Code plugins
|
||||
"Claude Code plugins - marketplaces and ERPLibre list": {
|
||||
"fr": "Plugins Claude Code - marketplaces et liste ERPLibre",
|
||||
"en": "Claude Code plugins - marketplaces and ERPLibre list",
|
||||
},
|
||||
"Manage Claude Code plugins and marketplaces!": {
|
||||
"fr": "Gérer les plugins et les marketplaces de Claude Code!",
|
||||
"en": "Manage Claude Code plugins and marketplaces!",
|
||||
},
|
||||
"Inventory": {
|
||||
"fr": "Inventaire",
|
||||
"en": "Inventory",
|
||||
},
|
||||
"List installed plugins": {
|
||||
"fr": "Lister les plugins installés",
|
||||
"en": "List installed plugins",
|
||||
},
|
||||
"List configured marketplaces": {
|
||||
"fr": "Lister les marketplaces configurés",
|
||||
"en": "List configured marketplaces",
|
||||
},
|
||||
"Search a plugin in the marketplaces": {
|
||||
"fr": "Chercher un plugin dans les marketplaces",
|
||||
"en": "Search a plugin in the marketplaces",
|
||||
},
|
||||
"Show a plugin detail and its token cost": {
|
||||
"fr": "Afficher le détail d'un plugin et son coût en tokens",
|
||||
"en": "Show a plugin detail and its token cost",
|
||||
},
|
||||
"Install": {
|
||||
"fr": "Installer",
|
||||
"en": "Install",
|
||||
},
|
||||
"Install the ERPLibre preferred list": {
|
||||
"fr": "Installer la liste préférée d'ERPLibre",
|
||||
"en": "Install the ERPLibre preferred list",
|
||||
},
|
||||
"Install a plugin by name": {
|
||||
"fr": "Installer un plugin par son nom",
|
||||
"en": "Install a plugin by name",
|
||||
},
|
||||
"Add a marketplace": {
|
||||
"fr": "Ajouter un marketplace",
|
||||
"en": "Add a marketplace",
|
||||
},
|
||||
"Maintenance": {
|
||||
"fr": "Maintenance",
|
||||
"en": "Maintenance",
|
||||
},
|
||||
"Update the marketplaces and the plugins": {
|
||||
"fr": "Mettre à jour les marketplaces et les plugins",
|
||||
"en": "Update the marketplaces and the plugins",
|
||||
},
|
||||
"Uninstall a plugin": {
|
||||
"fr": "Désinstaller un plugin",
|
||||
"en": "Uninstall a plugin",
|
||||
},
|
||||
"The claude command is not in the PATH.": {
|
||||
"fr": "La commande claude n'est pas dans le PATH.",
|
||||
"en": "The claude command is not in the PATH.",
|
||||
},
|
||||
"ERPLibre preferred plugins:": {
|
||||
"fr": "Plugins préférés d'ERPLibre :",
|
||||
"en": "ERPLibre preferred plugins:",
|
||||
},
|
||||
"brainstorming, subagent-driven development, TDD": {
|
||||
"fr": ("brainstorming, développement piloté par sous-agents, TDD"),
|
||||
"en": "brainstorming, subagent-driven development, TDD",
|
||||
},
|
||||
"Python type checking and code intelligence": {
|
||||
"fr": "typage Python et intelligence de code",
|
||||
"en": "Python type checking and code intelligence",
|
||||
},
|
||||
"vulnerability scan run entirely in session": {
|
||||
"fr": "analyse de vulnérabilités entièrement en session",
|
||||
"en": "vulnerability scan run entirely in session",
|
||||
},
|
||||
"write, improve and evaluate the repository skills": {
|
||||
"fr": "écrire, améliorer et évaluer les skills du dépôt",
|
||||
"en": "write, improve and evaluate the repository skills",
|
||||
},
|
||||
"Install these plugins? (y/Y): ": {
|
||||
"fr": "Installer ces plugins ? (o/O) : ",
|
||||
"en": "Install these plugins? (y/Y): ",
|
||||
},
|
||||
"Already installed, skipped.": {
|
||||
"fr": "Déjà installé, ignoré.",
|
||||
"en": "Already installed, skipped.",
|
||||
},
|
||||
"Plugin name: ": {
|
||||
"fr": "Nom du plugin : ",
|
||||
"en": "Plugin name: ",
|
||||
},
|
||||
"Marketplace source (URL, path or owner/repo): ": {
|
||||
"fr": "Source du marketplace (URL, chemin ou propriétaire/dépôt) : ",
|
||||
"en": "Marketplace source (URL, path or owner/repo): ",
|
||||
},
|
||||
"Keyword to search: ": {
|
||||
"fr": "Mot-clé à chercher : ",
|
||||
"en": "Keyword to search: ",
|
||||
},
|
||||
"No marketplace is configured.": {
|
||||
"fr": "Aucun marketplace n'est configuré.",
|
||||
"en": "No marketplace is configured.",
|
||||
},
|
||||
"No plugin matches this keyword.": {
|
||||
"fr": "Aucun plugin ne correspond à ce mot-clé.",
|
||||
"en": "No plugin matches this keyword.",
|
||||
},
|
||||
"Uninstall this plugin? (y/Y): ": {
|
||||
"fr": "Désinstaller ce plugin ? (o/O) : ",
|
||||
"en": "Uninstall this plugin? (y/Y): ",
|
||||
},
|
||||
"A restart of Claude Code applies the change.": {
|
||||
"fr": "Un redémarrage de Claude Code applique le changement.",
|
||||
"en": "A restart of Claude Code applies the change.",
|
||||
},
|
||||
"Show installed custom commands": {
|
||||
"fr": "Afficher les commandes personnalisées installées",
|
||||
"en": "Show installed custom commands",
|
||||
|
|
|
|||
|
|
@ -485,6 +485,134 @@ class TestSetupClaudeCommit(unittest.TestCase):
|
|||
mock_makedirs.assert_called_once()
|
||||
|
||||
|
||||
class TestClaudePlugins(unittest.TestCase):
|
||||
"""Le menu des plugins Claude Code.
|
||||
|
||||
Ce qui est vérifié ici ne se voit pas à la lecture : la frontière de mot
|
||||
qui distingue deux noms dont l'un contient l'autre, le refus qui n'installe
|
||||
rien, et l'absence de l'exécutable, qui doit se dire au lieu de passer pour
|
||||
un échec de la commande.
|
||||
"""
|
||||
|
||||
def test_absent_binary_reports_without_executing(self):
|
||||
todo = TODO()
|
||||
todo.execute = MagicMock()
|
||||
with patch("script.todo.todo.shutil.which", return_value=None), patch(
|
||||
"builtins.print"
|
||||
):
|
||||
self.assertEqual(todo._claude_plugin_exec("list"), 1)
|
||||
self.assertEqual(
|
||||
todo._claude_plugin_exec("list", capture=True), (1, [])
|
||||
)
|
||||
# La forme du retour suit l'appelant : un appelant qui déballe un
|
||||
# couple ne doit pas recevoir un entier nu.
|
||||
todo.execute.exec_command_live.assert_not_called()
|
||||
|
||||
def test_installed_name_matches_on_word_boundary(self):
|
||||
todo = TODO()
|
||||
# Chaque cas négatif CONTIENT le nom cherché comme sous-chaîne : une
|
||||
# recherche naïve les déclarerait tous posés.
|
||||
cases = [
|
||||
(["code-review-toolkit@market v1.0"], "code-review", False),
|
||||
(["my-superpowers@market v1.0"], "superpowers", False),
|
||||
(["superpowers2@market v1.0"], "superpowers", False),
|
||||
(["code-review@market v1.0"], "code-review", True),
|
||||
(["No plugins installed."], "superpowers", False),
|
||||
(["superpowers@market (enabled)"], "superpowers", True),
|
||||
]
|
||||
for lines, name, expected in cases:
|
||||
with self.subTest(name=name, lines=lines):
|
||||
with patch.object(
|
||||
todo, "_claude_plugin_exec", return_value=(0, lines)
|
||||
):
|
||||
self.assertIs(
|
||||
todo._claude_plugin_is_installed(name), expected
|
||||
)
|
||||
|
||||
def test_unreadable_list_reports_not_installed(self):
|
||||
"""Un code de sortie non nul ne vaut pas « absent » par hasard : la
|
||||
réinstallation qui suit est idempotente, l'inverse effacerait."""
|
||||
todo = TODO()
|
||||
with patch.object(
|
||||
todo, "_claude_plugin_exec", return_value=(2, ["boom"])
|
||||
):
|
||||
self.assertFalse(todo._claude_plugin_is_installed("superpowers"))
|
||||
|
||||
def test_refusing_the_preferred_list_installs_nothing(self):
|
||||
todo = TODO()
|
||||
with patch("builtins.input", return_value="n"), patch.object(
|
||||
todo, "_claude_plugin_exec"
|
||||
) as mock_exec, patch("builtins.print"):
|
||||
todo._claude_install_preferred_plugins()
|
||||
mock_exec.assert_not_called()
|
||||
|
||||
def test_accepting_installs_only_what_is_missing(self):
|
||||
todo = TODO()
|
||||
with patch("builtins.input", return_value="y"), patch.object(
|
||||
todo, "_claude_plugin_exec"
|
||||
) as mock_exec, patch.object(
|
||||
todo,
|
||||
"_claude_plugin_is_installed",
|
||||
side_effect=lambda name: name == "pyright-lsp",
|
||||
), patch(
|
||||
"builtins.print"
|
||||
):
|
||||
todo._claude_install_preferred_plugins()
|
||||
called = [call.args[0] for call in mock_exec.call_args_list]
|
||||
# « -y » est obligatoire : la sortie de TODO est un tuyau, et la CLI
|
||||
# refuse sans lui toute installation qui exécute une commande.
|
||||
self.assertEqual(
|
||||
called,
|
||||
[
|
||||
"install superpowers -y",
|
||||
"install claude-security -y",
|
||||
"install skill-creator -y",
|
||||
],
|
||||
)
|
||||
|
||||
def test_catalog_skips_an_unreadable_manifest(self):
|
||||
todo = TODO()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for name, body in (
|
||||
("good", '{"plugins":[{"name":"a",' '"description":"d"}]}'),
|
||||
("broken", "{not json"),
|
||||
):
|
||||
folder = os.path.join(tmp, name, ".claude-plugin")
|
||||
os.makedirs(folder)
|
||||
with open(
|
||||
os.path.join(folder, "marketplace.json"), "w"
|
||||
) as handle:
|
||||
handle.write(body)
|
||||
with patch.object(TODO, "_CLAUDE_MARKETPLACES_DIR", tmp):
|
||||
catalog = todo._claude_marketplace_catalog()
|
||||
self.assertEqual(catalog, [("a", "good", "d")])
|
||||
|
||||
def test_catalog_is_empty_without_any_marketplace(self):
|
||||
todo = TODO()
|
||||
with patch.object(
|
||||
TODO, "_CLAUDE_MARKETPLACES_DIR", "/nonexistent-marketplaces"
|
||||
):
|
||||
self.assertEqual(todo._claude_marketplace_catalog(), [])
|
||||
|
||||
def test_search_matches_name_and_description(self):
|
||||
todo = TODO()
|
||||
catalog = [
|
||||
("pyright-lsp", "official", "Python language server"),
|
||||
("mongodb", "official", "Document database"),
|
||||
]
|
||||
with patch.object(
|
||||
todo, "_claude_marketplace_catalog", return_value=catalog
|
||||
), patch("builtins.input", return_value="python"), patch(
|
||||
"builtins.print"
|
||||
) as mock_print:
|
||||
todo._claude_plugin_search()
|
||||
printed = " ".join(
|
||||
str(call.args[0]) for call in mock_print.call_args_list
|
||||
)
|
||||
self.assertIn("pyright-lsp", printed)
|
||||
self.assertNotIn("mongodb", printed)
|
||||
|
||||
|
||||
class TestSelectDatabase(unittest.TestCase):
|
||||
@patch("script.todo.database_manager.click")
|
||||
def test_select_database_returns_name(self, mock_click):
|
||||
|
|
|
|||
Loading…
Reference in a new issue