zulipcli 0.1__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.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Bosk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
zulipcli-0.1/PKG-INFO ADDED
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: zulipcli
3
+ Version: 0.1
4
+ Summary: Command-line interface for Zulip
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Author: Daniel Bosk
8
+ Author-email: daniel@bosk.se
9
+ Requires-Python: >=3.10
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Dist: zulip
17
+ Description-Content-Type: text/markdown
18
+
19
+ # zulipcli
zulipcli-0.1/README.md ADDED
@@ -0,0 +1 @@
1
+ # zulipcli
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "zulipcli"
3
+ version = "0.1"
4
+ description = "Command-line interface for Zulip"
5
+ authors = [
6
+ {name = "Daniel Bosk", email = "daniel@bosk.se"}
7
+ ]
8
+ license = "MIT"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "zulip"
13
+ ]
14
+
15
+ [project.scripts]
16
+ zulipcli = "zulipcli.cli:main"
17
+
18
+ [tool.poetry]
19
+ packages = [{include = "zulipcli", from = "src"}]
20
+ include = [
21
+ { path = "src/**/*.py", format = "wheel" }
22
+ ]
23
+ exclude = [
24
+ "*/**/.gitignore",
25
+ { path = "*/**/Makefile", format = "wheel" },
26
+ "*/**/ltxobj",
27
+ "*/**/.git"
28
+ ]
29
+
30
+ [build-system]
31
+ requires = ["poetry-core>=1.0.0"]
32
+ build-backend = "poetry.core.masonry.api"
33
+
34
+ [dependency-groups]
35
+ test = [
36
+ "pytest (>=9.0.2,<10.0.0)"
37
+ ]
@@ -0,0 +1,18 @@
1
+ MODULES+= __init__.py init.tex
2
+ MODULES+= cli.py cli.tex
3
+
4
+ .PHONY: all
5
+ all: ${MODULES}
6
+
7
+ __init__.py: init.nw
8
+ ${NOTANGLE.py}
9
+
10
+ cli.py: cli.nw
11
+ cli.tex: cli.nw
12
+
13
+ .PHONY: clean
14
+ clean:
15
+ ${RM} ${MODULES}
16
+
17
+ INCLUDE_MAKEFILES=../../makefiles
18
+ include ${INCLUDE_MAKEFILES}/noweb.mk
@@ -0,0 +1,494 @@
1
+ \chapter{The command-line interface}
2
+
3
+ The official Zulip CLI tools---[[zulip-api]] and [[zulip-send]]---cover
4
+ message sending and a handful of administrative operations, but lack
5
+ basic discovery commands.
6
+ When we need to send a direct message with [[zulip-send]], we must
7
+ already know the recipient's email address; there is no built-in way to
8
+ look it up.
9
+ Similarly, there is no command to list the available streams (channels).
10
+
11
+ The [[zulipcli]] command fills these gaps with three subcommands:
12
+ \begin{description}
13
+ \item[users] List users, optionally filtered by a name or email
14
+ pattern.
15
+ \item[send] Send a message to a stream or directly to a user.
16
+ \item[streams] List available streams.
17
+ \end{description}
18
+
19
+ We use the subcommand pattern familiar from Git and the
20
+ [[canvaslms]] tool: each subcommand registers itself on an argparse
21
+ subparser via an [[add_X_command]] function, and implements its logic
22
+ in an [[X_command]] function.
23
+
24
+
25
+ \section{Program structure}
26
+
27
+ <<[[cli.py]]>>=
28
+ """Command-line interface for Zulip."""
29
+
30
+ import argparse
31
+ import re
32
+ import sys
33
+
34
+ import zulipcli
35
+
36
+ <<functions>>
37
+
38
+ def main():
39
+ """Entry point for the zulipcli command."""
40
+ <<set up argument parser>>
41
+ <<parse arguments and dispatch>>
42
+ @
43
+
44
+
45
+ \section{Argument parsing}
46
+
47
+ The top-level parser accepts a \texttt{-{}-zuliprc} option to override the
48
+ default configuration file path.
49
+ Each subcommand then adds its own flags and positional arguments.
50
+
51
+ <<set up argument parser>>=
52
+ parser = argparse.ArgumentParser(
53
+ prog="zulipcli",
54
+ description="Command-line interface for Zulip"
55
+ )
56
+ parser.add_argument(
57
+ "--zuliprc",
58
+ default=None,
59
+ help="path to zuliprc config file (default: ~/.zuliprc)"
60
+ )
61
+ subparsers = parser.add_subparsers(dest="command")
62
+ <<register subcommands>>
63
+ @
64
+
65
+ We register all subcommands here.
66
+ Adding a new subcommand later means adding one line here and one
67
+ section below---the rest of the structure stays untouched.
68
+
69
+ <<register subcommands>>=
70
+ add_users_command(subparsers)
71
+ add_send_command(subparsers)
72
+ add_streams_command(subparsers)
73
+ @
74
+
75
+ After parsing, we create the Zulip client and dispatch to the
76
+ subcommand's handler function.
77
+ If no subcommand was given, we print the help text---this is more
78
+ useful than a cryptic error about a missing attribute.
79
+
80
+ <<parse arguments and dispatch>>=
81
+ args = parser.parse_args()
82
+ if not hasattr(args, "func") or args.func is None:
83
+ parser.print_help()
84
+ sys.exit(1)
85
+ client = zulipcli.get_client(args.zuliprc)
86
+ args.func(client, args)
87
+ @
88
+
89
+
90
+ \section{The \texttt{users} subcommand}
91
+ \label{sec:users}
92
+
93
+ This is the primary motivation for the tool.
94
+ The Zulip API method [[get_members]] returns a list of all users on the
95
+ server, each with fields like [[full_name]], [[email]], [[user_id]],
96
+ [[is_bot]], and [[is_active]].
97
+
98
+ We output users as tab-separated lines (name, email) so the output is
99
+ easy to pipe through standard Unix tools like [[grep]], [[cut]], and
100
+ [[sort]].
101
+ By default we exclude bots and deactivated users, since the common use
102
+ case is finding a human colleague's email address.
103
+
104
+ <<functions>>=
105
+ def add_users_command(subparsers):
106
+ """Register the users subcommand."""
107
+ p = subparsers.add_parser(
108
+ "users",
109
+ help="list users on the Zulip server"
110
+ )
111
+ p.add_argument(
112
+ "pattern",
113
+ nargs="?",
114
+ default=None,
115
+ help="filter users by name or email (regex)"
116
+ )
117
+ p.add_argument(
118
+ "--include-bots",
119
+ action="store_true",
120
+ default=False,
121
+ help="include bot accounts in output"
122
+ )
123
+ p.add_argument(
124
+ "--include-deactivated",
125
+ action="store_true",
126
+ default=False,
127
+ help="include deactivated accounts in output"
128
+ )
129
+ p.set_defaults(func=users_command)
130
+ @
131
+
132
+ The implementation fetches all members, applies the filters, and prints
133
+ each matching user.
134
+ We compile the pattern once (if given) and match it case-insensitively
135
+ against both the full name and the email address, so that
136
+ [[zulipcli users bosk]] finds both \enquote{Daniel Bosk} and
137
+ \enquote{dbosk@example.com}.
138
+
139
+ <<functions>>=
140
+ def users_command(client, args):
141
+ """List users, optionally filtered by pattern."""
142
+ result = client.get_members()
143
+ if result["result"] != "success":
144
+ print(f"Error: {result.get('msg', 'unknown error')}",
145
+ file=sys.stderr)
146
+ sys.exit(1)
147
+
148
+ if args.pattern:
149
+ pattern = re.compile(args.pattern, re.IGNORECASE)
150
+ else:
151
+ pattern = None
152
+
153
+ for member in sorted(result["members"],
154
+ key=lambda m: m["full_name"]):
155
+ <<skip filtered users>>
156
+ print(f"{member['full_name']}\t{member['email']}")
157
+ @
158
+
159
+ We skip users that don't match the active filters.
160
+ The order matters: we check the cheapest conditions first (boolean
161
+ flags) before the more expensive regex match.
162
+
163
+ <<skip filtered users>>=
164
+ if not args.include_bots and member.get("is_bot", False):
165
+ continue
166
+ if not args.include_deactivated \
167
+ and not member.get("is_active", True):
168
+ continue
169
+ if pattern and not (pattern.search(member["full_name"])
170
+ or pattern.search(member["email"])):
171
+ continue
172
+ @
173
+
174
+ Let's verify that the filtering logic works correctly.
175
+ We create mock API responses and check that bots and deactivated users
176
+ are excluded by default, and that the pattern filter matches against
177
+ both name and email fields.
178
+
179
+ <<test [[cli.py]]>>=
180
+ """Tests for zulipcli.cli."""
181
+ import argparse
182
+ import re
183
+
184
+ from unittest.mock import MagicMock
185
+
186
+ from zulipcli.cli import users_command
187
+
188
+ <<test functions>>
189
+ @
190
+
191
+ <<test functions>>=
192
+ def _make_members():
193
+ """Create a sample member list for testing."""
194
+ return {
195
+ "result": "success",
196
+ "members": [
197
+ {
198
+ "full_name": "Alice Admin",
199
+ "email": "alice@example.com",
200
+ "is_bot": False,
201
+ "is_active": True,
202
+ },
203
+ {
204
+ "full_name": "Bob Bot",
205
+ "email": "bob-bot@example.com",
206
+ "is_bot": True,
207
+ "is_active": True,
208
+ },
209
+ {
210
+ "full_name": "Charlie Inactive",
211
+ "email": "charlie@example.com",
212
+ "is_bot": False,
213
+ "is_active": False,
214
+ },
215
+ {
216
+ "full_name": "Diana Developer",
217
+ "email": "diana@example.com",
218
+ "is_bot": False,
219
+ "is_active": True,
220
+ },
221
+ ],
222
+ }
223
+ @
224
+
225
+ <<test functions>>=
226
+ def test_users_excludes_bots_by_default(capsys):
227
+ client = MagicMock()
228
+ client.get_members.return_value = _make_members()
229
+ args = argparse.Namespace(
230
+ pattern=None,
231
+ include_bots=False,
232
+ include_deactivated=False,
233
+ )
234
+ users_command(client, args)
235
+ output = capsys.readouterr().out
236
+ assert "Alice Admin" in output
237
+ assert "Bob Bot" not in output
238
+ @
239
+
240
+ <<test functions>>=
241
+ def test_users_excludes_deactivated_by_default(capsys):
242
+ client = MagicMock()
243
+ client.get_members.return_value = _make_members()
244
+ args = argparse.Namespace(
245
+ pattern=None,
246
+ include_bots=False,
247
+ include_deactivated=False,
248
+ )
249
+ users_command(client, args)
250
+ output = capsys.readouterr().out
251
+ assert "Alice Admin" in output
252
+ assert "Charlie Inactive" not in output
253
+ @
254
+
255
+ <<test functions>>=
256
+ def test_users_pattern_filters_by_name(capsys):
257
+ client = MagicMock()
258
+ client.get_members.return_value = _make_members()
259
+ args = argparse.Namespace(
260
+ pattern="diana",
261
+ include_bots=False,
262
+ include_deactivated=False,
263
+ )
264
+ users_command(client, args)
265
+ output = capsys.readouterr().out
266
+ assert "Diana Developer" in output
267
+ assert "Alice Admin" not in output
268
+ @
269
+
270
+ <<test functions>>=
271
+ def test_users_pattern_filters_by_email(capsys):
272
+ client = MagicMock()
273
+ client.get_members.return_value = _make_members()
274
+ args = argparse.Namespace(
275
+ pattern="alice@",
276
+ include_bots=False,
277
+ include_deactivated=False,
278
+ )
279
+ users_command(client, args)
280
+ output = capsys.readouterr().out
281
+ assert "Alice Admin" in output
282
+ assert "Diana Developer" not in output
283
+ @
284
+
285
+ <<test functions>>=
286
+ def test_users_include_bots(capsys):
287
+ client = MagicMock()
288
+ client.get_members.return_value = _make_members()
289
+ args = argparse.Namespace(
290
+ pattern=None,
291
+ include_bots=True,
292
+ include_deactivated=False,
293
+ )
294
+ users_command(client, args)
295
+ output = capsys.readouterr().out
296
+ assert "Bob Bot" in output
297
+ @
298
+
299
+
300
+ \section{The \texttt{send} subcommand}
301
+
302
+ The [[send]] subcommand wraps [[client.send_message()]].
303
+ It supports two modes: stream messages (requiring \texttt{-{}-stream}
304
+ and \texttt{-{}-topic}) and direct messages (requiring \texttt{-{}-to}).
305
+
306
+ We accept the message body either as a positional argument or from
307
+ standard input.
308
+ Reading from stdin is useful for piping output from other commands
309
+ directly into a Zulip message, which is a common scripting pattern.
310
+
311
+ <<functions>>=
312
+ def add_send_command(subparsers):
313
+ """Register the send subcommand."""
314
+ p = subparsers.add_parser(
315
+ "send",
316
+ help="send a message"
317
+ )
318
+ p.add_argument(
319
+ "--stream", "-s",
320
+ default=None,
321
+ help="destination stream (channel) name"
322
+ )
323
+ p.add_argument(
324
+ "--topic", "-t",
325
+ default=None,
326
+ help="topic within the stream"
327
+ )
328
+ p.add_argument(
329
+ "--to",
330
+ default=None,
331
+ help="recipient email for direct messages"
332
+ )
333
+ p.add_argument(
334
+ "message",
335
+ nargs="?",
336
+ default=None,
337
+ help="message content (reads stdin if omitted)"
338
+ )
339
+ p.set_defaults(func=send_command)
340
+ @
341
+
342
+ The Zulip API distinguishes between [[stream]] messages (sent to a
343
+ channel and topic) and [[direct]] messages (sent to one or more users
344
+ by email).
345
+ We require exactly one of these two modes and report an error if the
346
+ user provides neither or both.
347
+
348
+ <<functions>>=
349
+ def send_command(client, args):
350
+ """Send a message to a stream or directly to a user."""
351
+ content = args.message
352
+ if content is None:
353
+ content = sys.stdin.read()
354
+
355
+ if args.stream:
356
+ <<send stream message>>
357
+ elif args.to:
358
+ <<send direct message>>
359
+ else:
360
+ print("Error: specify --stream/--topic or --to",
361
+ file=sys.stderr)
362
+ sys.exit(1)
363
+ @
364
+
365
+ For stream messages, a topic is required---Zulip enforces this on the
366
+ server side, but we catch it early to give a clearer error message.
367
+
368
+ <<send stream message>>=
369
+ if not args.topic:
370
+ print("Error: --topic is required with --stream",
371
+ file=sys.stderr)
372
+ sys.exit(1)
373
+ request = {
374
+ "type": "stream",
375
+ "to": args.stream,
376
+ "topic": args.topic,
377
+ "content": content,
378
+ }
379
+ result = client.send_message(request)
380
+ if result["result"] != "success":
381
+ print(f"Error: {result.get('msg', 'unknown error')}",
382
+ file=sys.stderr)
383
+ sys.exit(1)
384
+ @
385
+
386
+ Direct messages use the [[direct]] type (called [[private]] in older
387
+ API versions).
388
+ The [[to]] field accepts a list of email addresses; we support a
389
+ single recipient for simplicity.
390
+
391
+ <<send direct message>>=
392
+ request = {
393
+ "type": "direct",
394
+ "to": [args.to],
395
+ "content": content,
396
+ }
397
+ result = client.send_message(request)
398
+ if result["result"] != "success":
399
+ print(f"Error: {result.get('msg', 'unknown error')}",
400
+ file=sys.stderr)
401
+ sys.exit(1)
402
+ @
403
+
404
+
405
+ \section{The \texttt{streams} subcommand}
406
+
407
+ The [[streams]] subcommand lists all streams (channels) on the server.
408
+ This is useful for discovering where to send messages---particularly
409
+ on a server with many streams where the web interface's sidebar
410
+ becomes unwieldy.
411
+
412
+ <<functions>>=
413
+ def add_streams_command(subparsers):
414
+ """Register the streams subcommand."""
415
+ p = subparsers.add_parser(
416
+ "streams",
417
+ help="list available streams (channels)"
418
+ )
419
+ p.add_argument(
420
+ "pattern",
421
+ nargs="?",
422
+ default=None,
423
+ help="filter streams by name (regex)"
424
+ )
425
+ p.set_defaults(func=streams_command)
426
+ @
427
+
428
+ Like the [[users]] command, we support an optional regex pattern for
429
+ filtering and sort the output alphabetically for readability.
430
+
431
+ <<functions>>=
432
+ def streams_command(client, args):
433
+ """List available streams."""
434
+ result = client.get_streams()
435
+ if result["result"] != "success":
436
+ print(f"Error: {result.get('msg', 'unknown error')}",
437
+ file=sys.stderr)
438
+ sys.exit(1)
439
+
440
+ if args.pattern:
441
+ pattern = re.compile(args.pattern, re.IGNORECASE)
442
+ else:
443
+ pattern = None
444
+
445
+ for stream in sorted(result["streams"],
446
+ key=lambda s: s["name"]):
447
+ if pattern and not pattern.search(stream["name"]):
448
+ continue
449
+ print(stream["name"])
450
+ @
451
+
452
+ Let's verify the streams command filters correctly.
453
+
454
+ <<test functions>>=
455
+ def test_streams_lists_all(capsys):
456
+ from zulipcli.cli import streams_command
457
+
458
+ client = MagicMock()
459
+ client.get_streams.return_value = {
460
+ "result": "success",
461
+ "streams": [
462
+ {"name": "general"},
463
+ {"name": "random"},
464
+ {"name": "announcements"},
465
+ ],
466
+ }
467
+ args = argparse.Namespace(pattern=None)
468
+ streams_command(client, args)
469
+ output = capsys.readouterr().out
470
+ lines = output.strip().split("\n")
471
+ assert lines == ["announcements", "general", "random"]
472
+ @
473
+
474
+ <<test functions>>=
475
+ def test_streams_filters_by_pattern(capsys):
476
+ from zulipcli.cli import streams_command
477
+
478
+ client = MagicMock()
479
+ client.get_streams.return_value = {
480
+ "result": "success",
481
+ "streams": [
482
+ {"name": "general"},
483
+ {"name": "random"},
484
+ {"name": "announcements"},
485
+ ],
486
+ }
487
+ args = argparse.Namespace(pattern="an")
488
+ streams_command(client, args)
489
+ output = capsys.readouterr().out
490
+ lines = output.strip().split("\n")
491
+ assert "random" in lines
492
+ assert "announcements" in lines
493
+ assert "general" not in lines
494
+ @
@@ -0,0 +1,59 @@
1
+ \chapter{The zulipcli package}
2
+
3
+ The Zulip Python API provides a powerful [[Client]] class for interacting
4
+ with a Zulip server, but the official command-line tools ([[zulip-api]],
5
+ [[zulip-send]]) expose only a fraction of its capabilities.
6
+ Most notably, there is no way to list users from the command line---which
7
+ makes it hard to discover the email addresses that [[zulip-send]] requires.
8
+
9
+ This package wraps the Zulip client and provides a more complete
10
+ command-line interface.
11
+ The package itself exposes a single factory function, [[get_client]],
12
+ so that both the CLI and any other Python code can obtain a configured
13
+ client without duplicating the setup logic.
14
+
15
+ \section{Package structure}
16
+
17
+ <<[[__init__.py]]>>=
18
+ """Zulip API client wrapper for the zulipcli package."""
19
+
20
+ <<imports>>
21
+ <<constants>>
22
+ <<functions>>
23
+ @
24
+
25
+ \section{Client creation}
26
+
27
+ The Zulip client reads credentials from a \texttt{zuliprc} file,
28
+ typically located at \texttt{\~{}/.zuliprc}.
29
+ We default to that path but allow callers to override it---the CLI
30
+ uses this to support its \texttt{-{}-zuliprc} flag.
31
+
32
+ We chose not to cache the client as a module-level singleton because
33
+ the CLI only creates one client per invocation anyway, and caching
34
+ would complicate testing (stale state between test runs).
35
+
36
+ <<imports>>=
37
+ import os
38
+ import zulip
39
+ @
40
+
41
+ <<constants>>=
42
+ DEFAULT_ZULIPRC = os.path.expanduser("~/.zuliprc")
43
+ @
44
+
45
+ <<functions>>=
46
+ def get_client(zuliprc=None):
47
+ """Create and return a configured Zulip API client.
48
+
49
+ Args:
50
+ zuliprc: Path to a zuliprc configuration file.
51
+ Defaults to ``~/.zuliprc``.
52
+
53
+ Returns:
54
+ A ``zulip.Client`` instance ready for API calls.
55
+ """
56
+ if zuliprc is None:
57
+ zuliprc = DEFAULT_ZULIPRC
58
+ return zulip.Client(config_file=zuliprc)
59
+ @