exist-shell 0.1.0__py3-none-any.whl
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.
- exist_shell/__init__.py +5 -0
- exist_shell/cache.py +186 -0
- exist_shell/client/__init__.py +19 -0
- exist_shell/client/_base.py +65 -0
- exist_shell/client/_collections.py +158 -0
- exist_shell/client/_documents.py +134 -0
- exist_shell/client/_groups.py +116 -0
- exist_shell/client/_permissions.py +172 -0
- exist_shell/client/_queries.py +37 -0
- exist_shell/client/_users.py +158 -0
- exist_shell/commands/__init__.py +1 -0
- exist_shell/commands/cat.py +51 -0
- exist_shell/commands/chmod.py +236 -0
- exist_shell/commands/chown.py +121 -0
- exist_shell/commands/collection.py +189 -0
- exist_shell/commands/cp.py +142 -0
- exist_shell/commands/edit.py +85 -0
- exist_shell/commands/exec.py +65 -0
- exist_shell/commands/group.py +183 -0
- exist_shell/commands/ls.py +66 -0
- exist_shell/commands/mkdir.py +24 -0
- exist_shell/commands/mv.py +124 -0
- exist_shell/commands/put.py +63 -0
- exist_shell/commands/rm.py +23 -0
- exist_shell/commands/server.py +114 -0
- exist_shell/commands/sync.py +767 -0
- exist_shell/commands/user.py +300 -0
- exist_shell/completions.py +221 -0
- exist_shell/config.py +233 -0
- exist_shell/exceptions.py +68 -0
- exist_shell/main.py +64 -0
- exist_shell/models.py +61 -0
- exist_shell/utils.py +179 -0
- exist_shell/xquery.py +267 -0
- exist_shell-0.1.0.dist-info/METADATA +10 -0
- exist_shell-0.1.0.dist-info/RECORD +38 -0
- exist_shell-0.1.0.dist-info/WHEEL +4 -0
- exist_shell-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""User management commands (ls, add, rm, info, passwd)."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from exist_shell.client import ExistClient
|
|
8
|
+
from exist_shell.completions import server_at_completer, server_nick_completer, user_arg_completer
|
|
9
|
+
from exist_shell.config import Config
|
|
10
|
+
from exist_shell.exceptions import ExistAuthError, ExistConnectionError, ExistQueryError
|
|
11
|
+
from exist_shell.utils import parse_user_at_server
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Manage eXist-db users.", no_args_is_help=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _stdin_is_tty() -> bool:
|
|
17
|
+
"""Return True when stdin is connected to a real terminal."""
|
|
18
|
+
return sys.stdin.isatty()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _resolve_server(
|
|
22
|
+
config: Config,
|
|
23
|
+
inline_server: str | None,
|
|
24
|
+
flag_server: str | None,
|
|
25
|
+
) -> str:
|
|
26
|
+
"""Resolve the effective server nick from an inline @server and --server flag.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
config: The loaded configuration.
|
|
30
|
+
inline_server: Server nick extracted from a ``user@server`` or ``@server``
|
|
31
|
+
argument; ``None`` if no ``@`` suffix was present.
|
|
32
|
+
flag_server: Server nick provided via the ``--server`` option; ``None``
|
|
33
|
+
if the flag was omitted.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
The resolved server nick, guaranteed to exist in ``config.servers``.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
typer.Exit: If both sources conflict, no server can be determined, or
|
|
40
|
+
the resolved nick does not exist in the configuration.
|
|
41
|
+
"""
|
|
42
|
+
if not config.servers:
|
|
43
|
+
typer.echo("Error: no servers configured. Use 'exsh server add' first.", err=True)
|
|
44
|
+
raise typer.Exit(1)
|
|
45
|
+
if inline_server and flag_server:
|
|
46
|
+
typer.echo(
|
|
47
|
+
"Error: conflicting server specifications: use @server or --server, not both.",
|
|
48
|
+
err=True,
|
|
49
|
+
)
|
|
50
|
+
raise typer.Exit(1)
|
|
51
|
+
server = inline_server or flag_server
|
|
52
|
+
if server is None:
|
|
53
|
+
if len(config.servers) != 1:
|
|
54
|
+
typer.echo(
|
|
55
|
+
"Error: --server is required when multiple servers are configured.",
|
|
56
|
+
err=True,
|
|
57
|
+
)
|
|
58
|
+
raise typer.Exit(1)
|
|
59
|
+
server = next(iter(config.servers))
|
|
60
|
+
if server not in config.servers:
|
|
61
|
+
typer.echo(f"Error: server '{server}' not found.", err=True)
|
|
62
|
+
raise typer.Exit(1)
|
|
63
|
+
return server
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command("ls")
|
|
67
|
+
def user_ls(
|
|
68
|
+
at_server: str | None = typer.Argument(None, metavar="@SERVER", help="Server in @nick form (e.g. @prod).", autocompletion=server_at_completer),
|
|
69
|
+
server: str | None = typer.Option(None, "--server", help="Server nick to query.", autocompletion=server_nick_completer),
|
|
70
|
+
) -> None:
|
|
71
|
+
"""List all user accounts and their group memberships.
|
|
72
|
+
|
|
73
|
+
The server may be specified as a bare ``@nick`` positional argument
|
|
74
|
+
(e.g. ``user ls @prod``) or via ``--server``. When omitted and only one
|
|
75
|
+
server is configured it is selected automatically.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
at_server: Optional server nick in ``@nick`` form.
|
|
79
|
+
server: Server nick to query. Auto-selected when only one server is
|
|
80
|
+
configured; required when multiple servers are configured.
|
|
81
|
+
"""
|
|
82
|
+
inline_server: str | None = None
|
|
83
|
+
if at_server is not None:
|
|
84
|
+
if not at_server.startswith("@"):
|
|
85
|
+
typer.echo("Error: positional server argument must be in @nick form (e.g. @prod).", err=True)
|
|
86
|
+
raise typer.Exit(1)
|
|
87
|
+
inline_server = at_server[1:]
|
|
88
|
+
if not inline_server:
|
|
89
|
+
typer.echo("Error: server nick cannot be empty.", err=True)
|
|
90
|
+
raise typer.Exit(1)
|
|
91
|
+
|
|
92
|
+
config = Config.load()
|
|
93
|
+
resolved = _resolve_server(config, inline_server, server)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
with ExistClient(config.servers[resolved]) as client:
|
|
97
|
+
users = client.list_users()
|
|
98
|
+
except ExistAuthError:
|
|
99
|
+
typer.echo(f"Error: authentication failed for server '{resolved}'.", err=True)
|
|
100
|
+
raise typer.Exit(1)
|
|
101
|
+
except ExistConnectionError as e:
|
|
102
|
+
typer.echo(f"Error: {e}", err=True)
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
|
|
105
|
+
for u in users:
|
|
106
|
+
typer.echo(f"{u.username}\t{', '.join(u.groups)}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command("add")
|
|
110
|
+
def user_add(
|
|
111
|
+
username: str = typer.Argument(help="Account name to create, optionally as user@server.", autocompletion=user_arg_completer),
|
|
112
|
+
group: str = typer.Option("guest", "--group", help="Comma-separated group names. The first is the primary group."),
|
|
113
|
+
password: str | None = typer.Option(None, "--password", help="Password (prompted if omitted)."),
|
|
114
|
+
server: str | None = typer.Option(None, "--server", help="Server nick to target.", autocompletion=server_nick_completer),
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Create a new user account on the server.
|
|
117
|
+
|
|
118
|
+
The username may include an inline server nick using ``user@server``
|
|
119
|
+
syntax (e.g. ``alice@prod``). Prompts for a password when ``--password``
|
|
120
|
+
is not supplied so the credential is never written to the shell history.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
username: The new account name, optionally suffixed with ``@server_nick``.
|
|
124
|
+
group: Comma-separated group names. The first becomes the primary group.
|
|
125
|
+
Defaults to ``guest``.
|
|
126
|
+
password: Plaintext password. Prompted interactively when omitted.
|
|
127
|
+
server: Server nick to target. Auto-selected when only one server is
|
|
128
|
+
configured; required when multiple servers are configured.
|
|
129
|
+
"""
|
|
130
|
+
bare_username, inline_server = parse_user_at_server(username)
|
|
131
|
+
if not bare_username:
|
|
132
|
+
typer.echo("Error: username cannot be empty.", err=True)
|
|
133
|
+
raise typer.Exit(1)
|
|
134
|
+
|
|
135
|
+
config = Config.load()
|
|
136
|
+
resolved = _resolve_server(config, inline_server, server)
|
|
137
|
+
|
|
138
|
+
groups = [g.strip() for g in group.split(",") if g.strip()]
|
|
139
|
+
if not groups:
|
|
140
|
+
typer.echo("Error: at least one group is required.", err=True)
|
|
141
|
+
raise typer.Exit(1)
|
|
142
|
+
|
|
143
|
+
if password is None:
|
|
144
|
+
password = typer.prompt(f"Password for '{bare_username}'", hide_input=True, confirmation_prompt=True)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
with ExistClient(config.servers[resolved]) as client:
|
|
148
|
+
client.create_user(bare_username, password, groups)
|
|
149
|
+
except ExistAuthError:
|
|
150
|
+
typer.echo(f"Error: authentication failed for server '{resolved}'.", err=True)
|
|
151
|
+
raise typer.Exit(1)
|
|
152
|
+
except ExistConnectionError as e:
|
|
153
|
+
typer.echo(f"Error: {e}", err=True)
|
|
154
|
+
raise typer.Exit(1)
|
|
155
|
+
except ExistQueryError as e:
|
|
156
|
+
typer.echo(f"Error: {e}", err=True)
|
|
157
|
+
raise typer.Exit(1)
|
|
158
|
+
|
|
159
|
+
typer.echo(f"User '{bare_username}' created.")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.command("rm")
|
|
163
|
+
def user_rm(
|
|
164
|
+
username: str = typer.Argument(help="Account name to remove, optionally as user@server.", autocompletion=user_arg_completer),
|
|
165
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
|
|
166
|
+
server: str | None = typer.Option(None, "--server", help="Server nick to target.", autocompletion=server_nick_completer),
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Remove a user account from the server.
|
|
169
|
+
|
|
170
|
+
The username may include an inline server nick using ``user@server``
|
|
171
|
+
syntax (e.g. ``alice@prod``). Prompts for confirmation unless ``--yes``
|
|
172
|
+
is supplied.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
username: The account name to remove, optionally suffixed with ``@server_nick``.
|
|
176
|
+
yes: When True, skip the confirmation prompt.
|
|
177
|
+
server: Server nick to target. Auto-selected when only one server is
|
|
178
|
+
configured; required when multiple servers are configured.
|
|
179
|
+
"""
|
|
180
|
+
bare_username, inline_server = parse_user_at_server(username)
|
|
181
|
+
if not bare_username:
|
|
182
|
+
typer.echo("Error: username cannot be empty.", err=True)
|
|
183
|
+
raise typer.Exit(1)
|
|
184
|
+
|
|
185
|
+
config = Config.load()
|
|
186
|
+
resolved = _resolve_server(config, inline_server, server)
|
|
187
|
+
|
|
188
|
+
if not yes:
|
|
189
|
+
typer.confirm(f"Remove user '{bare_username}'?", abort=True)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
with ExistClient(config.servers[resolved]) as client:
|
|
193
|
+
client.delete_user(bare_username)
|
|
194
|
+
except ExistAuthError:
|
|
195
|
+
typer.echo(f"Error: authentication failed for server '{resolved}'.", err=True)
|
|
196
|
+
raise typer.Exit(1)
|
|
197
|
+
except ExistConnectionError as e:
|
|
198
|
+
typer.echo(f"Error: {e}", err=True)
|
|
199
|
+
raise typer.Exit(1)
|
|
200
|
+
except ExistQueryError as e:
|
|
201
|
+
typer.echo(f"Error: {e}", err=True)
|
|
202
|
+
raise typer.Exit(1)
|
|
203
|
+
|
|
204
|
+
typer.echo(f"User '{bare_username}' removed.")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@app.command("info")
|
|
208
|
+
def user_info(
|
|
209
|
+
username: str = typer.Argument(help="Account name to inspect, optionally as user@server.", autocompletion=user_arg_completer),
|
|
210
|
+
server: str | None = typer.Option(None, "--server", help="Server nick to query.", autocompletion=server_nick_completer),
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Show detailed information about a user account.
|
|
213
|
+
|
|
214
|
+
The username may include an inline server nick using ``user@server``
|
|
215
|
+
syntax (e.g. ``alice@prod``).
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
username: The account name to inspect, optionally suffixed with ``@server_nick``.
|
|
219
|
+
server: Server nick to query. Auto-selected when only one server is
|
|
220
|
+
configured; required when multiple servers are configured.
|
|
221
|
+
"""
|
|
222
|
+
bare_username, inline_server = parse_user_at_server(username)
|
|
223
|
+
if not bare_username:
|
|
224
|
+
typer.echo("Error: username cannot be empty.", err=True)
|
|
225
|
+
raise typer.Exit(1)
|
|
226
|
+
|
|
227
|
+
config = Config.load()
|
|
228
|
+
resolved = _resolve_server(config, inline_server, server)
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
with ExistClient(config.servers[resolved]) as client:
|
|
232
|
+
info = client.get_user(bare_username)
|
|
233
|
+
except ExistAuthError:
|
|
234
|
+
typer.echo(f"Error: authentication failed for server '{resolved}'.", err=True)
|
|
235
|
+
raise typer.Exit(1)
|
|
236
|
+
except ExistConnectionError as e:
|
|
237
|
+
typer.echo(f"Error: {e}", err=True)
|
|
238
|
+
raise typer.Exit(1)
|
|
239
|
+
except ExistQueryError as e:
|
|
240
|
+
typer.echo(f"Error: {e}", err=True)
|
|
241
|
+
raise typer.Exit(1)
|
|
242
|
+
|
|
243
|
+
typer.echo(f"Username: {info.username}")
|
|
244
|
+
if info.full_name:
|
|
245
|
+
typer.echo(f"Full name: {info.full_name}")
|
|
246
|
+
typer.echo(f"Groups: {', '.join(info.groups)}")
|
|
247
|
+
typer.echo(f"Enabled: {info.enabled}")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@app.command("passwd")
|
|
251
|
+
def user_passwd(
|
|
252
|
+
username: str = typer.Argument(help="Account name, optionally as user@server.", autocompletion=user_arg_completer),
|
|
253
|
+
from_stdin: bool = typer.Option(False, "--stdin", help="Read new password from stdin (for scripting)."),
|
|
254
|
+
server: str | None = typer.Option(None, "--server", help="Server nick to target.", autocompletion=server_nick_completer),
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Change a user's password on the server.
|
|
257
|
+
|
|
258
|
+
The username may include an inline server nick using ``user@server``
|
|
259
|
+
syntax (e.g. ``alice@prod``). Prompts for the new password interactively
|
|
260
|
+
(with confirmation) when running on a TTY. When stdin is not a TTY (pipe
|
|
261
|
+
or redirect), or when ``--stdin`` is supplied explicitly, the password is
|
|
262
|
+
read as a single line from standard input without a confirmation prompt —
|
|
263
|
+
suitable for piped automation. The password is never accepted on the
|
|
264
|
+
command line to avoid shell history exposure.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
username: The account name, optionally suffixed with ``@server_nick``.
|
|
268
|
+
from_stdin: When True, read the new password from stdin instead of
|
|
269
|
+
prompting interactively. Also activated automatically when stdin
|
|
270
|
+
is not connected to a TTY.
|
|
271
|
+
server: Server nick to target. Auto-selected when only one server is
|
|
272
|
+
configured; required when multiple servers are configured.
|
|
273
|
+
"""
|
|
274
|
+
bare_username, inline_server = parse_user_at_server(username)
|
|
275
|
+
if not bare_username:
|
|
276
|
+
typer.echo("Error: username cannot be empty.", err=True)
|
|
277
|
+
raise typer.Exit(1)
|
|
278
|
+
|
|
279
|
+
config = Config.load()
|
|
280
|
+
resolved = _resolve_server(config, inline_server, server)
|
|
281
|
+
|
|
282
|
+
if from_stdin or not _stdin_is_tty():
|
|
283
|
+
password = sys.stdin.readline().rstrip("\n")
|
|
284
|
+
else:
|
|
285
|
+
password = typer.prompt(f"New password for '{bare_username}'", hide_input=True, confirmation_prompt=True)
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
with ExistClient(config.servers[resolved]) as client:
|
|
289
|
+
client.change_password(bare_username, password)
|
|
290
|
+
except ExistAuthError:
|
|
291
|
+
typer.echo(f"Error: authentication failed for server '{resolved}'.", err=True)
|
|
292
|
+
raise typer.Exit(1)
|
|
293
|
+
except ExistConnectionError as e:
|
|
294
|
+
typer.echo(f"Error: {e}", err=True)
|
|
295
|
+
raise typer.Exit(1)
|
|
296
|
+
except ExistQueryError as e:
|
|
297
|
+
typer.echo(f"Error: {e}", err=True)
|
|
298
|
+
raise typer.Exit(1)
|
|
299
|
+
|
|
300
|
+
typer.echo(f"Password for '{bare_username}' updated.")
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Shell completion helpers for eXist collection and document paths."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from exist_shell.cache import get_cached, get_cached_groups, get_cached_users, set_cached, set_cached_groups, set_cached_users
|
|
6
|
+
from exist_shell.client import ExistClient
|
|
7
|
+
from exist_shell.config import Config
|
|
8
|
+
from exist_shell.models import CollectionEntry
|
|
9
|
+
|
|
10
|
+
Kind = Literal["any", "collection", "resource"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def collection_target_completer(kind: Kind = "any", *, allow_local: bool = False):
|
|
14
|
+
"""Return a completion function for ``<nick>:<path>`` arguments filtered by item kind.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
kind: Which item types to include — ``"collection"`` for subcollections
|
|
18
|
+
only, ``"resource"`` for documents only, ``"any"`` for both.
|
|
19
|
+
allow_local: When True and the incomplete string contains no ``:``,
|
|
20
|
+
return an empty list so the shell falls back to its default
|
|
21
|
+
filesystem completion. When False (default), offer collection
|
|
22
|
+
nick completions instead.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
A completion function compatible with Typer's ``autocompletion`` parameter.
|
|
26
|
+
"""
|
|
27
|
+
def _complete(incomplete: str) -> list[str]:
|
|
28
|
+
try:
|
|
29
|
+
config = Config.load()
|
|
30
|
+
except Exception:
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
if ":" not in incomplete:
|
|
34
|
+
if allow_local:
|
|
35
|
+
return []
|
|
36
|
+
return [f"{nick}:" for nick in config.collections if nick.startswith(incomplete)]
|
|
37
|
+
|
|
38
|
+
nick, partial_path = incomplete.split(":", 1)
|
|
39
|
+
if nick not in config.collections:
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
if not partial_path.startswith("/"):
|
|
43
|
+
partial_path = "/" + partial_path
|
|
44
|
+
|
|
45
|
+
last_slash = partial_path.rfind("/")
|
|
46
|
+
dir_path = partial_path[: last_slash + 1]
|
|
47
|
+
prefix = partial_path[last_slash + 1:]
|
|
48
|
+
|
|
49
|
+
collection = config.collections[nick]
|
|
50
|
+
server = config.servers[collection.server_nick]
|
|
51
|
+
full_dir = f"/db/{collection.name}{dir_path}"
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
items = get_cached(nick, dir_path)
|
|
55
|
+
if items is None:
|
|
56
|
+
with ExistClient(server) as client:
|
|
57
|
+
items = client.list_collection(full_dir)
|
|
58
|
+
set_cached(nick, dir_path, items)
|
|
59
|
+
except Exception:
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
results = []
|
|
63
|
+
for item in items:
|
|
64
|
+
is_col = isinstance(item, CollectionEntry)
|
|
65
|
+
if kind == "collection" and not is_col:
|
|
66
|
+
continue
|
|
67
|
+
if kind == "resource" and is_col:
|
|
68
|
+
continue
|
|
69
|
+
item_name = item.name + ("/" if is_col else "")
|
|
70
|
+
if item_name.startswith(prefix):
|
|
71
|
+
results.append(f"{nick}:{dir_path}{item_name}")
|
|
72
|
+
return results
|
|
73
|
+
|
|
74
|
+
return _complete
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def chown_spec_completer(incomplete: str) -> list[str]:
|
|
78
|
+
"""Complete an ``owner_spec`` argument for the ``chown`` command.
|
|
79
|
+
|
|
80
|
+
Accepts an optional ``server@`` prefix to select which server to query for
|
|
81
|
+
user and group names. All six forms are supported:
|
|
82
|
+
|
|
83
|
+
- ``user`` / ``:group`` / ``user:group`` — uses the first reachable server.
|
|
84
|
+
- ``server@user`` / ``server@:group`` / ``server@user:group`` — uses the
|
|
85
|
+
named server; the ``server@`` prefix is stripped by the command before
|
|
86
|
+
the ownership change is applied.
|
|
87
|
+
|
|
88
|
+
When no ``@`` is present and multiple servers are configured, server-nick
|
|
89
|
+
completions (``server@``) are also offered so the user can pin a server.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
incomplete: The partially typed owner spec.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
List of completion candidates.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
config = Config.load()
|
|
99
|
+
except Exception:
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
# --- resolve server and the "rest" to complete ----------------------------
|
|
103
|
+
server = None
|
|
104
|
+
resolved_nick = ""
|
|
105
|
+
prefix = "" # "server@" to prepend to every candidate
|
|
106
|
+
rest = incomplete
|
|
107
|
+
|
|
108
|
+
if "@" in incomplete:
|
|
109
|
+
server_nick, _, rest = incomplete.partition("@")
|
|
110
|
+
if server_nick in config.servers:
|
|
111
|
+
server = config.servers[server_nick]
|
|
112
|
+
resolved_nick = server_nick
|
|
113
|
+
prefix = f"{server_nick}@"
|
|
114
|
+
else:
|
|
115
|
+
# Still typing the server nick: offer matching "server@" completions.
|
|
116
|
+
return [f"{s}@" for s in config.servers if s.startswith(server_nick)]
|
|
117
|
+
else:
|
|
118
|
+
# Pick the first configured server as a default.
|
|
119
|
+
for nick, s in config.servers.items():
|
|
120
|
+
server = s
|
|
121
|
+
resolved_nick = nick
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
if server is None:
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
# --- fetch users / groups and build candidates ----------------------------
|
|
128
|
+
try:
|
|
129
|
+
if ":" in rest:
|
|
130
|
+
user_part, _, partial_group = rest.partition(":")
|
|
131
|
+
groups = get_cached_groups(resolved_nick)
|
|
132
|
+
if groups is None:
|
|
133
|
+
with ExistClient(server) as client:
|
|
134
|
+
groups = client.list_groups()
|
|
135
|
+
set_cached_groups(resolved_nick, groups)
|
|
136
|
+
return [
|
|
137
|
+
f"{prefix}{user_part}:{g.name}"
|
|
138
|
+
for g in groups
|
|
139
|
+
if g.name.startswith(partial_group)
|
|
140
|
+
]
|
|
141
|
+
else:
|
|
142
|
+
users = get_cached_users(resolved_nick)
|
|
143
|
+
if users is None:
|
|
144
|
+
with ExistClient(server) as client:
|
|
145
|
+
users = client.list_users()
|
|
146
|
+
set_cached_users(resolved_nick, users)
|
|
147
|
+
results = [
|
|
148
|
+
f"{prefix}{u.username}"
|
|
149
|
+
for u in users
|
|
150
|
+
if u.username.startswith(rest)
|
|
151
|
+
]
|
|
152
|
+
# When no server pin typed yet and multiple servers exist,
|
|
153
|
+
# also offer "server@" so the user can pick a specific server.
|
|
154
|
+
if not prefix and len(config.servers) > 1:
|
|
155
|
+
results += [f"{s}@" for s in config.servers if s.startswith(rest)]
|
|
156
|
+
return results
|
|
157
|
+
except Exception:
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def server_nick_completer(incomplete: str) -> list[str]:
|
|
162
|
+
"""Complete a ``--server`` option value from configured server nicks.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
incomplete: The partially typed server nick.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
List of server nicks that start with ``incomplete``.
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
config = Config.load()
|
|
172
|
+
except Exception:
|
|
173
|
+
return []
|
|
174
|
+
return [nick for nick in config.servers if nick.startswith(incomplete)]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def user_arg_completer(incomplete: str) -> list[str]:
|
|
178
|
+
"""Complete ``user@server`` arguments by offering server-profile suffixes.
|
|
179
|
+
|
|
180
|
+
Handles three forms:
|
|
181
|
+
|
|
182
|
+
- No ``@`` present: returns nothing (usernames are open-ended).
|
|
183
|
+
- ``alice@``: returns ``alice@<nick>`` for each server whose nick starts
|
|
184
|
+
with the text after ``@``.
|
|
185
|
+
- ``alice@prod``: already resolved, returns nothing.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
incomplete: The partially typed argument value.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
List of completion candidates.
|
|
192
|
+
"""
|
|
193
|
+
try:
|
|
194
|
+
config = Config.load()
|
|
195
|
+
except Exception:
|
|
196
|
+
return []
|
|
197
|
+
if "@" in incomplete:
|
|
198
|
+
user_part, _, server_part = incomplete.rpartition("@")
|
|
199
|
+
return [f"{user_part}@{nick}" for nick in config.servers if nick.startswith(server_part)]
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def server_at_completer(incomplete: str) -> list[str]:
|
|
204
|
+
"""Complete a bare ``@server`` argument (used by commands like ``user ls``).
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
incomplete: The partially typed argument value (e.g. ``@`` or ``@pr``).
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
List of ``@nick`` candidates whose nick starts with the text after ``@``.
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
config = Config.load()
|
|
214
|
+
except Exception:
|
|
215
|
+
return []
|
|
216
|
+
if incomplete.startswith("@"):
|
|
217
|
+
prefix = incomplete[1:]
|
|
218
|
+
return [f"@{nick}" for nick in config.servers if nick.startswith(prefix)]
|
|
219
|
+
if not incomplete:
|
|
220
|
+
return [f"@{nick}" for nick in config.servers]
|
|
221
|
+
return []
|