python-yappy 0.1.1__py3-none-any.whl → 0.1.2__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.
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: python-yappy
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: API Bindings for Yapper!
5
5
  Author: H1387Lmao
6
6
  Requires-Dist: aiohttp>=3.14.5
7
7
  Requires-Dist: load-dotenv>=0.1.0
8
+ Requires-Dist: rich>=13.0
8
9
  Requires-Dist: uv-build>=0.13.1
9
10
  Requires-Python: >=3.14
10
11
  Description-Content-Type: text/markdown
@@ -24,7 +25,8 @@ YapPy brings a Python-friendly interface to [Yapper](https://yapper.website), a
24
25
  - **Commands** — register named commands with descriptions.
25
26
  - **Context-based interactions** — reply to messages through a context object.
26
27
  - **Core models** — work with `Context`, `Message`, `Channel`, and `User` objects.
27
- - **Optional logging** — enable verbose output when creating the application.
28
+ - **Argument validation** — type-hinted command parameters are converted for you, and mistakes get a helpful reply.
29
+ - **Rich logging** — coloured logs and readable tracebacks; `verbose=True` adds debug output.
28
30
 
29
31
  ## Installation
30
32
 
@@ -114,7 +116,7 @@ async def on_message(ctx):
114
116
  if ctx.is_bot:
115
117
  return
116
118
 
117
- await ctx.process.command(ctx)
119
+ await bot.process_command(ctx)
118
120
  ```
119
121
 
120
122
  The event name and handler arguments should match the event interface supported by the installed YapPy version.
@@ -134,6 +136,66 @@ async def ping(ctx):
134
136
 
135
137
  The `name` identifies the command, and `description` describes its purpose. Command registration and invocation behavior depend on the current implementation.
136
138
 
139
+ ### Command arguments
140
+
141
+ Arguments from the message are matched to your function's parameters. Add type hints and YapPy converts them for you:
142
+
143
+ ```python
144
+ @bot.command(name="add", description="Adds two numbers")
145
+ async def add(ctx, a: int, b: int = 0):
146
+ await ctx.reply(f"{a} + {b} = {a + b}")
147
+ ```
148
+
149
+ | Annotation | Accepts |
150
+ | --- | --- |
151
+ | `str` | the text exactly as typed (use `"quotes"` for spaces) |
152
+ | `int`, `float` | numbers |
153
+ | `bool` | `true`/`false`, `yes`/`no`, `on`/`off`, `1`/`0` |
154
+ | `User`, `Channel`, `Role` | a mention such as `<@123>` |
155
+ | *(none)* | guessed: whole numbers become `int`, decimals `float`, mentions are resolved, anything else is `str` |
156
+
157
+ Parameters with a default are optional, and `*args` collects any remaining arguments.
158
+
159
+ ### Command errors
160
+
161
+ If someone uses a command the wrong way, the bot tells them instead of failing silently:
162
+
163
+ ```text
164
+ !add
165
+ Missing required argument 'a'.
166
+ Usage: `!add <a> [b]`
167
+ ```
168
+
169
+ Too many arguments and values of the wrong type are handled the same way. Unknown commands are ignored, and an exception raised inside a command is logged rather than sent to chat.
170
+
171
+ To handle these yourself, register a `command_error` handler. It replaces the default replies:
172
+
173
+ ```python
174
+ from yappy import UnknownCommand
175
+
176
+ @bot.event("command_error")
177
+ async def on_command_error(ctx, error):
178
+ if isinstance(error, UnknownCommand):
179
+ return
180
+ await ctx.reply(str(error))
181
+ ```
182
+
183
+ `error` is one of `UnknownCommand`, `MissingArgument`, `TooManyArguments`, `BadArgument` or `CommandInvokeError` (all subclasses of `yappy.CommandError`). For `CommandInvokeError`, the exception your command raised is `error.original`, and `error.command` is the command involved.
184
+
185
+ ### Logging
186
+
187
+ YapPy logs through Python's `logging` module under the name `yappy` and prints it with [Rich](https://github.com/Textualize/rich). Exceptions appear as readable tracebacks. `App(verbose=True)` also shows debug output: events, HTTP requests and command invocations.
188
+
189
+ To configure logging yourself, create the app with `configure_logging=False`:
190
+
191
+ ```python
192
+ import logging
193
+ from yappy import App, setup_logging
194
+
195
+ bot = App(configure_logging=False)
196
+ setup_logging(level=logging.WARNING, show_locals=True) # or attach your own handlers to logging.getLogger("yappy")
197
+ ```
198
+
137
199
  ### Context and models
138
200
 
139
201
  YapPy currently provides these core models:
@@ -0,0 +1,12 @@
1
+ yappy/__init__.py,sha256=sfTl69d_NnVtVVLFQbQRmnsTqv8xjC4swCBL-FGxSjc,197
2
+ yappy/api.py,sha256=1j99nfK7n5FBl-vSZy98xtZqh5oBtZZEcFKY4cbHTSs,2854
3
+ yappy/app.py,sha256=-vn22pj5pUBxSLZPiqGZfeJDjmrG44Pu5NTLXQBESec,17300
4
+ yappy/consts.py,sha256=0pIUIY1Ayo6_q3XhWr9hvKLgOT7TC9RgsjPZNVYy8QQ,51
5
+ yappy/core.py,sha256=Z_Yc6cNqN_1qThGVZZ94bpjwMnWHaPlRqhYS3GGRRf0,11104
6
+ yappy/errors.py,sha256=x-CHCU1s4lhZVYqK9EwULYJBbIll8Bt09cC7dZ7Y8oI,2297
7
+ yappy/logger.py,sha256=IVjOwvFtaYLwWGHNurTk7SVcthT8gi02ztuuayBlyIY,2295
8
+ yappy/parser.py,sha256=yOp74Ch8RZ6nAadFVrRxKt5U4VF5rqIClSjWRXmcPAI,5468
9
+ yappy/utils.py,sha256=s7wmPTflqTAIgeAtf0a-gWgEfkQaZGKWYEqm2i4MsUM,129
10
+ python_yappy-0.1.2.dist-info/WHEEL,sha256=lrO5MD1WVAWzcbNy_L2BwtfPrcM3KfUFpbKYXLkJX4A,80
11
+ python_yappy-0.1.2.dist-info/METADATA,sha256=hEgEHzEIx6QaNLAP0uzt1qxaAsYdxaOqKzxXeiDxJMY,7103
12
+ python_yappy-0.1.2.dist-info/RECORD,,
yappy/__init__.py CHANGED
@@ -1 +1,6 @@
1
1
  from .app import App
2
+ from .errors import (
3
+ BadArgument, CommandError, CommandInvokeError, MissingArgument,
4
+ TooManyArguments, UnknownCommand, YappyError,
5
+ )
6
+ from .logger import setup_logging
yappy/api.py CHANGED
@@ -1,7 +1,11 @@
1
1
  from .consts import BASE_URL
2
2
  import aiohttp
3
+ import logging
3
4
  import random
4
5
  import asyncio
6
+ import time
7
+
8
+ log = logging.getLogger(__name__)
5
9
 
6
10
  async def api_push(session: aiohttp.ClientSession, method: str, path: str, body=None, tries=3):
7
11
  headers = {
@@ -9,10 +13,17 @@ async def api_push(session: aiohttp.ClientSession, method: str, path: str, body=
9
13
  "Content-Type": "application/json"
10
14
  }
11
15
 
16
+ started = time.perf_counter()
12
17
  try:
13
18
  async with session.request(method, BASE_URL + path, json=body, headers=headers, timeout=40) as r:
14
19
  r.raise_for_status()
15
- return await r.json()
20
+ data = await r.json()
21
+ if not path.startswith("/events"): # the long poll would be noise
22
+ log.debug(
23
+ "%s %s -> %s (%.0f ms)", method, path, r.status,
24
+ (time.perf_counter() - started) * 1000
25
+ )
26
+ return data
16
27
  except aiohttp.ClientResponseError as e:
17
28
  try:
18
29
  err = await e.history[-1].json() if e.history else {}
@@ -25,8 +36,12 @@ async def api_push(session: aiohttp.ClientSession, method: str, path: str, body=
25
36
  wait = int(e.headers.get("Retry-After") or 0) if e.status == 429 or busy else 0
26
37
 
27
38
  if 0 < wait <= 60 and tries > 1:
28
- print(f"{'busy' if busy else 'rate limited'}, retrying in {wait}s")
29
- await asyncio.sleep(wait * (1 + random.random() * 0.5))
39
+ delay = wait * (1 + random.random() * 0.5)
40
+ log.warning(
41
+ "%s on %s %s, retrying in %.1fs",
42
+ "Server busy" if busy else "Rate limited", method, path, delay
43
+ )
44
+ await asyncio.sleep(delay)
30
45
  return await api_push(session, method, path, body, tries - 1)
31
46
 
32
47
  raise RuntimeError(f"{method} {path} failed ({e.status}): {msg or e.message}") from None
yappy/app.py CHANGED
@@ -1,20 +1,27 @@
1
1
  from .core import *
2
2
  from .consts import BASE_URL
3
3
  from .api import *
4
- from .errors import ImproperToken, InvalidSignature, UnknownCommand
4
+ from .errors import (
5
+ CommandError, CommandInvokeError, ImproperToken, InvalidSignature,
6
+ UnknownCommand,
7
+ )
8
+ from .logger import setup_logging
5
9
  from .parser import parser
6
10
 
7
11
  import inspect
8
12
  import asyncio
9
13
  import aiohttp
10
- import traceback
14
+ import logging
11
15
  from functools import wraps
12
16
  from typing import Callable
13
17
 
18
+ log = logging.getLogger(__name__)
19
+
14
20
  class App:
15
21
  def __init__(
16
22
  self, verbose=False,
17
- prefix = "!"
23
+ prefix = "!",
24
+ configure_logging = True
18
25
  ):
19
26
  self.__commands = {}
20
27
  self.__event_handlers = {
@@ -28,12 +35,30 @@ class App:
28
35
  self._token = None
29
36
 
30
37
  self._verbose = verbose
38
+ self._configure_logging = configure_logging
31
39
 
32
40
  self.__cache = {
33
41
  "users":{}, "channels":{},
34
42
  "guilds": {}, "roles": {}
35
43
  }
36
44
 
45
+ # in-flight lookups, so concurrent events share one request
46
+ self.__inflight = {}
47
+ # running event-handler tasks (kept so they aren't garbage collected)
48
+ self.__tasks = set()
49
+
50
+ def _once(self, key, fn, *args):
51
+ """Run ``fn(*args)`` once per ``key`` at a time; concurrent callers
52
+ with the same key all wait on the same request."""
53
+ task = self.__inflight.get(key)
54
+ if task is None:
55
+ task = asyncio.ensure_future(fn(*args))
56
+ self.__inflight[key] = task
57
+ task.add_done_callback(
58
+ lambda _t, k=key: self.__inflight.pop(k, None)
59
+ )
60
+ return asyncio.shield(task)
61
+
37
62
  async def _load_bot_info(self):
38
63
  my_info = await API.get(self.session, "/me")
39
64
 
@@ -52,16 +77,10 @@ class App:
52
77
  is_bot=True
53
78
  )
54
79
 
55
- async def _cache_members(
56
- self, id
57
- ):
58
- _members_data = (await API.get(
59
- self.session, f"/servers/{id}/members"
60
- ))["members"]
61
-
80
+ def _build_members(self, data):
62
81
  members = []
63
82
 
64
- for _user_data in _members_data:
83
+ for _user_data in data["members"]:
65
84
  roles = [
66
85
  self.__cache["roles"][role_id]
67
86
  for role_id in _user_data["roles"]
@@ -72,7 +91,7 @@ class App:
72
91
  Member(
73
92
  bot=self,
74
93
  guild=None, roles=roles,
75
- id=id,
94
+ id=_user_data.get("id", _user_data.get("user_id")),
76
95
  name = _user_data["username"],
77
96
  display_name = _user_data["display_name"],
78
97
  badges = _user_data["badges"],
@@ -87,13 +106,7 @@ class App:
87
106
 
88
107
  return members
89
108
 
90
- async def _cache_roles(
91
- self, id
92
- ):
93
- _roles_data = (await API.get(
94
- self.session, f"/servers/{id}/roles"
95
- ))
96
-
109
+ def _build_roles(self, id, _roles_data):
97
110
  roles = []
98
111
 
99
112
  for role in _roles_data["roles"]:
@@ -120,20 +133,37 @@ class App:
120
133
  async def fetch_guild(
121
134
  self, id
122
135
  ) -> Guild:
123
- _guild_data = (await API.get(
124
- self.session, f"/servers/{id}"
125
- ))["server"]
136
+ # The server, roles and members endpoints don't depend on each other,
137
+ # so request them together (one round trip instead of three), and
138
+ # look up the owner as soon as the server payload is in.
139
+ server_t = asyncio.ensure_future(
140
+ API.get(self.session, f"/servers/{id}")
141
+ )
142
+ roles_t = asyncio.ensure_future(
143
+ API.get(self.session, f"/servers/{id}/roles")
144
+ )
145
+ members_t = asyncio.ensure_future(
146
+ API.get(self.session, f"/servers/{id}/members")
147
+ )
148
+ try:
149
+ _guild_data = (await server_t)["server"]
150
+ owner, roles_data, members_data = await asyncio.gather(
151
+ self.find_user(_guild_data["owner_id"]),
152
+ roles_t, members_t
153
+ )
154
+ except BaseException:
155
+ for t in (server_t, roles_t, members_t):
156
+ t.cancel()
157
+ raise
126
158
 
127
- roles = await self._cache_roles(id)
128
- members = await self._cache_members(id)
159
+ roles = self._build_roles(id, roles_data)
160
+ members = self._build_members(members_data)
129
161
 
130
162
  return Guild(
131
163
  self,
132
164
  id = id,
133
165
  name=_guild_data["name"],
134
- owner=await self.find_user(
135
- _guild_data["owner_id"]
136
- ),
166
+ owner=owner,
137
167
  member_count = _guild_data["member_count"],
138
168
  discoverable = _guild_data["discoverable"],
139
169
  created_at = _guild_data["created_at"],
@@ -149,8 +179,12 @@ class App:
149
179
  ):
150
180
  guild = self.get_guild(id)
151
181
  if guild is None:
152
- guild = await self.fetch_guild(id)
153
- self.__cache["guilds"]=guild
182
+ guild = await self._once(("guild", id), self._load_guild, id)
183
+ return guild
184
+
185
+ async def _load_guild(self, id):
186
+ guild = await self.fetch_guild(id)
187
+ self.__cache["guilds"][id] = guild
154
188
  return guild
155
189
 
156
190
  def get_guild(
@@ -184,8 +218,12 @@ class App:
184
218
  ):
185
219
  channel = self.get_channel(id)
186
220
  if channel is None:
187
- channel = await self.fetch_channel(id)
188
- self.__cache["channels"][id] = channel
221
+ channel = await self._once(("channel", id), self._load_channel, id)
222
+ return channel
223
+
224
+ async def _load_channel(self, id):
225
+ channel = await self.fetch_channel(id)
226
+ self.__cache["channels"][id] = channel
189
227
  return channel
190
228
 
191
229
  def get_channel(
@@ -223,11 +261,12 @@ class App:
223
261
  user = self.get_user(id)
224
262
 
225
263
  if user is None:
226
- user = await self.fetch_user(
227
- id
228
- )
264
+ user = await self._once(("user", id), self._load_user, id)
265
+ return user
229
266
 
230
- self.__cache["users"][id]=user
267
+ async def _load_user(self, id):
268
+ user = await self.fetch_user(id)
269
+ self.__cache["users"][id] = user
231
270
  return user
232
271
 
233
272
  def get_user(
@@ -256,45 +295,96 @@ class App:
256
295
  channel=channel
257
296
  )
258
297
 
259
- async def _parse_arguments(self, content):
260
- return await parser.parse_args(self, content)
298
+ async def _parse_arguments(self, content, command=None):
299
+ return await parser.parse_args(self, content, command)
261
300
 
262
301
  async def _handle_prefix_command(self, ctx):
263
- if ctx.message.content.startswith(self.prefix):
264
- if " " in ctx.message.content:
265
- name, raw = ctx.message.content.removeprefix(self.prefix).split(" ", maxsplit=1)
266
- else:
267
- name = ctx.message.content.removeprefix(self.prefix)
268
- raw = ""
269
- arguments = await self._parse_arguments(
270
- raw
271
- )
302
+ content = ctx.message.content
303
+ if not content.startswith(self.prefix):
304
+ return False
305
+
306
+ parts = content[len(self.prefix):].split(None, 1)
307
+ if not parts:
308
+ return False # nothing but the prefix
309
+ name = parts[0].lower()
310
+ raw = parts[1] if len(parts) > 1 else ""
311
+
312
+ cmd = self.__commands.get(name) or self.__aliases.get(name)
313
+ if cmd is None:
314
+ raise UnknownCommand(f"Unknown command '{name}'.")
315
+
316
+ # Arguments are parsed per the command's signature, so this is also
317
+ # where bad types are caught (BadArgument) and, right after, wrong
318
+ # counts (MissingArgument / TooManyArguments).
319
+ arguments = await self._parse_arguments(raw, cmd)
320
+ cmd.check_arguments(arguments)
321
+
322
+ log.debug(
323
+ "Command '%s' invoked by %s",
324
+ cmd.name, getattr(ctx.message.author, "name", "?")
325
+ )
326
+ try:
327
+ await cmd.func(ctx, *arguments)
328
+ except CommandError:
329
+ raise
330
+ except Exception as e:
331
+ raise CommandInvokeError(e, command=cmd) from e
272
332
 
273
- cmd = None
274
- if name in self.__commands:
275
- cmd = self.__commands[name.lower()]
276
- elif name in self.__aliases:
277
- cmd = self.__aliases[name.lower()]
278
- else:
279
- raise UnknownCommand(
280
- f"Unhandled command '{name.lower()}'!"
281
- )
282
-
283
- await cmd.func(
284
- ctx, *arguments
333
+ return True
334
+
335
+ async def process_command(self, ctx):
336
+ """Run the command in ``ctx.message`` if it is one.
337
+
338
+ Returns whether a command ran. Problems (unknown command, wrong
339
+ arguments, an exception inside the command) don't propagate: they go
340
+ to the ``command_error`` event handler if you registered one, or are
341
+ handled by :meth:`_default_command_error` otherwise.
342
+ """
343
+ try:
344
+ return await self._handle_prefix_command(ctx)
345
+ except CommandError as e:
346
+ await self._on_command_error(ctx, e)
347
+ return False
348
+
349
+ async def _on_command_error(self, ctx, error):
350
+ handler = self.__event_handlers.get("command_error")
351
+ if handler is None:
352
+ await self._default_command_error(ctx, error)
353
+ return
354
+ try:
355
+ await handler(ctx, error)
356
+ except Exception:
357
+ log.exception("Exception in the 'command_error' handler")
358
+
359
+ async def _default_command_error(self, ctx, error):
360
+ if isinstance(error, UnknownCommand):
361
+ # people type "!!!" or "!lol" all the time; don't answer those
362
+ log.debug("%s", error)
363
+ return
364
+
365
+ if isinstance(error, CommandInvokeError):
366
+ cmd = error.command
367
+ log.error(
368
+ "Command '%s' failed", cmd.name if cmd else "?",
369
+ exc_info=error.original
285
370
  )
371
+ return
286
372
 
287
- return True
288
- return False
373
+ # The person used the command wrong, so tell them how to use it.
374
+ author = getattr(ctx.message.author, "name", "?")
375
+ log.info("%s: %s (from %s)", type(error).__name__, error, author)
289
376
 
290
- async def process_command(self, ctx):
291
- await self._handle_prefix_command(
292
- ctx
293
- )
377
+ reply = str(error)
378
+ if error.command is not None:
379
+ reply += f"\nUsage: `{error.command.usage(self.prefix)}`"
380
+ try:
381
+ await ctx.reply(reply)
382
+ except Exception:
383
+ log.exception("Couldn't send the command error message")
294
384
 
295
385
  async def on_message(self, ctx):
296
386
  if ctx.is_bot: return
297
- await process_command(ctx)
387
+ await self.process_command(ctx)
298
388
 
299
389
  async def _handle_event(self, session: aiohttp.ClientSession, ev):
300
390
  d = ev["data"]
@@ -306,6 +396,13 @@ class App:
306
396
  )
307
397
  )
308
398
 
399
+ async def _dispatch(self, ev):
400
+ log.debug("Event '%s' (id %s)", ev["type"], ev["id"])
401
+ try:
402
+ await self._handle_event(self.session, ev)
403
+ except Exception:
404
+ log.exception("Unhandled exception in event '%s'", ev["type"])
405
+
309
406
  async def _init_commands(self):
310
407
  _commands = []
311
408
  for name, func in self.__commands.items():
@@ -318,12 +415,17 @@ class App:
318
415
  "/commands",
319
416
  {"commands": _commands}
320
417
  )
418
+ log.info(
419
+ "Registered %d command(s): %s",
420
+ len(_commands), ", ".join(c["name"] for c in _commands) or "-"
421
+ )
321
422
 
322
423
  async def _event_loop(self):
323
424
  async with aiohttp.ClientSession() as self.session:
324
425
  self.session._bearer_token=self._token
325
426
 
326
427
  self.user = await self._load_bot_info()
428
+ log.info("Logged in as %s (id %s)", self.user.name, self.user.id)
327
429
  await self._init_commands()
328
430
 
329
431
  events_init = await API.get(self.session, "/events?after=0&wait=0")
@@ -333,37 +435,58 @@ class App:
333
435
  if "ready" in self.__event_handlers:
334
436
  await self.__event_handlers["ready"]()
335
437
 
336
- while True:
337
- try:
338
- out = await API.get(self.session, f"/events?after={last}&wait=25")
339
- fails = 0
340
- except Exception as e:
341
- fails += 1
342
- if "(401)" in str(e):
343
- raise ImproperToken("Please check if your token is correctly used!")
344
- wait = min(30, 2 ** fails)
345
-
346
- print(f"retrying in {wait}s:", e)
347
-
348
- await asyncio.sleep(wait)
349
- continue
350
- for ev in out["events"]:
351
- if ev["id"] <= last:
352
- continue
353
- last = ev["id"]
438
+ try:
439
+ while True:
354
440
  try:
355
- await self._handle_event(self.session, ev)
356
- except Exception as e:
357
- traceback.print_exc()
358
-
441
+ out = await API.get(self.session, f"/events?after={last}&wait=25")
442
+ if fails:
443
+ log.info("Connection to Yapper restored")
444
+ fails = 0
445
+ except Exception as e:
446
+ fails += 1
447
+ if "(401)" in str(e):
448
+ raise ImproperToken("Please check if your token is correctly used!")
449
+ wait = min(30, 2 ** fails)
450
+
451
+ log.warning("Problem reaching Yapper: %s. Retrying in %ss", e, wait)
452
+
453
+ await asyncio.sleep(wait)
454
+ continue
455
+ for ev in out["events"]:
456
+ if ev["id"] <= last:
457
+ continue
458
+ last = ev["id"]
459
+ # Run each event in its own task so one slow handler
460
+ # doesn't hold up the others or the next poll.
461
+ task = asyncio.create_task(self._dispatch(ev))
462
+ self.__tasks.add(task)
463
+ task.add_done_callback(self.__tasks.discard)
464
+ finally:
465
+ # let in-flight handlers finish cancelling before the
466
+ # session is closed underneath them
467
+ tasks = list(self.__tasks)
468
+ for task in tasks:
469
+ task.cancel()
470
+ if tasks:
471
+ await asyncio.gather(*tasks, return_exceptions=True)
472
+
359
473
 
360
474
  def event(self, event_type: str):
475
+ """Register a coroutine as the handler for ``event_type``.
476
+
477
+ Besides Yapper's own events (``"message"``, ``"ready"``) there is
478
+ ``"command_error"``: ``async def handler(ctx, error)``, called when a
479
+ command is unknown, given bad arguments, or raises. ``error`` is a
480
+ :class:`yappy.errors.CommandError`. Registering it replaces the
481
+ default behaviour (replying with the problem and a usage line).
482
+ """
361
483
  def wrapper(func):
362
484
  if not inspect.iscoroutinefunction(func):
363
485
  raise InvalidSignature(f"Expected event handler for '{event_type}' to be a coroutine!")
364
486
  self.__event_handlers[
365
487
  event_type
366
488
  ] = func
489
+ return func
367
490
  return wrapper
368
491
 
369
492
  def command(
@@ -386,16 +509,19 @@ class App:
386
509
 
387
510
  for alias in aliases:
388
511
  self.__aliases[alias.lower()] = self.__commands[name.lower()]
512
+ return func
389
513
  return wrapper
390
514
 
391
515
  def run(self, token: str):
392
516
  self._token = token
393
517
 
394
- self._yappy_loop = asyncio.new_event_loop()
395
- asyncio.set_event_loop(self._yappy_loop)
518
+ if self._configure_logging:
519
+ setup_logging(verbose=self._verbose)
396
520
 
521
+ # asyncio.run turns Ctrl+C into a cancellation of the main task, so
522
+ # _event_loop unwinds normally and the HTTP session gets closed.
397
523
  try:
398
- self._yappy_loop.run_until_complete(self._event_loop())
399
- finally:
400
- self._yappy_loop.close()
524
+ asyncio.run(self._event_loop())
525
+ except KeyboardInterrupt:
526
+ log.info("Shutting down")
401
527
  __all__=["App"]
yappy/core.py CHANGED
@@ -1,8 +1,15 @@
1
1
  from .api import *
2
2
  from typing import Callable
3
+ import inspect
4
+ import logging
5
+ import typing
3
6
  import urllib.parse
7
+ from dataclasses import dataclass
8
+ from .errors import InvalidSignature, MissingArgument, TooManyArguments
4
9
  from .utils import get_asset_url
5
10
 
11
+ log = logging.getLogger(__name__)
12
+
6
13
  class Assets:
7
14
  def __init__(self,
8
15
  avatar: str = "",
@@ -39,6 +46,13 @@ class Bot(User):
39
46
  super().__init__(**kwargs)
40
47
  self.owner_id = owner_id
41
48
 
49
+ @dataclass(frozen=True)
50
+ class CommandParam:
51
+ """One argument a command accepts (not counting ``ctx``)."""
52
+ name: str
53
+ annotation: object = inspect.Parameter.empty
54
+ required: bool = True
55
+
42
56
  class Command:
43
57
  def __init__(self,
44
58
  name: str,
@@ -51,6 +65,65 @@ class Command:
51
65
  self.description = description
52
66
  self.func = func
53
67
 
68
+ self.params: list[CommandParam] = []
69
+ self.var_positional: CommandParam | None = None
70
+ if func is not None:
71
+ self._inspect_signature()
72
+
73
+ def _inspect_signature(self):
74
+ P = inspect.Parameter
75
+ params = list(inspect.signature(self.func).parameters.values())
76
+
77
+ if not params or params[0].kind not in (
78
+ P.POSITIONAL_ONLY, P.POSITIONAL_OR_KEYWORD
79
+ ):
80
+ raise InvalidSignature(
81
+ f"Command '{self.name}' must accept 'ctx' as its first argument!"
82
+ )
83
+
84
+ try:
85
+ hints = typing.get_type_hints(self.func)
86
+ except Exception:
87
+ # unresolvable annotations: treat them as "no annotation"
88
+ hints = {}
89
+
90
+ for p in params[1:]:
91
+ ann = hints.get(p.name, p.annotation)
92
+ if p.kind in (P.POSITIONAL_ONLY, P.POSITIONAL_OR_KEYWORD):
93
+ self.params.append(
94
+ CommandParam(p.name, ann, p.default is P.empty)
95
+ )
96
+ elif p.kind is P.VAR_POSITIONAL:
97
+ self.var_positional = CommandParam(p.name, ann, False)
98
+ elif p.kind is P.KEYWORD_ONLY and p.default is P.empty:
99
+ raise InvalidSignature(
100
+ f"Command '{self.name}' has a required keyword-only "
101
+ f"argument '{p.name}', which can't be filled from chat!"
102
+ )
103
+
104
+ def param_for(self, index: int) -> CommandParam | None:
105
+ """The parameter that the argument at ``index`` is bound to."""
106
+ if index < len(self.params):
107
+ return self.params[index]
108
+ return self.var_positional
109
+
110
+ def check_arguments(self, args: list):
111
+ """Raise if ``args`` doesn't fit this command's parameters."""
112
+ required = sum(1 for p in self.params if p.required)
113
+ if len(args) < required:
114
+ raise MissingArgument(self.params[len(args)].name, command=self)
115
+ if self.var_positional is None and len(args) > len(self.params):
116
+ raise TooManyArguments(len(self.params), len(args), command=self)
117
+
118
+ def usage(self, prefix: str = "") -> str:
119
+ parts = [
120
+ f"<{p.name}>" if p.required else f"[{p.name}]"
121
+ for p in self.params
122
+ ]
123
+ if self.var_positional is not None:
124
+ parts.append(f"[{self.var_positional.name}...]")
125
+ return " ".join([f"{prefix}{self.name}", *parts])
126
+
54
127
  class Permissions:
55
128
  def __init__(self, **perms):
56
129
  self._perms = perms
@@ -104,42 +177,50 @@ class Guild:
104
177
  }}
105
178
 
106
179
  for m in members:
107
- self._cache[m.id] = m
180
+ if m.id is not None:
181
+ self._cache["members"][m.id] = m
108
182
  m. guild = self
109
183
  async def find_member(self, mid):
110
184
  member = self.get_member(mid)
111
185
  if member is None:
112
- _user_data = (await API.get(
113
- self.bot.session,
114
- f"/servers/{self.id}/members/{mid}"
115
- ))["member"]
116
-
117
- roles = [
118
- self._cache["roles"][role_id]
119
- for role_id in _user_data["roles"]
120
- if role_id in self._cache["roles"]
121
- ]
122
-
123
- member = Member(
124
- bot=self.bot,
125
- guild=self, roles=roles,
126
- id=id,
127
- name = _user_data["username"],
128
- display_name = _user_data["display_name"],
129
- badges = _user_data["badges"],
130
- joined_at = _user_data["joined_at"],
131
- nickname = _user_data["nickname"],
132
- assets = Assets(
133
- avatar = "https://files.yapper.website/avatars/"+_user_data.get("avatar_path",""),
134
- banner = "https://files.yapper.website/banners/"+_user_data.get("banner_path", "")
135
- ),
186
+ # concurrent lookups of the same member share one request
187
+ member = await self.bot._once(
188
+ ("member", self.id, mid), self._load_member, mid
136
189
  )
190
+ return member
191
+
192
+ async def _load_member(self, mid):
193
+ _user_data = (await API.get(
194
+ self.bot.session,
195
+ f"/servers/{self.id}/members/{mid}"
196
+ ))["member"]
137
197
 
138
- self._cache["members"][mid] = member
139
-
198
+ roles = [
199
+ self._cache["roles"][role_id]
200
+ for role_id in _user_data["roles"]
201
+ if role_id in self._cache["roles"]
202
+ ]
203
+
204
+ member = Member(
205
+ bot=self.bot,
206
+ guild=self, roles=roles,
207
+ id=mid,
208
+ name = _user_data["username"],
209
+ display_name = _user_data["display_name"],
210
+ badges = _user_data["badges"],
211
+ joined_at = _user_data["joined_at"],
212
+ nickname = _user_data["nickname"],
213
+ assets = Assets(
214
+ avatar = "https://files.yapper.website/avatars/"+_user_data.get("avatar_path",""),
215
+ banner = "https://files.yapper.website/banners/"+_user_data.get("banner_path", "")
216
+ ),
217
+ )
218
+
219
+ self._cache["members"][mid] = member
140
220
  return member
221
+
141
222
  def get_member(self, mid):
142
- return self._cache.get(mid, None)
223
+ return self._cache["members"].get(mid, None)
143
224
 
144
225
  class Channel:
145
226
  def __init__(self,
yappy/errors.py CHANGED
@@ -1,8 +1,79 @@
1
- class ImproperToken(Exception):
1
+ class YappyError(Exception):
2
+ """Base class for all YapPy errors."""
3
+
4
+
5
+ class ImproperToken(YappyError):
2
6
  pass
3
7
 
4
- class InvalidSignature(Exception):
8
+
9
+ class InvalidSignature(YappyError):
5
10
  pass
6
-
7
- class UnknownCommand(Exception):
11
+
12
+
13
+ class CommandError(YappyError):
14
+ """Base class for errors raised while handling a command.
15
+
16
+ ``str(error)`` is a message that is safe to show to the person who ran
17
+ the command. ``command`` is the :class:`~yappy.core.Command` involved
18
+ (``None`` if the command couldn't be found).
19
+ """
20
+
21
+ def __init__(self, message: str = "", *, command=None):
22
+ super().__init__(message)
23
+ self.command = command
24
+
25
+
26
+ class UnknownCommand(CommandError):
8
27
  pass
28
+
29
+
30
+ class MissingArgument(CommandError):
31
+ """A required argument was not given."""
32
+
33
+ def __init__(self, param: str, *, command=None):
34
+ super().__init__(
35
+ f"Missing required argument '{param}'.", command=command
36
+ )
37
+ self.param = param
38
+
39
+
40
+ class TooManyArguments(CommandError):
41
+ """More arguments were given than the command accepts."""
42
+
43
+ def __init__(self, expected: int, given: int, *, command=None):
44
+ noun = "argument" if expected == 1 else "arguments"
45
+ super().__init__(
46
+ f"Too many arguments: expected at most {expected} {noun}, "
47
+ f"got {given}.",
48
+ command=command,
49
+ )
50
+ self.expected = expected
51
+ self.given = given
52
+
53
+
54
+ class BadArgument(CommandError):
55
+ """An argument couldn't be converted to what the command expects."""
56
+
57
+ def __init__(self, param: str, value, expected: str, *, command=None):
58
+ shown = str(value)
59
+ if len(shown) > 40:
60
+ shown = shown[:37] + "..."
61
+ super().__init__(
62
+ f"Invalid value {shown!r} for '{param}': expected {expected}.",
63
+ command=command,
64
+ )
65
+ self.param = param
66
+ self.value = value
67
+ self.expected = expected
68
+
69
+
70
+ class CommandInvokeError(CommandError):
71
+ """The command itself raised an exception. The original is ``original``."""
72
+
73
+ def __init__(self, original: BaseException, *, command=None):
74
+ name = command.name if command is not None else "?"
75
+ super().__init__(
76
+ f"Command '{name}' raised {type(original).__name__}: {original}",
77
+ command=command,
78
+ )
79
+ self.original = original
yappy/logger.py ADDED
@@ -0,0 +1,69 @@
1
+ """Logging for YapPy.
2
+
3
+ YapPy logs through the standard :mod:`logging` module under the ``"yappy"``
4
+ logger (``yappy.app``, ``yappy.api``, ...). :func:`setup_logging` attaches a
5
+ `Rich <https://rich.readthedocs.io>`_ handler so output is coloured and
6
+ exceptions are printed as readable tracebacks.
7
+
8
+ ``App.run()`` calls it for you. To take over logging yourself, create the app
9
+ with ``App(configure_logging=False)`` and add your own handlers to
10
+ ``logging.getLogger("yappy")``.
11
+ """
12
+
13
+ import asyncio
14
+ import logging
15
+
16
+ import aiohttp
17
+ from rich.console import Console
18
+ from rich.logging import RichHandler
19
+
20
+ LOGGER_NAME = "yappy"
21
+ _MARKER = "_yappy_rich_handler"
22
+
23
+
24
+ def setup_logging(
25
+ level: int | str | None = None,
26
+ *,
27
+ verbose: bool = False,
28
+ console: Console | None = None,
29
+ show_locals: bool = False,
30
+ ) -> logging.Logger:
31
+ """Send YapPy's logs to the terminal using Rich.
32
+
33
+ :param level: a ``logging`` level. Defaults to ``DEBUG`` if ``verbose``
34
+ else ``INFO``.
35
+ :param verbose: also show debug output (events, HTTP calls, command
36
+ invocations) and prefix each line with the module it came from.
37
+ :param console: a ``rich.console.Console`` to print to.
38
+ :param show_locals: include local variables in exception tracebacks.
39
+
40
+ Safe to call more than once; it replaces the handler it added before.
41
+ """
42
+ if level is None:
43
+ level = logging.DEBUG if verbose else logging.INFO
44
+
45
+ logger = logging.getLogger(LOGGER_NAME)
46
+ for handler in list(logger.handlers):
47
+ if getattr(handler, _MARKER, False):
48
+ logger.removeHandler(handler)
49
+
50
+ handler = RichHandler(
51
+ console=console,
52
+ show_path=False,
53
+ markup=False, # chat content in log messages must not be parsed as markup
54
+ rich_tracebacks=True,
55
+ tracebacks_show_locals=show_locals,
56
+ tracebacks_suppress=[aiohttp, asyncio],
57
+ log_time_format="[%H:%M:%S]",
58
+ omit_repeated_times=False,
59
+ )
60
+ handler.setFormatter(
61
+ logging.Formatter("%(name)s %(message)s" if verbose else "%(message)s")
62
+ )
63
+ setattr(handler, _MARKER, True)
64
+
65
+ logger.addHandler(handler)
66
+ logger.setLevel(level)
67
+ # we own the output; don't print every line a second time via the root logger
68
+ logger.propagate = False
69
+ return logger
yappy/parser.py CHANGED
@@ -1,6 +1,34 @@
1
+ import inspect
2
+ import logging
3
+ import math
1
4
  import re
2
5
  from typing import Any, List, Union
3
6
 
7
+ from .core import Channel, Role, User
8
+ from .errors import BadArgument
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+ # What a person sees when an argument doesn't fit the annotation.
13
+ _EXPECTED = {
14
+ int: "a whole number",
15
+ float: "a number",
16
+ bool: "true or false",
17
+ User: "a user mention like <@123>",
18
+ Channel: "a channel mention like <#123>",
19
+ Role: "a role mention like <@&123>",
20
+ }
21
+ _NOT_FOUND = {
22
+ User: "a user that exists",
23
+ Channel: "a channel that exists",
24
+ Role: "a role that exists",
25
+ }
26
+ _MODELS = (User, Channel, Role)
27
+ _INT = re.compile(r'^[+-]?[0-9]+$')
28
+ _TRUE = {"true", "yes", "y", "on", "1"}
29
+ _FALSE = {"false", "no", "n", "off", "0"}
30
+
31
+
4
32
  class CommandParser:
5
33
  def __init__(self):
6
34
  self.token_regex = re.compile(
@@ -13,41 +41,98 @@ class CommandParser:
13
41
  self.channel_regex = re.compile(r'^<#([0-9\-]+)>$|^<#(.+)>$')
14
42
  self.role_regex = re.compile(r'^<@&([0-9\-]+)>$|^<@&(.+)>$')
15
43
 
16
- async def parse_args(self, bot, text: str) -> List[Any]:
44
+ async def parse_args(self, bot, text: str, command=None) -> List[Any]:
45
+ """Split ``text`` into arguments.
46
+
47
+ When ``command`` is given, each argument is converted according to
48
+ the annotation of the parameter it will be passed to (``str``,
49
+ ``int``, ``float``, ``bool``, ``User``, ``Channel`` or ``Role``) and
50
+ a :class:`~yappy.errors.BadArgument` is raised if it doesn't fit.
51
+ Without annotations the type is guessed, as before.
52
+ """
17
53
  parsed_arguments = []
18
-
19
- for match in self.token_regex.finditer(text):
54
+
55
+ for index, match in enumerate(self.token_regex.finditer(text)):
20
56
  dq_str, sq_str, _raw, unquoted = match.groups()
21
-
57
+
22
58
  if dq_str is not None:
23
- parsed_arguments.append(dq_str.encode().decode('unicode_escape'))
24
- continue
25
- if sq_str is not None:
26
- parsed_arguments.append(sq_str.encode().decode('unicode_escape'))
27
- continue
28
-
29
- if _raw is not None:
30
- parsed_arguments.append(await self._process_mentions(bot, _raw))
31
- continue
32
-
33
- if unquoted is not None:
34
- parsed_arguments.append(self._parse_primitive(unquoted))
35
-
59
+ kind, token = "quoted", dq_str.encode().decode('unicode_escape')
60
+ elif sq_str is not None:
61
+ kind, token = "quoted", sq_str.encode().decode('unicode_escape')
62
+ elif _raw is not None:
63
+ kind, token = "mention", _raw
64
+ else:
65
+ kind, token = "bare", unquoted
66
+
67
+ param = command.param_for(index) if command is not None else None
68
+ parsed_arguments.append(
69
+ await self._convert(bot, kind, token, param, index, command)
70
+ )
71
+
36
72
  return parsed_arguments
37
73
 
74
+ async def _convert(self, bot, kind, token, param, index, command):
75
+ hint = param.annotation if param is not None else inspect.Parameter.empty
76
+ name = param.name if param is not None else f"argument {index + 1}"
77
+
78
+ def bad(expected):
79
+ return BadArgument(name, token, expected, command=command)
80
+
81
+ if hint is str:
82
+ return token
83
+
84
+ if hint is int:
85
+ if kind == "mention" or not _INT.match(token):
86
+ raise bad(_EXPECTED[int])
87
+ return int(token)
88
+
89
+ if hint is float:
90
+ try:
91
+ value = float(token) if kind != "mention" else math.nan
92
+ except ValueError:
93
+ value = math.nan
94
+ if not math.isfinite(value):
95
+ raise bad(_EXPECTED[float])
96
+ return value
97
+
98
+ if hint is bool:
99
+ word = token.lower()
100
+ if kind != "mention" and word in _TRUE:
101
+ return True
102
+ if kind != "mention" and word in _FALSE:
103
+ return False
104
+ raise bad(_EXPECTED[bool])
105
+
106
+ # No annotation (or one we don't convert): same behaviour as before.
107
+ if kind == "quoted":
108
+ value = token
109
+ elif kind == "mention":
110
+ try:
111
+ value = await self._process_mentions(bot, token)
112
+ except RuntimeError as e:
113
+ # the API said no (404 etc.); don't leak its message to chat
114
+ log.debug("Couldn't resolve mention %r: %s", token, e)
115
+ raise bad(_NOT_FOUND.get(hint, "a mention of something that exists"))
116
+ else:
117
+ value = self._parse_primitive(token)
118
+
119
+ if hint in _MODELS and not isinstance(value, hint):
120
+ raise bad(_EXPECTED[hint])
121
+ return value
122
+
38
123
  async def _process_mentions(self, bot, token: str) -> Any:
39
124
  if user_match := self.user_regex.match(token):
40
125
  user_id = user_match.group(1) or user_match.group(2)
41
126
  return await self._parse_user(bot, user_id)
42
-
127
+
43
128
  if channel_match := self.channel_regex.match(token):
44
129
  channel_id = channel_match.group(1) or channel_match.group(2)
45
130
  return await self._parse_channel(bot, channel_id)
46
-
131
+
47
132
  if role_match := self.role_regex.match(token):
48
133
  role_id = role_match.group(1) or role_match.group(2)
49
134
  return await self._parse_role(bot, role_id)
50
-
135
+
51
136
  return token
52
137
 
53
138
  def _parse_primitive(self, token: str) -> Union[int, float, str]:
@@ -1,11 +0,0 @@
1
- yappy/__init__.py,sha256=BZU8ktPMVc9XeJ5YJeeCAPCjRyaxYIU_zusvjiSi5_Q,21
2
- yappy/api.py,sha256=_v1r9xPqrqmNwiz7ESnIsrRrzXNrm34eem7XUa4UAWU,2392
3
- yappy/app.py,sha256=vT4MKygpFmZoc6yu8Uu16hxldNjt5cE2NB-EgFvhifc,11651
4
- yappy/consts.py,sha256=0pIUIY1Ayo6_q3XhWr9hvKLgOT7TC9RgsjPZNVYy8QQ,51
5
- yappy/core.py,sha256=quCOfJPjVzUbwdK2NeH3IN3Yr8sNOaQ6JZoMVtELPDk,8110
6
- yappy/errors.py,sha256=BZ42Du8XWA_SY9tR5Ye8gVWWVKAvRDZZ8Grt_CoOOZs,133
7
- yappy/parser.py,sha256=XgMXkXHyuL_-oH6rpsbQcnmO5KQrNADIT20GH4-C1LM,2583
8
- yappy/utils.py,sha256=s7wmPTflqTAIgeAtf0a-gWgEfkQaZGKWYEqm2i4MsUM,129
9
- python_yappy-0.1.1.dist-info/WHEEL,sha256=lrO5MD1WVAWzcbNy_L2BwtfPrcM3KfUFpbKYXLkJX4A,80
10
- python_yappy-0.1.1.dist-info/METADATA,sha256=gNceBXMHD6CuuMQO07gR6nTMTINHeVP98VmGIG7la98,4663
11
- python_yappy-0.1.1.dist-info/RECORD,,