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 ADDED
@@ -0,0 +1,2 @@
1
+ from .clients import Client, AsyncClient
2
+ from .exceptions import AuthFailedException, NoTokenFoundException
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()
@@ -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
+ [![GitHub license](https://img.shields.io/github/license/xpipe-io/xpipe-python-api.svg)](https://github.com/coandco/python_xpipe_api/blob/master/LICENSE)
24
+ [![PyPI version](https://img.shields.io/pypi/v/xpipe_api)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.9.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any