atril 0.7.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.
atril/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """atril: gestiona una instalación local de DeepTutor y sus temarios."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("atril")
atril/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Permite ejecutar el paquete con ``python -m atril``."""
2
+
3
+ from atril.main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
atril/backup.py ADDED
@@ -0,0 +1,315 @@
1
+ """Copias de seguridad de la estación con restic.
2
+
3
+ Se copian ``atril.toml``, ``data/`` y ``temarios/`` (no ``app/``, que se
4
+ reconstruye con ``atril instalar``), más un *git bundle* de cada repo (el git de
5
+ cada temario y ``[backup] repos_git``) dentro de ``data/atril/bundles/``.
6
+
7
+ La contraseña de restic viene del entorno (``RESTIC_PASSWORD``,
8
+ ``RESTIC_PASSWORD_FILE`` o ``RESTIC_PASSWORD_COMMAND``), nunca de ``atril.toml``.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from atril.config.station import BackupTarget, Station
21
+
22
+ PASSWORD_VARIABLES = (
23
+ "RESTIC_PASSWORD",
24
+ "RESTIC_PASSWORD_FILE",
25
+ "RESTIC_PASSWORD_COMMAND",
26
+ )
27
+ """Variables con las que restic obtiene la contraseña del repositorio."""
28
+
29
+ TAG = "atril"
30
+ """Etiqueta de los snapshots de atril."""
31
+
32
+ EXCLUDES = ("*.part", "*.nuevo")
33
+ """Descargas a medias de atril: no se copian."""
34
+
35
+ EXIT_NO_REPOSITORY = 10
36
+ """Código de restic (>= 0.17) cuando el repositorio no existe."""
37
+
38
+ EXIT_WRONG_PASSWORD = 12
39
+ """Código de restic cuando la contraseña no abre el repositorio."""
40
+
41
+ REMOTE_PREFIXES = ("rclone:", "sftp:", "s3:", "b2:", "azure:", "gs:", "rest:", "swift:")
42
+ """Repositorios que no son una carpeta local."""
43
+
44
+
45
+ class BackupError(Exception):
46
+ """Fallo de restic o de la preparación del backup; mensaje en español."""
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class Summary:
51
+ """Resumen de un ``restic backup``."""
52
+
53
+ snapshot: str
54
+ files_new: int
55
+ files_changed: int
56
+ data_added: int
57
+ seconds: float
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class Snapshot:
62
+ """Snapshot de un repositorio."""
63
+
64
+ short_id: str
65
+ time: str
66
+ paths: tuple[str, ...]
67
+ hostname: str
68
+
69
+
70
+ Progress = Callable[[float, int, int], None]
71
+ """Avance: fracción completada, bytes hechos y bytes totales."""
72
+
73
+
74
+ def is_local(target: BackupTarget) -> bool:
75
+ """El repositorio es una carpeta (p. ej. un disco externo)."""
76
+ return not target.repo.startswith(REMOTE_PREFIXES)
77
+
78
+
79
+ def is_reachable(target: BackupTarget) -> bool:
80
+ """Un repositorio local está accesible si existe su carpeta padre (disco conectado).
81
+
82
+ Los remotos se dan por accesibles: restic informará si no lo son.
83
+ """
84
+ return not is_local(target) or Path(target.repo).parent.is_dir()
85
+
86
+
87
+ def password_configured(env: dict[str, str] | None = None) -> bool:
88
+ """Hay alguna forma de contraseña para restic en el entorno."""
89
+ environ = os.environ if env is None else env
90
+ return any(environ.get(name) for name in PASSWORD_VARIABLES)
91
+
92
+
93
+ def backup_paths(station: Station) -> list[Path]:
94
+ """Lo que se copia de la estación (solo lo que existe)."""
95
+ paths = [station.root / "atril.toml", station.data_dir, station.syllabi_dir]
96
+ return [path for path in paths if path.exists()]
97
+
98
+
99
+ def bundles_dir(station: Station) -> Path:
100
+ """Carpeta de los git bundle (dentro de ``data/``, así entran en el snapshot)."""
101
+ return station.data_dir / "atril" / "bundles"
102
+
103
+
104
+ def git_repos(station: Station) -> dict[str, Path]:
105
+ """Repos de git a empaquetar: cada temario con ``.git`` y ``repos_git``."""
106
+ repos: dict[str, Path] = {}
107
+ if station.syllabi_dir.is_dir():
108
+ for folder in sorted(station.syllabi_dir.iterdir()):
109
+ if (folder / ".git").exists():
110
+ repos[f"temario-{folder.name}"] = folder
111
+ for repo in station.backup.git_repos:
112
+ repos[f"repo-{repo.name}"] = repo
113
+ return repos
114
+
115
+
116
+ class Restic:
117
+ """Llamadas a restic para un repositorio."""
118
+
119
+ def __init__(self, target: BackupTarget, executable: str = "restic") -> None:
120
+ """Prepara las llamadas al repositorio de ``target``."""
121
+ self.target = target
122
+ self.executable = executable
123
+
124
+ def _command(self, *args: str) -> list[str]:
125
+ """Comando completo de restic con el repositorio."""
126
+ return [self.executable, "-r", self.target.repo, *args]
127
+
128
+ def _run(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
129
+ """Ejecuta restic capturando la salida."""
130
+ result = subprocess.run(
131
+ self._command(*args),
132
+ capture_output=True,
133
+ text=True,
134
+ encoding="utf-8",
135
+ errors="replace",
136
+ check=False,
137
+ )
138
+ if check and result.returncode != 0:
139
+ raise BackupError(self._explain(result))
140
+ return result
141
+
142
+ def _explain(self, result: subprocess.CompletedProcess[str]) -> str:
143
+ """Mensaje de error de restic, con pista para los casos conocidos."""
144
+ if result.returncode == EXIT_WRONG_PASSWORD:
145
+ return f"{self.target.name}: la contraseña no abre el repositorio."
146
+ if result.returncode == EXIT_NO_REPOSITORY:
147
+ return f"{self.target.name}: el repositorio no existe ({self.target.repo})."
148
+ lines = [line for line in result.stderr.strip().splitlines() if line.strip()]
149
+ detail = lines[-1] if lines else f"código {result.returncode}"
150
+ return f"{self.target.name}: {detail}"
151
+
152
+ def ensure_repository(self) -> bool:
153
+ """Inicializa el repositorio si no existe.
154
+
155
+ Returns:
156
+ ``True`` si se ha creado ahora.
157
+ """
158
+ result = self._run("cat", "config", check=False)
159
+ if result.returncode == 0:
160
+ return False
161
+ if result.returncode != EXIT_NO_REPOSITORY:
162
+ raise BackupError(self._explain(result))
163
+ self._run("init")
164
+ return True
165
+
166
+ def backup(self, paths: list[Path], progress: Progress | None = None) -> Summary:
167
+ """``restic backup`` con avance; devuelve el resumen."""
168
+ args = ["backup", "--json", "--tag", TAG]
169
+ for pattern in EXCLUDES:
170
+ args += ["--exclude", pattern]
171
+ command = self._command(*args, *(str(path) for path in paths))
172
+ summary: dict[str, Any] = {}
173
+ errors: list[str] = []
174
+ process = subprocess.Popen(
175
+ command,
176
+ stdout=subprocess.PIPE,
177
+ stderr=subprocess.PIPE,
178
+ text=True,
179
+ encoding="utf-8",
180
+ errors="replace",
181
+ )
182
+ for line in process.stdout or ():
183
+ try:
184
+ message = json.loads(line)
185
+ except ValueError:
186
+ continue
187
+ kind = message.get("message_type")
188
+ if kind == "status" and progress:
189
+ progress(
190
+ float(message.get("percent_done", 0)),
191
+ int(message.get("bytes_done", 0)),
192
+ int(message.get("total_bytes", 0)),
193
+ )
194
+ elif kind == "summary":
195
+ summary = message
196
+ elif kind == "error":
197
+ errors.append(str(message.get("error", {}).get("message", message)))
198
+ stderr = process.stderr.read() if process.stderr else ""
199
+ process.wait()
200
+ # Código 3: snapshot hecho, pero algún fichero no se pudo leer.
201
+ if process.returncode not in {0, 3} or not summary:
202
+ detail = (errors or stderr.strip().splitlines() or ["?"])[-1]
203
+ raise BackupError(f"{self.target.name}: {detail}")
204
+ return Summary(
205
+ snapshot=str(summary.get("snapshot_id", ""))[:8],
206
+ files_new=int(summary.get("files_new", 0)),
207
+ files_changed=int(summary.get("files_changed", 0)),
208
+ data_added=int(summary.get("data_added", 0)),
209
+ seconds=float(summary.get("total_duration", 0)),
210
+ )
211
+
212
+ def forget(self, daily: int, weekly: int, monthly: int) -> None:
213
+ """Aplica la retención y borra los datos que ya no usa ningún snapshot."""
214
+ self._run(
215
+ "forget",
216
+ "--tag",
217
+ TAG,
218
+ "--keep-daily",
219
+ str(daily),
220
+ "--keep-weekly",
221
+ str(weekly),
222
+ "--keep-monthly",
223
+ str(monthly),
224
+ "--prune",
225
+ )
226
+
227
+ def snapshots(self) -> list[Snapshot]:
228
+ """Snapshots de atril, del más antiguo al más reciente."""
229
+ data = json.loads(self._run("snapshots", "--json", "--tag", TAG).stdout or "[]")
230
+ return [
231
+ Snapshot(
232
+ short_id=str(item.get("short_id", item.get("id", ""))[:8]),
233
+ time=str(item.get("time", ""))[:19],
234
+ paths=tuple(item.get("paths", [])),
235
+ hostname=str(item.get("hostname", "")),
236
+ )
237
+ for item in data or []
238
+ ]
239
+
240
+ def check(self, read_data: str | None = None) -> None:
241
+ """``restic check``; con ``read_data`` (p. ej. ``5%``) lee esa parte."""
242
+ args = ["check"]
243
+ if read_data:
244
+ args.append(f"--read-data-subset={read_data}")
245
+ self._run(*args)
246
+
247
+ def restore(self, snapshot: str, target: Path) -> None:
248
+ """Restaura un snapshot en ``target`` (con la estructura de la estación)."""
249
+ self._run("restore", snapshot, "--target", str(target))
250
+
251
+
252
+ def make_bundles(station: Station, git: str = "git") -> list[tuple[str, str]]:
253
+ """Crea un git bundle de cada repo.
254
+
255
+ Returns:
256
+ Pares (nombre, resultado) para informar; un repo sin commits o inaccesible
257
+ se anota y se sigue.
258
+ """
259
+ folder = bundles_dir(station)
260
+ folder.mkdir(parents=True, exist_ok=True)
261
+ results = []
262
+ for name, repo in git_repos(station).items():
263
+ target = folder / f"{name}.bundle"
264
+ result = subprocess.run(
265
+ [git, "-C", str(repo), "bundle", "create", str(target), "--all"],
266
+ capture_output=True,
267
+ text=True,
268
+ encoding="utf-8",
269
+ errors="replace",
270
+ check=False,
271
+ )
272
+ if result.returncode == 0:
273
+ results.append((name, "ok"))
274
+ else:
275
+ lines = result.stderr.strip().splitlines()
276
+ results.append((name, lines[-1] if lines else "error"))
277
+ return results
278
+
279
+
280
+ def find_restored_root(target: Path) -> Path | None:
281
+ """Carpeta restaurada con la estructura de una estación (``atril.toml`` o ``data``).
282
+
283
+ restic deja la estructura relativa al ancestro común de las rutas copiadas,
284
+ pero se busca a algo de profundidad por si cambia.
285
+ """
286
+ candidates = [target, *sorted(p for p in target.rglob("*") if p.is_dir())]
287
+ for folder in candidates:
288
+ looks_like_station = (folder / "atril.toml").is_file() or (
289
+ folder / "data"
290
+ ).is_dir()
291
+ if looks_like_station and len(folder.relative_to(target).parts) <= 6:
292
+ return folder
293
+ return None
294
+
295
+
296
+ def swap_in(station: Station, restored: Path, keep: Path) -> list[str]:
297
+ """Sustituye ``atril.toml``, ``data/`` y ``temarios/`` por los restaurados.
298
+
299
+ Lo que había se mueve a ``keep`` (no se borra nada).
300
+
301
+ Returns:
302
+ Los elementos sustituidos.
303
+ """
304
+ keep.mkdir(parents=True, exist_ok=False)
305
+ replaced = []
306
+ for name in ("atril.toml", "data", "temarios"):
307
+ source = restored / name
308
+ if not source.exists():
309
+ continue
310
+ current = station.root / name
311
+ if current.exists():
312
+ shutil.move(current, keep / name)
313
+ shutil.move(source, current)
314
+ replaced.append(name)
315
+ return replaced
atril/checks.py ADDED
@@ -0,0 +1,41 @@
1
+ """Resultados de comprobaciones que los comandos muestran al usuario."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+
6
+
7
+ class Level(StrEnum):
8
+ """Gravedad de una comprobación."""
9
+
10
+ OK = "ok"
11
+ INFO = "info"
12
+ WARNING = "aviso"
13
+ ERROR = "error"
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Finding:
18
+ """Resultado de una comprobación, con su mensaje en español."""
19
+
20
+ level: Level
21
+ message: str
22
+
23
+
24
+ def ok(message: str) -> Finding:
25
+ """Comprobación superada."""
26
+ return Finding(Level.OK, message)
27
+
28
+
29
+ def info(message: str) -> Finding:
30
+ """Dato informativo, sin valoración."""
31
+ return Finding(Level.INFO, message)
32
+
33
+
34
+ def warning(message: str) -> Finding:
35
+ """Situación que conviene revisar pero no impide continuar."""
36
+ return Finding(Level.WARNING, message)
37
+
38
+
39
+ def error(message: str) -> Finding:
40
+ """Situación que impide funcionar correctamente."""
41
+ return Finding(Level.ERROR, message)
@@ -0,0 +1 @@
1
+ """Comandos de la CLI, agrupados por área; ``atril.main`` los registra."""
@@ -0,0 +1,115 @@
1
+ """Utilidades compartidas por los comandos: estación, consola Rich y errores.
2
+
3
+ Toda la salida pasa por :data:`console` (y :data:`err_console` para errores). El
4
+ texto que viene de fuera (rutas, mensajes de configuración con ``[[fuente]]``,
5
+ líneas de comandos) se escapa para que Rich no lo interprete como markup.
6
+ """
7
+
8
+ from collections.abc import Iterable
9
+ from pathlib import Path
10
+ from typing import NoReturn
11
+
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.markup import escape
15
+
16
+ from atril.checks import Finding, Level
17
+ from atril.config import (
18
+ ConfigError,
19
+ Station,
20
+ Syllabus,
21
+ find_station,
22
+ load_station,
23
+ load_syllabi,
24
+ load_syllabus,
25
+ )
26
+ from atril.config.syllabus import KnowledgeBase
27
+
28
+ console = Console(soft_wrap=True)
29
+ """Consola de salida normal; ``soft_wrap`` evita cortar rutas y mensajes largos."""
30
+
31
+ err_console = Console(stderr=True, soft_wrap=True)
32
+ """Consola de errores."""
33
+
34
+ _STYLE = {
35
+ Level.OK: ("✓", "green"),
36
+ Level.INFO: ("·", "dim"),
37
+ Level.WARNING: ("!", "yellow"),
38
+ Level.ERROR: ("✗", "bold red"),
39
+ }
40
+
41
+
42
+ def fail(message: str) -> NoReturn:
43
+ """Muestra ``message`` como error y termina con código 1."""
44
+ err_console.print(f"[bold red]Error:[/] {escape(message)}")
45
+ raise typer.Exit(1)
46
+
47
+
48
+ def station_root(ctx: typer.Context) -> Path:
49
+ """Raíz de la estación: ``--estacion`` o búsqueda desde la carpeta actual."""
50
+ explicit: Path | None = ctx.obj
51
+ return explicit if explicit is not None else find_station(Path.cwd())
52
+
53
+
54
+ def get_station(ctx: typer.Context) -> Station:
55
+ """Carga ``atril.toml``; termina con error si falta o no es válido."""
56
+ try:
57
+ return load_station(station_root(ctx))
58
+ except ConfigError as exc:
59
+ fail(str(exc))
60
+
61
+
62
+ def get_syllabus(ctx: typer.Context, syllabus_id: str) -> Syllabus:
63
+ """Carga un temario por id; termina con error si no existe o no es válido."""
64
+ station = get_station(ctx)
65
+ root = station.syllabi_dir / syllabus_id
66
+ if not (root / "temario.toml").is_file():
67
+ found = sorted(
68
+ p.parent.name for p in station.syllabi_dir.glob("*/temario.toml")
69
+ )
70
+ names = ", ".join(found) or "ninguno"
71
+ fail(f"no existe el temario {syllabus_id!r} (disponibles: {names}).")
72
+ try:
73
+ return load_syllabus(root)
74
+ except ConfigError as exc:
75
+ fail(str(exc))
76
+
77
+
78
+ def find_kb(ctx: typer.Context, kb_id: str) -> tuple[Syllabus, KnowledgeBase]:
79
+ """Temario y KB a partir del id de KB (único en la estación)."""
80
+ station = get_station(ctx)
81
+ try:
82
+ syllabi, _ = load_syllabi(station.syllabi_dir)
83
+ except ConfigError as exc:
84
+ fail(str(exc))
85
+ for syllabus in syllabi:
86
+ for kb in syllabus.knowledge_bases:
87
+ if kb.id == kb_id:
88
+ return syllabus, kb
89
+ known = ", ".join(kb.id for s in syllabi for kb in s.knowledge_bases) or "ninguna"
90
+ fail(f"no existe la KB {kb_id!r} en ningún temario (disponibles: {known}).")
91
+
92
+
93
+ def show(findings: Iterable[Finding], title: str | None = None) -> bool:
94
+ """Muestra las comprobaciones, una por línea, con símbolo y color por nivel.
95
+
96
+ Args:
97
+ findings: Comprobaciones, en el orden en que se muestran.
98
+ title: Título de la sección, si lo hay.
99
+
100
+ Returns:
101
+ ``True`` si ninguna es un error.
102
+ """
103
+ if title:
104
+ console.rule(f"[bold]{escape(title)}[/]", align="left")
105
+ clean = True
106
+ for finding in findings:
107
+ symbol, style = _STYLE[finding.level]
108
+ console.print(f" [{style}]{symbol}[/] {escape(finding.message)}")
109
+ clean = clean and finding.level is not Level.ERROR
110
+ return clean
111
+
112
+
113
+ def success(message: str) -> None:
114
+ """Mensaje final de éxito."""
115
+ console.print(f"[bold green]{escape(message)}[/]")