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 +21 -0
- zulipcli-0.1/PKG-INFO +19 -0
- zulipcli-0.1/README.md +1 -0
- zulipcli-0.1/pyproject.toml +37 -0
- zulipcli-0.1/src/zulipcli/Makefile +18 -0
- zulipcli-0.1/src/zulipcli/cli.nw +494 -0
- zulipcli-0.1/src/zulipcli/init.nw +59 -0
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
|
+
@
|