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
exist_shell/config.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Configuration models and persistence for servers and collections."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import tomlkit
|
|
8
|
+
from pydantic import BaseModel, Field, SecretStr
|
|
9
|
+
|
|
10
|
+
NICK_PATTERN = r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$"
|
|
11
|
+
|
|
12
|
+
if sys.platform == "win32":
|
|
13
|
+
import platformdirs
|
|
14
|
+
|
|
15
|
+
_DEFAULT_CONFIG_PATH = Path(platformdirs.user_config_dir("exsh", appauthor=False)) / "config.toml"
|
|
16
|
+
_DEFAULT_CACHE_DIR = Path(platformdirs.user_cache_dir("exsh", appauthor=False))
|
|
17
|
+
else:
|
|
18
|
+
_DEFAULT_CONFIG_PATH = Path.home() / ".config" / "exsh" / "config.toml"
|
|
19
|
+
_DEFAULT_CACHE_DIR = Path.home() / ".cache" / "exsh"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _AppState:
|
|
23
|
+
r"""Process-level singleton that holds the active config file path.
|
|
24
|
+
|
|
25
|
+
The path is resolved in this order:
|
|
26
|
+
1. Explicitly set via ``set_config_path()`` (called by the ``--config`` flag).
|
|
27
|
+
2. ``EXSH_CONFIG`` environment variable.
|
|
28
|
+
3. Platform default: XDG (``~/.config/exsh``) on Unix, ``%APPDATA%\exsh`` on Windows.
|
|
29
|
+
XDG Base Directory Specification is a freedesktop.org standard for where
|
|
30
|
+
applications should store config, cache, and data files on Linux/macOS.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
"""Initialise with no explicit path set."""
|
|
35
|
+
self._config_path: Path | None = None
|
|
36
|
+
|
|
37
|
+
def set_config_path(self, path: Path) -> None:
|
|
38
|
+
"""Override the config file path for this process.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
path: Absolute or relative path to the config file.
|
|
42
|
+
"""
|
|
43
|
+
self._config_path = path
|
|
44
|
+
|
|
45
|
+
def config_path(self) -> Path:
|
|
46
|
+
"""Return the resolved config file path.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Path to the configuration file.
|
|
50
|
+
"""
|
|
51
|
+
if self._config_path is not None:
|
|
52
|
+
return self._config_path
|
|
53
|
+
env = os.environ.get("EXSH_CONFIG")
|
|
54
|
+
return Path(env) if env else _DEFAULT_CONFIG_PATH
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
app_state = _AppState()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Server(BaseModel):
|
|
61
|
+
"""An eXist-db server configuration."""
|
|
62
|
+
|
|
63
|
+
nick: str = Field(pattern=NICK_PATTERN)
|
|
64
|
+
host: str
|
|
65
|
+
port: int = 8080
|
|
66
|
+
user: str = "admin"
|
|
67
|
+
password: SecretStr = SecretStr("")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Collection(BaseModel):
|
|
71
|
+
"""A named collection on an eXist-db server."""
|
|
72
|
+
|
|
73
|
+
nick: str = Field(pattern=NICK_PATTERN)
|
|
74
|
+
server_nick: str
|
|
75
|
+
name: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Config(BaseModel):
|
|
79
|
+
"""Full application configuration holding servers and collections."""
|
|
80
|
+
|
|
81
|
+
servers: dict[str, Server] = {}
|
|
82
|
+
collections: dict[str, Collection] = {}
|
|
83
|
+
cache_dir: Path | None = None
|
|
84
|
+
|
|
85
|
+
def resolved_cache_dir(self) -> Path:
|
|
86
|
+
"""Return the active cache root directory.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
``cache_dir`` from config if set, otherwise ``~/.cache/exsh``.
|
|
90
|
+
"""
|
|
91
|
+
return self.cache_dir if self.cache_dir is not None else _DEFAULT_CACHE_DIR
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def load(cls) -> "Config":
|
|
95
|
+
"""Load configuration from disk, returning an empty config if the file is missing.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The loaded Config instance.
|
|
99
|
+
"""
|
|
100
|
+
path = app_state.config_path()
|
|
101
|
+
if not path.exists():
|
|
102
|
+
return cls()
|
|
103
|
+
with open(path) as f:
|
|
104
|
+
raw = tomlkit.load(f)
|
|
105
|
+
servers = {
|
|
106
|
+
nick: Server(nick=nick, **data)
|
|
107
|
+
for nick, data in raw.get("servers", {}).items()
|
|
108
|
+
}
|
|
109
|
+
collections = {
|
|
110
|
+
nick: Collection(nick=nick, **data)
|
|
111
|
+
for nick, data in raw.get("collections", {}).items()
|
|
112
|
+
}
|
|
113
|
+
cache_dir_raw = raw.get("cache_dir")
|
|
114
|
+
cache_dir = Path(cache_dir_raw) if cache_dir_raw else None
|
|
115
|
+
return cls(servers=servers, collections=collections, cache_dir=cache_dir)
|
|
116
|
+
|
|
117
|
+
def save(self) -> None:
|
|
118
|
+
"""Persist the current configuration to disk atomically."""
|
|
119
|
+
path = app_state.config_path()
|
|
120
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
data: dict = {
|
|
122
|
+
"servers": {
|
|
123
|
+
nick: {
|
|
124
|
+
"host": s.host,
|
|
125
|
+
"port": s.port,
|
|
126
|
+
"user": s.user,
|
|
127
|
+
"password": s.password.get_secret_value(),
|
|
128
|
+
}
|
|
129
|
+
for nick, s in self.servers.items()
|
|
130
|
+
},
|
|
131
|
+
"collections": {
|
|
132
|
+
nick: {
|
|
133
|
+
"server_nick": c.server_nick,
|
|
134
|
+
"name": c.name,
|
|
135
|
+
}
|
|
136
|
+
for nick, c in self.collections.items()
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
if self.cache_dir is not None:
|
|
140
|
+
data["cache_dir"] = str(self.cache_dir)
|
|
141
|
+
tmp = path.with_suffix(".tmp")
|
|
142
|
+
tmp.write_text(tomlkit.dumps(data))
|
|
143
|
+
tmp.rename(path)
|
|
144
|
+
|
|
145
|
+
def add_server(self, server: Server) -> None:
|
|
146
|
+
"""Add a server and persist the configuration.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
server: The server to add.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
ValueError: If a server with the same nick already exists.
|
|
153
|
+
"""
|
|
154
|
+
if server.nick in self.servers:
|
|
155
|
+
raise ValueError(f"Server nick '{server.nick}' already exists.")
|
|
156
|
+
self.servers[server.nick] = server
|
|
157
|
+
self.save()
|
|
158
|
+
|
|
159
|
+
def add_collection(self, collection: Collection) -> None:
|
|
160
|
+
"""Add a collection and persist the configuration.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
collection: The collection to add.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
ValueError: If a collection with the same nick already exists.
|
|
167
|
+
"""
|
|
168
|
+
if collection.nick in self.collections:
|
|
169
|
+
raise ValueError(f"Collection nick '{collection.nick}' already exists.")
|
|
170
|
+
self.collections[collection.nick] = collection
|
|
171
|
+
self.save()
|
|
172
|
+
|
|
173
|
+
def remove_collection(self, nick: str) -> None:
|
|
174
|
+
"""Remove a collection and persist the configuration.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
nick: Nickname of the collection to remove.
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
KeyError: If no collection with that nick exists.
|
|
181
|
+
"""
|
|
182
|
+
del self.collections[nick]
|
|
183
|
+
self.save()
|
|
184
|
+
|
|
185
|
+
def remove_server(self, nick: str) -> list[str]:
|
|
186
|
+
"""Remove a server and all collections registered on it, then persist.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
nick: Nickname of the server to remove.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
List of collection nicks that were removed as a side-effect.
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
KeyError: If no server with that nick exists.
|
|
196
|
+
"""
|
|
197
|
+
del self.servers[nick]
|
|
198
|
+
cascaded = [c for c, col in self.collections.items() if col.server_nick == nick]
|
|
199
|
+
for c in cascaded:
|
|
200
|
+
del self.collections[c]
|
|
201
|
+
self.save()
|
|
202
|
+
return cascaded
|
|
203
|
+
|
|
204
|
+
def rename_server(self, old_nick: str, new_nick: str) -> list[str]:
|
|
205
|
+
"""Rename a server nick and update all collection references, then persist.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
old_nick: Current nickname of the server.
|
|
209
|
+
new_nick: New nickname for the server.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
List of collection nicks whose server_nick was updated.
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
KeyError: If no server with old_nick exists.
|
|
216
|
+
ValueError: If new_nick already exists as a server nick.
|
|
217
|
+
"""
|
|
218
|
+
if new_nick in self.servers:
|
|
219
|
+
raise ValueError(f"Server nick '{new_nick}' already exists.")
|
|
220
|
+
server = self.servers[old_nick]
|
|
221
|
+
self.servers[new_nick] = Server(
|
|
222
|
+
nick=new_nick,
|
|
223
|
+
host=server.host,
|
|
224
|
+
port=server.port,
|
|
225
|
+
user=server.user,
|
|
226
|
+
password=server.password,
|
|
227
|
+
)
|
|
228
|
+
del self.servers[old_nick]
|
|
229
|
+
updated = [c for c, col in self.collections.items() if col.server_nick == old_nick]
|
|
230
|
+
for c in updated:
|
|
231
|
+
self.collections[c] = self.collections[c].model_copy(update={"server_nick": new_nick})
|
|
232
|
+
self.save()
|
|
233
|
+
return updated
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Custom exceptions for eXist-db REST API errors."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ExistError(Exception):
|
|
5
|
+
"""Base exception for eXist-db REST API errors.
|
|
6
|
+
|
|
7
|
+
Attributes:
|
|
8
|
+
status_code: HTTP status code associated with the error, if any.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, status_code: int | None = None) -> None:
|
|
12
|
+
"""Initialize with a message and optional HTTP status code."""
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ExistConnectionError(ExistError):
|
|
18
|
+
"""Raised when a network-level error prevents reaching the server.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
url: The URL that could not be reached.
|
|
22
|
+
cause: The underlying transport exception.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, url: str, cause: Exception) -> None:
|
|
26
|
+
"""Initialize with the target URL and the underlying cause."""
|
|
27
|
+
super().__init__(f"Cannot connect to {url}: {cause}")
|
|
28
|
+
self.url = url
|
|
29
|
+
self.cause = cause
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ExistAuthError(ExistError):
|
|
33
|
+
"""Raised when the server returns HTTP 401 Unauthorized.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
url: The URL that rejected the credentials.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, url: str) -> None:
|
|
40
|
+
"""Initialize with the URL that rejected authentication."""
|
|
41
|
+
super().__init__(f"Authentication failed for {url}", status_code=401)
|
|
42
|
+
self.url = url
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ExistNotFoundError(ExistError):
|
|
46
|
+
"""Raised when the server returns HTTP 404 Not Found.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
path: The eXist path that was not found.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, path: str) -> None:
|
|
53
|
+
"""Initialize with the path that was not found."""
|
|
54
|
+
super().__init__(f"Not found: {path}", status_code=404)
|
|
55
|
+
self.path = path
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ExistQueryError(ExistError):
|
|
59
|
+
"""Raised when the server rejects or fails to execute an XQuery.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
detail: The error detail returned by the server.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, detail: str) -> None:
|
|
66
|
+
"""Initialize with the server-provided error detail."""
|
|
67
|
+
super().__init__(f"XQuery error: {detail}")
|
|
68
|
+
self.detail = detail
|
exist_shell/main.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Entry point and top-level CLI app for exsh."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from exist_shell import __version__
|
|
8
|
+
from exist_shell.commands import collection, group, server, user
|
|
9
|
+
from exist_shell.config import app_state
|
|
10
|
+
from exist_shell.commands.cat import cat
|
|
11
|
+
from exist_shell.commands.chmod import chmod
|
|
12
|
+
from exist_shell.commands.chown import chown
|
|
13
|
+
from exist_shell.commands.exec import exec as exec_query
|
|
14
|
+
from exist_shell.commands.cp import cp
|
|
15
|
+
from exist_shell.commands.mkdir import mkdir
|
|
16
|
+
from exist_shell.commands.mv import mv
|
|
17
|
+
from exist_shell.commands.edit import edit
|
|
18
|
+
from exist_shell.commands.ls import ls
|
|
19
|
+
from exist_shell.commands.put import put
|
|
20
|
+
from exist_shell.commands.rm import rm
|
|
21
|
+
from exist_shell.commands.sync import sync
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
name="exsh",
|
|
25
|
+
help="eXist-db shell — interact with eXist-db via REST",
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
app.add_typer(server.app, name="server", help="Manage servers.")
|
|
30
|
+
app.add_typer(collection.app, name="collection", help="Manage collections.")
|
|
31
|
+
app.add_typer(user.app, name="user", help="Manage users.")
|
|
32
|
+
app.add_typer(group.app, name="group", help="Manage groups.")
|
|
33
|
+
app.command("ls", help="List contents of a collection path.")(ls)
|
|
34
|
+
app.command("chown", help="Change the owner and/or group of a document or collection.")(chown)
|
|
35
|
+
app.command("chmod", help="Change POSIX permissions of a document or collection.")(chmod)
|
|
36
|
+
app.command("cat", help="Print document content to stdout.")(cat)
|
|
37
|
+
app.command("put", help="Upload a document to a collection path.")(put)
|
|
38
|
+
app.command("edit", help="Edit a document in $VISUAL/$EDITOR and re-upload if changed.")(edit)
|
|
39
|
+
app.command("cp", help="Copy a document between local paths and remote collections.")(cp)
|
|
40
|
+
app.command("mv", help="Move or rename a document or collection on the server.")(mv)
|
|
41
|
+
app.command("rm", help="Delete one or more documents from a collection path.")(rm)
|
|
42
|
+
app.command("mkdir", help="Create a collection at a path inside a registered collection.")(mkdir)
|
|
43
|
+
app.command("sync", help="Sync a local folder and a remote collection.")(sync)
|
|
44
|
+
app.command("exec", help="Execute an XQuery script on an eXist-db server.")(exec_query)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _version_callback(value: bool) -> None:
|
|
48
|
+
if value:
|
|
49
|
+
typer.echo(f"exsh {__version__}")
|
|
50
|
+
raise typer.Exit()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.callback()
|
|
54
|
+
def main(
|
|
55
|
+
version: bool = typer.Option(False, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."),
|
|
56
|
+
config: Path | None = typer.Option(None, "--config", help="Path to config file (overrides EXSH_CONFIG env var and default)."),
|
|
57
|
+
) -> None:
|
|
58
|
+
"""eXist-db shell — interact with eXist-db via REST."""
|
|
59
|
+
if config is not None:
|
|
60
|
+
app_state.set_config_path(config)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
app()
|
exist_shell/models.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Pydantic models for eXist-db REST API responses."""
|
|
2
|
+
|
|
3
|
+
from typing import NamedTuple
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CollectionEntry(BaseModel):
|
|
9
|
+
"""A subcollection entry returned by the eXist-db REST API."""
|
|
10
|
+
|
|
11
|
+
name: str
|
|
12
|
+
created: str | None = None
|
|
13
|
+
owner: str | None = None
|
|
14
|
+
group: str | None = None
|
|
15
|
+
permissions: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ResourceEntry(BaseModel):
|
|
19
|
+
"""A document resource entry returned by the eXist-db REST API."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
created: str | None = None
|
|
23
|
+
last_modified: str | None = None
|
|
24
|
+
owner: str | None = None
|
|
25
|
+
group: str | None = None
|
|
26
|
+
permissions: str | None = None
|
|
27
|
+
size: int | None = None
|
|
28
|
+
mime_type: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
CollectionItem = CollectionEntry | ResourceEntry
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DocumentResult(NamedTuple):
|
|
35
|
+
"""A retrieved document's raw content and declared MIME type."""
|
|
36
|
+
|
|
37
|
+
content: bytes
|
|
38
|
+
mime_type: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GroupEntry(BaseModel):
|
|
42
|
+
"""A group entry with name and member list."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
members: list[str]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UserEntry(BaseModel):
|
|
49
|
+
"""A user entry with username and groups."""
|
|
50
|
+
|
|
51
|
+
username: str
|
|
52
|
+
groups: list[str]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UserInfo(BaseModel):
|
|
56
|
+
"""Detailed user account information."""
|
|
57
|
+
|
|
58
|
+
username: str
|
|
59
|
+
full_name: str | None = None
|
|
60
|
+
groups: list[str] = []
|
|
61
|
+
enabled: bool = True
|
exist_shell/utils.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Shared utilities for exist-shell commands."""
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
import xml.etree.ElementTree as ET
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from exist_shell.config import Collection, Config, Server
|
|
12
|
+
from exist_shell.exceptions import ExistAuthError, ExistConnectionError, ExistNotFoundError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def xq_escape(value: str) -> str:
|
|
16
|
+
"""Escape a string for safe embedding in an XQuery double-quoted string literal.
|
|
17
|
+
|
|
18
|
+
Doubles every ``"`` character so the value can be placed directly
|
|
19
|
+
between XQuery double-quote delimiters without ending the literal early.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
value: The raw string to embed in an XQuery double-quoted string.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The string with every double-quote character doubled.
|
|
26
|
+
"""
|
|
27
|
+
return value.replace('"', '""')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_remote(target: str) -> bool:
|
|
31
|
+
"""Return True if target uses the ``nick:path`` remote syntax.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
target: Raw argument string from the CLI.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
True if the string contains ``:``, indicating a remote path.
|
|
38
|
+
"""
|
|
39
|
+
return ":" in target
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_user_at_server(value: str) -> tuple[str, str | None]:
|
|
43
|
+
"""Split a ``user@server`` argument into a (username, server_nick) pair.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
value: Raw argument from the CLI, e.g. ``alice@prod``, ``alice``, or ``@prod``.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
A tuple ``(username, server_nick)`` where ``server_nick`` is ``None``
|
|
50
|
+
if no ``@`` suffix was present, and ``username`` is empty for bare
|
|
51
|
+
``@server`` forms.
|
|
52
|
+
"""
|
|
53
|
+
if "@" in value:
|
|
54
|
+
username, _, server = value.rpartition("@")
|
|
55
|
+
return username, server if server else None
|
|
56
|
+
return value, None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def guess_mime(path: Path, default: str = "application/octet-stream") -> str:
|
|
60
|
+
"""Guess the MIME type of a file from its extension.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
path: Local file path.
|
|
64
|
+
default: MIME type to return when the extension is unknown.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Guessed MIME type string, or ``default`` if the extension is unrecognised.
|
|
68
|
+
"""
|
|
69
|
+
guessed, _ = mimetypes.guess_type(str(path))
|
|
70
|
+
return guessed or default
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check_xml_wellformed(content: bytes, mime: str) -> str | None:
|
|
74
|
+
"""Return an error message if content is malformed XML, or None if valid or not XML.
|
|
75
|
+
|
|
76
|
+
Only inspects content when the MIME type is an XML type (``application/xml``,
|
|
77
|
+
``text/xml``, or any type ending in ``+xml``).
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
content: Raw bytes to check.
|
|
81
|
+
mime: MIME type of the content.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
None if the content passes the check or is not an XML MIME type; an
|
|
85
|
+
error message string if the XML is malformed.
|
|
86
|
+
"""
|
|
87
|
+
if mime != "application/xml" and mime != "text/xml" and not mime.endswith("+xml"):
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
ET.fromstring(content)
|
|
91
|
+
except ET.ParseError as e:
|
|
92
|
+
return str(e)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def validate_path(path: str) -> None:
|
|
97
|
+
"""Reject paths that contain traversal sequences or null bytes.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
path: The eXist path to validate (e.g. /subdir/doc.xml).
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ValueError: If the path contains ``..``, ``.``, or null bytes.
|
|
104
|
+
"""
|
|
105
|
+
if "\x00" in path:
|
|
106
|
+
raise ValueError("path contains null bytes")
|
|
107
|
+
for segment in path.split("/"):
|
|
108
|
+
if segment in ("..", "."):
|
|
109
|
+
raise ValueError(f"path traversal not allowed: '{segment}' segment")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_target(target: str, *, path_required: bool = True) -> tuple[str, str]:
|
|
113
|
+
"""Parse and validate a ``<nick>:<path>`` target argument.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
target: Raw argument string from the CLI (e.g. ``myapp:/docs/file.xml``).
|
|
117
|
+
path_required: If True, exit with an error when no path is given.
|
|
118
|
+
If False, default the path to ``/`` (used by ``ls``).
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Tuple of ``(nick, normalized_path)`` where path starts with ``/``.
|
|
122
|
+
"""
|
|
123
|
+
nick, _, path = target.partition(":")
|
|
124
|
+
if not path:
|
|
125
|
+
if path_required:
|
|
126
|
+
typer.echo("Error: path is required (use <nick>:<path>).", err=True)
|
|
127
|
+
raise typer.Exit(1)
|
|
128
|
+
path = "/"
|
|
129
|
+
if not path.startswith("/"):
|
|
130
|
+
path = "/" + path
|
|
131
|
+
try:
|
|
132
|
+
validate_path(path)
|
|
133
|
+
except ValueError as e:
|
|
134
|
+
typer.echo(f"Error: {e}", err=True)
|
|
135
|
+
raise typer.Exit(1)
|
|
136
|
+
return nick, path
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def resolve_collection(nick: str, path: str) -> tuple[Collection, Server, str]:
|
|
140
|
+
"""Look up a collection by nick and compute the full eXist path.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
nick: The collection nickname from the CLI argument.
|
|
144
|
+
path: The validated path component (starts with ``/``).
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Tuple of ``(collection, server, full_path)`` where ``full_path``
|
|
148
|
+
starts with ``/db/``.
|
|
149
|
+
"""
|
|
150
|
+
config = Config.load()
|
|
151
|
+
if nick not in config.collections:
|
|
152
|
+
typer.echo(f"Error: collection '{nick}' not found.", err=True)
|
|
153
|
+
raise typer.Exit(1)
|
|
154
|
+
collection = config.collections[nick]
|
|
155
|
+
server = config.servers[collection.server_nick]
|
|
156
|
+
full_path = f"/db/{collection.name}{path}"
|
|
157
|
+
return collection, server, full_path
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@contextmanager
|
|
161
|
+
def handle_exist_errors(path: str, nick: str, server_nick: str) -> Generator[None, None, None]:
|
|
162
|
+
"""Context manager that catches eXist client errors and exits cleanly.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
path: The path being accessed (used in error messages).
|
|
166
|
+
nick: The collection nick (used in error messages).
|
|
167
|
+
server_nick: The server nick (used in authentication error messages).
|
|
168
|
+
"""
|
|
169
|
+
try:
|
|
170
|
+
yield
|
|
171
|
+
except ExistNotFoundError:
|
|
172
|
+
typer.echo(f"Error: path '{path}' not found in collection '{nick}'.", err=True)
|
|
173
|
+
raise typer.Exit(1)
|
|
174
|
+
except ExistAuthError:
|
|
175
|
+
typer.echo(f"Error: authentication failed for server '{server_nick}'.", err=True)
|
|
176
|
+
raise typer.Exit(1)
|
|
177
|
+
except ExistConnectionError as e:
|
|
178
|
+
typer.echo(f"Error: {e}", err=True)
|
|
179
|
+
raise typer.Exit(1)
|