kuerzel 0.1.0__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.
- kuerzel-0.1.0/.gitignore +15 -0
- kuerzel-0.1.0/PKG-INFO +82 -0
- kuerzel-0.1.0/README.md +57 -0
- kuerzel-0.1.0/kuerzel/__init__.py +3 -0
- kuerzel-0.1.0/kuerzel/__main__.py +4 -0
- kuerzel-0.1.0/kuerzel/cli.py +210 -0
- kuerzel-0.1.0/pyproject.toml +44 -0
- kuerzel-0.1.0/tests/test_cli.py +123 -0
kuerzel-0.1.0/.gitignore
ADDED
kuerzel-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: kuerzel
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Kürzel, a desktop studio for Espanso snippets. This package downloads and starts the app.
|
|
5
|
+
Project-URL: Homepage, https://github.com/maximilianfeix/kuerzel
|
|
6
|
+
Project-URL: Releases, https://github.com/maximilianfeix/kuerzel/releases
|
|
7
|
+
Project-URL: Issues, https://github.com/maximilianfeix/kuerzel/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/maximilianfeix/kuerzel/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Maxi
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Keywords: espanso,gui,productivity,snippets,text-expander
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: MacOS X
|
|
14
|
+
Classifier: Environment :: Win32 (MS Windows)
|
|
15
|
+
Classifier: Environment :: X11 Applications
|
|
16
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
17
|
+
Classifier: Operating System :: MacOS
|
|
18
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
19
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Topic :: Text Processing
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Kürzel
|
|
27
|
+
|
|
28
|
+
A desktop studio for [Espanso](https://espanso.org) snippets: write, test and check your text expansions without editing YAML.
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
## Run it
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
pipx run kuerzel
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
or install the command for good:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
pipx install kuerzel
|
|
42
|
+
kuerzel
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`uvx kuerzel` and `pip install kuerzel` work too.
|
|
46
|
+
|
|
47
|
+
## What this package is
|
|
48
|
+
|
|
49
|
+
Kürzel is a desktop app, and too large for a Python wheel. This package is a small launcher with no dependencies. On first run it:
|
|
50
|
+
|
|
51
|
+
1. downloads the build for your system from the project's [GitHub release](https://github.com/maximilianfeix/kuerzel/releases) of the same version,
|
|
52
|
+
2. checks it against the release's published SHA-256 checksum and deletes it if it does not match,
|
|
53
|
+
3. keeps it in your user cache folder and starts it.
|
|
54
|
+
|
|
55
|
+
Later runs start the cached app straight away.
|
|
56
|
+
|
|
57
|
+
| System | What is downloaded | What happens |
|
|
58
|
+
| ------------------- | ------------------ | ---------------------------------------------------- |
|
|
59
|
+
| Windows, 64-bit | portable `.exe` | The app starts. |
|
|
60
|
+
| Linux, x86-64 | `.AppImage` | The app starts. |
|
|
61
|
+
| macOS, Intel or ARM | `.dmg` | The disk image opens; drag Kürzel to Applications. |
|
|
62
|
+
|
|
63
|
+
## Options
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
kuerzel download if needed, then start the app
|
|
67
|
+
kuerzel --path print where the app is stored
|
|
68
|
+
kuerzel --remove delete the downloaded app
|
|
69
|
+
kuerzel --version print the version
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Good to know
|
|
73
|
+
|
|
74
|
+
- The builds are not code-signed yet, so Windows and macOS warn on first launch.
|
|
75
|
+
- Kürzel edits the files of an existing [Espanso installation](https://espanso.org/install/). Without Espanso it opens a sample workspace.
|
|
76
|
+
- Kürzel is an independent project and is not affiliated with Espanso.
|
|
77
|
+
|
|
78
|
+
What the app does, how it works and its limits are described in the [main README](https://github.com/maximilianfeix/kuerzel#readme).
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
kuerzel-0.1.0/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Kürzel
|
|
2
|
+
|
|
3
|
+
A desktop studio for [Espanso](https://espanso.org) snippets: write, test and check your text expansions without editing YAML.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Run it
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pipx run kuerzel
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
or install the command for good:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pipx install kuerzel
|
|
17
|
+
kuerzel
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`uvx kuerzel` and `pip install kuerzel` work too.
|
|
21
|
+
|
|
22
|
+
## What this package is
|
|
23
|
+
|
|
24
|
+
Kürzel is a desktop app, and too large for a Python wheel. This package is a small launcher with no dependencies. On first run it:
|
|
25
|
+
|
|
26
|
+
1. downloads the build for your system from the project's [GitHub release](https://github.com/maximilianfeix/kuerzel/releases) of the same version,
|
|
27
|
+
2. checks it against the release's published SHA-256 checksum and deletes it if it does not match,
|
|
28
|
+
3. keeps it in your user cache folder and starts it.
|
|
29
|
+
|
|
30
|
+
Later runs start the cached app straight away.
|
|
31
|
+
|
|
32
|
+
| System | What is downloaded | What happens |
|
|
33
|
+
| ------------------- | ------------------ | ---------------------------------------------------- |
|
|
34
|
+
| Windows, 64-bit | portable `.exe` | The app starts. |
|
|
35
|
+
| Linux, x86-64 | `.AppImage` | The app starts. |
|
|
36
|
+
| macOS, Intel or ARM | `.dmg` | The disk image opens; drag Kürzel to Applications. |
|
|
37
|
+
|
|
38
|
+
## Options
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
kuerzel download if needed, then start the app
|
|
42
|
+
kuerzel --path print where the app is stored
|
|
43
|
+
kuerzel --remove delete the downloaded app
|
|
44
|
+
kuerzel --version print the version
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Good to know
|
|
48
|
+
|
|
49
|
+
- The builds are not code-signed yet, so Windows and macOS warn on first launch.
|
|
50
|
+
- Kürzel edits the files of an existing [Espanso installation](https://espanso.org/install/). Without Espanso it opens a sample workspace.
|
|
51
|
+
- Kürzel is an independent project and is not affiliated with Espanso.
|
|
52
|
+
|
|
53
|
+
What the app does, how it works and its limits are described in the [main README](https://github.com/maximilianfeix/kuerzel#readme).
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Downloads the Kürzel desktop app for this computer and starts it.
|
|
2
|
+
|
|
3
|
+
The app itself is an Electron program and far too large for a Python wheel,
|
|
4
|
+
so this package stays tiny: it fetches the matching build from the project's
|
|
5
|
+
GitHub release, checks it against the published SHA-256 checksum, keeps it in
|
|
6
|
+
the user's cache folder and runs it from there.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import hashlib
|
|
13
|
+
import os
|
|
14
|
+
import platform
|
|
15
|
+
import shutil
|
|
16
|
+
import stat
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import urllib.parse
|
|
21
|
+
import urllib.request
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from kuerzel import __version__
|
|
25
|
+
|
|
26
|
+
REPOSITORY = "maximilianfeix/kuerzel"
|
|
27
|
+
RELEASES = f"https://github.com/{REPOSITORY}/releases"
|
|
28
|
+
CHECKSUMS = "SHA256SUMS.txt"
|
|
29
|
+
# GitHub serves release files from these hosts after a redirect.
|
|
30
|
+
ALLOWED_HOSTS = (
|
|
31
|
+
"github.com",
|
|
32
|
+
"objects.githubusercontent.com",
|
|
33
|
+
"release-assets.githubusercontent.com",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LauncherError(Exception):
|
|
38
|
+
"""A problem the user can act on; shown without a traceback."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def asset_name(version: str, system: str | None = None, machine: str | None = None) -> str:
|
|
42
|
+
"""Returns the release file for a platform, or raises if there is none."""
|
|
43
|
+
system = (system or platform.system()).lower()
|
|
44
|
+
machine = (machine or platform.machine()).lower()
|
|
45
|
+
is_x64 = machine in ("x86_64", "amd64")
|
|
46
|
+
is_arm = machine in ("arm64", "aarch64")
|
|
47
|
+
|
|
48
|
+
if system == "windows" and is_x64:
|
|
49
|
+
return f"Kuerzel-{version}-windows-portable.exe"
|
|
50
|
+
if system == "darwin" and (is_x64 or is_arm):
|
|
51
|
+
return f"Kuerzel-{version}-macos-{'arm64' if is_arm else 'x64'}.dmg"
|
|
52
|
+
if system == "linux" and is_x64:
|
|
53
|
+
return f"Kuerzel-{version}-linux-x86_64.AppImage"
|
|
54
|
+
raise LauncherError(
|
|
55
|
+
f"There is no Kürzel build for {system} on {machine} yet.\n"
|
|
56
|
+
f"See {RELEASES} for the available downloads."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cache_dir() -> Path:
|
|
61
|
+
"""The per-user folder where downloaded builds are kept."""
|
|
62
|
+
home = Path.home()
|
|
63
|
+
if sys.platform == "win32":
|
|
64
|
+
base = Path(os.environ.get("LOCALAPPDATA") or home / "AppData" / "Local")
|
|
65
|
+
elif sys.platform == "darwin":
|
|
66
|
+
base = home / "Library" / "Caches"
|
|
67
|
+
else:
|
|
68
|
+
base = Path(os.environ.get("XDG_CACHE_HOME") or home / ".cache")
|
|
69
|
+
return base / "kuerzel" / "app"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def expected_checksum(listing: str, name: str) -> str:
|
|
73
|
+
"""Finds a file's SHA-256 in the text of a `sha256sum` listing."""
|
|
74
|
+
for line in listing.splitlines():
|
|
75
|
+
parts = line.split()
|
|
76
|
+
if len(parts) == 2 and parts[1].lstrip("*") == name:
|
|
77
|
+
digest = parts[0].lower()
|
|
78
|
+
if len(digest) == 64 and all(c in "0123456789abcdef" for c in digest):
|
|
79
|
+
return digest
|
|
80
|
+
raise LauncherError(f"The release does not list a checksum for {name}.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def sha256(path: Path) -> str:
|
|
84
|
+
digest = hashlib.sha256()
|
|
85
|
+
with path.open("rb") as handle:
|
|
86
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
87
|
+
digest.update(chunk)
|
|
88
|
+
return digest.hexdigest()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _open(url: str):
|
|
92
|
+
if not url.startswith("https://"):
|
|
93
|
+
raise LauncherError("Refusing to download over an insecure connection.")
|
|
94
|
+
request = urllib.request.Request(url, headers={"User-Agent": f"kuerzel-launcher/{__version__}"})
|
|
95
|
+
try:
|
|
96
|
+
response = urllib.request.urlopen(request, timeout=60) # noqa: S310 - https only, checked above
|
|
97
|
+
except OSError as error:
|
|
98
|
+
raise LauncherError(
|
|
99
|
+
f"Could not download {url}\n{error}\nYou can download the app by hand from {RELEASES}"
|
|
100
|
+
) from error
|
|
101
|
+
host = urllib.parse.urlparse(response.geturl()).hostname or ""
|
|
102
|
+
if response.geturl().split(":", 1)[0] != "https" or host not in ALLOWED_HOSTS:
|
|
103
|
+
response.close()
|
|
104
|
+
raise LauncherError(f"The download was redirected to an unexpected address: {host}")
|
|
105
|
+
return response
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def download(url: str, target: Path, show_progress: bool) -> None:
|
|
109
|
+
with _open(url) as response, target.open("wb") as out:
|
|
110
|
+
total = int(response.headers.get("Content-Length") or 0)
|
|
111
|
+
megabytes = f" ({total // (1024 * 1024)} MB)" if total else ""
|
|
112
|
+
# A live percentage only makes sense on a terminal; in a log or a pipe
|
|
113
|
+
# it would print hundreds of lines, so a single line is written there.
|
|
114
|
+
live = show_progress and total > 0 and sys.stderr.isatty()
|
|
115
|
+
if show_progress and not live:
|
|
116
|
+
print(f"Downloading Kürzel{megabytes}...", file=sys.stderr)
|
|
117
|
+
done = 0
|
|
118
|
+
shown = -1
|
|
119
|
+
while chunk := response.read(256 * 1024):
|
|
120
|
+
out.write(chunk)
|
|
121
|
+
done += len(chunk)
|
|
122
|
+
percent = done * 100 // total if total else 0
|
|
123
|
+
if live and percent != shown:
|
|
124
|
+
shown = percent
|
|
125
|
+
print(f"\rDownloading Kürzel{megabytes} {percent:3d}%", end="", file=sys.stderr)
|
|
126
|
+
if live:
|
|
127
|
+
print(file=sys.stderr)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def ensure_app(version: str = __version__, show_progress: bool = True) -> Path:
|
|
131
|
+
"""Returns the path of the app for this version, downloading it if needed."""
|
|
132
|
+
name = asset_name(version)
|
|
133
|
+
folder = cache_dir()
|
|
134
|
+
target = folder / name
|
|
135
|
+
if target.is_file():
|
|
136
|
+
return target
|
|
137
|
+
|
|
138
|
+
base = f"{RELEASES}/download/v{version}"
|
|
139
|
+
with _open(f"{base}/{CHECKSUMS}") as response:
|
|
140
|
+
listing = response.read(1024 * 1024).decode("utf-8", "replace")
|
|
141
|
+
expected = expected_checksum(listing, name)
|
|
142
|
+
|
|
143
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
handle, temp_name = tempfile.mkstemp(dir=folder, suffix=".part")
|
|
145
|
+
os.close(handle)
|
|
146
|
+
temp = Path(temp_name)
|
|
147
|
+
try:
|
|
148
|
+
download(f"{base}/{name}", temp, show_progress)
|
|
149
|
+
actual = sha256(temp)
|
|
150
|
+
if actual != expected:
|
|
151
|
+
raise LauncherError(
|
|
152
|
+
"The downloaded file does not match its published checksum and was deleted.\n"
|
|
153
|
+
f"Expected {expected}\nGot {actual}"
|
|
154
|
+
)
|
|
155
|
+
if sys.platform != "win32":
|
|
156
|
+
temp.chmod(temp.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
157
|
+
temp.replace(target)
|
|
158
|
+
finally:
|
|
159
|
+
temp.unlink(missing_ok=True)
|
|
160
|
+
|
|
161
|
+
# Older versions are no longer needed once a newer one is in place.
|
|
162
|
+
for old in folder.iterdir():
|
|
163
|
+
if old.is_file() and old != target and old.name.startswith("Kuerzel-"):
|
|
164
|
+
old.unlink(missing_ok=True)
|
|
165
|
+
return target
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def launch(app: Path) -> None:
|
|
169
|
+
"""Starts the app without tying it to this terminal."""
|
|
170
|
+
if sys.platform == "darwin":
|
|
171
|
+
# A disk image cannot be run in place: open it so the app can be
|
|
172
|
+
# dragged to Applications, as with a manual download.
|
|
173
|
+
subprocess.run(["open", str(app)], check=False) # noqa: S603, S607
|
|
174
|
+
print("Drag Kürzel to Applications, then start it from there.", file=sys.stderr)
|
|
175
|
+
return
|
|
176
|
+
options: dict = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
|
|
177
|
+
if sys.platform == "win32":
|
|
178
|
+
options["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
|
|
179
|
+
else:
|
|
180
|
+
options["start_new_session"] = True
|
|
181
|
+
subprocess.Popen([str(app)], **options) # noqa: S603 - a file we downloaded and verified
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def main(argv: list[str] | None = None) -> int:
|
|
185
|
+
parser = argparse.ArgumentParser(
|
|
186
|
+
prog="kuerzel",
|
|
187
|
+
description="Kürzel, a desktop studio for Espanso snippets. "
|
|
188
|
+
"Running this command downloads the app on first use and starts it.",
|
|
189
|
+
)
|
|
190
|
+
parser.add_argument("--version", action="version", version=f"kuerzel {__version__}")
|
|
191
|
+
parser.add_argument("--path", action="store_true", help="download if needed, print where the app is, and exit")
|
|
192
|
+
parser.add_argument("--remove", action="store_true", help="delete the downloaded app and exit")
|
|
193
|
+
args = parser.parse_args(argv)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
if args.remove:
|
|
197
|
+
shutil.rmtree(cache_dir(), ignore_errors=True)
|
|
198
|
+
print("Removed the downloaded app.")
|
|
199
|
+
return 0
|
|
200
|
+
app = ensure_app()
|
|
201
|
+
if args.path:
|
|
202
|
+
print(app)
|
|
203
|
+
return 0
|
|
204
|
+
launch(app)
|
|
205
|
+
return 0
|
|
206
|
+
except LauncherError as error:
|
|
207
|
+
print(error, file=sys.stderr)
|
|
208
|
+
return 1
|
|
209
|
+
except KeyboardInterrupt:
|
|
210
|
+
return 130
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kuerzel"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Kürzel, a desktop studio for Espanso snippets. This package downloads and starts the app."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Maxi" }]
|
|
13
|
+
keywords = ["espanso", "text-expander", "snippets", "gui", "productivity"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Win32 (MS Windows)",
|
|
17
|
+
"Environment :: MacOS X",
|
|
18
|
+
"Environment :: X11 Applications",
|
|
19
|
+
"Intended Audience :: End Users/Desktop",
|
|
20
|
+
"Operating System :: Microsoft :: Windows",
|
|
21
|
+
"Operating System :: MacOS",
|
|
22
|
+
"Operating System :: POSIX :: Linux",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Topic :: Text Processing",
|
|
25
|
+
"Topic :: Utilities",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/maximilianfeix/kuerzel"
|
|
30
|
+
Releases = "https://github.com/maximilianfeix/kuerzel/releases"
|
|
31
|
+
Issues = "https://github.com/maximilianfeix/kuerzel/issues"
|
|
32
|
+
Changelog = "https://github.com/maximilianfeix/kuerzel/blob/main/CHANGELOG.md"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
kuerzel = "kuerzel.cli:main"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.version]
|
|
38
|
+
path = "kuerzel/__init__.py"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["kuerzel"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.sdist]
|
|
44
|
+
include = ["kuerzel", "tests", "README.md"]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import io
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest import mock
|
|
7
|
+
|
|
8
|
+
from kuerzel import __version__, cli
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AssetNameTests(unittest.TestCase):
|
|
12
|
+
def test_picks_the_build_for_each_platform(self):
|
|
13
|
+
self.assertEqual(cli.asset_name("1.2.3", "Windows", "AMD64"), "Kuerzel-1.2.3-windows-portable.exe")
|
|
14
|
+
self.assertEqual(cli.asset_name("1.2.3", "Darwin", "arm64"), "Kuerzel-1.2.3-macos-arm64.dmg")
|
|
15
|
+
self.assertEqual(cli.asset_name("1.2.3", "Darwin", "x86_64"), "Kuerzel-1.2.3-macos-x64.dmg")
|
|
16
|
+
self.assertEqual(cli.asset_name("1.2.3", "Linux", "x86_64"), "Kuerzel-1.2.3-linux-x86_64.AppImage")
|
|
17
|
+
|
|
18
|
+
def test_explains_when_there_is_no_build(self):
|
|
19
|
+
for system, machine in (("Linux", "aarch64"), ("Windows", "ARM64"), ("FreeBSD", "amd64")):
|
|
20
|
+
with self.assertRaises(cli.LauncherError) as caught:
|
|
21
|
+
cli.asset_name("1.2.3", system, machine)
|
|
22
|
+
self.assertIn("releases", str(caught.exception))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ChecksumTests(unittest.TestCase):
|
|
26
|
+
DIGEST = "a" * 64
|
|
27
|
+
|
|
28
|
+
def test_reads_plain_and_binary_mode_listings(self):
|
|
29
|
+
listing = f"{'b' * 64} other.exe\n{self.DIGEST} *app.exe\n"
|
|
30
|
+
self.assertEqual(cli.expected_checksum(listing, "app.exe"), self.DIGEST)
|
|
31
|
+
|
|
32
|
+
def test_rejects_missing_and_malformed_entries(self):
|
|
33
|
+
for listing in ("", "nothex app.exe\n", f"{self.DIGEST} another.exe\n", f"{'z' * 64} app.exe\n"):
|
|
34
|
+
with self.assertRaises(cli.LauncherError):
|
|
35
|
+
cli.expected_checksum(listing, "app.exe")
|
|
36
|
+
|
|
37
|
+
def test_hashes_a_file(self):
|
|
38
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
39
|
+
path = Path(folder) / "file"
|
|
40
|
+
path.write_bytes(b"kuerzel")
|
|
41
|
+
self.assertEqual(cli.sha256(path), hashlib.sha256(b"kuerzel").hexdigest())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FakeResponse(io.BytesIO):
|
|
45
|
+
def __init__(self, data: bytes, url: str):
|
|
46
|
+
super().__init__(data)
|
|
47
|
+
self._url = url
|
|
48
|
+
self.headers = {"Content-Length": str(len(data))}
|
|
49
|
+
|
|
50
|
+
def geturl(self):
|
|
51
|
+
return self._url
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EnsureAppTests(unittest.TestCase):
|
|
55
|
+
def setUp(self):
|
|
56
|
+
self.folder = tempfile.TemporaryDirectory()
|
|
57
|
+
self.addCleanup(self.folder.cleanup)
|
|
58
|
+
self.name = "Kuerzel-9.9.9-linux-x86_64.AppImage"
|
|
59
|
+
patches = [
|
|
60
|
+
mock.patch.object(cli, "cache_dir", return_value=Path(self.folder.name)),
|
|
61
|
+
mock.patch.object(cli, "asset_name", return_value=self.name),
|
|
62
|
+
]
|
|
63
|
+
for patch in patches:
|
|
64
|
+
patch.start()
|
|
65
|
+
self.addCleanup(patch.stop)
|
|
66
|
+
|
|
67
|
+
def serve(self, payload: bytes, listed: bytes, host: str = "github.com"):
|
|
68
|
+
def opener(request, timeout=0):
|
|
69
|
+
url = request.full_url
|
|
70
|
+
body = f"{hashlib.sha256(listed).hexdigest()} {self.name}\n".encode() if url.endswith(cli.CHECKSUMS) else payload
|
|
71
|
+
return FakeResponse(body, f"https://{host}/file")
|
|
72
|
+
|
|
73
|
+
return mock.patch("urllib.request.urlopen", side_effect=opener)
|
|
74
|
+
|
|
75
|
+
def test_downloads_verifies_and_reuses(self):
|
|
76
|
+
with self.serve(b"app", b"app") as opened:
|
|
77
|
+
path = cli.ensure_app("9.9.9", show_progress=False)
|
|
78
|
+
self.assertEqual(path.read_bytes(), b"app")
|
|
79
|
+
self.assertEqual(opened.call_count, 2)
|
|
80
|
+
cli.ensure_app("9.9.9", show_progress=False)
|
|
81
|
+
self.assertEqual(opened.call_count, 2)
|
|
82
|
+
|
|
83
|
+
def test_deletes_a_file_that_fails_its_checksum(self):
|
|
84
|
+
with self.serve(b"tampered", b"app"):
|
|
85
|
+
with self.assertRaises(cli.LauncherError) as caught:
|
|
86
|
+
cli.ensure_app("9.9.9", show_progress=False)
|
|
87
|
+
self.assertIn("checksum", str(caught.exception))
|
|
88
|
+
self.assertEqual(list(Path(self.folder.name).iterdir()), [])
|
|
89
|
+
|
|
90
|
+
def test_refuses_unexpected_hosts_and_plain_http(self):
|
|
91
|
+
with self.serve(b"app", b"app", host="evil.example"):
|
|
92
|
+
with self.assertRaises(cli.LauncherError):
|
|
93
|
+
cli.ensure_app("9.9.9", show_progress=False)
|
|
94
|
+
with self.assertRaises(cli.LauncherError):
|
|
95
|
+
cli._open("http://github.com/file")
|
|
96
|
+
|
|
97
|
+
def test_removes_older_builds(self):
|
|
98
|
+
old = Path(self.folder.name) / "Kuerzel-0.0.1-linux-x86_64.AppImage"
|
|
99
|
+
old.write_bytes(b"old")
|
|
100
|
+
with self.serve(b"app", b"app"):
|
|
101
|
+
cli.ensure_app("9.9.9", show_progress=False)
|
|
102
|
+
self.assertFalse(old.exists())
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class MainTests(unittest.TestCase):
|
|
106
|
+
def test_reports_problems_without_a_traceback(self):
|
|
107
|
+
with mock.patch.object(cli, "ensure_app", side_effect=cli.LauncherError("no build")):
|
|
108
|
+
with mock.patch("sys.stderr", new_callable=io.StringIO) as stderr:
|
|
109
|
+
self.assertEqual(cli.main([]), 1)
|
|
110
|
+
self.assertIn("no build", stderr.getvalue())
|
|
111
|
+
|
|
112
|
+
def test_prints_the_path(self):
|
|
113
|
+
with mock.patch.object(cli, "ensure_app", return_value=Path("/tmp/app")):
|
|
114
|
+
with mock.patch("sys.stdout", new_callable=io.StringIO) as stdout:
|
|
115
|
+
self.assertEqual(cli.main(["--path"]), 0)
|
|
116
|
+
self.assertIn("app", stdout.getvalue())
|
|
117
|
+
|
|
118
|
+
def test_version_matches_the_package(self):
|
|
119
|
+
self.assertRegex(__version__, r"^\d+\.\d+\.\d+$")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
unittest.main()
|