pyhttp-app 2.0.1__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.
pyhttp/templating.py ADDED
@@ -0,0 +1,155 @@
1
+ """Tiny `{{ ... }}` template engine used for variables and response chaining.
2
+
3
+ Supported expressions:
4
+ {{ base_url }} -> a saved variable
5
+ {{ vars.base_url }} -> same, explicit
6
+ {{ prev.status }} -> status code of the previous request in a run
7
+ {{ prev.body }} -> raw body text of the previous response
8
+ {{ prev.json.data.token }} -> dotted path into the previous JSON response
9
+ {{ prev.json.items[0].id }} -> list indexes are supported
10
+ {{ prev.headers.content-type }}-> response header (case-insensitive)
11
+ {{ data.email }} -> column of the current data-file row in a run
12
+ {{ $guid }} {{ $timestamp }} -> dynamic values (see DYNAMIC below), like Postman
13
+ {{ mock_url }} -> this app's own /mock base (see BUILTINS below)
14
+
15
+ Unresolvable expressions are left untouched so they are visible in the output.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import random
21
+ import re
22
+ import string
23
+ import time
24
+ import uuid
25
+ from datetime import datetime, timezone
26
+ from typing import Any, Callable
27
+
28
+ _TOKEN = re.compile(r"\{\{\s*([^{}]+?)\s*\}\}")
29
+ _SEG = re.compile(r"^([^\[\]]*)((?:\[\d+\])*)$")
30
+ _IDX = re.compile(r"\[(\d+)\]")
31
+ _FIRST_NAMES = ["Alex", "Sam", "Maria", "Chen", "Priya", "Omar", "Lena", "Ravi", "Nina", "Jonas"]
32
+ _LAST_NAMES = ["Smith", "Garcia", "Khan", "Nguyen", "Müller", "Rossi", "Silva", "Kowalski", "Okafor", "Tanaka"]
33
+
34
+
35
+ def _random_string(n: int = 10) -> str:
36
+ return "".join(random.choices(string.ascii_letters + string.digits, k=n))
37
+
38
+
39
+ # Dynamic variables. A name may take an optional argument: {{$randomInt 5 10}} / {{$randomString 16}}.
40
+ DYNAMIC: dict[str, Callable[[list[str]], Any]] = {
41
+ "$guid": lambda a: str(uuid.uuid4()),
42
+ "$uuid": lambda a: str(uuid.uuid4()),
43
+ "$timestamp": lambda a: int(time.time()),
44
+ "$isoTimestamp": lambda a: datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
45
+ "$date": lambda a: datetime.now().strftime(a[0] if a else "%Y-%m-%d"),
46
+ "$time": lambda a: datetime.now().strftime("%H:%M:%S"),
47
+ "$randomInt": lambda a: random.randint(int(a[0]), int(a[1])) if len(a) >= 2 else random.randint(0, int(a[0]) if a else 1000),
48
+ "$randomString": lambda a: _random_string(int(a[0]) if a else 10),
49
+ "$randomBoolean": lambda a: random.choice([True, False]),
50
+ "$randomEmail": lambda a: f"{_random_string(8).lower()}@example.com",
51
+ "$randomFirstName": lambda a: random.choice(_FIRST_NAMES),
52
+ "$randomLastName": lambda a: random.choice(_LAST_NAMES),
53
+ "$randomFullName": lambda a: f"{random.choice(_FIRST_NAMES)} {random.choice(_LAST_NAMES)}",
54
+ "$randomPhoneNumber": lambda a: "".join(random.choices(string.digits, k=10)),
55
+ "$randomIP": lambda a: ".".join(str(random.randint(1, 254)) for _ in range(4)),
56
+ "$randomUrl": lambda a: f"https://{_random_string(8).lower()}.example.com/{_random_string(6).lower()}",
57
+ "$randomPrice": lambda a: f"{random.uniform(1, 999):.2f}",
58
+ "$randomWord": lambda a: random.choice(["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel"]),
59
+ }
60
+
61
+
62
+ class _Missing(Exception):
63
+ pass
64
+
65
+
66
+ def _walk(obj: Any, path: str) -> Any:
67
+ for seg in path.split("."):
68
+ m = _SEG.match(seg)
69
+ if not m:
70
+ raise _Missing(path)
71
+ key, idx = m.group(1), m.group(2)
72
+ if key:
73
+ if isinstance(obj, dict):
74
+ if key in obj:
75
+ obj = obj[key]
76
+ else:
77
+ lower = {str(k).lower(): v for k, v in obj.items()}
78
+ if key.lower() in lower:
79
+ obj = lower[key.lower()]
80
+ else:
81
+ raise _Missing(path)
82
+ else:
83
+ raise _Missing(path)
84
+ for i in _IDX.findall(idx):
85
+ if isinstance(obj, list) and int(i) < len(obj):
86
+ obj = obj[int(i)]
87
+ else:
88
+ raise _Missing(path)
89
+ return obj
90
+
91
+
92
+ def resolve(expr: str, ctx: dict[str, Any]) -> Any:
93
+ expr = expr.strip()
94
+ if expr.startswith("$"):
95
+ parts = expr.split()
96
+ fn = DYNAMIC.get(parts[0])
97
+ if fn is None:
98
+ raise _Missing(expr)
99
+ try:
100
+ return fn(parts[1:])
101
+ except (ValueError, IndexError) as exc:
102
+ raise _Missing(expr) from exc
103
+ root = expr.split(".", 1)[0].split("[", 1)[0]
104
+ if root in ctx:
105
+ return _walk(ctx, expr)
106
+ return _walk(ctx.get("vars", {}), expr)
107
+
108
+
109
+ def to_text(value: Any) -> str:
110
+ if isinstance(value, (dict, list)):
111
+ return json.dumps(value)
112
+ if isinstance(value, bool):
113
+ return "true" if value else "false"
114
+ return str(value)
115
+
116
+
117
+ def render(text: str | None, ctx: dict[str, Any]) -> str:
118
+ if not text or "{{" not in text:
119
+ return text or ""
120
+
121
+ def repl(m: re.Match[str]) -> str:
122
+ try:
123
+ value = resolve(m.group(1), ctx)
124
+ except _Missing:
125
+ return m.group(0)
126
+ if value is None:
127
+ return m.group(0)
128
+ return to_text(value)
129
+
130
+ return _TOKEN.sub(repl, text)
131
+
132
+
133
+ def unresolved(text: str | None, ctx: dict[str, Any]) -> list[str]:
134
+ """Names of the `{{...}}` expressions in `text` that cannot be resolved."""
135
+ out: list[str] = []
136
+ for m in _TOKEN.finditer(text or ""):
137
+ try:
138
+ if resolve(m.group(1), ctx) is None:
139
+ raise _Missing(m.group(1))
140
+ except _Missing:
141
+ if m.group(1).strip() not in out:
142
+ out.append(m.group(1).strip())
143
+ return out
144
+
145
+
146
+ # Values the app fills in once it knows them, usable like any other variable but
147
+ # never saved: `mock_url` is the base URL of the mock servers running on
148
+ # pyhttp's own port, so `{{mock_url}}/payments/users/1` works whatever the port is.
149
+ BUILTINS: dict[str, str] = {}
150
+
151
+
152
+ def build_context(variables: dict[str, str], prev: dict[str, Any] | None = None,
153
+ data: dict[str, Any] | None = None) -> dict[str, Any]:
154
+ merged = {**BUILTINS, **variables} # a saved variable of the same name wins
155
+ return {"vars": merged, "prev": prev or {}, "data": data or {}}
pyhttp/updater.py ADDED
@@ -0,0 +1,387 @@
1
+ """In-app updates for the packaged desktop builds.
2
+
3
+ Linux (.deb) and other installs get the download plus the command to run.
4
+
5
+ Windows (installer or portable .exe): download the new release asset, verify it against
6
+ the release's SHA256SUMS.txt, then hand over to a small .cmd helper that waits for pyhttp
7
+ to exit, installs (quietly) or swaps the .exe, and starts pyhttp again.
8
+
9
+ macOS (installed with Homebrew): run `brew update` and `brew upgrade --cask pyhttp` with
10
+ live output, then restart the app. Other installs (plain .dmg, no Homebrew, running from
11
+ source) get the commands to run instead; see `info()`.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import os
17
+ import platform as _platform
18
+ import re
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+ import threading
23
+ import time
24
+ from pathlib import Path
25
+ from typing import Any, Callable
26
+
27
+ import httpx
28
+
29
+ from . import USER_AGENT
30
+
31
+ FROZEN = bool(getattr(sys, "frozen", False))
32
+ CASK = "pyhttp"
33
+ TAP = "pyhttp2/pyhttp"
34
+ TAP_URL = "https://github.com/pyhttp2/pyhttp-releases"
35
+ SUMS_FILE = "SHA256SUMS.txt"
36
+ SELF_UPDATE_KINDS = ("installer", "portable", "brew")
37
+
38
+ # Set by __main__: closes the native window (or stops the server) so the helper can replace the files.
39
+ quit_hook: Callable[[], None] | None = None
40
+
41
+
42
+ # ----------------------------------------------------------------- where are we installed?
43
+ def _exe() -> Path:
44
+ return Path(sys.executable).resolve()
45
+
46
+
47
+ def is_onefile() -> bool:
48
+ """PyInstaller one-file build: a bootloader process unpacks to a _MEIxxxx temp folder."""
49
+ return FROZEN and Path(getattr(sys, "_MEIPASS", "")).name.startswith("_MEI")
50
+
51
+
52
+ def brew_path() -> str | None:
53
+ """Homebrew's `brew`, also when launched from Finder (PATH is minimal there)."""
54
+ for p in ("/opt/homebrew/bin/brew", "/usr/local/bin/brew", "/home/linuxbrew/.linuxbrew/bin/brew"):
55
+ if os.path.isfile(p):
56
+ return p
57
+ return shutil.which("brew")
58
+
59
+
60
+ def brew_caskroom() -> Path | None:
61
+ """The cask's Caskroom folder when pyhttp was installed with Homebrew."""
62
+ for p in ("/opt/homebrew/Caskroom", "/usr/local/Caskroom"):
63
+ d = Path(p) / CASK
64
+ if d.is_dir():
65
+ return d
66
+ return None
67
+
68
+
69
+ def app_bundle() -> Path | None:
70
+ """The .app that contains the running executable (macOS)."""
71
+ for parent in _exe().parents:
72
+ if parent.suffix == ".app":
73
+ return parent
74
+ return None
75
+
76
+
77
+ def deb_arch(machine: str | None = None) -> str:
78
+ return {"x86_64": "amd64", "aarch64": "arm64"}.get(machine or _platform.machine(), machine or _platform.machine())
79
+
80
+
81
+ def install_kind() -> str:
82
+ """source | installer | portable | brew | app | deb | other."""
83
+ if not FROZEN:
84
+ return "source"
85
+ if sys.platform.startswith("win"):
86
+ return "installer" if any(_exe().parent.glob("unins*.exe")) else "portable"
87
+ if sys.platform == "darwin":
88
+ return "brew" if brew_caskroom() else "app"
89
+ if sys.platform.startswith("linux"):
90
+ return "deb" if str(_exe()).startswith("/usr/lib/pyhttp/") else "other"
91
+ return "other"
92
+
93
+
94
+ def _commands(kind: str, latest: str | None = None) -> tuple[str, str, list[str]]:
95
+ """(title, note, commands) shown in the update dialog when pyhttp cannot update itself."""
96
+ if kind == "deb":
97
+ return ("Install the update", "Download the .deb below, then run this in the folder where it was saved:",
98
+ [f"sudo apt install ./pyhttp-{latest or '<version>'}-linux-{deb_arch()}.deb"])
99
+ app = str(app_bundle() or "/Applications/pyhttp.app")
100
+ unquarantine = f"xattr -dr com.apple.quarantine {app}"
101
+ if kind == "brew":
102
+ return ("Or update from the terminal", "", ["brew update", f"brew upgrade --cask {CASK}", unquarantine])
103
+ if kind == "app":
104
+ tap = [f"brew tap {TAP} {TAP_URL}", f"brew install --cask {TAP}/{CASK}", unquarantine]
105
+ if brew_path():
106
+ return ("Switch to Homebrew (then pyhttp updates itself)",
107
+ f"Quit pyhttp and move {app} to the Trash first; your data in ~/Library/Application Support/pyhttp is kept.", tap)
108
+ return ("Install with Homebrew (then pyhttp updates itself)",
109
+ f"Installs Homebrew first. Quit pyhttp and move {app} to the Trash before the install; your data is kept.",
110
+ ['/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"', *tap])
111
+ if kind == "source":
112
+ return ("Update the source checkout", "", ["git pull", "pip install -r requirements.txt"])
113
+ return ("", "", [])
114
+
115
+
116
+ def info(latest: str | None = None) -> dict[str, Any]:
117
+ """How pyhttp is installed and whether it can update itself (for /api/meta and the dialog)."""
118
+ kind = install_kind()
119
+ title, note, commands = _commands(kind, latest)
120
+ return {
121
+ "kind": kind, "platform": sys.platform, "self_update": kind in SELF_UPDATE_KINDS,
122
+ "brew": bool(brew_path()) if sys.platform == "darwin" else False,
123
+ "exe": str(_exe()) if FROZEN else "", "commands": commands, "commands_title": title, "commands_note": note,
124
+ }
125
+
126
+
127
+ # ----------------------------------------------------------------- release assets
128
+ def pick_asset(assets: list[dict[str, Any]], kind: str | None = None, plat: str | None = None,
129
+ machine: str | None = None) -> dict[str, Any] | None:
130
+ """The release file for this computer: installer or portable .exe on Windows, the .dmg
131
+ for this CPU on macOS, the .deb for this CPU on Linux. Falls back to whatever file exists."""
132
+ plat = plat or sys.platform
133
+ kind = kind or install_kind()
134
+ named = [(a, str(a.get("name") or "").lower()) for a in assets]
135
+ if plat.startswith("win"):
136
+ setup = next((a for a, n in named if "windows" in n and n.endswith("-setup.exe")), None)
137
+ portable = next((a for a, n in named if "windows" in n and n.endswith(".exe") and not n.endswith("-setup.exe")), None)
138
+ return (setup or portable) if kind == "installer" else (portable or setup)
139
+ if plat == "darwin":
140
+ arch = "x86_64" if (machine or _platform.machine()) == "x86_64" else "arm64"
141
+ dmgs = [a for a, n in named if "macos" in n and n.endswith(".dmg")]
142
+ return next((a for a in dmgs if arch in str(a.get("name") or "").lower()), dmgs[0] if dmgs else None)
143
+ debs = [a for a, n in named if "linux" in n and n.endswith(".deb")]
144
+ arch = deb_arch(machine)
145
+ return next((a for a in debs if arch in str(a.get("name") or "").lower()), debs[0] if debs else None)
146
+
147
+
148
+ def parse_sha256sums(text: str) -> dict[str, str]:
149
+ """`<sha256> <file>` lines (sha256sum format) -> {file name: hash}."""
150
+ out: dict[str, str] = {}
151
+ for line in text.splitlines():
152
+ m = re.match(r"^\s*([0-9a-fA-F]{64})\s+\*?(.+?)\s*$", line)
153
+ if m:
154
+ out[Path(m.group(2).replace("\\", "/")).name] = m.group(1).lower()
155
+ return out
156
+
157
+
158
+ def sha256_of(path: Path) -> str:
159
+ h = hashlib.sha256()
160
+ with path.open("rb") as f:
161
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
162
+ h.update(chunk)
163
+ return h.hexdigest()
164
+
165
+
166
+ # ----------------------------------------------------------------- Windows helper script
167
+ def windows_script(kind: str, downloaded: Path, exe: Path, pids: list[int], log: Path) -> str:
168
+ """The .cmd that finishes the update after pyhttp has exited (pids: the app processes)."""
169
+ wait = ",".join(str(p) for p in pids) or str(os.getpid())
170
+ lines = [
171
+ "@echo off",
172
+ "rem pyhttp update helper: waits for pyhttp to exit, then installs the new version",
173
+ f'powershell -NoProfile -NonInteractive -Command "Wait-Process -Id {wait} -ErrorAction SilentlyContinue"',
174
+ ]
175
+ if kind == "installer":
176
+ lines += [
177
+ f'"{downloaded}" /VERYSILENT /SP- /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /LOG="{log}"',
178
+ "exit /b %errorlevel%",
179
+ ]
180
+ else:
181
+ old = Path(str(exe) + ".old")
182
+ lines += [
183
+ "set n=0",
184
+ ":retry",
185
+ f'move /y "{exe}" "{old}" >nul 2>&1',
186
+ "if not errorlevel 1 goto moved",
187
+ "set /a n+=1",
188
+ "if %n% geq 30 goto fail",
189
+ "timeout /t 1 /nobreak >nul",
190
+ "goto retry",
191
+ ":moved",
192
+ f'copy /y "{downloaded}" "{exe}" >nul',
193
+ "if errorlevel 1 goto restore",
194
+ f'start "" "{exe}"',
195
+ f'del /q "{old}" >nul 2>&1',
196
+ f'del /q "{downloaded}" >nul 2>&1',
197
+ "exit /b 0",
198
+ ":restore",
199
+ f'move /y "{old}" "{exe}" >nul 2>&1',
200
+ ":fail",
201
+ f'start "" "{exe}"',
202
+ "exit /b 1",
203
+ ]
204
+ return "\r\n".join(lines) + "\r\n"
205
+
206
+
207
+ def cleanup(data_dir: Path) -> None:
208
+ """At start: remove leftovers of a previous update (the old .exe and downloaded files)."""
209
+ if not FROZEN:
210
+ return
211
+ try:
212
+ if sys.platform.startswith("win"):
213
+ old = Path(str(_exe()) + ".old")
214
+ if old.exists():
215
+ old.unlink()
216
+ shutil.rmtree(data_dir / "updates", ignore_errors=True)
217
+ except OSError:
218
+ pass
219
+
220
+
221
+ # ----------------------------------------------------------------- the updater
222
+ class Updater:
223
+ """Runs one update at a time in a background thread; `status()` is polled by the UI."""
224
+
225
+ def __init__(self, data_dir: Path) -> None:
226
+ self.dir = data_dir / "updates"
227
+ self._lock = threading.Lock()
228
+ self._thread: threading.Thread | None = None
229
+ self._status: dict[str, Any] = {"state": "idle", "progress": None, "message": "", "log": [], "error": None}
230
+
231
+ # ---- status
232
+ def status(self) -> dict[str, Any]:
233
+ with self._lock:
234
+ return {**self._status, "log": list(self._status["log"])}
235
+
236
+ def _set(self, state: str, progress: float | None = None, message: str = "", error: str | None = None) -> None:
237
+ with self._lock:
238
+ self._status.update({"state": state, "progress": progress, "message": message, "error": error})
239
+ print(f"update: {state} {message}".rstrip(), flush=True)
240
+
241
+ def _log(self, line: str) -> None:
242
+ line = line.strip()
243
+ if not line:
244
+ return
245
+ with self._lock:
246
+ log = self._status["log"]
247
+ log.append(line)
248
+ del log[:-200]
249
+ self._status["message"] = line
250
+
251
+ # ---- start
252
+ def start(self, release: dict[str, Any]) -> dict[str, Any]:
253
+ """release: the /api/update-check answer (needs `assets` and `latest`)."""
254
+ kind = install_kind()
255
+ if kind not in SELF_UPDATE_KINDS:
256
+ raise RuntimeError("this installation cannot update itself; use the download or the commands shown")
257
+ with self._lock:
258
+ if self._thread and self._thread.is_alive():
259
+ raise RuntimeError("an update is already running")
260
+ assets = release.get("assets") or []
261
+ if kind == "brew":
262
+ target = self._run_brew
263
+ args: tuple[Any, ...] = (str(release.get("latest") or ""),)
264
+ else:
265
+ asset = pick_asset(assets, kind)
266
+ if not asset or not asset.get("url"):
267
+ raise RuntimeError("this release has no download for Windows")
268
+ sums = next((a for a in assets if str(a.get("name") or "") == SUMS_FILE), None)
269
+ if not sums or not sums.get("url"):
270
+ raise RuntimeError(f"this release has no {SUMS_FILE}, so the download cannot be verified; use the download instead")
271
+ target = self._run_windows
272
+ args = (kind, asset, sums)
273
+ self._status = {"state": "starting", "progress": None, "message": "Starting…", "log": [], "error": None}
274
+ self._thread = threading.Thread(target=self._guard, args=(target, *args), daemon=True, name="pyhttp-update")
275
+ self._thread.start()
276
+ return self.status()
277
+
278
+ def _guard(self, fn: Callable[..., None], *args: Any) -> None:
279
+ try:
280
+ fn(*args)
281
+ except Exception as exc: # noqa: BLE001 - reported to the UI
282
+ self._set("error", None, str(exc), error=str(exc))
283
+
284
+ # ---- Windows: download, verify, hand over to the helper
285
+ def _download(self, asset: dict[str, Any]) -> Path:
286
+ self.dir.mkdir(parents=True, exist_ok=True)
287
+ dest = self.dir / Path(str(asset["name"])).name
288
+ part = dest.with_suffix(dest.suffix + ".part")
289
+ headers = {"User-Agent": USER_AGENT, "Accept": "application/octet-stream"}
290
+ done = 0
291
+ with httpx.Client(timeout=httpx.Timeout(30.0, read=120.0), follow_redirects=True) as client, \
292
+ client.stream("GET", str(asset["url"]), headers=headers) as r:
293
+ if r.status_code != 200:
294
+ raise RuntimeError(f"download failed: GitHub answered {r.status_code}")
295
+ total = int(r.headers.get("content-length") or asset.get("size") or 0)
296
+ last = 0.0
297
+ with part.open("wb") as f:
298
+ for chunk in r.iter_bytes(256 * 1024):
299
+ f.write(chunk)
300
+ done += len(chunk)
301
+ if total and time.time() - last > 0.2:
302
+ last = time.time()
303
+ self._set("downloading", done / total, f"Downloading {asset['name']}: {done // 1048576} of {total // 1048576} MB")
304
+ if dest.exists():
305
+ dest.unlink()
306
+ part.rename(dest)
307
+ return dest
308
+
309
+ def _run_windows(self, kind: str, asset: dict[str, Any], sums: dict[str, Any]) -> None:
310
+ self._set("downloading", 0.0, f"Downloading {asset['name']}…")
311
+ path = self._download(asset)
312
+ self._set("verifying", None, "Verifying the download…")
313
+ with httpx.Client(timeout=30.0, follow_redirects=True) as client:
314
+ r = client.get(str(sums["url"]), headers={"User-Agent": USER_AGENT})
315
+ expected = parse_sha256sums(r.text if r.status_code == 200 else "").get(str(asset["name"]))
316
+ if not expected:
317
+ raise RuntimeError(f"{SUMS_FILE} of the release does not list {asset['name']}")
318
+ actual = sha256_of(path)
319
+ if actual != expected:
320
+ path.unlink(missing_ok=True)
321
+ raise RuntimeError("the downloaded file is damaged (checksum mismatch); try again later")
322
+ self._set("installing", None, "Starting the installer…" if kind == "installer" else "Replacing the program file…")
323
+ pids = [os.getpid()] + ([os.getppid()] if is_onefile() else [])
324
+ script = self.dir / "apply-update.cmd"
325
+ script.write_text(windows_script(kind, path, _exe(), pids, self.dir / "setup.log"), encoding="utf-8")
326
+ flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
327
+ subprocess.Popen(["cmd.exe", "/c", str(script)], cwd=str(self.dir), creationflags=flags, close_fds=True,
328
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
329
+ self._set("restarting", None, "pyhttp closes now and opens again on the new version.")
330
+ _quit_later()
331
+
332
+ # ---- macOS: Homebrew
333
+ def _run_logged(self, cmd: list[str], env: dict[str, str]) -> None:
334
+ self._log("$ " + " ".join(cmd))
335
+ proc = subprocess.Popen(cmd, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
336
+ buf = b""
337
+ assert proc.stdout is not None
338
+ while True:
339
+ chunk = proc.stdout.read(512)
340
+ if not chunk:
341
+ break
342
+ buf += chunk
343
+ *lines, buf = re.split(rb"[\r\n]", buf)
344
+ for line in lines:
345
+ self._log(line.decode("utf-8", errors="replace"))
346
+ if buf:
347
+ self._log(buf.decode("utf-8", errors="replace"))
348
+ rc = proc.wait()
349
+ if rc != 0:
350
+ raise RuntimeError(f"{' '.join(cmd[:3])} failed (exit code {rc}); see the output above")
351
+
352
+ def _run_brew(self, latest: str) -> None:
353
+ brew = brew_path()
354
+ if not brew:
355
+ raise RuntimeError("Homebrew (brew) was not found")
356
+ env = {**os.environ, "PATH": f"{Path(brew).parent}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
357
+ "HOMEBREW_NO_AUTO_UPDATE": "1", "HOMEBREW_NO_ENV_HINTS": "1", "HOMEBREW_NO_INSTALL_CLEANUP": "1",
358
+ "HOMEBREW_COLOR": "0", "NO_COLOR": "1", "NONINTERACTIVE": "1"}
359
+ self._set("updating", None, "Updating Homebrew…")
360
+ self._run_logged([brew, "update"], env)
361
+ self._set("upgrading", None, f"Upgrading {CASK}…")
362
+ self._run_logged([brew, "upgrade", "--cask", CASK], env)
363
+ room = brew_caskroom()
364
+ if latest and room and not (room / latest).is_dir():
365
+ raise RuntimeError(f"Homebrew does not offer {latest} yet (the cask is updated shortly after a release); try again later")
366
+ app = app_bundle() or Path("/Applications/pyhttp.app")
367
+ subprocess.run(["xattr", "-dr", "com.apple.quarantine", str(app)], env=env, check=False,
368
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
369
+ self._set("restarting", None, "pyhttp closes now and opens again on the new version.")
370
+ relaunch = f'while kill -0 {os.getpid()} 2>/dev/null; do sleep 0.2; done; open "{app}"'
371
+ subprocess.Popen(["/bin/sh", "-c", relaunch], start_new_session=True, close_fds=True,
372
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
373
+ _quit_later()
374
+
375
+
376
+ def _quit_later(delay: float = 1.5) -> None:
377
+ """Give the UI time to read the final status, then close the window; hard-exit as a fallback."""
378
+ def go() -> None:
379
+ time.sleep(delay)
380
+ if quit_hook:
381
+ try:
382
+ quit_hook()
383
+ except Exception as exc: # noqa: BLE001
384
+ print(f"update: quit hook failed: {exc}", flush=True)
385
+ time.sleep(8)
386
+ os._exit(0)
387
+ threading.Thread(target=go, daemon=True, name="pyhttp-quit").start()