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/__init__.py +9 -0
- pyhttp/__main__.py +222 -0
- pyhttp/app.py +1333 -0
- pyhttp/assertions.py +289 -0
- pyhttp/cli.py +364 -0
- pyhttp/console.py +82 -0
- pyhttp/curl.py +151 -0
- pyhttp/db.py +1483 -0
- pyhttp/jsengine.py +224 -0
- pyhttp/jsonc.py +54 -0
- pyhttp/mcp.py +316 -0
- pyhttp/mock.py +204 -0
- pyhttp/monitors.py +116 -0
- pyhttp/oauth2.py +153 -0
- pyhttp/openapi.py +365 -0
- pyhttp/pm.js +496 -0
- pyhttp/postman.py +412 -0
- pyhttp/runner.py +1089 -0
- pyhttp/scripts.py +352 -0
- pyhttp/static/app.js +2739 -0
- pyhttp/static/codegen.js +134 -0
- pyhttp/static/editor.js +421 -0
- pyhttp/static/features.css +630 -0
- pyhttp/static/features.js +2396 -0
- pyhttp/static/index.html +372 -0
- pyhttp/static/logo.svg +13 -0
- pyhttp/static/style.css +585 -0
- pyhttp/static/vendor/codemirror.js +28 -0
- pyhttp/static/vendor/fonts/Inter-OFL.txt +93 -0
- pyhttp/static/vendor/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
- pyhttp/static/vendor/fonts/inter-latin-wght-normal.woff2 +0 -0
- pyhttp/static/viewer.js +434 -0
- pyhttp/templating.py +155 -0
- pyhttp/updater.py +387 -0
- pyhttp_app-2.0.1.dist-info/METADATA +654 -0
- pyhttp_app-2.0.1.dist-info/RECORD +40 -0
- pyhttp_app-2.0.1.dist-info/WHEEL +5 -0
- pyhttp_app-2.0.1.dist-info/entry_points.txt +2 -0
- pyhttp_app-2.0.1.dist-info/licenses/LICENSE.md +121 -0
- pyhttp_app-2.0.1.dist-info/top_level.txt +1 -0
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()
|