ggcode 1.1.1__tar.gz → 1.1.3__tar.gz
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.
- {ggcode-1.1.1 → ggcode-1.1.3}/PKG-INFO +18 -5
- ggcode-1.1.3/README.md +70 -0
- {ggcode-1.1.1 → ggcode-1.1.3}/ggcode.egg-info/PKG-INFO +18 -5
- ggcode-1.1.3/ggcode.egg-info/entry_points.txt +3 -0
- ggcode-1.1.3/ggcode_release_installer/__init__.py +1 -0
- ggcode-1.1.3/ggcode_release_installer/cli.py +344 -0
- {ggcode-1.1.1 → ggcode-1.1.3}/pyproject.toml +2 -1
- ggcode-1.1.3/tests/test_cli.py +96 -0
- ggcode-1.1.1/README.md +0 -57
- ggcode-1.1.1/ggcode.egg-info/entry_points.txt +0 -2
- ggcode-1.1.1/ggcode_release_installer/__init__.py +0 -1
- ggcode-1.1.1/ggcode_release_installer/cli.py +0 -147
- ggcode-1.1.1/tests/test_cli.py +0 -41
- {ggcode-1.1.1 → ggcode-1.1.3}/ggcode.egg-info/SOURCES.txt +0 -0
- {ggcode-1.1.1 → ggcode-1.1.3}/ggcode.egg-info/dependency_links.txt +0 -0
- {ggcode-1.1.1 → ggcode-1.1.3}/ggcode.egg-info/top_level.txt +0 -0
- {ggcode-1.1.1 → ggcode-1.1.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ggcode
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.3
|
|
4
4
|
Summary: Thin Python wrapper that installs the ggcode GitHub Release binary
|
|
5
5
|
Author: topcheer
|
|
6
6
|
License: MIT
|
|
@@ -12,10 +12,16 @@ Description-Content-Type: text/markdown
|
|
|
12
12
|
|
|
13
13
|
# ggcode Python wrapper
|
|
14
14
|
|
|
15
|
-
`ggcode` on PyPI
|
|
15
|
+
`ggcode` on PyPI bootstraps the native `ggcode` terminal agent from GitHub Releases.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
When the bootstrap runs, it installs the real binary into a stable CLI location instead of keeping
|
|
18
|
+
it in a wrapper-managed cache:
|
|
19
|
+
|
|
20
|
+
- macOS / Linux: prefers `/usr/local/bin`, falls back to `~/.local/bin`
|
|
21
|
+
- Windows: prefers `%USERPROFILE%\\AppData\\Local\\Programs\\ggcode\\bin`, falls back to `%USERPROFILE%\\.local\\bin`
|
|
22
|
+
|
|
23
|
+
If that directory is not already on `PATH`, the bootstrap updates your PATH configuration and asks
|
|
24
|
+
you to reopen the terminal so future `ggcode` launches resolve directly to the native binary.
|
|
19
25
|
|
|
20
26
|
## Install
|
|
21
27
|
|
|
@@ -29,12 +35,19 @@ Then run:
|
|
|
29
35
|
ggcode
|
|
30
36
|
```
|
|
31
37
|
|
|
38
|
+
If you ever need to rerun the bootstrap flow explicitly, you can also use:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
ggcode-bootstrap
|
|
42
|
+
```
|
|
43
|
+
|
|
32
44
|
## What it does
|
|
33
45
|
|
|
34
46
|
- Detects your operating system and CPU architecture
|
|
35
47
|
- Downloads the latest matching `ggcode` archive from GitHub Releases
|
|
36
48
|
- Verifies the archive against `checksums.txt`
|
|
37
|
-
-
|
|
49
|
+
- Installs the real binary into a stable PATH location
|
|
50
|
+
- Updates PATH so future `ggcode` launches bypass the Python wrapper
|
|
38
51
|
|
|
39
52
|
## Pin a specific ggcode release
|
|
40
53
|
|
ggcode-1.1.3/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# ggcode Python wrapper
|
|
2
|
+
|
|
3
|
+
`ggcode` on PyPI bootstraps the native `ggcode` terminal agent from GitHub Releases.
|
|
4
|
+
|
|
5
|
+
When the bootstrap runs, it installs the real binary into a stable CLI location instead of keeping
|
|
6
|
+
it in a wrapper-managed cache:
|
|
7
|
+
|
|
8
|
+
- macOS / Linux: prefers `/usr/local/bin`, falls back to `~/.local/bin`
|
|
9
|
+
- Windows: prefers `%USERPROFILE%\\AppData\\Local\\Programs\\ggcode\\bin`, falls back to `%USERPROFILE%\\.local\\bin`
|
|
10
|
+
|
|
11
|
+
If that directory is not already on `PATH`, the bootstrap updates your PATH configuration and asks
|
|
12
|
+
you to reopen the terminal so future `ggcode` launches resolve directly to the native binary.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install ggcode
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then run:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
ggcode
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
If you ever need to rerun the bootstrap flow explicitly, you can also use:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
ggcode-bootstrap
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## What it does
|
|
33
|
+
|
|
34
|
+
- Detects your operating system and CPU architecture
|
|
35
|
+
- Downloads the latest matching `ggcode` archive from GitHub Releases
|
|
36
|
+
- Verifies the archive against `checksums.txt`
|
|
37
|
+
- Installs the real binary into a stable PATH location
|
|
38
|
+
- Updates PATH so future `ggcode` launches bypass the Python wrapper
|
|
39
|
+
|
|
40
|
+
## Pin a specific ggcode release
|
|
41
|
+
|
|
42
|
+
By default, the wrapper always resolves the latest `ggcode` release.
|
|
43
|
+
|
|
44
|
+
To force a specific release, set `GGCODE_INSTALL_VERSION`:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
GGCODE_INSTALL_VERSION=vX.Y.Z ggcode
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
or:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
GGCODE_INSTALL_VERSION=X.Y.Z ggcode
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Supported platforms
|
|
57
|
+
|
|
58
|
+
- macOS
|
|
59
|
+
- Linux
|
|
60
|
+
- Windows
|
|
61
|
+
|
|
62
|
+
Supported architectures:
|
|
63
|
+
|
|
64
|
+
- x86_64 / amd64
|
|
65
|
+
- arm64
|
|
66
|
+
|
|
67
|
+
## Project links
|
|
68
|
+
|
|
69
|
+
- Repository: https://github.com/topcheer/ggcode
|
|
70
|
+
- Issues: https://github.com/topcheer/ggcode/issues
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ggcode
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.3
|
|
4
4
|
Summary: Thin Python wrapper that installs the ggcode GitHub Release binary
|
|
5
5
|
Author: topcheer
|
|
6
6
|
License: MIT
|
|
@@ -12,10 +12,16 @@ Description-Content-Type: text/markdown
|
|
|
12
12
|
|
|
13
13
|
# ggcode Python wrapper
|
|
14
14
|
|
|
15
|
-
`ggcode` on PyPI
|
|
15
|
+
`ggcode` on PyPI bootstraps the native `ggcode` terminal agent from GitHub Releases.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
When the bootstrap runs, it installs the real binary into a stable CLI location instead of keeping
|
|
18
|
+
it in a wrapper-managed cache:
|
|
19
|
+
|
|
20
|
+
- macOS / Linux: prefers `/usr/local/bin`, falls back to `~/.local/bin`
|
|
21
|
+
- Windows: prefers `%USERPROFILE%\\AppData\\Local\\Programs\\ggcode\\bin`, falls back to `%USERPROFILE%\\.local\\bin`
|
|
22
|
+
|
|
23
|
+
If that directory is not already on `PATH`, the bootstrap updates your PATH configuration and asks
|
|
24
|
+
you to reopen the terminal so future `ggcode` launches resolve directly to the native binary.
|
|
19
25
|
|
|
20
26
|
## Install
|
|
21
27
|
|
|
@@ -29,12 +35,19 @@ Then run:
|
|
|
29
35
|
ggcode
|
|
30
36
|
```
|
|
31
37
|
|
|
38
|
+
If you ever need to rerun the bootstrap flow explicitly, you can also use:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
ggcode-bootstrap
|
|
42
|
+
```
|
|
43
|
+
|
|
32
44
|
## What it does
|
|
33
45
|
|
|
34
46
|
- Detects your operating system and CPU architecture
|
|
35
47
|
- Downloads the latest matching `ggcode` archive from GitHub Releases
|
|
36
48
|
- Verifies the archive against `checksums.txt`
|
|
37
|
-
-
|
|
49
|
+
- Installs the real binary into a stable PATH location
|
|
50
|
+
- Updates PATH so future `ggcode` launches bypass the Python wrapper
|
|
38
51
|
|
|
39
52
|
## Pin a specific ggcode release
|
|
40
53
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.1.3"
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import stat
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tarfile
|
|
14
|
+
import tempfile
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import urllib.request
|
|
17
|
+
import zipfile
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
OWNER = "topcheer"
|
|
22
|
+
REPO = "ggcode"
|
|
23
|
+
MARKER_START = "# >>> ggcode PATH >>>"
|
|
24
|
+
MARKER_END = "# <<< ggcode PATH <<<"
|
|
25
|
+
METADATA = ".ggcode-wrapper.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class InstallResult:
|
|
30
|
+
binary_path: Path
|
|
31
|
+
install_dir: Path
|
|
32
|
+
version: str
|
|
33
|
+
path_updated: bool
|
|
34
|
+
installed_now: bool
|
|
35
|
+
needs_restart: bool
|
|
36
|
+
used_fallback: bool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def normalize_version() -> str:
|
|
40
|
+
selected = os.environ.get("GGCODE_INSTALL_VERSION", "").strip()
|
|
41
|
+
if not selected or selected == "latest":
|
|
42
|
+
return "latest"
|
|
43
|
+
if selected.startswith("v"):
|
|
44
|
+
return selected
|
|
45
|
+
return f"v{selected}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_target() -> tuple[str, str]:
|
|
49
|
+
system = sys.platform
|
|
50
|
+
if system.startswith("linux"):
|
|
51
|
+
goos = "linux"
|
|
52
|
+
elif system == "darwin":
|
|
53
|
+
goos = "darwin"
|
|
54
|
+
elif system in ("win32", "cygwin"):
|
|
55
|
+
goos = "windows"
|
|
56
|
+
else:
|
|
57
|
+
raise RuntimeError(f"Unsupported platform: {system}")
|
|
58
|
+
|
|
59
|
+
machine = platform.machine().lower() or os.environ.get("PROCESSOR_ARCHITECTURE", "").lower()
|
|
60
|
+
if machine in ("x86_64", "amd64"):
|
|
61
|
+
arch = "x86_64"
|
|
62
|
+
elif machine in ("arm64", "aarch64"):
|
|
63
|
+
arch = "arm64"
|
|
64
|
+
else:
|
|
65
|
+
raise RuntimeError(f"Unsupported architecture: {machine or 'unknown'}")
|
|
66
|
+
return goos, arch
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def release_base(version: str) -> str:
|
|
70
|
+
if version == "latest":
|
|
71
|
+
return f"https://github.com/{OWNER}/{REPO}/releases/latest/download"
|
|
72
|
+
return f"https://github.com/{OWNER}/{REPO}/releases/download/{version}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def preferred_install_dirs() -> list[Path]:
|
|
76
|
+
home = Path.home()
|
|
77
|
+
if os.name == "nt":
|
|
78
|
+
return [
|
|
79
|
+
home / "AppData" / "Local" / "Programs" / "ggcode" / "bin",
|
|
80
|
+
home / ".local" / "bin",
|
|
81
|
+
]
|
|
82
|
+
return [Path("/usr/local/bin"), home / ".local" / "bin"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def metadata_path(directory: Path) -> Path:
|
|
86
|
+
return directory / METADATA
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def download(url: str) -> bytes:
|
|
90
|
+
with urllib.request.urlopen(url) as response:
|
|
91
|
+
return response.read()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def resolve_release_version(version: str) -> str:
|
|
95
|
+
if version != "latest":
|
|
96
|
+
return version
|
|
97
|
+
|
|
98
|
+
with urllib.request.urlopen(f"https://github.com/{OWNER}/{REPO}/releases/latest") as response:
|
|
99
|
+
final_url = response.geturl()
|
|
100
|
+
|
|
101
|
+
parsed = urllib.parse.urlparse(final_url)
|
|
102
|
+
parts = [part for part in parsed.path.split("/") if part]
|
|
103
|
+
if len(parts) < 4 or parts[-2] != "tag":
|
|
104
|
+
raise RuntimeError(f"Could not resolve latest ggcode release from {final_url}")
|
|
105
|
+
return urllib.parse.unquote(parts[-1])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_checksums(body: str) -> dict[str, str]:
|
|
109
|
+
checksums: dict[str, str] = {}
|
|
110
|
+
for raw_line in body.splitlines():
|
|
111
|
+
parts = raw_line.strip().split()
|
|
112
|
+
if len(parts) >= 2:
|
|
113
|
+
checksums[parts[-1]] = parts[0]
|
|
114
|
+
return checksums
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def read_metadata(directory: Path) -> dict[str, str] | None:
|
|
118
|
+
try:
|
|
119
|
+
return json.loads(metadata_path(directory).read_text(encoding="utf-8"))
|
|
120
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def write_metadata(directory: Path, version: str) -> None:
|
|
125
|
+
metadata_path(directory).write_text(json.dumps({"version": version}, indent=2) + "\n", encoding="utf-8")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def existing_install(requested_version: str, binary_name: str) -> InstallResult | None:
|
|
129
|
+
for directory in preferred_install_dirs():
|
|
130
|
+
binary_path = directory / binary_name
|
|
131
|
+
if not binary_path.exists():
|
|
132
|
+
continue
|
|
133
|
+
metadata = read_metadata(directory) or {}
|
|
134
|
+
if requested_version != "latest" and metadata.get("version") != requested_version:
|
|
135
|
+
continue
|
|
136
|
+
path_updated = ensure_installed_path(directory)
|
|
137
|
+
return InstallResult(
|
|
138
|
+
binary_path=binary_path,
|
|
139
|
+
install_dir=directory,
|
|
140
|
+
version=metadata.get("version", "unknown"),
|
|
141
|
+
path_updated=path_updated,
|
|
142
|
+
installed_now=False,
|
|
143
|
+
needs_restart=path_updated,
|
|
144
|
+
used_fallback=directory != preferred_install_dirs()[0],
|
|
145
|
+
)
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def ensure_installed() -> InstallResult:
|
|
150
|
+
requested_version = normalize_version()
|
|
151
|
+
goos, arch = resolve_target()
|
|
152
|
+
archive_ext = ".zip" if goos == "windows" else ".tar.gz"
|
|
153
|
+
archive_name = f"ggcode_{goos}_{arch}{archive_ext}"
|
|
154
|
+
binary_name = "ggcode.exe" if goos == "windows" else "ggcode"
|
|
155
|
+
|
|
156
|
+
existing = existing_install(requested_version, binary_name)
|
|
157
|
+
if existing is not None:
|
|
158
|
+
return existing
|
|
159
|
+
|
|
160
|
+
version = resolve_release_version(requested_version)
|
|
161
|
+
base = release_base(version)
|
|
162
|
+
archive = download(f"{base}/{archive_name}")
|
|
163
|
+
checksums = parse_checksums(download(f"{base}/checksums.txt").decode("utf-8"))
|
|
164
|
+
expected = checksums.get(archive_name)
|
|
165
|
+
if not expected:
|
|
166
|
+
raise RuntimeError(f"Checksum for {archive_name} not found")
|
|
167
|
+
actual = hashlib.sha256(archive).hexdigest()
|
|
168
|
+
if actual.lower() != expected.lower():
|
|
169
|
+
raise RuntimeError(f"Checksum mismatch for {archive_name}")
|
|
170
|
+
|
|
171
|
+
with tempfile.TemporaryDirectory(prefix="ggcode-py-") as temp_dir:
|
|
172
|
+
temp_root = Path(temp_dir)
|
|
173
|
+
archive_path = temp_root / archive_name
|
|
174
|
+
archive_path.write_bytes(archive)
|
|
175
|
+
extract_dir = temp_root / "extract"
|
|
176
|
+
extract_dir.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
|
|
178
|
+
if archive_ext == ".zip":
|
|
179
|
+
with zipfile.ZipFile(archive_path) as zf:
|
|
180
|
+
zf.extractall(extract_dir)
|
|
181
|
+
else:
|
|
182
|
+
with tarfile.open(archive_path, "r:gz") as tf:
|
|
183
|
+
tf.extractall(extract_dir)
|
|
184
|
+
|
|
185
|
+
extracted = next((p for p in extract_dir.rglob(binary_name) if p.is_file()), None)
|
|
186
|
+
if extracted is None:
|
|
187
|
+
raise RuntimeError(f"Could not find {binary_name} in {archive_name}")
|
|
188
|
+
|
|
189
|
+
last_error: Exception | None = None
|
|
190
|
+
preferred = preferred_install_dirs()
|
|
191
|
+
for index, install_dir in enumerate(preferred):
|
|
192
|
+
try:
|
|
193
|
+
binary_path = install_binary(extracted, install_dir / binary_name, version)
|
|
194
|
+
path_updated = ensure_installed_path(install_dir)
|
|
195
|
+
return InstallResult(
|
|
196
|
+
binary_path=binary_path,
|
|
197
|
+
install_dir=install_dir,
|
|
198
|
+
version=version,
|
|
199
|
+
path_updated=path_updated,
|
|
200
|
+
installed_now=True,
|
|
201
|
+
needs_restart=path_updated,
|
|
202
|
+
used_fallback=index > 0,
|
|
203
|
+
)
|
|
204
|
+
except OSError as exc:
|
|
205
|
+
last_error = exc
|
|
206
|
+
if not is_permission_error(exc) or index == len(preferred) - 1:
|
|
207
|
+
break
|
|
208
|
+
|
|
209
|
+
if last_error is not None:
|
|
210
|
+
raise RuntimeError(f"Failed to install ggcode: {last_error}") from last_error
|
|
211
|
+
|
|
212
|
+
raise RuntimeError("Failed to install ggcode")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def install_binary(source: Path, destination: Path, version: str) -> Path:
|
|
216
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
temp_path = destination.with_name(f"{destination.name}.tmp-{os.getpid()}")
|
|
218
|
+
shutil.copy2(source, temp_path)
|
|
219
|
+
if os.name != "nt":
|
|
220
|
+
current_mode = temp_path.stat().st_mode
|
|
221
|
+
temp_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
222
|
+
os.replace(temp_path, destination)
|
|
223
|
+
write_metadata(destination.parent, version)
|
|
224
|
+
return destination
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def is_permission_error(exc: OSError) -> bool:
|
|
228
|
+
return exc.errno in {errno.EACCES, errno.EPERM}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def ensure_installed_path(directory: Path) -> bool:
|
|
232
|
+
entries = path_entries(os.environ.get("PATH", ""))
|
|
233
|
+
if any(same_path(entry, directory) for entry in entries):
|
|
234
|
+
return False
|
|
235
|
+
if os.name == "nt":
|
|
236
|
+
changed = ensure_windows_user_path(directory)
|
|
237
|
+
else:
|
|
238
|
+
changed = ensure_unix_path(directory)
|
|
239
|
+
os.environ["PATH"] = os.pathsep.join([str(directory), *entries])
|
|
240
|
+
return changed
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def path_entries(value: str) -> list[str]:
|
|
244
|
+
return [entry.strip() for entry in value.split(os.pathsep) if entry.strip()]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def same_path(left: str | Path, right: str | Path) -> bool:
|
|
248
|
+
left_path = os.path.abspath(os.fspath(left))
|
|
249
|
+
right_path = os.path.abspath(os.fspath(right))
|
|
250
|
+
if os.name == "nt":
|
|
251
|
+
left_path = left_path.rstrip("\\/").lower()
|
|
252
|
+
right_path = right_path.rstrip("\\/").lower()
|
|
253
|
+
return left_path == right_path
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def ensure_windows_user_path(directory: Path) -> bool:
|
|
257
|
+
escaped_dir = str(directory).replace("'", "''")
|
|
258
|
+
script = (
|
|
259
|
+
f"$dir = '{escaped_dir}'; "
|
|
260
|
+
"$current = [Environment]::GetEnvironmentVariable('Path', 'User'); "
|
|
261
|
+
"$parts = @(); "
|
|
262
|
+
"if ($current) { $parts = $current -split ';' | Where-Object { $_ -and $_.Trim() -ne '' } }; "
|
|
263
|
+
"$exists = $parts | Where-Object { $_.TrimEnd('\\\\') -ieq $dir.TrimEnd('\\\\') }; "
|
|
264
|
+
"if (-not $exists) { "
|
|
265
|
+
" $new = @($dir) + $parts; "
|
|
266
|
+
" [Environment]::SetEnvironmentVariable('Path', ($new -join ';'), 'User'); "
|
|
267
|
+
" Write-Output 'updated' "
|
|
268
|
+
"} else { "
|
|
269
|
+
" Write-Output 'unchanged' "
|
|
270
|
+
"}"
|
|
271
|
+
)
|
|
272
|
+
result = subprocess.run(
|
|
273
|
+
["powershell", "-NoProfile", "-Command", script],
|
|
274
|
+
check=True,
|
|
275
|
+
text=True,
|
|
276
|
+
capture_output=True,
|
|
277
|
+
)
|
|
278
|
+
return result.stdout.strip() == "updated"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def ensure_unix_path(directory: Path) -> bool:
|
|
282
|
+
changed = False
|
|
283
|
+
for target in profile_targets():
|
|
284
|
+
before = target.read_text(encoding="utf-8") if target.exists() else ""
|
|
285
|
+
after = upsert_path_block(before, directory)
|
|
286
|
+
if after != before:
|
|
287
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
288
|
+
target.write_text(after, encoding="utf-8")
|
|
289
|
+
changed = True
|
|
290
|
+
return changed
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def profile_targets() -> list[Path]:
|
|
294
|
+
home = Path.home()
|
|
295
|
+
shell = Path(os.environ.get("SHELL", "")).name
|
|
296
|
+
preferred: list[str] = []
|
|
297
|
+
if shell == "zsh":
|
|
298
|
+
preferred.extend([".zshrc", ".zprofile"])
|
|
299
|
+
elif shell == "bash":
|
|
300
|
+
preferred.extend([".bashrc", ".bash_profile"])
|
|
301
|
+
preferred.append(".profile")
|
|
302
|
+
|
|
303
|
+
existing = [".zshrc", ".zprofile", ".bashrc", ".bash_profile", ".profile"]
|
|
304
|
+
targets: list[Path] = []
|
|
305
|
+
for name in preferred + [name for name in existing if (home / name).exists()]:
|
|
306
|
+
path = home / name
|
|
307
|
+
if path not in targets:
|
|
308
|
+
targets.append(path)
|
|
309
|
+
return targets
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def upsert_path_block(content: str, directory: Path) -> str:
|
|
313
|
+
block = f'{MARKER_START}\nexport PATH="{directory}:$PATH"\n{MARKER_END}\n'
|
|
314
|
+
pattern = re.compile(re.escape(MARKER_START) + r"[\s\S]*?" + re.escape(MARKER_END) + r"\n?", re.MULTILINE)
|
|
315
|
+
if pattern.search(content):
|
|
316
|
+
return pattern.sub(block, content)
|
|
317
|
+
suffix = "\n" if content and not content.endswith("\n") else ""
|
|
318
|
+
return f"{content}{suffix}{block}"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def print_install_message(result: InstallResult) -> None:
|
|
322
|
+
action = "installed" if result.installed_now else "already available"
|
|
323
|
+
print(f"ggcode {result.version} {action} at {result.binary_path}", file=sys.stderr)
|
|
324
|
+
if result.needs_restart:
|
|
325
|
+
print("Reopen your terminal, then run `ggcode` directly.", file=sys.stderr)
|
|
326
|
+
print("If you ever need to rerun the bootstrap flow, use `ggcode-bootstrap`.", file=sys.stderr)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def main() -> int:
|
|
330
|
+
result = ensure_installed()
|
|
331
|
+
if result.installed_now or result.needs_restart:
|
|
332
|
+
print_install_message(result)
|
|
333
|
+
env = dict(os.environ)
|
|
334
|
+
env["GGCODE_WRAPPER_KIND"] = "python"
|
|
335
|
+
completed = subprocess.run([str(result.binary_path), *sys.argv[1:]], env=env)
|
|
336
|
+
return completed.returncode
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def bootstrap_main() -> int:
|
|
340
|
+
return main()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
if __name__ == "__main__":
|
|
344
|
+
raise SystemExit(main())
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ggcode"
|
|
7
|
-
version = "1.1.
|
|
7
|
+
version = "1.1.3"
|
|
8
8
|
description = "Thin Python wrapper that installs the ggcode GitHub Release binary"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -18,6 +18,7 @@ Issues = "https://github.com/topcheer/ggcode/issues"
|
|
|
18
18
|
|
|
19
19
|
[project.scripts]
|
|
20
20
|
ggcode = "ggcode_release_installer.cli:main"
|
|
21
|
+
ggcode-bootstrap = "ggcode_release_installer.cli:bootstrap_main"
|
|
21
22
|
|
|
22
23
|
[tool.setuptools]
|
|
23
24
|
packages = ["ggcode_release_installer"]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
from ggcode_release_installer import cli
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ResolveTargetTests(unittest.TestCase):
|
|
13
|
+
def test_normalize_version_defaults_to_latest(self) -> None:
|
|
14
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
15
|
+
self.assertEqual(cli.normalize_version(), "latest")
|
|
16
|
+
|
|
17
|
+
def test_normalize_version_uses_explicit_override(self) -> None:
|
|
18
|
+
with patch.dict(os.environ, {"GGCODE_INSTALL_VERSION": "1.2.3"}, clear=True):
|
|
19
|
+
self.assertEqual(cli.normalize_version(), "v1.2.3")
|
|
20
|
+
|
|
21
|
+
def test_resolve_target_uses_platform_module_for_machine(self) -> None:
|
|
22
|
+
with patch.object(cli.sys, "platform", "darwin"):
|
|
23
|
+
with patch.object(cli.platform, "machine", return_value="arm64"):
|
|
24
|
+
self.assertEqual(cli.resolve_target(), ("darwin", "arm64"))
|
|
25
|
+
|
|
26
|
+
def test_resolve_target_falls_back_to_processor_architecture(self) -> None:
|
|
27
|
+
env = dict(os.environ, PROCESSOR_ARCHITECTURE="AMD64")
|
|
28
|
+
with patch.dict(os.environ, env, clear=True):
|
|
29
|
+
with patch.object(cli.sys, "platform", "win32"):
|
|
30
|
+
with patch.object(cli.platform, "machine", return_value=""):
|
|
31
|
+
self.assertEqual(cli.resolve_target(), ("windows", "x86_64"))
|
|
32
|
+
|
|
33
|
+
def test_resolve_release_version_follows_latest_redirect(self) -> None:
|
|
34
|
+
response = unittest.mock.Mock()
|
|
35
|
+
response.geturl.return_value = "https://github.com/topcheer/ggcode/releases/tag/v9.9.9"
|
|
36
|
+
response.__enter__ = unittest.mock.Mock(return_value=response)
|
|
37
|
+
response.__exit__ = unittest.mock.Mock(return_value=False)
|
|
38
|
+
with patch.object(cli.urllib.request, "urlopen", return_value=response):
|
|
39
|
+
self.assertEqual(cli.resolve_release_version("latest"), "v9.9.9")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PathHandlingTests(unittest.TestCase):
|
|
43
|
+
def test_preferred_install_dirs_unix(self) -> None:
|
|
44
|
+
with patch.object(cli, "os") as fake_os:
|
|
45
|
+
fake_os.name = "posix"
|
|
46
|
+
with patch.object(cli.Path, "home", return_value=Path("/home/tester")):
|
|
47
|
+
dirs = cli.preferred_install_dirs()
|
|
48
|
+
self.assertEqual(dirs, [Path("/usr/local/bin"), Path("/home/tester/.local/bin")])
|
|
49
|
+
|
|
50
|
+
def test_upsert_path_block_appends_marker(self) -> None:
|
|
51
|
+
updated = cli.upsert_path_block("export FOO=bar\n", Path("/tmp/ggcode-bin"))
|
|
52
|
+
self.assertIn(cli.MARKER_START, updated)
|
|
53
|
+
self.assertIn('export PATH="/tmp/ggcode-bin:$PATH"', updated)
|
|
54
|
+
|
|
55
|
+
def test_upsert_path_block_replaces_existing_marker(self) -> None:
|
|
56
|
+
original = (
|
|
57
|
+
f"{cli.MARKER_START}\n"
|
|
58
|
+
'export PATH="/old/bin:$PATH"\n'
|
|
59
|
+
f"{cli.MARKER_END}\n"
|
|
60
|
+
)
|
|
61
|
+
updated = cli.upsert_path_block(original, Path("/new/bin"))
|
|
62
|
+
self.assertNotIn("/old/bin", updated)
|
|
63
|
+
self.assertIn('export PATH="/new/bin:$PATH"', updated)
|
|
64
|
+
|
|
65
|
+
def test_existing_install_uses_binary_without_resolving_latest(self) -> None:
|
|
66
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
67
|
+
install_dir = Path(temp_dir)
|
|
68
|
+
binary_path = install_dir / "ggcode"
|
|
69
|
+
binary_path.write_text("stub", encoding="utf-8")
|
|
70
|
+
cli.write_metadata(install_dir, "v1.0.21")
|
|
71
|
+
|
|
72
|
+
with patch.object(cli, "preferred_install_dirs", return_value=[install_dir]):
|
|
73
|
+
with patch.object(cli, "ensure_installed_path", return_value=False):
|
|
74
|
+
result = cli.existing_install("latest", "ggcode")
|
|
75
|
+
|
|
76
|
+
self.assertIsNotNone(result)
|
|
77
|
+
assert result is not None
|
|
78
|
+
self.assertEqual(result.binary_path, binary_path)
|
|
79
|
+
self.assertEqual(result.version, "v1.0.21")
|
|
80
|
+
|
|
81
|
+
def test_install_binary_writes_metadata(self) -> None:
|
|
82
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
83
|
+
temp_root = Path(temp_dir)
|
|
84
|
+
source = temp_root / "source"
|
|
85
|
+
source.write_text("ggcode", encoding="utf-8")
|
|
86
|
+
destination = temp_root / "bin" / "ggcode"
|
|
87
|
+
|
|
88
|
+
installed = cli.install_binary(source, destination, "v9.9.9")
|
|
89
|
+
|
|
90
|
+
self.assertEqual(installed, destination)
|
|
91
|
+
self.assertEqual(destination.read_text(encoding="utf-8"), "ggcode")
|
|
92
|
+
self.assertEqual(cli.read_metadata(destination.parent), {"version": "v9.9.9"})
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
unittest.main()
|
ggcode-1.1.1/README.md
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
# ggcode Python wrapper
|
|
2
|
-
|
|
3
|
-
`ggcode` on PyPI is a thin Python wrapper for the `ggcode` terminal agent.
|
|
4
|
-
|
|
5
|
-
It does not ship the platform binary inside the wheel. Instead, the wrapper downloads the latest
|
|
6
|
-
matching `ggcode` GitHub Release on first run, caches it locally, and then launches it.
|
|
7
|
-
|
|
8
|
-
## Install
|
|
9
|
-
|
|
10
|
-
```bash
|
|
11
|
-
pip install ggcode
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
Then run:
|
|
15
|
-
|
|
16
|
-
```bash
|
|
17
|
-
ggcode
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
## What it does
|
|
21
|
-
|
|
22
|
-
- Detects your operating system and CPU architecture
|
|
23
|
-
- Downloads the latest matching `ggcode` archive from GitHub Releases
|
|
24
|
-
- Verifies the archive against `checksums.txt`
|
|
25
|
-
- Extracts and caches the binary for future runs
|
|
26
|
-
|
|
27
|
-
## Pin a specific ggcode release
|
|
28
|
-
|
|
29
|
-
By default, the wrapper always resolves the latest `ggcode` release.
|
|
30
|
-
|
|
31
|
-
To force a specific release, set `GGCODE_INSTALL_VERSION`:
|
|
32
|
-
|
|
33
|
-
```bash
|
|
34
|
-
GGCODE_INSTALL_VERSION=vX.Y.Z ggcode
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
or:
|
|
38
|
-
|
|
39
|
-
```bash
|
|
40
|
-
GGCODE_INSTALL_VERSION=X.Y.Z ggcode
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
## Supported platforms
|
|
44
|
-
|
|
45
|
-
- macOS
|
|
46
|
-
- Linux
|
|
47
|
-
- Windows
|
|
48
|
-
|
|
49
|
-
Supported architectures:
|
|
50
|
-
|
|
51
|
-
- x86_64 / amd64
|
|
52
|
-
- arm64
|
|
53
|
-
|
|
54
|
-
## Project links
|
|
55
|
-
|
|
56
|
-
- Repository: https://github.com/topcheer/ggcode
|
|
57
|
-
- Issues: https://github.com/topcheer/ggcode/issues
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.1.1"
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import hashlib
|
|
4
|
-
import os
|
|
5
|
-
import platform
|
|
6
|
-
import shutil
|
|
7
|
-
import stat
|
|
8
|
-
import subprocess
|
|
9
|
-
import sys
|
|
10
|
-
import tarfile
|
|
11
|
-
import tempfile
|
|
12
|
-
import urllib.parse
|
|
13
|
-
import urllib.request
|
|
14
|
-
import zipfile
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
|
|
17
|
-
OWNER = "topcheer"
|
|
18
|
-
REPO = "ggcode"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def normalize_version() -> str:
|
|
22
|
-
selected = os.environ.get("GGCODE_INSTALL_VERSION", "").strip()
|
|
23
|
-
if not selected or selected == "latest":
|
|
24
|
-
return "latest"
|
|
25
|
-
if selected.startswith("v"):
|
|
26
|
-
return selected
|
|
27
|
-
return f"v{selected}"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def resolve_target() -> tuple[str, str]:
|
|
31
|
-
system = sys.platform
|
|
32
|
-
if system.startswith("linux"):
|
|
33
|
-
goos = "linux"
|
|
34
|
-
elif system == "darwin":
|
|
35
|
-
goos = "darwin"
|
|
36
|
-
elif system in ("win32", "cygwin"):
|
|
37
|
-
goos = "windows"
|
|
38
|
-
else:
|
|
39
|
-
raise RuntimeError(f"Unsupported platform: {system}")
|
|
40
|
-
|
|
41
|
-
machine = platform.machine().lower() or os.environ.get("PROCESSOR_ARCHITECTURE", "").lower()
|
|
42
|
-
if machine in ("x86_64", "amd64"):
|
|
43
|
-
arch = "x86_64"
|
|
44
|
-
elif machine in ("arm64", "aarch64"):
|
|
45
|
-
arch = "arm64"
|
|
46
|
-
else:
|
|
47
|
-
raise RuntimeError(f"Unsupported architecture: {machine or 'unknown'}")
|
|
48
|
-
return goos, arch
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def release_base(version: str) -> str:
|
|
52
|
-
if version == "latest":
|
|
53
|
-
return f"https://github.com/{OWNER}/{REPO}/releases/latest/download"
|
|
54
|
-
return f"https://github.com/{OWNER}/{REPO}/releases/download/{version}"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def cache_root() -> Path:
|
|
58
|
-
if os.name == "nt":
|
|
59
|
-
base = Path(os.environ.get("LOCALAPPDATA", tempfile.gettempdir()))
|
|
60
|
-
return base / "ggcode" / "python"
|
|
61
|
-
return Path.home() / ".cache" / "ggcode" / "python"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def download(url: str) -> bytes:
|
|
65
|
-
with urllib.request.urlopen(url) as response:
|
|
66
|
-
return response.read()
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def resolve_release_version(version: str) -> str:
|
|
70
|
-
if version != "latest":
|
|
71
|
-
return version
|
|
72
|
-
|
|
73
|
-
with urllib.request.urlopen(f"https://github.com/{OWNER}/{REPO}/releases/latest") as response:
|
|
74
|
-
final_url = response.geturl()
|
|
75
|
-
|
|
76
|
-
parsed = urllib.parse.urlparse(final_url)
|
|
77
|
-
parts = [part for part in parsed.path.split("/") if part]
|
|
78
|
-
if len(parts) < 4 or parts[-2] != "tag":
|
|
79
|
-
raise RuntimeError(f"Could not resolve latest ggcode release from {final_url}")
|
|
80
|
-
return urllib.parse.unquote(parts[-1])
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def parse_checksums(body: str) -> dict[str, str]:
|
|
84
|
-
checksums: dict[str, str] = {}
|
|
85
|
-
for raw_line in body.splitlines():
|
|
86
|
-
parts = raw_line.strip().split()
|
|
87
|
-
if len(parts) >= 2:
|
|
88
|
-
checksums[parts[-1]] = parts[0]
|
|
89
|
-
return checksums
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def ensure_installed() -> Path:
|
|
93
|
-
version = resolve_release_version(normalize_version())
|
|
94
|
-
goos, arch = resolve_target()
|
|
95
|
-
archive_ext = ".zip" if goos == "windows" else ".tar.gz"
|
|
96
|
-
archive_name = f"ggcode_{goos}_{arch}{archive_ext}"
|
|
97
|
-
binary_name = "ggcode.exe" if goos == "windows" else "ggcode"
|
|
98
|
-
install_dir = cache_root() / version / f"{goos}-{arch}"
|
|
99
|
-
binary_path = install_dir / binary_name
|
|
100
|
-
if binary_path.exists():
|
|
101
|
-
return binary_path
|
|
102
|
-
|
|
103
|
-
base = release_base(version)
|
|
104
|
-
archive = download(f"{base}/{archive_name}")
|
|
105
|
-
checksums = parse_checksums(download(f"{base}/checksums.txt").decode("utf-8"))
|
|
106
|
-
expected = checksums.get(archive_name)
|
|
107
|
-
if not expected:
|
|
108
|
-
raise RuntimeError(f"Checksum for {archive_name} not found")
|
|
109
|
-
actual = hashlib.sha256(archive).hexdigest()
|
|
110
|
-
if actual.lower() != expected.lower():
|
|
111
|
-
raise RuntimeError(f"Checksum mismatch for {archive_name}")
|
|
112
|
-
|
|
113
|
-
with tempfile.TemporaryDirectory(prefix="ggcode-py-") as temp_dir:
|
|
114
|
-
temp_root = Path(temp_dir)
|
|
115
|
-
archive_path = temp_root / archive_name
|
|
116
|
-
archive_path.write_bytes(archive)
|
|
117
|
-
extract_dir = temp_root / "extract"
|
|
118
|
-
extract_dir.mkdir(parents=True, exist_ok=True)
|
|
119
|
-
|
|
120
|
-
if archive_ext == ".zip":
|
|
121
|
-
with zipfile.ZipFile(archive_path) as zf:
|
|
122
|
-
zf.extractall(extract_dir)
|
|
123
|
-
else:
|
|
124
|
-
with tarfile.open(archive_path, "r:gz") as tf:
|
|
125
|
-
tf.extractall(extract_dir)
|
|
126
|
-
|
|
127
|
-
extracted = next((p for p in extract_dir.rglob(binary_name) if p.is_file()), None)
|
|
128
|
-
if extracted is None:
|
|
129
|
-
raise RuntimeError(f"Could not find {binary_name} in {archive_name}")
|
|
130
|
-
|
|
131
|
-
install_dir.mkdir(parents=True, exist_ok=True)
|
|
132
|
-
shutil.copy2(extracted, binary_path)
|
|
133
|
-
if os.name != "nt":
|
|
134
|
-
current_mode = binary_path.stat().st_mode
|
|
135
|
-
binary_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
136
|
-
|
|
137
|
-
return binary_path
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def main() -> int:
|
|
141
|
-
binary = ensure_installed()
|
|
142
|
-
result = subprocess.run([str(binary), *sys.argv[1:]])
|
|
143
|
-
return result.returncode
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if __name__ == "__main__":
|
|
147
|
-
raise SystemExit(main())
|
ggcode-1.1.1/tests/test_cli.py
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import unittest
|
|
5
|
-
from unittest.mock import patch
|
|
6
|
-
|
|
7
|
-
from ggcode_release_installer import cli
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class ResolveTargetTests(unittest.TestCase):
|
|
11
|
-
def test_normalize_version_defaults_to_latest(self) -> None:
|
|
12
|
-
with patch.dict(os.environ, {}, clear=True):
|
|
13
|
-
self.assertEqual(cli.normalize_version(), "latest")
|
|
14
|
-
|
|
15
|
-
def test_normalize_version_uses_explicit_override(self) -> None:
|
|
16
|
-
with patch.dict(os.environ, {"GGCODE_INSTALL_VERSION": "1.2.3"}, clear=True):
|
|
17
|
-
self.assertEqual(cli.normalize_version(), "v1.2.3")
|
|
18
|
-
|
|
19
|
-
def test_resolve_target_uses_platform_module_for_machine(self) -> None:
|
|
20
|
-
with patch.object(cli.sys, "platform", "darwin"):
|
|
21
|
-
with patch.object(cli.platform, "machine", return_value="arm64"):
|
|
22
|
-
self.assertEqual(cli.resolve_target(), ("darwin", "arm64"))
|
|
23
|
-
|
|
24
|
-
def test_resolve_target_falls_back_to_processor_architecture(self) -> None:
|
|
25
|
-
env = dict(os.environ, PROCESSOR_ARCHITECTURE="AMD64")
|
|
26
|
-
with patch.dict(os.environ, env, clear=True):
|
|
27
|
-
with patch.object(cli.sys, "platform", "win32"):
|
|
28
|
-
with patch.object(cli.platform, "machine", return_value=""):
|
|
29
|
-
self.assertEqual(cli.resolve_target(), ("windows", "x86_64"))
|
|
30
|
-
|
|
31
|
-
def test_resolve_release_version_follows_latest_redirect(self) -> None:
|
|
32
|
-
response = unittest.mock.Mock()
|
|
33
|
-
response.geturl.return_value = "https://github.com/topcheer/ggcode/releases/tag/v9.9.9"
|
|
34
|
-
response.__enter__ = unittest.mock.Mock(return_value=response)
|
|
35
|
-
response.__exit__ = unittest.mock.Mock(return_value=False)
|
|
36
|
-
with patch.object(cli.urllib.request, "urlopen", return_value=response):
|
|
37
|
-
self.assertEqual(cli.resolve_release_version("latest"), "v9.9.9")
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if __name__ == "__main__":
|
|
41
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|