ezshell 0.2.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.
src/core/updater.py ADDED
@@ -0,0 +1,428 @@
1
+ """Mise à jour d'ezShell : versions, nouveautés, rappel quotidien, installation.
2
+
3
+ Principes (docs/ezShell v2) :
4
+
5
+ * ``ezsh --update`` montre d'abord les nouveautés, demande confirmation (réponse par défaut : non),
6
+ et ne lance l'installation qu'après un « oui » (ou ``--yes``).
7
+ * Le rappel de nouvelle version n'ajoute AUCUNE requête réseau : il se nourrit de l'en-tête
8
+ ``x-ezsh-latest`` que le serveur renvoie déjà. Une ligne discrète, au plus une fois par jour,
9
+ coupée par ``EZSH_NO_UPDATE_CHECK=1``. Rien n'est jamais fait à l'ouverture du terminal.
10
+ * Stdlib uniquement : pas d'import lourd, le démarrage reste rapide.
11
+ * Tout texte venant du réseau est nettoyé (ANSI, caractères de contrôle) avant affichage.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ import urllib.error
23
+ import urllib.request
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import Callable, Dict, List, Optional, Sequence
27
+
28
+ from src import __version__
29
+ from src.core.sanitize import strip_control
30
+
31
+ RELEASES_URL = "https://github.com/Waddle-io/ezShell/releases"
32
+ PYPI_JSON_URL = "https://pypi.org/pypi/ezshell/json"
33
+ NOTICE_INTERVAL_S = 24 * 3600
34
+ MAX_NOTE_LINES = 10
35
+ FETCH_TIMEOUT_S = 6.0
36
+
37
+ _YES = {"o", "oui", "y", "yes"}
38
+
39
+ T = {
40
+ "fr": {
41
+ "offline": "Impossible de joindre le service pour vérifier la version. Réessaie plus tard.",
42
+ "uptodate": "Déjà à jour ({v}).",
43
+ "header": "ezShell {old} → {new}",
44
+ "important": "⚠ Important",
45
+ "added": "Nouveautés",
46
+ "fixed": "Corrections",
47
+ "more": "… et {n} autres : {url}",
48
+ "nonotes": "Pas de détail disponible : {url}",
49
+ "ask": "Mettre à jour maintenant ? [o/N] ",
50
+ "cancelled": "Mise à jour annulée.",
51
+ "editable": "Installation de développement détectée : mets à jour avec `git pull`, rien n'a été modifié.",
52
+ "need_confirm": "Aucun terminal interactif : rien n'a été installé. Relance avec `ezsh --update --yes`.",
53
+ "manual": "Pour mettre à jour, ouvre un NOUVEAU terminal et lance :",
54
+ "manual_win": "Sous Windows, ezsh ne peut pas se remplacer lui-même pendant qu'il tourne.",
55
+ "running": "Mise à jour en cours…",
56
+ "failed": "La mise à jour a échoué. Commande à lancer à la main :",
57
+ "notpublished": "La version {v} n'est pas encore publiée sur PyPI. Réessaie plus tard.",
58
+ "done": "Mis à jour : {old} → {new}. Ouvre un nouveau terminal pour en profiter.",
59
+ "done_unknown": "Mise à jour terminée. Ouvre un nouveau terminal pour en profiter.",
60
+ "notice": "Nouvelle version d'ezShell disponible ({old} → {new}) : lance `ezsh --update`.",
61
+ "version_notice": "(nouvelle version disponible : {new} — `ezsh --update`)",
62
+ },
63
+ "en": {
64
+ "offline": "Cannot reach the service to check the version. Try again later.",
65
+ "uptodate": "Already up to date ({v}).",
66
+ "header": "ezShell {old} → {new}",
67
+ "important": "⚠ Important",
68
+ "added": "What's new",
69
+ "fixed": "Fixes",
70
+ "more": "… and {n} more: {url}",
71
+ "nonotes": "No details available: {url}",
72
+ "ask": "Update now? [y/N] ",
73
+ "cancelled": "Update cancelled.",
74
+ "editable": "Development install detected: update with `git pull`, nothing was changed.",
75
+ "need_confirm": "No interactive terminal: nothing was installed. Re-run with `ezsh --update --yes`.",
76
+ "manual": "To update, open a NEW terminal and run:",
77
+ "manual_win": "On Windows, ezsh cannot replace itself while it is running.",
78
+ "running": "Updating…",
79
+ "failed": "The update failed. Command to run by hand:",
80
+ "notpublished": "Version {v} is not on PyPI yet. Try again later.",
81
+ "done": "Updated: {old} → {new}. Open a new terminal to use it.",
82
+ "done_unknown": "Update finished. Open a new terminal to use it.",
83
+ "notice": "A new ezShell version is available ({old} → {new}): run `ezsh --update`.",
84
+ "version_notice": "(new version available: {new} — `ezsh --update`)",
85
+ },
86
+ }
87
+
88
+
89
+ def lang() -> str:
90
+ forced = os.environ.get("EZSHELL_LANG")
91
+ if forced:
92
+ return "fr" if forced.lower().startswith("fr") else "en"
93
+ return "fr" if (os.environ.get("LANG") or "").lower().startswith("fr") else "en"
94
+
95
+
96
+ # --------------------------------------------------------------------------- versions
97
+ def installed_version() -> str:
98
+ return __version__
99
+
100
+
101
+ def parse_version(v: str) -> tuple:
102
+ m = re.match(r"^\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?", v or "")
103
+ return (int(m.group(1)), int(m.group(2) or 0), int(m.group(3) or 0)) if m else (0, 0, 0)
104
+
105
+
106
+ def compare_versions(a: str, b: str) -> int:
107
+ pa, pb = parse_version(a), parse_version(b)
108
+ return (pa > pb) - (pa < pb)
109
+
110
+
111
+ def _is_version(v: object) -> bool:
112
+ return isinstance(v, str) and re.fullmatch(r"\d+\.\d+\.\d+", v.strip()) is not None
113
+
114
+
115
+ # --------------------------------------------------------------------------- état local (rappel)
116
+ def state_path() -> Path:
117
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
118
+ return Path(base) / "ezshell" / "update.json"
119
+
120
+
121
+ def load_state(path: Optional[Path] = None) -> dict:
122
+ try:
123
+ data = json.loads((path or state_path()).read_text(encoding="utf-8"))
124
+ return data if isinstance(data, dict) else {}
125
+ except (OSError, json.JSONDecodeError):
126
+ return {}
127
+
128
+
129
+ def save_state(data: dict, path: Optional[Path] = None) -> None:
130
+ """Meilleur effort : un dossier en lecture seule ne doit jamais gêner l'outil."""
131
+ target = path or state_path()
132
+ try:
133
+ target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
134
+ tmp = target.with_suffix(".tmp")
135
+ tmp.write_text(json.dumps(data), encoding="utf-8")
136
+ os.replace(tmp, target)
137
+ except OSError:
138
+ pass
139
+
140
+
141
+ def checks_disabled() -> bool:
142
+ return os.environ.get("EZSH_NO_UPDATE_CHECK", "").strip().lower() in {"1", "true", "yes", "on"}
143
+
144
+
145
+ def note_latest(latest: Optional[str], path: Optional[Path] = None) -> None:
146
+ """Mémorise la dernière version annoncée par le serveur (en-tête `x-ezsh-latest`)."""
147
+ if not _is_version(latest):
148
+ return
149
+ state = load_state(path)
150
+ if state.get("latest") != latest.strip():
151
+ state["latest"] = latest.strip()
152
+ save_state(state, path)
153
+
154
+
155
+ def cached_latest(path: Optional[Path] = None) -> Optional[str]:
156
+ latest = load_state(path).get("latest")
157
+ return latest if _is_version(latest) else None
158
+
159
+
160
+ def take_notice(
161
+ *, now: Optional[float] = None, path: Optional[Path] = None, installed: Optional[str] = None
162
+ ) -> Optional[str]:
163
+ """Ligne de rappel à afficher, ou None. Au plus une fois par jour ; consomme le créneau."""
164
+ if checks_disabled():
165
+ return None
166
+ installed = installed or installed_version()
167
+ state = load_state(path)
168
+ latest = state.get("latest")
169
+ if not _is_version(latest) or compare_versions(latest, installed) <= 0:
170
+ return None
171
+ now = time.time() if now is None else now
172
+ last = state.get("last_notice")
173
+ if isinstance(last, (int, float)) and 0 <= now - last < NOTICE_INTERVAL_S:
174
+ return None
175
+ state["last_notice"] = now
176
+ save_state(state, path)
177
+ return T[lang()]["notice"].format(old=installed, new=latest)
178
+
179
+
180
+ def version_hint(path: Optional[Path] = None) -> Optional[str]:
181
+ """Pour `ezsh --version` : lit seulement le cache, sans réseau."""
182
+ latest = cached_latest(path)
183
+ if latest and compare_versions(latest, installed_version()) > 0 and not checks_disabled():
184
+ return T[lang()]["version_notice"].format(new=latest)
185
+ return None
186
+
187
+
188
+ # --------------------------------------------------------------------------- réseau
189
+ Opener = Callable[..., object]
190
+
191
+
192
+ def _get_json(url: str, timeout: float, opener: Opener) -> Optional[dict]:
193
+ try:
194
+ request = urllib.request.Request(url, headers={"user-agent": f"ezshell/{installed_version()}"})
195
+ with opener(request, timeout=timeout) as resp: # type: ignore[attr-defined]
196
+ data = json.loads(resp.read().decode("utf-8", errors="replace"))
197
+ return data if isinstance(data, dict) else None
198
+ except (urllib.error.URLError, OSError, ValueError, TimeoutError):
199
+ return None
200
+
201
+
202
+ def _clean(text: object, limit: int = 300) -> str:
203
+ return strip_control(str(text), keep_newlines=False).strip()[:limit]
204
+
205
+
206
+ def fetch_release_info(
207
+ base_url: str, installed: str, *, timeout: float = FETCH_TIMEOUT_S, opener: Opener = urllib.request.urlopen
208
+ ) -> Optional[dict]:
209
+ """`GET /api/v1/version?from=<installée>` → {latest, min_supported, releases:[…]} nettoyé, ou None."""
210
+ data = _get_json(f"{base_url.rstrip('/')}/api/v1/version?from={installed}", timeout, opener)
211
+ if not data or not _is_version(data.get("latest")):
212
+ return None
213
+ releases = []
214
+ for r in data.get("releases") or []:
215
+ if not isinstance(r, dict) or not _is_version(r.get("version")):
216
+ continue
217
+ releases.append(
218
+ {
219
+ "version": r["version"].strip(),
220
+ "important": [_clean(x) for x in (r.get("important") or []) if isinstance(x, str)][:10],
221
+ "added": [_clean(x) for x in (r.get("added") or []) if isinstance(x, str)][:20],
222
+ "fixed": [_clean(x) for x in (r.get("fixed") or []) if isinstance(x, str)][:20],
223
+ }
224
+ )
225
+ return {"latest": data["latest"].strip(), "min_supported": str(data.get("min_supported", "")), "releases": releases}
226
+
227
+
228
+ def pypi_latest(*, timeout: float = FETCH_TIMEOUT_S, opener: Opener = urllib.request.urlopen) -> Optional[str]:
229
+ data = _get_json(PYPI_JSON_URL, timeout, opener)
230
+ version = (data or {}).get("info", {}).get("version") if isinstance((data or {}).get("info"), dict) else None
231
+ return version if _is_version(version) else None
232
+
233
+
234
+ # --------------------------------------------------------------------------- nouveautés
235
+ def collect_notes(releases: Sequence[dict]) -> Dict[str, List[str]]:
236
+ """Notes cumulées (versions sautées), sans doublon ; l'ordre des versions est conservé."""
237
+ merged: Dict[str, List[str]] = {"important": [], "added": [], "fixed": []}
238
+ for r in sorted(releases, key=lambda x: parse_version(x["version"])):
239
+ for key in merged:
240
+ for line in r.get(key, []):
241
+ if line and line not in merged[key]:
242
+ merged[key].append(line)
243
+ return merged
244
+
245
+
246
+ def format_notes(old: str, new: str, releases: Sequence[dict], language: str) -> List[str]:
247
+ t = T[language]
248
+ notes = collect_notes(releases)
249
+ lines = [t["header"].format(old=old, new=new), ""]
250
+ total = sum(len(v) for v in notes.values())
251
+ if not total:
252
+ lines.append(t["nonotes"].format(url=RELEASES_URL))
253
+ return lines
254
+ budget = MAX_NOTE_LINES
255
+ for key in ("important", "added", "fixed"):
256
+ if not notes[key] or budget <= 0:
257
+ continue
258
+ lines.append(t[key])
259
+ for item in notes[key][:budget]:
260
+ lines.append(f" • {item}")
261
+ budget -= min(budget, len(notes[key]))
262
+ lines.append("")
263
+ shown = MAX_NOTE_LINES - budget
264
+ if total > shown:
265
+ lines.append(t["more"].format(n=total - shown, url=RELEASES_URL))
266
+ while lines and lines[-1] == "":
267
+ lines.pop()
268
+ return lines
269
+
270
+
271
+ # --------------------------------------------------------------------------- installation
272
+ @dataclass
273
+ class InstallPlan:
274
+ method: str # pipx | pip | script | editable
275
+ commands: List[List[str]] = field(default_factory=list)
276
+ needs_pypi: bool = False
277
+
278
+
279
+ def _direct_url() -> Optional[dict]:
280
+ try:
281
+ from importlib.metadata import distribution
282
+
283
+ raw = distribution("ezshell").read_text("direct_url.json")
284
+ data = json.loads(raw) if raw else None
285
+ return data if isinstance(data, dict) else None
286
+ except Exception:
287
+ return None
288
+
289
+
290
+ def _script_install_dir() -> Path:
291
+ return Path(os.environ.get("EZSHELL_INSTALL_DIR") or Path.home() / ".local" / "share" / "ezshell")
292
+
293
+
294
+ def _file_url_path(url: str) -> Optional[Path]:
295
+ if not url.startswith("file://"):
296
+ return None
297
+ from urllib.parse import unquote, urlparse
298
+
299
+ path = unquote(urlparse(url).path)
300
+ if os.name == "nt" and re.match(r"^/[A-Za-z]:", path):
301
+ path = path[1:]
302
+ return Path(path)
303
+
304
+
305
+ def detect_install(
306
+ *, direct_url: Optional[dict] = None, prefix: Optional[str] = None, executable: Optional[str] = None
307
+ ) -> InstallPlan:
308
+ du = _direct_url() if direct_url is None else direct_url
309
+ prefix = prefix or sys.prefix
310
+ exe = executable or sys.executable
311
+ parts = {p.lower() for p in Path(prefix).parts}
312
+
313
+ if du and (du.get("dir_info") or {}).get("editable"):
314
+ root = _file_url_path(str(du.get("url", "")))
315
+ if root is not None and root.resolve() == _script_install_dir().resolve() and (root / ".git").exists():
316
+ # install.sh : clone git + venv dédié, installé en mode éditable.
317
+ return InstallPlan(
318
+ "script",
319
+ [["git", "-C", str(root), "pull", "--ff-only"], [exe, "-m", "pip", "install", "--quiet", "-e", str(root)]],
320
+ )
321
+ return InstallPlan("editable")
322
+
323
+ if "pipx" in parts and "venvs" in parts:
324
+ if du and du.get("vcs_info"): # installé depuis l'URL git : on bascule sur PyPI
325
+ return InstallPlan("pipx", [["pipx", "install", "--force", "ezshell"]], needs_pypi=True)
326
+ return InstallPlan("pipx", [["pipx", "upgrade", "ezshell"]], needs_pypi=True)
327
+
328
+ return InstallPlan("pip", [[exe, "-m", "pip", "install", "--upgrade", "ezshell"]], needs_pypi=True)
329
+
330
+
331
+ def _printable(cmd: Sequence[str]) -> str:
332
+ return " ".join(f'"{c}"' if " " in c else c for c in cmd)
333
+
334
+
335
+ def _version_after(exe: str, runner: Callable[..., object]) -> Optional[str]:
336
+ try:
337
+ out = runner( # type: ignore[operator]
338
+ [exe, "-c", "from src import __version__; print(__version__)"], capture_output=True, text=True, timeout=20
339
+ )
340
+ text = (getattr(out, "stdout", "") or "").strip()
341
+ return text if _is_version(text) else None
342
+ except Exception:
343
+ return None
344
+
345
+
346
+ def run_update(
347
+ *,
348
+ yes: bool = False,
349
+ base_url: Optional[str] = None,
350
+ out: Callable[[str], None] = print,
351
+ input_fn: Callable[[str], str] = input,
352
+ interactive: Optional[bool] = None,
353
+ fetch: Callable[..., Optional[dict]] = fetch_release_info,
354
+ pypi: Callable[[], Optional[str]] = pypi_latest,
355
+ runner: Callable[..., object] = subprocess.run,
356
+ plan: Optional[InstallPlan] = None,
357
+ state: Optional[Path] = None,
358
+ windows: Optional[bool] = None,
359
+ ) -> int:
360
+ """0 : à jour / mis à jour / annulé · 1 : échec · 2 : confirmation impossible (non interactif)."""
361
+ t = T[lang()]
362
+ if base_url is None:
363
+ from src.core.proxy_client import api_url
364
+
365
+ base_url = api_url()
366
+ installed = installed_version()
367
+ if interactive is None:
368
+ interactive = sys.stdin.isatty() and sys.stdout.isatty()
369
+ windows = (os.name == "nt") if windows is None else windows
370
+
371
+ info = fetch(base_url, installed)
372
+ latest = info["latest"] if info else pypi()
373
+ if not latest:
374
+ out(t["offline"])
375
+ return 1
376
+ note_latest(latest, state)
377
+
378
+ if compare_versions(latest, installed) <= 0:
379
+ out(t["uptodate"].format(v=installed))
380
+ return 0
381
+
382
+ for line in format_notes(installed, latest, (info or {}).get("releases", []), lang()):
383
+ out(line)
384
+ out("")
385
+
386
+ plan = plan or detect_install()
387
+ if plan.method == "editable":
388
+ out(t["editable"])
389
+ return 1
390
+
391
+ if not yes:
392
+ if not interactive:
393
+ out(t["need_confirm"])
394
+ return 2
395
+ if input_fn(t["ask"]).strip().lower() not in _YES:
396
+ out(t["cancelled"])
397
+ return 0
398
+
399
+ manual_cmds = plan.commands
400
+ if windows and plan.method in ("pip", "pipx"):
401
+ out(t["manual_win"])
402
+ out(t["manual"])
403
+ for cmd in manual_cmds:
404
+ out(" " + _printable(cmd))
405
+ return 0
406
+
407
+ if plan.needs_pypi: # ne pas annoncer ni installer une version qui n'est pas (encore) sur PyPI
408
+ published = pypi()
409
+ if published is not None and compare_versions(published, latest) < 0:
410
+ out(t["notpublished"].format(v=latest))
411
+ return 1
412
+
413
+ out(t["running"])
414
+ for cmd in plan.commands:
415
+ try:
416
+ result = runner(cmd) # type: ignore[operator]
417
+ code = getattr(result, "returncode", 1)
418
+ except OSError:
419
+ code = 127
420
+ if code != 0:
421
+ out(t["failed"])
422
+ for c in manual_cmds:
423
+ out(" " + _printable(c))
424
+ return 1
425
+
426
+ new = _version_after(sys.executable, runner)
427
+ out(t["done"].format(old=installed, new=new) if new else t["done_unknown"])
428
+ return 0