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.
Files changed (31) hide show
  1. {stackvox-0.11.0/stackvox.egg-info → stackvox-0.12.0}/PKG-INFO +1 -1
  2. {stackvox-0.11.0 → stackvox-0.12.0}/pyproject.toml +9 -1
  3. {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/cli.py +21 -5
  4. {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/daemon.py +39 -8
  5. {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/text.py +321 -24
  6. {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/updates.py +39 -1
  7. {stackvox-0.11.0 → stackvox-0.12.0/stackvox.egg-info}/PKG-INFO +1 -1
  8. {stackvox-0.11.0 → stackvox-0.12.0}/tests/test_cli.py +22 -0
  9. {stackvox-0.11.0 → stackvox-0.12.0}/tests/test_daemon.py +72 -0
  10. {stackvox-0.11.0 → stackvox-0.12.0}/tests/test_text.py +156 -9
  11. {stackvox-0.11.0 → stackvox-0.12.0}/tests/test_updates.py +45 -0
  12. {stackvox-0.11.0 → stackvox-0.12.0}/LICENSE +0 -0
  13. {stackvox-0.11.0 → stackvox-0.12.0}/NOTICE +0 -0
  14. {stackvox-0.11.0 → stackvox-0.12.0}/README.md +0 -0
  15. {stackvox-0.11.0 → stackvox-0.12.0}/setup.cfg +0 -0
  16. {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/__init__.py +0 -0
  17. {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/__main__.py +0 -0
  18. {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/config.py +0 -0
  19. {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/data/stackvox-say +0 -0
  20. {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/engine.py +0 -0
  21. {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/paths.py +0 -0
  22. {stackvox-0.11.0 → stackvox-0.12.0}/stackvox/py.typed +0 -0
  23. {stackvox-0.11.0 → stackvox-0.12.0}/stackvox.egg-info/SOURCES.txt +0 -0
  24. {stackvox-0.11.0 → stackvox-0.12.0}/stackvox.egg-info/dependency_links.txt +0 -0
  25. {stackvox-0.11.0 → stackvox-0.12.0}/stackvox.egg-info/entry_points.txt +0 -0
  26. {stackvox-0.11.0 → stackvox-0.12.0}/stackvox.egg-info/requires.txt +0 -0
  27. {stackvox-0.11.0 → stackvox-0.12.0}/stackvox.egg-info/top_level.txt +0 -0
  28. {stackvox-0.11.0 → stackvox-0.12.0}/tests/test_config.py +0 -0
  29. {stackvox-0.11.0 → stackvox-0.12.0}/tests/test_daemon_protocol.py +0 -0
  30. {stackvox-0.11.0 → stackvox-0.12.0}/tests/test_engine.py +0 -0
  31. {stackvox-0.11.0 → stackvox-0.12.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.12.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
@@ -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.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
- 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
- )
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 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".
135
396
 
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 —
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).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
  # --------------------------------------------------------------------------- #
@@ -360,18 +650,24 @@ def _shape_paragraph(
360
650
  text: str,
361
651
  *,
362
652
  pronunciations: dict[str, str] | None,
363
- dev_terms_flag: bool,
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
- # 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:
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
- dev_terms_flag=dev_terms,
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.11.0
3
+ Version: 0.12.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
@@ -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
- # --- 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"
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 test_file_ref_disabled_with_dev_terms():
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 -----------------------------------------------------------------
@@ -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