xpipe_api 0.1.26__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 +410 -0
- xpipe_api/exceptions.py +17 -0
- xpipe_api-0.1.26.dist-info/LICENSE +21 -0
- xpipe_api-0.1.26.dist-info/METADATA +103 -0
- xpipe_api-0.1.26.dist-info/RECORD +7 -0
- xpipe_api-0.1.26.dist-info/WHEEL +4 -0
xpipe_api/__init__.py
ADDED
xpipe_api/clients.py
ADDED
@@ -0,0 +1,410 @@
|
|
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("10.1-12")
|
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}")
|
40
|
+
|
41
|
+
if not base_url:
|
42
|
+
base_url = "http://127.0.0.1:21722" if ptb else "http://127.0.0.1:21721"
|
43
|
+
|
44
|
+
self.token = token
|
45
|
+
self.auth_type = auth_type
|
46
|
+
self.base_url = base_url.strip("/")
|
47
|
+
self.raise_errors = raise_errors
|
48
|
+
|
49
|
+
def renew_session(self):
|
50
|
+
if self.auth_type == "ApiKey":
|
51
|
+
auth = {"type": self.auth_type, "key": self.token}
|
52
|
+
else:
|
53
|
+
auth = {"type": self.auth_type, "authFileContent": self.token}
|
54
|
+
data = {"auth": auth, "client": {"type": "Api", "name": "python_xpipe_api"}}
|
55
|
+
result = requests.post(f"{self.base_url}/handshake", json=data)
|
56
|
+
response = result.json()
|
57
|
+
session = response.get("sessionToken", None)
|
58
|
+
if session:
|
59
|
+
self.session = session
|
60
|
+
else:
|
61
|
+
raise AuthFailedException(json.dumps(response))
|
62
|
+
assert (
|
63
|
+
Version(self.daemon_version()["version"]) >= self.min_version
|
64
|
+
), f"xpipe_api requires XPipe of at least {self.min_version}"
|
65
|
+
|
66
|
+
def _post(self, *args, **kwargs) -> requests.Response:
|
67
|
+
if not self.session:
|
68
|
+
self.renew_session()
|
69
|
+
kwargs.setdefault("headers", {})["Authorization"] = f"Bearer {self.session}"
|
70
|
+
resp: requests.Response = requests.post(*args, **kwargs)
|
71
|
+
status_code, reason = resp.status_code, error_code_map.get(resp.status_code, "Unknown Code")
|
72
|
+
if self.raise_errors and status_code >= 400:
|
73
|
+
message = f"{status_code} {reason} for url: {resp.url}"
|
74
|
+
# Attempt to enrich the message with the parsed reason
|
75
|
+
if status_code == 400:
|
76
|
+
with suppress(Exception):
|
77
|
+
message = f'Client Error for {resp.url}: {resp.json()["message"]}'
|
78
|
+
elif status_code == 500:
|
79
|
+
with suppress(Exception):
|
80
|
+
message = f'Server Error for {resp.url}: {resp.json()["error"]["message"]}'
|
81
|
+
raise requests.HTTPError(message, response=resp)
|
82
|
+
return resp
|
83
|
+
|
84
|
+
def post(self, *args, **kwargs) -> bytes:
|
85
|
+
return self._post(*args, **kwargs).content
|
86
|
+
|
87
|
+
def _get(self, *args, **kwargs) -> requests.Response:
|
88
|
+
if not self.session:
|
89
|
+
self.renew_session()
|
90
|
+
kwargs.setdefault("headers", {})["Authorization"] = f"Bearer {self.session}"
|
91
|
+
resp = requests.get(*args, **kwargs)
|
92
|
+
status_code, reason = resp.status_code, error_code_map.get(resp.status_code, "Unknown Code")
|
93
|
+
if self.raise_errors and status_code >= 400:
|
94
|
+
message = f"{status_code} {reason} for url: {resp.url}"
|
95
|
+
# Attempt to enrich the message with the parsed reason
|
96
|
+
if status_code == 400:
|
97
|
+
try:
|
98
|
+
message = f'Client Error for {resp.url}: {resp.json()["message"]}'
|
99
|
+
except Exception:
|
100
|
+
pass
|
101
|
+
elif status_code == 500:
|
102
|
+
try:
|
103
|
+
message = f'Server Error for {resp.url}: {resp.json()["error"]["message"]}'
|
104
|
+
except Exception:
|
105
|
+
pass
|
106
|
+
raise requests.HTTPError(message, response=resp)
|
107
|
+
return resp
|
108
|
+
|
109
|
+
def get(self, *args, **kwargs) -> bytes:
|
110
|
+
return self._get(*args, **kwargs).content
|
111
|
+
|
112
|
+
def connection_query(self, categories: str = "*", connections: str = "*", types: str = "*") -> List[str]:
|
113
|
+
endpoint = f"{self.base_url}/connection/query"
|
114
|
+
data = {"categoryFilter": categories, "connectionFilter": connections, "typeFilter": types}
|
115
|
+
response = self.post(endpoint, json=data)
|
116
|
+
return json.loads(response).get("found", [])
|
117
|
+
|
118
|
+
def connection_info(self, uuids: Union[str, List[str]]) -> List[dict]:
|
119
|
+
endpoint = f"{self.base_url}/connection/info"
|
120
|
+
# If we're passed a single UUID, wrap it in a list like the API expects
|
121
|
+
if not isinstance(uuids, list):
|
122
|
+
uuids = [uuids]
|
123
|
+
data = {"connections": uuids}
|
124
|
+
response = self.post(endpoint, json=data)
|
125
|
+
return json.loads(response).get("infos", [])
|
126
|
+
|
127
|
+
def connection_add(self, name: str, conn_data: dict, validate: bool = False) -> str:
|
128
|
+
endpoint = f"{self.base_url}/connection/add"
|
129
|
+
data = {"name": name, "data": conn_data, "validate": validate}
|
130
|
+
response = self.post(endpoint, json=data)
|
131
|
+
return json.loads(response)["connection"]
|
132
|
+
|
133
|
+
def connection_remove(self, uuids: Union[str, List[str]]):
|
134
|
+
endpoint = f"{self.base_url}/connection/remove"
|
135
|
+
if not isinstance(uuids, list):
|
136
|
+
uuids = [uuids]
|
137
|
+
data = {"connections": uuids}
|
138
|
+
self.post(endpoint, json=data)
|
139
|
+
|
140
|
+
def connection_browse(self, connection: str, directory: Optional[str] = None):
|
141
|
+
endpoint = f"{self.base_url}/connection/browse"
|
142
|
+
data = {"connection": connection}
|
143
|
+
if directory:
|
144
|
+
data["directory"] = directory
|
145
|
+
self.post(endpoint, json=data)
|
146
|
+
|
147
|
+
def connection_terminal(self, connection: str, directory: Optional[str] = None):
|
148
|
+
endpoint = f"{self.base_url}/connection/terminal"
|
149
|
+
data = {"connection": connection}
|
150
|
+
if directory:
|
151
|
+
data["directory"] = directory
|
152
|
+
self.post(endpoint, json=data)
|
153
|
+
|
154
|
+
def connection_toggle(self, connection: str, state: bool):
|
155
|
+
endpoint = f"{self.base_url}/connection/toggle"
|
156
|
+
data = {"connection": connection, "state": state}
|
157
|
+
self.post(endpoint, json=data)
|
158
|
+
|
159
|
+
def connection_refresh(self, connection: str):
|
160
|
+
endpoint = f"{self.base_url}/connection/refresh"
|
161
|
+
data = {"connection": connection}
|
162
|
+
self.post(endpoint, json=data)
|
163
|
+
|
164
|
+
def get_connections(self, categories: str = "*", connections: str = "*", types: str = "*") -> List[dict]:
|
165
|
+
"""Convenience method to chain connection/query with connection/info"""
|
166
|
+
uuids = self.connection_query(categories, connections, types)
|
167
|
+
return self.connection_info(uuids) if uuids else []
|
168
|
+
|
169
|
+
def daemon_version(self) -> dict:
|
170
|
+
endpoint = f"{self.base_url}/daemon/version"
|
171
|
+
response = self.get(endpoint)
|
172
|
+
return json.loads(response)
|
173
|
+
|
174
|
+
def shell_start(self, conn_uuid: str) -> dict:
|
175
|
+
endpoint = f"{self.base_url}/shell/start"
|
176
|
+
data = {"connection": conn_uuid}
|
177
|
+
response = self.post(endpoint, json=data)
|
178
|
+
return json.loads(response) if response else {}
|
179
|
+
|
180
|
+
def shell_stop(self, conn_uuid: str):
|
181
|
+
endpoint = f"{self.base_url}/shell/stop"
|
182
|
+
data = {"connection": conn_uuid}
|
183
|
+
self.post(endpoint, json=data)
|
184
|
+
|
185
|
+
def shell_exec(self, conn_uuid: str, command: str) -> dict:
|
186
|
+
endpoint = f"{self.base_url}/shell/exec"
|
187
|
+
data = {"connection": conn_uuid, "command": command}
|
188
|
+
response = self.post(endpoint, json=data)
|
189
|
+
return json.loads(response) if response else {}
|
190
|
+
|
191
|
+
def fs_blob(self, blob_data: Union[bytes, str, BinaryIO]) -> str:
|
192
|
+
endpoint = f"{self.base_url}/fs/blob"
|
193
|
+
if isinstance(blob_data, str):
|
194
|
+
blob_data = blob_data.encode("utf-8")
|
195
|
+
response = self.post(endpoint, data=blob_data)
|
196
|
+
return json.loads(response)["blob"]
|
197
|
+
|
198
|
+
def fs_write(self, connection: str, blob: str, path: str):
|
199
|
+
endpoint = f"{self.base_url}/fs/write"
|
200
|
+
data = {"connection": connection, "blob": blob, "path": path}
|
201
|
+
self.post(endpoint, json=data)
|
202
|
+
|
203
|
+
def fs_script(self, connection: str, blob: str) -> str:
|
204
|
+
endpoint = f"{self.base_url}/fs/script"
|
205
|
+
data = {"connection": connection, "blob": blob}
|
206
|
+
response = self.post(endpoint, json=data)
|
207
|
+
return json.loads(response)["path"]
|
208
|
+
|
209
|
+
def _fs_read(self, connection: str, path: str) -> requests.Response:
|
210
|
+
# Internal version of the function that returns the raw response object
|
211
|
+
# Here so clients can do things like stream the response to disk if it's a big file
|
212
|
+
endpoint = f"{self.base_url}/fs/read"
|
213
|
+
data = {"connection": connection, "path": path}
|
214
|
+
return self._post(endpoint, json=data, stream=True)
|
215
|
+
|
216
|
+
def fs_read(self, connection: str, path: str) -> bytes:
|
217
|
+
return self._fs_read(connection, path).content
|
218
|
+
|
219
|
+
|
220
|
+
class AsyncClient(Client):
|
221
|
+
@classmethod
|
222
|
+
def from_sync_client(cls, sync: Client) -> "AsyncClient":
|
223
|
+
async_client = cls(token=sync.token, base_url=sync.base_url, raise_errors=sync.raise_errors)
|
224
|
+
async_client.auth_type = sync.auth_type
|
225
|
+
async_client.session = sync.session
|
226
|
+
return async_client
|
227
|
+
|
228
|
+
async def renew_session(self):
|
229
|
+
if self.auth_type == "ApiKey":
|
230
|
+
auth = {"type": self.auth_type, "key": self.token}
|
231
|
+
else:
|
232
|
+
auth = {"type": self.auth_type, "authFileContent": self.token}
|
233
|
+
data = {"auth": auth, "client": {"type": "Api", "name": "python_xpipe_api"}}
|
234
|
+
|
235
|
+
resp = await async_requests.post(f"{self.base_url}/handshake", json=data)
|
236
|
+
parsed = await resp.json(content_type=None)
|
237
|
+
session_token = parsed.get("sessionToken", None)
|
238
|
+
if session_token:
|
239
|
+
self.session = session_token
|
240
|
+
else:
|
241
|
+
raise AuthFailedException(json.dumps(parsed))
|
242
|
+
assert (
|
243
|
+
Version((await self.daemon_version())["version"]) >= self.min_version
|
244
|
+
), f"xpipe_api requires XPipe of at least {self.min_version}"
|
245
|
+
|
246
|
+
async def _post(self, *args, **kwargs) -> aiohttp.ClientResponse:
|
247
|
+
if not self.session:
|
248
|
+
await self.renew_session()
|
249
|
+
kwargs.setdefault("headers", {})["Authorization"] = f"Bearer {self.session}"
|
250
|
+
resp = await async_requests.post(*args, **kwargs)
|
251
|
+
if self.raise_errors and not resp.ok:
|
252
|
+
status_code, reason = resp.status, error_code_map.get(resp.status, "Unknown Code")
|
253
|
+
message = f"{status_code} {reason} for url: {resp.url}"
|
254
|
+
# Attempt to enrich the message with the parsed reason
|
255
|
+
text = await resp.text()
|
256
|
+
if status_code == 400:
|
257
|
+
with suppress(Exception):
|
258
|
+
message = f'Client Error: {json.loads(text)["message"]}'
|
259
|
+
elif status_code == 500:
|
260
|
+
with suppress(Exception):
|
261
|
+
message = f'Server Error: {json.loads(text)["error"]["message"]}'
|
262
|
+
raise ClientResponseError(
|
263
|
+
resp.request_info,
|
264
|
+
resp.history,
|
265
|
+
status=resp.status,
|
266
|
+
message=message,
|
267
|
+
headers=resp.headers,
|
268
|
+
)
|
269
|
+
return resp
|
270
|
+
|
271
|
+
async def post(self, *args, **kwargs) -> bytes:
|
272
|
+
resp = await self._post(*args, **kwargs)
|
273
|
+
return await resp.read()
|
274
|
+
|
275
|
+
async def _get(self, *args, **kwargs) -> aiohttp.ClientResponse:
|
276
|
+
if not self.session:
|
277
|
+
await self.renew_session()
|
278
|
+
kwargs.setdefault("headers", {})["Authorization"] = f"Bearer {self.session}"
|
279
|
+
resp = await async_requests.get(*args, **kwargs)
|
280
|
+
if self.raise_errors and not resp.ok:
|
281
|
+
status_code, reason = resp.status, error_code_map.get(resp.status, "Unknown Code")
|
282
|
+
message = f"{status_code} {reason} for url: {resp.url}"
|
283
|
+
# Attempt to enrich the message with the parsed reason
|
284
|
+
text = await resp.text()
|
285
|
+
if status_code == 400:
|
286
|
+
with suppress(Exception):
|
287
|
+
message = f'Client Error for {resp.url}: {json.loads(text)["message"]}'
|
288
|
+
elif status_code == 500:
|
289
|
+
with suppress(Exception):
|
290
|
+
message = f'Server Error for {resp.url}: {json.loads(text)["error"]["message"]}'
|
291
|
+
raise ClientResponseError(
|
292
|
+
resp.request_info,
|
293
|
+
resp.history,
|
294
|
+
status=resp.status,
|
295
|
+
message=message,
|
296
|
+
headers=resp.headers,
|
297
|
+
)
|
298
|
+
return resp
|
299
|
+
|
300
|
+
async def get(self, *args, **kwargs) -> bytes:
|
301
|
+
resp = await self._get(*args, **kwargs)
|
302
|
+
return await resp.read()
|
303
|
+
|
304
|
+
async def connection_query(self, categories: str = "*", connections: str = "*", types: str = "*") -> List[str]:
|
305
|
+
endpoint = f"{self.base_url}/connection/query"
|
306
|
+
data = {"categoryFilter": categories, "connectionFilter": connections, "typeFilter": types}
|
307
|
+
response = await self.post(endpoint, json=data)
|
308
|
+
return json.loads(response).get("found", [])
|
309
|
+
|
310
|
+
async def connection_info(self, uuids: Union[str, List[str]]) -> List[dict]:
|
311
|
+
endpoint = f"{self.base_url}/connection/info"
|
312
|
+
# If we're passed a single UUID, wrap it in a list like the API expects
|
313
|
+
if not isinstance(uuids, list):
|
314
|
+
uuids = [uuids]
|
315
|
+
data = {"connections": uuids}
|
316
|
+
response = await self.post(endpoint, json=data)
|
317
|
+
return json.loads(response).get("infos", [])
|
318
|
+
|
319
|
+
async def connection_add(self, name: str, conn_data: dict, validate: bool = False) -> str:
|
320
|
+
endpoint = f"{self.base_url}/connection/add"
|
321
|
+
data = {"name": name, "data": conn_data, "validate": validate}
|
322
|
+
response = await self.post(endpoint, json=data)
|
323
|
+
return json.loads(response)["connection"]
|
324
|
+
|
325
|
+
async def connection_remove(self, uuids: Union[str, List[str]]):
|
326
|
+
endpoint = f"{self.base_url}/connection/remove"
|
327
|
+
if not isinstance(uuids, list):
|
328
|
+
uuids = [uuids]
|
329
|
+
data = {"connections": uuids}
|
330
|
+
await self.post(endpoint, json=data)
|
331
|
+
|
332
|
+
async def connection_browse(self, connection: str, directory: Optional[str] = None):
|
333
|
+
endpoint = f"{self.base_url}/connection/browse"
|
334
|
+
data = {"connection": connection}
|
335
|
+
if directory:
|
336
|
+
data["directory"] = directory
|
337
|
+
await self.post(endpoint, json=data)
|
338
|
+
|
339
|
+
async def connection_terminal(self, connection: str, directory: Optional[str] = None):
|
340
|
+
endpoint = f"{self.base_url}/connection/terminal"
|
341
|
+
data = {"connection": connection}
|
342
|
+
if directory:
|
343
|
+
data["directory"] = directory
|
344
|
+
await self.post(endpoint, json=data)
|
345
|
+
|
346
|
+
async def connection_toggle(self, connection: str, state: bool):
|
347
|
+
endpoint = f"{self.base_url}/connection/toggle"
|
348
|
+
data = {"connection": connection, "state": state}
|
349
|
+
await self.post(endpoint, json=data)
|
350
|
+
|
351
|
+
async def connection_refresh(self, connection: str):
|
352
|
+
endpoint = f"{self.base_url}/connection/refresh"
|
353
|
+
data = {"connection": connection}
|
354
|
+
await self.post(endpoint, json=data)
|
355
|
+
|
356
|
+
async def get_connections(self, categories: str = "*", connections: str = "*", types: str = "*") -> List[dict]:
|
357
|
+
uuids = await self.connection_query(categories, connections, types)
|
358
|
+
return (await self.connection_info(uuids)) if uuids else []
|
359
|
+
|
360
|
+
async def daemon_version(self) -> dict:
|
361
|
+
endpoint = f"{self.base_url}/daemon/version"
|
362
|
+
response = await self.get(endpoint)
|
363
|
+
return json.loads(response)
|
364
|
+
|
365
|
+
async def shell_start(self, conn_uuid: str) -> dict:
|
366
|
+
endpoint = f"{self.base_url}/shell/start"
|
367
|
+
data = {"connection": conn_uuid}
|
368
|
+
response = await self.post(endpoint, json=data)
|
369
|
+
return json.loads(response) if response else {}
|
370
|
+
|
371
|
+
async def shell_stop(self, conn_uuid: str):
|
372
|
+
endpoint = f"{self.base_url}/shell/stop"
|
373
|
+
data = {"connection": conn_uuid}
|
374
|
+
await self.post(endpoint, json=data)
|
375
|
+
|
376
|
+
async def shell_exec(self, conn_uuid: str, command: str) -> dict:
|
377
|
+
endpoint = f"{self.base_url}/shell/exec"
|
378
|
+
data = {"connection": conn_uuid, "command": command}
|
379
|
+
response = await self.post(endpoint, json=data)
|
380
|
+
return json.loads(response)
|
381
|
+
|
382
|
+
async def fs_blob(self, blob_data: Union[bytes, str]) -> str:
|
383
|
+
endpoint = f"{self.base_url}/fs/blob"
|
384
|
+
if isinstance(blob_data, str):
|
385
|
+
blob_data = blob_data.encode("utf-8")
|
386
|
+
response = await self.post(endpoint, data=blob_data)
|
387
|
+
return json.loads(response)["blob"]
|
388
|
+
|
389
|
+
async def fs_write(self, connection: str, blob: str, path: str):
|
390
|
+
endpoint = f"{self.base_url}/fs/write"
|
391
|
+
data = {"connection": connection, "blob": blob, "path": path}
|
392
|
+
await self.post(endpoint, json=data)
|
393
|
+
|
394
|
+
async def fs_script(self, connection: str, blob: str) -> str:
|
395
|
+
endpoint = f"{self.base_url}/fs/script"
|
396
|
+
data = {"connection": connection, "blob": blob}
|
397
|
+
response = await self.post(endpoint, json=data)
|
398
|
+
return json.loads(response)["path"]
|
399
|
+
|
400
|
+
async def _fs_read(self, connection: str, path: str) -> aiohttp.ClientResponse:
|
401
|
+
# Internal version of the function that returns the raw response object
|
402
|
+
# Here so clients can do things like stream the response to disk if it's a big file
|
403
|
+
endpoint = f"{self.base_url}/fs/read"
|
404
|
+
data = {"connection": connection, "path": path}
|
405
|
+
resp = await self._post(endpoint, json=data)
|
406
|
+
return resp
|
407
|
+
|
408
|
+
async def fs_read(self, connection: str, path: str) -> bytes:
|
409
|
+
resp = await self._fs_read(connection, path)
|
410
|
+
return await resp.read()
|
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 supply a Bearer token via the Authorization header.",
|
13
|
+
403: "Forbidden. Please 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,103 @@
|
|
1
|
+
Metadata-Version: 2.1
|
2
|
+
Name: xpipe_api
|
3
|
+
Version: 0.1.26
|
4
|
+
Summary: Client for the XPipe API
|
5
|
+
Home-page: https://github.com/xpipe-io/xpipe-python-api
|
6
|
+
License: MIT
|
7
|
+
Author: Clint Olson
|
8
|
+
Author-email: coandco@gmail.com
|
9
|
+
Requires-Python: >=3.10,<4.0
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
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/coandco/python_xpipe_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
|
+
## Installation
|
29
|
+
```
|
30
|
+
python3 -m pip install xpipe_api
|
31
|
+
```
|
32
|
+
|
33
|
+
## Usage
|
34
|
+
|
35
|
+
```python
|
36
|
+
from xpipe_api import Client
|
37
|
+
|
38
|
+
# By default, Client() will read an access key from the file xpipe_auth on the local filesystem
|
39
|
+
# and talk to the XPipe HTTP server on localhost. To connect to a remote instance with an API
|
40
|
+
# key, use Client(token="foo", base_url = "http://servername:21721")
|
41
|
+
client = Client()
|
42
|
+
|
43
|
+
# connection_query accepts glob-based filters on the category, connection name, and connection type
|
44
|
+
all_connections = client.connection_query()
|
45
|
+
|
46
|
+
# Each connection includes uuid, category, connection, and type information
|
47
|
+
first_connection_uuid = all_connections[0]["uuid"]
|
48
|
+
|
49
|
+
# Before any shell commands can be run, a shell session must be started on a connection
|
50
|
+
client.shell_start(first_connection_uuid)
|
51
|
+
|
52
|
+
# Prints {'exitCode': 0, 'stdout': 'hello world', 'stderr': ''}
|
53
|
+
print(client.shell_exec(first_connection_uuid, "echo hello world"))
|
54
|
+
|
55
|
+
# Clean up after ourselves by stopping the shell session
|
56
|
+
client.shell_stop(first_connection_uuid)
|
57
|
+
```
|
58
|
+
|
59
|
+
There's also an async version of the client that can be accessed as AsyncClient:
|
60
|
+
|
61
|
+
```python
|
62
|
+
import asyncio
|
63
|
+
from xpipe_api import AsyncClient
|
64
|
+
|
65
|
+
|
66
|
+
async def main():
|
67
|
+
# By default, Client() will read an access key from the file xpipe_auth on the local filesystem
|
68
|
+
# and talk to the XPipe HTTP server on localhost. To connect to a remote instance with an API
|
69
|
+
# key, use Client(token="foo", base_url = "http://servername:21721")
|
70
|
+
client = AsyncClient()
|
71
|
+
|
72
|
+
# connection_query accepts glob-based filters on the category, connection name, and connection type
|
73
|
+
all_connections = await client.connection_query()
|
74
|
+
|
75
|
+
# Each connection includes uuid, category, connection, and type information
|
76
|
+
first_connection_uuid = all_connections[0]["uuid"]
|
77
|
+
|
78
|
+
# Before any shell commands can be run, a shell session must be started on a connection
|
79
|
+
await client.shell_start(first_connection_uuid)
|
80
|
+
|
81
|
+
# Prints {'exitCode': 0, 'stdout': 'hello world', 'stderr': ''}
|
82
|
+
print(await client.shell_exec(first_connection_uuid, "echo hello world"))
|
83
|
+
|
84
|
+
# Clean up after ourselves by stopping the shell session
|
85
|
+
await client.shell_stop(first_connection_uuid)
|
86
|
+
|
87
|
+
|
88
|
+
if __name__ == "__main__":
|
89
|
+
asyncio.run(main())
|
90
|
+
```
|
91
|
+
|
92
|
+
This is only a short summary of the library. You can find more supported functionalities in the source itself.
|
93
|
+
|
94
|
+
## Tests
|
95
|
+
|
96
|
+
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
|
97
|
+
rather than Local method" tests to work. Here's the recommended method for running the tests with poetry:
|
98
|
+
|
99
|
+
```commandline
|
100
|
+
cd /path/to/python_xpipe_api
|
101
|
+
poetry install
|
102
|
+
XPIPE_APIKEY=<api_key> poetry run pytest
|
103
|
+
```
|
@@ -0,0 +1,7 @@
|
|
1
|
+
xpipe_api/__init__.py,sha256=XLv-a-mt7OcGS9OQbDBHKBsI4mO-4lgWDA13nZ0njFY,108
|
2
|
+
xpipe_api/clients.py,sha256=Pg0jyrJpjv8bB0Xo4vdQUiqK3RsLdlAmJXorJLNBwhw,18740
|
3
|
+
xpipe_api/exceptions.py,sha256=FdkNKLV1gOH8mOuNM9wH_q3hYNFEdMbQwAEZUX4yZQU,410
|
4
|
+
xpipe_api-0.1.26.dist-info/LICENSE,sha256=hWd_i4lSck0lBXcxe-2P4VCUyPAecKW-X1oOiXv21wE,1089
|
5
|
+
xpipe_api-0.1.26.dist-info/METADATA,sha256=EMi4uQvxYDAw6jGDTK_ViCmUzgb9BNOZVeqyJbwIGvY,3900
|
6
|
+
xpipe_api-0.1.26.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
7
|
+
xpipe_api-0.1.26.dist-info/RECORD,,
|