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.
@@ -0,0 +1,15 @@
1
+ node_modules
2
+ out
3
+ dist
4
+ release
5
+ *.log
6
+ .DS_Store
7
+ Thumbs.db
8
+ .vscode/*
9
+ !.vscode/extensions.json
10
+ .env*
11
+ coverage
12
+ __pycache__
13
+ *.egg-info
14
+ python/dist
15
+ python/build
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
+ ![A tour of Kürzel](https://raw.githubusercontent.com/maximilianfeix/kuerzel/main/docs/tour.gif)
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
@@ -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
+ ![A tour of Kürzel](https://raw.githubusercontent.com/maximilianfeix/kuerzel/main/docs/tour.gif)
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,3 @@
1
+ """Launcher for Kürzel, a desktop studio for Espanso snippets."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from kuerzel.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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()