aioli-cli 0.1.26__tar.gz → 0.1.28__tar.gz
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.
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/PKG-INFO +1 -1
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/pyproject.toml +9 -9
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_cli/fetch_ui.py +38 -4
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_cli/main.py +7 -2
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_cli/status.py +83 -12
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_contract/__init__.py +24 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_contract/advise.py +149 -29
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_contract/fetch.py +741 -98
- aioli_cli-0.1.28/src/aioli_contract/readiness.py +252 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/companions.py +112 -50
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/data/models.toml +1 -1
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/models.py +82 -22
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/models_data.py +1 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/presets.py +3 -2
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_master/ops.py +59 -16
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/backends/minimax_comfy.py +8 -19
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/backends/process.py +1 -2
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/backends/stub.py +12 -5
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/backends/yue2.py +103 -17
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/config_data/default.toml +4 -3
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/core/formats.py +21 -10
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/core/jobs.py +4 -8
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/core/layout.py +3 -1
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/doctor.py +13 -6
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/history.py +1 -3
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/models.py +10 -12
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/paths.py +1 -4
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/presets.py +1 -5
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/transport/client.py +19 -28
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_podcast/episode.py +2 -1
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_podcast/script.py +3 -2
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_video/cli.py +47 -2
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_video/paths.py +3 -2
- aioli_cli-0.1.28/src/aioli_video/stub.py +51 -0
- aioli_cli-0.1.28/src/aioli_video/wgp.py +323 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/cli/doctor.py +14 -4
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/cli/generate.py +2 -1
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/devices.py +1 -3
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/engines/__init__.py +89 -9
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/engines/moss.py +4 -2
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/engines/qwen3.py +2 -1
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/engines/stub.py +1 -3
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/engines/voxcpm.py +2 -1
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/engines/xtts.py +2 -1
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/models_data.py +12 -6
- aioli_cli-0.1.26/src/aioli_video/stub.py +0 -30
- aioli_cli-0.1.26/src/aioli_video/wgp.py +0 -136
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/.gitignore +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/LICENSE +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/README.md +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_cli/_plugins.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_cli/registry.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_cli/setup.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_contract/config.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_contract/doctor.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_contract/probe.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/__init__.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/cli.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/config.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/doctor.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/gpu.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/launcher.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/plugin.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/stub.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_image/ui.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_master/cli.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_master/doctor.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_master/plugin.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/__init__.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/backends/__init__.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/backends/acestep.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/backends/base.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/backends/llm_caption.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/cli_commands/__init__.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/cli_commands/backend_cmd.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/cli_commands/generate_cmd.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/cli_commands/inspire_cmd.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/cli_commands/models_cmd.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/client.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/config.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/core/__init__.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/core/generate.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/generator.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/jsonstore.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/models_data.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/plugin.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/prompts.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/server.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/transport/__init__.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_music/tui.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_podcast/cast.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_podcast/cli.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_podcast/doctor.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_podcast/plugin.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_video/doctor.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_video/models_data.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_video/plugin.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_video/worker.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/__init__.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/cli/engines_cmd.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/cli/models_cmd.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/cli/profiles.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/config/__init__.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/config/settings.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/core/__init__.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/core/generation.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/core/model_manager.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/core/profiles.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/core/registry.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/core/types.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/doctor.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/engines/chatterbox.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/engines/kokoro.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/engines/piper.py +0 -0
- {aioli_cli-0.1.26 → aioli_cli-0.1.28}/src/aioli_voice/plugin.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "aioli-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.28"
|
|
4
4
|
description = "aioli — local-first AI media suite CLI (images, video, music, voice, podcast)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -117,6 +117,12 @@ addopts = "-q"
|
|
|
117
117
|
dev = [
|
|
118
118
|
"pytest>=8.0",
|
|
119
119
|
"pillow>=10.0", # solo tests (stubs de imagen); nunca en dependencies
|
|
120
|
+
# H0.4: tooling de CI pineado aqui (no en `uvx` run-steps) para que
|
|
121
|
+
# dependabot lo renueve: no ve herramientas invocadas con `uvx` en los
|
|
122
|
+
# workflows. release.yml ejecuta estas tools con `uv run`.
|
|
123
|
+
"ruff==0.16.8",
|
|
124
|
+
"mypy==2.3.1",
|
|
125
|
+
"pip-audit==2.10.1",
|
|
120
126
|
]
|
|
121
127
|
|
|
122
128
|
[tool.mypy]
|
|
@@ -175,18 +181,12 @@ exclude = [
|
|
|
175
181
|
[tool.ruff.lint]
|
|
176
182
|
select = ["E", "F", "W", "I", "UP", "B"]
|
|
177
183
|
ignore = [
|
|
178
|
-
# E501 lo posee el formatter; activarlo junto al formatter es ruido.
|
|
179
|
-
"E501",
|
|
180
184
|
# B008 es falso positivo sistemático: typer.Option/Argument en defaults
|
|
181
185
|
# es la API documentada de Typer, no llamada mutable en default.
|
|
182
186
|
"B008",
|
|
183
|
-
# UP042 (str,Enum)->StrEnum cambia semántica de str()/format; migración
|
|
184
|
-
# aparte, fuera del alcance de esta wave de lint.
|
|
185
|
-
"UP042",
|
|
186
187
|
]
|
|
187
188
|
|
|
188
189
|
[tool.ruff.format]
|
|
189
190
|
# line-length se hereda de [tool.ruff] line-length = 100.
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
# solo configura el formatter y limpia el lint sin reescribir el código.
|
|
191
|
+
# H0 (2026-09-18): reformateo masivo aplicado (32 ficheros);
|
|
192
|
+
# `ruff format --check .` verde en CI.
|
|
@@ -11,11 +11,37 @@ from pathlib import Path
|
|
|
11
11
|
from threading import Lock
|
|
12
12
|
|
|
13
13
|
from rich.console import Console
|
|
14
|
-
from rich.progress import
|
|
14
|
+
from rich.progress import (
|
|
15
|
+
BarColumn,
|
|
16
|
+
DownloadColumn,
|
|
17
|
+
Progress,
|
|
18
|
+
TaskID,
|
|
19
|
+
TaskProgressColumn,
|
|
20
|
+
TextColumn,
|
|
21
|
+
TimeElapsedColumn,
|
|
22
|
+
TransferSpeedColumn,
|
|
23
|
+
)
|
|
15
24
|
|
|
16
25
|
console = Console()
|
|
17
26
|
|
|
18
27
|
|
|
28
|
+
def live_progress() -> Progress:
|
|
29
|
+
"""Live bar for downloads: bytes + speed + elapsed, never a fake ETA.
|
|
30
|
+
|
|
31
|
+
H3.1: `TimeRemainingColumn` is deliberately absent. With an unknown
|
|
32
|
+
total rich would print a bogus countdown, so remaining time is never
|
|
33
|
+
shown — elapsed time and current speed are honest without a total.
|
|
34
|
+
"""
|
|
35
|
+
return Progress(
|
|
36
|
+
TextColumn("{task.description}"),
|
|
37
|
+
BarColumn(),
|
|
38
|
+
TaskProgressColumn(),
|
|
39
|
+
DownloadColumn(),
|
|
40
|
+
TransferSpeedColumn(),
|
|
41
|
+
TimeElapsedColumn(),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
19
45
|
class _Bar:
|
|
20
46
|
"""Thread-safe fan-out: one rich task per file."""
|
|
21
47
|
|
|
@@ -25,13 +51,21 @@ class _Bar:
|
|
|
25
51
|
self.lock = Lock()
|
|
26
52
|
|
|
27
53
|
def update(self, path: Path, done_bytes: int, total_bytes: int) -> None:
|
|
54
|
+
# H3.1: total desconocido (0) = tarea indeterminada (total=None).
|
|
55
|
+
# El total=1 anterior fingía porcentajes absurdos (p.ej. 1638400%)
|
|
56
|
+
# con ETA. Cuando el total llega tarde se fija entonces.
|
|
28
57
|
key = str(path)
|
|
29
58
|
with self.lock:
|
|
30
59
|
task = self.tasks.get(key)
|
|
31
60
|
if task is None:
|
|
32
|
-
task = self.progress.add_task(
|
|
61
|
+
task = self.progress.add_task(
|
|
62
|
+
path.name, total=total_bytes if total_bytes > 0 else None
|
|
63
|
+
)
|
|
33
64
|
self.tasks[key] = task
|
|
34
|
-
|
|
65
|
+
if total_bytes > 0:
|
|
66
|
+
self.progress.update(task, completed=done_bytes, total=total_bytes)
|
|
67
|
+
else:
|
|
68
|
+
self.progress.update(task, completed=done_bytes)
|
|
35
69
|
|
|
36
70
|
|
|
37
71
|
def download_many(
|
|
@@ -52,7 +86,7 @@ def download_many(
|
|
|
52
86
|
failures: list[tuple[str, str]] = []
|
|
53
87
|
flog: Lock = Lock()
|
|
54
88
|
|
|
55
|
-
with
|
|
89
|
+
with live_progress() as progress:
|
|
56
90
|
bar = _Bar(progress)
|
|
57
91
|
|
|
58
92
|
def _one(entry: ModelFile) -> None:
|
|
@@ -93,11 +93,16 @@ def setup(
|
|
|
93
93
|
|
|
94
94
|
|
|
95
95
|
@app.command()
|
|
96
|
-
def status(
|
|
96
|
+
def status(
|
|
97
|
+
quick: bool = typer.Option(False, "--quick", help="Nivel quick: presencia+tamaño."),
|
|
98
|
+
full: bool = typer.Option(False, "--full", help="Nivel full: sha256/recibo re-hasheado."),
|
|
99
|
+
) -> None:
|
|
97
100
|
"""Límites del equipo + qué motores sirven, faltan y cómo arreglarlos."""
|
|
98
101
|
from aioli_cli.status import run_status
|
|
99
102
|
|
|
100
|
-
|
|
103
|
+
if quick and full:
|
|
104
|
+
raise typer.BadParameter("--quick y --full son excluyentes")
|
|
105
|
+
run_status(level="full" if full else "quick")
|
|
101
106
|
|
|
102
107
|
|
|
103
108
|
@app.command()
|
|
@@ -8,7 +8,19 @@ from rich.table import Table
|
|
|
8
8
|
console = Console()
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
def run_status() -> None:
|
|
11
|
+
def run_status(level: str = "quick") -> None:
|
|
12
|
+
"""Niveles H1 (D08): quick / full / probado por separado.
|
|
13
|
+
|
|
14
|
+
- `quick` (defecto): presencia + tamano, sin re-hashear GBs. Nunca
|
|
15
|
+
vende "listo/certificado": lo presente se marca como presente.
|
|
16
|
+
- `full`: sha256/recibo re-hasheado; coincide con `setup` sin
|
|
17
|
+
descargas pendientes y con `models verify`.
|
|
18
|
+
- `probado`: solo inferencia real; status nunca lo afirma, solo
|
|
19
|
+
muestra la evidencia de `probe.json` cuando existe.
|
|
20
|
+
"""
|
|
21
|
+
if level not in ("quick", "full"):
|
|
22
|
+
raise ValueError(f"nivel desconocido: {level!r} (válidos: quick, full)")
|
|
23
|
+
quick = level == "quick"
|
|
12
24
|
from aioli_cli import _plugins # noqa: PLC0415
|
|
13
25
|
from aioli_cli.registry import registry # noqa: PLC0415
|
|
14
26
|
from aioli_cli.setup import machine_panel # noqa: PLC0415
|
|
@@ -34,6 +46,19 @@ def run_status() -> None:
|
|
|
34
46
|
console.print()
|
|
35
47
|
console.print(machine_panel(machine))
|
|
36
48
|
console.print()
|
|
49
|
+
if quick:
|
|
50
|
+
console.print(
|
|
51
|
+
"[dim]nivel quick: presencia+tamaño (sin re-hashear). "
|
|
52
|
+
"No certifica integridad ni inferencia; "
|
|
53
|
+
"`aioli status --full` re-hashea (sha256/recibo).[/]"
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
console.print(
|
|
57
|
+
"[dim]nivel full: sha256/recibo re-hasheado "
|
|
58
|
+
"(coincide con `setup` y `models verify`). "
|
|
59
|
+
"Probado exige inferencia real (sección probado).[/]"
|
|
60
|
+
)
|
|
61
|
+
console.print()
|
|
37
62
|
|
|
38
63
|
table = Table(title="¿Qué puedo usar?")
|
|
39
64
|
table.add_column("plugin")
|
|
@@ -56,7 +81,7 @@ def run_status() -> None:
|
|
|
56
81
|
runtimes_plat: list[ModelFile] = [] # plataforma actual: ¿hay motor usable?
|
|
57
82
|
|
|
58
83
|
def _ok_any(entry) -> bool:
|
|
59
|
-
return any(verify(entry, root, bin_dir, quick=
|
|
84
|
+
return any(verify(entry, root, bin_dir, quick=quick) == "ok" for root in _roots)
|
|
60
85
|
|
|
61
86
|
for raw in p.models():
|
|
62
87
|
e = ModelFile(**raw)
|
|
@@ -111,15 +136,26 @@ def run_status() -> None:
|
|
|
111
136
|
)
|
|
112
137
|
nofit.append(f"{tag} — {v.headline}")
|
|
113
138
|
elif is_ready:
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
139
|
+
if quick:
|
|
140
|
+
table.add_row(
|
|
141
|
+
p.name,
|
|
142
|
+
engine,
|
|
143
|
+
f"{v.icon} cabe",
|
|
144
|
+
f"{ok_n}/{len(core)}",
|
|
145
|
+
"—",
|
|
146
|
+
f"aioli {p.name} … (presente quick, confirma con --full)",
|
|
147
|
+
)
|
|
148
|
+
ready.append(f"{tag} (presente quick, sin verificar)")
|
|
149
|
+
else:
|
|
150
|
+
table.add_row(
|
|
151
|
+
p.name,
|
|
152
|
+
engine,
|
|
153
|
+
f"{v.icon} cabe",
|
|
154
|
+
f"{ok_n}/{len(core)}",
|
|
155
|
+
"—",
|
|
156
|
+
f"aioli {p.name} … (listo, verificado full)",
|
|
157
|
+
)
|
|
158
|
+
ready.append(tag)
|
|
123
159
|
elif gaps:
|
|
124
160
|
if "motor piper (binario)" in gaps:
|
|
125
161
|
fix = "uv tool install piper-tts (o exporta $PIPER_BIN)"
|
|
@@ -140,8 +176,18 @@ def run_status() -> None:
|
|
|
140
176
|
(licensed if _needs_license(entries) else easy).append(f"{tag} → {fix}")
|
|
141
177
|
console.print(table)
|
|
142
178
|
console.print()
|
|
179
|
+
probado = _probado_plugins()
|
|
180
|
+
if probado:
|
|
181
|
+
console.print("[bold]probado (inferencia real en este equipo):[/]")
|
|
182
|
+
for line in sorted(probado):
|
|
183
|
+
console.print(f"🧪 probado: [bold]{line}[/]")
|
|
184
|
+
else:
|
|
185
|
+
console.print("[dim]probado: sin evidencia de inferencia en este equipo;[/]")
|
|
186
|
+
console.print("[dim]solo una inferencia real marca probado (quick/full no bastan).[/]")
|
|
187
|
+
console.print()
|
|
188
|
+
ready_icon = "✅ presente (quick)" if quick else "✅ listo (full)"
|
|
143
189
|
for line in ready:
|
|
144
|
-
console.print(f"
|
|
190
|
+
console.print(f"{ready_icon}: [bold]{line}[/]")
|
|
145
191
|
for line in easy:
|
|
146
192
|
console.print(f"⬇️ instalable fácil: [bold]{line}[/]")
|
|
147
193
|
for line in licensed:
|
|
@@ -151,6 +197,31 @@ def run_status() -> None:
|
|
|
151
197
|
console.print()
|
|
152
198
|
|
|
153
199
|
|
|
200
|
+
def _probado_plugins() -> set[str]:
|
|
201
|
+
"""Plugins con al menos una inferencia que cupo en ESTE equipo.
|
|
202
|
+
|
|
203
|
+
Lee `probe.json` (solo lectura); vacío o ausente = sin evidencia.
|
|
204
|
+
"""
|
|
205
|
+
try:
|
|
206
|
+
from aioli_contract.probe import hardware_fingerprint, load_store # noqa: PLC0415
|
|
207
|
+
|
|
208
|
+
store = load_store()
|
|
209
|
+
hw = hardware_fingerprint()
|
|
210
|
+
out: set[str] = set()
|
|
211
|
+
plugins = store.get(hw, {})
|
|
212
|
+
for plugin, models in plugins.items():
|
|
213
|
+
if not isinstance(models, dict):
|
|
214
|
+
continue
|
|
215
|
+
for modes in models.values():
|
|
216
|
+
if not isinstance(modes, dict):
|
|
217
|
+
continue
|
|
218
|
+
if any(isinstance(m, dict) and m.get("fits") for m in modes.values()):
|
|
219
|
+
out.add(plugin)
|
|
220
|
+
return out
|
|
221
|
+
except Exception: # noqa: BLE001 — sin evidencia: no probado, sin romper status
|
|
222
|
+
return set()
|
|
223
|
+
|
|
224
|
+
|
|
154
225
|
def _piper_binary() -> bool:
|
|
155
226
|
"""El MISMO criterio que el motor en ejecución (A05).
|
|
156
227
|
|
|
@@ -16,6 +16,19 @@ from aioli_contract.fetch import (
|
|
|
16
16
|
smoke_runtime,
|
|
17
17
|
verify,
|
|
18
18
|
)
|
|
19
|
+
from aioli_contract.readiness import (
|
|
20
|
+
BackendSpec,
|
|
21
|
+
ExecutionPlan,
|
|
22
|
+
ProbeOutcome,
|
|
23
|
+
Readiness,
|
|
24
|
+
check_abi_ldd,
|
|
25
|
+
check_exec_bit,
|
|
26
|
+
check_sdk,
|
|
27
|
+
evaluate_readiness,
|
|
28
|
+
probe_runtime,
|
|
29
|
+
rank,
|
|
30
|
+
resolve_backend,
|
|
31
|
+
)
|
|
19
32
|
|
|
20
33
|
__all__ = [
|
|
21
34
|
"AioliConfig",
|
|
@@ -34,6 +47,17 @@ __all__ = [
|
|
|
34
47
|
"human_gb",
|
|
35
48
|
"judge",
|
|
36
49
|
"read_machine",
|
|
50
|
+
"BackendSpec",
|
|
51
|
+
"ExecutionPlan",
|
|
52
|
+
"ProbeOutcome",
|
|
53
|
+
"Readiness",
|
|
54
|
+
"check_abi_ldd",
|
|
55
|
+
"check_exec_bit",
|
|
56
|
+
"check_sdk",
|
|
57
|
+
"evaluate_readiness",
|
|
58
|
+
"probe_runtime",
|
|
59
|
+
"rank",
|
|
60
|
+
"resolve_backend",
|
|
37
61
|
"run_checks",
|
|
38
62
|
"sha256_of",
|
|
39
63
|
"smoke_runtime",
|
|
@@ -22,13 +22,33 @@ class GPU:
|
|
|
22
22
|
name: str
|
|
23
23
|
total_gb: float
|
|
24
24
|
free_gb: float
|
|
25
|
+
uuid: str = ""
|
|
26
|
+
index: int = 0
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
@dataclass
|
|
28
30
|
class Machine:
|
|
31
|
+
"""Snapshot de hardware de UNA invocacion (H4.1, D15).
|
|
32
|
+
|
|
33
|
+
Un snapshot por comando: `read_machine()` escanea una vez por proceso
|
|
34
|
+
(cache explicita, ver `invalidate_machine_cache`) y el MISMO objeto se
|
|
35
|
+
comparte entre todos los checks del comando en lugar de re-escanear
|
|
36
|
+
por plugin.
|
|
37
|
+
|
|
38
|
+
Convencion desconocido != 0:
|
|
39
|
+
- `gpus == []` = sin NVIDIA medible (desconocido), NO "0 GPUs de 0 GB".
|
|
40
|
+
- `free_gb == 0.0` en una GPU listada ES un dato valido (GPU ocupada).
|
|
41
|
+
- `ram_total_gb/ram_available_gb is None` = no medible; `ram_gb == 0.0`
|
|
42
|
+
es solo el alias legacy de ese desconocido.
|
|
43
|
+
- `cuda_visible_devices is None` = variable ausente (todo visible).
|
|
44
|
+
"""
|
|
45
|
+
|
|
29
46
|
gpus: list[GPU] = field(default_factory=list)
|
|
30
47
|
ram_gb: float = 0.0
|
|
31
48
|
disk_free_gb: float = 0.0
|
|
49
|
+
ram_total_gb: float | None = None
|
|
50
|
+
ram_available_gb: float | None = None
|
|
51
|
+
cuda_visible_devices: str | None = None
|
|
32
52
|
|
|
33
53
|
@property
|
|
34
54
|
def best_vram_gb(self) -> float:
|
|
@@ -117,29 +137,100 @@ REQUIREMENTS: dict[tuple[str, str], Requirement] = {
|
|
|
117
137
|
}
|
|
118
138
|
|
|
119
139
|
|
|
120
|
-
|
|
121
|
-
|
|
140
|
+
_MACHINE_CACHE: dict[str, Machine] = {}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def invalidate_machine_cache() -> None:
|
|
144
|
+
"""Invalidacion explicita del snapshot (H4.1).
|
|
145
|
+
|
|
146
|
+
La cache vive solo en el proceso actual: un comando CLI escanea una
|
|
147
|
+
vez y comparte el objeto; los tests o un cambio de GPU fuerzan con
|
|
148
|
+
`force_refresh=True` o llamando aqui.
|
|
149
|
+
"""
|
|
150
|
+
_MACHINE_CACHE.clear()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _cache_key(models_dir: Path | None) -> str:
|
|
122
154
|
try:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
155
|
+
target = models_dir or Path.home()
|
|
156
|
+
return str(target)
|
|
157
|
+
except Exception: # noqa: BLE001 — clave best-effort, nunca rompe el scan
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _query_gpus() -> list[GPU]:
|
|
162
|
+
"""Un unico `nvidia-smi` con UUID; fallback al query legacy sin UUID."""
|
|
163
|
+
queries = [
|
|
164
|
+
[
|
|
165
|
+
"nvidia-smi",
|
|
166
|
+
"--query-gpu=name,uuid,memory.total,memory.free",
|
|
167
|
+
"--format=csv,noheader,nounits",
|
|
168
|
+
],
|
|
169
|
+
[
|
|
170
|
+
"nvidia-smi",
|
|
171
|
+
"--query-gpu=name,memory.total,memory.free",
|
|
172
|
+
"--format=csv,noheader,nounits",
|
|
173
|
+
],
|
|
174
|
+
]
|
|
175
|
+
for argv in queries:
|
|
176
|
+
try:
|
|
177
|
+
out = subprocess.run(argv, capture_output=True, text=True, timeout=10)
|
|
178
|
+
except Exception: # noqa: BLE001 — no NVIDIA, CPU life
|
|
179
|
+
return []
|
|
180
|
+
if out.returncode != 0 or not out.stdout.strip():
|
|
181
|
+
continue
|
|
182
|
+
gpus: list[GPU] = []
|
|
183
|
+
try:
|
|
184
|
+
for idx, line in enumerate(out.stdout.strip().splitlines()):
|
|
185
|
+
parts = [s.strip() for s in line.split(",")]
|
|
186
|
+
if len(parts) == 4:
|
|
187
|
+
name, uuid, total, free = parts
|
|
188
|
+
elif len(parts) == 3:
|
|
189
|
+
name, total, free = parts
|
|
190
|
+
uuid = ""
|
|
191
|
+
else:
|
|
192
|
+
continue
|
|
193
|
+
gpus.append(GPU(name, int(total) / 1024, int(free) / 1024, uuid, idx))
|
|
194
|
+
except (ValueError, TypeError):
|
|
195
|
+
continue
|
|
196
|
+
return gpus
|
|
197
|
+
return []
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _read_ram() -> tuple[float | None, float | None]:
|
|
201
|
+
"""(total_GiB, disponible_GiB); (None, None) si no medible."""
|
|
202
|
+
total: float | None = None
|
|
203
|
+
avail: float | None = None
|
|
135
204
|
try:
|
|
136
205
|
with Path("/proc/meminfo").open() as fh:
|
|
137
206
|
for line in fh:
|
|
138
207
|
if line.startswith("MemTotal:"):
|
|
139
|
-
|
|
208
|
+
total = int(line.split()[1]) / 1024 / 1024
|
|
209
|
+
elif line.startswith("MemAvailable:"):
|
|
210
|
+
avail = int(line.split()[1]) / 1024 / 1024
|
|
211
|
+
if total is not None and avail is not None:
|
|
140
212
|
break
|
|
141
213
|
except OSError:
|
|
142
|
-
|
|
214
|
+
return None, None
|
|
215
|
+
return total, avail
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def read_machine(models_dir: Path | None = None, *, force_refresh: bool = False) -> Machine:
|
|
219
|
+
"""Snapshot unico de hardware por comando (H4.1).
|
|
220
|
+
|
|
221
|
+
Escanea una vez por proceso (un `nvidia-smi` + `/proc/meminfo` +
|
|
222
|
+
`shutil.disk_usage`) y devuelve SIEMPRE el mismo objeto mientras la
|
|
223
|
+
cache viva: todos los `judge()` del comando comparten este snapshot.
|
|
224
|
+
`force_refresh=True` o `invalidate_machine_cache()` rescanean.
|
|
225
|
+
"""
|
|
226
|
+
import os as _os
|
|
227
|
+
|
|
228
|
+
key = _cache_key(models_dir)
|
|
229
|
+
if not force_refresh and key in _MACHINE_CACHE:
|
|
230
|
+
return _MACHINE_CACHE[key]
|
|
231
|
+
gpus = _query_gpus()
|
|
232
|
+
ram_total, ram_avail = _read_ram()
|
|
233
|
+
cuda_env = _os.environ.get("CUDA_VISIBLE_DEVICES", "").strip() or None
|
|
143
234
|
disk = 0.0
|
|
144
235
|
try:
|
|
145
236
|
target = models_dir or Path.home()
|
|
@@ -148,7 +239,20 @@ def read_machine(models_dir: Path | None = None) -> Machine:
|
|
|
148
239
|
disk = shutil.disk_usage(target).free / 1024**3
|
|
149
240
|
except OSError:
|
|
150
241
|
pass
|
|
151
|
-
|
|
242
|
+
machine = Machine(
|
|
243
|
+
gpus=gpus,
|
|
244
|
+
ram_gb=ram_total if ram_total is not None else 0.0,
|
|
245
|
+
disk_free_gb=disk,
|
|
246
|
+
ram_total_gb=ram_total,
|
|
247
|
+
ram_available_gb=ram_avail,
|
|
248
|
+
cuda_visible_devices=cuda_env,
|
|
249
|
+
)
|
|
250
|
+
_MACHINE_CACHE[key] = machine
|
|
251
|
+
return machine
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
#: Alias de contrato H4.1: el snapshot ES la Machine compartida.
|
|
255
|
+
MachineSnapshot = Machine
|
|
152
256
|
|
|
153
257
|
|
|
154
258
|
@dataclass
|
|
@@ -165,7 +269,8 @@ def judge(plugin: str, backend: str, machine: Machine) -> Verdict:
|
|
|
165
269
|
# R09: motor sin datos NO es "cabe" — es desconocido. Setup no debe
|
|
166
270
|
# descargar GB a ciegas y status no debe prometer lo no probado.
|
|
167
271
|
return Verdict(
|
|
168
|
-
False,
|
|
272
|
+
False,
|
|
273
|
+
"❔",
|
|
169
274
|
f"{plugin}/{backend}: sin datos de requisitos, no se instala a ciegas.",
|
|
170
275
|
"Pruébalo a mano y comparte resultados para añadirlo a la tabla.",
|
|
171
276
|
)
|
|
@@ -174,7 +279,8 @@ def judge(plugin: str, backend: str, machine: Machine) -> Verdict:
|
|
|
174
279
|
gpu = machine.effective_gpu
|
|
175
280
|
if gpu is None:
|
|
176
281
|
return Verdict(
|
|
177
|
-
False,
|
|
282
|
+
False,
|
|
283
|
+
"❌",
|
|
178
284
|
f"{plugin}/{backend} necesita GPU con {req.min_vram_gb:.0f} GB y aquí no hay NVIDIA.",
|
|
179
285
|
f"Prueba {req.fallback}… en otra máquina, o quédate con voice/piper.",
|
|
180
286
|
)
|
|
@@ -182,24 +288,38 @@ def judge(plugin: str, backend: str, machine: Machine) -> Verdict:
|
|
|
182
288
|
free = gpu.free_gb
|
|
183
289
|
# 0 GB libres es un dato válido (GPU ocupada), no "desconocido": con
|
|
184
290
|
# memoria totalmente ocupada no hay veredicto verde.
|
|
185
|
-
busy = (
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
291
|
+
busy = (
|
|
292
|
+
f"Ahora mismo solo hay {free:.0f} GB libres en GPU ({gpu.name}); "
|
|
293
|
+
"cierra lo que la esté usando."
|
|
294
|
+
if free + 2 < req.min_vram_gb
|
|
295
|
+
else ""
|
|
296
|
+
)
|
|
297
|
+
disk_warn = (
|
|
298
|
+
f"Y ojo al disco: {machine.disk_free_gb:.0f} GB libres para {req.disk_gb:.0f} GB de pesos."
|
|
299
|
+
if machine.disk_free_gb and machine.disk_free_gb < req.disk_gb + 10
|
|
300
|
+
else ""
|
|
301
|
+
)
|
|
302
|
+
ram_warn = (
|
|
303
|
+
f"RAM justa: {machine.ram_gb:.0f} GB (el offload tira de RAM)."
|
|
304
|
+
if machine.ram_gb and machine.ram_gb < 8
|
|
305
|
+
else ""
|
|
306
|
+
)
|
|
193
307
|
if vram >= req.min_vram_gb + 4:
|
|
194
308
|
if busy or disk_warn or ram_warn:
|
|
195
309
|
detail = " ".join(x for x in (busy, disk_warn, ram_warn) if x)
|
|
196
|
-
return Verdict(
|
|
310
|
+
return Verdict(
|
|
311
|
+
True,
|
|
312
|
+
"⚠️",
|
|
313
|
+
f"{plugin}/{backend}: cabe con {vram:.0f} GB… si liberas. {req.blurb}",
|
|
314
|
+
detail,
|
|
315
|
+
)
|
|
197
316
|
return Verdict(True, "✅", f"{plugin}/{backend}: sobrado con {vram:.0f} GB. {req.blurb}")
|
|
198
317
|
if vram >= req.min_vram_gb:
|
|
199
318
|
detail = " ".join(x for x in (req.risk, busy, disk_warn, ram_warn) if x)
|
|
200
319
|
return Verdict(True, "⚠️", f"{plugin}/{backend}: cabe justo con {vram:.0f} GB.", detail)
|
|
201
320
|
return Verdict(
|
|
202
|
-
False,
|
|
321
|
+
False,
|
|
322
|
+
"❌",
|
|
203
323
|
f"{plugin}/{backend} pide {req.min_vram_gb:.0f} GB y tienes {vram:.0f} GB.",
|
|
204
324
|
f"Vete a {req.fallback}." if req.fallback else "Ese motor no es para este PC.",
|
|
205
325
|
)
|