zulipcli 0.3__tar.gz → 0.4__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.
- {zulipcli-0.3 → zulipcli-0.4}/PKG-INFO +1 -1
- {zulipcli-0.3 → zulipcli-0.4}/pyproject.toml +1 -1
- {zulipcli-0.3 → zulipcli-0.4}/src/zulipcli/cli.nw +516 -37
- {zulipcli-0.3 → zulipcli-0.4}/LICENSE +0 -0
- {zulipcli-0.3 → zulipcli-0.4}/README.md +0 -0
- {zulipcli-0.3 → zulipcli-0.4}/src/zulipcli/Makefile +0 -0
- {zulipcli-0.3 → zulipcli-0.4}/src/zulipcli/init.nw +0 -0
|
@@ -32,6 +32,7 @@ option names, and argument values.
|
|
|
32
32
|
<<[[cli.py]]>>=
|
|
33
33
|
"""Command-line interface for Zulip."""
|
|
34
34
|
|
|
35
|
+
import configparser
|
|
35
36
|
from datetime import datetime, timezone
|
|
36
37
|
from pathlib import Path
|
|
37
38
|
import re
|
|
@@ -42,6 +43,7 @@ from typing import Annotated, Optional
|
|
|
42
43
|
from rich.console import Console
|
|
43
44
|
from rich.markdown import Markdown
|
|
44
45
|
import typer
|
|
46
|
+
import zulip
|
|
45
47
|
|
|
46
48
|
import zulipcli
|
|
47
49
|
|
|
@@ -56,16 +58,29 @@ def main():
|
|
|
56
58
|
|
|
57
59
|
\section{Argument parsing}
|
|
58
60
|
|
|
59
|
-
The
|
|
60
|
-
override the default configuration file path.
|
|
61
|
-
|
|
61
|
+
The module still exposes a standalone [[app]] whose callback accepts
|
|
62
|
+
\texttt{-{}-zuliprc} to override the default configuration file path.
|
|
63
|
+
That callback stores the resolved path in the root context, so each
|
|
62
64
|
subcommand can ask one helper for a configured client.
|
|
63
65
|
Typing the option as a [[Path]] also gives us filesystem completion for
|
|
64
66
|
free.
|
|
65
67
|
|
|
68
|
+
We also expose [[build_embedded_app()]] for host applications that want
|
|
69
|
+
to mount the same command tree under their own subcommand name.
|
|
70
|
+
The embedded form declares no root options, so the host can choose the
|
|
71
|
+
mount name with [[add_typer()]] and populate [[ctx.obj["zuliprc"]]] in
|
|
72
|
+
its own callback.
|
|
73
|
+
The standalone form keeps Typer's completion-installation flags, while
|
|
74
|
+
the embedded form disables them by default so the host owns that
|
|
75
|
+
surface.
|
|
76
|
+
|
|
77
|
+
<<constants>>=
|
|
78
|
+
APP_HELP = "Command-line interface for Zulip"
|
|
79
|
+
@
|
|
80
|
+
|
|
66
81
|
<<functions>>=
|
|
67
82
|
app = typer.Typer(
|
|
68
|
-
help=
|
|
83
|
+
help=APP_HELP,
|
|
69
84
|
add_completion=True,
|
|
70
85
|
no_args_is_help=True,
|
|
71
86
|
)
|
|
@@ -84,24 +99,83 @@ def configure_app(
|
|
|
84
99
|
] = None,
|
|
85
100
|
):
|
|
86
101
|
"""Configure shared CLI options."""
|
|
87
|
-
ctx.
|
|
88
|
-
|
|
89
|
-
|
|
102
|
+
ctx.ensure_object(dict)
|
|
103
|
+
if zuliprc is not None:
|
|
104
|
+
ctx.obj["zuliprc"] = str(zuliprc)
|
|
105
|
+
else:
|
|
106
|
+
ctx.obj.setdefault("zuliprc", None)
|
|
107
|
+
@
|
|
108
|
+
|
|
109
|
+
<<functions>>=
|
|
110
|
+
def _get_root_zuliprc(ctx: typer.Context):
|
|
111
|
+
"""Return the zuliprc override stored in the root context."""
|
|
112
|
+
root_ctx = ctx.find_root()
|
|
113
|
+
obj = getattr(root_ctx, "obj", None) or {}
|
|
114
|
+
if "zuliprc" in obj:
|
|
115
|
+
return obj["zuliprc"]
|
|
116
|
+
params = getattr(root_ctx, "params", None) or {}
|
|
117
|
+
return params.get("zuliprc")
|
|
90
118
|
@
|
|
91
119
|
|
|
120
|
+
Client construction can fail before any API call if the configured
|
|
121
|
+
[[zuliprc]] path is missing or malformed.
|
|
122
|
+
Those failures are still ordinary command-line errors, so we report them
|
|
123
|
+
through the same [[Error: ...]] path instead of showing a traceback.
|
|
124
|
+
|
|
92
125
|
<<functions>>=
|
|
93
126
|
def _get_client_from_context(ctx):
|
|
94
127
|
"""Create a Zulip client from the current Typer context."""
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
128
|
+
try:
|
|
129
|
+
return zulipcli.get_client(_get_root_zuliprc(ctx))
|
|
130
|
+
except (configparser.Error, zulip.ZulipError) as error:
|
|
131
|
+
_exit_with_error(str(error))
|
|
98
132
|
@
|
|
99
133
|
|
|
134
|
+
The standalone and embedded applications share the same command
|
|
135
|
+
handlers.
|
|
136
|
+
We therefore build the embedded Typer application as a fresh object,
|
|
137
|
+
normalise its context in a minimal callback, and register the shared
|
|
138
|
+
handlers onto it.
|
|
139
|
+
That keeps the standalone and embedded command surfaces aligned without
|
|
140
|
+
forcing either form to own the other's root options.
|
|
141
|
+
|
|
142
|
+
<<functions>>=
|
|
143
|
+
def build_embedded_app(
|
|
144
|
+
*,
|
|
145
|
+
help: Optional[str] = None,
|
|
146
|
+
add_completion: bool = False,
|
|
147
|
+
no_args_is_help: bool = True,
|
|
148
|
+
) -> typer.Typer:
|
|
149
|
+
"""Return a Typer app suitable for mounting in another Typer app.
|
|
150
|
+
|
|
151
|
+
Example:
|
|
152
|
+
parent = typer.Typer()
|
|
153
|
+
parent.add_typer(build_embedded_app(), name="zulip")
|
|
154
|
+
|
|
155
|
+
The host callback should store the resolved zuliprc path in
|
|
156
|
+
``ctx.obj["zuliprc"]`` before the embedded commands run.
|
|
157
|
+
"""
|
|
158
|
+
embedded_app = typer.Typer(
|
|
159
|
+
help=APP_HELP if help is None else help,
|
|
160
|
+
add_completion=add_completion,
|
|
161
|
+
no_args_is_help=no_args_is_help,
|
|
162
|
+
)
|
|
100
163
|
|
|
101
|
-
|
|
164
|
+
@embedded_app.callback()
|
|
165
|
+
def configure_embedded_app(ctx: typer.Context):
|
|
166
|
+
"""Prepare shared CLI state for embedded use."""
|
|
167
|
+
ctx.ensure_object(dict)
|
|
168
|
+
ctx.obj.setdefault("zuliprc", None)
|
|
102
169
|
|
|
103
|
-
|
|
104
|
-
|
|
170
|
+
_register_commands(embedded_app)
|
|
171
|
+
return embedded_app
|
|
172
|
+
@
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
\section{Reporting Zulip errors}
|
|
176
|
+
|
|
177
|
+
The ordinary commands should all report user-facing Zulip failures in
|
|
178
|
+
the same way: print the message on stderr and exit with status [[1]].
|
|
105
179
|
Factoring that policy into two helpers keeps each command focused on its
|
|
106
180
|
own data handling instead of repeating the same error-checking branch.
|
|
107
181
|
|
|
@@ -135,17 +209,17 @@ If the client cannot be created or a Zulip API request fails, we simply
|
|
|
135
209
|
return no candidates.
|
|
136
210
|
|
|
137
211
|
Typer also gives completion callbacks the parsed command context.
|
|
138
|
-
That is enough for this CLI: the root context carries the
|
|
139
|
-
[[
|
|
140
|
-
|
|
212
|
+
That is enough for this CLI: the root context carries the resolved
|
|
213
|
+
[[zuliprc]] path, whether it came from the standalone [[--zuliprc]]
|
|
214
|
+
option or a host application's callback, and the active command context
|
|
215
|
+
already contains the selected stream for [[read TOPIC]] and
|
|
216
|
+
[[send --topic TOPIC]].
|
|
141
217
|
|
|
142
218
|
<<functions>>=
|
|
143
219
|
def _get_completion_client(ctx: typer.Context):
|
|
144
220
|
"""Create a Zulip client for shell completion lookups."""
|
|
145
|
-
root_ctx = ctx.find_root()
|
|
146
|
-
zuliprc = (root_ctx.params or {}).get("zuliprc")
|
|
147
221
|
try:
|
|
148
|
-
return zulipcli.get_client(
|
|
222
|
+
return zulipcli.get_client(_get_root_zuliprc(ctx))
|
|
149
223
|
except Exception:
|
|
150
224
|
return None
|
|
151
225
|
@
|
|
@@ -249,9 +323,9 @@ def complete_user_emails(ctx: typer.Context, incomplete: str):
|
|
|
249
323
|
return completions
|
|
250
324
|
@
|
|
251
325
|
|
|
252
|
-
Let's verify
|
|
253
|
-
|
|
254
|
-
|
|
326
|
+
Let's verify both CLI entry styles: the standalone application still
|
|
327
|
+
accepts [[--zuliprc]], while the embedded factory exposes the same
|
|
328
|
+
subcommands without claiming that root option for itself.
|
|
255
329
|
|
|
256
330
|
<<test [[cli.py]]>>=
|
|
257
331
|
"""Tests for zulipcli.cli."""
|
|
@@ -261,6 +335,7 @@ import io
|
|
|
261
335
|
from types import SimpleNamespace
|
|
262
336
|
|
|
263
337
|
import pytest
|
|
338
|
+
import typer
|
|
264
339
|
from click.shell_completion import _resolve_context, _resolve_incomplete
|
|
265
340
|
from typer.main import get_command
|
|
266
341
|
from typer.testing import CliRunner
|
|
@@ -315,6 +390,121 @@ def test_cli_passes_zuliprc_to_commands(monkeypatch):
|
|
|
315
390
|
assert seen["zuliprc"] == "/tmp/custom.zuliprc"
|
|
316
391
|
@
|
|
317
392
|
|
|
393
|
+
<<test functions>>=
|
|
394
|
+
def test_cli_reports_missing_zuliprc_without_traceback(monkeypatch):
|
|
395
|
+
def get_client(zuliprc=None):
|
|
396
|
+
raise cli.zulip.ConfigNotFoundError(
|
|
397
|
+
"api_key or email not specified and file "
|
|
398
|
+
"/tmp/missing.zuliprc does not exist"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
monkeypatch.setattr(cli.zulipcli, "get_client", get_client)
|
|
402
|
+
|
|
403
|
+
result = runner.invoke(
|
|
404
|
+
app,
|
|
405
|
+
["--zuliprc", "/tmp/missing.zuliprc", "users"],
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
assert result.exit_code == 1
|
|
409
|
+
assert result.output == (
|
|
410
|
+
"Error: api_key or email not specified and file "
|
|
411
|
+
"/tmp/missing.zuliprc does not exist\n"
|
|
412
|
+
)
|
|
413
|
+
@
|
|
414
|
+
|
|
415
|
+
<<test functions>>=
|
|
416
|
+
def test_embedded_app_mounts_under_custom_name(monkeypatch):
|
|
417
|
+
client = MagicMock()
|
|
418
|
+
client.get_streams.return_value = {
|
|
419
|
+
"result": "success",
|
|
420
|
+
"streams": [],
|
|
421
|
+
}
|
|
422
|
+
monkeypatch.setattr(
|
|
423
|
+
cli.zulipcli,
|
|
424
|
+
"get_client",
|
|
425
|
+
lambda zuliprc=None: client,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
parent = typer.Typer()
|
|
429
|
+
parent.add_typer(cli.build_embedded_app(), name="zulip")
|
|
430
|
+
|
|
431
|
+
result = runner.invoke(parent, ["zulip", "streams"])
|
|
432
|
+
|
|
433
|
+
assert result.exit_code == 0
|
|
434
|
+
client.get_streams.assert_called_once_with()
|
|
435
|
+
@
|
|
436
|
+
|
|
437
|
+
<<test functions>>=
|
|
438
|
+
def test_embedded_app_has_no_zuliprc_option():
|
|
439
|
+
command = get_command(cli.build_embedded_app())
|
|
440
|
+
|
|
441
|
+
assert "--zuliprc" not in {
|
|
442
|
+
option
|
|
443
|
+
for param in command.params
|
|
444
|
+
for option in getattr(param, "opts", [])
|
|
445
|
+
}
|
|
446
|
+
@
|
|
447
|
+
|
|
448
|
+
<<test functions>>=
|
|
449
|
+
def test_embedded_app_reads_zuliprc_from_parent_ctx_obj(monkeypatch):
|
|
450
|
+
client = MagicMock()
|
|
451
|
+
client.get_streams.return_value = {
|
|
452
|
+
"result": "success",
|
|
453
|
+
"streams": [],
|
|
454
|
+
}
|
|
455
|
+
seen = {}
|
|
456
|
+
|
|
457
|
+
def get_client(zuliprc=None):
|
|
458
|
+
seen["zuliprc"] = zuliprc
|
|
459
|
+
return client
|
|
460
|
+
|
|
461
|
+
monkeypatch.setattr(cli.zulipcli, "get_client", get_client)
|
|
462
|
+
|
|
463
|
+
parent = typer.Typer()
|
|
464
|
+
|
|
465
|
+
@parent.callback()
|
|
466
|
+
def configure_parent(ctx: typer.Context):
|
|
467
|
+
ctx.ensure_object(dict)
|
|
468
|
+
ctx.obj["zuliprc"] = "/tmp/fake.zuliprc"
|
|
469
|
+
|
|
470
|
+
parent.add_typer(cli.build_embedded_app(), name="zulip")
|
|
471
|
+
|
|
472
|
+
result = runner.invoke(parent, ["zulip", "streams"])
|
|
473
|
+
|
|
474
|
+
assert result.exit_code == 0
|
|
475
|
+
assert seen["zuliprc"] == "/tmp/fake.zuliprc"
|
|
476
|
+
@
|
|
477
|
+
|
|
478
|
+
<<test functions>>=
|
|
479
|
+
def test_standalone_app_still_has_zuliprc_option():
|
|
480
|
+
command = get_command(app)
|
|
481
|
+
|
|
482
|
+
assert "--zuliprc" in {
|
|
483
|
+
option
|
|
484
|
+
for param in command.params
|
|
485
|
+
for option in getattr(param, "opts", [])
|
|
486
|
+
}
|
|
487
|
+
@
|
|
488
|
+
|
|
489
|
+
<<test functions>>=
|
|
490
|
+
def test_standalone_callback_preserves_existing_ctx_obj():
|
|
491
|
+
class DummyContext:
|
|
492
|
+
def __init__(self):
|
|
493
|
+
self.obj = {"course": "DD1337"}
|
|
494
|
+
|
|
495
|
+
def ensure_object(self, object_type):
|
|
496
|
+
if self.obj is None:
|
|
497
|
+
self.obj = object_type()
|
|
498
|
+
return self.obj
|
|
499
|
+
|
|
500
|
+
ctx = DummyContext()
|
|
501
|
+
|
|
502
|
+
cli.configure_app(ctx)
|
|
503
|
+
|
|
504
|
+
assert ctx.obj["course"] == "DD1337"
|
|
505
|
+
assert ctx.obj["zuliprc"] is None
|
|
506
|
+
@
|
|
507
|
+
|
|
318
508
|
<<test functions>>=
|
|
319
509
|
def test_stream_completion_uses_zuliprc_override(monkeypatch):
|
|
320
510
|
client = MagicMock()
|
|
@@ -331,7 +521,8 @@ def test_stream_completion_uses_zuliprc_override(monkeypatch):
|
|
|
331
521
|
monkeypatch.setattr(cli.zulipcli, "get_client", get_client)
|
|
332
522
|
assert complete_stream_names(
|
|
333
523
|
SimpleNamespace(find_root=lambda: SimpleNamespace(
|
|
334
|
-
|
|
524
|
+
obj={"zuliprc": "/tmp/custom.zuliprc"},
|
|
525
|
+
params={},
|
|
335
526
|
)),
|
|
336
527
|
"g",
|
|
337
528
|
) == ["general"]
|
|
@@ -372,8 +563,7 @@ By default we exclude bots and deactivated users, since the common use
|
|
|
372
563
|
case is finding a human colleague's email address.
|
|
373
564
|
|
|
374
565
|
<<functions>>=
|
|
375
|
-
|
|
376
|
-
def users(
|
|
566
|
+
def _users_handler(
|
|
377
567
|
ctx: typer.Context,
|
|
378
568
|
pattern: Annotated[
|
|
379
569
|
Optional[str],
|
|
@@ -563,8 +753,7 @@ Reading from stdin is useful for piping output from other commands
|
|
|
563
753
|
directly into a Zulip message, which is a common scripting pattern.
|
|
564
754
|
|
|
565
755
|
<<functions>>=
|
|
566
|
-
|
|
567
|
-
def send(
|
|
756
|
+
def _send_handler(
|
|
568
757
|
ctx: typer.Context,
|
|
569
758
|
stream: Annotated[
|
|
570
759
|
Optional[str],
|
|
@@ -794,8 +983,7 @@ on a server with many streams where the web interface's sidebar
|
|
|
794
983
|
becomes unwieldy.
|
|
795
984
|
|
|
796
985
|
<<functions>>=
|
|
797
|
-
|
|
798
|
-
def streams(
|
|
986
|
+
def _streams_handler(
|
|
799
987
|
ctx: typer.Context,
|
|
800
988
|
pattern: Annotated[
|
|
801
989
|
Optional[str],
|
|
@@ -879,8 +1067,7 @@ identifier, so we first resolve the human-readable stream name via
|
|
|
879
1067
|
[[get_stream_id()]].
|
|
880
1068
|
|
|
881
1069
|
<<functions>>=
|
|
882
|
-
|
|
883
|
-
def topics(
|
|
1070
|
+
def _topics_handler(
|
|
884
1071
|
ctx: typer.Context,
|
|
885
1072
|
stream: Annotated[
|
|
886
1073
|
str,
|
|
@@ -999,13 +1186,21 @@ immediately after the most recent batch we just printed.
|
|
|
999
1186
|
That yields oldest-to-newest output without collecting the whole history
|
|
1000
1187
|
in memory first.
|
|
1001
1188
|
|
|
1189
|
+
Unread filtering is still just another Zulip narrow term, so the command
|
|
1190
|
+
can expose it as a boolean option without changing the pagination logic.
|
|
1191
|
+
|
|
1192
|
+
Marking messages as read needs more care. We should only acknowledge
|
|
1193
|
+
messages that actually reached stdout, so we collect the IDs of messages
|
|
1194
|
+
whose printing succeeded and update their flags only after the whole read
|
|
1195
|
+
finishes. That way an encoding failure or broken pipe cannot silently
|
|
1196
|
+
mark unseen messages as read.
|
|
1197
|
+
|
|
1002
1198
|
<<constants>>=
|
|
1003
1199
|
HISTORY_BATCH_SIZE = 1000
|
|
1004
1200
|
@
|
|
1005
1201
|
|
|
1006
1202
|
<<functions>>=
|
|
1007
|
-
|
|
1008
|
-
def read(
|
|
1203
|
+
def _read_handler(
|
|
1009
1204
|
ctx: typer.Context,
|
|
1010
1205
|
stream: Annotated[
|
|
1011
1206
|
str,
|
|
@@ -1021,14 +1216,54 @@ def read(
|
|
|
1021
1216
|
autocompletion=complete_topic_names,
|
|
1022
1217
|
),
|
|
1023
1218
|
] = None,
|
|
1219
|
+
unread: Annotated[
|
|
1220
|
+
bool,
|
|
1221
|
+
typer.Option(
|
|
1222
|
+
"--unread",
|
|
1223
|
+
"-U",
|
|
1224
|
+
help="only show unread messages",
|
|
1225
|
+
),
|
|
1226
|
+
] = False,
|
|
1227
|
+
mark_as_read: Annotated[
|
|
1228
|
+
bool,
|
|
1229
|
+
typer.Option(
|
|
1230
|
+
"--mark-as-read/--no-mark-as-read",
|
|
1231
|
+
help="mark printed messages as read",
|
|
1232
|
+
),
|
|
1233
|
+
] = False,
|
|
1024
1234
|
):
|
|
1025
1235
|
"""Read messages from a stream or topic."""
|
|
1026
1236
|
read_command(
|
|
1027
1237
|
_get_client_from_context(ctx),
|
|
1028
|
-
SimpleNamespace(
|
|
1238
|
+
SimpleNamespace(
|
|
1239
|
+
stream=stream,
|
|
1240
|
+
topic=topic,
|
|
1241
|
+
unread=unread,
|
|
1242
|
+
mark_as_read=mark_as_read,
|
|
1243
|
+
),
|
|
1029
1244
|
)
|
|
1030
1245
|
@
|
|
1031
1246
|
|
|
1247
|
+
The standalone and embedded applications must expose the same five
|
|
1248
|
+
subcommands.
|
|
1249
|
+
We therefore register the handlers through one helper that binds the
|
|
1250
|
+
shared command implementations to whichever [[typer.Typer]] instance we
|
|
1251
|
+
are constructing.
|
|
1252
|
+
|
|
1253
|
+
<<functions>>=
|
|
1254
|
+
def _register_commands(target: typer.Typer) -> None:
|
|
1255
|
+
"""Register Zulip subcommands on ``target``."""
|
|
1256
|
+
target.command("users")(_users_handler)
|
|
1257
|
+
target.command("send")(_send_handler)
|
|
1258
|
+
target.command("streams")(_streams_handler)
|
|
1259
|
+
target.command("topics")(_topics_handler)
|
|
1260
|
+
target.command("read")(_read_handler)
|
|
1261
|
+
@
|
|
1262
|
+
|
|
1263
|
+
<<functions>>=
|
|
1264
|
+
_register_commands(app)
|
|
1265
|
+
@
|
|
1266
|
+
|
|
1032
1267
|
The output needs to stay readable for both a whole stream and a single
|
|
1033
1268
|
topic.
|
|
1034
1269
|
We therefore print a UTC timestamp, sender, and topic on the first
|
|
@@ -1077,6 +1312,8 @@ def _print_read_message(message):
|
|
|
1077
1312
|
The request stays constant across pages except for the anchor.
|
|
1078
1313
|
Each successful batch is printed immediately, and then the anchor moves
|
|
1079
1314
|
to one past the last message ID we have already seen.
|
|
1315
|
+
If the caller asked to mark messages as read, we defer that update until
|
|
1316
|
+
after every page has been printed successfully.
|
|
1080
1317
|
|
|
1081
1318
|
<<functions>>=
|
|
1082
1319
|
def read_command(client, args):
|
|
@@ -1093,7 +1330,12 @@ def read_command(client, args):
|
|
|
1093
1330
|
request["narrow"].append(
|
|
1094
1331
|
{"operator": "topic", "operand": args.topic}
|
|
1095
1332
|
)
|
|
1333
|
+
if args.unread:
|
|
1334
|
+
request["narrow"].append(
|
|
1335
|
+
{"operator": "is", "operand": "unread"}
|
|
1336
|
+
)
|
|
1096
1337
|
|
|
1338
|
+
printed_message_ids = [] if args.mark_as_read else None
|
|
1097
1339
|
found_newest = False
|
|
1098
1340
|
while not found_newest:
|
|
1099
1341
|
result = _require_success(client.get_messages(request))
|
|
@@ -1102,7 +1344,75 @@ def read_command(client, args):
|
|
|
1102
1344
|
if result["messages"]:
|
|
1103
1345
|
for message in result["messages"]:
|
|
1104
1346
|
_print_read_message(message)
|
|
1347
|
+
if printed_message_ids is not None:
|
|
1348
|
+
printed_message_ids.append(message["id"])
|
|
1105
1349
|
request["anchor"] = result["messages"][-1]["id"] + 1
|
|
1350
|
+
|
|
1351
|
+
if printed_message_ids:
|
|
1352
|
+
_require_success(client.update_message_flags({
|
|
1353
|
+
"messages": printed_message_ids,
|
|
1354
|
+
"op": "add",
|
|
1355
|
+
"flag": "read",
|
|
1356
|
+
}))
|
|
1357
|
+
@
|
|
1358
|
+
|
|
1359
|
+
Let's verify that the CLI forwards the new filtering and acknowledgement
|
|
1360
|
+
flags to [[read_command()]].
|
|
1361
|
+
|
|
1362
|
+
<<test functions>>=
|
|
1363
|
+
def test_read_cli_passes_unread_and_mark_as_read_flags(monkeypatch):
|
|
1364
|
+
seen = {}
|
|
1365
|
+
|
|
1366
|
+
def fake_read_command(client, args):
|
|
1367
|
+
seen["client"] = client
|
|
1368
|
+
seen["args"] = args
|
|
1369
|
+
|
|
1370
|
+
monkeypatch.setattr(cli, "read_command", fake_read_command)
|
|
1371
|
+
monkeypatch.setattr(
|
|
1372
|
+
cli,
|
|
1373
|
+
"_get_client_from_context",
|
|
1374
|
+
lambda ctx: "client",
|
|
1375
|
+
)
|
|
1376
|
+
|
|
1377
|
+
result = runner.invoke(
|
|
1378
|
+
app,
|
|
1379
|
+
["read", "general", "-U", "--mark-as-read"],
|
|
1380
|
+
)
|
|
1381
|
+
|
|
1382
|
+
assert result.exit_code == 0
|
|
1383
|
+
assert seen["client"] == "client"
|
|
1384
|
+
assert seen["args"].stream == "general"
|
|
1385
|
+
assert seen["args"].topic is None
|
|
1386
|
+
assert seen["args"].unread is True
|
|
1387
|
+
assert seen["args"].mark_as_read is True
|
|
1388
|
+
@
|
|
1389
|
+
|
|
1390
|
+
We should also verify the default parser behaviour: without either flag,
|
|
1391
|
+
[[read]] should include all messages and leave them unread.
|
|
1392
|
+
|
|
1393
|
+
<<test functions>>=
|
|
1394
|
+
def test_read_cli_defaults_to_all_messages_without_marking(monkeypatch):
|
|
1395
|
+
seen = {}
|
|
1396
|
+
|
|
1397
|
+
def fake_read_command(client, args):
|
|
1398
|
+
seen["client"] = client
|
|
1399
|
+
seen["args"] = args
|
|
1400
|
+
|
|
1401
|
+
monkeypatch.setattr(cli, "read_command", fake_read_command)
|
|
1402
|
+
monkeypatch.setattr(
|
|
1403
|
+
cli,
|
|
1404
|
+
"_get_client_from_context",
|
|
1405
|
+
lambda ctx: "client",
|
|
1406
|
+
)
|
|
1407
|
+
|
|
1408
|
+
result = runner.invoke(app, ["read", "general"])
|
|
1409
|
+
|
|
1410
|
+
assert result.exit_code == 0
|
|
1411
|
+
assert seen["client"] == "client"
|
|
1412
|
+
assert seen["args"].stream == "general"
|
|
1413
|
+
assert seen["args"].topic is None
|
|
1414
|
+
assert seen["args"].unread is False
|
|
1415
|
+
assert seen["args"].mark_as_read is False
|
|
1106
1416
|
@
|
|
1107
1417
|
|
|
1108
1418
|
Let's build a small helper for synthetic history payloads.
|
|
@@ -1145,7 +1455,12 @@ def test_read_stream_uses_stream_name(capsys, monkeypatch):
|
|
|
1145
1455
|
|
|
1146
1456
|
client = MagicMock()
|
|
1147
1457
|
client.get_messages.side_effect = get_messages
|
|
1148
|
-
args = SimpleNamespace(
|
|
1458
|
+
args = SimpleNamespace(
|
|
1459
|
+
stream="general",
|
|
1460
|
+
topic=None,
|
|
1461
|
+
unread=False,
|
|
1462
|
+
mark_as_read=False,
|
|
1463
|
+
)
|
|
1149
1464
|
monkeypatch.setattr(cli, "_stdout_is_tty", lambda: False)
|
|
1150
1465
|
read_command(client, args)
|
|
1151
1466
|
|
|
@@ -1164,6 +1479,7 @@ def test_read_stream_uses_stream_name(capsys, monkeypatch):
|
|
|
1164
1479
|
"1970-01-01T00:00:00Z\tAlice Admin\tAnnouncements\n"
|
|
1165
1480
|
"Hello stream\n\n"
|
|
1166
1481
|
)
|
|
1482
|
+
client.update_message_flags.assert_not_called()
|
|
1167
1483
|
@
|
|
1168
1484
|
|
|
1169
1485
|
We should also verify that interactive terminals render Markdown through
|
|
@@ -1198,7 +1514,12 @@ def test_read_renders_markdown_on_tty(capsys, monkeypatch):
|
|
|
1198
1514
|
|
|
1199
1515
|
client = MagicMock()
|
|
1200
1516
|
client.get_messages.return_value = response
|
|
1201
|
-
args = SimpleNamespace(
|
|
1517
|
+
args = SimpleNamespace(
|
|
1518
|
+
stream="general",
|
|
1519
|
+
topic=None,
|
|
1520
|
+
unread=False,
|
|
1521
|
+
mark_as_read=False,
|
|
1522
|
+
)
|
|
1202
1523
|
monkeypatch.setattr(cli, "_stdout_is_tty", lambda: True)
|
|
1203
1524
|
monkeypatch.setattr(cli, "Console", FakeConsole)
|
|
1204
1525
|
monkeypatch.setattr(cli, "Markdown", FakeMarkdown)
|
|
@@ -1211,6 +1532,157 @@ def test_read_renders_markdown_on_tty(capsys, monkeypatch):
|
|
|
1211
1532
|
)
|
|
1212
1533
|
@
|
|
1213
1534
|
|
|
1535
|
+
We should also verify that unread reads add Zulip's [[is:unread]]
|
|
1536
|
+
narrow and only mark messages after they have all been printed.
|
|
1537
|
+
|
|
1538
|
+
<<test functions>>=
|
|
1539
|
+
def test_read_unread_narrows_and_marks_after_printing(monkeypatch):
|
|
1540
|
+
response = {
|
|
1541
|
+
"result": "success",
|
|
1542
|
+
"found_newest": True,
|
|
1543
|
+
"messages": [
|
|
1544
|
+
_make_stream_message(
|
|
1545
|
+
1,
|
|
1546
|
+
0,
|
|
1547
|
+
"Alice Admin",
|
|
1548
|
+
"Announcements",
|
|
1549
|
+
"First message",
|
|
1550
|
+
),
|
|
1551
|
+
_make_stream_message(
|
|
1552
|
+
2,
|
|
1553
|
+
60,
|
|
1554
|
+
"Bob Builder",
|
|
1555
|
+
"Announcements",
|
|
1556
|
+
"Second message",
|
|
1557
|
+
),
|
|
1558
|
+
],
|
|
1559
|
+
}
|
|
1560
|
+
requests = []
|
|
1561
|
+
events = []
|
|
1562
|
+
|
|
1563
|
+
def get_messages(request):
|
|
1564
|
+
requests.append(copy.deepcopy(request))
|
|
1565
|
+
return response
|
|
1566
|
+
|
|
1567
|
+
def print_message(message):
|
|
1568
|
+
events.append(("print", message["id"]))
|
|
1569
|
+
|
|
1570
|
+
def update_message_flags(request):
|
|
1571
|
+
events.append(("mark", copy.deepcopy(request)))
|
|
1572
|
+
return {"result": "success"}
|
|
1573
|
+
|
|
1574
|
+
client = MagicMock()
|
|
1575
|
+
client.get_messages.side_effect = get_messages
|
|
1576
|
+
client.update_message_flags.side_effect = update_message_flags
|
|
1577
|
+
args = SimpleNamespace(
|
|
1578
|
+
stream="general",
|
|
1579
|
+
topic=None,
|
|
1580
|
+
unread=True,
|
|
1581
|
+
mark_as_read=True,
|
|
1582
|
+
)
|
|
1583
|
+
monkeypatch.setattr(cli, "_print_read_message", print_message)
|
|
1584
|
+
|
|
1585
|
+
read_command(client, args)
|
|
1586
|
+
|
|
1587
|
+
assert requests == [{
|
|
1588
|
+
"anchor": 0,
|
|
1589
|
+
"num_before": 0,
|
|
1590
|
+
"num_after": HISTORY_BATCH_SIZE,
|
|
1591
|
+
"narrow": [
|
|
1592
|
+
{"operator": "stream", "operand": "general"},
|
|
1593
|
+
{"operator": "is", "operand": "unread"},
|
|
1594
|
+
],
|
|
1595
|
+
"client_gravatar": False,
|
|
1596
|
+
"apply_markdown": False,
|
|
1597
|
+
}]
|
|
1598
|
+
assert events == [
|
|
1599
|
+
("print", 1),
|
|
1600
|
+
("print", 2),
|
|
1601
|
+
("mark", {
|
|
1602
|
+
"messages": [1, 2],
|
|
1603
|
+
"op": "add",
|
|
1604
|
+
"flag": "read",
|
|
1605
|
+
}),
|
|
1606
|
+
]
|
|
1607
|
+
@
|
|
1608
|
+
|
|
1609
|
+
Empty history should not trigger a flag update even when the user asks
|
|
1610
|
+
for one.
|
|
1611
|
+
|
|
1612
|
+
<<test functions>>=
|
|
1613
|
+
def test_read_mark_as_read_skips_empty_history():
|
|
1614
|
+
client = MagicMock()
|
|
1615
|
+
client.get_messages.return_value = {
|
|
1616
|
+
"result": "success",
|
|
1617
|
+
"found_newest": True,
|
|
1618
|
+
"messages": [],
|
|
1619
|
+
}
|
|
1620
|
+
args = SimpleNamespace(
|
|
1621
|
+
stream="general",
|
|
1622
|
+
topic=None,
|
|
1623
|
+
unread=False,
|
|
1624
|
+
mark_as_read=True,
|
|
1625
|
+
)
|
|
1626
|
+
|
|
1627
|
+
read_command(client, args)
|
|
1628
|
+
|
|
1629
|
+
client.update_message_flags.assert_not_called()
|
|
1630
|
+
@
|
|
1631
|
+
|
|
1632
|
+
If printing fails midway, we should not mark even the earlier messages as
|
|
1633
|
+
read. The user has only seen a partial transcript.
|
|
1634
|
+
|
|
1635
|
+
<<test functions>>=
|
|
1636
|
+
def test_read_does_not_mark_messages_when_printing_fails(monkeypatch):
|
|
1637
|
+
client = MagicMock()
|
|
1638
|
+
client.get_messages.return_value = {
|
|
1639
|
+
"result": "success",
|
|
1640
|
+
"found_newest": True,
|
|
1641
|
+
"messages": [
|
|
1642
|
+
_make_stream_message(
|
|
1643
|
+
1,
|
|
1644
|
+
0,
|
|
1645
|
+
"Alice Admin",
|
|
1646
|
+
"Announcements",
|
|
1647
|
+
"First message",
|
|
1648
|
+
),
|
|
1649
|
+
_make_stream_message(
|
|
1650
|
+
2,
|
|
1651
|
+
60,
|
|
1652
|
+
"Bob Builder",
|
|
1653
|
+
"Announcements",
|
|
1654
|
+
"Second message",
|
|
1655
|
+
),
|
|
1656
|
+
],
|
|
1657
|
+
}
|
|
1658
|
+
args = SimpleNamespace(
|
|
1659
|
+
stream="general",
|
|
1660
|
+
topic=None,
|
|
1661
|
+
unread=False,
|
|
1662
|
+
mark_as_read=True,
|
|
1663
|
+
)
|
|
1664
|
+
seen = []
|
|
1665
|
+
|
|
1666
|
+
def print_message(message):
|
|
1667
|
+
seen.append(message["id"])
|
|
1668
|
+
if message["id"] == 2:
|
|
1669
|
+
raise UnicodeEncodeError(
|
|
1670
|
+
"ascii",
|
|
1671
|
+
"å",
|
|
1672
|
+
0,
|
|
1673
|
+
1,
|
|
1674
|
+
"ordinal not in range(128)",
|
|
1675
|
+
)
|
|
1676
|
+
|
|
1677
|
+
monkeypatch.setattr(cli, "_print_read_message", print_message)
|
|
1678
|
+
|
|
1679
|
+
with pytest.raises(UnicodeEncodeError):
|
|
1680
|
+
read_command(client, args)
|
|
1681
|
+
|
|
1682
|
+
assert seen == [1, 2]
|
|
1683
|
+
client.update_message_flags.assert_not_called()
|
|
1684
|
+
@
|
|
1685
|
+
|
|
1214
1686
|
We can also verify that topic reads keep both names in the narrow and
|
|
1215
1687
|
advance the anchor when the history spans more than one API page.
|
|
1216
1688
|
|
|
@@ -1255,6 +1727,8 @@ def test_read_topic_pages_through_history(capsys, monkeypatch):
|
|
|
1255
1727
|
args = SimpleNamespace(
|
|
1256
1728
|
stream="general",
|
|
1257
1729
|
topic="release planning",
|
|
1730
|
+
unread=False,
|
|
1731
|
+
mark_as_read=False,
|
|
1258
1732
|
)
|
|
1259
1733
|
monkeypatch.setattr(cli, "_stdout_is_tty", lambda: False)
|
|
1260
1734
|
read_command(client, args)
|
|
@@ -1291,7 +1765,12 @@ def test_read_reports_history_errors(capsys):
|
|
|
1291
1765
|
"result": "error",
|
|
1292
1766
|
"msg": "Invalid narrow operator",
|
|
1293
1767
|
}
|
|
1294
|
-
args = SimpleNamespace(
|
|
1768
|
+
args = SimpleNamespace(
|
|
1769
|
+
stream="general",
|
|
1770
|
+
topic=None,
|
|
1771
|
+
unread=False,
|
|
1772
|
+
mark_as_read=False,
|
|
1773
|
+
)
|
|
1295
1774
|
|
|
1296
1775
|
with pytest.raises(SystemExit) as excinfo:
|
|
1297
1776
|
read_command(client, args)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|