sentinelmeet-v2 2.0.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,4 @@
1
+ .venv/
2
+ dist/
3
+ *.egg-info/
4
+ __pycache__/
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Apocys
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.5
2
+ Name: sentinelmeet-v2
3
+ Version: 2.0.0
4
+ Summary: Installer for SentinelMeet v2: private, on-device meeting transcription, live translation and speaker ID for macOS (Apple Silicon). `pip install sentinelmeet-v2 && sentinelmeet install`.
5
+ Project-URL: Homepage, https://spawnkit.ai/#sentinelmeet
6
+ Project-URL: Releases, https://spawnkit.ai/sentinelmeet/
7
+ Author: Apocys
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: diarization,installer,meetings,sentinelos,transcription,translation,whisper
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: MacOS X
13
+ Classifier: Environment :: Win32 (MS Windows)
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: Microsoft :: Windows
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
19
+ Classifier: Topic :: Office/Business
20
+ Requires-Python: >=3.9
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # sentinelmeet-v2
26
+
27
+ Installer for **SentinelMeet v2**, a private meeting assistant for macOS by [SpawnKit](https://spawnkit.ai/#sentinelmeet).
28
+ Live transcription (Whisper large-v3-turbo), live translation, speaker identification and summaries, processed on your Mac.
29
+ Cloud summaries and CRM publication are optional and off until you turn them on.
30
+
31
+ ```bash
32
+ pip install sentinelmeet-v2
33
+ sentinelmeet install --launch
34
+ ```
35
+
36
+ The command downloads the latest build from `spawnkit.ai/sentinelmeet/`, checks its SHA-256,
37
+ and installs `/Applications/SentinelMeet v2.app`. An existing SentinelMeet v1 install is left untouched.
38
+
39
+ ## Commands
40
+
41
+ | Command | What it does |
42
+ | --- | --- |
43
+ | `sentinelmeet install [--version v2.0.0] [-y] [--launch]` | Install (or update to) a release |
44
+ | `sentinelmeet status` | Installed version, downloaded models, latest release |
45
+ | `sentinelmeet open` | Launch the app |
46
+ | `sentinelmeet uninstall [--purge]` | Remove the app; `--purge` also deletes meetings, models, settings |
47
+
48
+ ## Requirements
49
+
50
+ - **macOS 13+ on Apple Silicon** (M1 or later). Windows is coming; Linux is not planned yet.
51
+ - Python 3.9 or newer for this installer.
52
+ - About 5 GB free for the speech and summary models, downloaded on first launch.
53
+ - macOS asks for **Microphone** and **Screen Recording** (needed to hear the other side of a call).
54
+
55
+ ## Notes
56
+
57
+ - This build is not notarized by Apple yet. The installer removes the download quarantine flag so macOS lets it open.
58
+ - The app bundles FFmpeg 9.0.1 (static arm64 build from martin-riedl.de), licensed under the GPL v3. Source: https://ffmpeg.org/download.html
59
+
60
+ Support: support@spawnkit.ai
@@ -0,0 +1,36 @@
1
+ # sentinelmeet-v2
2
+
3
+ Installer for **SentinelMeet v2**, a private meeting assistant for macOS by [SpawnKit](https://spawnkit.ai/#sentinelmeet).
4
+ Live transcription (Whisper large-v3-turbo), live translation, speaker identification and summaries, processed on your Mac.
5
+ Cloud summaries and CRM publication are optional and off until you turn them on.
6
+
7
+ ```bash
8
+ pip install sentinelmeet-v2
9
+ sentinelmeet install --launch
10
+ ```
11
+
12
+ The command downloads the latest build from `spawnkit.ai/sentinelmeet/`, checks its SHA-256,
13
+ and installs `/Applications/SentinelMeet v2.app`. An existing SentinelMeet v1 install is left untouched.
14
+
15
+ ## Commands
16
+
17
+ | Command | What it does |
18
+ | --- | --- |
19
+ | `sentinelmeet install [--version v2.0.0] [-y] [--launch]` | Install (or update to) a release |
20
+ | `sentinelmeet status` | Installed version, downloaded models, latest release |
21
+ | `sentinelmeet open` | Launch the app |
22
+ | `sentinelmeet uninstall [--purge]` | Remove the app; `--purge` also deletes meetings, models, settings |
23
+
24
+ ## Requirements
25
+
26
+ - **macOS 13+ on Apple Silicon** (M1 or later). Windows is coming; Linux is not planned yet.
27
+ - Python 3.9 or newer for this installer.
28
+ - About 5 GB free for the speech and summary models, downloaded on first launch.
29
+ - macOS asks for **Microphone** and **Screen Recording** (needed to hear the other side of a call).
30
+
31
+ ## Notes
32
+
33
+ - This build is not notarized by Apple yet. The installer removes the download quarantine flag so macOS lets it open.
34
+ - The app bundles FFmpeg 9.0.1 (static arm64 build from martin-riedl.de), licensed under the GPL v3. Source: https://ffmpeg.org/download.html
35
+
36
+ Support: support@spawnkit.ai
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sentinelmeet-v2"
7
+ version = "2.0.0"
8
+ description = "Installer for SentinelMeet v2: private, on-device meeting transcription, live translation and speaker ID for macOS (Apple Silicon). `pip install sentinelmeet-v2 && sentinelmeet install`."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Apocys" }]
13
+ keywords = ["meetings", "transcription", "whisper", "translation", "diarization", "installer", "sentinelos"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: MacOS X",
17
+ "Environment :: Win32 (MS Windows)",
18
+ "Intended Audience :: End Users/Desktop",
19
+ "Operating System :: MacOS",
20
+ "Operating System :: Microsoft :: Windows",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Office/Business",
23
+ "Topic :: Multimedia :: Sound/Audio :: Speech",
24
+ ]
25
+ # stdlib only: urllib, json, subprocess, hashlib, pathlib, tempfile
26
+ dependencies = []
27
+
28
+ [project.urls]
29
+ Homepage = "https://spawnkit.ai/#sentinelmeet"
30
+ Releases = "https://spawnkit.ai/sentinelmeet/"
31
+
32
+ [project.scripts]
33
+ sentinelmeet = "sentinelmeet_installer.cli:main"
34
+
35
+ [project.optional-dependencies]
36
+ dev = ["pytest>=7"]
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/sentinelmeet_installer"]
@@ -0,0 +1,2 @@
1
+ """SentinelMeet installer package."""
2
+ __version__ = "2.0.0"
@@ -0,0 +1,311 @@
1
+ """SentinelMeet installer CLI.
2
+
3
+ sentinelmeet install [--version vX.Y.Z] [--yes]
4
+ sentinelmeet uninstall
5
+ sentinelmeet status
6
+ sentinelmeet open
7
+
8
+ Pure stdlib. Downloads the signed release asset from GitHub Releases, verifies
9
+ its SHA-256 against the release's SHA256SUMS when present, and installs:
10
+ macOS → /Applications/SentinelMeet.app (from the .dmg)
11
+ Windows → runs the NSIS setup.exe
12
+ Linux is not supported by the app yet (system-audio capture is macOS/Windows).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import hashlib
18
+ import json
19
+ import os
20
+ import platform
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ import tempfile
25
+ import urllib.error
26
+ import urllib.request
27
+ from pathlib import Path
28
+
29
+ REPO = os.environ.get("SENTINELMEET_REPO", "apocys/meeting-speech")
30
+ API = f"https://api.github.com/repos/{REPO}/releases"
31
+ # Public download feed (same shape as a GitHub release: tag_name + assets[]). Set SENTINELMEET_FEED="" to use GitHub.
32
+ FEED = os.environ.get("SENTINELMEET_FEED", "https://spawnkit.ai/sentinelmeet/releases/latest.json")
33
+ UA = {"User-Agent": "sentinelmeet-installer", "Accept": "application/vnd.github+json"}
34
+ APP_NAME = "SentinelMeet v2"
35
+ MAC_APP = Path("/Applications") / f"{APP_NAME}.app"
36
+ MAC_DATA = Path.home() / "Library/Application Support/com.apocys.sentinelmeet-v2"
37
+
38
+
39
+ class InstallError(RuntimeError):
40
+ pass
41
+
42
+
43
+ # ----------------------------------------------------------------------------- github
44
+
45
+
46
+ def _get_json(url: str) -> dict:
47
+ req = urllib.request.Request(url, headers=UA)
48
+ with urllib.request.urlopen(req, timeout=30) as r:
49
+ return json.loads(r.read().decode())
50
+
51
+
52
+ def fetch_release(version: str | None) -> dict:
53
+ if FEED:
54
+ base = FEED.rsplit("/", 1)[0]
55
+ url = FEED if not version else f"{base}/{version if version.startswith('v') else 'v' + version}.json"
56
+ try:
57
+ return _get_json(url)
58
+ except urllib.error.HTTPError as e:
59
+ if e.code == 404:
60
+ raise InstallError(f"No release found ({'latest' if not version else version}) at {url}") from None
61
+ raise InstallError(f"Download feed error: {e}") from None
62
+ url = f"{API}/latest" if not version else f"{API}/tags/{version if version.startswith('v') else 'v' + version}"
63
+ try:
64
+ return _get_json(url)
65
+ except urllib.error.HTTPError as e:
66
+ if e.code == 404:
67
+ raise InstallError(
68
+ f"No release found ({'latest' if not version else version}) on github.com/{REPO}. "
69
+ "Check https://github.com/%s/releases" % REPO
70
+ ) from None
71
+ raise InstallError(f"GitHub API error: {e}") from None
72
+
73
+
74
+ def pick_asset(release: dict, system: str, machine: str) -> dict:
75
+ """Choose the asset for this OS/arch. Names follow Tauri's bundler."""
76
+ assets = release.get("assets", [])
77
+ names = [a["name"] for a in assets]
78
+
79
+ def find(*needles: str) -> dict | None:
80
+ for a in assets:
81
+ n = a["name"].lower()
82
+ if all(x in n for x in needles):
83
+ return a
84
+ return None
85
+
86
+ if system == "Darwin":
87
+ if machine not in ("arm64", "aarch64"):
88
+ raise InstallError("SentinelMeet ships for Apple Silicon only (M1 and later).")
89
+ a = find("aarch64", ".dmg") or find("arm64", ".dmg")
90
+ elif system == "Windows":
91
+ a = find("x64", "setup", ".exe") or find("x64", ".msi")
92
+ else:
93
+ raise InstallError("Linux is not supported yet (system-audio capture is macOS/Windows only).")
94
+ if not a:
95
+ raise InstallError(f"No asset for {system}/{machine} in release {release.get('tag_name')}. Assets: {names}")
96
+ return a
97
+
98
+
99
+ def fetch_sha256sums(release: dict) -> dict[str, str]:
100
+ for a in release.get("assets", []):
101
+ if a["name"].upper().startswith("SHA256SUMS"):
102
+ with urllib.request.urlopen(urllib.request.Request(a["browser_download_url"], headers=UA), timeout=30) as r:
103
+ out = {}
104
+ for line in r.read().decode().splitlines():
105
+ parts = line.split()
106
+ if len(parts) >= 2:
107
+ out[parts[-1].lstrip("*")] = parts[0].lower()
108
+ return out
109
+ return {}
110
+
111
+
112
+ # ----------------------------------------------------------------------------- download
113
+
114
+
115
+ def download(url: str, dest: Path, expected_size: int | None = None) -> None:
116
+ req = urllib.request.Request(url, headers={"User-Agent": UA["User-Agent"]})
117
+ with urllib.request.urlopen(req, timeout=60) as r, open(dest, "wb") as f:
118
+ total = expected_size or int(r.headers.get("Content-Length") or 0)
119
+ done = 0
120
+ last = -1
121
+ while True:
122
+ chunk = r.read(1 << 20)
123
+ if not chunk:
124
+ break
125
+ f.write(chunk)
126
+ done += len(chunk)
127
+ if total:
128
+ pct = done * 100 // total
129
+ if pct != last and pct % 5 == 0:
130
+ sys.stdout.write(f"\r downloading {dest.name} {pct:3d}% ({done // (1 << 20)} / {total // (1 << 20)} MB)")
131
+ sys.stdout.flush()
132
+ last = pct
133
+ sys.stdout.write("\n")
134
+
135
+
136
+ def sha256(path: Path) -> str:
137
+ h = hashlib.sha256()
138
+ with open(path, "rb") as f:
139
+ for chunk in iter(lambda: f.read(1 << 20), b""):
140
+ h.update(chunk)
141
+ return h.hexdigest()
142
+
143
+
144
+ # ----------------------------------------------------------------------------- install
145
+
146
+
147
+ def install_macos_dmg(dmg: Path, yes: bool) -> Path:
148
+ mnt = Path(tempfile.mkdtemp(prefix="sentinelmeet-dmg-"))
149
+ subprocess.run(["hdiutil", "attach", str(dmg), "-nobrowse", "-quiet", "-mountpoint", str(mnt)], check=True)
150
+ try:
151
+ apps = list(mnt.glob("*.app"))
152
+ if not apps:
153
+ raise InstallError("No .app inside the dmg")
154
+ src = apps[0]
155
+ # Keep the bundle's own name ("SentinelMeet v2.app") so v1 is never overwritten.
156
+ dest = Path("/Applications") / src.name
157
+ if dest.exists():
158
+ if not yes and not _confirm(f"{dest} exists. Replace it?"):
159
+ raise InstallError("Aborted.")
160
+ subprocess.run(["pkill", "-x", "sentinelmeet"], check=False)
161
+ shutil.rmtree(dest)
162
+ shutil.copytree(src, dest, symlinks=True)
163
+ # Unsigned/ad-hoc builds carry the quarantine bit → Gatekeeper refuses.
164
+ subprocess.run(["xattr", "-dr", "com.apple.quarantine", str(dest)], check=False)
165
+ return dest
166
+ finally:
167
+ subprocess.run(["hdiutil", "detach", str(mnt), "-quiet"], check=False)
168
+
169
+
170
+ def install_windows(setup: Path, yes: bool) -> Path:
171
+ args = [str(setup)]
172
+ if yes:
173
+ args.append("/S") # NSIS silent
174
+ subprocess.run(args, check=True)
175
+ return Path(os.environ.get("LOCALAPPDATA", "")) / APP_NAME
176
+
177
+
178
+ def _confirm(q: str) -> bool:
179
+ return input(f"{q} [y/N] ").strip().lower() in ("y", "yes")
180
+
181
+
182
+ # ----------------------------------------------------------------------------- commands
183
+
184
+
185
+ def cmd_install(args: argparse.Namespace) -> int:
186
+ system, machine = platform.system(), platform.machine()
187
+ print(f"SentinelMeet installer — {system} {machine}")
188
+ rel = fetch_release(args.version)
189
+ asset = pick_asset(rel, system, machine)
190
+ tag = rel.get("tag_name")
191
+ print(f" release {tag}: {asset['name']} ({asset['size'] // (1 << 20)} MB)")
192
+ sums = fetch_sha256sums(rel)
193
+
194
+ with tempfile.TemporaryDirectory(prefix="sentinelmeet-") as td:
195
+ path = Path(td) / asset["name"]
196
+ download(asset["browser_download_url"], path, asset.get("size"))
197
+ digest = sha256(path)
198
+ if asset["name"] in sums:
199
+ if sums[asset["name"]] != digest:
200
+ raise InstallError("SHA-256 mismatch — download corrupted or tampered. Aborting.")
201
+ print(" sha256 verified ✓")
202
+ else:
203
+ print(f" sha256 {digest[:16]}… (no SHA256SUMS in this release — not verified)")
204
+
205
+ if system == "Darwin":
206
+ dest = install_macos_dmg(path, args.yes)
207
+ else:
208
+ dest = install_windows(path, args.yes)
209
+
210
+ print(f"\n✅ Installed {APP_NAME} {tag} → {dest}")
211
+ print("First launch downloads the speech models (~1.6 GB Whisper turbo + ~2.7 GB Qwen) — one time.")
212
+ if system == "Darwin":
213
+ print("macOS will ask for Microphone and Screen Recording (system audio) permissions.")
214
+ if args.launch:
215
+ subprocess.run(["open", str(dest)], check=False)
216
+ else:
217
+ print("Run: sentinelmeet open")
218
+ return 0
219
+
220
+
221
+ def cmd_uninstall(args: argparse.Namespace) -> int:
222
+ if platform.system() != "Darwin":
223
+ print("On Windows use Settings › Apps › SentinelMeet › Uninstall.")
224
+ return 1
225
+ if not MAC_APP.exists():
226
+ print("Not installed.")
227
+ return 0
228
+ if not args.yes and not _confirm(f"Remove {MAC_APP}?"):
229
+ return 1
230
+ subprocess.run(["pkill", "-x", "sentinelmeet"], check=False)
231
+ shutil.rmtree(MAC_APP)
232
+ print("Removed app.")
233
+ if args.purge:
234
+ if MAC_DATA.exists() and (args.yes or _confirm(f"Also delete meetings, models and settings in {MAC_DATA}?")):
235
+ shutil.rmtree(MAC_DATA)
236
+ print("Removed data.")
237
+ else:
238
+ print(f"Meetings/models kept in {MAC_DATA} (use --purge to delete).")
239
+ return 0
240
+
241
+
242
+ def cmd_status(_: argparse.Namespace) -> int:
243
+ system = platform.system()
244
+ if system == "Darwin":
245
+ installed = MAC_APP.exists()
246
+ ver = None
247
+ if installed:
248
+ try:
249
+ out = subprocess.run(
250
+ ["defaults", "read", str(MAC_APP / "Contents/Info.plist"), "CFBundleShortVersionString"],
251
+ capture_output=True, text=True, check=True,
252
+ ).stdout.strip()
253
+ ver = out
254
+ except Exception:
255
+ pass
256
+ print(f"app: {'installed ' + (ver or '') if installed else 'not installed'} ({MAC_APP})")
257
+ models = MAC_DATA / "models"
258
+ if models.exists():
259
+ names = sorted(p.name for p in models.rglob("*") if p.is_file() and p.suffix in (".bin", ".gguf"))
260
+ print(f"models: {', '.join(names) if names else 'none yet'}")
261
+ db = MAC_DATA / "meeting_minutes.sqlite"
262
+ print(f"database: {'present' if db.exists() else 'none'} ({db})")
263
+ else:
264
+ print(f"{system}: use the app's Settings for status.")
265
+ try:
266
+ latest = fetch_release(None).get("tag_name")
267
+ print(f"latest: {latest}")
268
+ except InstallError as e:
269
+ print(f"latest: ? ({e})")
270
+ return 0
271
+
272
+
273
+ def cmd_open(_: argparse.Namespace) -> int:
274
+ if platform.system() == "Darwin" and MAC_APP.exists():
275
+ subprocess.run(["open", str(MAC_APP)], check=False)
276
+ return 0
277
+ print("App not installed. Run: sentinelmeet install")
278
+ return 1
279
+
280
+
281
+ def main(argv: list[str] | None = None) -> int:
282
+ p = argparse.ArgumentParser(prog="sentinelmeet", description="Install / manage the SentinelMeet desktop app.")
283
+ sub = p.add_subparsers(dest="cmd", required=True)
284
+
285
+ i = sub.add_parser("install", help="download and install the latest release")
286
+ i.add_argument("--version", help="specific tag, e.g. v0.5.0 (default: latest)")
287
+ i.add_argument("--yes", "-y", action="store_true", help="no prompts; replace existing install")
288
+ i.add_argument("--launch", action="store_true", help="open the app after installing")
289
+ i.set_defaults(fn=cmd_install)
290
+
291
+ u = sub.add_parser("uninstall", help="remove the app (keeps your data unless --purge)")
292
+ u.add_argument("--yes", "-y", action="store_true")
293
+ u.add_argument("--purge", action="store_true", help="also delete meetings, models and settings")
294
+ u.set_defaults(fn=cmd_uninstall)
295
+
296
+ sub.add_parser("status", help="installed version, models, latest release").set_defaults(fn=cmd_status)
297
+ sub.add_parser("open", help="launch the app").set_defaults(fn=cmd_open)
298
+
299
+ args = p.parse_args(argv)
300
+ try:
301
+ return args.fn(args)
302
+ except InstallError as e:
303
+ print(f"✗ {e}", file=sys.stderr)
304
+ return 2
305
+ except KeyboardInterrupt:
306
+ print("\naborted", file=sys.stderr)
307
+ return 130
308
+
309
+
310
+ if __name__ == "__main__":
311
+ sys.exit(main())
@@ -0,0 +1,35 @@
1
+ import json
2
+ from sentinelmeet_installer import cli
3
+
4
+
5
+ def rel(names):
6
+ return {"tag_name": "v0.5.0", "assets": [{"name": n, "size": 1, "browser_download_url": "x"} for n in names]}
7
+
8
+
9
+ def test_pick_macos_arm():
10
+ r = rel(["SentinelMeet_0.5.0_aarch64.dmg", "SentinelMeet_0.5.0_x64-setup.exe", "SHA256SUMS"])
11
+ assert cli.pick_asset(r, "Darwin", "arm64")["name"].endswith("aarch64.dmg")
12
+
13
+
14
+ def test_pick_windows():
15
+ r = rel(["SentinelMeet_0.5.0_aarch64.dmg", "SentinelMeet_0.5.0_x64-setup.exe"])
16
+ assert cli.pick_asset(r, "Windows", "AMD64")["name"].endswith("setup.exe")
17
+
18
+
19
+ def test_intel_mac_refused():
20
+ import pytest
21
+ with pytest.raises(cli.InstallError):
22
+ cli.pick_asset(rel(["SentinelMeet_0.5.0_aarch64.dmg"]), "Darwin", "x86_64")
23
+
24
+
25
+ def test_linux_refused():
26
+ import pytest
27
+ with pytest.raises(cli.InstallError):
28
+ cli.pick_asset(rel(["a.dmg"]), "Linux", "x86_64")
29
+
30
+
31
+ def test_cli_help_runs():
32
+ import pytest
33
+ with pytest.raises(SystemExit) as e:
34
+ cli.main(["--help"])
35
+ assert e.value.code == 0