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 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,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"]
@@ -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
+ [![GitHub license](https://img.shields.io/github/license/xpipe-io/xpipe-python-api.svg)](https://github.com/xpipe-io/xpipe-python-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
+ ```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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.0.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any