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.
@@ -0,0 +1,172 @@
1
+ """Permissions mixin — ownership and mode operations."""
2
+
3
+ import xml.etree.ElementTree as ET
4
+
5
+ from exist_shell.client._queries import QueryMixin
6
+ from exist_shell.utils import xq_escape
7
+
8
+
9
+ def _mode_str_to_int(mode_str: str) -> int:
10
+ """Convert a POSIX mode string to an integer mode value.
11
+
12
+ Handles 9-character strings (``"rwxr-xr-x"``) as well as 10-character
13
+ strings with a leading type character (``"drwxr-xr-x"``). Special bits
14
+ (setuid ``s/S``, setgid ``s/S``, sticky ``t/T``) are decoded correctly.
15
+
16
+ Args:
17
+ mode_str: Mode string to convert.
18
+
19
+ Returns:
20
+ Integer mode value (0–0o7777).
21
+ """
22
+ if len(mode_str) >= 10:
23
+ mode_str = mode_str[1:]
24
+ if len(mode_str) < 9:
25
+ return 0
26
+ mode_str = mode_str[:9]
27
+ value = 0
28
+ # user
29
+ if mode_str[0] == "r":
30
+ value |= 0o400
31
+ if mode_str[1] == "w":
32
+ value |= 0o200
33
+ if mode_str[2] in ("x", "s"):
34
+ value |= 0o100
35
+ if mode_str[2] in ("s", "S"):
36
+ value |= 0o4000
37
+ # group
38
+ if mode_str[3] == "r":
39
+ value |= 0o040
40
+ if mode_str[4] == "w":
41
+ value |= 0o020
42
+ if mode_str[5] in ("x", "s"):
43
+ value |= 0o010
44
+ if mode_str[5] in ("s", "S"):
45
+ value |= 0o2000
46
+ # other
47
+ if mode_str[6] == "r":
48
+ value |= 0o004
49
+ if mode_str[7] == "w":
50
+ value |= 0o002
51
+ if mode_str[8] in ("x", "t"):
52
+ value |= 0o001
53
+ if mode_str[8] in ("t", "T"):
54
+ value |= 0o1000
55
+ return value
56
+
57
+
58
+ def _int_to_mode_str(mode: int) -> str:
59
+ """Convert an integer mode value to a 9-character POSIX mode string.
60
+
61
+ Args:
62
+ mode: Integer mode value (0–0o7777).
63
+
64
+ Returns:
65
+ 9-character string like ``"rwxr-xr-x"``.
66
+ """
67
+ chars: list[str] = []
68
+ # user
69
+ chars.append("r" if mode & 0o400 else "-")
70
+ chars.append("w" if mode & 0o200 else "-")
71
+ if mode & 0o4000:
72
+ chars.append("s" if mode & 0o100 else "S")
73
+ else:
74
+ chars.append("x" if mode & 0o100 else "-")
75
+ # group
76
+ chars.append("r" if mode & 0o040 else "-")
77
+ chars.append("w" if mode & 0o020 else "-")
78
+ if mode & 0o2000:
79
+ chars.append("s" if mode & 0o010 else "S")
80
+ else:
81
+ chars.append("x" if mode & 0o010 else "-")
82
+ # other
83
+ chars.append("r" if mode & 0o004 else "-")
84
+ chars.append("w" if mode & 0o002 else "-")
85
+ if mode & 0o1000:
86
+ chars.append("t" if mode & 0o001 else "T")
87
+ else:
88
+ chars.append("x" if mode & 0o001 else "-")
89
+ return "".join(chars)
90
+
91
+
92
+ class PermissionMixin(QueryMixin):
93
+ """Mixin providing ownership and mode operations against the eXist REST API."""
94
+
95
+ def chown_resource(self, path: str, owner: str | None, group: str | None) -> None:
96
+ """Change the owner and/or group of a document or collection.
97
+
98
+ Validates that the specified owner and group exist on the server before
99
+ applying the change, in a single round-trip. Either ``owner`` or
100
+ ``group`` (or both) must be non-``None``.
101
+
102
+ Args:
103
+ path: Full eXist path starting with ``/db/``.
104
+ owner: New owner username, or ``None`` to leave unchanged.
105
+ group: New group name, or ``None`` to leave unchanged.
106
+
107
+ Raises:
108
+ ExistConnectionError: If the server cannot be reached.
109
+ ExistAuthError: If the server returns HTTP 401.
110
+ ExistQueryError: If the user or group does not exist, the path is
111
+ not found, permission is denied, or the query otherwise fails.
112
+ """
113
+ safe_path = xq_escape(path)
114
+ validation: list[str] = []
115
+ changes: list[str] = []
116
+
117
+ if owner:
118
+ s = xq_escape(owner)
119
+ validation.append(
120
+ f'if (not(sm:user-exists("{s}"))) then error((), "User not found: {s}") else ()'
121
+ )
122
+ changes.append(f'sm:chown(xs:anyURI("{safe_path}"), "{s}")')
123
+
124
+ if group:
125
+ s = xq_escape(group)
126
+ validation.append(
127
+ f'if (not(sm:group-exists("{s}"))) then error((), "Group not found: {s}") else ()'
128
+ )
129
+ changes.append(f'sm:chgrp(xs:anyURI("{safe_path}"), "{s}")')
130
+
131
+ clauses = validation + changes
132
+ query = 'xquery version "3.1"; (' + ", ".join(clauses) + ", ())"
133
+ self.execute_query(query)
134
+
135
+ def get_permissions(self, path: str) -> int:
136
+ """Return the POSIX mode bits for a document or collection as an integer.
137
+
138
+ Args:
139
+ path: Full eXist path starting with ``/db/``.
140
+
141
+ Returns:
142
+ Integer mode value (0–0o7777).
143
+
144
+ Raises:
145
+ ExistConnectionError: If the server cannot be reached.
146
+ ExistAuthError: If the server returns HTTP 401.
147
+ ExistQueryError: If the path does not exist or the query fails.
148
+ """
149
+ safe_path = xq_escape(path)
150
+ query = f'xquery version "3.1"; sm:get-permissions(xs:anyURI("{safe_path}"))'
151
+ raw = self.execute_query(query)
152
+ el = ET.fromstring(raw)
153
+ mode_str = el.get("mode", "---------")
154
+ return _mode_str_to_int(mode_str)
155
+
156
+ def chmod_resource(self, path: str, mode: int) -> None:
157
+ """Change the POSIX mode of a document or collection.
158
+
159
+ Args:
160
+ path: Full eXist path starting with ``/db/``.
161
+ mode: New mode as an integer (0–0o7777).
162
+
163
+ Raises:
164
+ ExistConnectionError: If the server cannot be reached.
165
+ ExistAuthError: If the server returns HTTP 401.
166
+ ExistQueryError: If the path does not exist, permission is denied,
167
+ or the query otherwise fails.
168
+ """
169
+ safe_path = xq_escape(path)
170
+ mode_str = _int_to_mode_str(mode)
171
+ query = f'xquery version "3.1"; sm:chmod(xs:anyURI("{safe_path}"), "{mode_str}")'
172
+ self.execute_query(query)
@@ -0,0 +1,37 @@
1
+ """Query mixin — XQuery execution."""
2
+
3
+ import httpx
4
+
5
+ from exist_shell.client._base import ExistClientBase
6
+ from exist_shell.exceptions import ExistAuthError, ExistConnectionError, ExistQueryError
7
+
8
+
9
+ class QueryMixin(ExistClientBase):
10
+ """Mixin providing XQuery execution against the eXist REST API."""
11
+
12
+ def execute_query(self, query: str, context: str = "/db") -> str:
13
+ """Execute an XQuery string and return the raw response body.
14
+
15
+ Args:
16
+ query: XQuery source code to execute.
17
+ context: The eXist collection path used as the query context.
18
+
19
+ Returns:
20
+ Raw response text from the server.
21
+
22
+ Raises:
23
+ ExistConnectionError: If the server cannot be reached.
24
+ ExistAuthError: If the server returns HTTP 401.
25
+ ExistQueryError: If the server returns HTTP 400 or 500 (query error).
26
+ """
27
+ url = self._url(context)
28
+ try:
29
+ r = self._http.post(url, data={"_query": query, "_wrap": "no"})
30
+ except httpx.RequestError as e:
31
+ raise ExistConnectionError(url, e) from e
32
+ if r.status_code == 401:
33
+ raise ExistAuthError(url)
34
+ if r.status_code in (400, 500):
35
+ raise ExistQueryError(r.text.strip())
36
+ r.raise_for_status()
37
+ return r.text
@@ -0,0 +1,158 @@
1
+ """Users mixin — user account operations."""
2
+
3
+ import xml.etree.ElementTree as ET
4
+
5
+ from exist_shell.client._queries import QueryMixin
6
+ from exist_shell.models import UserEntry, UserInfo
7
+ from exist_shell.utils import xq_escape
8
+
9
+
10
+ class UserMixin(QueryMixin):
11
+ """Mixin providing user account operations against the eXist REST API."""
12
+
13
+ def list_users(self) -> list[UserEntry]:
14
+ """List all user accounts and their group memberships.
15
+
16
+ Returns:
17
+ List of UserEntry objects sorted alphabetically by username.
18
+
19
+ Raises:
20
+ ExistConnectionError: If the server cannot be reached.
21
+ ExistAuthError: If the server returns HTTP 401.
22
+ ExistQueryError: If the XQuery fails.
23
+ """
24
+ query = (
25
+ 'xquery version "3.1"; '
26
+ '<users>{ '
27
+ 'for $u in sm:list-users() '
28
+ 'let $groups := sm:get-user-groups($u) '
29
+ 'order by $u '
30
+ 'return <user name="{$u}" groups="{string-join($groups, ",")}"/> '
31
+ '}</users>'
32
+ )
33
+ raw = self.execute_query(query)
34
+ root = ET.fromstring(raw)
35
+ result: list[UserEntry] = []
36
+ for el in root.findall("user"):
37
+ groups_str = el.get("groups", "")
38
+ groups = [g for g in groups_str.split(",") if g]
39
+ result.append(UserEntry(username=el.get("name", ""), groups=groups))
40
+ return result
41
+
42
+ def get_user(self, username: str) -> UserInfo:
43
+ """Get detailed information about a single user account.
44
+
45
+ Args:
46
+ username: The account name to look up.
47
+
48
+ Returns:
49
+ UserInfo with the account's username, full name, group memberships,
50
+ and whether the account is enabled.
51
+
52
+ Raises:
53
+ ExistConnectionError: If the server cannot be reached.
54
+ ExistAuthError: If the server returns HTTP 401.
55
+ ExistQueryError: If the user does not exist or the query fails.
56
+ """
57
+ safe = xq_escape(username)
58
+ query = (
59
+ 'xquery version "3.1"; '
60
+ f'if (not(sm:user-exists("{safe}"))) '
61
+ f'then error((), "User not found: {safe}") '
62
+ f'else '
63
+ f'let $groups := sm:get-user-groups("{safe}") '
64
+ f'let $enabled := sm:is-account-enabled("{safe}") '
65
+ f'let $fullname := (sm:get-account-metadata("{safe}", xs:anyURI("http://axschema.org/namePerson")), "")[1] '
66
+ f'return <user '
67
+ f'name="{safe}" '
68
+ f'fullname="{{$fullname}}" '
69
+ f'enabled="{{$enabled}}" '
70
+ f'groups="{{string-join($groups, \',\')}}" />'
71
+ )
72
+ raw = self.execute_query(query)
73
+ el = ET.fromstring(raw)
74
+ groups_str = el.get("groups", "")
75
+ groups = [g for g in groups_str.split(",") if g]
76
+ return UserInfo(
77
+ username=el.get("name", username),
78
+ full_name=el.get("fullname") or None,
79
+ groups=groups,
80
+ enabled=(el.get("enabled", "true").lower() == "true"),
81
+ )
82
+
83
+ def user_exists(self, username: str) -> bool:
84
+ """Check whether a user account exists on the server.
85
+
86
+ Args:
87
+ username: The account name to check.
88
+
89
+ Returns:
90
+ True if the account exists, False otherwise.
91
+
92
+ Raises:
93
+ ExistConnectionError: If the server cannot be reached.
94
+ ExistAuthError: If the server returns HTTP 401.
95
+ ExistQueryError: If the query fails.
96
+ """
97
+ safe = xq_escape(username)
98
+ query = f'xquery version "3.1"; sm:user-exists("{safe}")'
99
+ return self.execute_query(query).strip() == "true"
100
+
101
+ def create_user(self, username: str, password: str, groups: list[str]) -> None:
102
+ """Create a new user account.
103
+
104
+ The first entry in groups becomes the primary group.
105
+
106
+ Args:
107
+ username: The new account name.
108
+ password: The plaintext password.
109
+ groups: One or more group names; the first is the primary group.
110
+
111
+ Raises:
112
+ ExistConnectionError: If the server cannot be reached.
113
+ ExistAuthError: If the server returns HTTP 401.
114
+ ExistQueryError: If the account already exists or the query fails.
115
+ """
116
+ safe_user = xq_escape(username)
117
+ safe_pass = xq_escape(password)
118
+ groups_xq = ", ".join(f'"{xq_escape(g)}"' for g in groups)
119
+ query = (
120
+ 'xquery version "3.1"; '
121
+ f'sm:create-account("{safe_user}", "{safe_pass}", ({groups_xq}))'
122
+ )
123
+ self.execute_query(query)
124
+
125
+ def delete_user(self, username: str) -> None:
126
+ """Remove a user account from the server.
127
+
128
+ Args:
129
+ username: The account name to remove.
130
+
131
+ Raises:
132
+ ExistConnectionError: If the server cannot be reached.
133
+ ExistAuthError: If the server returns HTTP 401.
134
+ ExistQueryError: If the user does not exist or the query fails.
135
+ """
136
+ safe = xq_escape(username)
137
+ query = f'xquery version "3.1"; sm:remove-account("{safe}")'
138
+ self.execute_query(query)
139
+
140
+ def change_password(self, username: str, password: str) -> None:
141
+ """Change the password of an existing user account.
142
+
143
+ Args:
144
+ username: The account name.
145
+ password: The new plaintext password.
146
+
147
+ Raises:
148
+ ExistConnectionError: If the server cannot be reached.
149
+ ExistAuthError: If the server returns HTTP 401.
150
+ ExistQueryError: If the user does not exist or the query fails.
151
+ """
152
+ safe_user = xq_escape(username)
153
+ safe_pass = xq_escape(password)
154
+ query = (
155
+ 'xquery version "3.1"; '
156
+ f'sm:passwd("{safe_user}", "{safe_pass}")'
157
+ )
158
+ self.execute_query(query)
@@ -0,0 +1 @@
1
+ """CLI subcommands for exsh."""
@@ -0,0 +1,51 @@
1
+ """cat command — print document content from an eXist collection path."""
2
+
3
+ import sys
4
+
5
+ import typer
6
+
7
+ from exist_shell.client import ExistClient
8
+ from exist_shell.completions import collection_target_completer
9
+ from exist_shell.utils import handle_exist_errors, parse_target, resolve_collection
10
+
11
+ _TEXT_TYPES = {"application/xml", "application/json", "application/javascript"}
12
+
13
+
14
+ def _is_text(mime_type: str) -> bool:
15
+ """Return True if the MIME type should be treated as human-readable text.
16
+
17
+ Args:
18
+ mime_type: The MIME type string (without parameters).
19
+
20
+ Returns:
21
+ True if the content is printable text, False if binary.
22
+ """
23
+ return mime_type.startswith("text/") or mime_type in _TEXT_TYPES
24
+
25
+
26
+ def cat(
27
+ target: str = typer.Argument(
28
+ help="Collection and document path: <nick>:<path>.",
29
+ autocompletion=collection_target_completer("resource"),
30
+ ),
31
+ raw: bool = typer.Option(False, "--raw", help="Write raw bytes to stdout even for binary content."),
32
+ ) -> None:
33
+ """Print the content of a document to stdout."""
34
+ nick, path = parse_target(target)
35
+ collection, server, full_path = resolve_collection(nick, path)
36
+
37
+ with handle_exist_errors(path, nick, collection.server_nick):
38
+ with ExistClient(server) as client:
39
+ result = client.get_document(full_path)
40
+
41
+ if not raw and not _is_text(result.mime_type):
42
+ typer.echo(
43
+ f"Error: '{path}' is binary ({result.mime_type}). Use --raw to write bytes to stdout.",
44
+ err=True,
45
+ )
46
+ raise typer.Exit(1)
47
+
48
+ if raw:
49
+ sys.stdout.buffer.write(result.content)
50
+ else:
51
+ sys.stdout.write(result.content.decode())
@@ -0,0 +1,236 @@
1
+ """chmod command — change POSIX permissions on a document or collection."""
2
+
3
+ import re
4
+ from collections.abc import Callable
5
+
6
+ import typer
7
+
8
+ from exist_shell.client import ExistClient
9
+ from exist_shell.completions import collection_target_completer
10
+ from exist_shell.exceptions import ExistQueryError
11
+ from exist_shell.models import CollectionEntry
12
+ from exist_shell.utils import handle_exist_errors, parse_target, resolve_collection
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Per-who bit tables used by the symbolic-mode parser
16
+ # ---------------------------------------------------------------------------
17
+
18
+ # Maps (who, perm_char) → the bit to set/clear
19
+ _WHO_PERM: dict[str, dict[str, int]] = {
20
+ "u": {"r": 0o400, "w": 0o200, "x": 0o100, "s": 0o4000, "t": 0},
21
+ "g": {"r": 0o040, "w": 0o020, "x": 0o010, "s": 0o2000, "t": 0},
22
+ "o": {"r": 0o004, "w": 0o002, "x": 0o001, "s": 0, "t": 0o1000},
23
+ }
24
+
25
+ # Bits to clear when applying the '=' operator for a given who
26
+ _WHO_CLEAR: dict[str, int] = {
27
+ "u": 0o4700, # setuid + user rwx
28
+ "g": 0o2070, # setgid + group rwx
29
+ "o": 0o1007, # sticky + other rwx
30
+ }
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Mode parsing helpers
34
+ # ---------------------------------------------------------------------------
35
+
36
+ _OCTAL_RE = re.compile(r"0?[0-7]{1,4}$")
37
+ _SYMBOLIC_RE = re.compile(r"[ugoa]*[+\-=][rwxst]*(,[ugoa]*[+\-=][rwxst]*)*$")
38
+
39
+ # Type alias: either a resolved integer mode or a callable that maps the
40
+ # current mode (int) to the new mode (int).
41
+ _ModeSpec = int | Callable[[int], int]
42
+
43
+
44
+ def _is_octal_mode(mode: str) -> bool:
45
+ """Return ``True`` if *mode* looks like an octal mode string.
46
+
47
+ Accepts strings of 1–4 octal digits with an optional leading ``0``
48
+ (e.g. ``"0755"``, ``"644"``, ``"7"``, ``"4755"``).
49
+
50
+ Args:
51
+ mode: The mode string from the CLI.
52
+
53
+ Returns:
54
+ ``True`` when the string is a valid octal mode.
55
+ """
56
+ return bool(_OCTAL_RE.match(mode))
57
+
58
+
59
+ def _parse_octal_mode(mode: str) -> int:
60
+ """Parse an octal mode string to an integer.
61
+
62
+ Args:
63
+ mode: An octal string like ``"0755"`` or ``"644"``.
64
+
65
+ Returns:
66
+ Integer mode value (0–0o7777).
67
+ """
68
+ return int(mode, 8)
69
+
70
+
71
+ def _apply_symbolic_mode(mode_str: str, current: int) -> int:
72
+ """Apply a symbolic mode string to a current integer mode.
73
+
74
+ Parses a comma-separated list of clauses of the form
75
+ ``[ugoa]*[+-=][rwxst]*``. An empty *who* prefix is treated as ``a``
76
+ (all).
77
+
78
+ Args:
79
+ mode_str: Symbolic mode string (e.g. ``"u+x"``, ``"go-w"``,
80
+ ``"a=rw"``, ``"u+x,go-r"``).
81
+ current: Current mode as an integer.
82
+
83
+ Returns:
84
+ New mode as an integer.
85
+
86
+ Raises:
87
+ ValueError: If the mode string contains an invalid clause.
88
+ """
89
+ result = current
90
+ for clause in mode_str.split(","):
91
+ m = re.fullmatch(r"([ugoa]*)([+\-=])([rwxst]*)", clause.strip())
92
+ if not m:
93
+ raise ValueError(f"invalid symbolic mode clause: '{clause}'")
94
+ who_str, op, perms_str = m.groups()
95
+
96
+ # Empty who or explicit 'a' expands to all three categories.
97
+ whos: list[str] = list(who_str) if (who_str and who_str != "a") else ["u", "g", "o"]
98
+
99
+ # Accumulate the bits that change.
100
+ change_bits = 0
101
+ for w in whos:
102
+ for p in perms_str:
103
+ change_bits |= _WHO_PERM[w].get(p, 0)
104
+
105
+ if op == "+":
106
+ result |= change_bits
107
+ elif op == "-":
108
+ result &= ~change_bits
109
+ else: # op == "="
110
+ # Clear all bits belonging to the specified who, then set new ones.
111
+ clear_mask = 0
112
+ for w in whos:
113
+ clear_mask |= _WHO_CLEAR[w]
114
+ result = (result & ~clear_mask) | change_bits
115
+
116
+ return result
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Internal helpers shared by single and recursive apply
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ def _resolve_mode(client: ExistClient, path: str, mode_spec: _ModeSpec) -> int:
125
+ """Return the concrete integer mode to apply at *path*.
126
+
127
+ For an integer *mode_spec* this is a no-op. For a callable, it first
128
+ queries the current permissions of *path* and passes them through.
129
+
130
+ Args:
131
+ client: Active ExistClient.
132
+ path: Full eXist path to query (only used for symbolic specs).
133
+ mode_spec: Resolved integer or symbolic-mode callable.
134
+
135
+ Returns:
136
+ Integer mode to apply.
137
+ """
138
+ if not isinstance(mode_spec, int):
139
+ current = client.get_permissions(path)
140
+ return mode_spec(current)
141
+ return mode_spec
142
+
143
+
144
+ def _chmod_tree(client: ExistClient, path: str, mode_spec: _ModeSpec) -> int:
145
+ """Recursively change permissions of a collection and all its contents.
146
+
147
+ For an octal (integer) *mode_spec* the same absolute mode is applied to
148
+ every item. For a symbolic *mode_spec* the relative change is applied
149
+ independently to each item's current permissions.
150
+
151
+ Args:
152
+ client: Active ExistClient.
153
+ path: Full eXist path to the root collection.
154
+ mode_spec: Resolved integer mode or symbolic-mode callable.
155
+
156
+ Returns:
157
+ Total number of resources and collections whose mode was changed.
158
+ """
159
+ client.chmod_resource(path, _resolve_mode(client, path, mode_spec))
160
+ count = 1
161
+ for item in client.list_collection(path):
162
+ child = f"{path}/{item.name}"
163
+ if isinstance(item, CollectionEntry):
164
+ count += _chmod_tree(client, child, mode_spec)
165
+ else:
166
+ client.chmod_resource(child, _resolve_mode(client, child, mode_spec))
167
+ count += 1
168
+ return count
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # chmod command
173
+ # ---------------------------------------------------------------------------
174
+
175
+
176
+ def chmod(
177
+ mode: str = typer.Argument(
178
+ help=(
179
+ "Permission mode: octal (e.g. '0755', '644') or symbolic "
180
+ "(e.g. 'u+x', 'go-w', 'a=rw', 'u+x,go-r')."
181
+ ),
182
+ ),
183
+ target: str = typer.Argument(
184
+ help="Remote path: <nick>:<path>.",
185
+ autocompletion=collection_target_completer("any"),
186
+ ),
187
+ recursive: bool = typer.Option(
188
+ False, "--recursive", "-R",
189
+ help="Apply recursively to all contents of a collection.",
190
+ ),
191
+ ) -> None:
192
+ """Change POSIX permissions on a document or collection.
193
+
194
+ Accepts both octal (e.g. ``0755``, ``644``) and symbolic (e.g. ``u+x``,
195
+ ``go-w``, ``a=rw``) mode specifications.
196
+
197
+ Raises:
198
+ typer.Exit: On invalid mode, unknown collection, or server error.
199
+ """
200
+ # Determine and validate the mode specification.
201
+ mode_spec: _ModeSpec
202
+ if _is_octal_mode(mode):
203
+ mode_spec = _parse_octal_mode(mode)
204
+ elif _SYMBOLIC_RE.match(mode):
205
+ captured = mode
206
+ mode_spec = lambda current, _m=captured: _apply_symbolic_mode(_m, current)
207
+ else:
208
+ typer.echo(
209
+ f"Error: invalid mode '{mode}'. "
210
+ "Use octal (e.g. '0755') or symbolic (e.g. 'u+x', 'go-w').",
211
+ err=True,
212
+ )
213
+ raise typer.Exit(1)
214
+
215
+ nick, path = parse_target(target, path_required=False)
216
+ collection, server, full_path = resolve_collection(nick, path)
217
+
218
+ try:
219
+ with handle_exist_errors(path, nick, collection.server_nick):
220
+ with ExistClient(server) as client:
221
+ if recursive:
222
+ if not client.is_collection(full_path):
223
+ typer.echo(
224
+ f"Error: '{path}' is not a collection. "
225
+ "Omit -R to chmod a single document.",
226
+ err=True,
227
+ )
228
+ raise typer.Exit(1)
229
+ count = _chmod_tree(client, full_path, mode_spec)
230
+ typer.echo(f"Permissions of '{path}' updated ({count} items).")
231
+ else:
232
+ client.chmod_resource(full_path, _resolve_mode(client, full_path, mode_spec))
233
+ typer.echo(f"Permissions of '{path}' updated.")
234
+ except ExistQueryError as e:
235
+ typer.echo(f"Error: {e}", err=True)
236
+ raise typer.Exit(1)