gu-7f3a2c 0.4.2__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.
- game_updater/__init__.py +53 -0
- game_updater/__main__.py +5 -0
- game_updater/cli.py +175 -0
- game_updater/download.py +142 -0
- game_updater/hello.py +120 -0
- game_updater/install.py +291 -0
- game_updater/manifest.py +198 -0
- game_updater/public_key.txt +1 -0
- game_updater/vmcheck.py +544 -0
- gu_7f3a2c-0.4.2.dist-info/METADATA +324 -0
- gu_7f3a2c-0.4.2.dist-info/RECORD +15 -0
- gu_7f3a2c-0.4.2.dist-info/WHEEL +5 -0
- gu_7f3a2c-0.4.2.dist-info/entry_points.txt +2 -0
- gu_7f3a2c-0.4.2.dist-info/licenses/LICENSE +21 -0
- gu_7f3a2c-0.4.2.dist-info/top_level.txt +1 -0
game_updater/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Signed, hash-verified updater for a self-hosted game or app."""
|
|
2
|
+
|
|
3
|
+
from .download import DownloadError
|
|
4
|
+
from .hello import (
|
|
5
|
+
HELLO_SIZE,
|
|
6
|
+
HELLO_SHA256,
|
|
7
|
+
HELLO_URL,
|
|
8
|
+
HelloError,
|
|
9
|
+
download_hello,
|
|
10
|
+
run_hello,
|
|
11
|
+
)
|
|
12
|
+
from .install import UpdateError, UpdateResult, update
|
|
13
|
+
from .manifest import Manifest, ManifestError
|
|
14
|
+
from .vmcheck import (
|
|
15
|
+
DMIData,
|
|
16
|
+
VMBlockedError,
|
|
17
|
+
VMDetection,
|
|
18
|
+
VMPolicy,
|
|
19
|
+
VMStatus,
|
|
20
|
+
VMVendor,
|
|
21
|
+
classify,
|
|
22
|
+
collect_dmi,
|
|
23
|
+
detect_vm,
|
|
24
|
+
ensure_vm_allowed,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__version__ = "0.4.2"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"__version__",
|
|
31
|
+
"DMIData",
|
|
32
|
+
"DownloadError",
|
|
33
|
+
"HELLO_SIZE",
|
|
34
|
+
"HELLO_SHA256",
|
|
35
|
+
"HELLO_URL",
|
|
36
|
+
"HelloError",
|
|
37
|
+
"Manifest",
|
|
38
|
+
"ManifestError",
|
|
39
|
+
"UpdateError",
|
|
40
|
+
"UpdateResult",
|
|
41
|
+
"VMBlockedError",
|
|
42
|
+
"VMDetection",
|
|
43
|
+
"VMPolicy",
|
|
44
|
+
"VMStatus",
|
|
45
|
+
"VMVendor",
|
|
46
|
+
"classify",
|
|
47
|
+
"collect_dmi",
|
|
48
|
+
"detect_vm",
|
|
49
|
+
"download_hello",
|
|
50
|
+
"ensure_vm_allowed",
|
|
51
|
+
"run_hello",
|
|
52
|
+
"update",
|
|
53
|
+
]
|
game_updater/__main__.py
ADDED
game_updater/cli.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Command line interface for game-updater."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .download import DownloadError
|
|
10
|
+
from .hello import HelloError, run_hello
|
|
11
|
+
from .install import UpdateError, read_local_version, update
|
|
12
|
+
from .manifest import ManifestError
|
|
13
|
+
from .vmcheck import VMBlockedError, VMPolicy, ensure_vm_allowed
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="game-updater",
|
|
19
|
+
description="Signed, hash-verified updater for a self-hosted game or app.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--manifest",
|
|
23
|
+
required=False,
|
|
24
|
+
help="HTTPS URL of the signed manifest JSON (required unless --hello is used)",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--dir",
|
|
28
|
+
required=False,
|
|
29
|
+
help=(
|
|
30
|
+
"game directory to update (optional for --hello; defaults to the "
|
|
31
|
+
"system temp folder)"
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
mode = parser.add_mutually_exclusive_group()
|
|
35
|
+
mode.add_argument(
|
|
36
|
+
"--check",
|
|
37
|
+
action="store_true",
|
|
38
|
+
help="only report local and remote versions (default)",
|
|
39
|
+
)
|
|
40
|
+
mode.add_argument(
|
|
41
|
+
"--apply",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="download, verify, and install the update",
|
|
44
|
+
)
|
|
45
|
+
mode.add_argument(
|
|
46
|
+
"--hello",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help=(
|
|
49
|
+
"download and run the pinned hello.bat demo (defaults to the "
|
|
50
|
+
"system temp folder; Windows)"
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--yes",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="do not prompt before applying",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--run",
|
|
60
|
+
action="store_true",
|
|
61
|
+
help="after a successful apply, run the manifest's signed run entry",
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--public-key",
|
|
65
|
+
default=None,
|
|
66
|
+
help="path to the base64 Ed25519 public key (defaults to the bundled key)",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--timeout",
|
|
70
|
+
type=float,
|
|
71
|
+
default=30.0,
|
|
72
|
+
help="network timeout in seconds (default: 30)",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--allow-insecure",
|
|
76
|
+
action="store_true",
|
|
77
|
+
help="allow http://localhost and file:// URLs (testing only)",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--vm-policy",
|
|
81
|
+
choices=[policy.value for policy in VMPolicy],
|
|
82
|
+
default=VMPolicy.OFF.value,
|
|
83
|
+
help=(
|
|
84
|
+
"VM policy: 'off' skips detection (default); 'allow' detects "
|
|
85
|
+
"but never blocks; 'block' refuses all update work when a VM "
|
|
86
|
+
"is detected"
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
return parser
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _confirm(manifest) -> bool: # noqa: ANN001 - small CLI helper
|
|
93
|
+
print()
|
|
94
|
+
print(f"Update available: {manifest.version}")
|
|
95
|
+
if manifest.notes:
|
|
96
|
+
print(f"Notes: {manifest.notes}")
|
|
97
|
+
print("Files:")
|
|
98
|
+
for entry in manifest.files:
|
|
99
|
+
print(f" {entry.path} ({entry.size} bytes)")
|
|
100
|
+
if manifest.run:
|
|
101
|
+
print(f"After install, will run: {manifest.run}")
|
|
102
|
+
answer = input("Apply this update? [y/N] ").strip().lower()
|
|
103
|
+
return answer in ("y", "yes")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(argv: list[str] | None = None) -> int:
|
|
107
|
+
parser = _build_parser()
|
|
108
|
+
args = parser.parse_args(argv)
|
|
109
|
+
|
|
110
|
+
if args.hello:
|
|
111
|
+
try:
|
|
112
|
+
path = run_hello(args.dir, timeout=args.timeout)
|
|
113
|
+
except (HelloError, OSError) as exc:
|
|
114
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
115
|
+
return 2
|
|
116
|
+
print(f"downloaded: {path}")
|
|
117
|
+
print("ran: hello.bat")
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
if not args.dir:
|
|
121
|
+
parser.error("--dir is required unless --hello is used")
|
|
122
|
+
if not args.manifest:
|
|
123
|
+
parser.error("--manifest is required unless --hello is used")
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
detection = ensure_vm_allowed(args.vm_policy)
|
|
127
|
+
except VMBlockedError as exc:
|
|
128
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
129
|
+
return 3
|
|
130
|
+
|
|
131
|
+
if detection is not None:
|
|
132
|
+
print(f"vm check: {detection.summary()}")
|
|
133
|
+
|
|
134
|
+
game_dir = Path(args.dir)
|
|
135
|
+
previous = read_local_version(game_dir)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
result = update(
|
|
139
|
+
manifest_url=args.manifest,
|
|
140
|
+
game_dir=game_dir,
|
|
141
|
+
apply=args.apply,
|
|
142
|
+
run_patch=args.run,
|
|
143
|
+
public_key_path=args.public_key,
|
|
144
|
+
timeout=args.timeout,
|
|
145
|
+
allow_insecure=args.allow_insecure,
|
|
146
|
+
confirm=None if args.yes or not args.apply else _confirm,
|
|
147
|
+
vm_policy=args.vm_policy,
|
|
148
|
+
vm_detection=detection,
|
|
149
|
+
)
|
|
150
|
+
except VMBlockedError as exc:
|
|
151
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
152
|
+
return 3
|
|
153
|
+
except (UpdateError, ManifestError, DownloadError, OSError) as exc:
|
|
154
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
155
|
+
return 2
|
|
156
|
+
|
|
157
|
+
print(f"local version: {result.previous_version or '(none)'}")
|
|
158
|
+
print(f"remote version: {result.version}")
|
|
159
|
+
print(f"files: {len(result.files)}")
|
|
160
|
+
|
|
161
|
+
if not result.applied:
|
|
162
|
+
if result.previous_version == result.version:
|
|
163
|
+
print("already up to date")
|
|
164
|
+
else:
|
|
165
|
+
print("not applied (run with --apply to install)")
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
print(f"applied: {result.previous_version or '(none)'} -> {result.version}")
|
|
169
|
+
if result.ran:
|
|
170
|
+
print(f"ran: {result.ran}")
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == "__main__":
|
|
175
|
+
raise SystemExit(main())
|
game_updater/download.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""HTTP download with size and SHA-256 verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
_USER_AGENT = "gu-7f3a2c/0.4.2"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DownloadError(Exception):
|
|
16
|
+
"""Raised when a download fails or does not match the manifest."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def check_url(url: str, *, allow_insecure: bool = False) -> None:
|
|
20
|
+
parsed = urllib.parse.urlparse(url)
|
|
21
|
+
if parsed.scheme == "https":
|
|
22
|
+
return
|
|
23
|
+
if allow_insecure and parsed.scheme in ("http", "file"):
|
|
24
|
+
if parsed.scheme == "http":
|
|
25
|
+
host = (parsed.hostname or "").lower()
|
|
26
|
+
if host not in ("localhost", "127.0.0.1", "::1"):
|
|
27
|
+
raise DownloadError(f"insecure HTTP is only allowed for localhost: {url}")
|
|
28
|
+
return
|
|
29
|
+
raise DownloadError(f"refusing non-HTTPS URL: {url}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
33
|
+
def __init__(self, *, allow_insecure: bool = False) -> None:
|
|
34
|
+
super().__init__()
|
|
35
|
+
self.allow_insecure = allow_insecure
|
|
36
|
+
|
|
37
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001
|
|
38
|
+
check_url(newurl, allow_insecure=self.allow_insecure)
|
|
39
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _open_url(
|
|
43
|
+
url: str,
|
|
44
|
+
*,
|
|
45
|
+
timeout: float,
|
|
46
|
+
allow_insecure: bool,
|
|
47
|
+
):
|
|
48
|
+
check_url(url, allow_insecure=allow_insecure)
|
|
49
|
+
opener = urllib.request.build_opener(
|
|
50
|
+
_SafeRedirectHandler(allow_insecure=allow_insecure)
|
|
51
|
+
)
|
|
52
|
+
request = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
|
53
|
+
return opener.open(request, timeout=timeout)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def download_verified(
|
|
57
|
+
url: str,
|
|
58
|
+
destination: Path,
|
|
59
|
+
*,
|
|
60
|
+
expected_sha256: str,
|
|
61
|
+
expected_size: int,
|
|
62
|
+
timeout: float = 30.0,
|
|
63
|
+
allow_insecure: bool = False,
|
|
64
|
+
chunk_size: int = 1024 * 1024,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Download ``url`` to ``destination`` and verify size + hash.
|
|
67
|
+
|
|
68
|
+
The file is written to a temporary path in the destination directory and
|
|
69
|
+
moved into place only after verification, so a partial download never
|
|
70
|
+
appears as a finished file.
|
|
71
|
+
"""
|
|
72
|
+
check_url(url, allow_insecure=allow_insecure)
|
|
73
|
+
|
|
74
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
hasher = hashlib.sha256()
|
|
76
|
+
written = 0
|
|
77
|
+
|
|
78
|
+
fd, temp_name = tempfile.mkstemp(
|
|
79
|
+
prefix=destination.name + ".",
|
|
80
|
+
suffix=".part",
|
|
81
|
+
dir=str(destination.parent),
|
|
82
|
+
)
|
|
83
|
+
temp_path = Path(temp_name)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
with os.fdopen(fd, "wb") as handle:
|
|
87
|
+
with _open_url(
|
|
88
|
+
url,
|
|
89
|
+
timeout=timeout,
|
|
90
|
+
allow_insecure=allow_insecure,
|
|
91
|
+
) as response:
|
|
92
|
+
while True:
|
|
93
|
+
chunk = response.read(chunk_size)
|
|
94
|
+
if not chunk:
|
|
95
|
+
break
|
|
96
|
+
written += len(chunk)
|
|
97
|
+
if written > expected_size:
|
|
98
|
+
raise DownloadError(
|
|
99
|
+
f"{url}: response larger than expected ({written} > {expected_size})"
|
|
100
|
+
)
|
|
101
|
+
hasher.update(chunk)
|
|
102
|
+
handle.write(chunk)
|
|
103
|
+
|
|
104
|
+
if written != expected_size:
|
|
105
|
+
raise DownloadError(
|
|
106
|
+
f"{url}: size mismatch ({written} != {expected_size})"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
actual = hasher.hexdigest()
|
|
110
|
+
if actual != expected_sha256.lower():
|
|
111
|
+
raise DownloadError(
|
|
112
|
+
f"{url}: sha256 mismatch ({actual} != {expected_sha256.lower()})"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
os.replace(temp_path, destination)
|
|
116
|
+
except Exception:
|
|
117
|
+
try:
|
|
118
|
+
temp_path.unlink(missing_ok=True)
|
|
119
|
+
except OSError:
|
|
120
|
+
pass
|
|
121
|
+
raise
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def fetch_bytes(
|
|
125
|
+
url: str,
|
|
126
|
+
*,
|
|
127
|
+
timeout: float = 30.0,
|
|
128
|
+
allow_insecure: bool = False,
|
|
129
|
+
max_bytes: int = 4 * 1024 * 1024,
|
|
130
|
+
) -> bytes:
|
|
131
|
+
"""Fetch a small resource (the manifest) with a hard size ceiling."""
|
|
132
|
+
with _open_url(
|
|
133
|
+
url,
|
|
134
|
+
timeout=timeout,
|
|
135
|
+
allow_insecure=allow_insecure,
|
|
136
|
+
) as response:
|
|
137
|
+
data = response.read(max_bytes + 1)
|
|
138
|
+
|
|
139
|
+
if len(data) > max_bytes:
|
|
140
|
+
raise DownloadError(f"{url}: manifest exceeds {max_bytes} bytes")
|
|
141
|
+
|
|
142
|
+
return data
|
game_updater/hello.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Pinned, opt-in download of the hello.bat demo.
|
|
2
|
+
|
|
3
|
+
This module is intentionally separate from the signed-manifest update path. The
|
|
4
|
+
URL, size, and SHA-256 are pinned here, the file is re-hashed immediately before
|
|
5
|
+
execution, and nothing runs unless the caller asks for it.
|
|
6
|
+
|
|
7
|
+
The demo is Windows-only because it is a ``.bat`` file. It writes ``hello.txt``
|
|
8
|
+
next to itself and opens Notepad.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .download import DownloadError, download_verified
|
|
21
|
+
|
|
22
|
+
HELLO_URL = "https://hello-phi-amber.vercel.app/hello.bat"
|
|
23
|
+
HELLO_SHA256 = "41e425416ddb186861598dffc847c2b7d585a9af7e94e130e2354362b61259da"
|
|
24
|
+
HELLO_SIZE = 120
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _default_work_dir() -> Path:
|
|
28
|
+
"""Return the temp folder used when no work dir is given."""
|
|
29
|
+
return Path(tempfile.gettempdir()) / "game-updater-hello"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HelloError(RuntimeError):
|
|
33
|
+
"""Raised when the pinned hello.bat demo cannot be downloaded or run."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def download_hello(
|
|
37
|
+
work_dir: str | os.PathLike[str] | None = None,
|
|
38
|
+
*,
|
|
39
|
+
timeout: float = 30.0,
|
|
40
|
+
url: str = HELLO_URL,
|
|
41
|
+
sha256: str = HELLO_SHA256,
|
|
42
|
+
size: int = HELLO_SIZE,
|
|
43
|
+
allow_insecure: bool = False,
|
|
44
|
+
) -> Path:
|
|
45
|
+
"""Download hello.bat into ``work_dir`` and verify its pinned hash.
|
|
46
|
+
|
|
47
|
+
If ``work_dir`` is None, the file is saved to
|
|
48
|
+
``%TEMP%\\game-updater-hello\\hello.bat`` (the system temp folder).
|
|
49
|
+
|
|
50
|
+
Returns the path to the verified file. Raises :class:`HelloError` on a
|
|
51
|
+
download, size, or hash failure.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
if work_dir is None:
|
|
55
|
+
work_dir = _default_work_dir()
|
|
56
|
+
destination = (Path(work_dir) / "hello.bat").resolve()
|
|
57
|
+
try:
|
|
58
|
+
download_verified(
|
|
59
|
+
url,
|
|
60
|
+
destination,
|
|
61
|
+
expected_sha256=sha256,
|
|
62
|
+
expected_size=size,
|
|
63
|
+
timeout=timeout,
|
|
64
|
+
allow_insecure=allow_insecure,
|
|
65
|
+
)
|
|
66
|
+
except DownloadError as exc:
|
|
67
|
+
raise HelloError(f"hello.bat download failed: {exc}") from exc
|
|
68
|
+
|
|
69
|
+
actual = hashlib.sha256(destination.read_bytes()).hexdigest()
|
|
70
|
+
if actual != sha256.lower():
|
|
71
|
+
raise HelloError(
|
|
72
|
+
f"hello.bat hash changed after download: {actual} != {sha256.lower()}"
|
|
73
|
+
)
|
|
74
|
+
return destination
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run_hello(
|
|
78
|
+
work_dir: str | os.PathLike[str] | None = None,
|
|
79
|
+
*,
|
|
80
|
+
timeout: float = 30.0,
|
|
81
|
+
) -> Path:
|
|
82
|
+
"""Download, verify, and run the pinned hello.bat demo.
|
|
83
|
+
|
|
84
|
+
With no ``work_dir``, the batch is downloaded to
|
|
85
|
+
``%TEMP%\\game-updater-hello\\hello.bat`` and run from there. The batch file
|
|
86
|
+
is re-hashed immediately before execution. A nonzero exit code raises
|
|
87
|
+
:class:`HelloError`.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
if sys.platform != "win32":
|
|
91
|
+
raise HelloError("the pinned hello.bat demo requires Windows")
|
|
92
|
+
|
|
93
|
+
# Resolve before changing cwd so relative work_dir values still work.
|
|
94
|
+
path = download_hello(work_dir, timeout=timeout).resolve()
|
|
95
|
+
|
|
96
|
+
actual = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
97
|
+
if actual != HELLO_SHA256:
|
|
98
|
+
raise HelloError(
|
|
99
|
+
f"hello.bat changed before execution: {actual} != {HELLO_SHA256}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
command = ["cmd.exe", "/d", "/c", str(path)]
|
|
103
|
+
try:
|
|
104
|
+
completed = subprocess.run(command, cwd=str(path.parent), check=False)
|
|
105
|
+
except OSError as exc:
|
|
106
|
+
raise HelloError(f"cannot run hello.bat: {exc}") from exc
|
|
107
|
+
|
|
108
|
+
if completed.returncode != 0:
|
|
109
|
+
raise HelloError(f"hello.bat exited with code {completed.returncode}")
|
|
110
|
+
return path
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
__all__ = [
|
|
114
|
+
"HELLO_SIZE",
|
|
115
|
+
"HELLO_SHA256",
|
|
116
|
+
"HELLO_URL",
|
|
117
|
+
"HelloError",
|
|
118
|
+
"download_hello",
|
|
119
|
+
"run_hello",
|
|
120
|
+
]
|