stackvox 0.8.0__tar.gz → 0.10.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {stackvox-0.8.0/stackvox.egg-info → stackvox-0.10.0}/PKG-INFO +1 -1
- {stackvox-0.8.0 → stackvox-0.10.0}/pyproject.toml +1 -1
- {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/cli.py +22 -1
- {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/daemon.py +40 -1
- {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/text.py +20 -2
- {stackvox-0.8.0 → stackvox-0.10.0/stackvox.egg-info}/PKG-INFO +1 -1
- {stackvox-0.8.0 → stackvox-0.10.0}/tests/test_cli.py +34 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/tests/test_daemon_protocol.py +24 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/tests/test_text.py +33 -1
- {stackvox-0.8.0 → stackvox-0.10.0}/LICENSE +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/NOTICE +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/README.md +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/setup.cfg +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/__init__.py +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/__main__.py +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/config.py +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/data/stackvox-say +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/engine.py +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/paths.py +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/py.typed +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/updates.py +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/stackvox.egg-info/SOURCES.txt +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/stackvox.egg-info/dependency_links.txt +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/stackvox.egg-info/entry_points.txt +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/stackvox.egg-info/requires.txt +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/stackvox.egg-info/top_level.txt +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/tests/test_config.py +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/tests/test_daemon.py +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/tests/test_engine.py +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/tests/test_paths.py +0 -0
- {stackvox-0.8.0 → stackvox-0.10.0}/tests/test_updates.py +0 -0
|
@@ -26,6 +26,7 @@ def _configure_logging() -> None:
|
|
|
26
26
|
SUBCOMMANDS = {
|
|
27
27
|
"serve",
|
|
28
28
|
"stop",
|
|
29
|
+
"cancel",
|
|
29
30
|
"status",
|
|
30
31
|
"say",
|
|
31
32
|
"speak",
|
|
@@ -51,7 +52,7 @@ _stackvox_completion() {
|
|
|
51
52
|
subcommand="${COMP_WORDS[1]:-}"
|
|
52
53
|
|
|
53
54
|
if [[ ${COMP_CWORD} -eq 1 ]]; then
|
|
54
|
-
COMPREPLY=( $(compgen -W "speak say normalize serve stop status voices welcome paths config completion install-helper" -- "$cur") )
|
|
55
|
+
COMPREPLY=( $(compgen -W "speak say normalize serve stop cancel status voices welcome paths config completion install-helper" -- "$cur") )
|
|
55
56
|
return 0
|
|
56
57
|
fi
|
|
57
58
|
|
|
@@ -155,6 +156,7 @@ def _build_parser(defaults: config.Defaults | None = None) -> argparse.ArgumentP
|
|
|
155
156
|
_add_voice_args(p_serve, defaults)
|
|
156
157
|
|
|
157
158
|
sub.add_parser("stop", help="Stop the running daemon")
|
|
159
|
+
sub.add_parser("cancel", help="Stop the current utterance without shutting down the daemon")
|
|
158
160
|
sub.add_parser("status", help="Print daemon status")
|
|
159
161
|
sub.add_parser("voices", help="List available voices")
|
|
160
162
|
sub.add_parser("welcome", help="Play a multilingual welcome message")
|
|
@@ -388,10 +390,28 @@ def _cmd_stop(_: argparse.Namespace) -> int:
|
|
|
388
390
|
return 0 if ok else 1
|
|
389
391
|
|
|
390
392
|
|
|
393
|
+
def _cmd_cancel(_: argparse.Namespace) -> int:
|
|
394
|
+
# Interrupt the current utterance but leave the daemon running (no model
|
|
395
|
+
# reload). Nothing playing / no daemon is not an error.
|
|
396
|
+
if not daemon.is_running():
|
|
397
|
+
return 0
|
|
398
|
+
ok, _resp = daemon.cancel()
|
|
399
|
+
return 0 if ok else 1
|
|
400
|
+
|
|
401
|
+
|
|
391
402
|
def _cmd_status(_: argparse.Namespace) -> int:
|
|
403
|
+
installed = updates._current_version()
|
|
392
404
|
if daemon.is_running():
|
|
393
405
|
print(f"running (pid {daemon.PID_PATH.read_text().strip()}) on {daemon.SOCKET_PATH}")
|
|
394
406
|
rc = 0
|
|
407
|
+
# The running daemon can lag the installed package if it wasn't restarted
|
|
408
|
+
# after an upgrade — surface that skew instead of trusting it blindly.
|
|
409
|
+
got, running_version = daemon.version()
|
|
410
|
+
if got and running_version != installed:
|
|
411
|
+
print(
|
|
412
|
+
f"daemon running {running_version}, but {installed} is installed "
|
|
413
|
+
f"— restart the daemon (stackvox stop; stackvox serve) to pick it up"
|
|
414
|
+
)
|
|
395
415
|
else:
|
|
396
416
|
print("stopped")
|
|
397
417
|
rc = 1
|
|
@@ -521,6 +541,7 @@ def main() -> int:
|
|
|
521
541
|
"normalize": _cmd_normalize,
|
|
522
542
|
"serve": _cmd_serve,
|
|
523
543
|
"stop": _cmd_stop,
|
|
544
|
+
"cancel": _cmd_cancel,
|
|
524
545
|
"status": _cmd_status,
|
|
525
546
|
"voices": _cmd_voices,
|
|
526
547
|
"welcome": _cmd_welcome,
|
|
@@ -5,8 +5,10 @@ Protocol: one JSON object per connection (line-terminated), reply is a status li
|
|
|
5
5
|
|
|
6
6
|
Request shapes:
|
|
7
7
|
{"text": "...", "voice": "af_sarah", "speed": 1.0, "lang": "en-us"}
|
|
8
|
-
{"command": "stop"}
|
|
8
|
+
{"command": "stop"} # shut the daemon down
|
|
9
|
+
{"command": "cancel"} # stop the current utterance, keep the daemon up
|
|
9
10
|
{"command": "ping"}
|
|
11
|
+
{"command": "version"}
|
|
10
12
|
|
|
11
13
|
Replies: "ok", "busy", "err: <msg>".
|
|
12
14
|
"""
|
|
@@ -203,6 +205,17 @@ class _DaemonState:
|
|
|
203
205
|
def shutdown(self) -> None:
|
|
204
206
|
self.stop_event.set()
|
|
205
207
|
|
|
208
|
+
def cancel(self) -> None:
|
|
209
|
+
"""Stop the current utterance and drop anything queued, without shutting
|
|
210
|
+
the daemon down. Drains the queue first so the worker can't immediately
|
|
211
|
+
pick up a pending item, then aborts the in-flight playback."""
|
|
212
|
+
while True:
|
|
213
|
+
try:
|
|
214
|
+
self.queue.get_nowait()
|
|
215
|
+
except queue.Empty:
|
|
216
|
+
break
|
|
217
|
+
self.tts.stop()
|
|
218
|
+
|
|
206
219
|
|
|
207
220
|
class _Handler(socketserver.StreamRequestHandler):
|
|
208
221
|
def handle(self) -> None:
|
|
@@ -222,11 +235,20 @@ class _Handler(socketserver.StreamRequestHandler):
|
|
|
222
235
|
self.wfile.write(b"ok\n")
|
|
223
236
|
return
|
|
224
237
|
|
|
238
|
+
if command == "version":
|
|
239
|
+
self.wfile.write(f"{updates._current_version()}\n".encode())
|
|
240
|
+
return
|
|
241
|
+
|
|
225
242
|
if command == "stop":
|
|
226
243
|
self.wfile.write(b"ok\n")
|
|
227
244
|
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
228
245
|
return
|
|
229
246
|
|
|
247
|
+
if command == "cancel":
|
|
248
|
+
state.cancel()
|
|
249
|
+
self.wfile.write(b"ok\n")
|
|
250
|
+
return
|
|
251
|
+
|
|
230
252
|
text = req.get("text")
|
|
231
253
|
if not text:
|
|
232
254
|
self.wfile.write(b"err: missing text\n")
|
|
@@ -341,5 +363,22 @@ def stop() -> tuple[bool, str]:
|
|
|
341
363
|
return send({"command": "stop"})
|
|
342
364
|
|
|
343
365
|
|
|
366
|
+
def cancel() -> tuple[bool, str]:
|
|
367
|
+
"""Stop the daemon's current utterance (and drop the queue) without shutting
|
|
368
|
+
it down."""
|
|
369
|
+
return send({"command": "cancel"})
|
|
370
|
+
|
|
371
|
+
|
|
344
372
|
def ping() -> tuple[bool, str]:
|
|
345
373
|
return send({"command": "ping"}, timeout=PING_TIMEOUT_SECONDS)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def version(timeout: float = PING_TIMEOUT_SECONDS) -> tuple[bool, str]:
|
|
377
|
+
"""(got_version, version_or_error) reported by the running daemon.
|
|
378
|
+
|
|
379
|
+
The running daemon can differ from the installed package if it wasn't
|
|
380
|
+
restarted after an upgrade, so this reports what is actually serving — not
|
|
381
|
+
what's on disk. A version reply starts with a digit; error strings don't.
|
|
382
|
+
"""
|
|
383
|
+
_, resp = send({"command": "version"}, timeout=timeout)
|
|
384
|
+
return resp[:1].isdigit(), resp
|
|
@@ -18,6 +18,7 @@ __all__ = [
|
|
|
18
18
|
"markdown_to_paragraphs",
|
|
19
19
|
"strip_emoji",
|
|
20
20
|
"strip_thousands_separators",
|
|
21
|
+
"versions_to_words",
|
|
21
22
|
"decimals_to_words",
|
|
22
23
|
"expand_units",
|
|
23
24
|
"apply_pronunciations",
|
|
@@ -84,6 +85,21 @@ def strip_thousands_separators(text: str) -> str:
|
|
|
84
85
|
return re.sub(r"(?<=\d),(?=\d)", "", text)
|
|
85
86
|
|
|
86
87
|
|
|
88
|
+
_VERSION = re.compile(r"(?<!\d)\d+(?:\.\d+){2,}(?!\d)")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def versions_to_words(text: str) -> str:
|
|
92
|
+
"""1.2.3 -> "1 point 2 point 3"; 0.7.0 -> "0 point 7 point 0".
|
|
93
|
+
|
|
94
|
+
A dotted version string has more than one decimal point, which
|
|
95
|
+
`decimals_to_words` can't handle — it splits only the first, leaving a stray
|
|
96
|
+
full stop mid-number ("0.7.0" -> "0 point 7.0"). Run this before it. Two-part
|
|
97
|
+
numbers are left untouched so ordinary decimals still read digit-by-digit,
|
|
98
|
+
and a trailing sentence stop (``upgrade to 1.2.3.``) is preserved.
|
|
99
|
+
"""
|
|
100
|
+
return _VERSION.sub(lambda m: m.group(0).replace(".", " point "), text)
|
|
101
|
+
|
|
102
|
+
|
|
87
103
|
def decimals_to_words(text: str) -> str:
|
|
88
104
|
"""1198.9 -> "1198 point 9"; 770.72 -> "770 point 7 2". Removes the bare
|
|
89
105
|
"." between digits, which TTS can otherwise read as a full stop."""
|
|
@@ -117,14 +133,15 @@ _DEV_PRONUNCIATIONS: dict[str, str] = {
|
|
|
117
133
|
"cli": "C L I",
|
|
118
134
|
"ci": "C I",
|
|
119
135
|
"ide": "I D E",
|
|
120
|
-
"aws": "A
|
|
136
|
+
"aws": "A.W.S.", # dotted: espeak reads a lone "A" as the article "uh"; dots force the letter name "ay"
|
|
121
137
|
"uri": "U R I",
|
|
122
|
-
"iam": "I
|
|
138
|
+
"iam": "I.A.M.", # ditto — the middle "A" needs the dot
|
|
123
139
|
"saas": "sass",
|
|
124
140
|
"paas": "pass",
|
|
125
141
|
"tui": "T U I",
|
|
126
142
|
"postgresql": "postgres",
|
|
127
143
|
"kubectl": "kube control",
|
|
144
|
+
"stackone": "stack one", # org name espeak garbles as "stac kone"
|
|
128
145
|
}
|
|
129
146
|
|
|
130
147
|
|
|
@@ -287,6 +304,7 @@ def _shape_paragraph(
|
|
|
287
304
|
if expand_units_flag: # units BEFORE decimals (see note above)
|
|
288
305
|
text = expand_units(text, locale)
|
|
289
306
|
if expand_numbers_flag:
|
|
307
|
+
text = versions_to_words(text) # multi-dot versions BEFORE the decimal split
|
|
290
308
|
text = decimals_to_words(text)
|
|
291
309
|
return re.sub(r"[ \t]{2,}", " ", text).strip()
|
|
292
310
|
|
|
@@ -261,6 +261,9 @@ class TestCmdStatus:
|
|
|
261
261
|
pid_path.read_text.return_value = "12345\n"
|
|
262
262
|
mocker.patch.object(cli.daemon, "PID_PATH", pid_path)
|
|
263
263
|
mocker.patch.object(cli.daemon, "SOCKET_PATH", "/tmp/x.sock")
|
|
264
|
+
# status now queries the running daemon's version; stub it so the test
|
|
265
|
+
# doesn't reach a real socket.
|
|
266
|
+
mocker.patch.object(cli.daemon, "version", return_value=(False, "n/a"))
|
|
264
267
|
|
|
265
268
|
rc = cli._cmd_status(_ns())
|
|
266
269
|
|
|
@@ -269,6 +272,23 @@ class TestCmdStatus:
|
|
|
269
272
|
assert "running" in out
|
|
270
273
|
assert "12345" in out
|
|
271
274
|
|
|
275
|
+
def test_running_flags_daemon_version_skew(self, mocker, capsys):
|
|
276
|
+
mocker.patch.object(cli.daemon, "is_running", return_value=True)
|
|
277
|
+
pid_path = mocker.MagicMock()
|
|
278
|
+
pid_path.read_text.return_value = "1\n"
|
|
279
|
+
mocker.patch.object(cli.daemon, "PID_PATH", pid_path)
|
|
280
|
+
mocker.patch.object(cli.daemon, "SOCKET_PATH", "/tmp/x.sock")
|
|
281
|
+
mocker.patch.object(cli.daemon, "version", return_value=(True, "0.5.0"))
|
|
282
|
+
mocker.patch.object(cli.updates, "_current_version", return_value="0.8.0")
|
|
283
|
+
mocker.patch.object(cli.updates, "check_for_update", return_value=None)
|
|
284
|
+
|
|
285
|
+
rc = cli._cmd_status(_ns())
|
|
286
|
+
|
|
287
|
+
assert rc == 0
|
|
288
|
+
out = capsys.readouterr().out
|
|
289
|
+
assert "0.5.0" in out and "0.8.0" in out # the skew is surfaced
|
|
290
|
+
assert "restart" in out.lower()
|
|
291
|
+
|
|
272
292
|
def test_stopped_returns_one(self, mocker, capsys):
|
|
273
293
|
mocker.patch.object(cli.daemon, "is_running", return_value=False)
|
|
274
294
|
rc = cli._cmd_status(_ns())
|
|
@@ -503,3 +523,17 @@ class TestNormalizeParsing:
|
|
|
503
523
|
mocker.patch.object(cli.sys, "argv", ["stackvox", "paths"])
|
|
504
524
|
assert cli.main() == 0
|
|
505
525
|
assert paths_handler.called
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
class TestCmdCancel:
|
|
529
|
+
def test_running_calls_daemon_cancel(self, mocker):
|
|
530
|
+
mocker.patch.object(cli.daemon, "is_running", return_value=True)
|
|
531
|
+
cancel = mocker.patch.object(cli.daemon, "cancel", return_value=(True, "ok"))
|
|
532
|
+
assert cli._cmd_cancel(_ns()) == 0
|
|
533
|
+
cancel.assert_called_once()
|
|
534
|
+
|
|
535
|
+
def test_no_daemon_is_a_noop(self, mocker):
|
|
536
|
+
mocker.patch.object(cli.daemon, "is_running", return_value=False)
|
|
537
|
+
cancel = mocker.patch.object(cli.daemon, "cancel")
|
|
538
|
+
assert cli._cmd_cancel(_ns()) == 0
|
|
539
|
+
cancel.assert_not_called()
|
|
@@ -76,6 +76,22 @@ def test_ping_returns_ok(server: ServerHarness):
|
|
|
76
76
|
assert _roundtrip(server.sock, json.dumps({"command": "ping"}) + "\n") == "ok"
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
def test_version_returns_installed_version(server: ServerHarness):
|
|
80
|
+
from stackvox import updates
|
|
81
|
+
|
|
82
|
+
reply = _roundtrip(server.sock, json.dumps({"command": "version"}) + "\n")
|
|
83
|
+
assert reply == updates._current_version()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_version_client_helper_reports_running_version(server: ServerHarness, monkeypatch):
|
|
87
|
+
from stackvox import daemon, updates
|
|
88
|
+
|
|
89
|
+
monkeypatch.setattr(daemon, "SOCKET_PATH", server.sock)
|
|
90
|
+
got, resp = daemon.version()
|
|
91
|
+
assert got is True
|
|
92
|
+
assert resp == updates._current_version()
|
|
93
|
+
|
|
94
|
+
|
|
79
95
|
def test_plain_text_is_treated_as_text_field(server: ServerHarness):
|
|
80
96
|
"""Non-JSON payloads are accepted and wrapped as `{"text": line}`."""
|
|
81
97
|
import time
|
|
@@ -153,3 +169,11 @@ def test_send_helper_when_daemon_not_running(tmp_path, monkeypatch):
|
|
|
153
169
|
ok, resp = daemon.send({"command": "ping"})
|
|
154
170
|
assert not ok
|
|
155
171
|
assert resp == "daemon not running"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_cancel_stops_playback_and_keeps_daemon_up(server: ServerHarness):
|
|
175
|
+
reply = _roundtrip(server.sock, json.dumps({"command": "cancel"}) + "\n")
|
|
176
|
+
assert reply == "ok"
|
|
177
|
+
server.tts.stop.assert_called() # current utterance aborted
|
|
178
|
+
# unlike `stop`, the daemon stays up
|
|
179
|
+
assert _roundtrip(server.sock, json.dumps({"command": "ping"}) + "\n") == "ok"
|
|
@@ -10,6 +10,7 @@ from stackvox.text import (
|
|
|
10
10
|
shape_pauses,
|
|
11
11
|
strip_emoji,
|
|
12
12
|
strip_thousands_separators,
|
|
13
|
+
versions_to_words,
|
|
13
14
|
)
|
|
14
15
|
|
|
15
16
|
# --- numbers ---------------------------------------------------------------
|
|
@@ -28,6 +29,31 @@ def test_year_is_not_a_decimal():
|
|
|
28
29
|
assert decimals_to_words("built in 2023") == "built in 2023"
|
|
29
30
|
|
|
30
31
|
|
|
32
|
+
def test_semver_all_parts_spoken():
|
|
33
|
+
assert versions_to_words("0.7.0") == "0 point 7 point 0"
|
|
34
|
+
assert versions_to_words("1.2.3") == "1 point 2 point 3"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_semver_multi_digit_component():
|
|
38
|
+
assert versions_to_words("1.20.3") == "1 point 20 point 3"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_version_preserves_trailing_sentence_stop():
|
|
42
|
+
assert versions_to_words("upgrade to 1.2.3.") == "upgrade to 1 point 2 point 3."
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_two_part_number_is_left_for_the_decimal_pass():
|
|
46
|
+
# Only two components — an ordinary decimal, not a version.
|
|
47
|
+
assert versions_to_words("3.14") == "3.14"
|
|
48
|
+
assert decimals_to_words(versions_to_words("3.14")) == "3 point 1 4"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_semver_normalizes_end_to_end():
|
|
52
|
+
out = normalize_for_speech("Upgraded to 0.7.0 today.", markdown=False)
|
|
53
|
+
assert "0 point 7 point 0" in out
|
|
54
|
+
assert "0 point 7.0" not in out
|
|
55
|
+
|
|
56
|
+
|
|
31
57
|
# --- units -----------------------------------------------------------------
|
|
32
58
|
|
|
33
59
|
|
|
@@ -200,11 +226,17 @@ def test_plain_text_mode_splits_paragraphs():
|
|
|
200
226
|
def test_dev_acronyms_are_spelled_out():
|
|
201
227
|
assert normalize_for_speech("Use the CLI.", markdown=False) == "Use the C L I."
|
|
202
228
|
assert "C L I" in normalize_for_speech("the cli tool", markdown=False) # lowercase too
|
|
203
|
-
assert "A
|
|
229
|
+
assert "A.W.S." in normalize_for_speech("deploy to AWS", markdown=False) # dotted → "ay"
|
|
230
|
+
assert "I.A.M." in normalize_for_speech("set up IAM", markdown=False)
|
|
204
231
|
assert "U R I" in normalize_for_speech("parse the URI", markdown=False)
|
|
205
232
|
assert "C I C D" in normalize_for_speech("the CI/CD pipeline", markdown=False)
|
|
206
233
|
|
|
207
234
|
|
|
235
|
+
def test_org_names_are_spoken_word_by_word():
|
|
236
|
+
assert "stack one" in normalize_for_speech("deployed to StackOne today", markdown=False)
|
|
237
|
+
assert "stack one" in normalize_for_speech("the stackone api", markdown=False) # case-insensitive
|
|
238
|
+
|
|
239
|
+
|
|
208
240
|
def test_dev_terms_leave_correctly_voiced_acronyms_alone():
|
|
209
241
|
out = normalize_for_speech("Send JSON over HTTP to the API.", markdown=False)
|
|
210
242
|
assert "JSON" in out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|