pyyapper 1.2.0a1__tar.gz → 1.3.2__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 (37) hide show
  1. {pyyapper-1.2.0a1/src/pyyapper.egg-info → pyyapper-1.3.2}/PKG-INFO +3 -1
  2. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/pyproject.toml +2 -1
  3. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/__init__.py +6 -3
  4. pyyapper-1.3.2/src/pyyapper/_version.py +1 -0
  5. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/bot.py +179 -24
  6. pyyapper-1.3.2/src/pyyapper/cache.py +52 -0
  7. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/errors.py +5 -0
  8. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/ext/commands/core.py +7 -4
  9. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/ext/tasks.py +3 -2
  10. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/http.py +31 -9
  11. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/models.py +74 -2
  12. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/pagination.py +5 -1
  13. pyyapper-1.3.2/src/pyyapper/presence.py +139 -0
  14. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/wait_for.py +2 -1
  15. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/webhooks.py +31 -6
  16. {pyyapper-1.2.0a1 → pyyapper-1.3.2/src/pyyapper.egg-info}/PKG-INFO +3 -1
  17. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper.egg-info/SOURCES.txt +2 -0
  18. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper.egg-info/requires.txt +3 -0
  19. pyyapper-1.2.0a1/src/pyyapper/_version.py +0 -1
  20. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/LICENSE +0 -0
  21. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/README.md +0 -0
  22. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/setup.cfg +0 -0
  23. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/buttons.py +0 -0
  24. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/commands.py +0 -0
  25. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/embeds.py +0 -0
  26. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/ext/__init__.py +0 -0
  27. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/ext/commands/__init__.py +0 -0
  28. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/ext/commands/checks.py +0 -0
  29. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/ext/commands/converters.py +0 -0
  30. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/ext/commands/errors.py +0 -0
  31. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/py.typed +0 -0
  32. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper.egg-info/dependency_links.txt +0 -0
  33. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper.egg-info/top_level.txt +0 -0
  34. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/tests/test_ext_commands.py +0 -0
  35. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/tests/test_smoke.py +0 -0
  36. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/tests/test_tasks.py +0 -0
  37. {pyyapper-1.2.0a1 → pyyapper-1.3.2}/tests/test_webhooks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyyapper
3
- Version: 1.2.0a1
3
+ Version: 1.3.2
4
4
  Summary: An intuitive, async Python library for the Yapper bot API.
5
5
  Author: PyYapper contributors
6
6
  License-Expression: MIT
@@ -31,6 +31,8 @@ Requires-Dist: twine; extra == "dev"
31
31
  Provides-Extra: docs
32
32
  Requires-Dist: mkdocs; extra == "docs"
33
33
  Requires-Dist: mkdocs-material; extra == "docs"
34
+ Provides-Extra: speed
35
+ Requires-Dist: orjson; extra == "speed"
34
36
  Dynamic: license-file
35
37
 
36
38
  # PyYapper
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyyapper"
7
- version = "1.2.0a1"
7
+ version = "1.3.2"
8
8
  description = "An intuitive, async Python library for the Yapper bot API."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -33,6 +33,7 @@ dependencies = [
33
33
  [project.optional-dependencies]
34
34
  dev = ["pytest", "pytest-asyncio", "build", "twine"]
35
35
  docs = ["mkdocs", "mkdocs-material"]
36
+ speed = ["orjson"]
36
37
 
37
38
  [project.urls]
38
39
  Homepage = "https://github.com/pyyapper/pyyapper"
@@ -14,10 +14,12 @@ from .errors import (
14
14
  NotFound,
15
15
  RateLimited,
16
16
  ServerError,
17
+ ServiceUnavailable,
17
18
  )
18
19
  from .models import (
19
- User, Server, Channel, Category, Member, Message, Role,
20
+ User, Server, Channel, Category, Member, Message, Role, VoiceChannel, picture_url,
20
21
  )
22
+ from .presence import Activity, Presence
21
23
  from .pagination import Paginator
22
24
  from .webhooks import Webhook
23
25
  from .wait_for import WaiterRegistry, wait_for
@@ -29,8 +31,9 @@ __all__ = [
29
31
  "Bot", "Context", "InteractionContext",
30
32
  "Embed", "Button", "Row",
31
33
  "YapperError", "ConfigurationError", "HTTPException",
32
- "BadRequest", "Unauthorized", "Forbidden", "NotFound", "RateLimited", "ServerError",
33
- "User", "Server", "Channel", "Category", "Member", "Message", "Role",
34
+ "BadRequest", "Unauthorized", "Forbidden", "NotFound", "RateLimited", "ServerError", "ServiceUnavailable",
35
+ "User", "Server", "Channel", "Category", "Member", "Message", "Role", "VoiceChannel", "picture_url",
36
+ "Activity", "Presence",
34
37
  "Paginator", "Webhook", "WaiterRegistry", "wait_for",
35
38
  "commands", "tasks",
36
39
  "Cog", "Command", "Group",
@@ -0,0 +1 @@
1
+ __version__ = "1.3.2"
@@ -17,15 +17,18 @@ from .models import (
17
17
  Role,
18
18
  Server,
19
19
  User,
20
+ VoiceChannel,
20
21
  )
22
+ from .cache import TTLCache, MISSING as _CACHE_MISSING
23
+ from .presence import Activity, Presence
21
24
  from .embeds import Embed
22
25
  from .buttons import Button, Row
23
26
 
24
27
  # Event names that are valid.
25
28
  EVENTS = {
26
29
  "message", "command", "command_error", "member_join", "member_leave",
27
- "reaction_add", "reaction_remove", "interaction", "test",
28
- "ready", "error",
30
+ "reaction_add", "reaction_remove", "voice_join", "voice_leave",
31
+ "interaction", "test", "ready", "error",
29
32
  }
30
33
 
31
34
  # Helper to coerce embeds/buttons to dicts.
@@ -170,14 +173,25 @@ class Bot:
170
173
 
171
174
  def __init__(
172
175
  self,
173
- token: str,
176
+ token: Optional[str] = None,
174
177
  *,
175
178
  base_url: str = "https://www.yapper.website/api/bot/v1",
176
179
  prefixes: Optional[list[str]] = None,
177
180
  log: bool = True,
181
+ ignore_bots: bool = True,
182
+ max_age: Optional[int] = None,
183
+ cache_ttl: float = 0.0,
178
184
  ) -> None:
185
+ if token is None:
186
+ import os
187
+ token = os.environ.get("YAPPER_BOT_TOKEN")
179
188
  self.token = str(token).strip() if token else ""
180
189
  self.base_url = base_url
190
+ self.ignore_bots = bool(ignore_bots)
191
+ self.max_age = max_age
192
+ self._presence = None
193
+ self._cache = TTLCache(default_ttl=cache_ttl)
194
+ self.cache_ttl = cache_ttl
181
195
  self.prefixes = prefixes if prefixes is not None else ["/", "!"]
182
196
  self.log = log
183
197
 
@@ -430,27 +444,71 @@ class Bot:
430
444
  # ------------------------------------------------------------------ #
431
445
  # Servers / channels / members
432
446
  # ------------------------------------------------------------------ #
433
- async def servers(self) -> list[Server]:
447
+ async def servers(self, *, cache_ttl: Optional[float] = None, force: bool = False) -> list[Server]:
448
+ key = ("servers",)
449
+ _eff_ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
450
+ if not force and _eff_ttl and _eff_ttl > 0:
451
+ ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
452
+ cached = self._cache.get(key)
453
+ if cached is not _CACHE_MISSING:
454
+ return cached
434
455
  out = await self.api("GET", "/servers")
435
- return [Server.from_dict(s) for s in (out.get("servers") or [])]
436
-
437
- async def channels(self, server_id: Any = None, *, type: Optional[str] = None) -> list[Channel]:
456
+ res = [Server.from_dict(s) for s in (out.get("servers") or [])]
457
+ if cache_ttl != 0:
458
+ ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
459
+ self._cache.set(key, res, ttl)
460
+ return res
461
+
462
+ async def channels(self, server_id: Any = None, *, type: Optional[str] = None, cache_ttl: Optional[float] = None, force: bool = False) -> list[Channel]:
463
+ key = ("channels", server_id, type)
464
+ _eff_ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
465
+ if not force and _eff_ttl and _eff_ttl > 0:
466
+ ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
467
+ cached = self._cache.get(key)
468
+ if cached is not _CACHE_MISSING:
469
+ return cached
438
470
  q = f"?type={type}" if type else ""
439
471
  path = f"/servers/{server_id}/channels{q}" if server_id else f"/channels{q}"
440
472
  out = await self.api("GET", path)
441
- return [Channel.from_dict(c) for c in (out.get("channels") or [])]
442
-
443
- async def categories(self, server_id: Any) -> list[Category]:
473
+ res = [Channel.from_dict(c) for c in (out.get("channels") or [])]
474
+ if cache_ttl != 0:
475
+ ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
476
+ self._cache.set(key, res, ttl)
477
+ return res
478
+
479
+ async def categories(self, server_id: Any, *, cache_ttl: Optional[float] = None, force: bool = False) -> list[Category]:
480
+ key = ("categories", server_id)
481
+ _eff_ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
482
+ if not force and _eff_ttl and _eff_ttl > 0:
483
+ ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
484
+ cached = self._cache.get(key)
485
+ if cached is not _CACHE_MISSING:
486
+ return cached
444
487
  out = await self.api("GET", f"/servers/{server_id}/channels")
445
- return [Category.from_dict(c) for c in (out.get("categories") or [])]
446
-
447
- async def channel(self, channel_id: Any) -> Optional[Channel]:
488
+ res = [Category.from_dict(c) for c in (out.get("categories") or [])]
489
+ if cache_ttl != 0:
490
+ ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
491
+ self._cache.set(key, res, ttl)
492
+ return res
493
+
494
+ async def channel(self, channel_id: Any, *, cache_ttl: Optional[float] = None, force: bool = False) -> Optional[Channel]:
495
+ key = ("channel", channel_id)
496
+ _eff_ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
497
+ if not force and _eff_ttl and _eff_ttl > 0:
498
+ ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
499
+ cached = self._cache.get(key)
500
+ if cached is not _CACHE_MISSING:
501
+ return cached
448
502
  try:
449
503
  out = await self.api("GET", f"/channels/{channel_id}")
450
504
  except Exception:
451
505
  return None
452
506
  c = out.get("channel") if isinstance(out, dict) else None
453
- return Channel.from_dict(c) if c else None
507
+ res = Channel.from_dict(c) if c else None
508
+ if cache_ttl != 0:
509
+ ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
510
+ self._cache.set(key, res, ttl)
511
+ return res
454
512
 
455
513
  async def members(self, server_id: Any, after: Any = None) -> list[Member]:
456
514
  path = f"/servers/{server_id}/members?limit=200"
@@ -463,10 +521,21 @@ class Bot:
463
521
  out = await self.api("GET", f"/channels/{channel_id}/messages?limit={limit}")
464
522
  return [Message.from_dict(m) for m in (out.get("messages") or [])]
465
523
 
466
- async def user(self, user_id: Any) -> User:
524
+ async def user(self, user_id: Any, *, cache_ttl: Optional[float] = None, force: bool = False) -> User:
525
+ key = ("user", user_id)
526
+ _eff_ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
527
+ if not force and _eff_ttl and _eff_ttl > 0:
528
+ ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
529
+ cached = self._cache.get(key)
530
+ if cached is not _CACHE_MISSING:
531
+ return cached
467
532
  out = await self.api("GET", f"/users/{user_id}")
468
533
  u = out.get("user") if isinstance(out, dict) else out
469
- return User.from_dict(u)
534
+ res = User.from_dict(u)
535
+ if cache_ttl != 0:
536
+ ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
537
+ self._cache.set(key, res, ttl)
538
+ return res
470
539
 
471
540
  # ------------------------------------------------------------------ #
472
541
  # Profile
@@ -485,9 +554,18 @@ class Bot:
485
554
  async def set_banner(self, image: Any, type: Optional[str] = None) -> Any:
486
555
  return await self.http.upload("PUT", "/me/banner", image, type)
487
556
 
488
- async def set_status(self, status: str = "online", activity: Optional[str] = None) -> Any:
557
+ async def set_status(self, status: str = "online", activity: Any = None) -> Any:
558
+ if isinstance(activity, Activity):
559
+ activity = activity.to_text()
560
+ elif isinstance(activity, Presence):
561
+ status = activity.to_status()
562
+ activity = activity.to_activity()
489
563
  return await self.api("PUT", "/me/status", {"status": status, "activity": activity})
490
564
 
565
+ async def set_presence(self, *, status: str = "online", activity: Any = None) -> Any:
566
+ self._presence = Presence(status=status, activity=activity)
567
+ return await self.set_status(status=status, activity=activity)
568
+
491
569
  # ------------------------------------------------------------------ #
492
570
  # Messages advanced
493
571
  # ------------------------------------------------------------------ #
@@ -548,13 +626,35 @@ class Bot:
548
626
  # ------------------------------------------------------------------ #
549
627
  # Moderation
550
628
  # ------------------------------------------------------------------ #
551
- async def server(self, server_id: Any) -> Any:
629
+ async def server(self, server_id: Any, *, cache_ttl: Optional[float] = None, force: bool = False) -> Any:
630
+ key = ("server", server_id)
631
+ _eff_ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
632
+ if not force and _eff_ttl and _eff_ttl > 0:
633
+ ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
634
+ cached = self._cache.get(key)
635
+ if cached is not _CACHE_MISSING:
636
+ return cached
552
637
  out = await self.api("GET", f"/servers/{server_id}")
553
- return out.get("server") if isinstance(out, dict) else out
554
-
555
- async def roles(self, server_id: Any) -> list[Role]:
638
+ res = out.get("server") if isinstance(out, dict) else out
639
+ if cache_ttl != 0:
640
+ ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
641
+ self._cache.set(key, res, ttl)
642
+ return res
643
+
644
+ async def roles(self, server_id: Any, *, cache_ttl: Optional[float] = None, force: bool = False) -> list[Role]:
645
+ key = ("roles", server_id)
646
+ _eff_ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
647
+ if not force and _eff_ttl and _eff_ttl > 0:
648
+ ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
649
+ cached = self._cache.get(key)
650
+ if cached is not _CACHE_MISSING:
651
+ return cached
556
652
  out = await self.api("GET", f"/servers/{server_id}/roles")
557
- return [Role.from_dict(r) for r in (out.get("roles") or [])]
653
+ res = [Role.from_dict(r) for r in (out.get("roles") or [])]
654
+ if cache_ttl != 0:
655
+ ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
656
+ self._cache.set(key, res, ttl)
657
+ return res
558
658
 
559
659
  async def member(self, server_id: Any, user_id: Any) -> Member:
560
660
  out = await self.api("GET", f"/servers/{server_id}/members/{user_id}")
@@ -588,6 +688,24 @@ class Bot:
588
688
  "user_id": user_id, "minutes": minutes, "reason": reason,
589
689
  })
590
690
 
691
+ # ------------------------------------------------------------------ #
692
+ # Voice
693
+ # ------------------------------------------------------------------ #
694
+ async def voice(self, server_id: Any) -> list:
695
+ out = await self.api("GET", f"/servers/{server_id}/voice")
696
+ voice = out.get("voice", []) if isinstance(out, dict) else []
697
+ return [VoiceChannel.from_dict(v) for v in (voice or [])]
698
+
699
+ async def move_member(self, server_id: Any, user_id: Any, channel_id: Any) -> Any:
700
+ return await self.api("POST", f"/servers/{server_id}/voice/move", {
701
+ "user_id": user_id, "channel_id": channel_id,
702
+ })
703
+
704
+ async def disconnect_member(self, server_id: Any, user_id: Any) -> Any:
705
+ return await self.api("POST", f"/servers/{server_id}/voice/disconnect", {
706
+ "user_id": user_id,
707
+ })
708
+
591
709
  # ------------------------------------------------------------------ #
592
710
  # Channels management
593
711
  # ------------------------------------------------------------------ #
@@ -664,7 +782,10 @@ class Bot:
664
782
  try:
665
783
  self.me = User.from_dict(await self.api("GET", "/me"))
666
784
  await self.api("PUT", "/commands", {"commands": cmd_list})
667
- first = await self.api("GET", "/events?after=0&wait=0")
785
+ q = "/events?after=0&wait=0"
786
+ if self.max_age is not None:
787
+ q += f"&max_age={int(self.max_age)}"
788
+ first = await self.api("GET", q)
668
789
  break
669
790
  except Exception as e:
670
791
  status = getattr(e, "status", None)
@@ -684,6 +805,12 @@ class Bot:
684
805
  print(f"[pyyapper] {self.me.username} is online"
685
806
  + (f" with {len(cmd_list)} command{'s' if len(cmd_list) != 1 else ''}" if cmd_list else ""))
686
807
  await self._emit("ready", self.me)
808
+ # re-apply saved presence
809
+ if getattr(self, "_presence", None) is not None:
810
+ try:
811
+ await self.set_presence(status=self._presence.status, activity=self._presence.activity)
812
+ except Exception:
813
+ pass
687
814
  # start registered tasks
688
815
  try:
689
816
  from .ext.tasks import register_task, cancel_all_tasks
@@ -700,7 +827,10 @@ class Bot:
700
827
  failures = 0
701
828
  while self.running:
702
829
  try:
703
- out = await self.api("GET", f"/events?after={self.last}&wait=25", timeout=45.0)
830
+ q = f"/events?after={self.last}&wait=25"
831
+ if self.max_age is not None:
832
+ q += f"&max_age={int(self.max_age)}"
833
+ out = await self.api("GET", q, timeout=45.0)
704
834
  failures = 0
705
835
  except Exception as e:
706
836
  failures += 1
@@ -748,6 +878,19 @@ class Bot:
748
878
  def stop(self) -> None:
749
879
  self.running = False
750
880
 
881
+ def invalidate_cache(self, key: Any = None) -> None:
882
+ """Drop cached read results. ``key=None`` clears everything."""
883
+ self._cache.invalidate(key)
884
+
885
+ async def send_many(self, channel_id: Any, contents: Any, *, concurrency: int = 5, **opts: Any) -> list:
886
+ sem = asyncio.Semaphore(max(1, int(concurrency)))
887
+
888
+ async def _one(c: Any) -> Any:
889
+ async with sem:
890
+ return await self.send(channel_id, c, **opts)
891
+
892
+ return list(await asyncio.gather(*(_one(c) for c in (contents or [])), return_exceptions=True))
893
+
751
894
  # ------------------------------------------------------------------ #
752
895
  # Internals
753
896
  # ------------------------------------------------------------------ #
@@ -768,6 +911,9 @@ class Bot:
768
911
  except Exception:
769
912
  pass
770
913
 
914
+ if len(fns) == 1:
915
+ await _run(fns[0])
916
+ return
771
917
  # Dispatch handlers concurrently to reduce latency when several
772
918
  # listeners are registered for the same event. Order is not guaranteed.
773
919
  await asyncio.gather(*(_run(fn) for fn in fns), return_exceptions=True)
@@ -782,6 +928,15 @@ class Bot:
782
928
  etype = ev.get("type")
783
929
  data = ev.get("data") or {}
784
930
  if etype == "message":
931
+ if self.ignore_bots and data.get("author_bot"):
932
+ # skip messages from other bots/webhooks
933
+ if self._waiters is not None:
934
+ try:
935
+ ctx = Context(self, data)
936
+ self._waiters.dispatch("message", ctx)
937
+ except Exception:
938
+ pass
939
+ return
785
940
  ctx = Context(self, data)
786
941
  # resolve command
787
942
  name = data.get("command")
@@ -0,0 +1,52 @@
1
+ """Tiny TTL cache used by PyYapper for read-heavy API calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, Optional
7
+
8
+ _MISSING = object()
9
+
10
+
11
+ class TTLCache:
12
+ """A simple in-memory TTL cache.
13
+
14
+ Keys may be any hashable value. ``get`` returns the sentinel
15
+ :data:`MISSING` when a key is absent or expired.
16
+ """
17
+
18
+ def __init__(self, default_ttl: float = 15.0) -> None:
19
+ self._store: dict[Any, tuple[Any, float]] = {}
20
+ self.default_ttl = float(default_ttl)
21
+
22
+ def get(self, key: Any) -> Any:
23
+ entry = self._store.get(key)
24
+ if entry is None:
25
+ return _MISSING
26
+ value, expiry = entry
27
+ if time.monotonic() >= expiry:
28
+ self._store.pop(key, None)
29
+ return _MISSING
30
+ return value
31
+
32
+ def set(self, key: Any, value: Any, ttl: Optional[float] = None) -> None:
33
+ if ttl is None:
34
+ ttl = self.default_ttl
35
+ if ttl and ttl > 0:
36
+ self._store[key] = (value, time.monotonic() + float(ttl))
37
+
38
+ def invalidate(self, key: Any = None) -> None:
39
+ if key is None:
40
+ self._store.clear()
41
+ else:
42
+ self._store.pop(key, None)
43
+
44
+ def invalidate_prefix(self, prefix: Any) -> None:
45
+ for k in [k for k in list(self._store.keys()) if isinstance(k, tuple) and k and k[0] == prefix]:
46
+ self._store.pop(k, None)
47
+
48
+ def size(self) -> int:
49
+ return len(self._store)
50
+
51
+
52
+ MISSING = _MISSING
@@ -72,6 +72,10 @@ class ServerError(HTTPException):
72
72
  """5xx - something went wrong on Yapper's side."""
73
73
 
74
74
 
75
+ class ServiceUnavailable(ServerError):
76
+ """503 - Yapper is busy; retry after Retry-After."""
77
+
78
+
75
79
  # Status code -> exception class mapping used by the HTTP layer.
76
80
  STATUS_TO_EXC: dict[int, type[HTTPException]] = {
77
81
  400: BadRequest,
@@ -79,6 +83,7 @@ STATUS_TO_EXC: dict[int, type[HTTPException]] = {
79
83
  403: Forbidden,
80
84
  404: NotFound,
81
85
  429: RateLimited,
86
+ 503: ServiceUnavailable,
82
87
  }
83
88
 
84
89
 
@@ -376,10 +376,13 @@ def remove_cog(bot, name):
376
376
  import asyncio
377
377
  # best-effort: if event loop running, schedule; else run
378
378
  try:
379
- loop = asyncio.get_event_loop()
380
- if loop.is_running():
381
- loop.create_task(res)
382
- else:
379
+ try:
380
+ loop = asyncio.get_running_loop()
381
+ if loop.is_running():
382
+ loop.create_task(res)
383
+ except RuntimeError:
384
+ loop = asyncio.new_event_loop()
385
+ asyncio.set_event_loop(loop)
383
386
  loop.run_until_complete(res)
384
387
  except Exception:
385
388
  pass
@@ -132,9 +132,10 @@ class Loop:
132
132
  try:
133
133
  loop = asyncio.get_running_loop()
134
134
  except RuntimeError:
135
- # Fallback: try to get/create an event loop.
135
+ # Fallback: create a new event loop (cross-platform).
136
136
  try:
137
- loop = asyncio.get_event_loop()
137
+ loop = asyncio.new_event_loop()
138
+ asyncio.set_event_loop(loop)
138
139
  except RuntimeError:
139
140
  return None
140
141
  try:
@@ -15,6 +15,25 @@ from __future__ import annotations
15
15
  import asyncio
16
16
  import json
17
17
  import random
18
+
19
+ try:
20
+ import orjson as _orjson
21
+
22
+ def _json_loads(data):
23
+ return _orjson.loads(data)
24
+
25
+ def _json_dumps(obj):
26
+ return _orjson.dumps(obj)
27
+
28
+ _HAS_ORJSON = True
29
+ except ImportError: # pragma: no cover
30
+ _HAS_ORJSON = False
31
+
32
+ def _json_loads(data):
33
+ return json.loads(data)
34
+
35
+ def _json_dumps(obj):
36
+ return json.dumps(obj).encode()
18
37
  from typing import Any, Optional
19
38
 
20
39
  import aiohttp
@@ -93,7 +112,8 @@ class HTTPClient:
93
112
  # JSON body unless caller already provided bytes
94
113
  if content_type is None:
95
114
  content_type = "application/json"
96
- data = json.dumps(body)
115
+ dumped = _json_dumps(body)
116
+ data = dumped if isinstance(dumped, (bytes, bytearray)) else dumped.encode()
97
117
  elif body is not None:
98
118
  data = body
99
119
  if content_type:
@@ -111,8 +131,8 @@ class HTTPClient:
111
131
  raw = await res.read()
112
132
  text = raw.decode("utf-8", errors="replace") if raw else ""
113
133
  try:
114
- payload = json.loads(text) if text else {}
115
- except json.JSONDecodeError:
134
+ payload = _json_loads(text) if text else {}
135
+ except Exception:
116
136
  payload = {}
117
137
  if res.status < 400:
118
138
  return payload
@@ -127,12 +147,14 @@ class HTTPClient:
127
147
  retry_after = float(ra)
128
148
  except ValueError:
129
149
  retry_after = None
130
- # retry on 429 with Retry-After
131
- if res.status == 429 and retry_after and retry_after > 0 and retry_after <= 60 and attempt < retries:
132
- if self.log:
133
- print(f"[pyyapper] rate limited, retrying {method} {path} in {retry_after}s")
134
- await asyncio.sleep(retry_after)
135
- continue
150
+ # retry on 429 / 503 with Retry-After
151
+ if res.status in (429, 503) and retry_after and retry_after > 0 and attempt < retries:
152
+ cap = 60 if res.status == 429 else 30
153
+ if retry_after <= cap:
154
+ if self.log:
155
+ print(f"[pyyapper] {res.status} retrying {method} {path} in {retry_after}s")
156
+ await asyncio.sleep(retry_after)
157
+ continue
136
158
  exc_cls = exception_for_status(res.status)
137
159
  raise exc_cls(
138
160
  res.status,
@@ -43,6 +43,16 @@ class _Model:
43
43
  return out
44
44
 
45
45
 
46
+ def picture_url(bucket: str, path: Any) -> Optional[str]:
47
+ """Build a full picture URL from a path returned by the API."""
48
+ if not path:
49
+ return None
50
+ p = str(path)
51
+ if p.startswith("/"):
52
+ return "https://www.yapper.website" + p
53
+ return f"https://files.yapper.website/{bucket}/{p}"
54
+
55
+
46
56
  @dataclass
47
57
  class User(_Model):
48
58
  id: str = ""
@@ -51,10 +61,21 @@ class User(_Model):
51
61
  bio: str = ""
52
62
  avatar_url: Optional[str] = None
53
63
  banner_url: Optional[str] = None
64
+ avatar_path: Optional[str] = None
65
+ banner_path: Optional[str] = None
66
+ badges: list[Any] = field(default_factory=list)
54
67
  created_at: Optional[str] = None
55
68
  bot: Optional[dict[str, Any]] = None
56
69
  raw: dict[str, Any] = field(default_factory=dict, repr=False)
57
70
 
71
+ @property
72
+ def avatar(self) -> Optional[str]:
73
+ return self.avatar_url or picture_url("avatars", self.avatar_path)
74
+
75
+ @property
76
+ def banner(self) -> Optional[str]:
77
+ return self.banner_url or picture_url("banners", self.banner_path)
78
+
58
79
  @classmethod
59
80
  def from_dict(cls, data: dict[str, Any] | None) -> "User":
60
81
  data = data or {}
@@ -65,6 +86,9 @@ class User(_Model):
65
86
  bio=data.get("bio", "") or "",
66
87
  avatar_url=data.get("avatar_url"),
67
88
  banner_url=data.get("banner_url"),
89
+ avatar_path=data.get("avatar_path"),
90
+ banner_path=data.get("banner_path"),
91
+ badges=list(data.get("badges") or []),
68
92
  created_at=data.get("created_at"),
69
93
  bot=data.get("bot"),
70
94
  raw=dict(data),
@@ -75,12 +99,26 @@ class User(_Model):
75
99
  class Server(_Model):
76
100
  id: str = ""
77
101
  name: str = ""
102
+ icon_path: Optional[str] = None
103
+ owner_id: Optional[str] = None
104
+ member_count: Optional[int] = None
78
105
  raw: dict[str, Any] = field(default_factory=dict, repr=False)
79
106
 
107
+ @property
108
+ def icon(self) -> Optional[str]:
109
+ return picture_url("icons", self.icon_path)
110
+
80
111
  @classmethod
81
112
  def from_dict(cls, data: dict[str, Any] | None) -> "Server":
82
113
  data = data or {}
83
- return cls(id=data.get("id", "") or "", name=data.get("name", "") or "", raw=dict(data))
114
+ return cls(
115
+ id=data.get("id", "") or "",
116
+ name=data.get("name", "") or "",
117
+ icon_path=data.get("icon_path"),
118
+ owner_id=data.get("owner_id"),
119
+ member_count=data.get("member_count"),
120
+ raw=dict(data),
121
+ )
84
122
 
85
123
 
86
124
  @dataclass
@@ -166,13 +204,25 @@ class Message(_Model):
166
204
  author: Optional[str] = None
167
205
  content: str = ""
168
206
  attachment: Optional[str] = None
207
+ attachment_path: Optional[str] = None
169
208
  reply_to_id: Optional[str] = None
170
209
  created_at: Optional[str] = None
210
+ edited_at: Optional[str] = None
211
+ pinned_at: Optional[str] = None
171
212
  mentioned: bool = False
213
+ author_bot: bool = False
214
+ embeds: list[Any] = field(default_factory=list)
215
+ components: list[Any] = field(default_factory=list)
172
216
  command: Optional[str] = None
173
217
  args: Optional[str] = None
174
218
  raw: dict[str, Any] = field(default_factory=dict, repr=False)
175
219
 
220
+ @property
221
+ def attachment_url(self) -> Optional[str]:
222
+ if self.attachment:
223
+ return self.attachment
224
+ return picture_url("attachments", self.attachment_path)
225
+
176
226
  @classmethod
177
227
  def from_dict(cls, data: dict[str, Any] | None) -> "Message":
178
228
  data = data or {}
@@ -185,9 +235,15 @@ class Message(_Model):
185
235
  author=data.get("author"),
186
236
  content=data.get("content", "") or "",
187
237
  attachment=data.get("attachment"),
238
+ attachment_path=data.get("attachment_path"),
188
239
  reply_to_id=data.get("reply_to_id"),
189
240
  created_at=data.get("created_at"),
241
+ edited_at=data.get("edited_at"),
242
+ pinned_at=data.get("pinned_at"),
190
243
  mentioned=bool(data.get("mentioned", False)),
244
+ author_bot=bool(data.get("author_bot", False)),
245
+ embeds=list(data.get("embeds") or []),
246
+ components=list(data.get("components") or []),
191
247
  command=data.get("command"),
192
248
  args=data.get("args"),
193
249
  raw=dict(data),
@@ -203,4 +259,20 @@ class Role(_Model):
203
259
  @classmethod
204
260
  def from_dict(cls, data: dict[str, Any] | None) -> "Role":
205
261
  data = data or {}
206
- return cls(id=data.get("id", "") or "", name=data.get("name", "") or "", raw=dict(data))
262
+ return cls(id=data.get("id", "") or "", name=data.get("name", "") or "", raw=dict(data))
263
+
264
+
265
+ @dataclass
266
+ class VoiceChannel(_Model):
267
+ channel_id: str = ""
268
+ user_ids: list[str] = field(default_factory=list)
269
+ raw: dict[str, Any] = field(default_factory=dict, repr=False)
270
+
271
+ @classmethod
272
+ def from_dict(cls, data: dict[str, Any] | None) -> "VoiceChannel":
273
+ data = data or {}
274
+ return cls(
275
+ channel_id=data.get("channel_id", "") or "",
276
+ user_ids=list(data.get("user_ids") or []),
277
+ raw=dict(data),
278
+ )
@@ -62,7 +62,11 @@ class Paginator:
62
62
  return [Row(*btns)]
63
63
 
64
64
  async def _wait(self) -> Any:
65
- loop = asyncio.get_event_loop()
65
+ try:
66
+ loop = asyncio.get_running_loop()
67
+ except RuntimeError:
68
+ loop = asyncio.new_event_loop()
69
+ asyncio.set_event_loop(loop)
66
70
  self._future = loop.create_future()
67
71
  try:
68
72
  return await asyncio.wait_for(self._future, timeout=self.timeout)
@@ -0,0 +1,139 @@
1
+ """Presence / activity helpers for PyYapper bots.
2
+
3
+ Yapper's API accepts a status (``online``/``idle``/``dnd``) and a single
4
+ activity line (up to 60 characters). This module adds a small, discord.py-style
5
+ ``Activity`` builder on top of that, including an activity *type* (playing,
6
+ listening, watching, ...) that is rendered into the activity line. Extra
7
+ "rich" fields (details, state, timestamps, assets) are stored locally so they
8
+ are available when Yapper supports them, and are serialized into the activity
9
+ string in the meantime.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from typing import Any, Optional
17
+
18
+ ACTIVITY_TYPES = ("playing", "streaming", "listening", "watching", "competing", "custom")
19
+
20
+ _PREFIX = {
21
+ "playing": "Playing",
22
+ "streaming": "Streaming",
23
+ "listening": "Listening to",
24
+ "watching": "Watching",
25
+ "competing": "Competing in",
26
+ "custom": "",
27
+ }
28
+
29
+
30
+ @dataclass
31
+ class Activity:
32
+ """A rich activity descriptor.
33
+
34
+ Example::
35
+
36
+ act = Activity.playing("chess")
37
+ act = Activity.listening("lofi beats")
38
+ act = Activity.custom("Moderating")
39
+ """
40
+
41
+ name: str = ""
42
+ type: str = "playing"
43
+ state: Optional[str] = None
44
+ details: Optional[str] = None
45
+ url: Optional[str] = None
46
+ start: Optional[datetime] = None
47
+ end: Optional[datetime] = None
48
+ large_image: Optional[str] = None
49
+ small_image: Optional[str] = None
50
+ large_text: Optional[str] = None
51
+ small_text: Optional[str] = None
52
+ extra: dict[str, Any] = field(default_factory=dict)
53
+
54
+ # -- factories --------------------------------------------------------
55
+ @classmethod
56
+ def playing(cls, name: str, **kw: Any) -> "Activity":
57
+ return cls(name=name, type="playing", **kw)
58
+
59
+ @classmethod
60
+ def streaming(cls, name: str, url: Optional[str] = None, **kw: Any) -> "Activity":
61
+ return cls(name=name, type="streaming", url=url, **kw)
62
+
63
+ @classmethod
64
+ def listening(cls, name: str, **kw: Any) -> "Activity":
65
+ return cls(name=name, type="listening", **kw)
66
+
67
+ @classmethod
68
+ def watching(cls, name: str, **kw: Any) -> "Activity":
69
+ return cls(name=name, type="watching", **kw)
70
+
71
+ @classmethod
72
+ def competing(cls, name: str, **kw: Any) -> "Activity":
73
+ return cls(name=name, type="competing", **kw)
74
+
75
+ @classmethod
76
+ def custom(cls, text: str, **kw: Any) -> "Activity":
77
+ return cls(name=text, type="custom", **kw)
78
+
79
+ # -- serialization ----------------------------------------------------
80
+ def to_text(self) -> str:
81
+ """Render to the 60-char activity line Yapper accepts."""
82
+ prefix = _PREFIX.get(self.type, "")
83
+ base = self.name.strip() if self.name else ""
84
+ if self.type == "custom":
85
+ text = base
86
+ elif prefix and base:
87
+ text = f"{prefix} {base}"
88
+ else:
89
+ text = base
90
+ # Append state/details if room (Yapper caps at 60).
91
+ if self.state and len(text) + len(self.state) + 3 <= 60:
92
+ text = f"{text} — {self.state}"
93
+ return text[:60]
94
+
95
+ def to_dict(self) -> dict[str, Any]:
96
+ out: dict[str, Any] = {"type": self.type, "name": self.name}
97
+ if self.state is not None:
98
+ out["state"] = self.state
99
+ if self.details is not None:
100
+ out["details"] = self.details
101
+ if self.url is not None:
102
+ out["url"] = self.url
103
+ if self.start is not None:
104
+ out["start"] = self.start.isoformat()
105
+ if self.end is not None:
106
+ out["end"] = self.end.isoformat()
107
+ if self.large_image is not None:
108
+ out["large_image"] = self.large_image
109
+ if self.small_image is not None:
110
+ out["small_image"] = self.small_image
111
+ if self.large_text is not None:
112
+ out["large_text"] = self.large_text
113
+ if self.small_text is not None:
114
+ out["small_text"] = self.small_text
115
+ out.update(self.extra)
116
+ return out
117
+
118
+ def to_api(self) -> str:
119
+ return self.to_text()
120
+
121
+ def __str__(self) -> str:
122
+ return self.to_text()
123
+
124
+
125
+ @dataclass
126
+ class Presence:
127
+ """Status + activity pair."""
128
+ status: str = "online"
129
+ activity: Optional[Any] = None
130
+
131
+ def to_status(self) -> str:
132
+ return self.status
133
+
134
+ def to_activity(self) -> Optional[str]:
135
+ if self.activity is None:
136
+ return None
137
+ if isinstance(self.activity, Activity):
138
+ return self.activity.to_text()
139
+ return str(self.activity)[:60] if self.activity is not None else None
@@ -104,7 +104,8 @@ class WaiterRegistry:
104
104
  try:
105
105
  loop = asyncio.get_running_loop()
106
106
  except RuntimeError:
107
- loop = asyncio.get_event_loop()
107
+ loop = asyncio.new_event_loop()
108
+ asyncio.set_event_loop(loop)
108
109
  future = loop.create_future()
109
110
  waiter = self.add(event, check, future)
110
111
  try:
@@ -10,6 +10,25 @@ from __future__ import annotations
10
10
  import asyncio
11
11
  import json
12
12
  from typing import Any, Optional
13
+
14
+ try:
15
+ import orjson as _orjson
16
+
17
+ def _json_loads(data):
18
+ return _orjson.loads(data)
19
+
20
+ def _json_dumps(obj):
21
+ return _orjson.dumps(obj)
22
+
23
+ _HAS_ORJSON = True
24
+ except ImportError: # pragma: no cover
25
+ _HAS_ORJSON = False
26
+
27
+ def _json_loads(data):
28
+ return json.loads(data)
29
+
30
+ def _json_dumps(obj):
31
+ return json.dumps(obj).encode()
13
32
  from urllib.parse import urlparse
14
33
 
15
34
  import aiohttp
@@ -134,11 +153,16 @@ class Webhook:
134
153
  session = await self._get_session()
135
154
  for attempt in range(retries + 1):
136
155
  try:
156
+ if json_body is None:
157
+ data_body = None
158
+ else:
159
+ dumped = _json_dumps(json_body)
160
+ data_body = dumped if isinstance(dumped, (bytes, bytearray)) else dumped.encode()
137
161
  kwargs = dict(
138
162
  params=params,
139
- json=json_body,
163
+ data=data_body,
140
164
  timeout=aiohttp.ClientTimeout(total=timeout),
141
- headers={"User-Agent": USER_AGENT},
165
+ headers={"User-Agent": USER_AGENT, "Content-Type": "application/json"} if data_body is not None else {"User-Agent": USER_AGENT},
142
166
  )
143
167
  if hasattr(session, "request"):
144
168
  cm = session.request(method, url, **kwargs)
@@ -151,8 +175,8 @@ class Webhook:
151
175
  raw = await res.read()
152
176
  text = raw.decode("utf-8", errors="replace") if raw else ""
153
177
  try:
154
- payload = json.loads(text) if text else None
155
- except json.JSONDecodeError:
178
+ payload = _json_loads(text) if text else None
179
+ except Exception:
156
180
  payload = None
157
181
  if res.status < 400:
158
182
  return payload
@@ -167,12 +191,13 @@ class Webhook:
167
191
  except (TypeError, ValueError):
168
192
  retry_after = None
169
193
  if (
170
- res.status == 429
194
+ res.status in (429, 503)
171
195
  and retry_after is not None
172
196
  and retry_after > 0
173
197
  and attempt < retries
174
198
  ):
175
- await asyncio.sleep(min(retry_after, 60.0))
199
+ cap = 60.0 if res.status == 429 else 30.0
200
+ await asyncio.sleep(min(retry_after, cap))
176
201
  continue
177
202
  exc_cls = exception_for_status(res.status)
178
203
  # RateLimited subclass expected for 429
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyyapper
3
- Version: 1.2.0a1
3
+ Version: 1.3.2
4
4
  Summary: An intuitive, async Python library for the Yapper bot API.
5
5
  Author: PyYapper contributors
6
6
  License-Expression: MIT
@@ -31,6 +31,8 @@ Requires-Dist: twine; extra == "dev"
31
31
  Provides-Extra: docs
32
32
  Requires-Dist: mkdocs; extra == "docs"
33
33
  Requires-Dist: mkdocs-material; extra == "docs"
34
+ Provides-Extra: speed
35
+ Requires-Dist: orjson; extra == "speed"
34
36
  Dynamic: license-file
35
37
 
36
38
  # PyYapper
@@ -5,12 +5,14 @@ src/pyyapper/__init__.py
5
5
  src/pyyapper/_version.py
6
6
  src/pyyapper/bot.py
7
7
  src/pyyapper/buttons.py
8
+ src/pyyapper/cache.py
8
9
  src/pyyapper/commands.py
9
10
  src/pyyapper/embeds.py
10
11
  src/pyyapper/errors.py
11
12
  src/pyyapper/http.py
12
13
  src/pyyapper/models.py
13
14
  src/pyyapper/pagination.py
15
+ src/pyyapper/presence.py
14
16
  src/pyyapper/py.typed
15
17
  src/pyyapper/wait_for.py
16
18
  src/pyyapper/webhooks.py
@@ -9,3 +9,6 @@ twine
9
9
  [docs]
10
10
  mkdocs
11
11
  mkdocs-material
12
+
13
+ [speed]
14
+ orjson
@@ -1 +0,0 @@
1
- __version__ = "1.2.0a1"
File without changes
File without changes
File without changes
File without changes
File without changes