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,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)
|