Skip to content

Commit 42efaf9

Browse files
committed
spinners for setup steps with --progress --batch
For large --batch scripts (the very kind of SQL scripts for which the progress bar is useful) the initial steps of pre-parsing the batch and optionally replaying the checkpoint log can take substantial time. Therefore, add text and spinners showing the stage and progress of the setup steps. A lightweight new dependency is added due to limitations in the spinners from prompt-toolkit: the prompt-toolkit spinners must have a known upper bound to which to iterate, and they leave behind random "mid-spin" characters on completion like pre-parsing batch \ which creates visual confusion about whether the step completed successfully. yaspin spinners can integrate with prompt-toolkit more deeply than is done here, and could also for instance be used during long interactive queries (though that particular usecase may have little value). Another option would be spinners from the "rich" library, which we may try in the future, if adopting "rich" for tabular output.
1 parent 0474627 commit 42efaf9

4 files changed

Lines changed: 109 additions & 5 deletions

File tree

changelog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
Upcoming (TBD)
22
==============
33

4+
Features
5+
--------
6+
* `--progress` spinners for setup steps in `--batch` mode.
7+
8+
49
Internal
510
--------
611
* Add test coverage for `client_commands.py`.

mycli/main_modes/batch.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from prompt_toolkit.shortcuts import ProgressBar
1111
from prompt_toolkit.shortcuts.progress_bar import formatters as progress_bar_formatters
1212
import pymysql
13+
from yaspin import yaspin
1314

1415
from mycli.packages.batch_utils import statements_from_filehandle
1516
from mycli.packages.interactive_utils import confirm_destructive_query
@@ -28,6 +29,7 @@ def replay_checkpoint_file(
2829
batch_path: str,
2930
checkpoint_path: str | None,
3031
resume: bool,
32+
progress: bool = False,
3133
) -> int:
3234
if not resume:
3335
return 0
@@ -48,16 +50,27 @@ def replay_checkpoint_file(
4850
batch_gen = statements_from_filehandle(batch_h)
4951
except ValueError as e:
5052
raise CheckpointReplayError(f'Error reading --batch file: {batch_path}: {e}') from None
53+
if progress:
54+
spinner = yaspin(text='replaying checkpoint', side='right', stream=sys.stderr)
55+
spinner.start()
5156
for checkpoint_statement, _checkpoint_counter in statements_from_filehandle(checkpoint_h):
5257
try:
5358
batch_statement, _batch_counter = next(batch_gen)
5459
except StopIteration:
60+
if progress:
61+
spinner.fail('✘')
5562
raise CheckpointReplayError('Checkpoint script longer than batch script.') from None
5663
except ValueError as e:
64+
if progress:
65+
spinner.fail('✘')
5766
raise CheckpointReplayError(f'Error reading --batch file: {batch_path}: {e}') from None
5867
if checkpoint_statement != batch_statement:
68+
if progress:
69+
spinner.fail('✘')
5970
raise CheckpointReplayError(f'Statement mismatch: {checkpoint_statement}.')
6071
completed_count += 1
72+
if progress:
73+
spinner.ok('✔')
6174
except ValueError as e:
6275
raise CheckpointReplayError(f'Error reading --checkpoint file: {checkpoint_path}: {e}') from None
6376
except FileNotFoundError as e:
@@ -119,10 +132,12 @@ def main_batch_with_progress_bar(mycli: 'MyCli', cli_args: 'CliArgs') -> int:
119132
click.secho('--progress is only compatible with a plain file.', err=True, fg='red')
120133
return 1
121134
try:
122-
completed_statement_count = replay_checkpoint_file(cli_args.batch, cli_args.checkpoint, cli_args.resume)
135+
completed_statement_count = replay_checkpoint_file(cli_args.batch, cli_args.checkpoint, cli_args.resume, progress=True)
123136
batch_count_h = click.open_file(cli_args.batch)
124-
for _statement, _counter in statements_from_filehandle(batch_count_h):
125-
goal_statements += 1
137+
with yaspin(text='pre-parsing batch ', side='right', stream=sys.stderr) as spinner:
138+
for _statement, _counter in statements_from_filehandle(batch_count_h):
139+
goal_statements += 1
140+
spinner.ok('✔')
126141
batch_count_h.close()
127142
batch_h = click.open_file(cli_args.batch)
128143
batch_gen = statements_from_filehandle(batch_h)
@@ -140,7 +155,7 @@ def main_batch_with_progress_bar(mycli: 'MyCli', cli_args: 'CliArgs') -> int:
140155
if goal_statements:
141156
pb_style = prompt_toolkit.styles.Style.from_dict({'bar-a': 'reverse'})
142157
custom_formatters = [
143-
progress_bar_formatters.Bar(start='[', end=']', sym_a=' ', sym_b=' ', sym_c=' '),
158+
progress_bar_formatters.Bar(start='running queries [', end=']', sym_a=' ', sym_b=' ', sym_c=' '),
144159
progress_bar_formatters.Text(' '),
145160
progress_bar_formatters.Progress(),
146161
progress_bar_formatters.Text(' '),

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ dependencies = [
2525
"pyfzf ~= 0.3.1",
2626
"rapidfuzz ~= 3.14.3",
2727
"keyring ~= 25.7.0",
28+
"yaspin ~= 3.4.0",
2829
]
2930

3031
[project.urls]

test/pytests/test_main_modes_batch.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,23 @@ def close(self) -> None:
6868
self.closed = True
6969

7070

71+
class DummyStream:
72+
def __init__(self, tty: bool = False) -> None:
73+
self.closed = False
74+
self.tty = tty
75+
self.writes: list[str] = []
76+
77+
def isatty(self) -> bool:
78+
return self.tty
79+
80+
def write(self, value: str) -> int:
81+
self.writes.append(value)
82+
return len(value)
83+
84+
def flush(self) -> None:
85+
return None
86+
87+
7188
class DummyProgressBar:
7289
calls: list[list[int]] = []
7390

@@ -86,6 +103,32 @@ def __call__(self, iterable) -> list[int]:
86103
return values
87104

88105

106+
class DummySpinner:
107+
instances: list['DummySpinner'] = []
108+
109+
def __init__(self, *args, **kwargs) -> None:
110+
self.fail_calls: list[str] = []
111+
self.ok_calls: list[str] = []
112+
self.started = False
113+
DummySpinner.instances.append(self)
114+
115+
def __enter__(self) -> 'DummySpinner':
116+
self.start()
117+
return self
118+
119+
def __exit__(self, exc_type, exc, tb) -> Literal[False]:
120+
return False
121+
122+
def start(self) -> None:
123+
self.started = True
124+
125+
def fail(self, text: str) -> None:
126+
self.fail_calls.append(text)
127+
128+
def ok(self, text: str) -> None:
129+
self.ok_calls.append(text)
130+
131+
89132
def dispatch_batch_statements(
90133
mycli: DummyMyCli,
91134
cli_args: DummyCliArgs,
@@ -108,7 +151,7 @@ def main_batch_from_stdin(mycli: DummyMyCli, cli_args: DummyCliArgs) -> int:
108151

109152

110153
def make_fake_sys(stdin_tty: bool, stderr_tty: bool | None = None) -> SimpleNamespace:
111-
stderr = SimpleNamespace(isatty=lambda: stderr_tty) if stderr_tty is not None else object()
154+
stderr = DummyStream(bool(stderr_tty))
112155
return SimpleNamespace(
113156
stdin=SimpleNamespace(isatty=lambda: stdin_tty),
114157
stderr=stderr,
@@ -186,6 +229,21 @@ def test_replay_checkpoint_file_rejects_checkpoint_longer_than_batch(tmp_path: P
186229
batch_mode.replay_checkpoint_file(batch_path, checkpoint, resume=True)
187230

188231

232+
def test_replay_checkpoint_file_marks_progress_failed_when_checkpoint_is_longer(
233+
monkeypatch,
234+
tmp_path: Path,
235+
) -> None:
236+
batch_path = write_batch_file(tmp_path, 'select 1;\n')
237+
checkpoint = write_checkpoint_file(tmp_path, 'select 1;\nselect 2;\n')
238+
DummySpinner.instances.clear()
239+
monkeypatch.setattr(batch_mode, 'yaspin', DummySpinner)
240+
241+
with pytest.raises(batch_mode.CheckpointReplayError, match='Checkpoint script longer than batch script.'):
242+
batch_mode.replay_checkpoint_file(batch_path, checkpoint, resume=True, progress=True)
243+
244+
assert DummySpinner.instances[0].fail_calls == ['✘']
245+
246+
189247
@pytest.mark.skipif(os.name == 'nt', reason='todo: unknown')
190248
def test_replay_checkpoint_file_rejects_batch_read_error(monkeypatch, tmp_path: Path) -> None:
191249
batch_path = write_batch_file(tmp_path, 'select 1;\n')
@@ -219,6 +277,31 @@ def fake_statements_from_filehandle(handle):
219277
batch_mode.replay_checkpoint_file(batch_path, checkpoint, resume=True)
220278

221279

280+
@pytest.mark.skipif(os.name == 'nt', reason='todo: unknown')
281+
def test_replay_checkpoint_file_marks_progress_failed_for_batch_iteration_error(monkeypatch, tmp_path: Path) -> None:
282+
batch_path = write_batch_file(tmp_path, 'select 1;\n')
283+
284+
def raise_on_next():
285+
raise ValueError('bad batch iterator')
286+
yield
287+
288+
def fake_statements_from_filehandle(handle):
289+
if handle.name == batch_path:
290+
return raise_on_next()
291+
return iter([('select 1;', 0)])
292+
293+
DummySpinner.instances.clear()
294+
monkeypatch.setattr(batch_mode, 'statements_from_filehandle', fake_statements_from_filehandle)
295+
monkeypatch.setattr(batch_mode, 'yaspin', DummySpinner)
296+
297+
checkpoint = write_checkpoint_file(tmp_path, 'select 1;\n')
298+
299+
with pytest.raises(batch_mode.CheckpointReplayError, match=f'Error reading --batch file: {batch_path}: bad batch iterator'):
300+
batch_mode.replay_checkpoint_file(batch_path, checkpoint, resume=True, progress=True)
301+
302+
assert DummySpinner.instances[0].fail_calls == ['✘']
303+
304+
222305
@pytest.mark.skipif(os.name == 'nt', reason='todo: unknown')
223306
def test_replay_checkpoint_file_rejects_checkpoint_read_error(monkeypatch, tmp_path: Path) -> None:
224307
batch_path = write_batch_file(tmp_path, 'select 1;\n')

0 commit comments

Comments
 (0)