pieforhead 0.0.1__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.
pieforhead/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .pieforhead import PieFed, PieFedAsync
2
+ from .utils import __version__ as VERSION
3
+
4
+ __all__ = ["PieFed", "PieFedAsync", "VERSION"]
pieforhead/clients.py ADDED
@@ -0,0 +1,481 @@
1
+ import json
2
+ import logging
3
+
4
+ import httpx
5
+ import semver
6
+
7
+ from .exceptions import PieFedClientException
8
+ from .utils import __version__ as VERSION
9
+
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class BaseClient:
15
+ """Class that both synchronous and async clients inherit from."""
16
+
17
+ instance_url: str
18
+ timeout: float = 5.0
19
+ api_version: str = "alpha"
20
+ headers: dict = {}
21
+ jwt_token: str = ""
22
+ current_username_or_email: str = ""
23
+ current_password: str = ""
24
+ raise_exceptions: bool = True
25
+ _nodeinfo: dict | None = None
26
+ _instance_version: semver.Version | None = None
27
+
28
+ def __init__(
29
+ self,
30
+ instance_url: str,
31
+ timeout: float = 5.0,
32
+ api_version: str = "alpha",
33
+ raise_exceptions: bool = True,
34
+ ):
35
+ """Constructor for base client class.
36
+
37
+ Args:
38
+ instance_url str: url of the piefed instance e.g. https://site.tld
39
+ timeout (float, optional): Timeout value for requests. Defaults to 5.0.
40
+ api_version (str, optional): Version of the PieFed API. Defaults to "alpha".
41
+ """
42
+ self.instance_url = instance_url
43
+ self.timeout = timeout
44
+ self.api_version = api_version
45
+ self.raise_exceptions = raise_exceptions
46
+ self.headers = {"User-Agent": f"pieforhead/{VERSION}", "Accept": "application/json"}
47
+
48
+ def _request_error(self, message: str, error: Exception) -> dict | None:
49
+ # Always log the error
50
+ logger.error(message)
51
+
52
+ # Only raise if desired
53
+ if self.raise_exceptions:
54
+ raise PieFedClientException(message) from error
55
+
56
+ # Return None if raising is not desired
57
+ return
58
+
59
+
60
+ class PieFedClient(BaseClient):
61
+ """Synchronous client for handling network requests."""
62
+
63
+ _client: httpx.Client | None = None
64
+
65
+ def __init__(
66
+ self,
67
+ instance_url: str,
68
+ timeout: float = 5.0,
69
+ api_version: str = "alpha",
70
+ existing_client: httpx.Client | None = None,
71
+ raise_exceptions: bool = True,
72
+ ):
73
+ """Constructor for Synchronous client.
74
+
75
+ Args:
76
+ instance_url str: url of the piefed instance e.g. https://site.tld
77
+ timeout (float, optional): Timeout value for requests. Defaults to 5.0.
78
+ api_version (str, optional): Version of the PieFed API. Defaults to "alpha".
79
+ existing_client (httpx.Client, optional): Pass an httpx.Client object to reuse it (mainly used for tests).
80
+ Defaults to None.
81
+ raise_exceptions (bool, optional): Whether to raise exceptions on request errors or just return None.
82
+ Defaults to True.
83
+ """
84
+ super().__init__(
85
+ instance_url=instance_url,
86
+ timeout=timeout,
87
+ api_version=api_version,
88
+ raise_exceptions=raise_exceptions,
89
+ )
90
+
91
+ base_url = f"{instance_url}/api/{api_version}/"
92
+
93
+ if existing_client:
94
+ self._client = existing_client
95
+ else:
96
+ self._client = httpx.Client(headers=self.headers, timeout=self.timeout, base_url=base_url)
97
+
98
+ def request(
99
+ self,
100
+ method: str,
101
+ endpoint: str,
102
+ params: dict = {},
103
+ data: dict = {},
104
+ files: dict | None = None,
105
+ ) -> dict | None:
106
+ """Function to handle network requests.
107
+
108
+ Args:
109
+ method (str): HTTP method for the request, one of ["GET", "POST", "PUT"]
110
+ endpoint (str): API endpoint to send request to e.g. "post/list"
111
+ params (dict, optional): url parameters for the request (only for "GET") Defaults to {}.
112
+ data (dict, optional): json payload for the request ("POST" and "PUT") Defaults to {}.
113
+ files (dict, optional): multipart files to include with the request.
114
+
115
+ Raises:
116
+ PieFedClientException: should also include error message returned from server if available
117
+
118
+ Returns:
119
+ dict: deserialized json returned by the server
120
+ """
121
+
122
+ if self.jwt_token and "Authorization" not in self.headers:
123
+ self.headers["Authorization"] = "Bearer " + self.jwt_token
124
+
125
+ try:
126
+ # Make the request
127
+ logger.debug(f"method={method}, endpoint={endpoint}, params={params}, data={data}")
128
+
129
+ resp = self._client.request(
130
+ method=method,
131
+ url=endpoint,
132
+ headers=self.headers,
133
+ timeout=self.timeout,
134
+ params=params,
135
+ json=data,
136
+ files=files,
137
+ )
138
+
139
+ resp.raise_for_status()
140
+
141
+ # Everything went well
142
+ return resp.json()
143
+
144
+ except httpx.HTTPStatusError as e:
145
+ # Server returned a bad status code, try to pull out the returned error message
146
+ logger.error(msg=str(e))
147
+
148
+ message = f"{method} request to {endpoint} failed with status {resp.status_code}"
149
+ try:
150
+ # A try...except inside a try...except kinda sucks - but more information is better, right?
151
+ error_body = resp.json()
152
+ except json.decoder.JSONDecodeError:
153
+ error_body = {}
154
+
155
+ if error_body and error_body.get("message", ""):
156
+ message += f": {error_body['message']}"
157
+
158
+ return self._request_error(message=message, error=e)
159
+
160
+ except httpx.TimeoutException as e:
161
+ # Server timed out
162
+ logger.error(msg=str(e))
163
+
164
+ message = f"{method} request to {endpoint} timed out"
165
+ return self._request_error(message=message, error=e)
166
+
167
+ except httpx.RequestError as e:
168
+ # Some other problem with the request
169
+ logger.error(msg=str(e))
170
+
171
+ message = f"{method} request to {endpoint} failed"
172
+ return self._request_error(message=message, error=e)
173
+
174
+ except json.decoder.JSONDecodeError as e:
175
+ # Server returned something that isn't JSON
176
+ logger.error(msg=str(e))
177
+
178
+ message = f"{method} request to {endpoint} did not return parseable JSON"
179
+ return self._request_error(message=message, error=e)
180
+
181
+ def close(self):
182
+ self._client.close()
183
+
184
+ def log_in(self, username_or_email: str, password: str) -> bool:
185
+ payload = {"username": username_or_email, "password": password}
186
+ method = "POST"
187
+
188
+ resp = self.request(method=method, endpoint="user/login", data=payload)
189
+
190
+ if resp is None:
191
+ # Problem with the request that was suppressed, just return False
192
+ return False
193
+
194
+ self.jwt_token = resp["jwt"]
195
+ self.headers["Authorization"] = "Bearer " + self.jwt_token
196
+ self.current_username_or_email = username_or_email
197
+ self.current_password = password
198
+
199
+ return True
200
+
201
+ def is_logged_in(self) -> bool:
202
+ return bool(self.jwt_token)
203
+
204
+ def log_out(self) -> bool:
205
+ method = "POST"
206
+
207
+ resp = self.request(method=method, endpoint="user/logout")
208
+
209
+ if resp is None:
210
+ # Problem with the request that was suppressed, just return False
211
+ return False
212
+
213
+ self.jwt_token = ""
214
+ self.current_username_or_email = ""
215
+ self.current_password = ""
216
+
217
+ return True
218
+
219
+ @property
220
+ def nodeinfo(self) -> dict | None:
221
+ """Dict containing the instance's nodeinfo output.
222
+
223
+ Returns:
224
+ dict: deserialized copy of the instance's nodeinfo or None if the request failed and exception ignored
225
+ """
226
+ if self._nodeinfo:
227
+ return self._nodeinfo
228
+
229
+ method = "GET"
230
+ endpoint = self.instance_url + "/nodeinfo/2.1.json"
231
+
232
+ resp = self.request(method=method, endpoint=endpoint)
233
+ self._nodeinfo = resp
234
+
235
+ return self._nodeinfo if self._nodeinfo else None
236
+
237
+ @property
238
+ def instance_version(self) -> semver.Version | None:
239
+ """The instance's software version from the nodeinfo.
240
+
241
+ Returns:
242
+ semver.Version: software version of the piefed instance or None if something failed and the exception was
243
+ suppressed
244
+ """
245
+
246
+ current_nodeinfo = self.nodeinfo
247
+
248
+ if current_nodeinfo is None:
249
+ # Problem with the request that was suppressed, just return False
250
+ return None
251
+
252
+ version_str = self.nodeinfo["software"]["version"]
253
+
254
+ try:
255
+ self._instance_version = semver.Version.parse(version_str)
256
+ return self._instance_version
257
+ except ValueError as e:
258
+ logger.error(msg=str(e))
259
+ logger.error(f"_instance_version={version_str}")
260
+
261
+ message = f"Instance version from nodeinfo not parseable: {version_str}"
262
+ return self._request_error(message=message, error=e)
263
+
264
+ def __enter__(self):
265
+ return self
266
+
267
+ def __exit__(self, *args):
268
+ self.close()
269
+
270
+
271
+ class AsyncPieFedClient(BaseClient):
272
+ """Asynchronous client for handling network requests."""
273
+
274
+ _client: httpx.AsyncClient = None
275
+
276
+ def __init__(
277
+ self,
278
+ instance_url: str,
279
+ timeout: float = 5.0,
280
+ api_version: str = "alpha",
281
+ existing_client: httpx.AsyncClient | None = None,
282
+ raise_exceptions: bool = True,
283
+ ):
284
+ """Constructor for Asynchronous client.
285
+
286
+ Args:
287
+ instance_url str: url of the piefed instance e.g. https://site.tld
288
+ timeout (float, optional): Timeout value for requests. Defaults to 5.0.
289
+ api_version (str, optional): Version of the PieFed API. Defaults to "alpha".
290
+ existing_client (httpx.Client, optional): Pass an httpx.Client object to reuse it (mainly used for tests).
291
+ Defaults to None.
292
+ raise_exceptions: (bool, optional): Whether to raise exceptions on request errors or just return None.
293
+ Defaults to True.
294
+ """
295
+ super().__init__(
296
+ instance_url=instance_url,
297
+ timeout=timeout,
298
+ api_version=api_version,
299
+ raise_exceptions=raise_exceptions,
300
+ )
301
+
302
+ base_url = f"{instance_url}/api/{api_version}/"
303
+
304
+ if existing_client:
305
+ self._client = existing_client
306
+ else:
307
+ self._client = httpx.AsyncClient(headers=self.headers, timeout=self.timeout, base_url=base_url)
308
+
309
+ async def request(
310
+ self,
311
+ method: str,
312
+ endpoint: str,
313
+ params: dict = {},
314
+ data: dict = {},
315
+ files: dict | None = None,
316
+ ) -> dict | None:
317
+ """Async function to handle network requests.
318
+
319
+ Args:
320
+ method (str): HTTP method for the request, one of ["GET", "POST", "PUT"]
321
+ endpoint (str): API endpoint to send request to e.g. "post/list"
322
+ params (dict, optional): url parameters for the request (only for "GET") Defaults to {}.
323
+ data (dict, optional): json payload for the request ("POST" and "PUT") Defaults to {}.
324
+ files (dict, optional): multipart files to include with the request.
325
+
326
+ Raises:
327
+ PieFedClientException: should also include error message returned from server if available
328
+
329
+ Returns:
330
+ dict: deserialized json returned by the server
331
+ """
332
+
333
+ if self.jwt_token and "Authorization" not in self.headers:
334
+ self.headers["Authorization"] = "Bearer " + self.jwt_token
335
+
336
+ try:
337
+ # Make the request
338
+ logger.debug(f"method={method}, endpoint={endpoint}, params={params}, data={data}")
339
+
340
+ resp = await self._client.request(
341
+ method=method,
342
+ url=endpoint,
343
+ headers=self.headers,
344
+ timeout=self.timeout,
345
+ params=params,
346
+ json=data,
347
+ files=files,
348
+ )
349
+
350
+ resp.raise_for_status()
351
+
352
+ # Everything went well
353
+ return resp.json()
354
+
355
+ except httpx.HTTPStatusError as e:
356
+ # Server returned a bad status code, try to pull out the returned error message
357
+ logger.error(msg=str(e))
358
+
359
+ message = f"{method} request to {endpoint} failed with status {resp.status_code}"
360
+ try:
361
+ # A try...except inside a try...except kinda sucks - but more information is better, right?
362
+ error_body = resp.json()
363
+ except json.decoder.JSONDecodeError:
364
+ error_body = {}
365
+
366
+ if error_body and error_body.get("message", ""):
367
+ message += f": {error_body['message']}"
368
+
369
+ return self._request_error(message=message, error=e)
370
+
371
+ except httpx.TimeoutException as e:
372
+ # Server timed out
373
+ logger.error(msg=str(e))
374
+
375
+ message = f"{method} request to {endpoint} timed out"
376
+ return self._request_error(message=message, error=e)
377
+
378
+ except httpx.RequestError as e:
379
+ # Some other problem with the request
380
+ logger.error(msg=str(e))
381
+
382
+ message = f"{method} request to {endpoint} failed"
383
+ return self._request_error(message=message, error=e)
384
+
385
+ except json.decoder.JSONDecodeError as e:
386
+ # Server returned something that isn't JSON
387
+ logger.error(msg=str(e))
388
+
389
+ message = f"{method} request to {endpoint} did not return parseable JSON"
390
+ return self._request_error(message=message, error=e)
391
+
392
+ async def aclose(self):
393
+ await self._client.aclose()
394
+
395
+ async def close(self):
396
+ await self.aclose()
397
+
398
+ async def __aenter__(self):
399
+ return self
400
+
401
+ async def __aexit__(self, *args):
402
+ await self.aclose()
403
+
404
+ async def log_in(self, username_or_email: str, password: str) -> bool:
405
+ payload = {"username": username_or_email, "password": password}
406
+ method = "POST"
407
+
408
+ resp = await self.request(method=method, endpoint="user/login", data=payload)
409
+
410
+ if resp is None:
411
+ # Problem with the request that was suppressed, just return False
412
+ return False
413
+
414
+ self.jwt_token = resp["jwt"]
415
+ self.headers["Authorization"] = "Bearer " + self.jwt_token
416
+ self.current_username_or_email = username_or_email
417
+ self.current_password = password
418
+
419
+ return True
420
+
421
+ async def is_logged_in(self) -> bool:
422
+ return bool(self.jwt_token)
423
+
424
+ async def log_out(self) -> bool:
425
+ method = "POST"
426
+
427
+ resp = await self.request(method=method, endpoint="user/logout")
428
+
429
+ if resp is None:
430
+ # Problem with the request that was suppressed, just return False
431
+ return False
432
+
433
+ self.jwt_token = ""
434
+ self.current_username_or_email = ""
435
+ self.current_password = ""
436
+
437
+ return True
438
+
439
+ @property
440
+ async def nodeinfo(self) -> dict:
441
+ """Dict containing the instance's nodeinfo output.
442
+
443
+ Returns:
444
+ dict: deserialized copy of the instance's nodeinfo or None if the request failed and exception ignored
445
+ """
446
+ if self._nodeinfo:
447
+ return self._nodeinfo
448
+
449
+ method = "GET"
450
+ endpoint = self.instance_url + "/nodeinfo/2.1.json"
451
+
452
+ resp = await self.request(method=method, endpoint=endpoint)
453
+ self._nodeinfo = resp
454
+
455
+ return self._nodeinfo if self._nodeinfo else None
456
+
457
+ @property
458
+ async def instance_version(self) -> semver.Version:
459
+ """The instance's software version from the nodeinfo.
460
+
461
+ Returns:
462
+ semver.Version: software version of the piefed instance
463
+ """
464
+
465
+ current_nodeinfo = await self.nodeinfo
466
+
467
+ if current_nodeinfo is None:
468
+ # Problem with the request that was suppressed, just return False
469
+ return None
470
+
471
+ version_str = current_nodeinfo["software"]["version"]
472
+
473
+ try:
474
+ self._instance_version = semver.Version.parse(version_str)
475
+ return self._instance_version
476
+ except ValueError as e:
477
+ logger.error(msg=str(e))
478
+ logger.error(f"_instance_version={version_str}")
479
+
480
+ message = f"Instance version from nodeinfo not parseable: {version_str}"
481
+ return self._request_error(message=message, error=e)
@@ -0,0 +1,14 @@
1
+ class PieFedClientException(Exception):
2
+ pass
3
+
4
+
5
+ class NotImplementedError(Exception):
6
+ pass
7
+
8
+
9
+ class SiteException(Exception):
10
+ pass
11
+
12
+
13
+ class ImageException(Exception):
14
+ pass
pieforhead/image.py ADDED
@@ -0,0 +1,185 @@
1
+ from io import IOBase
2
+ from os import PathLike
3
+ from pathlib import Path
4
+ from typing import BinaryIO
5
+
6
+ from .clients import AsyncPieFedClient, PieFedClient
7
+ from .exceptions import ImageException
8
+
9
+
10
+ class Image:
11
+ """Class to hold image endpoints."""
12
+
13
+ def __init__(self, client: PieFedClient):
14
+ self._client = client
15
+
16
+ def upload(
17
+ self,
18
+ image: str | BinaryIO | PathLike | tuple[str, bytes | bytearray, str],
19
+ other_endpoint: str | None = None,
20
+ ) -> dict | None:
21
+ """General function for uploading an image.
22
+
23
+ Args:
24
+ image (str | BinaryIO | PathLike | tuple[str, bytes | bytearray, str]): image to upload. Tuple argument
25
+ must have form suitable for httpx.request, meaning that it is of the form:
26
+ (filename, image_bytes, mime_type).
27
+ other_endpoint (str | None, optional): Used to divert request to one of the other upload endpoints.
28
+ Defaults to None.
29
+
30
+ Raises:
31
+ ImageException: raised if image type isn't understood or if unknown endpoint is provided
32
+
33
+ Returns:
34
+ dict | None: returns dict if successful and None if there is an error and exceptions were suppressed by the
35
+ client.
36
+ """
37
+
38
+ opened_file = False
39
+ file_obj = None
40
+
41
+ try:
42
+ # Figure out how to handle the image depending on the type provided
43
+ if isinstance(image, str | PathLike):
44
+ path = Path(image)
45
+ opened_file = True
46
+ file_obj = path.open("rb")
47
+ elif isinstance(image, tuple):
48
+ file_obj = image
49
+ elif isinstance(image, IOBase):
50
+ file_obj = image
51
+ file_obj.seek(0)
52
+ else:
53
+ if self._client.raise_exceptions:
54
+ raise ImageException(f"Incompatible image type provided for upload: {type(image)}")
55
+ return
56
+
57
+ if not other_endpoint:
58
+ endpoint = "upload/image"
59
+ elif other_endpoint == "community":
60
+ endpoint = "upload/community_image"
61
+ elif other_endpoint == "user":
62
+ endpoint = "upload/user_image"
63
+ else:
64
+ if self._client.raise_exceptions:
65
+ raise ImageException(f"Unknown image endpoint provided: {other_endpoint}")
66
+ return
67
+
68
+ files = {"file": file_obj}
69
+ return self._client.request(method="POST", endpoint=endpoint, files=files)
70
+ finally:
71
+ # Make sure to still close the file object no matter what happened with the request
72
+ if opened_file:
73
+ file_obj.close()
74
+
75
+ def upload_community_image(
76
+ self,
77
+ image: str | BinaryIO | PathLike | tuple[str, bytes | bytearray, str],
78
+ ) -> dict | None:
79
+ return self.upload(image=image, other_endpoint="community")
80
+
81
+ def upload_user_image(
82
+ self,
83
+ image: str | BinaryIO | PathLike | tuple[str, bytes | bytearray, str],
84
+ ) -> dict | None:
85
+ return self.upload(image=image, other_endpoint="user")
86
+
87
+ def delete(self, url: str) -> dict | None:
88
+ """Delete an uploaded image by providing its url.
89
+
90
+ Args:
91
+ url (str): url of the uploaded image to delete
92
+
93
+ Returns:
94
+ dict | None: should be {"result": "ok"} if successful. None if there is an exception that is suppressed
95
+ """
96
+ return self._client.request(method="POST", endpoint="image/delete", data={"file": url})
97
+
98
+
99
+ class ImageAsync:
100
+ """Class to hold async image endpoints."""
101
+
102
+ def __init__(self, client: AsyncPieFedClient):
103
+ self._client = client
104
+
105
+ async def upload(
106
+ self,
107
+ image: str | BinaryIO | PathLike | tuple[str, bytes | bytearray, str],
108
+ other_endpoint: str | None = None,
109
+ ) -> dict | None:
110
+ """General function for uploading an image.
111
+
112
+ Args:
113
+ image (str | BinaryIO | PathLike | tuple[str, bytes | bytearray, str]): image to upload. Tuple argument
114
+ must have form suitable for httpx.request, meaning that it is of the form:
115
+ (filename, image_bytes, mime_type).
116
+ other_endpoint (str | None, optional): Used to divert request to one of the other upload endpoints.
117
+ Defaults to None.
118
+
119
+ Raises:
120
+ ImageException: raised if image type isn't understood or if unknown endpoint is provided
121
+
122
+ Returns:
123
+ dict | None: returns dict if successful and None if there is an error and exceptions were suppressed by the
124
+ client.
125
+ """
126
+
127
+ opened_file = False
128
+ file_obj = None
129
+
130
+ try:
131
+ # Figure out how to handle the image depending on the type provided
132
+ if isinstance(image, str | PathLike):
133
+ path = Path(image)
134
+ opened_file = True
135
+ file_obj = path.open("rb")
136
+ elif isinstance(image, tuple):
137
+ file_obj = image
138
+ elif isinstance(image, IOBase):
139
+ file_obj = image
140
+ file_obj.seek(0)
141
+ else:
142
+ if self._client.raise_exceptions:
143
+ raise ImageException(f"Incompatible image type provided for upload: {type(image)}")
144
+ return
145
+
146
+ if not other_endpoint:
147
+ endpoint = "upload/image"
148
+ elif other_endpoint == "community":
149
+ endpoint = "upload/community_image"
150
+ elif other_endpoint == "user":
151
+ endpoint = "upload/user_image"
152
+ else:
153
+ if self._client.raise_exceptions:
154
+ raise ImageException(f"Unknown image endpoint provided: {other_endpoint}")
155
+ return
156
+
157
+ files = {"file": file_obj}
158
+ return await self._client.request(method="POST", endpoint=endpoint, files=files)
159
+ finally:
160
+ # Make sure to still close the file object no matter what happened with the request
161
+ if opened_file:
162
+ file_obj.close()
163
+
164
+ async def upload_community_image(
165
+ self,
166
+ image: str | BinaryIO | PathLike | tuple[str, bytes | bytearray, str],
167
+ ) -> dict | None:
168
+ return await self.upload(image=image, other_endpoint="community")
169
+
170
+ async def upload_user_image(
171
+ self,
172
+ image: str | BinaryIO | PathLike | tuple[str, bytes | bytearray, str],
173
+ ) -> dict | None:
174
+ return await self.upload(image=image, other_endpoint="user")
175
+
176
+ async def delete(self, url: str) -> dict | None:
177
+ """Delete an uploaded image by providing its url.
178
+
179
+ Args:
180
+ url (str): url of the uploaded image to delete
181
+
182
+ Returns:
183
+ dict | None: should be {"result": "ok"} if successful. None if there is an exception that is suppressed
184
+ """
185
+ return await self._client.request(method="POST", endpoint="image/delete", data={"file": url})
@@ -0,0 +1,79 @@
1
+ import logging
2
+
3
+ import semver
4
+
5
+ from .clients import AsyncPieFedClient, PieFedClient
6
+ from .site import Site, SiteAsync
7
+ from .image import Image, ImageAsync
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class PieFed:
13
+ _client: PieFedClient
14
+ instance_url: str
15
+
16
+ def __init__(self, instance_url: str, timeout: float = 5.0, raise_exceptions: bool = True):
17
+ self.instance_url = instance_url.removesuffix("/")
18
+ self._client = PieFedClient(instance_url=instance_url, timeout=timeout, raise_exceptions=raise_exceptions)
19
+
20
+ # Initialize attributes used for different endpoint groupings
21
+ self.site = Site(client=self._client)
22
+ self.image = Image(client=self._client)
23
+
24
+ def __enter__(self):
25
+ return self
26
+
27
+ def log_in(self, username_or_email: str, password: str) -> bool:
28
+ return self._client.log_in(username_or_email, password)
29
+
30
+ def is_logged_in(self) -> bool:
31
+ return self._client.is_logged_in()
32
+
33
+ def log_out(self) -> bool:
34
+ return self._client.log_out()
35
+
36
+ def close(self):
37
+ self._client.close()
38
+
39
+ def __exit__(self, *args):
40
+ self.close()
41
+
42
+ @property
43
+ def instance_version(self) -> semver.Version:
44
+ return self._client.instance_version
45
+
46
+
47
+ class PieFedAsync:
48
+ _client: AsyncPieFedClient
49
+ instance_url: str
50
+
51
+ def __init__(self, instance_url: str, timeout: float = 5.0, raise_exceptions: bool = True):
52
+ self.instance_url = instance_url.removesuffix("/")
53
+ self._client = AsyncPieFedClient(instance_url=instance_url, timeout=timeout, raise_exceptions=raise_exceptions)
54
+
55
+ # Initialize attributes used for different endpoint groupings
56
+ self.site = SiteAsync(client=self._client)
57
+ self.image = ImageAsync(client=self._client)
58
+
59
+ async def __aenter__(self):
60
+ return self
61
+
62
+ async def log_in(self, username_or_email: str, password: str) -> bool:
63
+ return await self._client.log_in(username_or_email, password)
64
+
65
+ async def is_logged_in(self) -> bool:
66
+ return await self._client.is_logged_in()
67
+
68
+ async def log_out(self) -> bool:
69
+ return await self._client.log_out()
70
+
71
+ async def close(self):
72
+ await self._client.close()
73
+
74
+ async def __aexit__(self, *args):
75
+ await self.close()
76
+
77
+ @property
78
+ async def instance_version(self) -> semver.Version:
79
+ return await self._client.instance_version
pieforhead/site.py ADDED
@@ -0,0 +1,142 @@
1
+ import semver
2
+
3
+ from .clients import PieFedClient, AsyncPieFedClient
4
+ from .exceptions import NotImplementedError, SiteException
5
+
6
+
7
+ class Site:
8
+ """Class to hold Site endpoints."""
9
+
10
+ def __init__(self, client: PieFedClient):
11
+ self._client = client
12
+
13
+ def get(self) -> dict | None:
14
+ """Dumps value returned from site endpoint into a dict.
15
+
16
+ Returns:
17
+ dict: contents of GET site endpoint
18
+ """
19
+
20
+ return self._client.request(method="GET", endpoint="site")
21
+
22
+ def version(self) -> semver.Version | None:
23
+ """Fetch software version from site/version endpoint.
24
+
25
+ Returns:
26
+ semver.Version: software version
27
+ """
28
+
29
+ resp = self._client.request(method="GET", endpoint="site/version")
30
+
31
+ if resp is None:
32
+ # Some problem with the request, but exceptions are suppressed
33
+ return
34
+
35
+ if "version" in resp:
36
+ try:
37
+ return semver.Version.parse(resp["version"])
38
+ except ValueError as e:
39
+ raise SiteException(f"Version not parseable: {resp['version']}") from e
40
+
41
+ return
42
+
43
+ def block(self, block: bool, instance_id: int) -> bool | None:
44
+ """Block or unblock an instance with a given id.
45
+
46
+ Args:
47
+ block (bool): True = block, False = unblock
48
+ instance_id (int): id of instance
49
+
50
+ Returns:
51
+ bool: True if instance is now blocked, False if it is now unblocked
52
+ """
53
+ payload = {"block": block, "instance_id": instance_id}
54
+
55
+ resp = self._client.request(method="POST", endpoint="site/block", data=payload)
56
+
57
+ if resp is None:
58
+ # Some problem with the request, but exceptions are suppressed
59
+ return
60
+
61
+ return resp["blocked"]
62
+
63
+ def instance_chooser(self):
64
+ if self._client.raise_exceptions:
65
+ raise NotImplementedError
66
+ else:
67
+ return
68
+
69
+ def instance_chooser_search(self):
70
+ if self._client.raise_exceptions:
71
+ raise NotImplementedError
72
+ else:
73
+ return
74
+
75
+
76
+ class SiteAsync:
77
+ """Class to hold async Site endpoints."""
78
+
79
+ def __init__(self, client: AsyncPieFedClient):
80
+ self._client = client
81
+
82
+ async def get(self) -> dict | None:
83
+ """Dumps value returned from site endpoint into a dict.
84
+
85
+ Returns:
86
+ dict: contents of GET site endpoint
87
+ """
88
+
89
+ return await self._client.request(method="GET", endpoint="site")
90
+
91
+ async def version(self) -> semver.Version | None:
92
+ """Fetch software version from site/version endpoint.
93
+
94
+ Returns:
95
+ semver.Version: software version
96
+ """
97
+
98
+ resp = await self._client.request(method="GET", endpoint="site/version")
99
+
100
+ if resp is None:
101
+ # Some problem with the request, but exceptions are suppressed
102
+ return
103
+
104
+ if "version" in resp:
105
+ try:
106
+ return semver.Version.parse(resp["version"])
107
+ except ValueError as e:
108
+ raise SiteException(f"Version not parseable: {resp['version']}") from e
109
+
110
+ return
111
+
112
+ async def block(self, block: bool, instance_id: int) -> bool | None:
113
+ """Block or unblock an instance with a given id.
114
+
115
+ Args:
116
+ block (bool): True = block, False = unblock
117
+ instance_id (int): id of instance
118
+
119
+ Returns:
120
+ bool: True if instance is now blocked, False if it is now unblocked
121
+ """
122
+ payload = {"block": block, "instance_id": instance_id}
123
+
124
+ resp = await self._client.request(method="POST", endpoint="site/block", data=payload)
125
+
126
+ if resp is None:
127
+ # Some problem with the request, but exceptions are suppressed
128
+ return
129
+
130
+ return resp["blocked"]
131
+
132
+ async def instance_chooser(self):
133
+ if self._client.raise_exceptions:
134
+ raise NotImplementedError
135
+ else:
136
+ return
137
+
138
+ async def instance_chooser_search(self):
139
+ if self._client.raise_exceptions:
140
+ raise NotImplementedError
141
+ else:
142
+ return
pieforhead/utils.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.5
2
+ Name: pieforhead
3
+ Version: 0.0.1
4
+ Summary: A python library for interacting with the PieFed API
5
+ Project-URL: Homepage, https://github.com/wjs018/pieforhead
6
+ Project-URL: Issues, https://github.com/wjs018/pieforhead/issues
7
+ Author-email: wjs018 <wjs018@gmail.com>
8
+ License-Expression: Unlicense
9
+ License-File: LICENSE
10
+ Keywords: fediverse,piefed
11
+ Classifier: Development Status :: 2 - Pre-Alpha
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: httpx
16
+ Requires-Dist: semver
17
+ Description-Content-Type: text/markdown
18
+
19
+ # pieforhead
20
+
21
+ A python wrapper for the PieFed API supporting both synchronous and asynchronous usage. This is an early work in progress, and few endpoints are supported yet.
22
+
23
+ ![pieforhead logo](logo.png)
24
+
25
+ ## Usage Notes
26
+
27
+ In general, all successful requests to the server should return a `dict` with the server's json response. Due to the alpha nature of the PieFed API, the maintenance burden of creating and updating response models as features/fields are added to the API is a burden that I don't want to take on at this time. Please refer to the official PieFed OpenAPI specification ([stable version](https://stable.wjs018.xyz/api/alpha/swagger), [development version](https://crust.piefed.social/api/alpha/swagger)) to see what your code should expect as a response.
28
+
29
+ By default, exceptions will be raised if something goes wrong (e.g. malformed request, network timeout, 5XX errors, etc.) to provide a trace. However, to suppress these exceptions, you can pass the `raise_exceptions=False` keyword argument when instantiating your client. This will cause a returned value of `None` instead of raising an exception. Please make sure that your code handles such a case appropriately.
30
+
31
+ ## Synchronous Client Usage
32
+
33
+ Users familiar with network libraries like `requests` would probably be most familiar with synchronous usage. However, to make network requests a bit more efficient, this library makes use of `Client`s within `httpx` where you can take advantage of connection pooling for multiple network requests. This means that when you instantiate a `PieFed` class, it is best practice to close it when you are done with it. Here are a couple examples that show how this can be done.
34
+
35
+ These snippets all do the same three things:
36
+
37
+ 1. Create a `PieFed` object with the given instance url (this creates the client)
38
+ 2. Log in using a username and password
39
+ 3. Close the client after we no longer need it
40
+
41
+ Closing it manually using the `close` function:
42
+
43
+ ```python
44
+ from piefedhead import PieFed
45
+
46
+ piefed = PieFed("https://instance.tld")
47
+ piefed.log_in("username", "password")
48
+
49
+ piefed.close()
50
+ ```
51
+
52
+ Using a context manager (a `with` block):
53
+
54
+ ```python
55
+ # Inside a context manager
56
+ with PieFed("https://instance.tld") as piefed:
57
+ piefed.log_in("username", "password")
58
+
59
+ # The connection is closed after leaving the `with` block
60
+ ```
61
+
62
+ Using a callback that ensures the `close` function is called when execution has ended:
63
+
64
+ ```python
65
+ # Registering an event handler
66
+ import atexit
67
+
68
+ piefed = PieFed("https://instance.tld")
69
+ atexit.register(piefed.close) # Automatically closes the client at program termination
70
+ ```
71
+
72
+ ## Asynchronous Client Usage
73
+
74
+ To use this libray in an async manner, there is a separate client, `PieFedAsync` that should be used. Everything else should work exactly the same as the synchronous client, except that results will need to be `await`ed.
75
+
76
+ Here is a snippet that does the same three actions as outlined above for the synchronous client, but using the `PieFedAsync` client.
77
+
78
+ ```python
79
+ # Put things in an async function so that we can use the await keyword
80
+ async def main():
81
+ piefed_async = PieFedAsync("https://instance.tld")
82
+ await piefed_async.log_in("username", "password") # Notice the await keyword
83
+
84
+ await piefed_async.close()
85
+
86
+ # Start the event loop and execute the async function
87
+ asyncio.run(main())
88
+ ```
89
+
90
+ Similar to the synchronous client, you can also use a context handler to automatically close the async client:
91
+
92
+ ```python
93
+ async def main():
94
+ async with PieFedAsync("https://instance.tld") as piefed_async:
95
+ await piefed_async.log_in("username", "password")
96
+
97
+ # Client is closed automatically once outside the context handler
98
+
99
+ # Start the event loop and execute the async function
100
+ asyncio.run(main())
101
+ ```
102
+
103
+ ## Endpoint Coverage
104
+
105
+ This project is in an early stage. Here is a high level summary of where things stand with implementing the wrapper around different PieFed API endpoint groups:
106
+
107
+ | Endpoint Group | Sync Client | Async Client | Notes |
108
+ | :-------------: | :---------: | :----------: | :--------------------------------------- |
109
+ | Site | ✅ | ✅ | Instance chooser endpoints unimplemented |
110
+ | Upload | ✅ | ✅ | |
111
+ | User | 🟡 | 🟡 | Auth endpoints implemented |
112
+ | Post | ❌ | ❌ | |
113
+ | Comment | ❌ | ❌ | |
114
+ | Community | ❌ | ❌ | |
115
+ | Feed | ❌ | ❌ | |
116
+ | Topic | ❌ | ❌ | |
117
+ | Private Message | ❌ | ❌ | |
118
+ | Admin | ❌ | ❌ | |
119
+ | Misc. | ❌ | ❌ | |
@@ -0,0 +1,11 @@
1
+ pieforhead/__init__.py,sha256=nvEcwCSSK6K4ywr78d9EXgk7WJCcGcaK86Zjj9uNSxc,134
2
+ pieforhead/clients.py,sha256=RBWIfrR3dhlQI2nwXgxQ9VOeRhOj-B1pjoxSKc6jqEs,16167
3
+ pieforhead/exceptions.py,sha256=Q-JiNqUJ7Bf9sThXfXqCfI9zs7UXEO2af4P9SbD5EG8,185
4
+ pieforhead/image.py,sha256=0hiv6FCmd8-tICWb8sWZzyIGcBTIN25lzPPtEc8FXKM,6984
5
+ pieforhead/pieforhead.py,sha256=U9sJExCd5hDf9m2BxBMg1XaE_yinDdUDcpUFhS82EiM,2386
6
+ pieforhead/site.py,sha256=vgWNHg2Ey2sJRglTiGQifeeOHMyH121wBV7bskq14Jc,4115
7
+ pieforhead/utils.py,sha256=sXLh7g3KC4QCFxcZGBTpG2scR7hmmBsMjq6LqRptkRg,22
8
+ pieforhead-0.0.1.dist-info/METADATA,sha256=rJp0cdOxcGaiOLFWhuKSUy8JptPh1mlHsk7yxd0bbUM,5772
9
+ pieforhead-0.0.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ pieforhead-0.0.1.dist-info/licenses/LICENSE,sha256=awOCsWJ58m_2kBQwBUGWejVqZm6wuRtCL2hi9rfa0X4,1211
11
+ pieforhead-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <https://unlicense.org>