stackvox 0.11.0__tar.gz → 0.12.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.
- {stackvox-0.11.0/stackvox.egg-info → stackvox-0.12.0}/PKG-INFO +1 -1
- {stackvox-0.11.0 → stackvox-0.12.0}/pyproject.toml +9 -1
- {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/cli.py +21 -5
- {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/daemon.py +39 -8
- {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/text.py +321 -24
- {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/updates.py +39 -1
- {stackvox-0.11.0 → stackvox-0.12.0/stackvox.egg-info}/PKG-INFO +1 -1
- {stackvox-0.11.0 → stackvox-0.12.0}/tests/test_cli.py +22 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/tests/test_daemon.py +72 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/tests/test_text.py +156 -9
- {stackvox-0.11.0 → stackvox-0.12.0}/tests/test_updates.py +45 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/LICENSE +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/NOTICE +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/README.md +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/setup.cfg +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/__init__.py +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/__main__.py +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/config.py +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/data/stackvox-say +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/engine.py +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/paths.py +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/py.typed +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/stackvox.egg-info/SOURCES.txt +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/stackvox.egg-info/dependency_links.txt +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/stackvox.egg-info/entry_points.txt +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/stackvox.egg-info/requires.txt +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/stackvox.egg-info/top_level.txt +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/tests/test_config.py +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/tests/test_daemon_protocol.py +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/tests/test_engine.py +0 -0
- {stackvox-0.11.0 → stackvox-0.12.0}/tests/test_paths.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "stackvox"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.12.0"
|
|
8
8
|
description = "Offline TTS library using Kokoro-82M"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -61,6 +61,14 @@ stackvox = ["py.typed", "data/stackvox-say"]
|
|
|
61
61
|
line-length = 110
|
|
62
62
|
target-version = "py310"
|
|
63
63
|
|
|
64
|
+
[tool.ruff.format]
|
|
65
|
+
# Ruff 0.16 began formatting Python code blocks inside Markdown. Ours are
|
|
66
|
+
# illustrative rather than real code, and their layout is deliberate: the
|
|
67
|
+
# `normalize_for_speech` signature in docs/speech-normalization.md aligns its
|
|
68
|
+
# trailing comments into a readable column of flags, which the formatter
|
|
69
|
+
# collapses. Prose is a human call, so keep the formatter to the code.
|
|
70
|
+
exclude = ["*.md"]
|
|
71
|
+
|
|
64
72
|
[tool.ruff.lint]
|
|
65
73
|
select = [
|
|
66
74
|
"E", # pycodestyle errors
|
|
@@ -87,7 +87,7 @@ _stackvox_completion() {
|
|
|
87
87
|
;;
|
|
88
88
|
esac
|
|
89
89
|
|
|
90
|
-
local norm_flags="--no-markdown --no-dev-terms --pronunciations --no-expand-units --no-expand-numbers --no-pauses --tables --code-blocks --code-placeholder --strip-emoji --no-terminal-stops --locale"
|
|
90
|
+
local norm_flags="--no-markdown --no-dev-terms --no-filenames --pronunciations --no-expand-units --no-expand-numbers --no-pauses --tables --code-blocks --code-placeholder --strip-emoji --no-terminal-stops --locale"
|
|
91
91
|
|
|
92
92
|
case "$subcommand" in
|
|
93
93
|
speak)
|
|
@@ -220,6 +220,12 @@ def _add_normalize_args(parser: argparse.ArgumentParser, *, with_switch: bool) -
|
|
|
220
220
|
action="store_false",
|
|
221
221
|
help="Do not spell out dev acronyms espeak mispronounces (CLI, CI, IDE, AWS, URI, IAM, ...)",
|
|
222
222
|
)
|
|
223
|
+
parser.add_argument(
|
|
224
|
+
"--no-filenames",
|
|
225
|
+
dest="filenames",
|
|
226
|
+
action="store_false",
|
|
227
|
+
help="Leave filenames and paths as written (no 'README dot md', no 'line 42 of cli dot py')",
|
|
228
|
+
)
|
|
223
229
|
parser.add_argument(
|
|
224
230
|
"--no-expand-units",
|
|
225
231
|
dest="expand_units",
|
|
@@ -294,6 +300,7 @@ def _normalize_kwargs(args: argparse.Namespace) -> dict:
|
|
|
294
300
|
"markdown": args.markdown,
|
|
295
301
|
"pronunciations": _load_pronunciations(args.pronunciations),
|
|
296
302
|
"dev_terms": args.dev_terms,
|
|
303
|
+
"filenames": args.filenames,
|
|
297
304
|
"expand_units": args.expand_units,
|
|
298
305
|
"expand_numbers": args.expand_numbers,
|
|
299
306
|
"pauses": args.pauses,
|
|
@@ -425,12 +432,21 @@ def _cmd_status(_: argparse.Namespace) -> int:
|
|
|
425
432
|
rc = 0
|
|
426
433
|
# The running daemon can lag the installed package if it wasn't restarted
|
|
427
434
|
# after an upgrade — surface that skew instead of trusting it blindly.
|
|
435
|
+
# Only a daemon that is genuinely BEHIND wants restarting; a newer one
|
|
436
|
+
# means the client just run is the older install (a checkout alongside a
|
|
437
|
+
# global one), and restarting would downgrade what's serving.
|
|
428
438
|
got, running_version = daemon.version()
|
|
429
439
|
if got and running_version != installed:
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
440
|
+
if updates._is_newer(installed, running_version):
|
|
441
|
+
print(
|
|
442
|
+
f"daemon running {running_version}, but {installed} is installed "
|
|
443
|
+
f"— restart the daemon (stackvox stop; stackvox serve) to pick it up"
|
|
444
|
+
)
|
|
445
|
+
else:
|
|
446
|
+
print(
|
|
447
|
+
f"daemon running {running_version} is newer than the {installed} client "
|
|
448
|
+
f"you just ran; no restart needed"
|
|
449
|
+
)
|
|
434
450
|
else:
|
|
435
451
|
print("stopped")
|
|
436
452
|
rc = 1
|
|
@@ -26,6 +26,7 @@ import socket
|
|
|
26
26
|
import socketserver
|
|
27
27
|
import sys
|
|
28
28
|
import threading
|
|
29
|
+
import time
|
|
29
30
|
from typing import Any
|
|
30
31
|
|
|
31
32
|
import sounddevice as sd
|
|
@@ -42,6 +43,10 @@ MAX_QUEUE = 2
|
|
|
42
43
|
WORKER_POLL_SECONDS = 0.5
|
|
43
44
|
CLIENT_TIMEOUT_SECONDS = 1.0
|
|
44
45
|
PING_TIMEOUT_SECONDS = 0.5
|
|
46
|
+
# How long `stop` waits for the daemon process to actually exit after it has
|
|
47
|
+
# acknowledged the request, and how often it re-checks.
|
|
48
|
+
STOP_TIMEOUT_SECONDS = 5.0
|
|
49
|
+
STOP_POLL_SECONDS = 0.05
|
|
45
50
|
RECV_BYTES = 1024
|
|
46
51
|
|
|
47
52
|
|
|
@@ -273,14 +278,19 @@ def _pid_alive(pid: int) -> bool:
|
|
|
273
278
|
return False
|
|
274
279
|
|
|
275
280
|
|
|
276
|
-
def
|
|
281
|
+
def _read_pid() -> int | None:
|
|
282
|
+
"""The pid recorded in the pid file, or None if absent or unparseable."""
|
|
277
283
|
if not PID_PATH.exists():
|
|
278
|
-
return
|
|
284
|
+
return None
|
|
279
285
|
try:
|
|
280
|
-
|
|
281
|
-
except ValueError:
|
|
282
|
-
return
|
|
283
|
-
|
|
286
|
+
return int(PID_PATH.read_text().strip())
|
|
287
|
+
except (ValueError, OSError):
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def is_running() -> bool:
|
|
292
|
+
pid = _read_pid()
|
|
293
|
+
return pid is not None and _pid_alive(pid)
|
|
284
294
|
|
|
285
295
|
|
|
286
296
|
def _check_for_update_async() -> None:
|
|
@@ -359,8 +369,29 @@ def say(
|
|
|
359
369
|
return send(req)
|
|
360
370
|
|
|
361
371
|
|
|
362
|
-
def stop() -> tuple[bool, str]:
|
|
363
|
-
|
|
372
|
+
def stop(timeout: float = STOP_TIMEOUT_SECONDS) -> tuple[bool, str]:
|
|
373
|
+
"""Shut the daemon down and wait until the process has actually gone.
|
|
374
|
+
|
|
375
|
+
The daemon acknowledges the request and only then unwinds `serve_forever`
|
|
376
|
+
and removes its pid file, so returning on the ack alone made
|
|
377
|
+
`stackvox stop && stackvox serve` race: `serve` still saw a live pid, refused
|
|
378
|
+
to start, and the shutdown then completed, leaving nothing running at all.
|
|
379
|
+
Reporting success before the process has exited makes that sequence, the one
|
|
380
|
+
`status` recommends, silently useless.
|
|
381
|
+
"""
|
|
382
|
+
pid = _read_pid()
|
|
383
|
+
ok, resp = send({"command": "stop"})
|
|
384
|
+
if not ok or pid is None:
|
|
385
|
+
# Nothing acknowledged, or no pid to watch: the caller's own
|
|
386
|
+
# `is_running` check is as good as it gets.
|
|
387
|
+
return ok, resp
|
|
388
|
+
deadline = time.monotonic() + timeout
|
|
389
|
+
while True:
|
|
390
|
+
if not _pid_alive(pid):
|
|
391
|
+
return True, resp
|
|
392
|
+
if time.monotonic() >= deadline:
|
|
393
|
+
return False, f"daemon acknowledged stop but pid {pid} is still alive after {timeout:g}s"
|
|
394
|
+
time.sleep(STOP_POLL_SECONDS)
|
|
364
395
|
|
|
365
396
|
|
|
366
397
|
def cancel() -> tuple[bool, str]:
|
|
@@ -12,6 +12,7 @@ exposed for composition and testing. See ``docs/speech-normalization.md``.
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
import re
|
|
15
|
+
from collections.abc import Callable
|
|
15
16
|
|
|
16
17
|
__all__ = [
|
|
17
18
|
"normalize_for_speech",
|
|
@@ -19,8 +20,10 @@ __all__ = [
|
|
|
19
20
|
"strip_emoji",
|
|
20
21
|
"strip_thousands_separators",
|
|
21
22
|
"versions_to_words",
|
|
23
|
+
"speak_versions",
|
|
22
24
|
"decimals_to_words",
|
|
23
25
|
"speak_file_refs",
|
|
26
|
+
"speak_file_names",
|
|
24
27
|
"expand_units",
|
|
25
28
|
"apply_pronunciations",
|
|
26
29
|
"shape_pauses",
|
|
@@ -102,6 +105,81 @@ def versions_to_words(text: str) -> str:
|
|
|
102
105
|
return _VERSION.sub(lambda m: m.group(0).replace(".", " point "), text)
|
|
103
106
|
|
|
104
107
|
|
|
108
|
+
# --------------------------------------------------------------------------- #
|
|
109
|
+
# Semantic versions #
|
|
110
|
+
# --------------------------------------------------------------------------- #
|
|
111
|
+
# `versions_to_words` handles the dotted digits. This handles everything else
|
|
112
|
+
# in a semver string, all of which espeak gets wrong:
|
|
113
|
+
#
|
|
114
|
+
# * a pre-release suffix GLUES to the core: "1.2.3-rc.1" voices as
|
|
115
|
+
# "one point two point three-arsee-one" (hyphen swallowed, suffix dot silent)
|
|
116
|
+
# * "^", ">" and "<" are SILENT, so "^1.2.3" is indistinguishable from a pin
|
|
117
|
+
# * ">=" is broken by our own `=` rule below, which splits it into "> equals",
|
|
118
|
+
# and a bare ">" voices as nothing, so ">=1.2.3" says "equals 1.2.3",
|
|
119
|
+
# inverting the meaning. espeak reads an intact ">=" correctly, so this stage
|
|
120
|
+
# must consume the operator BEFORE `expand_units` sees the "=".
|
|
121
|
+
# * "1.x" voices as "one ex" (dot silent)
|
|
122
|
+
#
|
|
123
|
+
# "~" is left to `expand_units`, which already maps it to "about"; accidentally
|
|
124
|
+
# the right reading for a tilde range.
|
|
125
|
+
|
|
126
|
+
# Longest operators first: ">=" must win before ">". Each requires a following
|
|
127
|
+
# digit (optionally "v"-prefixed), which keeps the rules in version context and
|
|
128
|
+
# off ordinary punctuation. The leading `(\S?)` captures whatever non-space
|
|
129
|
+
# character precedes the operator and re-emits it with a space, so glued forms
|
|
130
|
+
# like pip's "requests>=2.0" or "arr[i]<5" don't fuse into "requestsat least".
|
|
131
|
+
_VERSION_OPERATORS: list[tuple[str, str]] = [
|
|
132
|
+
(r"(\S?)>=\s*(?=v?\d)", "at least "),
|
|
133
|
+
(r"(\S?)<=\s*(?=v?\d)", "at most "),
|
|
134
|
+
(r"(\S?)==\s*(?=v?\d)", "exactly "),
|
|
135
|
+
(r"(\S?)!=\s*(?=v?\d)", "not equal to "),
|
|
136
|
+
(r"(\S?)>\s*(?=v?\d)", "above "),
|
|
137
|
+
(r"(\S?)<\s*(?=v?\d)", "below "),
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
# "^" only at a token start: "x^2" and ")^2" are exponentiation, not a range.
|
|
141
|
+
_CARET_RANGE = re.compile(r"(?<![\w)])\^\s*(?=v?\d)")
|
|
142
|
+
|
|
143
|
+
# A dotted core (>=2 parts, so a bare "100" can't match) followed by a
|
|
144
|
+
# "-prerelease" or "+build" suffix.
|
|
145
|
+
_VERSION_SUFFIX = re.compile(r"(?<![\w.])(v?\d+(?:\.\d+){1,3})([-+][0-9A-Za-z][0-9A-Za-z.-]*)(?![\w])")
|
|
146
|
+
|
|
147
|
+
# Wildcard versions: "1.x", "2.*", "1.2.X".
|
|
148
|
+
_VERSION_WILDCARD = re.compile(r"(?<![\w.])(v?\d+(?:\.\d+)*)\.([xX*])(?![\w])")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _operator_repl(spoken: str) -> Callable[[re.Match[str]], str]:
|
|
152
|
+
"""Replacement that re-emits the captured boundary character with a space."""
|
|
153
|
+
|
|
154
|
+
def repl(match: re.Match[str]) -> str:
|
|
155
|
+
boundary = match.group(1)
|
|
156
|
+
return f"{boundary} {spoken}" if boundary else spoken
|
|
157
|
+
|
|
158
|
+
return repl
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def speak_versions(text: str) -> str:
|
|
162
|
+
"""Voice the non-numeric parts of a semantic version.
|
|
163
|
+
|
|
164
|
+
``1.2.3-rc.1`` -> "1.2.3, rc 1"; ``>=1.2.3`` -> "at least 1.2.3";
|
|
165
|
+
``^1.2.3`` -> "compatible with 1.2.3"; ``1.x`` -> "1 dot x". The dotted
|
|
166
|
+
digits are left for :func:`versions_to_words`, which runs later, so this
|
|
167
|
+
stage only unglues and names things espeak drops.
|
|
168
|
+
|
|
169
|
+
Must run BEFORE :func:`expand_units`, whose ``=`` rule would otherwise split
|
|
170
|
+
``>=`` into a silent ``>`` plus "equals".
|
|
171
|
+
"""
|
|
172
|
+
for pattern, words in _VERSION_OPERATORS:
|
|
173
|
+
text = re.sub(pattern, _operator_repl(words), text)
|
|
174
|
+
text = _CARET_RANGE.sub("compatible with ", text)
|
|
175
|
+
# Suffix: comma for a beat, then the tag's own dots and hyphens as spaces.
|
|
176
|
+
text = _VERSION_SUFFIX.sub(
|
|
177
|
+
lambda m: f"{m.group(1)}, {re.sub(r'[.-]', ' ', m.group(2)[1:])}",
|
|
178
|
+
text,
|
|
179
|
+
)
|
|
180
|
+
return _VERSION_WILDCARD.sub(r"\1 dot \2", text)
|
|
181
|
+
|
|
182
|
+
|
|
105
183
|
def decimals_to_words(text: str) -> str:
|
|
106
184
|
"""1198.9 -> "1198 point 9"; 770.72 -> "770 point 7 2". Removes the bare
|
|
107
185
|
"." between digits, which TTS can otherwise read as a full stop."""
|
|
@@ -113,45 +191,257 @@ def decimals_to_words(text: str) -> str:
|
|
|
113
191
|
|
|
114
192
|
|
|
115
193
|
# --------------------------------------------------------------------------- #
|
|
116
|
-
# File &
|
|
194
|
+
# File & path references #
|
|
117
195
|
# --------------------------------------------------------------------------- #
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
196
|
+
# espeak already spells extensions correctly on its own: ".md" voices as "em
|
|
197
|
+
# dee", ".tf" as "tee eff", ".json" as "jason", so these stages deliberately
|
|
198
|
+
# leave the extension alone. What espeak gets wrong is everything around it:
|
|
199
|
+
#
|
|
200
|
+
# * the dot between stem and extension is SILENT ("README.md" -> "readmee-emdee")
|
|
201
|
+
# * a hyphen inside a name is swallowed ("speech-normalization" -> one word)
|
|
202
|
+
# * a leading dot is silent, so ".github" reads as "github"
|
|
203
|
+
# * "~/" glues into "tilde-slash"
|
|
204
|
+
# * ".yml" reads as "immle" (".yaml" is fine)
|
|
205
|
+
#
|
|
206
|
+
# So: voice the dot, space the hyphens, voice a leading dot, say "home" for "~",
|
|
207
|
+
# and alias the one bad extension. Directory segments are spoken with "slash"
|
|
208
|
+
# between them, which is how a person reads a path aloud.
|
|
209
|
+
|
|
210
|
+
# Only these extensions mark a dotted token as a filename. An allowlist, not a
|
|
211
|
+
# general "word.word" rule, because ordinary prose is full of lookalikes espeak
|
|
212
|
+
# ALREADY voices correctly and which must not be touched: attribute access
|
|
213
|
+
# (os.path.join, self.assertEqual), abbreviations (e.g., i.e., U.S.), and
|
|
214
|
+
# domains (example.com, claude.ai) all fall outside it for free.
|
|
215
|
+
# Single-letter extensions (.c, .h, .r) are deliberately absent: they would
|
|
216
|
+
# rewrite initials like "J.R.R" into "J dot R dot R". Those files keep espeak's
|
|
217
|
+
# existing reading rather than risk a prose regression.
|
|
218
|
+
_FILE_EXTENSIONS = frozenset(
|
|
219
|
+
[
|
|
220
|
+
"py",
|
|
221
|
+
"pyi",
|
|
222
|
+
"pyx",
|
|
223
|
+
"ipynb",
|
|
224
|
+
"rb",
|
|
225
|
+
"rs",
|
|
226
|
+
"go",
|
|
227
|
+
"java",
|
|
228
|
+
"kt",
|
|
229
|
+
"kts",
|
|
230
|
+
"swift",
|
|
231
|
+
"cpp",
|
|
232
|
+
"hpp",
|
|
233
|
+
"cc",
|
|
234
|
+
"cxx",
|
|
235
|
+
"cs",
|
|
236
|
+
"php",
|
|
237
|
+
"lua",
|
|
238
|
+
"pl",
|
|
239
|
+
"scala",
|
|
240
|
+
"clj",
|
|
241
|
+
"cljs",
|
|
242
|
+
"ex",
|
|
243
|
+
"exs",
|
|
244
|
+
"erl",
|
|
245
|
+
"vim",
|
|
246
|
+
"el",
|
|
247
|
+
"ts",
|
|
248
|
+
"tsx",
|
|
249
|
+
"js",
|
|
250
|
+
"jsx",
|
|
251
|
+
"mjs",
|
|
252
|
+
"cjs",
|
|
253
|
+
"vue",
|
|
254
|
+
"svelte",
|
|
255
|
+
"astro",
|
|
256
|
+
"json",
|
|
257
|
+
"yaml",
|
|
258
|
+
"yml",
|
|
259
|
+
"toml",
|
|
260
|
+
"ini",
|
|
261
|
+
"cfg",
|
|
262
|
+
"conf",
|
|
263
|
+
"env",
|
|
264
|
+
"xml",
|
|
265
|
+
"csv",
|
|
266
|
+
"tsv",
|
|
267
|
+
"properties",
|
|
268
|
+
"lock",
|
|
269
|
+
"md",
|
|
270
|
+
"mdx",
|
|
271
|
+
"rst",
|
|
272
|
+
"txt",
|
|
273
|
+
"adoc",
|
|
274
|
+
"tex",
|
|
275
|
+
"html",
|
|
276
|
+
"htm",
|
|
277
|
+
"css",
|
|
278
|
+
"scss",
|
|
279
|
+
"sass",
|
|
280
|
+
"less",
|
|
281
|
+
"svg",
|
|
282
|
+
"tf",
|
|
283
|
+
"tfvars",
|
|
284
|
+
"tfstate",
|
|
285
|
+
"mk",
|
|
286
|
+
"cmake",
|
|
287
|
+
"gradle",
|
|
288
|
+
"bzl",
|
|
289
|
+
"proto",
|
|
290
|
+
"graphql",
|
|
291
|
+
"gql",
|
|
292
|
+
"sql",
|
|
293
|
+
"sh",
|
|
294
|
+
"bash",
|
|
295
|
+
"zsh",
|
|
296
|
+
"fish",
|
|
297
|
+
"ps1",
|
|
298
|
+
"bat",
|
|
299
|
+
"dockerfile",
|
|
300
|
+
"gitignore",
|
|
301
|
+
"editorconfig",
|
|
302
|
+
"npmrc",
|
|
303
|
+
"nvmrc",
|
|
304
|
+
"log",
|
|
305
|
+
"wav",
|
|
306
|
+
"mp3",
|
|
307
|
+
"pdf",
|
|
308
|
+
"png",
|
|
309
|
+
"jpg",
|
|
310
|
+
"jpeg",
|
|
311
|
+
"gif",
|
|
312
|
+
"webp",
|
|
313
|
+
"zip",
|
|
314
|
+
"tar",
|
|
315
|
+
"gz",
|
|
316
|
+
"whl",
|
|
317
|
+
]
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Extensions espeak mispronounces, mapped to a spelling it reads correctly.
|
|
321
|
+
# ".yml" -> "immle"; the "yaml" spelling voices as "yaml".
|
|
322
|
+
_EXTENSION_ALIASES: dict[str, str] = {"yml": "yaml"}
|
|
323
|
+
|
|
324
|
+
# Dotted tokens that LOOK like "name.ext" with a real extension but are read as
|
|
325
|
+
# a single product name. Compared lowercased against the whole matched token.
|
|
326
|
+
_NOT_FILENAMES = frozenset({"node.js", "next.js", "nuxt.js", "vue.js", "ember.js", "backbone.js"})
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _speak_segment(segment: str) -> str:
|
|
330
|
+
"""Space out a hyphenated path segment: "speech-normalization" -> "speech
|
|
331
|
+
normalization". espeak swallows the hyphen and runs the halves together.
|
|
332
|
+
Underscores are left alone; espeak doesn't voice them."""
|
|
333
|
+
return segment.replace("-", " ")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _speak_basename(name: str) -> str:
|
|
337
|
+
"""``speech-normalization.md`` -> "speech normalization dot md".
|
|
338
|
+
|
|
339
|
+
Every dot becomes a spoken "dot" (espeak drops it), hyphens become spaces,
|
|
340
|
+
and the final extension is run through the alias map. The extension itself
|
|
341
|
+
is otherwise untouched; espeak spells it correctly already.
|
|
342
|
+
"""
|
|
343
|
+
segments = name.split(".")
|
|
344
|
+
segments[-1] = _EXTENSION_ALIASES.get(segments[-1].lower(), segments[-1])
|
|
345
|
+
return " dot ".join(_speak_segment(segment) for segment in segments)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _speak_dirs(path: str) -> str:
|
|
349
|
+
"""``src/lib/`` -> "src slash lib"; ``~/.config/`` -> "home slash dot config".
|
|
350
|
+
|
|
351
|
+
A leading dot and a ``~`` are both mis-voiced by espeak (silent, and
|
|
352
|
+
"tilde-slash" respectively), so they're spelled out here.
|
|
353
|
+
"""
|
|
354
|
+
spoken: list[str] = []
|
|
355
|
+
for segment in path.split("/"):
|
|
356
|
+
if not segment or segment == ".":
|
|
357
|
+
continue
|
|
358
|
+
if segment == "~":
|
|
359
|
+
spoken.append("home")
|
|
360
|
+
elif segment == "..":
|
|
361
|
+
spoken.append("dot dot")
|
|
362
|
+
elif segment.startswith("."):
|
|
363
|
+
spoken.append("dot " + _speak_segment(segment[1:]))
|
|
364
|
+
else:
|
|
365
|
+
spoken.append(_speak_segment(segment))
|
|
366
|
+
return " slash ".join(spoken)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _is_filename(basename: str) -> bool:
|
|
370
|
+
"""True when the final dotted component is a known file extension."""
|
|
371
|
+
if basename.lower() in _NOT_FILENAMES:
|
|
372
|
+
return False
|
|
373
|
+
return basename.rsplit(".", 1)[-1].lower() in _FILE_EXTENSIONS
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# Directory segments, captured. The inner segment allows empty, so an absolute
|
|
377
|
+
# "/abs/path/" is consumed by the match rather than left stranded in the text.
|
|
378
|
+
_DIRS = r"((?:[\w.~-]*/)*)"
|
|
379
|
+
_BASENAME = r"([A-Za-z0-9_-]+(?:\.[A-Za-z][\w-]*)+)" # name with >=1 letter-initial extension
|
|
123
380
|
|
|
124
381
|
_FILE_REF = re.compile(
|
|
125
382
|
r"(?<!\w)"
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
r":(\d+)(?:-(\d+))?(?::(\d+))?" # :line, optional -end (range) or :column
|
|
129
|
-
r"(?!\w)"
|
|
383
|
+
+ _DIRS
|
|
384
|
+
+ _BASENAME
|
|
385
|
+
+ r":(\d+)(?:-(\d+))?(?::(\d+))?" # :line, optional -end (range) or :column
|
|
386
|
+
+ r"(?!\w)"
|
|
130
387
|
)
|
|
131
388
|
|
|
389
|
+
# Same shape without the ":line" suffix. The trailing guard allows a following
|
|
390
|
+
# "." so a sentence-final "See README.md." still matches.
|
|
391
|
+
_FILE_NAME = re.compile(r"(?<![\w/~.-])" + _DIRS + _BASENAME + r"(?![\w-])")
|
|
392
|
+
|
|
132
393
|
|
|
133
394
|
def speak_file_refs(text: str) -> str:
|
|
134
|
-
"""Turn ``path/file.ext:line`` refs into spoken "line N of file ext".
|
|
395
|
+
"""Turn ``path/file.ext:line`` refs into spoken "line N of file dot ext in path".
|
|
135
396
|
|
|
136
|
-
``engine.py:42`` -> "line 42 of engine py"; ``cli.py:100-118`` ->
|
|
137
|
-
to 118 of cli py"; ``foo.ts:666:10`` -> "line 666,
|
|
138
|
-
|
|
139
|
-
|
|
397
|
+
``engine.py:42`` -> "line 42 of engine dot py"; ``src/cli.py:100-118`` ->
|
|
398
|
+
"lines 100 to 118 of cli dot py in src"; ``foo.ts:666:10`` -> "line 666,
|
|
399
|
+
column 10 of foo dot ts". The line number leads because spoken aloud it's
|
|
400
|
+
the signal, and the directory trails as a prepositional phrase, which is how
|
|
401
|
+
a person says it.
|
|
402
|
+
|
|
403
|
+
The ``:line`` suffix is the trigger, so bare times/ratios/verses (``12:30``,
|
|
404
|
+
``3:1``, ``John 3:16``) and dotted versions (``1.2.3``) are left untouched --
|
|
140
405
|
none of them carry a dotted-filename before the colon.
|
|
141
406
|
"""
|
|
142
407
|
|
|
143
408
|
def repl(match: re.Match[str]) -> str:
|
|
144
|
-
basename = match.group(1).
|
|
145
|
-
start, end, column = match.group(
|
|
409
|
+
dirs, basename = match.group(1), match.group(2)
|
|
410
|
+
start, end, column = match.group(3), match.group(4), match.group(5)
|
|
146
411
|
if end:
|
|
147
412
|
location = f"lines {start} to {end}"
|
|
148
413
|
else:
|
|
149
414
|
location = f"line {start}" + (f", column {column}" if column else "")
|
|
150
|
-
|
|
415
|
+
spoken = f"{location} of {_speak_basename(basename)}"
|
|
416
|
+
directories = _speak_dirs(dirs)
|
|
417
|
+
return f"{spoken} in {directories}" if directories else spoken
|
|
151
418
|
|
|
152
419
|
return _FILE_REF.sub(repl, text)
|
|
153
420
|
|
|
154
421
|
|
|
422
|
+
def speak_file_names(text: str) -> str:
|
|
423
|
+
"""Voice a bare filename or path, with no ``:line`` suffix needed.
|
|
424
|
+
|
|
425
|
+
``README.md`` -> "README dot md"; ``docs/speech-normalization.md`` -> "docs
|
|
426
|
+
slash speech normalization dot md". Gated on a known extension
|
|
427
|
+
(:data:`_FILE_EXTENSIONS`), so prose lookalikes espeak already reads
|
|
428
|
+
correctly (``os.path.join``, ``e.g.``, ``example.com``) pass through.
|
|
429
|
+
|
|
430
|
+
Run this AFTER :func:`speak_file_refs`: that stage rewrites its own matches
|
|
431
|
+
into prose containing no dotted token, so the two never fight over one ref.
|
|
432
|
+
"""
|
|
433
|
+
|
|
434
|
+
def repl(match: re.Match[str]) -> str:
|
|
435
|
+
dirs, basename = match.group(1), match.group(2)
|
|
436
|
+
if not _is_filename(basename):
|
|
437
|
+
return match.group(0)
|
|
438
|
+
spoken = _speak_basename(basename)
|
|
439
|
+
directories = _speak_dirs(dirs)
|
|
440
|
+
return f"{directories} slash {spoken}" if directories else spoken
|
|
441
|
+
|
|
442
|
+
return _FILE_NAME.sub(repl, text)
|
|
443
|
+
|
|
444
|
+
|
|
155
445
|
# --------------------------------------------------------------------------- #
|
|
156
446
|
# Pronunciations #
|
|
157
447
|
# --------------------------------------------------------------------------- #
|
|
@@ -360,18 +650,24 @@ def _shape_paragraph(
|
|
|
360
650
|
text: str,
|
|
361
651
|
*,
|
|
362
652
|
pronunciations: dict[str, str] | None,
|
|
363
|
-
|
|
653
|
+
filenames_flag: bool,
|
|
364
654
|
expand_units_flag: bool,
|
|
365
655
|
expand_numbers_flag: bool,
|
|
366
656
|
pauses_flag: bool,
|
|
367
657
|
locale: str,
|
|
368
658
|
) -> str:
|
|
369
|
-
#
|
|
370
|
-
# plain "line 42", not a decimal) and
|
|
371
|
-
# dev-term dict
|
|
372
|
-
|
|
659
|
+
# Filenames first, for two reasons: the ref stage consumes the ":line" digits
|
|
660
|
+
# (so the number stages see a plain "line 42", not a decimal), and spacing the
|
|
661
|
+
# dot leaves the stem a standalone word, which is what lets the dev-term dict
|
|
662
|
+
# below still fix "cli" -> "C L I" without gluing it to the extension.
|
|
663
|
+
# Refs before bare names: the ref stage rewrites its matches into prose with
|
|
664
|
+
# no dotted token left, so the two never fight over the same reference.
|
|
665
|
+
if filenames_flag:
|
|
373
666
|
text = speak_file_refs(text)
|
|
667
|
+
text = speak_file_names(text)
|
|
374
668
|
if expand_numbers_flag:
|
|
669
|
+
# Version ranges before units: the "=" unit rule would split ">=".
|
|
670
|
+
text = speak_versions(text)
|
|
375
671
|
text = strip_thousands_separators(text)
|
|
376
672
|
if pauses_flag:
|
|
377
673
|
text = shape_pauses(text)
|
|
@@ -391,6 +687,7 @@ def normalize_for_speech(
|
|
|
391
687
|
markdown: bool = True,
|
|
392
688
|
pronunciations: dict[str, str] | None = None,
|
|
393
689
|
dev_terms: bool = True,
|
|
690
|
+
filenames: bool = True,
|
|
394
691
|
expand_units: bool = True,
|
|
395
692
|
expand_numbers: bool = True,
|
|
396
693
|
pauses: bool = True,
|
|
@@ -429,7 +726,7 @@ def normalize_for_speech(
|
|
|
429
726
|
shaped = _shape_paragraph(
|
|
430
727
|
para,
|
|
431
728
|
pronunciations=effective_pronunciations,
|
|
432
|
-
|
|
729
|
+
filenames_flag=filenames,
|
|
433
730
|
expand_units_flag=expand_units_flag,
|
|
434
731
|
expand_numbers_flag=expand_numbers_flag,
|
|
435
732
|
pauses_flag=pauses,
|
|
@@ -18,6 +18,7 @@ from __future__ import annotations
|
|
|
18
18
|
import json
|
|
19
19
|
import logging
|
|
20
20
|
import os
|
|
21
|
+
import sys
|
|
21
22
|
import urllib.error
|
|
22
23
|
import urllib.request
|
|
23
24
|
from dataclasses import dataclass
|
|
@@ -26,14 +27,51 @@ from importlib.metadata import PackageNotFoundError
|
|
|
26
27
|
from importlib.metadata import version as _pkg_version
|
|
27
28
|
from pathlib import Path
|
|
28
29
|
|
|
30
|
+
if sys.version_info >= (3, 11):
|
|
31
|
+
import tomllib
|
|
32
|
+
else: # pragma: no cover - covered by 3.10 CI
|
|
33
|
+
import tomli as tomllib
|
|
34
|
+
|
|
29
35
|
from stackvox.paths import cache_dir
|
|
30
36
|
|
|
31
37
|
logger = logging.getLogger(__name__)
|
|
32
38
|
|
|
33
39
|
|
|
40
|
+
def _source_tree_version(pyproject: Path | None = None) -> str | None:
|
|
41
|
+
"""Our version from `pyproject.toml`, when running from a source checkout.
|
|
42
|
+
|
|
43
|
+
An editable install records its version in dist metadata at install time, so
|
|
44
|
+
the moment a release bumps `pyproject.toml` that metadata goes stale. The
|
|
45
|
+
developer then gets a phantom "update available: 0.9.0 -> 0.11.0" against
|
|
46
|
+
their own tree, and `status` reads the running daemon as newer than the
|
|
47
|
+
"installed" package and tells them to restart it for no reason.
|
|
48
|
+
|
|
49
|
+
Returns None when there's no such file (a normal pip/pipx install) or when it
|
|
50
|
+
isn't ours, so the metadata path stays authoritative for real installs.
|
|
51
|
+
"""
|
|
52
|
+
path = pyproject or Path(__file__).resolve().parent.parent / "pyproject.toml"
|
|
53
|
+
try:
|
|
54
|
+
with path.open("rb") as handle:
|
|
55
|
+
data = tomllib.load(handle)
|
|
56
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
57
|
+
return None
|
|
58
|
+
project = data.get("project")
|
|
59
|
+
if not isinstance(project, dict) or project.get("name") != "stackvox":
|
|
60
|
+
return None
|
|
61
|
+
version = project.get("version")
|
|
62
|
+
return version if isinstance(version, str) else None
|
|
63
|
+
|
|
64
|
+
|
|
34
65
|
def _current_version() -> str:
|
|
35
66
|
"""Read our own installed version. Late-bound so importing this module
|
|
36
|
-
early in the package init chain doesn't trip a circular import.
|
|
67
|
+
early in the package init chain doesn't trip a circular import.
|
|
68
|
+
|
|
69
|
+
A source checkout wins over dist metadata, which goes stale on an editable
|
|
70
|
+
install after every release.
|
|
71
|
+
"""
|
|
72
|
+
from_source = _source_tree_version()
|
|
73
|
+
if from_source is not None:
|
|
74
|
+
return from_source
|
|
37
75
|
try:
|
|
38
76
|
return _pkg_version("stackvox")
|
|
39
77
|
except PackageNotFoundError:
|
|
@@ -129,6 +129,7 @@ def _norm_ns(text=None, file=None, **overrides):
|
|
|
129
129
|
markdown=True,
|
|
130
130
|
pronunciations=None,
|
|
131
131
|
dev_terms=True,
|
|
132
|
+
filenames=True,
|
|
132
133
|
expand_units=True,
|
|
133
134
|
expand_numbers=True,
|
|
134
135
|
pauses=True,
|
|
@@ -291,6 +292,27 @@ class TestCmdStatus:
|
|
|
291
292
|
assert "0.5.0" in out and "0.8.0" in out # the skew is surfaced
|
|
292
293
|
assert "restart" in out.lower()
|
|
293
294
|
|
|
295
|
+
def test_running_does_not_advise_restart_when_the_daemon_is_ahead(self, mocker, capsys):
|
|
296
|
+
"""A daemon NEWER than the client means the client is the older install,
|
|
297
|
+
so restarting would downgrade what's serving."""
|
|
298
|
+
mocker.patch.object(cli.daemon, "is_running", return_value=True)
|
|
299
|
+
pid_path = mocker.MagicMock()
|
|
300
|
+
pid_path.read_text.return_value = "1\n"
|
|
301
|
+
mocker.patch.object(cli.daemon, "PID_PATH", pid_path)
|
|
302
|
+
mocker.patch.object(cli.daemon, "SOCKET_PATH", "/tmp/x.sock")
|
|
303
|
+
mocker.patch.object(cli.daemon, "version", return_value=(True, "0.11.0"))
|
|
304
|
+
mocker.patch.object(cli.updates, "_current_version", return_value="0.9.0")
|
|
305
|
+
mocker.patch.object(cli.updates, "check_for_update", return_value=None)
|
|
306
|
+
|
|
307
|
+
rc = cli._cmd_status(_ns())
|
|
308
|
+
|
|
309
|
+
assert rc == 0
|
|
310
|
+
out = capsys.readouterr().out
|
|
311
|
+
assert "0.11.0" in out and "0.9.0" in out
|
|
312
|
+
assert "stackvox stop" not in out # the restart advice itself
|
|
313
|
+
assert "newer" in out.lower()
|
|
314
|
+
assert "no restart needed" in out
|
|
315
|
+
|
|
294
316
|
def test_stopped_returns_one(self, mocker, capsys):
|
|
295
317
|
mocker.patch.object(cli.daemon, "is_running", return_value=False)
|
|
296
318
|
rc = cli._cmd_status(_ns())
|
|
@@ -78,6 +78,9 @@ class TestSendHelpers:
|
|
|
78
78
|
assert send.call_args.args[0] == {"text": "hi"}
|
|
79
79
|
|
|
80
80
|
def test_stop_sends_command_stop(self, mocker):
|
|
81
|
+
# _read_pid is mocked out so the wait-for-exit loop can't reach a real
|
|
82
|
+
# daemon's pid file and block on a live process.
|
|
83
|
+
mocker.patch.object(daemon, "_read_pid", return_value=None)
|
|
81
84
|
send = mocker.patch.object(daemon, "send", return_value=(True, "ok"))
|
|
82
85
|
daemon.stop()
|
|
83
86
|
assert send.call_args.args[0] == {"command": "stop"}
|
|
@@ -136,3 +139,72 @@ class TestStartDeviceWatcher:
|
|
|
136
139
|
with caplog.at_level(logging.DEBUG, logger="stackvox.daemon"):
|
|
137
140
|
daemon._start_device_watcher() # must not raise
|
|
138
141
|
assert any("CoreAudio unavailable" in r.message for r in caplog.records)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TestStopWaitsForExit:
|
|
145
|
+
"""`stop` reports success only once the process has actually gone.
|
|
146
|
+
|
|
147
|
+
The daemon acknowledges the request before unwinding, so returning on the
|
|
148
|
+
ack made `stackvox stop && stackvox serve` race: serve saw a live pid and
|
|
149
|
+
refused, then the shutdown finished, leaving nothing running.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def test_returns_send_failure_unchanged(self, mocker):
|
|
153
|
+
mocker.patch.object(daemon, "_read_pid", return_value=4242)
|
|
154
|
+
mocker.patch.object(daemon, "send", return_value=(False, "daemon not running"))
|
|
155
|
+
|
|
156
|
+
actual = daemon.stop()
|
|
157
|
+
|
|
158
|
+
assert actual == (False, "daemon not running")
|
|
159
|
+
|
|
160
|
+
def test_does_not_wait_when_no_pid_is_recorded(self, mocker):
|
|
161
|
+
mocker.patch.object(daemon, "_read_pid", return_value=None)
|
|
162
|
+
mocker.patch.object(daemon, "send", return_value=(True, "ok"))
|
|
163
|
+
alive = mocker.patch.object(daemon, "_pid_alive")
|
|
164
|
+
|
|
165
|
+
actual = daemon.stop()
|
|
166
|
+
|
|
167
|
+
assert actual == (True, "ok")
|
|
168
|
+
alive.assert_not_called()
|
|
169
|
+
|
|
170
|
+
def test_waits_until_the_process_has_exited(self, mocker):
|
|
171
|
+
mocker.patch.object(daemon, "_read_pid", return_value=4242)
|
|
172
|
+
mocker.patch.object(daemon, "send", return_value=(True, "ok"))
|
|
173
|
+
alive = mocker.patch.object(daemon, "_pid_alive", side_effect=[True, True, False])
|
|
174
|
+
sleep = mocker.patch.object(daemon.time, "sleep")
|
|
175
|
+
|
|
176
|
+
actual = daemon.stop()
|
|
177
|
+
|
|
178
|
+
assert actual == (True, "ok")
|
|
179
|
+
assert alive.call_count == 3
|
|
180
|
+
assert sleep.call_count == 2
|
|
181
|
+
|
|
182
|
+
def test_fails_when_the_process_outlives_the_timeout(self, mocker):
|
|
183
|
+
mocker.patch.object(daemon, "_read_pid", return_value=4242)
|
|
184
|
+
mocker.patch.object(daemon, "send", return_value=(True, "ok"))
|
|
185
|
+
mocker.patch.object(daemon, "_pid_alive", return_value=True)
|
|
186
|
+
mocker.patch.object(daemon.time, "sleep")
|
|
187
|
+
|
|
188
|
+
ok, resp = daemon.stop(timeout=0.0)
|
|
189
|
+
|
|
190
|
+
assert ok is False
|
|
191
|
+
assert "still alive" in resp
|
|
192
|
+
assert "4242" in resp
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class TestReadPid:
|
|
196
|
+
def test_returns_none_when_missing(self, mocker, tmp_path):
|
|
197
|
+
mocker.patch.object(daemon, "PID_PATH", tmp_path / "missing.pid")
|
|
198
|
+
assert daemon._read_pid() is None
|
|
199
|
+
|
|
200
|
+
def test_returns_none_when_unparseable(self, mocker, tmp_path):
|
|
201
|
+
pid = tmp_path / "garbage.pid"
|
|
202
|
+
pid.write_text("not-a-number")
|
|
203
|
+
mocker.patch.object(daemon, "PID_PATH", pid)
|
|
204
|
+
assert daemon._read_pid() is None
|
|
205
|
+
|
|
206
|
+
def test_returns_the_recorded_pid(self, mocker, tmp_path):
|
|
207
|
+
pid = tmp_path / "live.pid"
|
|
208
|
+
pid.write_text("4242\n")
|
|
209
|
+
mocker.patch.object(daemon, "PID_PATH", pid)
|
|
210
|
+
assert daemon._read_pid() == 4242
|
|
@@ -8,7 +8,9 @@ from stackvox.text import (
|
|
|
8
8
|
markdown_to_paragraphs,
|
|
9
9
|
normalize_for_speech,
|
|
10
10
|
shape_pauses,
|
|
11
|
+
speak_file_names,
|
|
11
12
|
speak_file_refs,
|
|
13
|
+
speak_versions,
|
|
12
14
|
strip_emoji,
|
|
13
15
|
strip_thousands_separators,
|
|
14
16
|
versions_to_words,
|
|
@@ -55,23 +57,94 @@ def test_semver_normalizes_end_to_end():
|
|
|
55
57
|
assert "0 point 7.0" not in out
|
|
56
58
|
|
|
57
59
|
|
|
58
|
-
# ---
|
|
60
|
+
# --- semantic versions -----------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_version_prerelease_is_ungluded_from_the_core():
|
|
64
|
+
""" "1.2.3-rc.1" glues into "three-arsee-one": espeak swallows the hyphen and
|
|
65
|
+
drops the suffix dot."""
|
|
66
|
+
assert speak_versions("1.2.3-rc.1") == "1.2.3, rc 1"
|
|
67
|
+
assert speak_versions("1.2.3-beta.2") == "1.2.3, beta 2"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_version_build_metadata():
|
|
71
|
+
assert speak_versions("1.2.3+build.5") == "1.2.3, build 5"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_version_operators_are_spoken_semantically():
|
|
75
|
+
assert speak_versions("^1.2.3") == "compatible with 1.2.3"
|
|
76
|
+
assert speak_versions(">=1.2.3") == "at least 1.2.3"
|
|
77
|
+
assert speak_versions("<=2.0.0") == "at most 2.0.0"
|
|
78
|
+
assert speak_versions(">1.0.0") == "above 1.0.0"
|
|
79
|
+
assert speak_versions("<2.0.0") == "below 2.0.0"
|
|
80
|
+
assert speak_versions("==1.2.3") == "exactly 1.2.3"
|
|
81
|
+
assert speak_versions("!=1.2.3") == "not equal to 1.2.3"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_version_operator_keeps_a_glued_boundary_separate():
|
|
85
|
+
"""pip writes the operator glued to the package name."""
|
|
86
|
+
assert speak_versions("requests>=2.0") == "requests at least 2.0"
|
|
87
|
+
assert speak_versions("arr[i]<5") == "arr[i] below 5"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_caret_exponent_is_not_a_version_range():
|
|
91
|
+
""" "x^2" is exponentiation; only a token-initial caret is a caret range."""
|
|
92
|
+
assert speak_versions("x^2 plus 1") == "x^2 plus 1"
|
|
93
|
+
assert speak_versions("(a+b)^2") == "(a+b)^2"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_version_wildcard_dot_is_spoken():
|
|
97
|
+
assert speak_versions("1.x") == "1 dot x"
|
|
98
|
+
assert speak_versions("2.*") == "2 dot *"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_version_operator_survives_the_units_equals_rule():
|
|
102
|
+
"""Regression: `expand_units` maps "=" to " equals ", which used to split
|
|
103
|
+
">=" into a silent ">" plus "equals", inverting the meaning."""
|
|
104
|
+
assert normalize_for_speech(">=1.2.3", markdown=False) == "at least 1 point 2 point 3."
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_version_normalizes_end_to_end():
|
|
108
|
+
actual = normalize_for_speech("Upgrade to ^1.2.3-rc.1 today.", markdown=False)
|
|
109
|
+
assert actual == "Upgrade to compatible with 1 point 2 point 3, rc 1 today."
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_version_leaves_line_ranges_and_dates_alone():
|
|
113
|
+
assert speak_versions("cli.py:100-118") == "cli.py:100-118"
|
|
114
|
+
assert speak_versions("released 2026-09-08") == "released 2026-09-08"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_version_leaves_two_part_numbers_to_the_decimal_stage():
|
|
118
|
+
"""A two-part version is indistinguishable from a decimal, where
|
|
119
|
+
digit-by-digit is the correct reading. Deliberately untouched."""
|
|
120
|
+
assert normalize_for_speech("770.72", markdown=False) == "770 point 7 2."
|
|
121
|
+
assert normalize_for_speech("3.11", markdown=False) == "3 point 1 1."
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# --- file & path references ------------------------------------------------
|
|
59
125
|
|
|
60
126
|
|
|
61
127
|
def test_file_ref_leads_with_line_then_file():
|
|
62
|
-
|
|
128
|
+
"""Every dot is spoken, including one inside a multi-part name."""
|
|
129
|
+
assert speak_file_refs("unifiedAPIv2.service.ts:666") == "line 666 of unifiedAPIv2 dot service dot ts"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_file_ref_trails_directories_after_the_file():
|
|
133
|
+
assert speak_file_refs("Open /abs/path/to/module.py:7.") == (
|
|
134
|
+
"Open line 7 of module dot py in abs slash path slash to."
|
|
135
|
+
)
|
|
63
136
|
|
|
64
137
|
|
|
65
|
-
def
|
|
66
|
-
assert speak_file_refs("
|
|
138
|
+
def test_file_ref_without_directories_has_no_trailing_clause():
|
|
139
|
+
assert speak_file_refs("engine.py:42") == "line 42 of engine dot py"
|
|
67
140
|
|
|
68
141
|
|
|
69
142
|
def test_file_ref_line_range():
|
|
70
|
-
assert speak_file_refs("cli.py:100-118") == "lines 100 to 118 of cli py"
|
|
143
|
+
assert speak_file_refs("src/cli.py:100-118") == "lines 100 to 118 of cli dot py in src"
|
|
71
144
|
|
|
72
145
|
|
|
73
146
|
def test_file_ref_line_and_column():
|
|
74
|
-
assert speak_file_refs("foo.ts:666:10") == "line 666, column 10 of foo ts"
|
|
147
|
+
assert speak_file_refs("foo.ts:666:10") == "line 666, column 10 of foo dot ts"
|
|
75
148
|
|
|
76
149
|
|
|
77
150
|
def test_file_ref_leaves_times_ratios_verses_untouched():
|
|
@@ -85,12 +158,86 @@ def test_file_ref_leaves_dotted_versions_untouched():
|
|
|
85
158
|
|
|
86
159
|
def test_file_ref_normalizes_end_to_end():
|
|
87
160
|
out = normalize_for_speech("See `engine.py:42` for the fix.", markdown=True)
|
|
88
|
-
assert out == "See line 42 of engine py for the fix."
|
|
161
|
+
assert out == "See line 42 of engine dot py for the fix."
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_file_name_speaks_the_dot():
|
|
165
|
+
assert speak_file_names("See README.md for setup.") == "See README dot md for setup."
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_file_name_at_end_of_sentence():
|
|
169
|
+
assert speak_file_names("Check pyproject.toml.") == "Check pyproject dot toml."
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_file_name_speaks_path_segments_with_slash():
|
|
173
|
+
actual = speak_file_names("docs/speech-normalization.md")
|
|
174
|
+
assert actual == "docs slash speech normalization dot md"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_file_name_spaces_hyphens_espeak_swallows():
|
|
178
|
+
assert speak_file_names("read-aloud.py") == "read aloud dot py"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_file_name_voices_leading_dot_and_home():
|
|
182
|
+
assert speak_file_names(".github/workflows/deploy.yaml") == (
|
|
183
|
+
"dot github slash workflows slash deploy dot yaml"
|
|
184
|
+
)
|
|
185
|
+
assert speak_file_names("~/.config/stackvox/config.toml") == (
|
|
186
|
+
"home slash dot config slash stackvox slash config dot toml"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_file_name_aliases_yml_espeak_reads_as_immle():
|
|
191
|
+
assert speak_file_names("ci.yml") == "ci dot yaml"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_file_name_multi_dot_name():
|
|
195
|
+
assert speak_file_names("vite.config.ts") == "vite dot config dot ts"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_file_name_requires_a_known_extension():
|
|
199
|
+
"""Attribute access, abbreviations and domains espeak already reads
|
|
200
|
+
correctly must survive the bare-filename matcher untouched."""
|
|
201
|
+
for text in [
|
|
202
|
+
"os.path.join",
|
|
203
|
+
"self.assertEqual",
|
|
204
|
+
"Use e.g. this one",
|
|
205
|
+
"i.e. that one",
|
|
206
|
+
"U.S. policy",
|
|
207
|
+
"example.com",
|
|
208
|
+
"claude.ai",
|
|
209
|
+
"J.R.R Tolkien",
|
|
210
|
+
]:
|
|
211
|
+
assert speak_file_names(text) == text
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_file_name_leaves_dotted_js_product_names_untouched():
|
|
215
|
+
assert speak_file_names("Node.js and Next.js") == "Node.js and Next.js"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_file_name_leaves_refs_to_the_ref_stage():
|
|
219
|
+
"""speak_file_refs runs first and leaves no dotted token behind, so the
|
|
220
|
+
bare-name stage must not re-touch its output."""
|
|
221
|
+
once = speak_file_refs("src/cli.py:42")
|
|
222
|
+
assert speak_file_names(once) == once
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_spacing_the_dot_lets_the_dev_dict_fix_the_stem():
|
|
226
|
+
""" "cli.py" glued reads as "kligh dot py"; with the dot spaced, the stem is a
|
|
227
|
+
standalone word the dev-term dict can correct."""
|
|
228
|
+
assert normalize_for_speech("Look at cli.py.", markdown=False) == "Look at C L I dot py."
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_filenames_disabled():
|
|
232
|
+
out = normalize_for_speech("See engine.py:42 and README.md.", markdown=False, filenames=False)
|
|
233
|
+
assert out == "See engine.py:42 and README.md."
|
|
89
234
|
|
|
90
235
|
|
|
91
|
-
def
|
|
236
|
+
def test_filenames_independent_of_dev_terms():
|
|
237
|
+
"""Filenames have their own flag: turning the acronym dict off must not
|
|
238
|
+
silently disable them."""
|
|
92
239
|
out = normalize_for_speech("See engine.py:42.", markdown=False, dev_terms=False)
|
|
93
|
-
assert out == "See engine
|
|
240
|
+
assert out == "See line 42 of engine dot py."
|
|
94
241
|
|
|
95
242
|
|
|
96
243
|
# --- units -----------------------------------------------------------------
|
|
@@ -218,3 +218,48 @@ class TestFormatNotice:
|
|
|
218
218
|
assert "0.3.1" in msg
|
|
219
219
|
assert "0.4.0" in msg
|
|
220
220
|
assert "pipx upgrade stackvox" in msg
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class TestSourceTreeVersion:
|
|
224
|
+
"""An editable install's dist metadata goes stale the moment a release bumps
|
|
225
|
+
pyproject, so a source checkout has to win over it."""
|
|
226
|
+
|
|
227
|
+
def test_reads_the_version_from_our_own_pyproject(self, tmp_path):
|
|
228
|
+
path = tmp_path / "pyproject.toml"
|
|
229
|
+
path.write_text('[project]\nname = "stackvox"\nversion = "1.2.3"\n')
|
|
230
|
+
assert updates._source_tree_version(path) == "1.2.3"
|
|
231
|
+
|
|
232
|
+
def test_ignores_a_pyproject_that_is_not_ours(self, tmp_path):
|
|
233
|
+
path = tmp_path / "pyproject.toml"
|
|
234
|
+
path.write_text('[project]\nname = "something-else"\nversion = "1.2.3"\n')
|
|
235
|
+
assert updates._source_tree_version(path) is None
|
|
236
|
+
|
|
237
|
+
def test_returns_none_when_there_is_no_pyproject(self, tmp_path):
|
|
238
|
+
assert updates._source_tree_version(tmp_path / "nope.toml") is None
|
|
239
|
+
|
|
240
|
+
def test_returns_none_when_the_file_is_malformed(self, tmp_path):
|
|
241
|
+
path = tmp_path / "pyproject.toml"
|
|
242
|
+
path.write_text("not valid toml {{{")
|
|
243
|
+
assert updates._source_tree_version(path) is None
|
|
244
|
+
|
|
245
|
+
def test_returns_none_when_the_version_is_absent(self, tmp_path):
|
|
246
|
+
path = tmp_path / "pyproject.toml"
|
|
247
|
+
path.write_text('[project]\nname = "stackvox"\n')
|
|
248
|
+
assert updates._source_tree_version(path) is None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class TestCurrentVersion:
|
|
252
|
+
def test_source_tree_beats_stale_dist_metadata(self, mocker):
|
|
253
|
+
mocker.patch.object(updates, "_source_tree_version", return_value="0.11.0")
|
|
254
|
+
mocker.patch.object(updates, "_pkg_version", return_value="0.9.0")
|
|
255
|
+
assert updates._current_version() == "0.11.0"
|
|
256
|
+
|
|
257
|
+
def test_falls_back_to_dist_metadata_for_a_real_install(self, mocker):
|
|
258
|
+
mocker.patch.object(updates, "_source_tree_version", return_value=None)
|
|
259
|
+
mocker.patch.object(updates, "_pkg_version", return_value="0.11.0")
|
|
260
|
+
assert updates._current_version() == "0.11.0"
|
|
261
|
+
|
|
262
|
+
def test_reports_unknown_when_not_installed_at_all(self, mocker):
|
|
263
|
+
mocker.patch.object(updates, "_source_tree_version", return_value=None)
|
|
264
|
+
mocker.patch.object(updates, "_pkg_version", side_effect=updates.PackageNotFoundError)
|
|
265
|
+
assert updates._current_version() == "0.0.0+unknown"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|