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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zulipcli
3
- Version: 0.3
3
+ Version: 0.4
4
4
  Summary: Command-line interface for Zulip
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "zulipcli"
3
- version = "0.3"
3
+ version = "0.4"
4
4
  description = "Command-line interface for Zulip"
5
5
  authors = [
6
6
  {name = "Daniel Bosk", email = "daniel@bosk.se"}
@@ -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 top-level Typer application accepts a \texttt{-{}-zuliprc} option to
60
- override the default configuration file path.
61
- The callback stores that override in the root context, so each
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="Command-line interface for Zulip",
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.obj = {
88
- "zuliprc": None if zuliprc is None else str(zuliprc),
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
- root_ctx = ctx.find_root()
96
- obj = root_ctx.obj or {}
97
- return zulipcli.get_client(obj.get("zuliprc"))
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
- \section{Reporting Zulip API errors}
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
- The ordinary commands should all report Zulip API failures in the same
104
- way: print the server's message on stderr and exit with status [[1]].
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 top-level
139
- [[--zuliprc]] override, and the active command context already contains
140
- the selected stream for [[read TOPIC]] and [[send --topic TOPIC]].
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(zuliprc)
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 that the Typer application still shows help when invoked
253
- without a subcommand and that the shared [[--zuliprc]] option reaches
254
- both commands and completion callbacks.
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
- params={"zuliprc": "/tmp/custom.zuliprc"},
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
- @app.command("users")
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
- @app.command("send")
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
- @app.command("streams")
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
- @app.command("topics")
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
- @app.command("read")
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(stream=stream, topic=topic),
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(stream="general", topic=None)
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(stream="general", topic=None)
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(stream="general", topic=None)
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