xcorecli 1.0.0__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.
xcli/worker/cli.py ADDED
@@ -0,0 +1,569 @@
1
+ """
2
+ worker_cmd.py — Commandes CLI pour gérer FastAPI et Celery en processus séparés.
3
+
4
+ Commandes :
5
+ xcore worker start Lance API + Celery ensemble
6
+ xcore worker start api Lance uniquement FastAPI (uvicorn)
7
+ xcore worker start celery Lance uniquement le worker Celery
8
+ xcore worker stop Arrête tous les processus xcore
9
+ xcore worker stop api Arrête uniquement FastAPI
10
+ xcore worker stop celery Arrête uniquement Celery
11
+ xcore worker status État des processus en cours
12
+ xcore worker logs Affiche les dernières lignes de log
13
+ xcore worker inspect Liste les tâches et files d'attente
14
+ xcore worker purge [queue] Vide une file d'attente
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import signal
22
+ import subprocess
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ PID_DIR = Path(".xcore/pids")
29
+ LOG_DIR = Path("log")
30
+ PID_API = PID_DIR / "api.pid"
31
+ PID_CELERY = PID_DIR / "celery.pid"
32
+ LOG_API = LOG_DIR / "api.log"
33
+ LOG_CELERY = LOG_DIR / "celery.log"
34
+
35
+
36
+ # ── Helpers ────────────────────────────────────────────────────────────────────
37
+
38
+
39
+ def _ensure_dirs() -> None:
40
+ PID_DIR.mkdir(parents=True, exist_ok=True)
41
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
42
+
43
+
44
+ def _write_pid(path: Path, pid: int) -> None:
45
+ path.write_text(str(pid))
46
+
47
+
48
+ def _read_pid(path: Path) -> int | None:
49
+ if not path.exists():
50
+ return None
51
+ try:
52
+ return int(path.read_text().strip())
53
+ except ValueError:
54
+ return None
55
+
56
+
57
+ def _is_running(pid: int | None) -> bool:
58
+ if pid is None:
59
+ return False
60
+ try:
61
+ os.kill(pid, 0)
62
+ return True
63
+ except (ProcessLookupError, PermissionError):
64
+ return False
65
+
66
+
67
+ def _stop_pid(path: Path, label: str) -> bool:
68
+ from rich.console import Console
69
+
70
+ console = Console()
71
+ pid = _read_pid(path)
72
+ if not _is_running(pid):
73
+ console.print(f" [dim]{label} n'est pas en cours d'exécution[/dim]")
74
+ path.unlink(missing_ok=True)
75
+ return False
76
+
77
+ try:
78
+ os.kill(pid, signal.SIGTERM)
79
+ for _ in range(30):
80
+ time.sleep(0.2)
81
+ if not _is_running(pid):
82
+ break
83
+ else:
84
+ os.kill(pid, signal.SIGKILL)
85
+ path.unlink(missing_ok=True)
86
+ console.print(f" [green]✓[/green] {label} arrêté (PID {pid})")
87
+ return True
88
+ except ProcessLookupError:
89
+ path.unlink(missing_ok=True)
90
+ return False
91
+
92
+
93
+ def _load_config(config_path: str | None) -> Any:
94
+ try:
95
+ from xcore.configurations.loader import ConfigLoader
96
+
97
+ return ConfigLoader.load(config_path)
98
+ except Exception:
99
+ return None
100
+
101
+
102
+ def _resolve_celery_app() -> str:
103
+ return "xcore.services.xworker.xworker:_celery_worker"
104
+
105
+
106
+ # ── Lancement des processus ────────────────────────────────────────────────────
107
+
108
+
109
+ def _start_api(args: Any) -> subprocess.Popen | None:
110
+ from rich.console import Console
111
+
112
+ console = Console()
113
+
114
+ cfg = _load_config(getattr(args, "config", None))
115
+ srv = getattr(cfg.app, "server", None) if cfg else None
116
+
117
+ _arg_app = getattr(args, "app", "main:app")
118
+ _arg_host = getattr(args, "host", "0.0.0.0")
119
+ _arg_port = getattr(args, "port", 8000)
120
+
121
+ app_path = _arg_app if _arg_app != "main:app" else (getattr(srv, "app", _arg_app) if srv else _arg_app)
122
+ host = _arg_host if _arg_host != "0.0.0.0" else (getattr(srv, "host", _arg_host) if srv else _arg_host)
123
+ port = _arg_port if _arg_port != 8000 else (getattr(srv, "port", _arg_port) if srv else _arg_port)
124
+ workers = getattr(args, "workers", None) or (getattr(srv, "workers", 1) if srv else 1)
125
+ reload = getattr(args, "reload", False) or (getattr(srv, "reload", False) if srv else False)
126
+ log_level = (
127
+ args.loglevel.lower()
128
+ if args.loglevel != "info"
129
+ else (getattr(srv, "log_level", "info") if srv else "info")
130
+ )
131
+
132
+ cmd = [
133
+ sys.executable,
134
+ "-m",
135
+ "uvicorn",
136
+ app_path,
137
+ "--host",
138
+ host,
139
+ "--port",
140
+ str(port),
141
+ "--log-level",
142
+ log_level,
143
+ ]
144
+
145
+ if reload:
146
+ cmd.append("--reload")
147
+ elif workers > 1:
148
+ cmd.extend(["--workers", str(workers)])
149
+
150
+ if getattr(args, "detach", False):
151
+ _ensure_dirs()
152
+ log_file = open(LOG_API, "a")
153
+ proc = subprocess.Popen(
154
+ cmd,
155
+ stdout=log_file,
156
+ stderr=log_file,
157
+ start_new_session=True,
158
+ )
159
+ _write_pid(PID_API, proc.pid)
160
+ console.print(
161
+ f" [green]✓[/green] API démarrée en arrière-plan — PID {proc.pid} "
162
+ f"[dim]log → {LOG_API}[/dim]"
163
+ )
164
+ return proc
165
+ else:
166
+ console.print(f" [cyan]→[/cyan] API [dim]{' '.join(cmd[2:])}[/dim]")
167
+ return subprocess.Popen(cmd)
168
+
169
+
170
+ def _start_celery(args: Any) -> subprocess.Popen | None:
171
+ from rich.console import Console
172
+
173
+ console = Console()
174
+
175
+ celery_app = _resolve_celery_app()
176
+ queues = getattr(args, "queues", None)
177
+ concurrency = getattr(args, "concurrency", None)
178
+ log_level = args.loglevel.upper()
179
+
180
+ cfg = _load_config(getattr(args, "config", None))
181
+ if cfg:
182
+ worker_cfg = cfg.services.xworker
183
+ if queues is None:
184
+ queues = ",".join(worker_cfg.queues)
185
+ if concurrency is None:
186
+ concurrency = worker_cfg.concurrency
187
+
188
+ if queues is None:
189
+ queues = "default"
190
+ if concurrency is None:
191
+ concurrency = 4
192
+
193
+ cmd = [
194
+ sys.executable,
195
+ "-m",
196
+ "celery",
197
+ "-A",
198
+ celery_app,
199
+ "worker",
200
+ "--loglevel",
201
+ log_level,
202
+ "-Q",
203
+ queues,
204
+ "--concurrency",
205
+ str(concurrency),
206
+ ]
207
+
208
+ hostname = getattr(args, "hostname", None)
209
+ if hostname:
210
+ cmd.extend(["-n", hostname])
211
+
212
+ if getattr(args, "detach", False):
213
+ _ensure_dirs()
214
+ log_file = open(LOG_CELERY, "a")
215
+ proc = subprocess.Popen(
216
+ cmd,
217
+ stdout=log_file,
218
+ stderr=log_file,
219
+ start_new_session=True,
220
+ )
221
+ _write_pid(PID_CELERY, proc.pid)
222
+ console.print(
223
+ f" [green]✓[/green] Celery démarré en arrière-plan — PID {proc.pid} "
224
+ f"[dim]log → {LOG_CELERY}[/dim]"
225
+ )
226
+ return proc
227
+ else:
228
+ console.print(f" [cyan]→[/cyan] Celery [dim]{' '.join(cmd[2:])}[/dim]")
229
+ return subprocess.Popen(cmd)
230
+
231
+
232
+ # ── Handlers de commandes ─────────────────────────────────────────────────────
233
+
234
+
235
+ def _cmd_start(args: Any) -> None:
236
+ from rich.console import Console
237
+ from rich.panel import Panel
238
+
239
+ console = Console()
240
+ target = getattr(args, "target", "all")
241
+
242
+ console.print(
243
+ Panel(
244
+ f"[bold]Démarrage xcore[/bold] [dim]target={target}[/dim]",
245
+ border_style="blue",
246
+ padding=(0, 2),
247
+ )
248
+ )
249
+
250
+ if target in ("all", "api"):
251
+ api_proc = _start_api(args)
252
+ else:
253
+ api_proc = None
254
+
255
+ if target in ("all", "celery"):
256
+ celery_proc = _start_celery(args)
257
+ else:
258
+ celery_proc = None
259
+
260
+ if getattr(args, "detach", False):
261
+ return
262
+
263
+ procs = [p for p in (api_proc, celery_proc) if p is not None]
264
+ if not procs:
265
+ return
266
+
267
+ console.print("\n[dim]Ctrl+C pour arrêter[/dim]\n")
268
+
269
+ try:
270
+ while all(_is_running(p.pid) for p in procs):
271
+ time.sleep(0.5)
272
+ except KeyboardInterrupt:
273
+ console.print("\n[yellow]⚠[/yellow] Arrêt en cours…")
274
+ finally:
275
+ for proc in procs:
276
+ if _is_running(proc.pid):
277
+ proc.terminate()
278
+ for proc in procs:
279
+ try:
280
+ proc.wait(timeout=8)
281
+ except subprocess.TimeoutExpired:
282
+ proc.kill()
283
+ console.print("[green]✓[/green] Tous les processus arrêtés.")
284
+
285
+
286
+ def _cmd_stop(args: Any) -> None:
287
+ from rich.console import Console
288
+ from rich.panel import Panel
289
+
290
+ console = Console()
291
+ target = getattr(args, "target", "all")
292
+
293
+ console.print(
294
+ Panel(
295
+ f"[bold]Arrêt xcore[/bold] [dim]target={target}[/dim]",
296
+ border_style="red",
297
+ padding=(0, 2),
298
+ )
299
+ )
300
+
301
+ if target in ("all", "api"):
302
+ _stop_pid(PID_API, "API")
303
+ if target in ("all", "celery"):
304
+ _stop_pid(PID_CELERY, "Celery")
305
+
306
+
307
+ def _cmd_status(args: Any) -> None:
308
+ from rich.console import Console
309
+ from rich.table import Table
310
+
311
+ console = Console()
312
+
313
+ api_pid = _read_pid(PID_API)
314
+ celery_pid = _read_pid(PID_CELERY)
315
+
316
+ table = Table(title="État des processus xcore", border_style="blue", min_width=55)
317
+ table.add_column("Service", style="bold")
318
+ table.add_column("PID", justify="right")
319
+ table.add_column("État")
320
+ table.add_column("Log")
321
+
322
+ def _status_cell(pid: int | None) -> tuple[str, str]:
323
+ if _is_running(pid):
324
+ return str(pid), "[green]● En cours[/green]"
325
+ return str(pid) if pid else "—", "[dim]○ Arrêté[/dim]"
326
+
327
+ api_pid_str, api_status = _status_cell(api_pid)
328
+ cel_pid_str, cel_status = _status_cell(celery_pid)
329
+
330
+ table.add_row("FastAPI (uvicorn)", api_pid_str, api_status, str(LOG_API))
331
+ table.add_row("Celery worker", cel_pid_str, cel_status, str(LOG_CELERY))
332
+
333
+ console.print(table)
334
+
335
+ if getattr(args, "json", False):
336
+ data = {
337
+ "api": {"pid": api_pid, "running": _is_running(api_pid)},
338
+ "celery": {"pid": celery_pid, "running": _is_running(celery_pid)},
339
+ }
340
+ console.print_json(json.dumps(data))
341
+
342
+
343
+ def _cmd_logs(args: Any) -> None:
344
+ from rich.console import Console
345
+
346
+ console = Console()
347
+ target = getattr(args, "target", "all")
348
+ lines = getattr(args, "lines", 50)
349
+ follow = getattr(args, "follow", False)
350
+
351
+ targets: list[tuple[str, Path]] = []
352
+ if target in ("all", "api"):
353
+ targets.append(("API", LOG_API))
354
+ if target in ("all", "celery"):
355
+ targets.append(("Celery", LOG_CELERY))
356
+
357
+ if follow and len(targets) == 1:
358
+ label, log_path = targets[0]
359
+ if not log_path.exists():
360
+ console.print(
361
+ f"[yellow]⚠[/yellow] {log_path} introuvable — le service est-il démarré ?"
362
+ )
363
+ return
364
+ console.print(f"[dim]→ {log_path} (Ctrl+C pour quitter)[/dim]\n")
365
+ try:
366
+ proc = subprocess.Popen(["tail", "-f", "-n", str(lines), str(log_path)])
367
+ proc.wait()
368
+ except KeyboardInterrupt:
369
+ proc.terminate()
370
+ return
371
+
372
+ for label, log_path in targets:
373
+ if not log_path.exists():
374
+ console.print(f"[dim]{label}: {log_path} introuvable[/dim]\n")
375
+ continue
376
+ console.rule(f"[bold]{label}[/bold] [dim]{log_path}[/dim]")
377
+ result = subprocess.run(
378
+ ["tail", "-n", str(lines), str(log_path)],
379
+ capture_output=True,
380
+ text=True,
381
+ )
382
+ console.print(result.stdout or "[dim](vide)[/dim]")
383
+
384
+
385
+ def _cmd_inspect(_args: Any) -> None:
386
+ from rich.console import Console
387
+ from rich.table import Table
388
+
389
+ console = Console()
390
+
391
+ celery_app_path = _resolve_celery_app()
392
+ module_path, attr = celery_app_path.rsplit(":", 1)
393
+
394
+ try:
395
+ import importlib
396
+
397
+ mod = importlib.import_module(module_path)
398
+ celery_app = getattr(mod, attr)
399
+ except Exception as exc:
400
+ console.print(f"[red]✗[/red] Impossible de charger l'app Celery : {exc}")
401
+ return
402
+
403
+ task_table = Table(title="Tâches Celery enregistrées", border_style="blue")
404
+ task_table.add_column("Nom", style="cyan")
405
+ task_table.add_column("Module")
406
+
407
+ for name, task in sorted(celery_app.tasks.items()):
408
+ if name.startswith("celery."):
409
+ continue
410
+ module = getattr(task, "__module__", "—")
411
+ task_table.add_row(name, module)
412
+
413
+ console.print(task_table)
414
+
415
+ console.print()
416
+ try:
417
+ inspect = celery_app.control.inspect(timeout=3)
418
+ active = inspect.active() or {}
419
+ reserved = inspect.reserved() or {}
420
+
421
+ if not active and not reserved:
422
+ console.print(
423
+ "[dim]Aucun worker actif trouvé (broker inaccessible ou aucun worker lancé)[/dim]"
424
+ )
425
+ return
426
+
427
+ worker_table = Table(title="Workers actifs", border_style="green")
428
+ worker_table.add_column("Worker")
429
+ worker_table.add_column("Tâches actives", justify="right")
430
+ worker_table.add_column("Tâches en attente", justify="right")
431
+
432
+ all_workers = set(active) | set(reserved)
433
+ for worker in sorted(all_workers):
434
+ n_active = len(active.get(worker, []))
435
+ n_reserved = len(reserved.get(worker, []))
436
+ worker_table.add_row(worker, str(n_active), str(n_reserved))
437
+
438
+ console.print(worker_table)
439
+
440
+ except Exception as exc:
441
+ console.print(f"[yellow]⚠[/yellow] Broker inaccessible : {exc}")
442
+
443
+
444
+ def _cmd_purge(args: Any) -> None:
445
+ from rich.console import Console
446
+
447
+ console = Console()
448
+ queue = getattr(args, "queue", None) or "default"
449
+
450
+ celery_app_path = _resolve_celery_app()
451
+
452
+ cmd = [
453
+ sys.executable,
454
+ "-m",
455
+ "celery",
456
+ "-A",
457
+ celery_app_path,
458
+ "purge",
459
+ "-Q",
460
+ queue,
461
+ "-f",
462
+ ]
463
+
464
+ console.print(f"[yellow]⚠[/yellow] Purge de la file [bold]{queue}[/bold]…")
465
+ result = subprocess.run(cmd, capture_output=True, text=True)
466
+ if result.returncode == 0:
467
+ console.print(f"[green]✓[/green] {result.stdout.strip() or 'File vidée.'}")
468
+ else:
469
+ console.print(f"[red]✗[/red] {result.stderr.strip()}")
470
+
471
+
472
+ def _cmd_beat(args: Any) -> None:
473
+ from rich.console import Console
474
+
475
+ console = Console()
476
+ celery_app_path = _resolve_celery_app()
477
+ log_level = args.loglevel.upper()
478
+
479
+ cmd = [
480
+ sys.executable,
481
+ "-m",
482
+ "celery",
483
+ "-A",
484
+ celery_app_path,
485
+ "beat",
486
+ "--loglevel",
487
+ log_level,
488
+ ]
489
+
490
+ schedule = getattr(args, "schedule", None)
491
+ if schedule:
492
+ cmd.extend(["--schedule", schedule])
493
+
494
+ if getattr(args, "detach", False):
495
+ _ensure_dirs()
496
+ pid_beat = PID_DIR / "beat.pid"
497
+ log_beat = LOG_DIR / "beat.log"
498
+ log_file = open(log_beat, "a")
499
+ proc = subprocess.Popen(
500
+ cmd,
501
+ stdout=log_file,
502
+ stderr=log_file,
503
+ start_new_session=True,
504
+ )
505
+ _write_pid(pid_beat, proc.pid)
506
+ console.print(
507
+ f" [green]✓[/green] Beat démarré en arrière-plan — PID {proc.pid} "
508
+ f"[dim]log → {log_beat}[/dim]"
509
+ )
510
+ else:
511
+ console.print(f" [cyan]→[/cyan] Beat [dim]{' '.join(cmd[2:])}[/dim]")
512
+ console.print("[dim]Ctrl+C pour arrêter[/dim]\n")
513
+ try:
514
+ proc = subprocess.Popen(cmd)
515
+ proc.wait()
516
+ except KeyboardInterrupt:
517
+ proc.terminate()
518
+ proc.wait()
519
+
520
+
521
+ # ── Point d'entrée principal ──────────────────────────────────────────────────
522
+
523
+
524
+ def handle_worker(args: Any) -> None:
525
+ sub = getattr(args, "worker_subcommand", None)
526
+
527
+ if sub == "start":
528
+ _cmd_start(args)
529
+ elif sub == "stop":
530
+ _cmd_stop(args)
531
+ elif sub == "status":
532
+ _cmd_status(args)
533
+ elif sub == "logs":
534
+ _cmd_logs(args)
535
+ elif sub == "inspect":
536
+ _cmd_inspect(args)
537
+ elif sub == "purge":
538
+ _cmd_purge(args)
539
+ elif sub == "beat":
540
+ _cmd_beat(args)
541
+ else:
542
+ from rich.console import Console
543
+ from rich.table import Table
544
+
545
+ console = Console()
546
+ table = Table(
547
+ title="xcore worker — commandes disponibles",
548
+ border_style="blue",
549
+ min_width=65,
550
+ )
551
+ table.add_column("Commande", style="bold cyan")
552
+ table.add_column("Description")
553
+
554
+ rows = [
555
+ ("xcore worker start [api|celery]", "Lance API et/ou Celery worker"),
556
+ ("xcore worker stop [api|celery]", "Arrête les processus en cours"),
557
+ ("xcore worker status", "Affiche l'état des processus"),
558
+ ("xcore worker logs [api|celery]", "Affiche les dernières lignes de log"),
559
+ ("xcore worker inspect", "Liste les tâches et workers actifs"),
560
+ ("xcore worker purge [queue]", "Vide une file d'attente Celery"),
561
+ ("xcore worker beat", "Lance le scheduler Celery Beat"),
562
+ ]
563
+ for cmd, desc in rows:
564
+ table.add_row(cmd, desc)
565
+
566
+ console.print(table)
567
+ console.print(
568
+ "\n[dim]Exemple : xcore worker start --detach -Q default,emails -c 4[/dim]"
569
+ )