longwei 1.0.0.dev0__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.
longwei/__init__.py ADDED
@@ -0,0 +1,74 @@
1
+ """Package 'longwei' level definitions."""
2
+
3
+ import sys
4
+ from enum import Enum
5
+ from importlib.metadata import version
6
+ from typing import Final
7
+
8
+ from longwei.constants import INSTANCE_TYPE_MASTODON
9
+ from longwei.constants import INSTANCE_TYPE_PLEROMA
10
+ from longwei.constants import INSTANCE_TYPE_TAKAHE
11
+ from longwei.exceptions import ActivityPubError
12
+ from longwei.exceptions import ApiError
13
+ from longwei.exceptions import ClientError
14
+ from longwei.exceptions import ConflictError
15
+ from longwei.exceptions import ForbiddenError
16
+ from longwei.exceptions import GoneError
17
+ from longwei.exceptions import NetworkError
18
+ from longwei.exceptions import NotFoundError
19
+ from longwei.exceptions import RatelimitError
20
+ from longwei.exceptions import ServerError
21
+ from longwei.exceptions import UnauthorizedError
22
+ from longwei.exceptions import UnprocessedError
23
+ from longwei.models import Account
24
+ from longwei.types import ActivityPubClass
25
+ from longwei.types import Status
26
+
27
+ __display_name__: Final[str] = "Longwei"
28
+ __version__: Final[str] = version(str(__package__))
29
+
30
+ USER_AGENT: Final[str] = f"{__display_name__}_v{__version__}_Python_{sys.version.split()[0]}"
31
+
32
+
33
+ class Visibility(str, Enum):
34
+ """Enumerating possible Visibility values for statuses."""
35
+
36
+ PUBLIC = "public"
37
+ UNLISTED = "unlisted"
38
+ PRIVATE = "private"
39
+ DIRECT = "direct"
40
+
41
+
42
+ class SearchType(str, Enum):
43
+ """Enumerating possible type values for status searches."""
44
+
45
+ ACCOUNTS = "accounts"
46
+ HASHTAGS = "hashtags"
47
+ STATUSES = "statuses"
48
+
49
+
50
+ __all__ = [ # noqa: RUF022
51
+ "Account",
52
+ "ActivityPubClass",
53
+ "ActivityPubError",
54
+ "ApiError",
55
+ "ClientError",
56
+ "ConflictError",
57
+ "__display_name__",
58
+ "ForbiddenError",
59
+ "GoneError",
60
+ "INSTANCE_TYPE_MASTODON",
61
+ "INSTANCE_TYPE_PLEROMA",
62
+ "INSTANCE_TYPE_TAKAHE",
63
+ "NetworkError",
64
+ "NotFoundError",
65
+ "RatelimitError",
66
+ "SearchType",
67
+ "ServerError",
68
+ "Status",
69
+ "UnauthorizedError",
70
+ "UnprocessedError",
71
+ "USER_AGENT",
72
+ "__version__",
73
+ "Visibility",
74
+ ]
longwei/_mixin_auth.py ADDED
@@ -0,0 +1,218 @@
1
+ """Auth mixin: OAuth app creation, token acquisition, authorization URL generation."""
2
+
3
+ import json
4
+ import logging
5
+ from urllib.parse import urlencode
6
+
7
+ from httpx import AsyncClient
8
+ from httpx import HTTPError
9
+
10
+ from longwei import USER_AGENT
11
+ from longwei import __display_name__
12
+ from longwei.constants import REDIRECT_URI
13
+ from longwei.exceptions import NetworkError
14
+ from longwei.utils import _check_exception
15
+
16
+ logger = logging.getLogger(__display_name__)
17
+
18
+
19
+ class _AuthMixin:
20
+ """Mixin providing static OAuth/authentication methods."""
21
+
22
+ @staticmethod
23
+ async def generate_authorization_url(
24
+ instance_url: str,
25
+ client_id: str,
26
+ user_agent: str = USER_AGENT,
27
+ ) -> str:
28
+ """Create URL to get access token interactively from website.
29
+
30
+ :param instance_url: The URL of the Mastodon instance you want to connect to
31
+ :param client_id: Client id of app as generated by create_app method
32
+ :param user_agent: User agent identifier to use. Defaults to longwei related one.
33
+
34
+ :returns: String containing URL to visit to get access token interactively from instance.
35
+ """
36
+ logger.debug(
37
+ "ActivityPub.get_auth_token_interactive(instance_url=%s, client=...,client_id=%s, user_agent=%s)",
38
+ instance_url,
39
+ client_id,
40
+ user_agent,
41
+ )
42
+ if "http" not in instance_url:
43
+ instance_url = f"https://{instance_url}"
44
+
45
+ url_params = urlencode(
46
+ {
47
+ "response_type": "code",
48
+ "client_id": client_id,
49
+ "redirect_uri": REDIRECT_URI,
50
+ "scope": "read write",
51
+ }
52
+ )
53
+ auth_url = f"{instance_url}/oauth/authorize?{url_params}"
54
+ logger.debug(
55
+ "ActivityPub.get_auth_token_interactive(...) -> %s",
56
+ auth_url,
57
+ )
58
+
59
+ return auth_url
60
+
61
+ @staticmethod
62
+ async def validate_authorization_code(
63
+ client: AsyncClient,
64
+ instance_url: str,
65
+ authorization_code: str,
66
+ client_id: str,
67
+ client_secret: str,
68
+ ) -> str:
69
+ """Validate an authorization code and get access token needed for API access.
70
+
71
+ :param client: httpx.AsyncClient
72
+ :param instance_url: The URL of the Mastodon instance you want to connect to
73
+ :param authorization_code: authorization code
74
+ :param client_id: client id as returned by create_app method
75
+ :param client_secret: client secret as returned by create_app method
76
+
77
+ :returns: access token
78
+ """
79
+ logger.debug(
80
+ "ActivityPub.validate_authorization_code(authorization_code=%s, client_id=%s, client_secret=<redacted>)",
81
+ authorization_code,
82
+ client_id,
83
+ )
84
+ if "http" not in instance_url:
85
+ instance_url = f"https://{instance_url}"
86
+
87
+ data = {
88
+ "client_id": client_id,
89
+ "client_secret": client_secret,
90
+ "scope": "read write",
91
+ "redirect_uri": REDIRECT_URI,
92
+ "grant_type": "authorization_code",
93
+ "code": authorization_code,
94
+ }
95
+ try:
96
+ response = await client.post(url=f"{instance_url}/oauth/token", data=data)
97
+ logger.debug(
98
+ "ActivityPub.validate_authorization_code - response:\n%s",
99
+ response,
100
+ )
101
+ await _check_exception(response)
102
+ response_dict = response.json()
103
+ except HTTPError as error:
104
+ raise NetworkError from error
105
+
106
+ logger.debug(
107
+ "ActivityPub.validate_authorization_code - response.json: \n%s",
108
+ json.dumps(response_dict, indent=4),
109
+ )
110
+ return str(response_dict["access_token"])
111
+
112
+ @staticmethod
113
+ async def get_auth_token( # noqa: PLR0913 - No way around needing all this parameters
114
+ instance_url: str,
115
+ username: str,
116
+ password: str,
117
+ client: AsyncClient,
118
+ user_agent: str = USER_AGENT,
119
+ client_website: str = "https://pypi.org/project/longwei/",
120
+ ) -> str:
121
+ """Create an app and use it to get an access token.
122
+
123
+ :param instance_url: The URL of the Mastodon instance you want to connect to
124
+ :param username: The username of the account you want to get an auth_token for
125
+ :param password: The password of the account you want to get an auth_token for
126
+ :param client: httpx.AsyncClient
127
+ :param user_agent: User agent identifier to use. Defaults to longwei related one.
128
+ :param client_website: Link to site for user_agent. Defaults to link to longwei on Pypi.org
129
+
130
+ :returns: The access token is being returned.
131
+ """
132
+ logger.debug(
133
+ "ActivityPub.get_auth_token(instance_url=%s, username=%s, password=<redacted>, client=..., "
134
+ "user_agent=%s, client_website=%s)",
135
+ instance_url,
136
+ username,
137
+ user_agent,
138
+ client_website,
139
+ )
140
+ if "http" not in instance_url:
141
+ instance_url = f"https://{instance_url}"
142
+
143
+ client_id, client_secret = await _AuthMixin.create_app(
144
+ client_website=client_website,
145
+ instance_url=instance_url,
146
+ client=client,
147
+ user_agent=user_agent,
148
+ )
149
+
150
+ data = {
151
+ "client_id": client_id,
152
+ "client_secret": client_secret,
153
+ "scope": "read write",
154
+ "redirect_uris": REDIRECT_URI,
155
+ "grant_type": "password",
156
+ "username": username,
157
+ "password": password,
158
+ }
159
+
160
+ try:
161
+ response = await client.post(url=f"{instance_url}/oauth/token", data=data)
162
+ logger.debug("ActivityPub.get_auth_token - response:\n%s", response)
163
+ await _check_exception(response)
164
+ response_dict = response.json()
165
+ except HTTPError as error:
166
+ raise NetworkError from error
167
+
168
+ logger.debug(
169
+ "ActivityPub.get_auth_token - response.json: \n%s",
170
+ json.dumps(response_dict, indent=4),
171
+ )
172
+ return str(response_dict["access_token"])
173
+
174
+ @staticmethod
175
+ async def create_app(
176
+ instance_url: str,
177
+ client: AsyncClient,
178
+ user_agent: str = USER_AGENT,
179
+ client_website: str = "https://pypi.org/project/longwei/",
180
+ ) -> tuple[str, str]:
181
+ """Create an app.
182
+
183
+ :param instance_url: The URL of the Mastodon instance you want to connect to
184
+ :param client: httpx.AsyncClient
185
+ :param user_agent: User agent identifier to use. Defaults to longwei related one.
186
+ :param client_website: Link to site for user_agent. Defaults to link to longwei on Pypi.org
187
+
188
+ :returns: tuple(client_id, client_secret)
189
+ """
190
+ logger.debug(
191
+ "ActivityPub.create_app(instance_url=%s, client=..., user_agent=%s, client_website=%s)",
192
+ instance_url,
193
+ user_agent,
194
+ client_website,
195
+ )
196
+
197
+ if "http" not in instance_url:
198
+ instance_url = f"https://{instance_url}"
199
+
200
+ data = {
201
+ "client_name": user_agent,
202
+ "client_website": client_website,
203
+ "scopes": "read write",
204
+ "redirect_uris": REDIRECT_URI,
205
+ }
206
+ try:
207
+ response = await client.post(url=f"{instance_url}/api/v1/apps", data=data)
208
+ logger.debug("ActivityPub.create_app response: \n%s", response)
209
+ await _check_exception(response)
210
+ response_dict = response.json()
211
+ except HTTPError as error:
212
+ raise NetworkError from error
213
+
214
+ logger.debug(
215
+ "ActivityPub.create_app response.json: \n%s",
216
+ json.dumps(response_dict, indent=4),
217
+ )
218
+ return (response_dict["client_id"]), (response_dict["client_secret"])
@@ -0,0 +1,103 @@
1
+ """Credentials mixin: verify_credentials, determine_instance_type, _set_instance_parameters."""
2
+
3
+ import json
4
+ import logging
5
+ from typing import Any
6
+
7
+ from httpx import HTTPError
8
+
9
+ from longwei import __display_name__
10
+ from longwei.constants import INSTANCE_TYPE_PLEROMA
11
+ from longwei.constants import INSTANCE_TYPE_TAKAHE
12
+ from longwei.constants import MAX_ATTACHMENTS_PLEROMA
13
+ from longwei.constants import MAX_ATTACHMENTS_TAKAHE
14
+ from longwei.exceptions import NetworkError
15
+ from longwei.models import Account
16
+ from longwei.types import ActivityPubClass
17
+ from longwei.utils import _check_exception
18
+
19
+ logger = logging.getLogger(__display_name__)
20
+
21
+
22
+ class _CredentialsMixin:
23
+ """Mixin providing credential verification and instance type detection."""
24
+
25
+ async def verify_credentials(self: ActivityPubClass) -> Account:
26
+ """Verify the credentials of the user.
27
+
28
+ :returns: The account information for the authenticated user.
29
+
30
+ """
31
+ url = f"{self.instance}/api/v1/accounts/verify_credentials"
32
+ logger.debug("ActivityPub.verify_credentials() - url=%s", url)
33
+ try:
34
+ response = await self.client.get(url=url, headers=self.headers, timeout=self.timeout)
35
+ logger.debug(
36
+ "ActivityPub.verify_credentials - response: \n%s",
37
+ response,
38
+ )
39
+ self._update_ratelimit(response.headers)
40
+ await _check_exception(response=response)
41
+ self._parse_next_prev(links=response.headers.get("Link"))
42
+ account = Account.model_validate(response.json())
43
+ except HTTPError as error:
44
+ raise NetworkError from error
45
+
46
+ return account
47
+
48
+ async def determine_instance_type(self: ActivityPubClass) -> None:
49
+ """Check if the instance is a Pleroma instance or not."""
50
+ instance = self.instance
51
+ if "http" not in self.instance:
52
+ instance = f"https://{self.instance}"
53
+
54
+ try:
55
+ response = await self.client.get(url=f"{instance}/api/v1/instance", timeout=self.timeout)
56
+ await _check_exception(response=response)
57
+ response_dict = response.json()
58
+ except HTTPError as error:
59
+ raise NetworkError from error
60
+
61
+ logger.debug(
62
+ "ActivityPub.determine_instance_type -> response.dict:\n%s",
63
+ json.dumps(response_dict, indent=4),
64
+ )
65
+ self.instance = instance
66
+
67
+ self._set_instance_parameters(response_dict)
68
+
69
+ logger.debug(
70
+ "ActivityPub.determine_instance_type() ... instance_type=%s",
71
+ self.instance_type,
72
+ )
73
+
74
+ def _set_instance_parameters(self, response_dict: dict[str, Any]) -> None:
75
+ """Set instance parameters from the /api/v1/instance response dict.
76
+
77
+ :param response_dict: Dictionary returned by call to .../api/v1/instance
78
+ """
79
+ version = response_dict.get("version", "")
80
+ if INSTANCE_TYPE_TAKAHE in version:
81
+ self.instance_type = INSTANCE_TYPE_TAKAHE
82
+ self.max_attachments = MAX_ATTACHMENTS_TAKAHE
83
+ elif INSTANCE_TYPE_PLEROMA in version:
84
+ self.instance_type = INSTANCE_TYPE_PLEROMA
85
+ self.max_attachments = MAX_ATTACHMENTS_PLEROMA
86
+
87
+ instance_config = response_dict.get("configuration", {})
88
+
89
+ if (max_status_len := instance_config.get("statuses", {}).get("max_characters")) or (
90
+ max_status_len := response_dict.get("max_characters")
91
+ ):
92
+ self.max_status_len = max_status_len
93
+
94
+ if (max_attachments := instance_config.get("statuses", {}).get("max_media_attachments")) or (
95
+ max_attachments := response_dict.get("max_media_attachments")
96
+ ):
97
+ self.max_attachments = max_attachments
98
+
99
+ if max_att_size := instance_config.get("media_attachments", {}).get("image_size_limit"):
100
+ self.max_att_size = max_att_size
101
+
102
+ if mime_types := instance_config.get("media_attachments", {}).get("supported_mime_types"):
103
+ self.supported_mime_types = mime_types
@@ -0,0 +1,115 @@
1
+ """Request helper mixin: rate-limit checking, pagination parsing, rate-limit updating."""
2
+
3
+ import logging
4
+ from typing import Any
5
+ from urllib.parse import parse_qs
6
+ from urllib.parse import urlparse
7
+
8
+ from whenever import Instant
9
+
10
+ from longwei import __display_name__
11
+ from longwei.constants import INSTANCE_TYPE_PLEROMA
12
+ from longwei.constants import INSTANCE_TYPE_TAKAHE
13
+ from longwei.constants import PAGINATION_UNDEFINED
14
+ from longwei.exceptions import RatelimitError
15
+ from longwei.types import ActivityPubClass
16
+ from longwei.utils import parse_rate_limit_reset
17
+
18
+ logger = logging.getLogger(__display_name__)
19
+
20
+
21
+ class _RequestHelpersMixin:
22
+ """Mixin providing _pre_call_checks, _parse_next_prev, and _update_ratelimit."""
23
+
24
+ async def _pre_call_checks(self: ActivityPubClass) -> None:
25
+ """Do checks before contacting the instance server.
26
+
27
+ Raises RatelimitError if rate limit is exhausted and reset is in the future.
28
+ """
29
+ logger.debug(
30
+ "ActivityPub.__pre_call_checks - Limit remaining: %s - Limit resetting at %s",
31
+ self.ratelimit_remaining,
32
+ self.ratelimit_reset,
33
+ )
34
+ if self.ratelimit_remaining == 0 and self.ratelimit_reset > Instant.now().py_datetime():
35
+ raise RatelimitError(
36
+ 429,
37
+ None,
38
+ "Rate limited",
39
+ ratelimit_reset=self.ratelimit_reset,
40
+ ratelimit_limit=self.ratelimit_limit,
41
+ )
42
+
43
+ def _parse_next_prev(self: ActivityPubClass, links: str | None) -> None:
44
+ """Extract min_id and max_id from a Link header and store in self.pagination.
45
+
46
+ :param links: The links header from the response
47
+ """
48
+ logger.debug("ActivityPub.__parse_next_prev - links = %s", links)
49
+
50
+ if not links:
51
+ return
52
+
53
+ self.pagination = PAGINATION_UNDEFINED
54
+
55
+ for comma_links in links.split(sep=", "):
56
+ pagination_rel: str | None = None
57
+ if 'rel="next"' in comma_links:
58
+ pagination_rel = "next"
59
+ elif 'rel="prev"' in comma_links:
60
+ pagination_rel = "prev"
61
+ else:
62
+ continue
63
+
64
+ urls = comma_links.split(sep="; ")
65
+
66
+ logger.debug("ActivityPub.__parse_next_prev - rel = %s - urls = %s", pagination_rel, urls)
67
+
68
+ for url in urls:
69
+ parsed_url = urlparse(url=url.lstrip("<").rstrip(">"))
70
+ queries_dict = parse_qs(str(parsed_url.query))
71
+ logger.debug("ActivityPub.__parse_next_prev - queries_dict = %s", queries_dict)
72
+ min_id = queries_dict.get("min_id")
73
+ max_id = queries_dict.get("max_id")
74
+ if min_id:
75
+ self.pagination[pagination_rel]["min_id"] = min_id[0]
76
+ if max_id:
77
+ self.pagination[pagination_rel]["max_id"] = max_id[0]
78
+
79
+ logger.debug("ActivityPub.__parse_next_prev - pagination = %s", self.pagination)
80
+
81
+ def _update_ratelimit(self: ActivityPubClass, headers: Any) -> None:
82
+ """Update rate limit tracking from response headers.
83
+
84
+ :param headers: The headers of the response
85
+ """
86
+ temp_ratelimit_limit = temp_ratelimit_remaining = temp_ratelimit_reset = None
87
+
88
+ if self.instance_type in (INSTANCE_TYPE_TAKAHE, INSTANCE_TYPE_PLEROMA):
89
+ # Takahe and Pleroma do not return rate limit headers.
90
+ # Default to 5 minute rate limit reset time
91
+ temp_ratelimit_reset = (Instant.now().add(minutes=5)).format_iso()
92
+ else:
93
+ temp_ratelimit_limit = headers.get("X-RateLimit-Limit")
94
+ temp_ratelimit_remaining = headers.get("X-RateLimit-Remaining")
95
+ temp_ratelimit_reset = headers.get("X-RateLimit-Reset")
96
+
97
+ if temp_ratelimit_limit:
98
+ self.ratelimit_limit = int(temp_ratelimit_limit)
99
+ if temp_ratelimit_remaining:
100
+ self.ratelimit_remaining = int(temp_ratelimit_remaining)
101
+ if temp_ratelimit_reset:
102
+ parsed_reset = parse_rate_limit_reset(temp_ratelimit_reset)
103
+ if parsed_reset:
104
+ self.ratelimit_reset = parsed_reset.py_datetime()
105
+
106
+ logger.debug(
107
+ "ActivityPub.__update_ratelimit - Pleroma Instance: %s - RateLimit Limit %s",
108
+ self.instance_type == INSTANCE_TYPE_PLEROMA,
109
+ self.ratelimit_limit,
110
+ )
111
+ logger.debug(
112
+ "ActivityPub.__update_ratelimit - Limit remaining: %s - Limit resetting at %s",
113
+ self.ratelimit_remaining,
114
+ self.ratelimit_reset,
115
+ )