vinospeak 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Karan Shukla
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: vinospeak
3
+ Version: 0.1.0
4
+ Summary: Intel NPU-powered Kokoro text-to-speech with OpenVINO
5
+ Author: Karan Shukla
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/karanshukla/vinoSpeak
8
+ Project-URL: Repository, https://github.com/karanshukla/vinoSpeak
9
+ Project-URL: Issues, https://github.com/karanshukla/vinoSpeak/issues
10
+ Project-URL: Changelog, https://github.com/karanshukla/vinoSpeak/blob/main/CHANGELOG.md
11
+ Keywords: kokoro,openvino,npu,text-to-speech,tts,linux
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: End Users/Desktop
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: espeakng-loader>=0.2.4
28
+ Requires-Dist: huggingface-hub>=0.24.0
29
+ Requires-Dist: numpy>=1.24.0
30
+ Requires-Dist: openvino==2026.4.1
31
+ Requires-Dist: openvino-genai==2026.4.1.0
32
+ Requires-Dist: soundfile>=0.12.1
33
+ Dynamic: license-file
34
+
35
+ # vinoSpeak
36
+
37
+ Intel NPU-powered text-to-speech using the Kokoro-82M INT8 OpenVINO model.
38
+ Text goes in, a WAV comes out, and nothing leaves your machine after the
39
+ one-time model download.
40
+
41
+ ## Requirements
42
+
43
+ - Python 3.11 or newer
44
+ - An Intel NPU with a compatible driver. GPU and CPU also work
45
+ (`--device GPU`, `--device CPU`).
46
+ - Internet access on first run to download the model (~160 MB)
47
+
48
+ OpenVINO is pinned to the release vinoPop and vinoWhisper use, so programs
49
+ sharing one NPU never run mismatched runtimes.
50
+
51
+ ## Install
52
+
53
+ From PyPI, with [uv](https://docs.astral.sh/uv/):
54
+
55
+ ```bash
56
+ uv tool install vinospeak
57
+ vinospeak "Hello."
58
+ ```
59
+
60
+ OpenVINO is pinned exactly; everything else resolves to the newest release
61
+ that fits. To install exactly what `uv.lock` pins, hashes included, use a
62
+ checkout. `--locked` fails if the lock is out of date:
63
+
64
+ ```bash
65
+ git clone https://github.com/karanshukla/vinoSpeak && cd vinoSpeak
66
+ uv sync --locked
67
+ uv run vinospeak "Hello."
68
+ ```
69
+
70
+ To put that checkout's `vinospeak` on your PATH with the lock's versions as
71
+ constraints (`uv tool install` doesn't read `uv.lock` by itself):
72
+
73
+ ```bash
74
+ uv export --locked --no-dev --no-emit-project --no-hashes -o constraints.txt
75
+ uv tool install --constraints constraints.txt .
76
+ ```
77
+
78
+ ## Synthesize speech
79
+
80
+ The first run downloads `OpenVINO/Kokoro-82M-int8-ov` from Hugging Face, at one
81
+ pinned commit, and checks every file against its sha256 (see
82
+ [SECURITY.md](SECURITY.md)). The model is cached under
83
+ `${XDG_CACHE_HOME:-~/.cache}/vinospeak/` by default.
84
+
85
+ ```bash
86
+ vinospeak "Hello from vinoSpeak." # plays it on your speakers
87
+ vinospeak "Hello from vinoSpeak." --output hello.wav # saves a WAV instead
88
+ ```
89
+
90
+ Playback goes through `pw-play` (PipeWire, what Wayland desktops run), falling
91
+ back to `paplay` or `aplay`.
92
+
93
+ The default inference device is the Intel NPU. Choose a different OpenVINO
94
+ device, voice, language, speed, or model directory with the corresponding
95
+ options:
96
+
97
+ ```bash
98
+ vinospeak "A sample sentence." \
99
+ --device NPU \
100
+ --voice af_heart \
101
+ --language en-us \
102
+ --speed 1.0 \
103
+ --model-dir ./kokoro-model \
104
+ --output speech.wav
105
+ ```
106
+
107
+ Use `vinospeak --help` to see all options. The audio uses the sample rate
108
+ reported by OpenVINO GenAI.
109
+
110
+ ## Check this machine
111
+
112
+ ```bash
113
+ vinospeak-doctor # --json for a bug report
114
+ ```
115
+
116
+ Reports the OpenVINO packages and devices, whether the cached model still
117
+ matches its pins, and, if [ovfetch](https://github.com/karanshukla/ovfetch) is
118
+ on your `PATH`, whether your OpenVINO version is inside the range recorded as
119
+ working with your NPU driver. Install it with `cargo install ovfetch --locked`.
120
+ ovfetch is advisory here: it never changes what gets installed or run.
121
+
122
+ ## Use it from Python
123
+
124
+ ```python
125
+ from vino_speak import Synthesizer
126
+
127
+ synth = Synthesizer("NPU") # compiles the model once; reuse it
128
+ audio = synth.synthesize("Hello.") # audio.samples (float32), audio.sample_rate
129
+
130
+ speech = synth.speak("A long passage.") # returns at once, plays in the background
131
+ speech.stop() # cuts it off; speech.wait() blocks until it finishes instead
132
+ ```
133
+
134
+ `speak()` synthesizes the first sentence on its own and plays it while the
135
+ rest is synthesized in chunks that at most double in size, so speech starts
136
+ after about 1.5 s on the NPU however long the text is.
137
+
138
+ ## Development
139
+
140
+ See [CONTRIBUTING.md](CONTRIBUTING.md). `uv run poe check` runs everything CI
141
+ does; none of it needs OpenVINO or an NPU.
@@ -0,0 +1,107 @@
1
+ # vinoSpeak
2
+
3
+ Intel NPU-powered text-to-speech using the Kokoro-82M INT8 OpenVINO model.
4
+ Text goes in, a WAV comes out, and nothing leaves your machine after the
5
+ one-time model download.
6
+
7
+ ## Requirements
8
+
9
+ - Python 3.11 or newer
10
+ - An Intel NPU with a compatible driver. GPU and CPU also work
11
+ (`--device GPU`, `--device CPU`).
12
+ - Internet access on first run to download the model (~160 MB)
13
+
14
+ OpenVINO is pinned to the release vinoPop and vinoWhisper use, so programs
15
+ sharing one NPU never run mismatched runtimes.
16
+
17
+ ## Install
18
+
19
+ From PyPI, with [uv](https://docs.astral.sh/uv/):
20
+
21
+ ```bash
22
+ uv tool install vinospeak
23
+ vinospeak "Hello."
24
+ ```
25
+
26
+ OpenVINO is pinned exactly; everything else resolves to the newest release
27
+ that fits. To install exactly what `uv.lock` pins, hashes included, use a
28
+ checkout. `--locked` fails if the lock is out of date:
29
+
30
+ ```bash
31
+ git clone https://github.com/karanshukla/vinoSpeak && cd vinoSpeak
32
+ uv sync --locked
33
+ uv run vinospeak "Hello."
34
+ ```
35
+
36
+ To put that checkout's `vinospeak` on your PATH with the lock's versions as
37
+ constraints (`uv tool install` doesn't read `uv.lock` by itself):
38
+
39
+ ```bash
40
+ uv export --locked --no-dev --no-emit-project --no-hashes -o constraints.txt
41
+ uv tool install --constraints constraints.txt .
42
+ ```
43
+
44
+ ## Synthesize speech
45
+
46
+ The first run downloads `OpenVINO/Kokoro-82M-int8-ov` from Hugging Face, at one
47
+ pinned commit, and checks every file against its sha256 (see
48
+ [SECURITY.md](SECURITY.md)). The model is cached under
49
+ `${XDG_CACHE_HOME:-~/.cache}/vinospeak/` by default.
50
+
51
+ ```bash
52
+ vinospeak "Hello from vinoSpeak." # plays it on your speakers
53
+ vinospeak "Hello from vinoSpeak." --output hello.wav # saves a WAV instead
54
+ ```
55
+
56
+ Playback goes through `pw-play` (PipeWire, what Wayland desktops run), falling
57
+ back to `paplay` or `aplay`.
58
+
59
+ The default inference device is the Intel NPU. Choose a different OpenVINO
60
+ device, voice, language, speed, or model directory with the corresponding
61
+ options:
62
+
63
+ ```bash
64
+ vinospeak "A sample sentence." \
65
+ --device NPU \
66
+ --voice af_heart \
67
+ --language en-us \
68
+ --speed 1.0 \
69
+ --model-dir ./kokoro-model \
70
+ --output speech.wav
71
+ ```
72
+
73
+ Use `vinospeak --help` to see all options. The audio uses the sample rate
74
+ reported by OpenVINO GenAI.
75
+
76
+ ## Check this machine
77
+
78
+ ```bash
79
+ vinospeak-doctor # --json for a bug report
80
+ ```
81
+
82
+ Reports the OpenVINO packages and devices, whether the cached model still
83
+ matches its pins, and, if [ovfetch](https://github.com/karanshukla/ovfetch) is
84
+ on your `PATH`, whether your OpenVINO version is inside the range recorded as
85
+ working with your NPU driver. Install it with `cargo install ovfetch --locked`.
86
+ ovfetch is advisory here: it never changes what gets installed or run.
87
+
88
+ ## Use it from Python
89
+
90
+ ```python
91
+ from vino_speak import Synthesizer
92
+
93
+ synth = Synthesizer("NPU") # compiles the model once; reuse it
94
+ audio = synth.synthesize("Hello.") # audio.samples (float32), audio.sample_rate
95
+
96
+ speech = synth.speak("A long passage.") # returns at once, plays in the background
97
+ speech.stop() # cuts it off; speech.wait() blocks until it finishes instead
98
+ ```
99
+
100
+ `speak()` synthesizes the first sentence on its own and plays it while the
101
+ rest is synthesized in chunks that at most double in size, so speech starts
102
+ after about 1.5 s on the NPU however long the text is.
103
+
104
+ ## Development
105
+
106
+ See [CONTRIBUTING.md](CONTRIBUTING.md). `uv run poe check` runs everything CI
107
+ does; none of it needs OpenVINO or an NPU.
@@ -0,0 +1,137 @@
1
+ [build-system]
2
+ requires = ["setuptools>=78.1.1"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "vinospeak"
7
+ version = "0.1.0"
8
+ description = "Intel NPU-powered Kokoro text-to-speech with OpenVINO"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Karan Shukla" }]
13
+ keywords = ["kokoro", "openvino", "npu", "text-to-speech", "tts", "linux"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: End Users/Desktop",
19
+ "Operating System :: POSIX :: Linux",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ "Topic :: Multimedia :: Sound/Audio :: Speech",
26
+ "Typing :: Typed",
27
+ ]
28
+ requires-python = ">=3.11"
29
+ # OpenVINO is pinned to the exact release the other vino apps use (vinoPop's
30
+ # requirements-ai.txt, vinoWhisper's uv.lock): two programs on one NPU must not
31
+ # run different OpenVINO releases. Bump it in step with them; `ovfetch detect`
32
+ # reports the range your NPU driver is known to work with.
33
+ dependencies = [
34
+ "espeakng-loader>=0.2.4",
35
+ "huggingface-hub>=0.24.0",
36
+ "numpy>=1.24.0",
37
+ "openvino==2026.4.1",
38
+ "openvino-genai==2026.4.1.0",
39
+ "soundfile>=0.12.1",
40
+ ]
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/karanshukla/vinoSpeak"
44
+ Repository = "https://github.com/karanshukla/vinoSpeak"
45
+ Issues = "https://github.com/karanshukla/vinoSpeak/issues"
46
+ Changelog = "https://github.com/karanshukla/vinoSpeak/blob/main/CHANGELOG.md"
47
+
48
+ [project.scripts]
49
+ vinospeak = "vino_speak.cli:main"
50
+ vinospeak-doctor = "vino_speak.doctor:main"
51
+
52
+ [tool.setuptools.packages.find]
53
+ include = ["vino_speak*"]
54
+
55
+ # Dropping either from the wheel fails quietly: the pins are read at run time.
56
+ [tool.setuptools.package-data]
57
+ vino_speak = ["model_pins.json", "py.typed"]
58
+
59
+ # ── uv ────────────────────────────────────────────────────────────────────────
60
+
61
+ [dependency-groups]
62
+ # Installable alone, so CI never downloads OpenVINO
63
+ dev = [
64
+ "pytest>=8.0",
65
+ "pytest-cov>=5.0",
66
+ "ruff>=0.6",
67
+ "mypy>=1.10",
68
+ "poethepoet>=0.27",
69
+ "bump-my-version>=0.24",
70
+ ]
71
+
72
+ # ── bump-my-version ───────────────────────────────────────────────────────────
73
+
74
+ [tool.bumpversion]
75
+ current_version = "0.1.0"
76
+ commit = true
77
+ tag = true
78
+ tag_name = "v{new_version}"
79
+ pre_commit_hooks = ["uv lock", "git add uv.lock"]
80
+
81
+ [[tool.bumpversion.files]]
82
+ filename = "pyproject.toml"
83
+ search = 'version = "{current_version}"'
84
+ replace = 'version = "{new_version}"'
85
+
86
+ # ── Tasks ─────────────────────────────────────────────────────────────────────
87
+
88
+ [tool.poe.tasks]
89
+ lint = "ruff check ."
90
+ fmt = "ruff format --check ."
91
+ types = "mypy"
92
+ test = "pytest -v --tb=short"
93
+ fix.sequence = [{ cmd = "ruff check --fix ." }, { cmd = "ruff format ." }]
94
+ security.cmd = "uvx --from bandit[toml]==1.9.4 bandit -c pyproject.toml -r ."
95
+ security.help = "the same Bandit scan the Bandit workflow runs"
96
+ check.sequence = ["lint", "fmt", "types", "test"]
97
+ check.help = "everything CI runs, locally"
98
+ doctor = "vinospeak-doctor"
99
+
100
+ # ── Ruff ──────────────────────────────────────────────────────────────────────
101
+
102
+ [tool.ruff]
103
+ line-length = 100
104
+ target-version = "py311"
105
+ extend-exclude = [".venv"]
106
+
107
+ [tool.ruff.lint]
108
+ select = ["E", "F", "I", "UP", "B", "SIM"]
109
+ ignore = [
110
+ "E501", # line length is the formatter's job
111
+ ]
112
+
113
+ # ── Mypy ──────────────────────────────────────────────────────────────────────
114
+
115
+ [tool.mypy]
116
+ files = ["vino_speak", "scripts"]
117
+ ignore_missing_imports = true
118
+ strict = true
119
+
120
+ # ── Pytest ────────────────────────────────────────────────────────────────────
121
+
122
+ [tool.pytest.ini_options]
123
+ testpaths = ["tests"]
124
+ python_files = ["test_*.py"]
125
+ addopts = "-q"
126
+
127
+ [tool.coverage.report]
128
+ show_missing = true
129
+
130
+ # ── Bandit ────────────────────────────────────────────────────────────────────
131
+
132
+ [tool.bandit]
133
+ exclude_dirs = ["tests", ".venv", ".git", "build", "dist"]
134
+ skips = [
135
+ "B404", # import subprocess: ovfetch is run by design
136
+ "B603", # subprocess without shell
137
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,52 @@
1
+ import pytest
2
+
3
+ from vino_speak import cli
4
+
5
+
6
+ def test_parser_defaults_to_npu():
7
+ arguments = cli.build_parser().parse_args(["Hello"])
8
+ assert arguments.device == "NPU"
9
+ assert arguments.voice == "af_heart"
10
+ assert arguments.model_dir is None
11
+
12
+
13
+ def test_main_reports_errors_through_the_parser(monkeypatch, capsys):
14
+ def refuse(*args, **kwargs):
15
+ raise ValueError("Speed must be between 0.5 and 2.0.")
16
+
17
+ monkeypatch.setattr(cli, "synthesize", refuse)
18
+ with pytest.raises(SystemExit) as exit_info:
19
+ cli.main(["Hello", "--speed", "9"])
20
+ assert exit_info.value.code == 2
21
+ assert "Speed must be between" in capsys.readouterr().err
22
+
23
+
24
+ def test_main_prints_where_the_speech_went(monkeypatch, capsys, tmp_path):
25
+ monkeypatch.setattr(cli, "synthesize", lambda *args, **kwargs: None)
26
+ cli.main(["Hello", "--output", str(tmp_path / "out.wav")])
27
+ assert str(tmp_path / "out.wav") in capsys.readouterr().out
28
+
29
+
30
+ def test_main_speaks_when_there_is_no_output(monkeypatch, capsys):
31
+ spoken = []
32
+ monkeypatch.setattr(cli, "speak", lambda text, **kwargs: spoken.append(text))
33
+ monkeypatch.setattr(cli, "synthesize", lambda *args, **kwargs: pytest.fail("wrote a file"))
34
+ cli.main(["Hello"])
35
+ assert spoken == ["Hello"]
36
+ assert capsys.readouterr().out == ""
37
+
38
+
39
+ def test_saving_to_a_file_does_not_speak(monkeypatch, tmp_path):
40
+ monkeypatch.setattr(cli, "synthesize", lambda *args, **kwargs: None)
41
+ monkeypatch.setattr(cli, "speak", lambda *args, **kwargs: pytest.fail("spoke a saved file"))
42
+ cli.main(["Hello", "--output", str(tmp_path / "out.wav")])
43
+
44
+
45
+ def test_ctrl_c_exits_quietly_with_130(monkeypatch):
46
+ def interrupted(*args, **kwargs):
47
+ raise KeyboardInterrupt
48
+
49
+ monkeypatch.setattr(cli, "speak", interrupted)
50
+ with pytest.raises(SystemExit) as exit_info:
51
+ cli.main(["Hello"])
52
+ assert exit_info.value.code == 130
@@ -0,0 +1,86 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from vino_speak import doctor, ovfetch
6
+ from vino_speak.model import MODEL_FILES
7
+
8
+
9
+ @pytest.fixture(autouse=True)
10
+ def isolated(monkeypatch, tmp_path):
11
+ monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
12
+ monkeypatch.setattr(doctor, "_distribution", lambda name: "1.0")
13
+ monkeypatch.setattr(
14
+ doctor, "_devices", lambda: [doctor.Result(doctor.OK, "devices", "CPU, NPU")]
15
+ )
16
+
17
+
18
+ def by_label(results, label):
19
+ return next(r for r in results if r.label == label)
20
+
21
+
22
+ def test_a_missing_package_is_a_failure(monkeypatch):
23
+ monkeypatch.setattr(doctor, "_distribution", lambda name: None)
24
+ assert by_label(doctor._packages(), "openvino-genai").status == doctor.FAIL
25
+
26
+
27
+ def test_a_model_not_yet_downloaded_is_unknown_not_failed():
28
+ assert by_label(doctor._model(), "model").status == doctor.UNKNOWN
29
+
30
+
31
+ def test_a_stale_model_fails(monkeypatch, tmp_path):
32
+ for name in MODEL_FILES:
33
+ path = tmp_path / "vinospeak" / "kokoro-82m-int8-ov" / name
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ path.write_bytes(b"stale")
36
+ assert by_label(doctor._model(), "model").status == doctor.FAIL
37
+
38
+
39
+ def test_ovfetch_is_only_consulted_with_an_npu(monkeypatch):
40
+ monkeypatch.setattr(ovfetch, "detect", lambda: pytest.fail("no NPU, so no ovfetch"))
41
+ assert doctor._ovfetch(has_npu=False) == []
42
+
43
+
44
+ def test_a_missing_ovfetch_is_unknown_with_an_install_hint(monkeypatch):
45
+ monkeypatch.setattr(ovfetch, "detect", lambda: None)
46
+ (result,) = doctor._ovfetch(has_npu=True)
47
+ assert result.status == doctor.UNKNOWN
48
+ assert ovfetch.INSTALL_HINT in result.detail
49
+
50
+
51
+ def test_a_broken_ovfetch_is_a_warning_never_a_failure(monkeypatch):
52
+ def broken():
53
+ raise ovfetch.OvfetchError("ovfetch detect exited 2: boom")
54
+
55
+ monkeypatch.setattr(ovfetch, "detect", broken)
56
+ (result,) = doctor._ovfetch(has_npu=True)
57
+ assert result.status == doctor.WARN
58
+
59
+
60
+ def test_ovfetch_notes_become_results_and_never_fail(monkeypatch):
61
+ status = {
62
+ "devices": [{"kind": "npu"}],
63
+ "openvino_ceiling": "2025.4",
64
+ "platforms": [],
65
+ "npu_driver": "1.32.0",
66
+ }
67
+ monkeypatch.setattr(ovfetch, "detect", lambda: status)
68
+ monkeypatch.setattr(doctor, "_distribution", lambda name: "2026.4.1")
69
+ results = doctor._ovfetch(has_npu=True)
70
+ assert by_label(results, "npu: openvino range").status == doctor.WARN
71
+ assert all(r.status != doctor.FAIL for r in results)
72
+
73
+
74
+ def test_json_output_and_exit_status(capsys, monkeypatch):
75
+ monkeypatch.setattr(doctor, "_ovfetch", lambda has_npu: [])
76
+ code = doctor.main(["--json"])
77
+ report = json.loads(capsys.readouterr().out)
78
+ assert code == 0
79
+ assert report["failed"] == []
80
+ assert by_label([doctor.Result(**c) for c in report["checks"]], "vinospeak").status == doctor.OK
81
+
82
+
83
+ def test_exit_status_is_1_when_something_failed(capsys, monkeypatch):
84
+ monkeypatch.setattr(doctor, "_distribution", lambda name: None)
85
+ assert doctor.main([]) == 1
86
+ assert "failed" in capsys.readouterr().out