xpipe_api 0.1.30__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.
- xpipe_api/__init__.py +2 -0
- xpipe_api/clients.py +469 -0
- xpipe_api/exceptions.py +17 -0
- xpipe_api-0.1.30.dist-info/LICENSE +21 -0
- xpipe_api-0.1.30.dist-info/METADATA +44 -0
- xpipe_api-0.1.30.dist-info/RECORD +7 -0
- xpipe_api-0.1.30.dist-info/WHEEL +4 -0
xpipe_api/__init__.py
ADDED
xpipe_api/clients.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import BinaryIO, List, Optional, Union
|
|
6
|
+
|
|
7
|
+
import aiohttp.web_response
|
|
8
|
+
import requests
|
|
9
|
+
from aiohttp import ClientResponseError
|
|
10
|
+
from aiohttp_requests import requests as async_requests
|
|
11
|
+
from packaging.version import Version
|
|
12
|
+
|
|
13
|
+
from .exceptions import AuthFailedException, NoTokenFoundException, error_code_map
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Client:
|
|
17
|
+
token: str
|
|
18
|
+
auth_type: str
|
|
19
|
+
base_url: str
|
|
20
|
+
raise_errors: bool
|
|
21
|
+
session: Optional[str] = None
|
|
22
|
+
min_version: Version = Version("17.0")
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self, token: Optional[str] = None, base_url: Optional[str] = None, ptb: bool = False, raise_errors: bool = True
|
|
26
|
+
):
|
|
27
|
+
auth_type = "ApiKey"
|
|
28
|
+
# Try getting the auth from the local filesystem if none is provided
|
|
29
|
+
if not token:
|
|
30
|
+
try:
|
|
31
|
+
auth_type = "Local"
|
|
32
|
+
auth_filename = "xpipe_ptb_auth" if ptb else "xpipe_auth"
|
|
33
|
+
# Look for Windows or Mac env vars for tmpdir, fall back to /tmp if they don't exist
|
|
34
|
+
auth_file = Path(os.getenv("TEMP") or os.getenv("TMPDIR") or "/tmp") / auth_filename
|
|
35
|
+
token = auth_file.read_text().strip()
|
|
36
|
+
except PermissionError:
|
|
37
|
+
raise NoTokenFoundException("Bad permissions on xpipe_auth: is the daemon running as another user?")
|
|
38
|
+
except Exception as e:
|
|
39
|
+
raise NoTokenFoundException(f"No auth provided and couldn't load xpipe_auth: {e!r}. Is the XPipe daemon running?")
|
|
40
|
+
|
|
41
|
+
port = int(os.environ.get('XPIPE_BEACON_PORT', "21721"))
|
|
42
|
+
if ptb:
|
|
43
|
+
port = port + 1
|
|
44
|
+
|
|
45
|
+
if not base_url:
|
|
46
|
+
base_url = "http://127.0.0.1:" + str(port) if ptb else "http://127.0.0.1:" + str(port)
|
|
47
|
+
|
|
48
|
+
self.token = token
|
|
49
|
+
self.auth_type = auth_type
|
|
50
|
+
self.base_url = base_url.strip("/")
|
|
51
|
+
self.raise_errors = raise_errors
|
|
52
|
+
|
|
53
|
+
def renew_session(self):
|
|
54
|
+
if self.auth_type == "ApiKey":
|
|
55
|
+
auth = {"type": self.auth_type, "key": self.token}
|
|
56
|
+
else:
|
|
57
|
+
auth = {"type": self.auth_type, "authFileContent": self.token}
|
|
58
|
+
data = {"auth": auth, "client": {"type": "Api", "name": "python_xpipe_api"}}
|
|
59
|
+
result = requests.post(f"{self.base_url}/handshake", json=data)
|
|
60
|
+
response = result.json()
|
|
61
|
+
session = response.get("sessionToken", None)
|
|
62
|
+
if session:
|
|
63
|
+
self.session = session
|
|
64
|
+
daemon_version = self.daemon_version()["version"]
|
|
65
|
+
assert (
|
|
66
|
+
daemon_version == "dev" or Version(daemon_version) >= self.min_version
|
|
67
|
+
), f"xpipe_api requires XPipe of at least {self.min_version}"
|
|
68
|
+
else:
|
|
69
|
+
raise AuthFailedException(json.dumps(response))
|
|
70
|
+
|
|
71
|
+
def _post(self, *args, **kwargs) -> requests.Response:
|
|
72
|
+
if not self.session:
|
|
73
|
+
self.renew_session()
|
|
74
|
+
kwargs.setdefault("headers", {})["Authorization"] = f"Bearer {self.session}"
|
|
75
|
+
resp: requests.Response = requests.post(*args, **kwargs)
|
|
76
|
+
status_code, reason = resp.status_code, error_code_map.get(resp.status_code, "Unknown Code")
|
|
77
|
+
if self.raise_errors and status_code >= 400:
|
|
78
|
+
message = f"{status_code} {reason} for url: {resp.url}"
|
|
79
|
+
# Attempt to enrich the message with the parsed reason
|
|
80
|
+
if status_code == 400:
|
|
81
|
+
with suppress(Exception):
|
|
82
|
+
message = f'Client Error for {resp.url}: {resp.json()["message"]}'
|
|
83
|
+
elif status_code == 500:
|
|
84
|
+
with suppress(Exception):
|
|
85
|
+
message = f'Server Error for {resp.url}: {resp.json()["error"]["message"]}'
|
|
86
|
+
raise requests.HTTPError(message, response=resp)
|
|
87
|
+
return resp
|
|
88
|
+
|
|
89
|
+
def post(self, *args, **kwargs) -> bytes:
|
|
90
|
+
return self._post(*args, **kwargs).content
|
|
91
|
+
|
|
92
|
+
def _get(self, *args, **kwargs) -> requests.Response:
|
|
93
|
+
if not self.session:
|
|
94
|
+
self.renew_session()
|
|
95
|
+
kwargs.setdefault("headers", {})["Authorization"] = f"Bearer {self.session}"
|
|
96
|
+
resp = requests.get(*args, **kwargs)
|
|
97
|
+
status_code, reason = resp.status_code, error_code_map.get(resp.status_code, "Unknown Code")
|
|
98
|
+
if self.raise_errors and status_code >= 400:
|
|
99
|
+
message = f"{status_code} {reason} for url: {resp.url}"
|
|
100
|
+
# Attempt to enrich the message with the parsed reason
|
|
101
|
+
if status_code == 400:
|
|
102
|
+
try:
|
|
103
|
+
message = f'Client Error for {resp.url}: {resp.json()["message"]}'
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
elif status_code == 500:
|
|
107
|
+
try:
|
|
108
|
+
message = f'Server Error for {resp.url}: {resp.json()["error"]["message"]}'
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
raise requests.HTTPError(message, response=resp)
|
|
112
|
+
return resp
|
|
113
|
+
|
|
114
|
+
def get(self, *args, **kwargs) -> bytes:
|
|
115
|
+
return self._get(*args, **kwargs).content
|
|
116
|
+
|
|
117
|
+
def connection_query(self, categories: str = "**", connections: str = "**", types: str = "*") -> List[str]:
|
|
118
|
+
endpoint = f"{self.base_url}/connection/query"
|
|
119
|
+
data = {"categoryFilter": categories, "connectionFilter": connections, "typeFilter": types}
|
|
120
|
+
response = self.post(endpoint, json=data)
|
|
121
|
+
return json.loads(response).get("found", [])
|
|
122
|
+
|
|
123
|
+
def connection_info(self, uuids: Union[str, List[str]]) -> List[dict]:
|
|
124
|
+
endpoint = f"{self.base_url}/connection/info"
|
|
125
|
+
# If we're passed a single UUID, wrap it in a list like the API expects
|
|
126
|
+
if not isinstance(uuids, list):
|
|
127
|
+
uuids = [uuids]
|
|
128
|
+
data = {"connections": uuids}
|
|
129
|
+
response = self.post(endpoint, json=data)
|
|
130
|
+
return json.loads(response).get("infos", [])
|
|
131
|
+
|
|
132
|
+
def connection_add(self, name: str, conn_data: dict, validate: bool = False, category: str = None) -> str:
|
|
133
|
+
endpoint = f"{self.base_url}/connection/add"
|
|
134
|
+
data = {"name": name, "data": conn_data, "validate": validate}
|
|
135
|
+
if category:
|
|
136
|
+
data["category"] = category
|
|
137
|
+
response = self.post(endpoint, json=data)
|
|
138
|
+
return json.loads(response)["connection"]
|
|
139
|
+
|
|
140
|
+
def category_add(self, name: str, parent: str) -> str:
|
|
141
|
+
endpoint = f"{self.base_url}/category/add"
|
|
142
|
+
data = {"name": name, "parent": parent}
|
|
143
|
+
response = self.post(endpoint, json=data)
|
|
144
|
+
return json.loads(response)["category"]
|
|
145
|
+
|
|
146
|
+
def category_query(self, category_filter: str = "**") -> List[str]:
|
|
147
|
+
endpoint = f"{self.base_url}/category/query"
|
|
148
|
+
data = {"filter": category_filter}
|
|
149
|
+
response = self.post(endpoint, json=data)
|
|
150
|
+
return json.loads(response).get("found", [])
|
|
151
|
+
|
|
152
|
+
def category_info(self, uuids: Union[str, List[str]]) -> List[dict]:
|
|
153
|
+
endpoint = f"{self.base_url}/category/info"
|
|
154
|
+
# If we're passed a single UUID, wrap it in a list like the API expects
|
|
155
|
+
if not isinstance(uuids, list):
|
|
156
|
+
uuids = [uuids]
|
|
157
|
+
data = {"categories": uuids}
|
|
158
|
+
response = self.post(endpoint, json=data)
|
|
159
|
+
return json.loads(response).get("infos", [])
|
|
160
|
+
|
|
161
|
+
def category_remove(self, uuids: Union[str, List[str]], remove_children_categories: bool, remove_contents: bool):
|
|
162
|
+
endpoint = f"{self.base_url}/category/remove"
|
|
163
|
+
if not isinstance(uuids, list):
|
|
164
|
+
uuids = [uuids]
|
|
165
|
+
data = {"categories": uuids, "removeChildrenCategories": remove_children_categories, "removeContents": remove_contents}
|
|
166
|
+
self.post(endpoint, json=data)
|
|
167
|
+
|
|
168
|
+
def connection_remove(self, uuids: Union[str, List[str]]):
|
|
169
|
+
endpoint = f"{self.base_url}/connection/remove"
|
|
170
|
+
if not isinstance(uuids, list):
|
|
171
|
+
uuids = [uuids]
|
|
172
|
+
data = {"connections": uuids}
|
|
173
|
+
self.post(endpoint, json=data)
|
|
174
|
+
|
|
175
|
+
def connection_refresh(self, connection: str):
|
|
176
|
+
endpoint = f"{self.base_url}/connection/refresh"
|
|
177
|
+
data = {"connection": connection}
|
|
178
|
+
self.post(endpoint, json=data)
|
|
179
|
+
|
|
180
|
+
def get_connections(self, categories: str = "**", connections: str = "**", types: str = "*") -> List[dict]:
|
|
181
|
+
"""Convenience method to chain connection/query with connection/info"""
|
|
182
|
+
uuids = self.connection_query(categories, connections, types)
|
|
183
|
+
return self.connection_info(uuids) if uuids else []
|
|
184
|
+
|
|
185
|
+
def daemon_version(self) -> dict:
|
|
186
|
+
endpoint = f"{self.base_url}/daemon/version"
|
|
187
|
+
response = self.get(endpoint)
|
|
188
|
+
return json.loads(response)
|
|
189
|
+
|
|
190
|
+
def shell_start(self, conn_uuid: str) -> dict:
|
|
191
|
+
endpoint = f"{self.base_url}/shell/start"
|
|
192
|
+
data = {"connection": conn_uuid}
|
|
193
|
+
response = self.post(endpoint, json=data)
|
|
194
|
+
return json.loads(response) if response else {}
|
|
195
|
+
|
|
196
|
+
def shell_stop(self, conn_uuid: str):
|
|
197
|
+
endpoint = f"{self.base_url}/shell/stop"
|
|
198
|
+
data = {"connection": conn_uuid}
|
|
199
|
+
self.post(endpoint, json=data)
|
|
200
|
+
|
|
201
|
+
def shell_exec(self, conn_uuid: str, command: str) -> dict:
|
|
202
|
+
endpoint = f"{self.base_url}/shell/exec"
|
|
203
|
+
data = {"connection": conn_uuid, "command": command}
|
|
204
|
+
response = self.post(endpoint, json=data)
|
|
205
|
+
return json.loads(response) if response else {}
|
|
206
|
+
|
|
207
|
+
def fs_blob(self, blob_data: Union[bytes, str, BinaryIO]) -> str:
|
|
208
|
+
endpoint = f"{self.base_url}/fs/blob"
|
|
209
|
+
if isinstance(blob_data, str):
|
|
210
|
+
blob_data = blob_data.encode("utf-8")
|
|
211
|
+
response = self.post(endpoint, data=blob_data)
|
|
212
|
+
return json.loads(response)["blob"]
|
|
213
|
+
|
|
214
|
+
def fs_write(self, connection: str, blob: str, path: str):
|
|
215
|
+
endpoint = f"{self.base_url}/fs/write"
|
|
216
|
+
data = {"connection": connection, "blob": blob, "path": path}
|
|
217
|
+
self.post(endpoint, json=data)
|
|
218
|
+
|
|
219
|
+
def fs_script(self, connection: str, blob: str) -> str:
|
|
220
|
+
endpoint = f"{self.base_url}/fs/script"
|
|
221
|
+
data = {"connection": connection, "blob": blob}
|
|
222
|
+
response = self.post(endpoint, json=data)
|
|
223
|
+
return json.loads(response)["path"]
|
|
224
|
+
|
|
225
|
+
def _fs_read(self, connection: str, path: str) -> requests.Response:
|
|
226
|
+
# Internal version of the function that returns the raw response object
|
|
227
|
+
# Here so clients can do things like stream the response to disk if it's a big file
|
|
228
|
+
endpoint = f"{self.base_url}/fs/read"
|
|
229
|
+
data = {"connection": connection, "path": path}
|
|
230
|
+
return self._post(endpoint, json=data, stream=True)
|
|
231
|
+
|
|
232
|
+
def fs_read(self, connection: str, path: str) -> bytes:
|
|
233
|
+
return self._fs_read(connection, path).content
|
|
234
|
+
|
|
235
|
+
def action(self, action_data: dict, confirm: bool):
|
|
236
|
+
endpoint = f"{self.base_url}/action"
|
|
237
|
+
data = {"action": action_data, "confirm": confirm}
|
|
238
|
+
self.post(endpoint, json=data)
|
|
239
|
+
|
|
240
|
+
def secret_encrypt(self, secret: str):
|
|
241
|
+
endpoint = f"{self.base_url}/secret/encrypt"
|
|
242
|
+
data = {"value": secret}
|
|
243
|
+
return json.loads(self.post(endpoint, json=data))["encrypted"]
|
|
244
|
+
|
|
245
|
+
def secret_decrypt(self, encrypted: dict):
|
|
246
|
+
endpoint = f"{self.base_url}/secret/decrypt"
|
|
247
|
+
data = {"encrypted": encrypted}
|
|
248
|
+
return json.loads(self.post(endpoint, json=data))["decrypted"]
|
|
249
|
+
|
|
250
|
+
class AsyncClient(Client):
|
|
251
|
+
@classmethod
|
|
252
|
+
def from_sync_client(cls, sync: Client) -> "AsyncClient":
|
|
253
|
+
async_client = cls(token=sync.token, base_url=sync.base_url, raise_errors=sync.raise_errors)
|
|
254
|
+
async_client.auth_type = sync.auth_type
|
|
255
|
+
async_client.session = sync.session
|
|
256
|
+
return async_client
|
|
257
|
+
|
|
258
|
+
async def renew_session(self):
|
|
259
|
+
if self.auth_type == "ApiKey":
|
|
260
|
+
auth = {"type": self.auth_type, "key": self.token}
|
|
261
|
+
else:
|
|
262
|
+
auth = {"type": self.auth_type, "authFileContent": self.token}
|
|
263
|
+
data = {"auth": auth, "client": {"type": "Api", "name": "python_xpipe_api"}}
|
|
264
|
+
|
|
265
|
+
resp = await async_requests.post(f"{self.base_url}/handshake", json=data)
|
|
266
|
+
parsed = await resp.json(content_type=None)
|
|
267
|
+
session_token = parsed.get("sessionToken", None)
|
|
268
|
+
if session_token:
|
|
269
|
+
self.session = session_token
|
|
270
|
+
daemon_version = (await self.daemon_version())["version"]
|
|
271
|
+
assert (
|
|
272
|
+
daemon_version == "dev" or Version(daemon_version) >= self.min_version
|
|
273
|
+
), f"xpipe_api requires XPipe of at least {self.min_version}"
|
|
274
|
+
else:
|
|
275
|
+
raise AuthFailedException(json.dumps(parsed))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
async def _post(self, *args, **kwargs) -> aiohttp.ClientResponse:
|
|
279
|
+
if not self.session:
|
|
280
|
+
await self.renew_session()
|
|
281
|
+
kwargs.setdefault("headers", {})["Authorization"] = f"Bearer {self.session}"
|
|
282
|
+
resp = await async_requests.post(*args, **kwargs)
|
|
283
|
+
if self.raise_errors and not resp.ok:
|
|
284
|
+
status_code, reason = resp.status, error_code_map.get(resp.status, "Unknown Code")
|
|
285
|
+
message = f"{status_code} {reason} for url: {resp.url}"
|
|
286
|
+
# Attempt to enrich the message with the parsed reason
|
|
287
|
+
text = await resp.text()
|
|
288
|
+
if status_code == 400:
|
|
289
|
+
with suppress(Exception):
|
|
290
|
+
message = f'Client Error: {json.loads(text)["message"]}'
|
|
291
|
+
elif status_code == 500:
|
|
292
|
+
with suppress(Exception):
|
|
293
|
+
message = f'Server Error: {json.loads(text)["error"]["message"]}'
|
|
294
|
+
raise ClientResponseError(
|
|
295
|
+
resp.request_info,
|
|
296
|
+
resp.history,
|
|
297
|
+
status=resp.status,
|
|
298
|
+
message=message,
|
|
299
|
+
headers=resp.headers,
|
|
300
|
+
)
|
|
301
|
+
return resp
|
|
302
|
+
|
|
303
|
+
async def post(self, *args, **kwargs) -> bytes:
|
|
304
|
+
resp = await self._post(*args, **kwargs)
|
|
305
|
+
return await resp.read()
|
|
306
|
+
|
|
307
|
+
async def _get(self, *args, **kwargs) -> aiohttp.ClientResponse:
|
|
308
|
+
if not self.session:
|
|
309
|
+
await self.renew_session()
|
|
310
|
+
kwargs.setdefault("headers", {})["Authorization"] = f"Bearer {self.session}"
|
|
311
|
+
resp = await async_requests.get(*args, **kwargs)
|
|
312
|
+
if self.raise_errors and not resp.ok:
|
|
313
|
+
status_code, reason = resp.status, error_code_map.get(resp.status, "Unknown Code")
|
|
314
|
+
message = f"{status_code} {reason} for url: {resp.url}"
|
|
315
|
+
# Attempt to enrich the message with the parsed reason
|
|
316
|
+
text = await resp.text()
|
|
317
|
+
if status_code == 400:
|
|
318
|
+
with suppress(Exception):
|
|
319
|
+
message = f'Client Error for {resp.url}: {json.loads(text)["message"]}'
|
|
320
|
+
elif status_code == 500:
|
|
321
|
+
with suppress(Exception):
|
|
322
|
+
message = f'Server Error for {resp.url}: {json.loads(text)["error"]["message"]}'
|
|
323
|
+
raise ClientResponseError(
|
|
324
|
+
resp.request_info,
|
|
325
|
+
resp.history,
|
|
326
|
+
status=resp.status,
|
|
327
|
+
message=message,
|
|
328
|
+
headers=resp.headers,
|
|
329
|
+
)
|
|
330
|
+
return resp
|
|
331
|
+
|
|
332
|
+
async def get(self, *args, **kwargs) -> bytes:
|
|
333
|
+
resp = await self._get(*args, **kwargs)
|
|
334
|
+
return await resp.read()
|
|
335
|
+
|
|
336
|
+
async def connection_query(self, categories: str = "**", connections: str = "**", types: str = "*") -> List[str]:
|
|
337
|
+
endpoint = f"{self.base_url}/connection/query"
|
|
338
|
+
data = {"categoryFilter": categories, "connectionFilter": connections, "typeFilter": types}
|
|
339
|
+
response = await self.post(endpoint, json=data)
|
|
340
|
+
return json.loads(response).get("found", [])
|
|
341
|
+
|
|
342
|
+
async def connection_info(self, uuids: Union[str, List[str]]) -> List[dict]:
|
|
343
|
+
endpoint = f"{self.base_url}/connection/info"
|
|
344
|
+
# If we're passed a single UUID, wrap it in a list like the API expects
|
|
345
|
+
if not isinstance(uuids, list):
|
|
346
|
+
uuids = [uuids]
|
|
347
|
+
data = {"connections": uuids}
|
|
348
|
+
response = await self.post(endpoint, json=data)
|
|
349
|
+
return json.loads(response).get("infos", [])
|
|
350
|
+
|
|
351
|
+
async def connection_add(self, name: str, conn_data: dict, validate: bool = False, category: str = None) -> str:
|
|
352
|
+
endpoint = f"{self.base_url}/connection/add"
|
|
353
|
+
data = {"name": name, "data": conn_data, "validate": validate}
|
|
354
|
+
if category:
|
|
355
|
+
data["category"] = category
|
|
356
|
+
response = await self.post(endpoint, json=data)
|
|
357
|
+
return json.loads(response)["connection"]
|
|
358
|
+
|
|
359
|
+
async def category_add(self, name: str, parent: str) -> str:
|
|
360
|
+
endpoint = f"{self.base_url}/category/add"
|
|
361
|
+
data = {"name": name, "parent": parent}
|
|
362
|
+
response = await self.post(endpoint, json=data)
|
|
363
|
+
return json.loads(response)["category"]
|
|
364
|
+
|
|
365
|
+
async def category_query(self, category_filter: str = "**") -> List[str]:
|
|
366
|
+
endpoint = f"{self.base_url}/category/query"
|
|
367
|
+
data = {"filter": category_filter}
|
|
368
|
+
response = await self.post(endpoint, json=data)
|
|
369
|
+
return json.loads(response).get("found", [])
|
|
370
|
+
|
|
371
|
+
async def category_info(self, uuids: Union[str, List[str]]) -> List[dict]:
|
|
372
|
+
endpoint = f"{self.base_url}/category/info"
|
|
373
|
+
# If we're passed a single UUID, wrap it in a list like the API expects
|
|
374
|
+
if not isinstance(uuids, list):
|
|
375
|
+
uuids = [uuids]
|
|
376
|
+
data = {"categories": uuids}
|
|
377
|
+
response = await self.post(endpoint, json=data)
|
|
378
|
+
return json.loads(response).get("infos", [])
|
|
379
|
+
|
|
380
|
+
async def category_remove(self, uuids: Union[str, List[str]], remove_children_categories: bool, remove_contents: bool):
|
|
381
|
+
endpoint = f"{self.base_url}/category/remove"
|
|
382
|
+
if not isinstance(uuids, list):
|
|
383
|
+
uuids = [uuids]
|
|
384
|
+
data = {"categories": uuids, "removeChildrenCategories": remove_children_categories, "removeContents": remove_contents}
|
|
385
|
+
await self.post(endpoint, json=data)
|
|
386
|
+
|
|
387
|
+
async def connection_remove(self, uuids: Union[str, List[str]]):
|
|
388
|
+
endpoint = f"{self.base_url}/connection/remove"
|
|
389
|
+
if not isinstance(uuids, list):
|
|
390
|
+
uuids = [uuids]
|
|
391
|
+
data = {"connections": uuids}
|
|
392
|
+
await self.post(endpoint, json=data)
|
|
393
|
+
|
|
394
|
+
async def connection_refresh(self, connection: str):
|
|
395
|
+
endpoint = f"{self.base_url}/connection/refresh"
|
|
396
|
+
data = {"connection": connection}
|
|
397
|
+
await self.post(endpoint, json=data)
|
|
398
|
+
|
|
399
|
+
async def get_connections(self, categories: str = "**", connections: str = "**", types: str = "*") -> List[dict]:
|
|
400
|
+
uuids = await self.connection_query(categories, connections, types)
|
|
401
|
+
return (await self.connection_info(uuids)) if uuids else []
|
|
402
|
+
|
|
403
|
+
async def daemon_version(self) -> dict:
|
|
404
|
+
endpoint = f"{self.base_url}/daemon/version"
|
|
405
|
+
response = await self.get(endpoint)
|
|
406
|
+
return json.loads(response)
|
|
407
|
+
|
|
408
|
+
async def shell_start(self, conn_uuid: str) -> dict:
|
|
409
|
+
endpoint = f"{self.base_url}/shell/start"
|
|
410
|
+
data = {"connection": conn_uuid}
|
|
411
|
+
response = await self.post(endpoint, json=data)
|
|
412
|
+
return json.loads(response) if response else {}
|
|
413
|
+
|
|
414
|
+
async def shell_stop(self, conn_uuid: str):
|
|
415
|
+
endpoint = f"{self.base_url}/shell/stop"
|
|
416
|
+
data = {"connection": conn_uuid}
|
|
417
|
+
await self.post(endpoint, json=data)
|
|
418
|
+
|
|
419
|
+
async def shell_exec(self, conn_uuid: str, command: str) -> dict:
|
|
420
|
+
endpoint = f"{self.base_url}/shell/exec"
|
|
421
|
+
data = {"connection": conn_uuid, "command": command}
|
|
422
|
+
response = await self.post(endpoint, json=data)
|
|
423
|
+
return json.loads(response)
|
|
424
|
+
|
|
425
|
+
async def fs_blob(self, blob_data: Union[bytes, str]) -> str:
|
|
426
|
+
endpoint = f"{self.base_url}/fs/blob"
|
|
427
|
+
if isinstance(blob_data, str):
|
|
428
|
+
blob_data = blob_data.encode("utf-8")
|
|
429
|
+
response = await self.post(endpoint, data=blob_data)
|
|
430
|
+
return json.loads(response)["blob"]
|
|
431
|
+
|
|
432
|
+
async def fs_write(self, connection: str, blob: str, path: str):
|
|
433
|
+
endpoint = f"{self.base_url}/fs/write"
|
|
434
|
+
data = {"connection": connection, "blob": blob, "path": path}
|
|
435
|
+
await self.post(endpoint, json=data)
|
|
436
|
+
|
|
437
|
+
async def fs_script(self, connection: str, blob: str) -> str:
|
|
438
|
+
endpoint = f"{self.base_url}/fs/script"
|
|
439
|
+
data = {"connection": connection, "blob": blob}
|
|
440
|
+
response = await self.post(endpoint, json=data)
|
|
441
|
+
return json.loads(response)["path"]
|
|
442
|
+
|
|
443
|
+
async def _fs_read(self, connection: str, path: str) -> aiohttp.ClientResponse:
|
|
444
|
+
# Internal version of the function that returns the raw response object
|
|
445
|
+
# Here so clients can do things like stream the response to disk if it's a big file
|
|
446
|
+
endpoint = f"{self.base_url}/fs/read"
|
|
447
|
+
data = {"connection": connection, "path": path}
|
|
448
|
+
resp = await self._post(endpoint, json=data)
|
|
449
|
+
return resp
|
|
450
|
+
|
|
451
|
+
async def fs_read(self, connection: str, path: str) -> bytes:
|
|
452
|
+
resp = await self._fs_read(connection, path)
|
|
453
|
+
return await resp.read()
|
|
454
|
+
|
|
455
|
+
async def action(self, action_data: dict, confirm: bool):
|
|
456
|
+
endpoint = f"{self.base_url}/action"
|
|
457
|
+
data = {"action": action_data, "confirm": confirm}
|
|
458
|
+
response = await self.post(endpoint, json=data)
|
|
459
|
+
return response
|
|
460
|
+
|
|
461
|
+
async def secret_encrypt(self, secret: str):
|
|
462
|
+
endpoint = f"{self.base_url}/secret/encrypt"
|
|
463
|
+
data = {"value": secret}
|
|
464
|
+
return json.loads(await self.post(endpoint, json=data))["encrypted"]
|
|
465
|
+
|
|
466
|
+
async def secret_decrypt(self, encrypted: dict):
|
|
467
|
+
endpoint = f"{self.base_url}/secret/decrypt"
|
|
468
|
+
data = {"encrypted": encrypted}
|
|
469
|
+
return json.loads(await self.post(endpoint, json=data))["decrypted"]
|
xpipe_api/exceptions.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class NoTokenFoundException(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AuthFailedException(Exception):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
error_code_map = {
|
|
10
|
+
200: "Ok.",
|
|
11
|
+
400: "Bad Request.",
|
|
12
|
+
401: "Unauthorized. Please make sure that the HTTP API is enabled and supply a Bearer token via the Authorization header.",
|
|
13
|
+
403: "Forbidden. Please make sure that the HTTP API is enabled and supply a valid Bearer token via the Authorization header.",
|
|
14
|
+
404: "Not Found.",
|
|
15
|
+
500: "Internal Server Error."
|
|
16
|
+
}
|
|
17
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Clint Olson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: xpipe_api
|
|
3
|
+
Version: 0.1.30
|
|
4
|
+
Summary: Client for the XPipe API
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Clint Olson
|
|
7
|
+
Author-email: coandco@gmail.com
|
|
8
|
+
Requires-Python: >=3.10,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Dist: aiohttp-requests (>=0.2.4,<0.3.0)
|
|
16
|
+
Requires-Dist: packaging (>=24.1,<25.0)
|
|
17
|
+
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
|
18
|
+
Project-URL: Repository, https://github.com/xpipe-io/xpipe-python-api
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# XPipe Python API
|
|
22
|
+
|
|
23
|
+
[](https://github.com/xpipe-io/xpipe-python-api/blob/master/LICENSE)
|
|
24
|
+
[](https://pypi.org/project/xpipe_api/)
|
|
25
|
+
|
|
26
|
+
Python client for the XPipe API. This library is a wrapper for the raw [HTTP API](https://github.com/xpipe-io/xpipe/blob/master/openapi.yaml) and intended to make working with it more convenient.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python3 -m pip install xpipe_api
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
You can find the documentation at https://docs.xpipe.io/guide/python-api.
|
|
33
|
+
|
|
34
|
+
## Development
|
|
35
|
+
|
|
36
|
+
To run the test suite, you'll need to define the XPIPE_APIKEY env var. This will allow the two "log in with the ApiKey
|
|
37
|
+
rather than Local method" tests to work. Here's the recommended method for running the tests with poetry:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
cd /path/to/xpipe-python-api
|
|
41
|
+
poetry install
|
|
42
|
+
XPIPE_APIKEY=<api_key> poetry run pytest
|
|
43
|
+
```
|
|
44
|
+
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
xpipe_api/__init__.py,sha256=XLv-a-mt7OcGS9OQbDBHKBsI4mO-4lgWDA13nZ0njFY,108
|
|
2
|
+
xpipe_api/clients.py,sha256=x6d8IPxbOLRaZL9bUZyPNJdNs9TmmnGlvQoGYQ6xKAA,21727
|
|
3
|
+
xpipe_api/exceptions.py,sha256=JKhn6gBJ9XJYMFTmEkJXuL-LRIi0SLHUtrBBoRoGHkw,496
|
|
4
|
+
xpipe_api-0.1.30.dist-info/LICENSE,sha256=hWd_i4lSck0lBXcxe-2P4VCUyPAecKW-X1oOiXv21wE,1089
|
|
5
|
+
xpipe_api-0.1.30.dist-info/METADATA,sha256=CYDzf3f6BOuduP7yHmQHWfh2_hqlDCqeEwOPVHiXjMQ,1660
|
|
6
|
+
xpipe_api-0.1.30.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
|
7
|
+
xpipe_api-0.1.30.dist-info/RECORD,,
|