xpipe_api 0.1.26__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,83 @@
1
+ # XPipe Python API
2
+
3
+ [![GitHub license](https://img.shields.io/github/license/xpipe-io/xpipe-python-api.svg)](https://github.com/coandco/python_xpipe_api/blob/master/LICENSE)
4
+ [![PyPI version](https://img.shields.io/pypi/v/xpipe_api)](https://pypi.org/project/xpipe_api/)
5
+
6
+ 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.
7
+
8
+ ## Installation
9
+ ```
10
+ python3 -m pip install xpipe_api
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ from xpipe_api import Client
17
+
18
+ # By default, Client() will read an access key from the file xpipe_auth on the local filesystem
19
+ # and talk to the XPipe HTTP server on localhost. To connect to a remote instance with an API
20
+ # key, use Client(token="foo", base_url = "http://servername:21721")
21
+ client = Client()
22
+
23
+ # connection_query accepts glob-based filters on the category, connection name, and connection type
24
+ all_connections = client.connection_query()
25
+
26
+ # Each connection includes uuid, category, connection, and type information
27
+ first_connection_uuid = all_connections[0]["uuid"]
28
+
29
+ # Before any shell commands can be run, a shell session must be started on a connection
30
+ client.shell_start(first_connection_uuid)
31
+
32
+ # Prints {'exitCode': 0, 'stdout': 'hello world', 'stderr': ''}
33
+ print(client.shell_exec(first_connection_uuid, "echo hello world"))
34
+
35
+ # Clean up after ourselves by stopping the shell session
36
+ client.shell_stop(first_connection_uuid)
37
+ ```
38
+
39
+ There's also an async version of the client that can be accessed as AsyncClient:
40
+
41
+ ```python
42
+ import asyncio
43
+ from xpipe_api import AsyncClient
44
+
45
+
46
+ async def main():
47
+ # By default, Client() will read an access key from the file xpipe_auth on the local filesystem
48
+ # and talk to the XPipe HTTP server on localhost. To connect to a remote instance with an API
49
+ # key, use Client(token="foo", base_url = "http://servername:21721")
50
+ client = AsyncClient()
51
+
52
+ # connection_query accepts glob-based filters on the category, connection name, and connection type
53
+ all_connections = await client.connection_query()
54
+
55
+ # Each connection includes uuid, category, connection, and type information
56
+ first_connection_uuid = all_connections[0]["uuid"]
57
+
58
+ # Before any shell commands can be run, a shell session must be started on a connection
59
+ await client.shell_start(first_connection_uuid)
60
+
61
+ # Prints {'exitCode': 0, 'stdout': 'hello world', 'stderr': ''}
62
+ print(await client.shell_exec(first_connection_uuid, "echo hello world"))
63
+
64
+ # Clean up after ourselves by stopping the shell session
65
+ await client.shell_stop(first_connection_uuid)
66
+
67
+
68
+ if __name__ == "__main__":
69
+ asyncio.run(main())
70
+ ```
71
+
72
+ This is only a short summary of the library. You can find more supported functionalities in the source itself.
73
+
74
+ ## Tests
75
+
76
+ 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
77
+ rather than Local method" tests to work. Here's the recommended method for running the tests with poetry:
78
+
79
+ ```commandline
80
+ cd /path/to/python_xpipe_api
81
+ poetry install
82
+ XPIPE_APIKEY=<api_key> poetry run pytest
83
+ ```
@@ -0,0 +1,22 @@
1
+ [tool.poetry]
2
+ name = "xpipe_api"
3
+ version = "0.1.26"
4
+ description = "Client for the XPipe API"
5
+ authors = ["Clint Olson <coandco@gmail.com>", "Christopher Schnick <crschnick@xpipe.io>"]
6
+ repository = "https://github.com/xpipe-io/xpipe-python-api"
7
+ readme = "README.md"
8
+ license = "MIT"
9
+
10
+ [tool.poetry.dependencies]
11
+ python = "^3.10"
12
+ requests = "^2.32.3"
13
+ aiohttp-requests = "^0.2.4"
14
+ packaging = "^24.1"
15
+
16
+ [tool.poetry.group.dev.dependencies]
17
+ pytest = "^8.2.2"
18
+ pytest-asyncio = "^0.23.7"
19
+
20
+ [build-system]
21
+ requires = ["poetry-core>=1.0.0"]
22
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,2 @@
1
+ from .clients import Client, AsyncClient
2
+ from .exceptions import AuthFailedException, NoTokenFoundException
@@ -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
+