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.
- ezshell-0.2.0.dist-info/METADATA +174 -0
- ezshell-0.2.0.dist-info/RECORD +37 -0
- ezshell-0.2.0.dist-info/WHEEL +5 -0
- ezshell-0.2.0.dist-info/entry_points.txt +3 -0
- ezshell-0.2.0.dist-info/licenses/LICENSE +21 -0
- ezshell-0.2.0.dist-info/top_level.txt +1 -0
- src/__init__.py +3 -0
- src/app.py +286 -0
- src/core/__init__.py +0 -0
- src/core/assistant.py +525 -0
- src/core/capture.py +213 -0
- src/core/chat_bridge.py +107 -0
- src/core/config.py +238 -0
- src/core/context.py +229 -0
- src/core/groq_client.py +376 -0
- src/core/llm_client.py +111 -0
- src/core/prompts.py +262 -0
- src/core/provider.py +233 -0
- src/core/proxy_client.py +166 -0
- src/core/pty_shell.py +273 -0
- src/core/redact.py +95 -0
- src/core/risk.py +455 -0
- src/core/safety.py +268 -0
- src/core/sanitize.py +39 -0
- src/core/schema.py +65 -0
- src/core/shell_hook.py +388 -0
- src/core/suggest.py +94 -0
- src/core/updater.py +428 -0
- src/i18n.py +422 -0
- src/main.py +346 -0
- src/ui/chat_tui.py +634 -0
- src/ui/gui_window.py +1018 -0
- src/ui/inline.py +330 -0
- src/ui/logo.py +194 -0
- src/ui/panel.py +163 -0
- src/ui/prompt.py +184 -0
- src/ui/render.py +269 -0
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
|