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.
Files changed (75) hide show
  1. cli/__init__.py +0 -0
  2. cli/cleanup.py +136 -0
  3. cli/commands/__init__.py +0 -0
  4. cli/commands/_base.py +397 -0
  5. cli/commands/clean.py +416 -0
  6. cli/commands/create.py +120 -0
  7. cli/commands/credentials.py +96 -0
  8. cli/commands/dashboard.py +157 -0
  9. cli/commands/delete.py +77 -0
  10. cli/commands/doctor.py +60 -0
  11. cli/commands/edit.py +42 -0
  12. cli/commands/env.py +35 -0
  13. cli/commands/init.py +104 -0
  14. cli/commands/list.py +80 -0
  15. cli/commands/logs.py +138 -0
  16. cli/commands/prepare.py +972 -0
  17. cli/commands/restart.py +145 -0
  18. cli/commands/settings.py +21 -0
  19. cli/commands/shell.py +115 -0
  20. cli/commands/show.py +206 -0
  21. cli/commands/start.py +932 -0
  22. cli/commands/status.py +326 -0
  23. cli/commands/stop.py +362 -0
  24. cli/commands/switch.py +43 -0
  25. cli/commands/update.py +96 -0
  26. cli/commands/validate.py +65 -0
  27. cli/commands/version.py +30 -0
  28. cli/compose/__init__.py +95 -0
  29. cli/compose/builder.py +1031 -0
  30. cli/compose/healthcheck.py +158 -0
  31. cli/compose/network.py +1274 -0
  32. cli/compose/runtime.py +678 -0
  33. cli/compose/templates.py +74 -0
  34. cli/core/__init__.py +1 -0
  35. cli/core/cmd.py +186 -0
  36. cli/core/context.py +459 -0
  37. cli/core/paths.py +97 -0
  38. cli/core/profile_io.py +104 -0
  39. cli/debug.py +81 -0
  40. cli/editors/__init__.py +32 -0
  41. cli/editors/_readiness.py +258 -0
  42. cli/editors/_state.py +83 -0
  43. cli/editors/env.py +1514 -0
  44. cli/editors/frontend.py +185 -0
  45. cli/editors/infra.py +192 -0
  46. cli/editors/kin.py +401 -0
  47. cli/editors/profile.py +1299 -0
  48. cli/editors/profile_meta.py +113 -0
  49. cli/editors/runnable.py +903 -0
  50. cli/editors/settings.py +1557 -0
  51. cli/env/__init__.py +43 -0
  52. cli/env/dotenv.py +69 -0
  53. cli/env/loaders.py +29 -0
  54. cli/env/regenerate.py +321 -0
  55. cli/env/resolver.py +440 -0
  56. cli/main.py +123 -0
  57. cli/models/__init__.py +12 -0
  58. cli/models/env.py +230 -0
  59. cli/models/profile.py +351 -0
  60. cli/models/service.py +153 -0
  61. cli/runner/__init__.py +28 -0
  62. cli/runner/base.py +372 -0
  63. cli/runner/binaries.py +133 -0
  64. cli/runner/docker.py +486 -0
  65. cli/runner/git.py +618 -0
  66. cli/runner/process.py +293 -0
  67. cli/runner/redaction.py +47 -0
  68. cli/settings.py +710 -0
  69. cli/ui.py +1155 -0
  70. magicbox_dk-0.0.1.dist-info/METADATA +342 -0
  71. magicbox_dk-0.0.1.dist-info/RECORD +75 -0
  72. magicbox_dk-0.0.1.dist-info/WHEEL +5 -0
  73. magicbox_dk-0.0.1.dist-info/entry_points.txt +3 -0
  74. magicbox_dk-0.0.1.dist-info/licenses/LICENSE +430 -0
  75. 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)
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"]