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/__init__.py
ADDED
exist_shell/cache.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""File-based TTL cache for shell completion listings."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from exist_shell.config import Config
|
|
9
|
+
from exist_shell.models import CollectionEntry, CollectionItem, GroupEntry, ResourceEntry, UserEntry
|
|
10
|
+
|
|
11
|
+
CACHE_TTL = 5.0
|
|
12
|
+
SERVER_CACHE_TTL = 60.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get_cache_dir() -> Path:
|
|
16
|
+
"""Return the completions cache directory, resolved from the active config.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Path to the completions cache directory.
|
|
20
|
+
"""
|
|
21
|
+
return Config.load().resolved_cache_dir() / "completions"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _cache_path(nick: str, dir_path: str) -> Path:
|
|
25
|
+
"""Return the cache file path for a (nick, dir_path) pair.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
nick: Collection nick name.
|
|
29
|
+
dir_path: Directory path within the collection (e.g. ``/books/``).
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Path to the cache file.
|
|
33
|
+
"""
|
|
34
|
+
digest = hashlib.sha256(dir_path.encode()).hexdigest()[:16]
|
|
35
|
+
return _get_cache_dir() / f"{nick}@{digest}.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_cached(nick: str, dir_path: str) -> list[CollectionItem] | None:
|
|
39
|
+
"""Return a cached directory listing if it exists and is still fresh.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
nick: Collection nick name.
|
|
43
|
+
dir_path: Directory path within the collection.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
List of ``CollectionItem`` objects, or ``None`` on cache miss or expiry.
|
|
47
|
+
"""
|
|
48
|
+
path = _cache_path(nick, dir_path)
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(path.read_text())
|
|
51
|
+
if time.time() - data["ts"] > CACHE_TTL:
|
|
52
|
+
return None
|
|
53
|
+
items: list[CollectionItem] = []
|
|
54
|
+
for raw in data["items"]:
|
|
55
|
+
kind = raw.pop("kind")
|
|
56
|
+
if kind == "collection":
|
|
57
|
+
items.append(CollectionEntry.model_validate(raw))
|
|
58
|
+
else:
|
|
59
|
+
items.append(ResourceEntry.model_validate(raw))
|
|
60
|
+
return items
|
|
61
|
+
except Exception:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def set_cached(nick: str, dir_path: str, items: list[CollectionItem]) -> None:
|
|
66
|
+
"""Write a directory listing to the cache atomically.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
nick: Collection nick name.
|
|
70
|
+
dir_path: Directory path within the collection.
|
|
71
|
+
items: List of ``CollectionItem`` objects to cache.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
_get_cache_dir().mkdir(parents=True, exist_ok=True)
|
|
75
|
+
serialized = []
|
|
76
|
+
for item in items:
|
|
77
|
+
d = item.model_dump()
|
|
78
|
+
d["kind"] = "collection" if isinstance(item, CollectionEntry) else "resource"
|
|
79
|
+
serialized.append(d)
|
|
80
|
+
payload = json.dumps({"ts": time.time(), "items": serialized})
|
|
81
|
+
cache_path = _cache_path(nick, dir_path)
|
|
82
|
+
tmp = cache_path.with_suffix(".tmp")
|
|
83
|
+
tmp.write_text(payload)
|
|
84
|
+
tmp.rename(cache_path)
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _server_cache_path(server_nick: str, kind: str) -> Path:
|
|
90
|
+
"""Return the cache file path for a (server_nick, kind) pair.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
server_nick: Server nick name.
|
|
94
|
+
kind: Data kind — ``"users"`` or ``"groups"``.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Path to the cache file.
|
|
98
|
+
"""
|
|
99
|
+
return _get_cache_dir() / f"{server_nick}@{kind}.json"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_cached_users(server_nick: str) -> list[UserEntry] | None:
|
|
103
|
+
"""Return a cached user list if it exists and is still fresh.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
server_nick: Server nick name.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
List of ``UserEntry`` objects, or ``None`` on cache miss or expiry.
|
|
110
|
+
"""
|
|
111
|
+
path = _server_cache_path(server_nick, "users")
|
|
112
|
+
try:
|
|
113
|
+
data = json.loads(path.read_text())
|
|
114
|
+
if time.time() - data["ts"] > SERVER_CACHE_TTL:
|
|
115
|
+
return None
|
|
116
|
+
return [UserEntry.model_validate(u) for u in data["items"]]
|
|
117
|
+
except Exception:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def set_cached_users(server_nick: str, users: list[UserEntry]) -> None:
|
|
122
|
+
"""Write a user list to the cache atomically.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
server_nick: Server nick name.
|
|
126
|
+
users: List of ``UserEntry`` objects to cache.
|
|
127
|
+
"""
|
|
128
|
+
try:
|
|
129
|
+
_get_cache_dir().mkdir(parents=True, exist_ok=True)
|
|
130
|
+
payload = json.dumps({"ts": time.time(), "items": [u.model_dump() for u in users]})
|
|
131
|
+
cache_path = _server_cache_path(server_nick, "users")
|
|
132
|
+
tmp = cache_path.with_suffix(".tmp")
|
|
133
|
+
tmp.write_text(payload)
|
|
134
|
+
tmp.rename(cache_path)
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_cached_groups(server_nick: str) -> list[GroupEntry] | None:
|
|
140
|
+
"""Return a cached group list if it exists and is still fresh.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
server_nick: Server nick name.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
List of ``GroupEntry`` objects, or ``None`` on cache miss or expiry.
|
|
147
|
+
"""
|
|
148
|
+
path = _server_cache_path(server_nick, "groups")
|
|
149
|
+
try:
|
|
150
|
+
data = json.loads(path.read_text())
|
|
151
|
+
if time.time() - data["ts"] > SERVER_CACHE_TTL:
|
|
152
|
+
return None
|
|
153
|
+
return [GroupEntry.model_validate(g) for g in data["items"]]
|
|
154
|
+
except Exception:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def set_cached_groups(server_nick: str, groups: list[GroupEntry]) -> None:
|
|
159
|
+
"""Write a group list to the cache atomically.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
server_nick: Server nick name.
|
|
163
|
+
groups: List of ``GroupEntry`` objects to cache.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
_get_cache_dir().mkdir(parents=True, exist_ok=True)
|
|
167
|
+
payload = json.dumps({"ts": time.time(), "items": [g.model_dump() for g in groups]})
|
|
168
|
+
cache_path = _server_cache_path(server_nick, "groups")
|
|
169
|
+
tmp = cache_path.with_suffix(".tmp")
|
|
170
|
+
tmp.write_text(payload)
|
|
171
|
+
tmp.rename(cache_path)
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def invalidate(nick: str) -> None:
|
|
177
|
+
"""Delete all cached listings for the given nick.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
nick: Collection nick name whose cache entries should be removed.
|
|
181
|
+
"""
|
|
182
|
+
try:
|
|
183
|
+
for f in _get_cache_dir().glob(f"{nick}@*.json"):
|
|
184
|
+
f.unlink(missing_ok=True)
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""HTTP client for the eXist-db REST API."""
|
|
2
|
+
|
|
3
|
+
from exist_shell.client._collections import CollectionMixin
|
|
4
|
+
from exist_shell.client._documents import DocumentMixin
|
|
5
|
+
from exist_shell.client._groups import GroupMixin
|
|
6
|
+
from exist_shell.client._permissions import PermissionMixin
|
|
7
|
+
from exist_shell.client._users import UserMixin
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExistClient(CollectionMixin, DocumentMixin, UserMixin, GroupMixin, PermissionMixin):
|
|
11
|
+
"""HTTP client scoped to a single eXist-db server.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
server: The server configuration to connect to.
|
|
15
|
+
timeout: Request timeout in seconds.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["ExistClient"]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Base HTTP client — connection setup and low-level helpers."""
|
|
2
|
+
|
|
3
|
+
from typing import Self
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from exist_shell.config import Server
|
|
9
|
+
from exist_shell.exceptions import ExistAuthError, ExistConnectionError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExistClientBase:
|
|
13
|
+
"""Core HTTP plumbing shared by all domain mixins.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
server: The server configuration to connect to.
|
|
17
|
+
timeout: Request timeout in seconds.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, server: Server, timeout: float = 30.0) -> None:
|
|
21
|
+
"""Initialize the client and open an HTTP connection."""
|
|
22
|
+
self._base = f"http://{server.host}:{server.port}/exist"
|
|
23
|
+
self._http = httpx.Client(
|
|
24
|
+
auth=(server.user, server.password.get_secret_value()),
|
|
25
|
+
timeout=timeout,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def _url(self, path: str) -> str:
|
|
29
|
+
"""Build a percent-encoded REST URL for the given eXist path.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
path: Full eXist path starting with /db/ (e.g. /db/myapp/doc.xml).
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Absolute URL safe to pass to httpx.
|
|
36
|
+
"""
|
|
37
|
+
return f"{self._base}/rest{quote(path, safe='/')}"
|
|
38
|
+
|
|
39
|
+
def check_connection(self) -> None:
|
|
40
|
+
"""Verify connectivity and credentials against the server.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ExistConnectionError: If the server cannot be reached.
|
|
44
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
45
|
+
"""
|
|
46
|
+
url = f"{self._base}/rest/db"
|
|
47
|
+
try:
|
|
48
|
+
r = self._http.get(url)
|
|
49
|
+
except httpx.RequestError as e:
|
|
50
|
+
raise ExistConnectionError(url, e) from e
|
|
51
|
+
if r.status_code == 401:
|
|
52
|
+
raise ExistAuthError(url)
|
|
53
|
+
r.raise_for_status()
|
|
54
|
+
|
|
55
|
+
def close(self) -> None:
|
|
56
|
+
"""Close the underlying HTTP connection."""
|
|
57
|
+
self._http.close()
|
|
58
|
+
|
|
59
|
+
def __enter__(self) -> Self:
|
|
60
|
+
"""Enter the context manager."""
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def __exit__(self, *_: object) -> None:
|
|
64
|
+
"""Exit the context manager and close the connection."""
|
|
65
|
+
self.close()
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Collections mixin — collection-level operations."""
|
|
2
|
+
|
|
3
|
+
import xml.etree.ElementTree as ET
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from exist_shell.client._queries import QueryMixin
|
|
8
|
+
from exist_shell.exceptions import ExistAuthError, ExistConnectionError, ExistNotFoundError, ExistQueryError
|
|
9
|
+
from exist_shell.models import CollectionEntry, CollectionItem, ResourceEntry
|
|
10
|
+
|
|
11
|
+
_EXIST_NS = "http://exist.sourceforge.net/NS/exist"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CollectionMixin(QueryMixin):
|
|
15
|
+
"""Mixin providing collection-level operations against the eXist REST API."""
|
|
16
|
+
|
|
17
|
+
def collection_exists(self, name: str) -> bool:
|
|
18
|
+
"""Check whether a top-level collection exists under /db/.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
name: Collection name (without the /db/ prefix).
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
True if the collection exists, False if 404.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
ExistConnectionError: If the server cannot be reached.
|
|
28
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
29
|
+
"""
|
|
30
|
+
url = self._url(f"/db/{name}")
|
|
31
|
+
try:
|
|
32
|
+
r = self._http.get(url)
|
|
33
|
+
except httpx.RequestError as e:
|
|
34
|
+
raise ExistConnectionError(url, e) from e
|
|
35
|
+
if r.status_code == 401:
|
|
36
|
+
raise ExistAuthError(url)
|
|
37
|
+
return r.status_code in (200, 207)
|
|
38
|
+
|
|
39
|
+
def list_collection(self, path: str) -> list[CollectionItem]:
|
|
40
|
+
"""List subcollections and resources at the given eXist path.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
path: Full eXist path starting with /db/ (e.g. /db/myapp/sub).
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Ordered list of CollectionEntry and ResourceEntry objects.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ExistConnectionError: If the server cannot be reached.
|
|
50
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
51
|
+
ExistNotFoundError: If the path does not exist.
|
|
52
|
+
"""
|
|
53
|
+
url = self._url(path)
|
|
54
|
+
try:
|
|
55
|
+
r = self._http.get(url)
|
|
56
|
+
except httpx.RequestError as e:
|
|
57
|
+
raise ExistConnectionError(url, e) from e
|
|
58
|
+
if r.status_code == 401:
|
|
59
|
+
raise ExistAuthError(url)
|
|
60
|
+
if r.status_code == 404:
|
|
61
|
+
raise ExistNotFoundError(path)
|
|
62
|
+
r.raise_for_status()
|
|
63
|
+
items: list[CollectionItem] = []
|
|
64
|
+
root = ET.fromstring(r.text)
|
|
65
|
+
col = root.find(f"{{{_EXIST_NS}}}collection")
|
|
66
|
+
if col is not None:
|
|
67
|
+
for el in col.findall(f"{{{_EXIST_NS}}}collection"):
|
|
68
|
+
items.append(CollectionEntry(
|
|
69
|
+
name=el.get("name", ""),
|
|
70
|
+
created=el.get("created"),
|
|
71
|
+
owner=el.get("owner"),
|
|
72
|
+
group=el.get("group"),
|
|
73
|
+
permissions=el.get("permissions"),
|
|
74
|
+
))
|
|
75
|
+
for el in col.findall(f"{{{_EXIST_NS}}}resource"):
|
|
76
|
+
items.append(ResourceEntry(
|
|
77
|
+
name=el.get("name", ""),
|
|
78
|
+
created=el.get("created"),
|
|
79
|
+
last_modified=el.get("last-modified"),
|
|
80
|
+
owner=el.get("owner"),
|
|
81
|
+
group=el.get("group"),
|
|
82
|
+
permissions=el.get("permissions"),
|
|
83
|
+
size=int(el.get("size", 0)) or None,
|
|
84
|
+
mime_type=el.get("mime-type"),
|
|
85
|
+
))
|
|
86
|
+
return items
|
|
87
|
+
|
|
88
|
+
def create_collection(self, path: str) -> None:
|
|
89
|
+
"""Create a collection at the given eXist path.
|
|
90
|
+
|
|
91
|
+
Intermediate collections are created automatically if they do not exist.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
path: Full eXist path starting with /db/ (e.g. /db/myapp/newcoll).
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
ExistConnectionError: If the server cannot be reached.
|
|
98
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
99
|
+
ExistNotFoundError: If the collection cannot be created.
|
|
100
|
+
"""
|
|
101
|
+
clean = path.rstrip("/")
|
|
102
|
+
# fold-left walks each path segment from /db downward, threading the
|
|
103
|
+
# parent path as the accumulator. xmldb:create-collection is idempotent
|
|
104
|
+
# (existing collections return their path without error), so intermediate
|
|
105
|
+
# levels are created only when missing. [1] keeps the path string and
|
|
106
|
+
# discards the create-collection return value from the sequence.
|
|
107
|
+
query = (
|
|
108
|
+
'xquery version "3.1"; '
|
|
109
|
+
f'let $parts := tokenize("{clean}", "/")[. != ""] '
|
|
110
|
+
"let $_ := fold-left(tail($parts), \"/\" || head($parts), function($parent, $seg) { "
|
|
111
|
+
" let $new := $parent || \"/\" || $seg "
|
|
112
|
+
" return ($new, xmldb:create-collection($parent, $seg))[1] "
|
|
113
|
+
"}) "
|
|
114
|
+
"return ()"
|
|
115
|
+
)
|
|
116
|
+
try:
|
|
117
|
+
self.execute_query(query, context="/db")
|
|
118
|
+
except ExistQueryError:
|
|
119
|
+
raise ExistNotFoundError(path)
|
|
120
|
+
|
|
121
|
+
def delete_collection(self, path: str) -> None:
|
|
122
|
+
"""Delete an empty collection at the given eXist path.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
path: Full eXist path starting with /db/ (e.g. /db/myapp/subcol).
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
ExistConnectionError: If the server cannot be reached.
|
|
129
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
130
|
+
ExistNotFoundError: If the path does not exist.
|
|
131
|
+
"""
|
|
132
|
+
url = self._url(path)
|
|
133
|
+
try:
|
|
134
|
+
r = self._http.delete(url)
|
|
135
|
+
except httpx.RequestError as e:
|
|
136
|
+
raise ExistConnectionError(url, e) from e
|
|
137
|
+
if r.status_code == 401:
|
|
138
|
+
raise ExistAuthError(url)
|
|
139
|
+
if r.status_code == 404:
|
|
140
|
+
raise ExistNotFoundError(path)
|
|
141
|
+
r.raise_for_status()
|
|
142
|
+
|
|
143
|
+
def is_collection(self, path: str) -> bool:
|
|
144
|
+
"""Return True if path is an existing collection, False if it is a document or absent.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
path: Full eXist path starting with /db/ (e.g. /db/myapp/sub).
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
True when a collection exists at that path, False otherwise.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
ExistConnectionError: If the server cannot be reached.
|
|
154
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
155
|
+
"""
|
|
156
|
+
query = f'xquery version "3.1"; xmldb:collection-available("{path}")'
|
|
157
|
+
result = self.execute_query(query)
|
|
158
|
+
return result.strip() == "true"
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Documents mixin — document-level operations."""
|
|
2
|
+
|
|
3
|
+
from pathlib import PurePosixPath
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from exist_shell.client._queries import QueryMixin
|
|
8
|
+
from exist_shell.exceptions import ExistAuthError, ExistConnectionError, ExistNotFoundError, ExistQueryError
|
|
9
|
+
from exist_shell.models import DocumentResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DocumentMixin(QueryMixin):
|
|
13
|
+
"""Mixin providing document-level operations against the eXist REST API."""
|
|
14
|
+
|
|
15
|
+
def get_document(self, path: str) -> DocumentResult:
|
|
16
|
+
"""Retrieve a document's raw bytes and declared MIME type.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
path: Full eXist path starting with /db/ (e.g. /db/myapp/doc.xml).
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
DocumentResult with the raw content bytes and MIME type string.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
ExistConnectionError: If the server cannot be reached.
|
|
26
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
27
|
+
ExistNotFoundError: If the path does not exist.
|
|
28
|
+
"""
|
|
29
|
+
url = self._url(path)
|
|
30
|
+
try:
|
|
31
|
+
r = self._http.get(url)
|
|
32
|
+
except httpx.RequestError as e:
|
|
33
|
+
raise ExistConnectionError(url, e) from e
|
|
34
|
+
if r.status_code == 401:
|
|
35
|
+
raise ExistAuthError(url)
|
|
36
|
+
if r.status_code == 404:
|
|
37
|
+
raise ExistNotFoundError(path)
|
|
38
|
+
r.raise_for_status()
|
|
39
|
+
mime_type = r.headers.get("content-type", "application/octet-stream").split(";")[0].strip()
|
|
40
|
+
return DocumentResult(content=r.content, mime_type=mime_type)
|
|
41
|
+
|
|
42
|
+
def put_document(self, path: str, content: bytes, mime_type: str) -> None:
|
|
43
|
+
"""Store a document at the given eXist path.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
path: Full eXist path starting with /db/ (e.g. /db/myapp/doc.xml).
|
|
47
|
+
content: Raw document bytes.
|
|
48
|
+
mime_type: MIME type sent as the Content-Type header.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ExistConnectionError: If the server cannot be reached.
|
|
52
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
53
|
+
ExistNotFoundError: If the parent collection does not exist.
|
|
54
|
+
"""
|
|
55
|
+
url = self._url(path)
|
|
56
|
+
try:
|
|
57
|
+
r = self._http.put(url, content=content, headers={"Content-Type": mime_type})
|
|
58
|
+
except httpx.RequestError as e:
|
|
59
|
+
raise ExistConnectionError(url, e) from e
|
|
60
|
+
if r.status_code == 401:
|
|
61
|
+
raise ExistAuthError(url)
|
|
62
|
+
if r.status_code == 404:
|
|
63
|
+
raise ExistNotFoundError(path)
|
|
64
|
+
r.raise_for_status()
|
|
65
|
+
|
|
66
|
+
def delete_document(self, path: str) -> None:
|
|
67
|
+
"""Delete a document at the given eXist path.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
path: Full eXist path starting with /db/ (e.g. /db/myapp/doc.xml).
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
ExistConnectionError: If the server cannot be reached.
|
|
74
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
75
|
+
ExistNotFoundError: If the path does not exist.
|
|
76
|
+
"""
|
|
77
|
+
url = self._url(path)
|
|
78
|
+
try:
|
|
79
|
+
r = self._http.delete(url)
|
|
80
|
+
except httpx.RequestError as e:
|
|
81
|
+
raise ExistConnectionError(url, e) from e
|
|
82
|
+
if r.status_code == 401:
|
|
83
|
+
raise ExistAuthError(url)
|
|
84
|
+
if r.status_code == 404:
|
|
85
|
+
raise ExistNotFoundError(path)
|
|
86
|
+
r.raise_for_status()
|
|
87
|
+
|
|
88
|
+
def move_document(self, src_path: str, dst_path: str) -> None:
|
|
89
|
+
"""Move or rename a single document using XQuery.
|
|
90
|
+
|
|
91
|
+
Selects the most efficient XQuery call based on the relationship
|
|
92
|
+
between source and destination:
|
|
93
|
+
|
|
94
|
+
- Same parent → ``xmldb:rename``
|
|
95
|
+
- Different parent, same name → ``xmldb:move``
|
|
96
|
+
- Different parent, different name → ``xmldb:move`` then ``xmldb:rename``
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
src_path: Full eXist path of the source document.
|
|
100
|
+
dst_path: Full eXist path of the destination document.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ExistConnectionError: If the server cannot be reached.
|
|
104
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
105
|
+
ExistNotFoundError: If the source or destination parent does not exist.
|
|
106
|
+
"""
|
|
107
|
+
src = PurePosixPath(src_path.rstrip("/"))
|
|
108
|
+
dst = PurePosixPath(dst_path.rstrip("/"))
|
|
109
|
+
src_parent = str(src.parent)
|
|
110
|
+
src_name = src.name
|
|
111
|
+
dst_parent = str(dst.parent)
|
|
112
|
+
dst_name = dst.name
|
|
113
|
+
|
|
114
|
+
if src_parent == dst_parent:
|
|
115
|
+
query = (
|
|
116
|
+
f'xquery version "3.1"; '
|
|
117
|
+
f'xmldb:rename("{src_parent}", "{src_name}", "{dst_name}")'
|
|
118
|
+
)
|
|
119
|
+
elif src_name == dst_name:
|
|
120
|
+
query = (
|
|
121
|
+
f'xquery version "3.1"; '
|
|
122
|
+
f'xmldb:move("{src_parent}", "{dst_parent}", "{src_name}")'
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
query = (
|
|
126
|
+
f'xquery version "3.1"; '
|
|
127
|
+
f'let $_ := xmldb:move("{src_parent}", "{dst_parent}", "{src_name}") '
|
|
128
|
+
f'return xmldb:rename("{dst_parent}", "{src_name}", "{dst_name}")'
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
self.execute_query(query)
|
|
133
|
+
except ExistQueryError as e:
|
|
134
|
+
raise ExistNotFoundError(src_path) from e
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Groups mixin — group management operations."""
|
|
2
|
+
|
|
3
|
+
import xml.etree.ElementTree as ET
|
|
4
|
+
|
|
5
|
+
from exist_shell.client._queries import QueryMixin
|
|
6
|
+
from exist_shell.models import GroupEntry
|
|
7
|
+
from exist_shell.utils import xq_escape
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GroupMixin(QueryMixin):
|
|
11
|
+
"""Mixin providing group management operations against the eXist REST API."""
|
|
12
|
+
|
|
13
|
+
def list_groups(self) -> list[GroupEntry]:
|
|
14
|
+
"""List all groups and their members.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
List of GroupEntry objects sorted alphabetically by group name.
|
|
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
|
+
'<groups>{ '
|
|
27
|
+
'for $g in sm:list-groups() '
|
|
28
|
+
'let $members := sm:get-group-members($g) '
|
|
29
|
+
'order by $g '
|
|
30
|
+
'return <group name="{$g}" members="{string-join($members, ",")}"/> '
|
|
31
|
+
'}</groups>'
|
|
32
|
+
)
|
|
33
|
+
raw = self.execute_query(query)
|
|
34
|
+
root = ET.fromstring(raw)
|
|
35
|
+
result: list[GroupEntry] = []
|
|
36
|
+
for el in root.findall("group"):
|
|
37
|
+
members_str = el.get("members", "")
|
|
38
|
+
members = [m for m in members_str.split(",") if m]
|
|
39
|
+
result.append(GroupEntry(name=el.get("name", ""), members=members))
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
def group_exists(self, groupname: str) -> bool:
|
|
43
|
+
"""Check whether a group exists on the server.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
groupname: The group name to check.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
True if the group exists, False otherwise.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ExistConnectionError: If the server cannot be reached.
|
|
53
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
54
|
+
ExistQueryError: If the query fails.
|
|
55
|
+
"""
|
|
56
|
+
safe = xq_escape(groupname)
|
|
57
|
+
query = f'xquery version "3.1"; sm:group-exists("{safe}")'
|
|
58
|
+
return self.execute_query(query).strip() == "true"
|
|
59
|
+
|
|
60
|
+
def get_group_members(self, groupname: str) -> list[str]:
|
|
61
|
+
"""Return the list of members belonging to a group.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
groupname: The group name to look up.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
List of member usernames belonging to the group.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ExistConnectionError: If the server cannot be reached.
|
|
71
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
72
|
+
ExistQueryError: If the group does not exist or the query fails.
|
|
73
|
+
"""
|
|
74
|
+
safe = xq_escape(groupname)
|
|
75
|
+
query = (
|
|
76
|
+
'xquery version "3.1"; '
|
|
77
|
+
f'if (not(sm:group-exists("{safe}"))) '
|
|
78
|
+
f'then error((), "Group not found: {safe}") '
|
|
79
|
+
f'else '
|
|
80
|
+
f'let $members := sm:get-group-members("{safe}") '
|
|
81
|
+
f'return <members>{{string-join($members, ",")}}</members>'
|
|
82
|
+
)
|
|
83
|
+
raw = self.execute_query(query)
|
|
84
|
+
el = ET.fromstring(raw)
|
|
85
|
+
members_str = el.text or ""
|
|
86
|
+
return [m for m in members_str.split(",") if m]
|
|
87
|
+
|
|
88
|
+
def create_group(self, groupname: str) -> None:
|
|
89
|
+
"""Create a new group.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
groupname: The group name to create.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ExistConnectionError: If the server cannot be reached.
|
|
96
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
97
|
+
ExistQueryError: If the group already exists or the query fails.
|
|
98
|
+
"""
|
|
99
|
+
safe = xq_escape(groupname)
|
|
100
|
+
query = f'xquery version "3.1"; sm:create-group("{safe}")'
|
|
101
|
+
self.execute_query(query)
|
|
102
|
+
|
|
103
|
+
def delete_group(self, groupname: str) -> None:
|
|
104
|
+
"""Remove a group from the server.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
groupname: The group name to remove.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
ExistConnectionError: If the server cannot be reached.
|
|
111
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
112
|
+
ExistQueryError: If the group does not exist or the query fails.
|
|
113
|
+
"""
|
|
114
|
+
safe = xq_escape(groupname)
|
|
115
|
+
query = f'xquery version "3.1"; sm:remove-group("{safe}")'
|
|
116
|
+
self.execute_query(query)
|