magicbox-dk 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__init__.py +0 -0
- cli/cleanup.py +136 -0
- cli/commands/__init__.py +0 -0
- cli/commands/_base.py +397 -0
- cli/commands/clean.py +416 -0
- cli/commands/create.py +120 -0
- cli/commands/credentials.py +96 -0
- cli/commands/dashboard.py +157 -0
- cli/commands/delete.py +77 -0
- cli/commands/doctor.py +60 -0
- cli/commands/edit.py +42 -0
- cli/commands/env.py +35 -0
- cli/commands/init.py +104 -0
- cli/commands/list.py +80 -0
- cli/commands/logs.py +138 -0
- cli/commands/prepare.py +972 -0
- cli/commands/restart.py +145 -0
- cli/commands/settings.py +21 -0
- cli/commands/shell.py +115 -0
- cli/commands/show.py +206 -0
- cli/commands/start.py +932 -0
- cli/commands/status.py +326 -0
- cli/commands/stop.py +362 -0
- cli/commands/switch.py +43 -0
- cli/commands/update.py +96 -0
- cli/commands/validate.py +65 -0
- cli/commands/version.py +30 -0
- cli/compose/__init__.py +95 -0
- cli/compose/builder.py +1031 -0
- cli/compose/healthcheck.py +158 -0
- cli/compose/network.py +1274 -0
- cli/compose/runtime.py +678 -0
- cli/compose/templates.py +74 -0
- cli/core/__init__.py +1 -0
- cli/core/cmd.py +186 -0
- cli/core/context.py +459 -0
- cli/core/paths.py +97 -0
- cli/core/profile_io.py +104 -0
- cli/debug.py +81 -0
- cli/editors/__init__.py +32 -0
- cli/editors/_readiness.py +258 -0
- cli/editors/_state.py +83 -0
- cli/editors/env.py +1514 -0
- cli/editors/frontend.py +185 -0
- cli/editors/infra.py +192 -0
- cli/editors/kin.py +401 -0
- cli/editors/profile.py +1299 -0
- cli/editors/profile_meta.py +113 -0
- cli/editors/runnable.py +903 -0
- cli/editors/settings.py +1557 -0
- cli/env/__init__.py +43 -0
- cli/env/dotenv.py +69 -0
- cli/env/loaders.py +29 -0
- cli/env/regenerate.py +321 -0
- cli/env/resolver.py +440 -0
- cli/main.py +123 -0
- cli/models/__init__.py +12 -0
- cli/models/env.py +230 -0
- cli/models/profile.py +351 -0
- cli/models/service.py +153 -0
- cli/runner/__init__.py +28 -0
- cli/runner/base.py +372 -0
- cli/runner/binaries.py +133 -0
- cli/runner/docker.py +486 -0
- cli/runner/git.py +618 -0
- cli/runner/process.py +293 -0
- cli/runner/redaction.py +47 -0
- cli/settings.py +710 -0
- cli/ui.py +1155 -0
- magicbox_dk-0.0.1.dist-info/METADATA +342 -0
- magicbox_dk-0.0.1.dist-info/RECORD +75 -0
- magicbox_dk-0.0.1.dist-info/WHEEL +5 -0
- magicbox_dk-0.0.1.dist-info/entry_points.txt +3 -0
- magicbox_dk-0.0.1.dist-info/licenses/LICENSE +430 -0
- magicbox_dk-0.0.1.dist-info/top_level.txt +1 -0
cli/__init__.py
ADDED
|
File without changes
|
cli/cleanup.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Garbage collection des clones git orphelins d'un profil.
|
|
2
|
+
|
|
3
|
+
Un clone (``<profil>/repos/<entry_name>``) devient orphelin quand l'entrée
|
|
4
|
+
correspondante est supprimée du profil ou change de mode (git → remote/dev).
|
|
5
|
+
``gc_clones`` raisonne profil par profil : un clone est orphelin si le YAML
|
|
6
|
+
du profil ne le référence plus. ``gc_profile_clones`` couvre ``dk clean`` :
|
|
7
|
+
il purge **tous** les clones du profil (le profil revient à l'état « non
|
|
8
|
+
préparé », un ``dk prepare`` re-clone ce qui est nécessaire).
|
|
9
|
+
|
|
10
|
+
Pas de GC des globales ``settings.env.connection`` : un endpoint plus
|
|
11
|
+
référencé par aucun profil reste utilisable pour un profil futur — on le
|
|
12
|
+
préserve.
|
|
13
|
+
|
|
14
|
+
Toute la logique vit dans :class:`CloneGC` ; :func:`gc_clones` et
|
|
15
|
+
:func:`gc_profile_clones` sont des façades pour les call sites historiques.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import shutil
|
|
21
|
+
|
|
22
|
+
import questionary
|
|
23
|
+
import typer
|
|
24
|
+
|
|
25
|
+
from cli.core.context import MagicboxContext
|
|
26
|
+
from cli.ui import GLYPH_ERR, GLYPH_INFO, GLYPH_OK
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CloneGC:
|
|
30
|
+
"""GC des clones git d'un profil (``<profil>/repos/``).
|
|
31
|
+
|
|
32
|
+
Pré-bindé sur un ``ctx`` + ``profile_id`` ; expose le listing des clones,
|
|
33
|
+
la détection des orphelins, et les deux opérations de purge (orphelins
|
|
34
|
+
seuls vs tous les clones).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, ctx: MagicboxContext, profile_id: str) -> None:
|
|
38
|
+
self.ctx = ctx
|
|
39
|
+
self.profile_id = profile_id
|
|
40
|
+
|
|
41
|
+
def clones(self) -> list[str]:
|
|
42
|
+
"""Noms des dossiers de clone git présents dans le ``repos/`` du profil."""
|
|
43
|
+
repos = self.ctx.profile_repos_dir(self.profile_id)
|
|
44
|
+
if not repos.exists():
|
|
45
|
+
return []
|
|
46
|
+
# On ne compte que les dépôts git (les .DS_Store et autres sont ignorés).
|
|
47
|
+
return sorted(
|
|
48
|
+
d.name for d in repos.iterdir() if d.is_dir() and (d / ".git").exists()
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def orphans(self) -> list[str]:
|
|
52
|
+
"""Clones présents qu'aucune entrée git du YAML du profil ne référence."""
|
|
53
|
+
referenced = self.ctx.profile_clone_names(self.profile_id)
|
|
54
|
+
return [n for n in self.clones() if n not in referenced]
|
|
55
|
+
|
|
56
|
+
def _delete(self, names: list[str], *, silent: bool = False) -> list[str]:
|
|
57
|
+
"""Supprime les dossiers de clone ``names`` du ``repos/`` du profil.
|
|
58
|
+
|
|
59
|
+
Retourne les noms effectivement supprimés (les échecs ``OSError`` sont
|
|
60
|
+
rapportés sur stderr et n'interrompent pas la boucle).
|
|
61
|
+
"""
|
|
62
|
+
repos = self.ctx.profile_repos_dir(self.profile_id)
|
|
63
|
+
deleted: list[str] = []
|
|
64
|
+
for name in names:
|
|
65
|
+
try:
|
|
66
|
+
shutil.rmtree(repos / name)
|
|
67
|
+
except OSError as exc:
|
|
68
|
+
typer.secho(f" {GLYPH_ERR} {name} : {exc}", fg=typer.colors.RED, err=True)
|
|
69
|
+
continue
|
|
70
|
+
if not silent:
|
|
71
|
+
typer.secho(f" {GLYPH_OK} clone supprimé : {name}", fg=typer.colors.GREEN)
|
|
72
|
+
deleted.append(name)
|
|
73
|
+
return deleted
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _confirm(count: int) -> bool:
|
|
77
|
+
"""Prompt de confirmation partagé. ``False`` ⇒ conservation (message)."""
|
|
78
|
+
confirmed = questionary.confirm(
|
|
79
|
+
f"Supprimer ces {count} clone(s) ?", default=False,
|
|
80
|
+
).ask()
|
|
81
|
+
if not confirmed:
|
|
82
|
+
typer.echo("Clones conservés.")
|
|
83
|
+
return bool(confirmed)
|
|
84
|
+
|
|
85
|
+
def gc_orphans(self, *, ask: bool = True, silent: bool = False) -> list[str]:
|
|
86
|
+
"""Supprime les clones orphelins. Retourne la liste des noms supprimés."""
|
|
87
|
+
orphans = self.orphans()
|
|
88
|
+
if not orphans:
|
|
89
|
+
return []
|
|
90
|
+
if not silent:
|
|
91
|
+
typer.secho(f"\nClones orphelins ({len(orphans)}) :", fg=typer.colors.YELLOW)
|
|
92
|
+
for n in orphans:
|
|
93
|
+
typer.echo(f" {GLYPH_INFO} {n}")
|
|
94
|
+
if ask and not self._confirm(len(orphans)):
|
|
95
|
+
return []
|
|
96
|
+
return self._delete(orphans, silent=silent)
|
|
97
|
+
|
|
98
|
+
def gc_all(self, *, ask: bool = True) -> list[str]:
|
|
99
|
+
"""Supprime tous les clones git du profil. Retourne les noms supprimés.
|
|
100
|
+
|
|
101
|
+
Pensé pour ``dk clean`` : le profil revient à l'état « non préparé », un
|
|
102
|
+
``dk prepare`` re-clonera ce qui est nécessaire.
|
|
103
|
+
"""
|
|
104
|
+
clones = self.clones()
|
|
105
|
+
if not clones:
|
|
106
|
+
typer.echo(" (aucun clone git pour ce profil)")
|
|
107
|
+
return []
|
|
108
|
+
typer.secho(f"\nClones git du profil ({len(clones)}) :", fg=typer.colors.YELLOW)
|
|
109
|
+
for n in clones:
|
|
110
|
+
typer.echo(f" {GLYPH_INFO} {n}")
|
|
111
|
+
if ask and not self._confirm(len(clones)):
|
|
112
|
+
return []
|
|
113
|
+
return self._delete(clones)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── Façades pour les call sites historiques ──────────────────────────────────── #
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def gc_clones(
|
|
120
|
+
ctx: MagicboxContext, profile_id: str, *, ask: bool = True, silent: bool = False,
|
|
121
|
+
) -> list[str]:
|
|
122
|
+
"""Supprime les clones orphelins du profil. Retourne la liste des noms supprimés.
|
|
123
|
+
|
|
124
|
+
Façade vers :meth:`CloneGC.gc_orphans`.
|
|
125
|
+
"""
|
|
126
|
+
return CloneGC(ctx, profile_id).gc_orphans(ask=ask, silent=silent)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def gc_profile_clones(
|
|
130
|
+
ctx: MagicboxContext, profile_id: str, *, ask: bool = True,
|
|
131
|
+
) -> list[str]:
|
|
132
|
+
"""Supprime tous les clones git du profil (``<profil>/repos/``).
|
|
133
|
+
|
|
134
|
+
Façade vers :meth:`CloneGC.gc_all`.
|
|
135
|
+
"""
|
|
136
|
+
return CloneGC(ctx, profile_id).gc_all(ask=ask)
|
cli/commands/__init__.py
ADDED
|
File without changes
|
cli/commands/_base.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Classe de base des commandes ``dk`` — :class:`BaseCommand`.
|
|
2
|
+
|
|
3
|
+
Chaque commande Typer reste une **fonction fine** (Typer introspecte sa
|
|
4
|
+
signature pour générer ``--help`` / parser les arguments, cf.
|
|
5
|
+
``cli/core/cmd.py``) qui instancie une sous-classe de :class:`BaseCommand` et
|
|
6
|
+
appelle :meth:`~BaseCommand.execute`. La classe porte :
|
|
7
|
+
|
|
8
|
+
* le **cycle de vie** commun (bootstrap + debug + résolution profil), piloté par
|
|
9
|
+
des attributs de classe surchargeables (``requires_profile``, ``require_*``,
|
|
10
|
+
``requires_init``, ``loads_context``) ;
|
|
11
|
+
* une **frontière d'erreur** unique : :class:`cli.runner.base.CmdError` levé par
|
|
12
|
+
une commande externe (git/docker en mode ``check=True``) est rendu en panneau
|
|
13
|
+
d'échec standardisé, sortie déjà masquée des secrets ;
|
|
14
|
+
* des **helpers transverses** : :meth:`fail`, :meth:`confirm`, :meth:`guard_os`,
|
|
15
|
+
:meth:`check_cmd`. Les panneaux récapitulatifs passent par ``self.steps.panel_*``.
|
|
16
|
+
|
|
17
|
+
Le rendu de progression (sections, étapes numérotées, items ✓/✗/⚠, panneaux)
|
|
18
|
+
est délégué à :class:`cli.ui.StepReporter` exposé via ``self.steps``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import itertools
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from abc import ABC, abstractmethod
|
|
27
|
+
from collections.abc import Callable, Iterator
|
|
28
|
+
from contextlib import contextmanager
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Annotated, Any, NoReturn, TypeVar
|
|
31
|
+
|
|
32
|
+
import questionary
|
|
33
|
+
import typer
|
|
34
|
+
|
|
35
|
+
from cli.core.cmd import bootstrap, bootstrap_no_profile, resolve_target
|
|
36
|
+
from cli.core.context import MagicboxContext
|
|
37
|
+
from cli.debug import dlog, set_debug, set_verbose
|
|
38
|
+
from cli.runner import process
|
|
39
|
+
from cli.runner.base import CmdError, CmdResult
|
|
40
|
+
from cli.runner.redaction import redact
|
|
41
|
+
from cli.ui import StepReporter
|
|
42
|
+
|
|
43
|
+
#: Option Typer partagée ``--id <uuid>`` : cible un profil par son UUID plutôt
|
|
44
|
+
#: que par son libellé. Mutuellement exclusive avec l'argument de libellé
|
|
45
|
+
#: (garde-fou dans ``cli.core.cmd.resolve_target``).
|
|
46
|
+
ProfileIdOption = Annotated[
|
|
47
|
+
str | None,
|
|
48
|
+
typer.Option("--id", help="Cible le profil par son UUID plutôt que par son libellé."),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BaseCommand(ABC):
|
|
53
|
+
"""Base de toutes les commandes ``dk``.
|
|
54
|
+
|
|
55
|
+
Sous-classer, surcharger les attributs de config si besoin, implémenter
|
|
56
|
+
:meth:`run`, et exposer une entrée Typer :meth:`cli` (héritée telle quelle
|
|
57
|
+
pour une commande sans argument, redéfinie en ``@staticmethod`` typée
|
|
58
|
+
sinon). ``cli`` construit l'instance et déclenche :meth:`_dispatch` (le
|
|
59
|
+
cycle de vie partagé : préambule + ``run`` + frontière ``CmdError``).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
# ── Config (surchargée par sous-classe) ──────────────────────────────── #
|
|
63
|
+
|
|
64
|
+
#: Cible un profil (``bootstrap``) vs aucun profil précis (``bootstrap_no_profile``).
|
|
65
|
+
requires_profile: bool = True
|
|
66
|
+
#: Transmis à ``bootstrap`` : le dossier du profil doit exister.
|
|
67
|
+
require_existing: bool = True
|
|
68
|
+
#: Transmis à ``bootstrap`` : le profil doit avoir été préparé (``dk prepare``).
|
|
69
|
+
require_prepared: bool = False
|
|
70
|
+
#: ``False`` ⇒ ne pas exiger ``dk init`` (cas ``init`` / ``dashboard``).
|
|
71
|
+
requires_init: bool = True
|
|
72
|
+
#: ``False`` ⇒ ne charge aucun contexte (cas ``version``).
|
|
73
|
+
loads_context: bool = True
|
|
74
|
+
#: ``True`` ⇒ sans cible (ni libellé ni ``--id``) sur TTY, ouvre un picker de
|
|
75
|
+
#: profil au lieu de retomber sur l'actif. Réservé aux commandes où l'on cible
|
|
76
|
+
#: volontiers un autre profil que l'actif (``switch``, ``delete``). Les
|
|
77
|
+
#: commandes « profil courant » (``start``/``edit``/``prepare``…) gardent
|
|
78
|
+
#: ``False`` (défaut → profil actif, pas de prompt).
|
|
79
|
+
picks_profile: bool = False
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self, profile: str | None = None, *,
|
|
83
|
+
debug: bool = False, verbose: bool = False,
|
|
84
|
+
profile_id: str | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
self.profile_name = profile
|
|
87
|
+
#: UUID ciblé via ``--id`` (mutuellement exclusif avec ``profile``).
|
|
88
|
+
self._id_flag = profile_id
|
|
89
|
+
self.debug = debug
|
|
90
|
+
self.verbose = verbose
|
|
91
|
+
self.ctx: MagicboxContext | None = None
|
|
92
|
+
self.profile_id: str | None = None
|
|
93
|
+
self.steps = StepReporter()
|
|
94
|
+
self.step_status = None # rich.Status courant pendant une étape spinner
|
|
95
|
+
|
|
96
|
+
# ── Point d'entrée Typer + cycle de vie ──────────────────────────────── #
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def cli(cls) -> None:
|
|
100
|
+
"""Point d'entrée Typer **par défaut** : commandes sans argument.
|
|
101
|
+
|
|
102
|
+
Monté dans ``cli/main.py`` via ``app.command("verbe")(MaCommande.cli)``.
|
|
103
|
+
Une commande qui prend des arguments/options **redéfinit** ``cli`` en
|
|
104
|
+
``@staticmethod`` typée (Typer l'introspecte pour ``--help`` / le parsing)
|
|
105
|
+
et appelle ``MaCommande(...)._dispatch()``. Les commandes triviales
|
|
106
|
+
(``version``, ``dashboard``, ``settings``) héritent de ce défaut —
|
|
107
|
+
``cls()`` instancie la bonne sous-classe.
|
|
108
|
+
"""
|
|
109
|
+
cls()._dispatch()
|
|
110
|
+
|
|
111
|
+
def _dispatch(self) -> None:
|
|
112
|
+
"""Préambule standard + exécution de :meth:`run`, frontière d'erreur incluse.
|
|
113
|
+
|
|
114
|
+
Cycle de vie partagé, appelé par :meth:`cli` une fois l'instance
|
|
115
|
+
construite. Selon les attributs de config : bootstrap profil, bootstrap
|
|
116
|
+
sans profil, simple ``MagicboxContext.load`` (sans garde-fou), ou rien.
|
|
117
|
+
Un :class:`CmdError` remontant de :meth:`run` est rendu en panneau d'échec.
|
|
118
|
+
"""
|
|
119
|
+
# Verbose déroule le détail des étapes (cf. ``-v/--verbose``) — posé avant
|
|
120
|
+
# tout pour couvrir aussi le préambule. ``set_debug`` est géré par bootstrap.
|
|
121
|
+
set_verbose(self.verbose)
|
|
122
|
+
if self.requires_profile:
|
|
123
|
+
self.ctx, self.profile_id = bootstrap(
|
|
124
|
+
self.profile_name,
|
|
125
|
+
debug=self.debug,
|
|
126
|
+
require_existing=self.require_existing,
|
|
127
|
+
require_prepared=self.require_prepared,
|
|
128
|
+
profile_id=self._id_flag,
|
|
129
|
+
pick_when_unset=self.picks_profile,
|
|
130
|
+
)
|
|
131
|
+
if self.profile_name is not None:
|
|
132
|
+
dlog(f"profil `{self.profile_name}` → uuid {self.profile_id}")
|
|
133
|
+
elif self.requires_init:
|
|
134
|
+
self.ctx = bootstrap_no_profile(debug=self.debug)
|
|
135
|
+
elif self.loads_context:
|
|
136
|
+
set_debug(self.debug)
|
|
137
|
+
self.ctx = MagicboxContext.load()
|
|
138
|
+
else:
|
|
139
|
+
set_debug(self.debug)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
self.run()
|
|
143
|
+
except CmdError as exc:
|
|
144
|
+
# Échec d'une commande externe en mode ``check=True`` (git/docker).
|
|
145
|
+
# ``exc.output`` est déjà masqué des secrets par le runner.
|
|
146
|
+
self.steps.panel_err(str(exc), detail=exc.output or None)
|
|
147
|
+
raise typer.Exit(code=exc.returncode or 1) from exc
|
|
148
|
+
except KeyboardInterrupt:
|
|
149
|
+
# Ctrl+C : les ``finally``/context managers ont déjà déroulé (Rich a
|
|
150
|
+
# restauré le curseur, les viewers le terminal). On tue les éventuels
|
|
151
|
+
# sous-process encore vivants, puis on indique CLAIREMENT ce qui s'est
|
|
152
|
+
# passé (l'utilisateur ne doit pas se demander si ça tourne en fond).
|
|
153
|
+
process.terminate_all(grace=2.0)
|
|
154
|
+
self.steps.panel_warn("Interrompu", self._interrupt_note())
|
|
155
|
+
raise typer.Exit(code=130) from None
|
|
156
|
+
|
|
157
|
+
@abstractmethod
|
|
158
|
+
def run(self) -> None:
|
|
159
|
+
"""Logique métier de la commande. ``self.ctx`` / ``self.profile_id`` sont prêts."""
|
|
160
|
+
...
|
|
161
|
+
|
|
162
|
+
def _interrupt_note(self) -> str | None:
|
|
163
|
+
"""Indication contextuelle affichée après un Ctrl+C (``None`` = aucune).
|
|
164
|
+
|
|
165
|
+
Surchargée par les commandes qui peuvent laisser un état partiel (ex.
|
|
166
|
+
``start``/``stop`` : conteneurs à moitié lancés/arrêtés) pour dire à
|
|
167
|
+
l'utilisateur quoi vérifier. Les commandes sans effet de bord renvoient
|
|
168
|
+
``None`` (juste « Interrompu »).
|
|
169
|
+
"""
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
def resolve_target(self) -> str:
|
|
173
|
+
"""Résout le profil ciblé pour une commande à résolution manuelle.
|
|
174
|
+
|
|
175
|
+
Pour les commandes ``requires_profile = False`` qui résolvent elles-mêmes
|
|
176
|
+
le profil (``clean``, ``switch``, ``restart``) : applique la même règle
|
|
177
|
+
libellé-OU-``--id`` que ``bootstrap``, avec le picker piloté par
|
|
178
|
+
``picks_profile``. ``self.ctx`` doit être chargé.
|
|
179
|
+
"""
|
|
180
|
+
assert self.ctx is not None
|
|
181
|
+
return resolve_target(
|
|
182
|
+
self.ctx, self.profile_name, self._id_flag,
|
|
183
|
+
pick_when_unset=self.picks_profile,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# ── Helpers d'erreur ─────────────────────────────────────────────────── #
|
|
187
|
+
|
|
188
|
+
def fail(
|
|
189
|
+
self, message: str, *, code: int = 1, cause: BaseException | None = None,
|
|
190
|
+
) -> NoReturn:
|
|
191
|
+
"""Affiche ``message`` en rouge (stderr) puis sort avec ``code``."""
|
|
192
|
+
typer.secho(message, fg=typer.colors.RED, err=True)
|
|
193
|
+
raise typer.Exit(code=code) from cause
|
|
194
|
+
|
|
195
|
+
def check_cmd(self, result: CmdResult, message: str) -> None:
|
|
196
|
+
"""Sort en erreur si une commande externe (``CmdResult``) a échoué.
|
|
197
|
+
|
|
198
|
+
Aligné sur ``cli/runner`` : les méthodes Docker renvoient un
|
|
199
|
+
:class:`CmdResult` (contrat returncode, jamais d'exception). Sur échec,
|
|
200
|
+
affiche ``message`` + la sortie capturée **masquée des secrets**.
|
|
201
|
+
"""
|
|
202
|
+
if result.ok:
|
|
203
|
+
return
|
|
204
|
+
output = redact(result.stderr or result.stdout)
|
|
205
|
+
self.steps.panel_err(message, detail=output or None)
|
|
206
|
+
raise typer.Exit(code=result.returncode or 1)
|
|
207
|
+
|
|
208
|
+
@contextmanager
|
|
209
|
+
def guard_os(self, error_template: str) -> Iterator[None]:
|
|
210
|
+
"""Convertit une ``OSError`` d'I/O fichier en sortie d'erreur propre.
|
|
211
|
+
|
|
212
|
+
``error_template`` est formaté avec ``exc`` (ex.
|
|
213
|
+
``"Suppression impossible : {exc}"``).
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
yield
|
|
217
|
+
except OSError as exc:
|
|
218
|
+
self.fail(error_template.format(exc=exc), cause=exc)
|
|
219
|
+
|
|
220
|
+
# ── Confirmation interactive ─────────────────────────────────────────── #
|
|
221
|
+
|
|
222
|
+
def confirm(self, message: str, *, yes: bool = False, default: bool = False) -> bool:
|
|
223
|
+
"""Demande confirmation (sauf ``yes=True``). ``False`` ⇒ « Annulé. ».
|
|
224
|
+
|
|
225
|
+
Retourne ``True`` si l'action peut continuer, ``False`` si l'utilisateur
|
|
226
|
+
a refusé (un message « Annulé. » est alors affiché). Le flag ``yes``
|
|
227
|
+
court-circuite la question (``--yes/-y``).
|
|
228
|
+
"""
|
|
229
|
+
if yes:
|
|
230
|
+
return True
|
|
231
|
+
# Suspend le spinner global le temps du prompt (sinon la Live l'écrase).
|
|
232
|
+
with self.steps.paused():
|
|
233
|
+
answered = questionary.confirm(message, default=default).ask()
|
|
234
|
+
if answered:
|
|
235
|
+
return True
|
|
236
|
+
typer.echo("Annulé.")
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ── Système de steps déclaratif ─────────────────────────────────────────────── #
|
|
241
|
+
|
|
242
|
+
_STEP_ORDER = itertools.count()
|
|
243
|
+
_F = TypeVar("_F", bound=Callable[..., None])
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True)
|
|
247
|
+
class _StepDef:
|
|
248
|
+
"""Métadonnées attachées à une méthode décorée :func:`step`."""
|
|
249
|
+
|
|
250
|
+
title: str
|
|
251
|
+
order: int
|
|
252
|
+
when: Callable[[StepCommand], bool] | None
|
|
253
|
+
spinner: bool
|
|
254
|
+
collapse: bool
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def step(
|
|
258
|
+
title: str,
|
|
259
|
+
*,
|
|
260
|
+
when: Callable[[StepCommand], bool] | None = None,
|
|
261
|
+
spinner: bool = False,
|
|
262
|
+
collapse: bool = True,
|
|
263
|
+
) -> Callable[[_F], _F]:
|
|
264
|
+
"""Marque une méthode comme une **étape** d'une :class:`StepCommand`.
|
|
265
|
+
|
|
266
|
+
Les étapes s'exécutent dans l'ordre de déclaration, rendues de façon
|
|
267
|
+
homogène et **collapsible par défaut** (façon ``docker build``) : pendant
|
|
268
|
+
l'exécution un spinner montre l'activité courante (dernier ``self.ok``/
|
|
269
|
+
``info``), et à la fin tout est remplacé par une seule ligne d'outcome
|
|
270
|
+
``✓ [i/N] Titre`` (``⚠``/``✗`` selon avertissements/erreurs, qui restent
|
|
271
|
+
affichés). Avec ``spinner=True``, l'étape est une opération opaque (build,
|
|
272
|
+
up, down) : spinner animé + outcome auto, sans items.
|
|
273
|
+
|
|
274
|
+
``collapse=False`` : repli sur le rendu déroulant (en-tête + items inline) —
|
|
275
|
+
pour une étape qui gère son propre affichage live (``rich.Live``) ou imprime
|
|
276
|
+
en direct (sortie d'un binaire externe). ``--debug`` / sortie non-TTY
|
|
277
|
+
désactivent aussi le collapse automatiquement.
|
|
278
|
+
|
|
279
|
+
``when`` : prédicat ``(self) -> bool`` évalué avant l'exécution — si faux,
|
|
280
|
+
l'étape est sautée et exclue de la numérotation (ex. ``--no-build``).
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
def deco(fn: _F) -> _F:
|
|
284
|
+
fn._stepdef = _StepDef( # type: ignore[attr-defined]
|
|
285
|
+
title, next(_STEP_ORDER), when, spinner, collapse,
|
|
286
|
+
)
|
|
287
|
+
return fn
|
|
288
|
+
|
|
289
|
+
return deco
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class StepCommand(BaseCommand):
|
|
293
|
+
"""Commande dont la logique est découpée en **étapes** (méthodes :func:`step`).
|
|
294
|
+
|
|
295
|
+
Le ``run()`` est fourni : il appelle :meth:`setup` (préparation de l'état
|
|
296
|
+
partagé porté par ``self``), exécute chaque étape active dans l'ordre de
|
|
297
|
+
déclaration via ``self.steps`` (numérotation ``[i/N]`` automatique), puis
|
|
298
|
+
:meth:`finish` (panneau récapitulatif). Les sous-classes n'écrivent que des
|
|
299
|
+
méthodes : tout le code vit dans la classe.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
#: Libellé du spinner global de commande (cf. :meth:`cli.ui.StepReporter.command`).
|
|
303
|
+
command_label: str = "Traitement"
|
|
304
|
+
|
|
305
|
+
def setup(self) -> None:
|
|
306
|
+
"""Hook avant les étapes : charge le profil, calcule l'état partagé."""
|
|
307
|
+
|
|
308
|
+
def finish(self) -> None:
|
|
309
|
+
"""Hook après les étapes : panneau récapitulatif final (optionnel)."""
|
|
310
|
+
|
|
311
|
+
def pump(self, fn: Callable[[], Any], *, interval: float = 0.1) -> Any:
|
|
312
|
+
"""Exécute ``fn()`` en **arrière-plan** tout en gardant le panneau vivant.
|
|
313
|
+
|
|
314
|
+
Un travail bloquant sur le thread principal (clone/fetch git, I/O réseau)
|
|
315
|
+
empêche le spinner et les chronos d'avancer. On délègue donc ``fn`` à un
|
|
316
|
+
thread daemon et on **pompe** l'affichage depuis le thread courant
|
|
317
|
+
(``tick`` + ``sleep``) jusqu'à ce qu'il finisse — spinner et chronos
|
|
318
|
+
restent fluides.
|
|
319
|
+
|
|
320
|
+
Gestion d'échec : l'exception levée par ``fn`` est propagée à
|
|
321
|
+
l'identique (la frontière ``CmdError`` de :meth:`_dispatch` la rend en
|
|
322
|
+
panneau). Gestion des signaux : au Ctrl+C, on tue les process enfants
|
|
323
|
+
enregistrés (``process.terminate_all`` — les commandes externes passent
|
|
324
|
+
toutes par le runner, donc sont enregistrées et killables), on attend
|
|
325
|
+
brièvement la fin du thread, puis on relève ``KeyboardInterrupt``.
|
|
326
|
+
"""
|
|
327
|
+
box: dict[str, Any] = {}
|
|
328
|
+
|
|
329
|
+
def _target() -> None:
|
|
330
|
+
try:
|
|
331
|
+
box["result"] = fn()
|
|
332
|
+
except BaseException as exc: # noqa: BLE001 — relayé tel quel au thread principal
|
|
333
|
+
box["exc"] = exc
|
|
334
|
+
|
|
335
|
+
worker = threading.Thread(target=_target, daemon=True)
|
|
336
|
+
worker.start()
|
|
337
|
+
try:
|
|
338
|
+
while worker.is_alive():
|
|
339
|
+
self.steps.tick()
|
|
340
|
+
time.sleep(interval)
|
|
341
|
+
except KeyboardInterrupt:
|
|
342
|
+
process.terminate_all(grace=1.0) # kill des enfants → débloque fn
|
|
343
|
+
worker.join(timeout=2.0)
|
|
344
|
+
raise
|
|
345
|
+
worker.join()
|
|
346
|
+
if "exc" in box:
|
|
347
|
+
raise box["exc"]
|
|
348
|
+
return box.get("result")
|
|
349
|
+
|
|
350
|
+
def _ordered_steps(self) -> list[tuple[str, _StepDef]]:
|
|
351
|
+
"""Méthodes :func:`step` de la classe, dans l'ordre de déclaration."""
|
|
352
|
+
defs: dict[str, _StepDef] = {}
|
|
353
|
+
for klass in type(self).__mro__:
|
|
354
|
+
for name, attr in vars(klass).items():
|
|
355
|
+
sdef = getattr(attr, "_stepdef", None)
|
|
356
|
+
if isinstance(sdef, _StepDef) and name not in defs:
|
|
357
|
+
defs[name] = sdef
|
|
358
|
+
return sorted(defs.items(), key=lambda kv: kv[1].order)
|
|
359
|
+
|
|
360
|
+
def active_steps(self) -> list[tuple[str, _StepDef]]:
|
|
361
|
+
"""Étapes à exécuter (``when`` filtré). Appelé après :meth:`setup`."""
|
|
362
|
+
return [
|
|
363
|
+
(name, sdef)
|
|
364
|
+
for name, sdef in self._ordered_steps()
|
|
365
|
+
if sdef.when is None or sdef.when(self)
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
def run_active_steps(self, active: list[tuple[str, _StepDef]]) -> None:
|
|
369
|
+
"""Exécute ``active`` dans le panneau **déjà ouvert** (cf. ``self.steps``).
|
|
370
|
+
|
|
371
|
+
Extrait de :meth:`run` pour qu'un orchestrateur (``dk restart``) puisse
|
|
372
|
+
enchaîner les étapes de plusieurs commandes dans **un seul** panneau.
|
|
373
|
+
"""
|
|
374
|
+
for name, sdef in active:
|
|
375
|
+
method = getattr(self, name)
|
|
376
|
+
# ``self.step_status`` expose l'activité de l'étape (progression
|
|
377
|
+
# build branchée via ``on_progress``, etc.).
|
|
378
|
+
with self.steps.running_step(
|
|
379
|
+
sdef.title, collapse=sdef.collapse, spinner=sdef.spinner,
|
|
380
|
+
) as status:
|
|
381
|
+
self.step_status = status
|
|
382
|
+
method()
|
|
383
|
+
self.step_status = None
|
|
384
|
+
|
|
385
|
+
def run(self) -> None:
|
|
386
|
+
# ``setup`` (garde-fous, prompts) tourne AVANT le panneau. Puis un seul
|
|
387
|
+
# panneau Live (header animé en haut + une ligne par étape qui se coche)
|
|
388
|
+
# couvre toutes les étapes ; ``finish`` (récap + éventuel ``--attach``)
|
|
389
|
+
# tourne HORS panneau (rendu final, parfois bloquant comme le suivi logs).
|
|
390
|
+
self.setup()
|
|
391
|
+
active = self.active_steps()
|
|
392
|
+
with self.steps.panel(self.command_label, [sdef.title for _, sdef in active]):
|
|
393
|
+
self.run_active_steps(active)
|
|
394
|
+
self.finish()
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
__all__ = ["BaseCommand", "ProfileIdOption", "StepCommand", "step"]
|