stackvox 0.11.0__tar.gz → 0.13.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.
Files changed (31) hide show
  1. {stackvox-0.11.0/stackvox.egg-info → stackvox-0.13.0}/PKG-INFO +4 -2
  2. {stackvox-0.11.0 → stackvox-0.13.0}/README.md +3 -1
  3. {stackvox-0.11.0 → stackvox-0.13.0}/pyproject.toml +9 -1
  4. {stackvox-0.11.0 → stackvox-0.13.0}/stackvox/cli.py +28 -5
  5. {stackvox-0.11.0 → stackvox-0.13.0}/stackvox/daemon.py +39 -8
  6. {stackvox-0.11.0 → stackvox-0.13.0}/stackvox/text.py +359 -27
  7. {stackvox-0.11.0 → stackvox-0.13.0}/stackvox/updates.py +39 -1
  8. {stackvox-0.11.0 → stackvox-0.13.0/stackvox.egg-info}/PKG-INFO +4 -2
  9. {stackvox-0.11.0 → stackvox-0.13.0}/tests/test_cli.py +23 -0
  10. {stackvox-0.11.0 → stackvox-0.13.0}/tests/test_daemon.py +72 -0
  11. {stackvox-0.11.0 → stackvox-0.13.0}/tests/test_text.py +197 -9
  12. {stackvox-0.11.0 → stackvox-0.13.0}/tests/test_updates.py +45 -0
  13. {stackvox-0.11.0 → stackvox-0.13.0}/LICENSE +0 -0
  14. {stackvox-0.11.0 → stackvox-0.13.0}/NOTICE +0 -0
  15. {stackvox-0.11.0 → stackvox-0.13.0}/setup.cfg +0 -0
  16. {stackvox-0.11.0 → stackvox-0.13.0}/stackvox/__init__.py +0 -0
  17. {stackvox-0.11.0 → stackvox-0.13.0}/stackvox/__main__.py +0 -0
  18. {stackvox-0.11.0 → stackvox-0.13.0}/stackvox/config.py +0 -0
  19. {stackvox-0.11.0 → stackvox-0.13.0}/stackvox/data/stackvox-say +0 -0
  20. {stackvox-0.11.0 → stackvox-0.13.0}/stackvox/engine.py +0 -0
  21. {stackvox-0.11.0 → stackvox-0.13.0}/stackvox/paths.py +0 -0
  22. {stackvox-0.11.0 → stackvox-0.13.0}/stackvox/py.typed +0 -0
  23. {stackvox-0.11.0 → stackvox-0.13.0}/stackvox.egg-info/SOURCES.txt +0 -0
  24. {stackvox-0.11.0 → stackvox-0.13.0}/stackvox.egg-info/dependency_links.txt +0 -0
  25. {stackvox-0.11.0 → stackvox-0.13.0}/stackvox.egg-info/entry_points.txt +0 -0
  26. {stackvox-0.11.0 → stackvox-0.13.0}/stackvox.egg-info/requires.txt +0 -0
  27. {stackvox-0.11.0 → stackvox-0.13.0}/stackvox.egg-info/top_level.txt +0 -0
  28. {stackvox-0.11.0 → stackvox-0.13.0}/tests/test_config.py +0 -0
  29. {stackvox-0.11.0 → stackvox-0.13.0}/tests/test_daemon_protocol.py +0 -0
  30. {stackvox-0.11.0 → stackvox-0.13.0}/tests/test_engine.py +0 -0
  31. {stackvox-0.11.0 → stackvox-0.13.0}/tests/test_paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.11.0
3
+ Version: 0.13.0
4
4
  Summary: Offline TTS library using Kokoro-82M
5
5
  Author-email: "StackOne Technologies Ltd." <support@stackone.com>
6
6
  License-Expression: Apache-2.0
@@ -103,10 +103,12 @@ stackvox speak --normalize --file post.md # normalize, then synthesize
103
103
  stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon
104
104
  ```
105
105
 
106
- `--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-dev-terms`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--code-blocks {drop,placeholder}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
106
+ `--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-dev-terms`, `--no-abbreviations`, `--no-filenames`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--code-blocks {drop,placeholder}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
107
107
 
108
108
  **Dev-output handling** (on by default; `--no-dev-terms` to disable) covers two things espeak reads badly: acronyms it says as words (`CLI` → "C L I", `AWS`, `URI`, `IAM`, …) and file-line references, which it voices literally as "dot py colon". A `file.ext:line` ref is re-spoken lead-with-the-line: `engine.py:42` → "line 42 of engine py", `cli.py:100-118` → "lines 100 to 118 of cli py", `foo.ts:666:10` → "line 666, column 10 of foo ts". Times, ratios, and versions (`12:30`, `3:1`, `1.2.3`) are left alone.
109
109
 
110
+ **Chat shorthand** (on by default; `--no-abbreviations` to disable) is expanded to the words it stands for, so a response that reads "lmk" is spoken as "let me know": `lmk`, `iirc`, `afaik`, `idk`, `tbh`, `imo`, `btw`, `fyi`, `asap`, `wrt`, `tl;dr`/`tldr`. Whole-word and case-insensitive. Shorthand that doubles as a real word or is already voiced correctly (`rn`, `eta`, `aka`) is deliberately left alone; a `--pronunciations` entry overrides any of these.
111
+
110
112
  Fenced code blocks are dropped by default. `--code-blocks placeholder` instead speaks a short stand-in so a skipped block doesn't sound disjointed (ChatGPT-style) — set the wording with `--code-placeholder` (e.g. `--code-blocks placeholder --code-placeholder "you can see the code in the chat history"`); consecutive blocks collapse to one.
111
113
 
112
114
  Bash completion:
@@ -69,10 +69,12 @@ stackvox speak --normalize --file post.md # normalize, then synthesize
69
69
  stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon
70
70
  ```
71
71
 
72
- `--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-dev-terms`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--code-blocks {drop,placeholder}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
72
+ `--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-dev-terms`, `--no-abbreviations`, `--no-filenames`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--code-blocks {drop,placeholder}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
73
73
 
74
74
  **Dev-output handling** (on by default; `--no-dev-terms` to disable) covers two things espeak reads badly: acronyms it says as words (`CLI` → "C L I", `AWS`, `URI`, `IAM`, …) and file-line references, which it voices literally as "dot py colon". A `file.ext:line` ref is re-spoken lead-with-the-line: `engine.py:42` → "line 42 of engine py", `cli.py:100-118` → "lines 100 to 118 of cli py", `foo.ts:666:10` → "line 666, column 10 of foo ts". Times, ratios, and versions (`12:30`, `3:1`, `1.2.3`) are left alone.
75
75
 
76
+ **Chat shorthand** (on by default; `--no-abbreviations` to disable) is expanded to the words it stands for, so a response that reads "lmk" is spoken as "let me know": `lmk`, `iirc`, `afaik`, `idk`, `tbh`, `imo`, `btw`, `fyi`, `asap`, `wrt`, `tl;dr`/`tldr`. Whole-word and case-insensitive. Shorthand that doubles as a real word or is already voiced correctly (`rn`, `eta`, `aka`) is deliberately left alone; a `--pronunciations` entry overrides any of these.
77
+
76
78
  Fenced code blocks are dropped by default. `--code-blocks placeholder` instead speaks a short stand-in so a skipped block doesn't sound disjointed (ChatGPT-style) — set the wording with `--code-placeholder` (e.g. `--code-blocks placeholder --code-placeholder "you can see the code in the chat history"`); consecutive blocks collapse to one.
77
79
 
78
80
  Bash completion:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stackvox"
7
- version = "0.11.0"
7
+ version = "0.13.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-abbreviations --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,18 @@ 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-abbreviations",
225
+ dest="abbreviations",
226
+ action="store_false",
227
+ help="Leave chat shorthand as written (no 'lmk' -> 'let me know', 'iirc', 'btw', ...)",
228
+ )
229
+ parser.add_argument(
230
+ "--no-filenames",
231
+ dest="filenames",
232
+ action="store_false",
233
+ help="Leave filenames and paths as written (no 'README dot md', no 'line 42 of cli dot py')",
234
+ )
223
235
  parser.add_argument(
224
236
  "--no-expand-units",
225
237
  dest="expand_units",
@@ -294,6 +306,8 @@ def _normalize_kwargs(args: argparse.Namespace) -> dict:
294
306
  "markdown": args.markdown,
295
307
  "pronunciations": _load_pronunciations(args.pronunciations),
296
308
  "dev_terms": args.dev_terms,
309
+ "abbreviations": args.abbreviations,
310
+ "filenames": args.filenames,
297
311
  "expand_units": args.expand_units,
298
312
  "expand_numbers": args.expand_numbers,
299
313
  "pauses": args.pauses,
@@ -425,12 +439,21 @@ def _cmd_status(_: argparse.Namespace) -> int:
425
439
  rc = 0
426
440
  # The running daemon can lag the installed package if it wasn't restarted
427
441
  # after an upgrade — surface that skew instead of trusting it blindly.
442
+ # Only a daemon that is genuinely BEHIND wants restarting; a newer one
443
+ # means the client just run is the older install (a checkout alongside a
444
+ # global one), and restarting would downgrade what's serving.
428
445
  got, running_version = daemon.version()
429
446
  if got and running_version != installed:
430
- print(
431
- f"daemon running {running_version}, but {installed} is installed "
432
- f"— restart the daemon (stackvox stop; stackvox serve) to pick it up"
433
- )
447
+ if updates._is_newer(installed, running_version):
448
+ print(
449
+ f"daemon running {running_version}, but {installed} is installed "
450
+ f"— restart the daemon (stackvox stop; stackvox serve) to pick it up"
451
+ )
452
+ else:
453
+ print(
454
+ f"daemon running {running_version} is newer than the {installed} client "
455
+ f"you just ran; no restart needed"
456
+ )
434
457
  else:
435
458
  print("stopped")
436
459
  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 is_running() -> bool:
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 False
284
+ return None
279
285
  try:
280
- pid = int(PID_PATH.read_text().strip())
281
- except ValueError:
282
- return False
283
- return _pid_alive(pid)
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
- return send({"command": "stop"})
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 & line references #
194
+ # File & path references #
117
195
  # --------------------------------------------------------------------------- #
118
- # `engine.py:42` reads as "…dot py colon forty two" — the dots and colon get
119
- # voiced literally. Spoken aloud the line number is the signal and the filename
120
- # is noise, so lead with "line N of <file>" (how a person actually says it) and
121
- # soften the path: drop directories to the basename, and voice the dotted name
122
- # word by word.
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
- r"(?:[\w.-]*/)*" # optional directory segments (dropped — "src/" reads as "slash")
127
- r"([A-Za-z0-9_-]+(?:\.[A-Za-z][\w-]*)+)" # filename with >=1 letter-initial extension
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".
396
+
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.
135
402
 
136
- ``engine.py:42`` -> "line 42 of engine py"; ``cli.py:100-118`` -> "lines 100
137
- to 118 of cli py"; ``foo.ts:666:10`` -> "line 666, column 10 of foo ts". The
138
- ``:line`` suffix is the trigger, so bare times/ratios/verses (``12:30``,
139
- ``3:1``, ``John 3:16``) and dotted versions (``1.2.3``) are left untouched —
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).replace(".", " ")
145
- start, end, column = match.group(2), match.group(3), match.group(4)
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
- return f"{location} of {basename}"
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
  # --------------------------------------------------------------------------- #
@@ -194,6 +484,36 @@ _DEV_PRONUNCIATIONS: dict[str, str] = {
194
484
  }
195
485
 
196
486
 
487
+ # --------------------------------------------------------------------------- #
488
+ # Abbreviations #
489
+ # --------------------------------------------------------------------------- #
490
+ # Chat shorthand, expanded to the words it stands for. Distinct from
491
+ # _DEV_PRONUNCIATIONS above, which fixes how a term *sounds*; this changes
492
+ # *which words are said*, so it gets its own flag (`abbreviations`). That is the
493
+ # lesson `filenames` taught when it was coupled to `dev_terms`.
494
+ #
495
+ # Applied whole-word and case-insensitive, so "LMK", "Lmk" and "lmk" all match.
496
+ # Deliberately excluded ("rn", "eta", "aka" and friends): real words, Greek
497
+ # letters, or acronyms espeak already voices correctly, so expanding them costs
498
+ # more in false positives than it buys. "fyi" keeps its letters because that is how it is
499
+ # said aloud, not "for your information".
500
+ _ABBREVIATIONS: dict[str, str] = {
501
+ "lmk": "let me know",
502
+ "iirc": "if I recall correctly",
503
+ "afaik": "as far as I know",
504
+ "idk": "I don't know",
505
+ "tbh": "to be honest",
506
+ "imo": "in my opinion",
507
+ "btw": "by the way",
508
+ "fyi": "F Y I",
509
+ "asap": "as soon as possible",
510
+ "wrt": "with respect to",
511
+ # Both spellings of the same abbreviation; the bare form is at least as common.
512
+ "tl;dr": "too long, didn't read",
513
+ "tldr": "too long, didn't read",
514
+ }
515
+
516
+
197
517
  # --------------------------------------------------------------------------- #
198
518
  # Pauses #
199
519
  # --------------------------------------------------------------------------- #
@@ -360,18 +680,24 @@ def _shape_paragraph(
360
680
  text: str,
361
681
  *,
362
682
  pronunciations: dict[str, str] | None,
363
- dev_terms_flag: bool,
683
+ filenames_flag: bool,
364
684
  expand_units_flag: bool,
365
685
  expand_numbers_flag: bool,
366
686
  pauses_flag: bool,
367
687
  locale: str,
368
688
  ) -> str:
369
- # File refs first: it consumes the ":line" digits (so the number stages see a
370
- # plain "line 42", not a decimal) and forms the spoken basename before the
371
- # dev-term dict runs over it.
372
- if dev_terms_flag:
689
+ # Filenames first, for two reasons: the ref stage consumes the ":line" digits
690
+ # (so the number stages see a plain "line 42", not a decimal), and spacing the
691
+ # dot leaves the stem a standalone word, which is what lets the dev-term dict
692
+ # below still fix "cli" -> "C L I" without gluing it to the extension.
693
+ # Refs before bare names: the ref stage rewrites its matches into prose with
694
+ # no dotted token left, so the two never fight over the same reference.
695
+ if filenames_flag:
373
696
  text = speak_file_refs(text)
697
+ text = speak_file_names(text)
374
698
  if expand_numbers_flag:
699
+ # Version ranges before units: the "=" unit rule would split ">=".
700
+ text = speak_versions(text)
375
701
  text = strip_thousands_separators(text)
376
702
  if pauses_flag:
377
703
  text = shape_pauses(text)
@@ -391,6 +717,8 @@ def normalize_for_speech(
391
717
  markdown: bool = True,
392
718
  pronunciations: dict[str, str] | None = None,
393
719
  dev_terms: bool = True,
720
+ abbreviations: bool = True,
721
+ filenames: bool = True,
394
722
  expand_units: bool = True,
395
723
  expand_numbers: bool = True,
396
724
  pauses: bool = True,
@@ -404,9 +732,13 @@ def normalize_for_speech(
404
732
  """Normalize ``text`` into speakable prose. Returns paragraphs joined by
405
733
  newlines. See ``docs/speech-normalization.md`` for the full contract."""
406
734
  expand_units_flag, expand_numbers_flag = expand_units, expand_numbers
407
- # Built-in dev-term fixes first; caller-supplied pronunciations override them
408
- # (keyed case-insensitively, so a caller's "CLI" beats the default "cli").
409
- effective_pronunciations: dict[str, str] = dict(_DEV_PRONUNCIATIONS) if dev_terms else {}
735
+ # Built-in fixes first; caller-supplied pronunciations override them (keyed
736
+ # case-insensitively, so a caller's "CLI" beats the default "cli"). The two
737
+ # built-in dicts share no keys, and neither one's spoken form contains a key
738
+ # of the other, so the sequential passes can't feed each other.
739
+ effective_pronunciations: dict[str, str] = dict(_ABBREVIATIONS) if abbreviations else {}
740
+ if dev_terms:
741
+ effective_pronunciations.update(_DEV_PRONUNCIATIONS)
410
742
  for written, spoken in (pronunciations or {}).items():
411
743
  effective_pronunciations[written.lower()] = spoken
412
744
 
@@ -429,7 +761,7 @@ def normalize_for_speech(
429
761
  shaped = _shape_paragraph(
430
762
  para,
431
763
  pronunciations=effective_pronunciations,
432
- dev_terms_flag=dev_terms,
764
+ filenames_flag=filenames,
433
765
  expand_units_flag=expand_units_flag,
434
766
  expand_numbers_flag=expand_numbers_flag,
435
767
  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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.11.0
3
+ Version: 0.13.0
4
4
  Summary: Offline TTS library using Kokoro-82M
5
5
  Author-email: "StackOne Technologies Ltd." <support@stackone.com>
6
6
  License-Expression: Apache-2.0
@@ -103,10 +103,12 @@ stackvox speak --normalize --file post.md # normalize, then synthesize
103
103
  stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon
104
104
  ```
105
105
 
106
- `--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-dev-terms`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--code-blocks {drop,placeholder}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
106
+ `--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-dev-terms`, `--no-abbreviations`, `--no-filenames`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--code-blocks {drop,placeholder}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
107
107
 
108
108
  **Dev-output handling** (on by default; `--no-dev-terms` to disable) covers two things espeak reads badly: acronyms it says as words (`CLI` → "C L I", `AWS`, `URI`, `IAM`, …) and file-line references, which it voices literally as "dot py colon". A `file.ext:line` ref is re-spoken lead-with-the-line: `engine.py:42` → "line 42 of engine py", `cli.py:100-118` → "lines 100 to 118 of cli py", `foo.ts:666:10` → "line 666, column 10 of foo ts". Times, ratios, and versions (`12:30`, `3:1`, `1.2.3`) are left alone.
109
109
 
110
+ **Chat shorthand** (on by default; `--no-abbreviations` to disable) is expanded to the words it stands for, so a response that reads "lmk" is spoken as "let me know": `lmk`, `iirc`, `afaik`, `idk`, `tbh`, `imo`, `btw`, `fyi`, `asap`, `wrt`, `tl;dr`/`tldr`. Whole-word and case-insensitive. Shorthand that doubles as a real word or is already voiced correctly (`rn`, `eta`, `aka`) is deliberately left alone; a `--pronunciations` entry overrides any of these.
111
+
110
112
  Fenced code blocks are dropped by default. `--code-blocks placeholder` instead speaks a short stand-in so a skipped block doesn't sound disjointed (ChatGPT-style) — set the wording with `--code-placeholder` (e.g. `--code-blocks placeholder --code-placeholder "you can see the code in the chat history"`); consecutive blocks collapse to one.
111
113
 
112
114
  Bash completion:
@@ -129,6 +129,8 @@ def _norm_ns(text=None, file=None, **overrides):
129
129
  markdown=True,
130
130
  pronunciations=None,
131
131
  dev_terms=True,
132
+ abbreviations=True,
133
+ filenames=True,
132
134
  expand_units=True,
133
135
  expand_numbers=True,
134
136
  pauses=True,
@@ -291,6 +293,27 @@ class TestCmdStatus:
291
293
  assert "0.5.0" in out and "0.8.0" in out # the skew is surfaced
292
294
  assert "restart" in out.lower()
293
295
 
296
+ def test_running_does_not_advise_restart_when_the_daemon_is_ahead(self, mocker, capsys):
297
+ """A daemon NEWER than the client means the client is the older install,
298
+ so restarting would downgrade what's serving."""
299
+ mocker.patch.object(cli.daemon, "is_running", return_value=True)
300
+ pid_path = mocker.MagicMock()
301
+ pid_path.read_text.return_value = "1\n"
302
+ mocker.patch.object(cli.daemon, "PID_PATH", pid_path)
303
+ mocker.patch.object(cli.daemon, "SOCKET_PATH", "/tmp/x.sock")
304
+ mocker.patch.object(cli.daemon, "version", return_value=(True, "0.11.0"))
305
+ mocker.patch.object(cli.updates, "_current_version", return_value="0.9.0")
306
+ mocker.patch.object(cli.updates, "check_for_update", return_value=None)
307
+
308
+ rc = cli._cmd_status(_ns())
309
+
310
+ assert rc == 0
311
+ out = capsys.readouterr().out
312
+ assert "0.11.0" in out and "0.9.0" in out
313
+ assert "stackvox stop" not in out # the restart advice itself
314
+ assert "newer" in out.lower()
315
+ assert "no restart needed" in out
316
+
294
317
  def test_stopped_returns_one(self, mocker, capsys):
295
318
  mocker.patch.object(cli.daemon, "is_running", return_value=False)
296
319
  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
- # --- file & line references ------------------------------------------------
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
- assert speak_file_refs("unifiedAPIv2.service.ts:666") == "line 666 of unifiedAPIv2 service ts"
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 test_file_ref_drops_directories_to_basename():
66
- assert speak_file_refs("Open /abs/path/to/module.py:7.") == "Open line 7 of module py."
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"
89
179
 
90
180
 
91
- def test_file_ref_disabled_with_dev_terms():
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."
234
+
235
+
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.py:42."
240
+ assert out == "See line 42 of engine dot py."
94
241
 
95
242
 
96
243
  # --- units -----------------------------------------------------------------
@@ -340,3 +487,44 @@ def test_caller_pronunciations_override_dev_terms():
340
487
  out = normalize_for_speech("Use the CLI.", markdown=False, pronunciations={"CLI": "command line"})
341
488
  assert "command line" in out
342
489
  assert "C L I" not in out
490
+
491
+
492
+ def test_chat_abbreviations_are_expanded():
493
+ assert normalize_for_speech("lmk what you think", markdown=False) == "let me know what you think."
494
+ assert "if I recall correctly" in normalize_for_speech("iirc it shipped", markdown=False)
495
+ assert "by the way" in normalize_for_speech("btw the build is green", markdown=False)
496
+ assert "as soon as possible" in normalize_for_speech("ship it asap", markdown=False)
497
+ assert "with respect to" in normalize_for_speech("wrt the plan", markdown=False)
498
+
499
+
500
+ def test_chat_abbreviations_are_case_insensitive():
501
+ assert "let me know" in normalize_for_speech("LMK if that works", markdown=False)
502
+ assert "to be honest" in normalize_for_speech("Tbh it is fine", markdown=False)
503
+
504
+
505
+ def test_tldr_expands_in_both_spellings():
506
+ assert "too long, didn't read" in normalize_for_speech("tl;dr it works", markdown=False)
507
+ assert "too long, didn't read" in normalize_for_speech("tldr it works", markdown=False)
508
+
509
+
510
+ def test_chat_abbreviations_are_whole_word_only():
511
+ # must not rewrite the middle of a longer token
512
+ assert "let me know" not in normalize_for_speech("lmkfoo", markdown=False)
513
+ assert "in my opinion" not in normalize_for_speech("imos", markdown=False)
514
+
515
+
516
+ def test_chat_abbreviations_can_be_disabled():
517
+ assert normalize_for_speech("lmk soon", markdown=False, abbreviations=False) == "lmk soon."
518
+
519
+
520
+ def test_caller_pronunciations_override_abbreviations():
521
+ out = normalize_for_speech("lmk soon", markdown=False, pronunciations={"LMK": "tell me"})
522
+ assert "tell me" in out
523
+ assert "let me know" not in out
524
+
525
+
526
+ def test_abbreviations_independent_of_dev_terms():
527
+ # the `filenames` lesson: an unrelated switch must not silently disable this
528
+ out = normalize_for_speech("lmk about the CLI", markdown=False, dev_terms=False)
529
+ assert "let me know" in out
530
+ assert "C L I" not in out
@@ -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