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,121 @@
1
+ """chown command — change the owner and/or group of a document or collection."""
2
+
3
+ import typer
4
+
5
+ from exist_shell.client import ExistClient
6
+ from exist_shell.completions import chown_spec_completer, collection_target_completer
7
+ from exist_shell.exceptions import ExistQueryError
8
+ from exist_shell.models import CollectionEntry
9
+ from exist_shell.utils import handle_exist_errors, parse_target, resolve_collection
10
+
11
+
12
+ def _parse_spec(spec: str) -> tuple[str | None, str | None]:
13
+ """Parse an owner spec into ``(owner, group)``, stripping any ``server@`` prefix.
14
+
15
+ Accepted forms: ``owner``, ``:group``, ``owner:group``,
16
+ ``server@owner``, ``server@:group``, ``server@owner:group``.
17
+ The ``server@`` prefix is used only for tab completion and is discarded here.
18
+
19
+ Args:
20
+ spec: The raw owner spec from the CLI.
21
+
22
+ Returns:
23
+ Tuple ``(owner, group)`` where either element is ``None`` when not
24
+ specified.
25
+ """
26
+ if "@" in spec:
27
+ _, _, spec = spec.partition("@")
28
+ if ":" in spec:
29
+ owner, _, group = spec.partition(":")
30
+ return owner.strip() or None, group.strip() or None
31
+ return spec.strip() or None, None
32
+
33
+
34
+ def _chown_tree(client: ExistClient, path: str, owner: str | None, group: str | None) -> int:
35
+ """Recursively change ownership of a collection and all its contents.
36
+
37
+ Args:
38
+ client: Active ExistClient.
39
+ path: Full eXist path to the root collection.
40
+ owner: New owner username, or ``None`` to leave unchanged.
41
+ group: New group name, or ``None`` to leave unchanged.
42
+
43
+ Returns:
44
+ Total number of resources and collections whose ownership was changed.
45
+ """
46
+ client.chown_resource(path, owner, group)
47
+ count = 1
48
+ for item in client.list_collection(path):
49
+ child = f"{path}/{item.name}"
50
+ if isinstance(item, CollectionEntry):
51
+ count += _chown_tree(client, child, owner, group)
52
+ else:
53
+ client.chown_resource(child, owner, group)
54
+ count += 1
55
+ return count
56
+
57
+
58
+ def chown(
59
+ owner_spec: str = typer.Argument(
60
+ help=(
61
+ "Owner/group spec: 'owner', ':group', 'owner:group'. "
62
+ "Prefix with 'server@' to pin a server for tab completion "
63
+ "(e.g. 'prod@alice:editors')."
64
+ ),
65
+ autocompletion=chown_spec_completer,
66
+ ),
67
+ target: str = typer.Argument(
68
+ help="Remote path: <nick>:<path>.",
69
+ autocompletion=collection_target_completer("any"),
70
+ ),
71
+ recursive: bool = typer.Option(
72
+ False, "--recursive", "-R",
73
+ help="Apply recursively to all contents of a collection.",
74
+ ),
75
+ ) -> None:
76
+ """Change the owner and/or group of a document or collection on the server.
77
+
78
+ Raises:
79
+ typer.Exit: If the ``server@`` prefix in the owner spec names a different
80
+ server than the one the target collection is registered against.
81
+ """
82
+ spec_server_nick = owner_spec.partition("@")[0] if "@" in owner_spec else None
83
+ owner, group = _parse_spec(owner_spec)
84
+ if owner is None and group is None:
85
+ typer.echo(
86
+ "Error: specify at least an owner or a group "
87
+ "(e.g. 'alice', ':editors', 'alice:editors').",
88
+ err=True,
89
+ )
90
+ raise typer.Exit(1)
91
+
92
+ nick, path = parse_target(target, path_required=False)
93
+ collection, server, full_path = resolve_collection(nick, path)
94
+
95
+ if spec_server_nick and spec_server_nick != collection.server_nick:
96
+ typer.echo(
97
+ f"Error: owner spec targets server '{spec_server_nick}' but "
98
+ f"collection '{nick}' belongs to server '{collection.server_nick}'.",
99
+ err=True,
100
+ )
101
+ raise typer.Exit(1)
102
+
103
+ try:
104
+ with handle_exist_errors(path, nick, collection.server_nick):
105
+ with ExistClient(server) as client:
106
+ if recursive:
107
+ if not client.is_collection(full_path):
108
+ typer.echo(
109
+ f"Error: '{path}' is not a collection. "
110
+ "Omit -R to chown a single document.",
111
+ err=True,
112
+ )
113
+ raise typer.Exit(1)
114
+ count = _chown_tree(client, full_path, owner, group)
115
+ typer.echo(f"Ownership of '{path}' updated ({count} items).")
116
+ else:
117
+ client.chown_resource(full_path, owner, group)
118
+ typer.echo(f"Ownership of '{path}' updated.")
119
+ except ExistQueryError as e:
120
+ typer.echo(f"Error: {e}", err=True)
121
+ raise typer.Exit(1)
@@ -0,0 +1,189 @@
1
+ """Collection management commands (add, new, ls, rm)."""
2
+
3
+ import typer
4
+
5
+ from exist_shell.client import ExistClient
6
+ from exist_shell.config import Collection, Config
7
+ from exist_shell.exceptions import ExistAuthError, ExistConnectionError, ExistNotFoundError
8
+
9
+ app = typer.Typer(help="Manage collections.", no_args_is_help=True)
10
+
11
+
12
+ def _complete_collection_target(incomplete: str) -> list[str]:
13
+ if "@" not in incomplete:
14
+ return []
15
+ prefix, partial = incomplete.split("@", 1)
16
+ try:
17
+ servers = Config.load().servers
18
+ except Exception:
19
+ return []
20
+ return [f"{prefix}@{nick}" for nick in servers if nick.startswith(partial)]
21
+
22
+
23
+ def _complete_server(incomplete: str) -> list[str]:
24
+ try:
25
+ servers = Config.load().servers
26
+ except Exception:
27
+ return []
28
+ return [nick for nick in servers if nick.startswith(incomplete)]
29
+
30
+
31
+ def _list() -> None:
32
+ config = Config.load()
33
+ for nick, c in config.collections.items():
34
+ typer.echo(f"{nick}\t/db/{c.name}\t@{c.server_nick}")
35
+
36
+
37
+ app.command("ls", help="List configured collections.")(_list)
38
+ app.command("list", help="List configured collections.", hidden=True)(_list)
39
+
40
+
41
+ @app.command("add")
42
+ def collection_add(
43
+ target: str = typer.Argument(
44
+ help="Collection name, optionally with server: <name>[@<server>].",
45
+ autocompletion=_complete_collection_target,
46
+ ),
47
+ server: str | None = typer.Option(
48
+ None, "--server", help="Server nick.", autocompletion=_complete_server
49
+ ),
50
+ nick: str | None = typer.Option(None, help="Nickname (default: collection name)."),
51
+ ) -> None:
52
+ """Add a collection and verify it exists on the server before saving."""
53
+ name = target
54
+ if "@" in target:
55
+ name, server_from_target = target.split("@", 1)
56
+ if server is not None and server != server_from_target:
57
+ typer.echo("Error: conflicting --server and @server in argument.", err=True)
58
+ raise typer.Exit(1)
59
+ server = server_from_target
60
+
61
+ config = Config.load()
62
+
63
+ if server is None:
64
+ if len(config.servers) == 1:
65
+ server = next(iter(config.servers))
66
+ else:
67
+ typer.echo("Error: --server is required when multiple servers are configured.", err=True)
68
+ raise typer.Exit(1)
69
+
70
+ if server not in config.servers:
71
+ typer.echo(f"Error: server '{server}' not found.", err=True)
72
+ raise typer.Exit(1)
73
+
74
+ resolved_nick = nick or name
75
+ if resolved_nick in config.collections:
76
+ typer.echo(
77
+ f"Error: nick '{resolved_nick}' already exists. Use --nick to provide a unique nickname.",
78
+ err=True,
79
+ )
80
+ raise typer.Exit(1)
81
+
82
+ try:
83
+ with ExistClient(config.servers[server]) as client:
84
+ if not client.collection_exists(name):
85
+ typer.echo(f"Error: '/db/{name}' not found on server '{server}'.", err=True)
86
+ raise typer.Exit(1)
87
+ except ExistAuthError:
88
+ typer.echo(f"Error: authentication failed for server '{server}'.", err=True)
89
+ raise typer.Exit(1)
90
+ except ExistConnectionError as e:
91
+ typer.echo(f"Error: {e}", err=True)
92
+ raise typer.Exit(1)
93
+
94
+ config.add_collection(Collection(nick=resolved_nick, server_nick=server, name=name))
95
+ typer.echo(f"Collection '{resolved_nick}' added.")
96
+
97
+
98
+ @app.command("new")
99
+ def collection_new(
100
+ target: str = typer.Argument(
101
+ help="Collection name, optionally with server: <name>[@<server>].",
102
+ autocompletion=_complete_collection_target,
103
+ ),
104
+ server: str | None = typer.Option(
105
+ None, "--server", help="Server nick.", autocompletion=_complete_server
106
+ ),
107
+ nick: str | None = typer.Option(None, "--nick", help="Nickname (default: collection name)."),
108
+ ) -> None:
109
+ """Create /db/<name> on the server and register it in the config."""
110
+ name = target
111
+ if "@" in target:
112
+ name, server_from_target = target.split("@", 1)
113
+ if server is not None and server != server_from_target:
114
+ typer.echo("Error: conflicting --server and @server in argument.", err=True)
115
+ raise typer.Exit(1)
116
+ server = server_from_target
117
+
118
+ config = Config.load()
119
+
120
+ if server is None:
121
+ if len(config.servers) == 1:
122
+ server = next(iter(config.servers))
123
+ else:
124
+ typer.echo("Error: --server is required when multiple servers are configured.", err=True)
125
+ raise typer.Exit(1)
126
+
127
+ if server not in config.servers:
128
+ typer.echo(f"Error: server '{server}' not found.", err=True)
129
+ raise typer.Exit(1)
130
+
131
+ resolved_nick = nick or name
132
+ if resolved_nick in config.collections:
133
+ typer.echo(
134
+ f"Error: nick '{resolved_nick}' already exists. Use --nick to provide a unique nickname.",
135
+ err=True,
136
+ )
137
+ raise typer.Exit(1)
138
+
139
+ try:
140
+ with ExistClient(config.servers[server]) as client:
141
+ if client.collection_exists(name):
142
+ typer.echo(f"'/db/{name}' already exists on server '{server}'.")
143
+ return
144
+ client.create_collection(f"/db/{name}")
145
+ except ExistAuthError:
146
+ typer.echo(f"Error: authentication failed for server '{server}'.", err=True)
147
+ raise typer.Exit(1)
148
+ except ExistConnectionError as e:
149
+ typer.echo(f"Error: {e}", err=True)
150
+ raise typer.Exit(1)
151
+
152
+ config.add_collection(Collection(nick=resolved_nick, server_nick=server, name=name))
153
+ typer.echo(f"Collection '{resolved_nick}' created at /db/{name}.")
154
+
155
+
156
+ @app.command("rm")
157
+ def collection_rm(
158
+ nick: str = typer.Argument(help="Nickname of the collection to remove."),
159
+ delete: bool = typer.Option(False, "--delete", help="Also delete the collection from the server."),
160
+ ) -> None:
161
+ """Remove a collection from the config, optionally deleting it from the server."""
162
+ config = Config.load()
163
+ if nick not in config.collections:
164
+ typer.echo(f"Error: collection '{nick}' not found.", err=True)
165
+ raise typer.Exit(1)
166
+
167
+ if delete:
168
+ collection = config.collections[nick]
169
+ if collection.server_nick not in config.servers:
170
+ typer.echo(f"Error: server '{collection.server_nick}' not found.", err=True)
171
+ raise typer.Exit(1)
172
+ try:
173
+ with ExistClient(config.servers[collection.server_nick]) as client:
174
+ client.delete_collection(f"/db/{collection.name}")
175
+ except ExistAuthError:
176
+ typer.echo(f"Error: authentication failed for server '{collection.server_nick}'.", err=True)
177
+ raise typer.Exit(1)
178
+ except ExistConnectionError as e:
179
+ typer.echo(f"Error: {e}", err=True)
180
+ raise typer.Exit(1)
181
+ except ExistNotFoundError:
182
+ typer.echo(
183
+ f"Error: '/db/{collection.name}' not found on server '{collection.server_nick}'.",
184
+ err=True,
185
+ )
186
+ raise typer.Exit(1)
187
+
188
+ config.remove_collection(nick)
189
+ typer.echo(f"Collection '{nick}' removed.")
@@ -0,0 +1,142 @@
1
+ """cp command — copy documents between local paths and remote eXist collections."""
2
+
3
+ from pathlib import Path, PurePosixPath
4
+
5
+ import typer
6
+
7
+ from exist_shell.cache import invalidate
8
+ from exist_shell.client import ExistClient
9
+ from exist_shell.completions import collection_target_completer
10
+ from exist_shell.utils import guess_mime, handle_exist_errors, is_remote, parse_target, resolve_collection
11
+
12
+
13
+ def _remote_dest(path: str, source_name: str) -> str:
14
+ """Resolve the final remote destination path.
15
+
16
+ Args:
17
+ path: The target path as typed (may end with ``/`` to signal a directory).
18
+ source_name: Filename to append when path ends with ``/``.
19
+
20
+ Returns:
21
+ Final destination path without a trailing slash.
22
+ """
23
+ if path.endswith("/"):
24
+ return path.rstrip("/") + "/" + source_name
25
+ return path
26
+
27
+
28
+ def _local_dest(target: str, source_name: str) -> Path:
29
+ """Resolve the final local destination path.
30
+
31
+ Args:
32
+ target: Local target path as typed.
33
+ source_name: Filename to append when target is an existing directory.
34
+
35
+ Returns:
36
+ Resolved local Path for writing.
37
+ """
38
+ p = Path(target)
39
+ if p.is_dir():
40
+ return p / source_name
41
+ return p
42
+
43
+
44
+ def _local_to_remote(source: str, target: str) -> None:
45
+ """Copy a local file to a remote eXist path.
46
+
47
+ Args:
48
+ source: Local file path.
49
+ target: Remote ``nick:path`` destination.
50
+ """
51
+ src_path = Path(source)
52
+ try:
53
+ content = src_path.read_bytes()
54
+ except OSError as e:
55
+ typer.echo(f"Error: cannot read '{source}': {e}", err=True)
56
+ raise typer.Exit(1)
57
+
58
+ mime_type = guess_mime(src_path)
59
+
60
+ nick, tgt_path = parse_target(target)
61
+ dest_path = _remote_dest(tgt_path, src_path.name)
62
+ collection, server, full_dest = resolve_collection(nick, dest_path)
63
+
64
+ with handle_exist_errors(dest_path, nick, collection.server_nick):
65
+ with ExistClient(server) as client:
66
+ client.put_document(full_dest, content, mime_type)
67
+ invalidate(nick)
68
+
69
+
70
+ def _remote_to_local(source: str, target: str) -> None:
71
+ """Copy a remote eXist document to a local path.
72
+
73
+ Args:
74
+ source: Remote ``nick:path`` source.
75
+ target: Local file or directory path.
76
+ """
77
+ nick, src_path = parse_target(source)
78
+ collection, server, full_src = resolve_collection(nick, src_path)
79
+
80
+ with handle_exist_errors(src_path, nick, collection.server_nick):
81
+ with ExistClient(server) as client:
82
+ result = client.get_document(full_src)
83
+
84
+ dest = _local_dest(target, PurePosixPath(src_path).name)
85
+ try:
86
+ dest.write_bytes(result.content)
87
+ except OSError as e:
88
+ typer.echo(f"Error: cannot write '{dest}': {e}", err=True)
89
+ raise typer.Exit(1)
90
+
91
+
92
+ def _remote_to_remote(source: str, target: str) -> None:
93
+ """Copy a remote eXist document to another remote eXist path.
94
+
95
+ Args:
96
+ source: Remote ``nick:path`` source.
97
+ target: Remote ``nick:path`` destination (may be a different collection or server).
98
+ """
99
+ src_nick, src_path = parse_target(source)
100
+ src_collection, src_server, src_full = resolve_collection(src_nick, src_path)
101
+
102
+ with handle_exist_errors(src_path, src_nick, src_collection.server_nick):
103
+ with ExistClient(src_server) as client:
104
+ result = client.get_document(src_full)
105
+
106
+ tgt_nick, tgt_path = parse_target(target)
107
+ dest_path = _remote_dest(tgt_path, PurePosixPath(src_path).name)
108
+ tgt_collection, tgt_server, tgt_full = resolve_collection(tgt_nick, dest_path)
109
+
110
+ with handle_exist_errors(dest_path, tgt_nick, tgt_collection.server_nick):
111
+ with ExistClient(tgt_server) as client:
112
+ client.put_document(tgt_full, result.content, result.mime_type)
113
+ invalidate(tgt_nick)
114
+
115
+
116
+ def cp(
117
+ source: str = typer.Argument(
118
+ help="Source: remote ``<nick>:<path>`` or local file path.",
119
+ autocompletion=collection_target_completer("resource", allow_local=True),
120
+ ),
121
+ target: str = typer.Argument(
122
+ help="Target: remote ``<nick>:<path>`` or local path (file or directory).",
123
+ autocompletion=collection_target_completer("any", allow_local=True),
124
+ ),
125
+ ) -> None:
126
+ """Copy a document between local paths and remote eXist collections."""
127
+ src_remote = is_remote(source)
128
+ tgt_remote = is_remote(target)
129
+
130
+ if not src_remote and not tgt_remote:
131
+ typer.echo(
132
+ "Error: at least one of source or target must be a remote path (nick:path).",
133
+ err=True,
134
+ )
135
+ raise typer.Exit(1)
136
+
137
+ if src_remote and tgt_remote:
138
+ _remote_to_remote(source, target)
139
+ elif src_remote:
140
+ _remote_to_local(source, target)
141
+ else:
142
+ _local_to_remote(source, target)
@@ -0,0 +1,85 @@
1
+ """edit command — download, edit locally, and re-upload a document."""
2
+
3
+ import os
4
+ import shlex
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ from pathlib import Path, PurePosixPath
9
+
10
+ import typer
11
+
12
+ from exist_shell.cache import invalidate
13
+ from exist_shell.client import ExistClient
14
+ from exist_shell.completions import collection_target_completer
15
+ from exist_shell.utils import check_xml_wellformed, handle_exist_errors, parse_target, resolve_collection
16
+
17
+
18
+ def _find_editor() -> str:
19
+ """Return the editor command from $VISUAL, $EDITOR, or a platform default.
20
+
21
+ Returns:
22
+ Editor command string (may include flags, e.g. ``code --wait``).
23
+ """
24
+ for var in ("VISUAL", "EDITOR"):
25
+ val = os.environ.get(var, "").strip()
26
+ if val:
27
+ return val
28
+ return "notepad" if sys.platform == "win32" else "vi"
29
+
30
+
31
+ def edit(
32
+ target: str = typer.Argument(
33
+ help="Collection and document path: <nick>:<path>.",
34
+ autocompletion=collection_target_completer("resource"),
35
+ ),
36
+ ) -> None:
37
+ """Download a document, open it in $VISUAL/$EDITOR, and re-upload if changed."""
38
+ nick, path = parse_target(target)
39
+ collection, server, full_path = resolve_collection(nick, path)
40
+
41
+ with handle_exist_errors(path, nick, collection.server_nick):
42
+ with ExistClient(server) as client:
43
+ result = client.get_document(full_path)
44
+
45
+ suffix = PurePosixPath(path).suffix
46
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
47
+ tmp_path = Path(tmp.name)
48
+ tmp.write(result.content)
49
+
50
+ try:
51
+ editor = _find_editor()
52
+ last_seen = result.content
53
+
54
+ while True:
55
+ proc = subprocess.run(shlex.split(editor) + [str(tmp_path)])
56
+ if proc.returncode != 0:
57
+ typer.echo(f"Error: editor exited with code {proc.returncode}.", err=True)
58
+ raise typer.Exit(1)
59
+
60
+ new_content = tmp_path.read_bytes()
61
+
62
+ if new_content == last_seen:
63
+ if last_seen == result.content:
64
+ typer.echo("No changes.")
65
+ else:
66
+ typer.echo("Aborted: document still has XML errors, not uploaded.", err=True)
67
+ raise typer.Exit(1)
68
+ return
69
+
70
+ if xml_error := check_xml_wellformed(new_content, result.mime_type):
71
+ typer.echo(f"Warning: not well-formed XML: {xml_error}", err=True)
72
+ typer.echo("Fix the error and save to continue, or quit without changes to abort.", err=True)
73
+ typer.echo("Press Enter to re-open the editor...", err=True)
74
+ sys.stdin.readline()
75
+ last_seen = new_content
76
+ continue
77
+
78
+ break
79
+
80
+ with handle_exist_errors(path, nick, collection.server_nick):
81
+ with ExistClient(server) as client:
82
+ client.put_document(full_path, new_content, result.mime_type)
83
+ invalidate(nick)
84
+ finally:
85
+ tmp_path.unlink(missing_ok=True)
@@ -0,0 +1,65 @@
1
+ """exec command — execute an XQuery script on an eXist-db server."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from exist_shell.client import ExistClient
9
+ from exist_shell.exceptions import ExistQueryError
10
+ from exist_shell.utils import handle_exist_errors, parse_target, resolve_collection
11
+ from exist_shell.xquery import list_validators, preprocess, validate_locally
12
+
13
+
14
+ def exec(
15
+ target: str | None = typer.Argument(
16
+ default=None,
17
+ help="Collection context for the query: <nick>[:<path>].",
18
+ ),
19
+ file: Path | None = typer.Option(None, "-f", "--file", help="XQuery file to execute (default: stdin)."),
20
+ no_fix: bool = typer.Option(False, "--no-fix", help="Skip XQuery preprocessing (version declaration, namespace imports)."),
21
+ no_validate: bool = typer.Option(False, "--no-validate", help="Skip local validation even if a validator is installed."),
22
+ validator: str | None = typer.Option(None, "--validator", help="Name of the local validator to use (default: first installed)."),
23
+ list_validators_flag: bool = typer.Option(False, "--list-validators", help="List known validators and their install status, then exit."),
24
+ ) -> None:
25
+ """Execute an XQuery script on an eXist-db server and print the result."""
26
+ if list_validators_flag:
27
+ for name, path in list_validators():
28
+ status = path or "not installed"
29
+ typer.echo(f"{name:12}{status}")
30
+ raise typer.Exit()
31
+
32
+ if target is None:
33
+ typer.echo("Error: missing argument 'TARGET'.", err=True)
34
+ raise typer.Exit(1)
35
+
36
+ nick, path = parse_target(target, path_required=False)
37
+ collection, server, full_path = resolve_collection(nick, path)
38
+
39
+ if file is not None:
40
+ try:
41
+ code = file.read_text(encoding="utf-8")
42
+ except OSError as e:
43
+ typer.echo(f"Error: cannot read '{file}': {e}", err=True)
44
+ raise typer.Exit(1)
45
+ else:
46
+ code = sys.stdin.read()
47
+
48
+ if not no_fix:
49
+ code = preprocess(code)
50
+
51
+ if not no_validate:
52
+ result = validate_locally(code, validator=validator)
53
+ if not result.ok:
54
+ typer.echo(f"Error: {result.error}", err=True)
55
+ raise typer.Exit(1)
56
+
57
+ try:
58
+ with handle_exist_errors(path, nick, collection.server_nick):
59
+ with ExistClient(server) as client:
60
+ output = client.execute_query(code, context=full_path)
61
+ except ExistQueryError as e:
62
+ typer.echo(f"Error: {e}", err=True)
63
+ raise typer.Exit(1)
64
+
65
+ typer.echo(output, nl=False)