pyyapper 1.4.0__tar.gz → 1.6.0__tar.gz

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.
Files changed (43) hide show
  1. {pyyapper-1.4.0/src/pyyapper.egg-info → pyyapper-1.6.0}/PKG-INFO +1 -1
  2. {pyyapper-1.4.0 → pyyapper-1.6.0}/pyproject.toml +1 -1
  3. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/__init__.py +15 -1
  4. pyyapper-1.6.0/src/pyyapper/_version.py +1 -0
  5. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/bot.py +24 -0
  6. pyyapper-1.6.0/src/pyyapper/components_v2.py +319 -0
  7. pyyapper-1.6.0/src/pyyapper/oauth.py +217 -0
  8. {pyyapper-1.4.0 → pyyapper-1.6.0/src/pyyapper.egg-info}/PKG-INFO +1 -1
  9. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper.egg-info/SOURCES.txt +3 -0
  10. pyyapper-1.6.0/tests/test_oauth.py +35 -0
  11. pyyapper-1.4.0/src/pyyapper/_version.py +0 -1
  12. {pyyapper-1.4.0 → pyyapper-1.6.0}/LICENSE +0 -0
  13. {pyyapper-1.4.0 → pyyapper-1.6.0}/README.md +0 -0
  14. {pyyapper-1.4.0 → pyyapper-1.6.0}/setup.cfg +0 -0
  15. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/buttons.py +0 -0
  16. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/cache.py +0 -0
  17. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/commands.py +0 -0
  18. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/config.py +0 -0
  19. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/embeds.py +0 -0
  20. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/errors.py +0 -0
  21. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/ext/__init__.py +0 -0
  22. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/ext/commands/__init__.py +0 -0
  23. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/ext/commands/checks.py +0 -0
  24. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/ext/commands/converters.py +0 -0
  25. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/ext/commands/core.py +0 -0
  26. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/ext/commands/errors.py +0 -0
  27. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/ext/tasks.py +0 -0
  28. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/http.py +0 -0
  29. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/i18n.py +0 -0
  30. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/metrics.py +0 -0
  31. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/models.py +0 -0
  32. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/pagination.py +0 -0
  33. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/presence.py +0 -0
  34. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/py.typed +0 -0
  35. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/wait_for.py +0 -0
  36. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/webhooks.py +0 -0
  37. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper.egg-info/dependency_links.txt +0 -0
  38. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper.egg-info/requires.txt +0 -0
  39. {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper.egg-info/top_level.txt +0 -0
  40. {pyyapper-1.4.0 → pyyapper-1.6.0}/tests/test_ext_commands.py +0 -0
  41. {pyyapper-1.4.0 → pyyapper-1.6.0}/tests/test_smoke.py +0 -0
  42. {pyyapper-1.4.0 → pyyapper-1.6.0}/tests/test_tasks.py +0 -0
  43. {pyyapper-1.4.0 → pyyapper-1.6.0}/tests/test_webhooks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyyapper
3
- Version: 1.4.0
3
+ Version: 1.6.0
4
4
  Summary: An intuitive, async Python library for the Yapper bot API.
5
5
  Author: PyYapper contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyyapper"
7
- version = "1.4.0"
7
+ version = "1.6.0"
8
8
  description = "An intuitive, async Python library for the Yapper bot API."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -23,10 +23,18 @@ from .presence import Activity, Presence
23
23
  from .pagination import Paginator
24
24
  from .webhooks import Webhook
25
25
  from .wait_for import WaiterRegistry, wait_for
26
+ from .oauth import OAuth2Client, OAuthError, generate_state, generate_pkce, YapperOAuth, LoginWithYapper
26
27
  from .config import load_config, load_env_file, get_token
27
28
  from .i18n import Translator, t as translate
28
29
  from .metrics import Metrics
29
30
  from .ext import commands, tasks
31
+ from . import components_v2 as components_v2
32
+ from .components_v2 import (
33
+ IS_COMPONENTS_V2, TextDisplay, Thumbnail, Section, MediaGallery, MediaGalleryItem,
34
+ Separator, FileComponent, Container, ActionRow,
35
+ text, thumbnail, section, gallery, separator, file_display, container, action_row,
36
+ row, normalize_components,
37
+ )
30
38
 
31
39
  __version__ = _version.__version__
32
40
 
@@ -38,9 +46,15 @@ __all__ = [
38
46
  "User", "Server", "Channel", "Category", "Member", "Message", "Role", "VoiceChannel", "picture_url",
39
47
  "Activity", "Presence",
40
48
  "Paginator", "Webhook", "WaiterRegistry", "wait_for",
49
+ "OAuth2Client", "OAuthError", "generate_state", "generate_pkce",
50
+ "YapperOAuth", "LoginWithYapper",
41
51
  "load_config", "load_env_file", "get_token",
42
52
  "Translator", "translate", "Metrics",
43
- "commands", "tasks",
53
+ "commands", "tasks", "components_v2",
54
+ "IS_COMPONENTS_V2", "TextDisplay", "Thumbnail", "Section", "MediaGallery",
55
+ "MediaGalleryItem", "Separator", "FileComponent", "Container", "ActionRow",
56
+ "text", "thumbnail", "section", "gallery", "separator", "file_display",
57
+ "container", "action_row", "row", "normalize_components",
44
58
  "Cog", "Command", "Group",
45
59
  "__version__",
46
60
  ]
@@ -0,0 +1 @@
1
+ __version__ = "1.6.0"
@@ -88,6 +88,9 @@ class Context:
88
88
  async def say(self, content: Any = "", **opts: Any) -> Any:
89
89
  return await self._bot.send(self.channel_id, content, **opts)
90
90
 
91
+ async def send_layout(self, components: Any, **opts: Any) -> Any:
92
+ return await self._bot.send_layout(self.channel_id, components, **opts)
93
+
91
94
  def __repr__(self) -> str:
92
95
  return f"Context(id={self.id!r}, author={self.author!r})"
93
96
 
@@ -137,6 +140,10 @@ class InteractionContext:
137
140
  kw = {**changes, **kw}
138
141
  changes = None
139
142
  body = {"type": "update"}
143
+ if "layout" in kw:
144
+ from .components_v2 import normalize_components
145
+ body["flags"] = 32768
146
+ body["components"] = normalize_components(kw.pop("layout"))
140
147
  if "content" in kw:
141
148
  c = kw.pop("content")
142
149
  body["content"] = None if c is None else str(c)
@@ -556,6 +563,23 @@ class Bot:
556
563
  def send_embed(self, channel_id: Any, embed: Any, content: str = "") -> Any:
557
564
  return self.send(channel_id, content, embeds=[embed])
558
565
 
566
+ async def send_layout(self, channel_id: Any, components: Any, *, reply_to: Any = None,
567
+ flags: int = 32768) -> Any:
568
+ from .components_v2 import normalize_components
569
+ body: dict[str, Any] = {"flags": flags, "components": normalize_components(components)}
570
+ if reply_to is not None:
571
+ body["reply_to"] = reply_to
572
+ out = await self.api("POST", f"/channels/{channel_id}/messages", body)
573
+ msg = out.get("message") if isinstance(out, dict) else None
574
+ return msg if msg is not None else out
575
+
576
+ async def edit_layout(self, channel_id: Any, message_id: Any, components: Any,
577
+ *, flags: int = 32768) -> Any:
578
+ from .components_v2 import normalize_components
579
+ body: dict[str, Any] = {"flags": flags, "components": normalize_components(components)}
580
+ out = await self.api("PATCH", f"/channels/{channel_id}/messages/{message_id}", body)
581
+ return out.get("message") if isinstance(out, dict) else out
582
+
559
583
  async def delete(self, channel_id: Any, message_id: Any) -> Any:
560
584
  return await self.api("DELETE", f"/channels/{channel_id}/messages/{message_id}")
561
585
 
@@ -0,0 +1,319 @@
1
+ """Components V2 builders for PyYapper.
2
+
3
+ Components V2 lets you build a whole message out of layout parts: containers
4
+ (cards), sections with a button or thumbnail next to the text, media galleries,
5
+ separators, files and text displays, with button rows anywhere.
6
+
7
+ Send one with ``bot.send_layout(channel, [container(...)])`` (or
8
+ ``ctx.send_layout(...)``), which sets ``flags: 32768`` (``IS_COMPONENTS_V2``).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Optional
14
+
15
+ from .buttons import Button, Row
16
+ from .embeds import Embed # noqa: F401 (kept for parity)
17
+
18
+
19
+ def _plain(x: Any) -> Any:
20
+ if hasattr(x, "to_dict"):
21
+ return x.to_dict()
22
+ if hasattr(x, "toJSON"):
23
+ return x.toJSON()
24
+ return x
25
+
26
+
27
+ def _color(c: Any) -> Optional[int]:
28
+ if c is None:
29
+ return None
30
+ if isinstance(c, bool):
31
+ return None
32
+ if isinstance(c, int):
33
+ return c
34
+ s = str(c).strip()
35
+ named = {"red": 0xED4245, "green": 0x57F287, "blue": 0x5865F2,
36
+ "yellow": 0xFEE75C, "purple": 0x9B59B6, "orange": 0xE67E22,
37
+ "grey": 0x95A5A6, "gray": 0x95A5A6, "blurple": 0x5865F2}
38
+ if s.lower() in named:
39
+ return named[s.lower()]
40
+ hexed = s.lstrip("#").lstrip("0x").lstrip("x")
41
+ if s.lower().startswith("0x"):
42
+ hexed = s[2:]
43
+ try:
44
+ return int(hexed, 16)
45
+ except ValueError:
46
+ return None
47
+
48
+
49
+ def _media(url: Any) -> dict[str, Any]:
50
+ if isinstance(url, dict):
51
+ return dict(url)
52
+ return {"url": str(url)}
53
+
54
+
55
+ def _normalize_v2(obj: Any) -> Any:
56
+ """Coerce builders/dicts to plain dicts and normalize action-row types."""
57
+ if hasattr(obj, "to_dict"):
58
+ obj = obj.to_dict()
59
+ if isinstance(obj, dict):
60
+ # action row: "row" -> 1
61
+ t = obj.get("type")
62
+ if t in ("row", "action_row"):
63
+ obj["type"] = 1
64
+ if t == "button":
65
+ obj["type"] = 2
66
+ # recurse
67
+ for k in ("components", "items", "accessory"):
68
+ if k in obj and isinstance(obj[k], list):
69
+ obj[k] = [_normalize_v2(x) for x in obj[k]]
70
+ elif k in obj and obj[k] is not None and not isinstance(obj[k], (str, int, float, bool)):
71
+ obj[k] = _normalize_v2(obj[k])
72
+ if "media" in obj and isinstance(obj["media"], (dict,)):
73
+ obj["media"] = _normalize_v2(obj["media"])
74
+ if "file" in obj and isinstance(obj["file"], (dict,)):
75
+ obj["file"] = _normalize_v2(obj["file"])
76
+ return obj
77
+ if isinstance(obj, (list, tuple)):
78
+ return [_normalize_v2(x) for x in obj]
79
+ return obj
80
+
81
+
82
+ # --------------------------------------------------------------------------- #
83
+ # Builders
84
+ # --------------------------------------------------------------------------- #
85
+ class TextDisplay:
86
+ """Type 10 — a text display (markdown)."""
87
+
88
+ def __init__(self, content: Any, *, id: Optional[int] = None) -> None:
89
+ self.data: dict[str, Any] = {"type": 10, "content": str(content)}
90
+ if id is not None:
91
+ self.data["id"] = id
92
+
93
+ def to_dict(self) -> dict[str, Any]:
94
+ return dict(self.data)
95
+
96
+ def toJSON(self) -> dict[str, Any]:
97
+ return self.to_dict()
98
+
99
+
100
+ class Thumbnail:
101
+ """Type 11 — a thumbnail (only valid as a section accessory)."""
102
+
103
+ def __init__(self, url: Any, *, description: Optional[str] = None,
104
+ spoiler: bool = False, id: Optional[int] = None) -> None:
105
+ self.data: dict[str, Any] = {"type": 11, "media": {"url": str(url)}}
106
+ if description is not None:
107
+ self.data["description"] = str(description)
108
+ if spoiler:
109
+ self.data["spoiler"] = True
110
+ if id is not None:
111
+ self.data["id"] = id
112
+
113
+ def to_dict(self) -> dict[str, Any]:
114
+ return dict(self.data)
115
+
116
+ def toJSON(self) -> dict[str, Any]:
117
+ return self.to_dict()
118
+
119
+
120
+ class Section:
121
+ """Type 9 — 1 to 3 text displays plus a button or thumbnail accessory."""
122
+
123
+ def __init__(self, *texts: Any, accessory: Any = None, id: Optional[int] = None) -> None:
124
+ comps = [_plain(t) if not hasattr(t, "to_dict") else t.to_dict() for t in texts]
125
+ # allow passing raw strings
126
+ norm = []
127
+ for t in texts:
128
+ if isinstance(t, str):
129
+ norm.append({"type": 10, "content": t})
130
+ else:
131
+ norm.append(_normalize_v2(_plain(t)))
132
+ self.data: dict[str, Any] = {"type": 9, "components": norm}
133
+ if accessory is not None:
134
+ self.data["accessory"] = _normalize_v2(_plain(accessory))
135
+ if id is not None:
136
+ self.data["id"] = id
137
+
138
+ def accessory(self, acc: Any) -> "Section":
139
+ self.data["accessory"] = _normalize_v2(_plain(acc))
140
+ return self
141
+
142
+ def to_dict(self) -> dict[str, Any]:
143
+ return _normalize_v2(dict(self.data))
144
+
145
+ def toJSON(self) -> dict[str, Any]:
146
+ return self.to_dict()
147
+
148
+
149
+ class MediaGalleryItem:
150
+ def __init__(self, url: Any, *, description: Optional[str] = None, spoiler: bool = False) -> None:
151
+ self.data: dict[str, Any] = {"media": {"url": str(url)}}
152
+ if description is not None:
153
+ self.data["description"] = str(description)
154
+ if spoiler:
155
+ self.data["spoiler"] = True
156
+
157
+ def to_dict(self) -> dict[str, Any]:
158
+ return dict(self.data)
159
+
160
+
161
+ class MediaGallery:
162
+ """Type 12 — a gallery of 1 to 10 media items."""
163
+
164
+ def __init__(self, *items: Any, id: Optional[int] = None) -> None:
165
+ norm = []
166
+ for it in items:
167
+ if isinstance(it, str):
168
+ norm.append({"media": {"url": it}})
169
+ elif isinstance(it, MediaGalleryItem):
170
+ norm.append(it.to_dict())
171
+ elif isinstance(it, dict):
172
+ if "media" in it:
173
+ norm.append(dict(it))
174
+ else:
175
+ norm.append({"media": {"url": str(it)}})
176
+ else:
177
+ norm.append(_normalize_v2(_plain(it)))
178
+ self.data: dict[str, Any] = {"type": 12, "items": norm}
179
+ if id is not None:
180
+ self.data["id"] = id
181
+
182
+ def to_dict(self) -> dict[str, Any]:
183
+ return _normalize_v2(dict(self.data))
184
+
185
+ def toJSON(self) -> dict[str, Any]:
186
+ return self.to_dict()
187
+
188
+
189
+ class Separator:
190
+ """Type 14 — a divider or spacer."""
191
+
192
+ def __init__(self, *, divider: bool = True, spacing: int = 1, id: Optional[int] = None) -> None:
193
+ self.data: dict[str, Any] = {"type": 14, "divider": bool(divider), "spacing": int(spacing)}
194
+ if id is not None:
195
+ self.data["id"] = id
196
+
197
+ def to_dict(self) -> dict[str, Any]:
198
+ return dict(self.data)
199
+
200
+ def toJSON(self) -> dict[str, Any]:
201
+ return self.to_dict()
202
+
203
+
204
+ class FileComponent:
205
+ """Type 13 — a file download card."""
206
+
207
+ def __init__(self, url: Any, *, spoiler: bool = False, id: Optional[int] = None) -> None:
208
+ self.data: dict[str, Any] = {"type": 13, "file": {"url": str(url)}}
209
+ if spoiler:
210
+ self.data["spoiler"] = True
211
+ if id is not None:
212
+ self.data["id"] = id
213
+
214
+ def to_dict(self) -> dict[str, Any]:
215
+ return dict(self.data)
216
+
217
+ def toJSON(self) -> dict[str, Any]:
218
+ return self.to_dict()
219
+
220
+
221
+ class Container:
222
+ """Type 17 — a card. ``components``: action rows, text displays, sections, galleries, separators, files."""
223
+
224
+ def __init__(self, *components: Any, accent_color: Any = None, spoiler: bool = False,
225
+ id: Optional[int] = None) -> None:
226
+ self.data: dict[str, Any] = {"type": 17, "components": [_normalize_v2(_plain(c)) for c in components]}
227
+ ac = _color(accent_color)
228
+ if ac is not None:
229
+ self.data["accent_color"] = ac
230
+ if spoiler:
231
+ self.data["spoiler"] = True
232
+ if id is not None:
233
+ self.data["id"] = id
234
+
235
+ def add(self, *components: Any) -> "Container":
236
+ self.data["components"].extend(_normalize_v2(_plain(c)) for c in components)
237
+ return self
238
+
239
+ def accent(self, color: Any) -> "Container":
240
+ ac = _color(color)
241
+ if ac is not None:
242
+ self.data["accent_color"] = ac
243
+ return self
244
+
245
+ def to_dict(self) -> dict[str, Any]:
246
+ return _normalize_v2(dict(self.data))
247
+
248
+ def toJSON(self) -> dict[str, Any]:
249
+ return self.to_dict()
250
+
251
+
252
+ class ActionRow:
253
+ """Type 1 — an action row of up to 5 buttons (V2 canonical)."""
254
+
255
+ def __init__(self, *buttons: Any, id: Optional[int] = None) -> None:
256
+ btns = []
257
+ for b in buttons:
258
+ btns.extend(b if isinstance(b, list) else [b])
259
+ self.data: dict[str, Any] = {"type": 1, "components": [_plain(x) for x in btns]}
260
+ if id is not None:
261
+ self.data["id"] = id
262
+
263
+ def to_dict(self) -> dict[str, Any]:
264
+ return _normalize_v2(dict(self.data))
265
+
266
+ def toJSON(self) -> dict[str, Any]:
267
+ return self.to_dict()
268
+
269
+
270
+ # --------------------------------------------------------------------------- #
271
+ # Factory functions (discord.py / JS-style convenience)
272
+ # --------------------------------------------------------------------------- #
273
+ def text(content: Any, *, id: Optional[int] = None) -> TextDisplay:
274
+ return TextDisplay(content, id=id)
275
+
276
+
277
+ def thumbnail(url: Any, *, description: Optional[str] = None, spoiler: bool = False,
278
+ id: Optional[int] = None) -> Thumbnail:
279
+ return Thumbnail(url, description=description, spoiler=spoiler, id=id)
280
+
281
+
282
+ def section(*texts: Any, accessory: Any = None, id: Optional[int] = None) -> Section:
283
+ return Section(*texts, accessory=accessory, id=id)
284
+
285
+
286
+ def gallery(*items: Any, id: Optional[int] = None) -> MediaGallery:
287
+ return MediaGallery(*items, id=id)
288
+
289
+
290
+ def separator(*, divider: bool = True, spacing: int = 1, id: Optional[int] = None) -> Separator:
291
+ return Separator(divider=divider, spacing=spacing, id=id)
292
+
293
+
294
+ def file_display(url: Any, *, spoiler: bool = False, id: Optional[int] = None) -> FileComponent:
295
+ return FileComponent(url, spoiler=spoiler, id=id)
296
+
297
+
298
+ def container(*components: Any, accent_color: Any = None, spoiler: bool = False,
299
+ id: Optional[int] = None) -> Container:
300
+ return Container(*components, accent_color=accent_color, spoiler=spoiler, id=id)
301
+
302
+
303
+ def action_row(*buttons: Any, id: Optional[int] = None) -> ActionRow:
304
+ return ActionRow(*buttons, id=id)
305
+
306
+
307
+ # alias for Row-style usage in V2
308
+ row = action_row
309
+
310
+
311
+ IS_COMPONENTS_V2 = 32768
312
+
313
+
314
+ def normalize_components(components: Any) -> list:
315
+ """Normalize a list of V2 components to plain dicts."""
316
+ out = []
317
+ for c in (components if isinstance(components, (list, tuple)) else [components]):
318
+ out.append(_normalize_v2(_plain(c)))
319
+ return out
@@ -0,0 +1,217 @@
1
+ """OAuth2 "Login with Yapper" client for PyYapper.
2
+
3
+ Implements the authorization-code flow (with optional PKCE) described in the
4
+ Yapper developer docs. This is intended for web apps that let users log in with
5
+ their Yapper account; it is independent of the bot API.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import secrets
15
+ import urllib.parse
16
+ from typing import Any, Optional
17
+
18
+ import aiohttp
19
+
20
+ from .errors import YapperError
21
+
22
+
23
+ class OAuthError(YapperError):
24
+ """An OAuth2 request failed.
25
+
26
+ Attributes:
27
+ error: OAuth2 error code (e.g. ``invalid_grant``).
28
+ description: Human readable description.
29
+ status: HTTP status code.
30
+ """
31
+
32
+ def __init__(self, error: str, description: str = "", *, status: int = 400) -> None:
33
+ self.error = error
34
+ self.description = description
35
+ self.status = status
36
+ super().__init__(f"{error}: {description}".strip(": "))
37
+
38
+
39
+ DEFAULT_BASE = "https://www.yapper.website"
40
+ AUTHORIZE_PATH = "/oauth/authorize"
41
+ TOKEN_PATH = "/api/oauth/token"
42
+ ME_PATH = "/api/oauth/me"
43
+
44
+
45
+ def _b64url(data: bytes) -> str:
46
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
47
+
48
+
49
+ def generate_state(n: int = 32) -> str:
50
+ """Generate a random, URL-safe ``state`` value."""
51
+ return secrets.token_urlsafe(n)
52
+
53
+
54
+ def generate_pkce(verifier_length: int = 64) -> tuple[str, str]:
55
+ """Generate a (code_verifier, code_challenge) pair for PKCE (S256)."""
56
+ verifier = secrets.token_urlsafe(verifier_length)[:128]
57
+ if len(verifier) < 43:
58
+ verifier = verifier + secrets.token_urlsafe(43 - len(verifier))
59
+ challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
60
+ return verifier, challenge
61
+
62
+
63
+ class OAuth2Client:
64
+ """A client for one Yapper OAuth2 app.
65
+
66
+ Parameters
67
+ ----------
68
+ client_id:
69
+ The app's client id.
70
+ client_secret:
71
+ The app's client secret (omit for PKCE/public apps).
72
+ redirect_uri:
73
+ Default redirect URI (must match one registered for the app).
74
+ base_url:
75
+ Yapper base URL. Defaults to ``https://www.yapper.website``.
76
+ session:
77
+ Optional shared :class:`aiohttp.ClientSession`.
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ client_id: str,
83
+ client_secret: Optional[str] = None,
84
+ redirect_uri: Optional[str] = None,
85
+ *,
86
+ base_url: str = DEFAULT_BASE,
87
+ session: Optional[aiohttp.ClientSession] = None,
88
+ ) -> None:
89
+ self.client_id = str(client_id)
90
+ self.client_secret = client_secret
91
+ self.redirect_uri = redirect_uri
92
+ self.base_url = base_url.rstrip("/")
93
+ self._session = session
94
+ self._owns_session = session is None
95
+
96
+ # ------------------------------------------------------------------ #
97
+ # Session management
98
+ # ------------------------------------------------------------------ #
99
+ async def _get_session(self) -> aiohttp.ClientSession:
100
+ if self._session is None or getattr(self._session, "closed", False):
101
+ self._session = aiohttp.ClientSession()
102
+ self._owns_session = True
103
+ return self._session
104
+
105
+ async def close(self) -> None:
106
+ if self._owns_session and self._session is not None and not getattr(self._session, "closed", True):
107
+ await self._session.close()
108
+ self._session = None
109
+
110
+ async def __aenter__(self) -> "OAuth2Client":
111
+ return self
112
+
113
+ async def __aexit__(self, *exc: Any) -> None:
114
+ await self.close()
115
+
116
+ # ------------------------------------------------------------------ #
117
+ # Authorization URL
118
+ # ------------------------------------------------------------------ #
119
+ def authorization_url(
120
+ self,
121
+ state: str,
122
+ *,
123
+ scope: str = "identify",
124
+ redirect_uri: Optional[str] = None,
125
+ code_challenge: Optional[str] = None,
126
+ code_challenge_method: str = "S256",
127
+ ) -> str:
128
+ params: dict[str, Any] = {
129
+ "client_id": self.client_id,
130
+ "redirect_uri": redirect_uri or self.redirect_uri,
131
+ "response_type": "code",
132
+ "scope": scope,
133
+ "state": state,
134
+ }
135
+ if code_challenge is not None:
136
+ params["code_challenge"] = code_challenge
137
+ params["code_challenge_method"] = code_challenge_method
138
+ return f"{self.base_url}{AUTHORIZE_PATH}?{urllib.parse.urlencode(params)}"
139
+
140
+ # ------------------------------------------------------------------ #
141
+ # Token exchange
142
+ # ------------------------------------------------------------------ #
143
+ async def exchange_code(
144
+ self,
145
+ code: str,
146
+ *,
147
+ redirect_uri: Optional[str] = None,
148
+ code_verifier: Optional[str] = None,
149
+ use_basic_auth: bool = False,
150
+ ) -> dict[str, Any]:
151
+ url = f"{self.base_url}{TOKEN_PATH}"
152
+ body: dict[str, Any] = {
153
+ "grant_type": "authorization_code",
154
+ "code": code,
155
+ "redirect_uri": redirect_uri or self.redirect_uri,
156
+ "client_id": self.client_id,
157
+ }
158
+ if self.client_secret is not None and not use_basic_auth:
159
+ body["client_secret"] = self.client_secret
160
+ if code_verifier is not None:
161
+ body["code_verifier"] = code_verifier
162
+
163
+ headers: dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"}
164
+ if use_basic_auth and self.client_secret is not None:
165
+ import base64 as _b64
166
+
167
+ creds = _b64.b64encode(f"{self.client_id}:{self.client_secret}".encode()).decode()
168
+ headers["Authorization"] = f"Basic {creds}"
169
+ body.pop("client_secret", None)
170
+
171
+ session = await self._get_session()
172
+ async with session.post(url, data=body, headers=headers) as res:
173
+ raw = await res.read()
174
+ text = raw.decode("utf-8", errors="replace") if raw else ""
175
+ try:
176
+ payload = json.loads(text) if text else {}
177
+ except Exception:
178
+ payload = {}
179
+ if res.status >= 400:
180
+ err = payload.get("error", "invalid_request") if isinstance(payload, dict) else "invalid_request"
181
+ desc = payload.get("error_description", "") if isinstance(payload, dict) else ""
182
+ raise OAuthError(err, desc, status=res.status)
183
+ return payload if isinstance(payload, dict) else {}
184
+
185
+ # ------------------------------------------------------------------ #
186
+ # User info
187
+ # ------------------------------------------------------------------ #
188
+ async def fetch_user(self, access_token: str) -> dict[str, Any]:
189
+ url = f"{self.base_url}{ME_PATH}"
190
+ session = await self._get_session()
191
+ async with session.get(url, headers={"Authorization": f"Bearer {access_token}"}) as res:
192
+ raw = await res.read()
193
+ text = raw.decode("utf-8", errors="replace") if raw else ""
194
+ try:
195
+ payload = json.loads(text) if text else {}
196
+ except Exception:
197
+ payload = {}
198
+ if res.status >= 400:
199
+ err = payload.get("error", "invalid_token") if isinstance(payload, dict) else "invalid_token"
200
+ desc = payload.get("error_description", "") if isinstance(payload, dict) else ""
201
+ raise OAuthError(err, desc, status=res.status)
202
+ return payload if isinstance(payload, dict) else {}
203
+
204
+ # ------------------------------------------------------------------ #
205
+ # Convenience
206
+ # ------------------------------------------------------------------ #
207
+ async def login(self, code: str, **kw: Any) -> dict[str, Any]:
208
+ """Exchange ``code`` and immediately fetch the user. Returns ``{token, user}``."""
209
+ token = await self.exchange_code(code, **kw)
210
+ access_token = token.get("access_token")
211
+ user = await self.fetch_user(access_token) if access_token else None
212
+ return {"token": token, "user": user}
213
+
214
+
215
+ # Aliases
216
+ YapperOAuth = OAuth2Client
217
+ LoginWithYapper = OAuth2Client
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyyapper
3
- Version: 1.4.0
3
+ Version: 1.6.0
4
4
  Summary: An intuitive, async Python library for the Yapper bot API.
5
5
  Author: PyYapper contributors
6
6
  License-Expression: MIT
@@ -7,6 +7,7 @@ src/pyyapper/bot.py
7
7
  src/pyyapper/buttons.py
8
8
  src/pyyapper/cache.py
9
9
  src/pyyapper/commands.py
10
+ src/pyyapper/components_v2.py
10
11
  src/pyyapper/config.py
11
12
  src/pyyapper/embeds.py
12
13
  src/pyyapper/errors.py
@@ -14,6 +15,7 @@ src/pyyapper/http.py
14
15
  src/pyyapper/i18n.py
15
16
  src/pyyapper/metrics.py
16
17
  src/pyyapper/models.py
18
+ src/pyyapper/oauth.py
17
19
  src/pyyapper/pagination.py
18
20
  src/pyyapper/presence.py
19
21
  src/pyyapper/py.typed
@@ -32,6 +34,7 @@ src/pyyapper/ext/commands/converters.py
32
34
  src/pyyapper/ext/commands/core.py
33
35
  src/pyyapper/ext/commands/errors.py
34
36
  tests/test_ext_commands.py
37
+ tests/test_oauth.py
35
38
  tests/test_smoke.py
36
39
  tests/test_tasks.py
37
40
  tests/test_webhooks.py
@@ -0,0 +1,35 @@
1
+ import asyncio
2
+
3
+ from pyyapper import OAuth2Client, generate_state, generate_pkce, OAuthError
4
+
5
+
6
+ def test_auth_url():
7
+ c = OAuth2Client("cid", "secret", "https://example.com/cb")
8
+ url = c.authorization_url("st")
9
+ assert "client_id=cid" in url
10
+ assert "state=st" in url
11
+ assert "response_type=code" in url
12
+
13
+
14
+ def test_pkce():
15
+ v, ch = generate_pkce()
16
+ assert len(v) >= 43
17
+ assert len(ch) >= 43
18
+
19
+
20
+ def test_generate_state():
21
+ s = generate_state()
22
+ assert len(s) >= 32
23
+
24
+
25
+ def test_oauth_error():
26
+ e = OAuthError("invalid_grant", "bad code", status=400)
27
+ assert e.error == "invalid_grant"
28
+ assert e.status == 400
29
+
30
+
31
+ def test_async_context_manager():
32
+ async def main():
33
+ async with OAuth2Client("cid", redirect_uri="https://example.com/cb") as c:
34
+ assert c.client_id == "cid"
35
+ asyncio.run(main())
@@ -1 +0,0 @@
1
- __version__ = "1.4.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes