diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..61710f5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "dpy_commons" +version = "0.1.0" +description = "Shared discord.py utilities — message/embed parsing, limit-fitting, link extraction, chunking, timestamps, await-prompts, limit-safe send. Config-free, installable." +requires-python = ">=3.10" +dependencies = [ + "discord.py>=2.2", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/dpy_commons"] diff --git a/src/dpy_commons/__init__.py b/src/dpy_commons/__init__.py new file mode 100644 index 0000000..be59a84 --- /dev/null +++ b/src/dpy_commons/__init__.py @@ -0,0 +1,61 @@ +"""dpy_commons — shared discord.py utilities (the discord-side sibling of commons) + +a module of functions grouped by concern: message/embed parsing, embed sanitizing + +limit-fitting, link extraction, text chunking, timestamp helpers, interactive await-prompts, +and a limit-safe send. see each submodule's docstring for the contract. +""" +from .embeds import fit_embed, sanitize_embed, split_embeds +from .limits import ( + EMBED_AUTHOR, + EMBED_DESC, + EMBED_FIELD_NAME, + EMBED_FIELD_VALUE, + EMBED_FOOTER, + EMBED_MAX_FIELDS, + EMBED_TITLE, + EMBED_TOTAL, + MSG_LIMIT, + MSG_MAX_EMBEDS, +) +from .parsing import extract_message_links, parse_message, sanitize_mentions +from .prompts import choose, confirm +from .send import safe_send +from .text import ( + chunk_text, + discord_timestamp, + format_table, + humanize_delta, + truncate, + wrap_bare_links, +) + +__version__ = "0.1.0" + +__all__ = [ + "parse_message", + "extract_message_links", + "sanitize_mentions", + "fit_embed", + "sanitize_embed", + "split_embeds", + "chunk_text", + "format_table", + "discord_timestamp", + "humanize_delta", + "truncate", + "wrap_bare_links", + "confirm", + "choose", + "safe_send", + "MSG_LIMIT", + "EMBED_TITLE", + "EMBED_DESC", + "EMBED_FIELD_NAME", + "EMBED_FIELD_VALUE", + "EMBED_FOOTER", + "EMBED_AUTHOR", + "EMBED_TOTAL", + "EMBED_MAX_FIELDS", + "MSG_MAX_EMBEDS", + "__version__", +] diff --git a/src/dpy_commons/embeds.py b/src/dpy_commons/embeds.py new file mode 100644 index 0000000..7be3bc4 --- /dev/null +++ b/src/dpy_commons/embeds.py @@ -0,0 +1,135 @@ +"""embed helpers: fit an embed within discord's hard limits, sanitize one to be safe-to-send, +and split a list into sendable groups; none of these mutate their input""" +from __future__ import annotations + +import logging + +import discord + +from .limits import ( + EMBED_AUTHOR, + EMBED_DESC, + EMBED_FIELD_NAME, + EMBED_FIELD_VALUE, + EMBED_FOOTER, + EMBED_MAX_FIELDS, + EMBED_TITLE, + EMBED_TOTAL, + MSG_MAX_EMBEDS, +) +from .text import truncate, wrap_bare_links + +log = logging.getLogger(__name__) + +_ZWSP = "​" + + +def _copy(embed: discord.Embed) -> discord.Embed: + """deep copy via dict round-trip so the input is never mutated""" + return discord.Embed.from_dict(embed.to_dict()) + + +def fit_embed(embed: discord.Embed) -> discord.Embed: + """return a copy guaranteed within discord's hard limits: title/description/field/footer/ + author truncated to their caps, fields beyond 25 dropped, and the description then fields + shrunk until the 6000 total fits; appends an ellipsis where it cuts, never mutates input""" + out = _copy(embed) + + if out.title: + out.title = truncate(out.title, EMBED_TITLE) + if out.description: + out.description = truncate(out.description, EMBED_DESC) + + footer = out.footer + if footer and footer.text: + out.set_footer(text=truncate(footer.text, EMBED_FOOTER), icon_url=footer.icon_url) + author = out.author + if author and author.name: + out.set_author(name=truncate(author.name, EMBED_AUTHOR), url=author.url, icon_url=author.icon_url) + + fields = list(out.fields) + out.clear_fields() + for field in fields[:EMBED_MAX_FIELDS]: + out.add_field( + name=truncate(field.name or _ZWSP, EMBED_FIELD_NAME), + value=truncate(field.value or _ZWSP, EMBED_FIELD_VALUE), + inline=field.inline, + ) + + _shrink_to_total(out) + return out + + +def _shrink_to_total(embed: discord.Embed) -> None: + """truncate the description, then trailing fields, until len(embed) <= EMBED_TOTAL""" + if len(embed) <= EMBED_TOTAL: + return + + if embed.description: + overflow = len(embed) - EMBED_TOTAL + keep = max(0, len(embed.description) - overflow) + embed.description = truncate(embed.description, keep) if keep else _ZWSP + if len(embed) <= EMBED_TOTAL: + return + + while len(embed) > EMBED_TOTAL and embed.fields: + embed.remove_field(len(embed.fields) - 1) + + +def sanitize_embed(embed: discord.Embed) -> discord.Embed: + """return a copy safe to send: non-discord links wrapped in <>, color normalized, empty + strings discord rejects filled with a zero-width space, all other parts preserved, then + run through fit_embed so the result is both clean and within limits; never mutates input""" + out = _copy(embed) + + if out.title: + out.title = wrap_bare_links(out.title) + out.description = wrap_bare_links(out.description) if out.description else out.description + out.colour = _normalize_colour(out.colour) + + footer = out.footer + if footer and footer.text: + out.set_footer(text=wrap_bare_links(footer.text), icon_url=footer.icon_url) + author = out.author + if author and author.name: + out.set_author(name=wrap_bare_links(author.name), url=author.url, icon_url=author.icon_url) + + fields = list(out.fields) + out.clear_fields() + for field in fields: + out.add_field( + name=wrap_bare_links(field.name) if field.name else _ZWSP, + value=wrap_bare_links(field.value) if field.value else _ZWSP, + inline=field.inline, + ) + + if not out.description and not out.fields: + out.description = _ZWSP + + return fit_embed(out) + + +def _normalize_colour(value: object) -> "discord.Colour | None": + """coerce a color-ish value to a discord.Colour: Colour passthrough, int, str '#hex', + or an object with a .value; falls back to default() on bad input rather than raising""" + if value is None: + return None + if isinstance(value, discord.Colour): + return value + try: + if isinstance(value, int): + return discord.Colour(value) + if isinstance(value, str): + return discord.Colour(int(value.lstrip("#"), 16)) + inner = getattr(value, "value", None) + if isinstance(inner, int): + return discord.Colour(inner) + except (ValueError, TypeError): + log.warning("dpy_commons: could not normalize embed colour %r, using default", value) + return discord.Colour.default() + + +def split_embeds(embeds: "list[discord.Embed]") -> "list[list[discord.Embed]]": + """chunk a list of embeds into groups of <= 10 (MSG_MAX_EMBEDS) so each group sends in one + message""" + return [embeds[i:i + MSG_MAX_EMBEDS] for i in range(0, len(embeds), MSG_MAX_EMBEDS)] diff --git a/src/dpy_commons/limits.py b/src/dpy_commons/limits.py new file mode 100644 index 0000000..492bce0 --- /dev/null +++ b/src/dpy_commons/limits.py @@ -0,0 +1,13 @@ +"""discord hard limits — single source of truth; every module references these, nothing +hardcodes a limit inline""" + +MSG_LIMIT = 2000 +EMBED_TITLE = 256 +EMBED_DESC = 4096 +EMBED_FIELD_NAME = 256 +EMBED_FIELD_VALUE = 1024 +EMBED_FOOTER = 2048 +EMBED_AUTHOR = 256 +EMBED_TOTAL = 6000 +EMBED_MAX_FIELDS = 25 +MSG_MAX_EMBEDS = 10 diff --git a/src/dpy_commons/parsing.py b/src/dpy_commons/parsing.py new file mode 100644 index 0000000..8cef46f --- /dev/null +++ b/src/dpy_commons/parsing.py @@ -0,0 +1,115 @@ +"""parsing / extraction: turn a discord.Message into a structured payload, pull jump-links +out of text, and neutralize raw mention tokens; all pure — no bot/global reached for""" +from __future__ import annotations + +import logging +import re +from typing import Union + +import discord + +from .embeds import fit_embed +from .text import wrap_bare_links + +log = logging.getLogger(__name__) + +_MESSAGE_LINK_RE = re.compile( + r"https?://(?:ptb\.|canary\.)?discord(?:app)?\.com/channels/(\d+)/(\d+)/(\d+)" +) +_RAW_MENTION_RE = re.compile(r"<@[!&]?\d+>") + + +def sanitize_mentions(text: str) -> str: + """neutralize pings in raw text: @everyone/@here -> (at)everyone/(at)here, and raw + <@id>/<@&id> mention tokens -> (at)mention; pure text, resolves no names""" + if not text: + return text + text = text.replace("@everyone", "(at)everyone").replace("@here", "(at)here") + return _RAW_MENTION_RE.sub("(at)mention", text) + + +def extract_message_links( + text_or_message: "Union[str, discord.Message]", +) -> "list[tuple[int, int, int]]": + """pull discord message jump-links out of text or a Message's content, returning + (guild_id, channel_id, message_id) tuples""" + text = text_or_message.content if isinstance(text_or_message, discord.Message) else text_or_message + return [(int(g), int(c), int(m)) for g, c, m in _MESSAGE_LINK_RE.findall(text or "")] + + +async def parse_message(message: discord.Message, *, strict: bool = False) -> dict: + """extract a discord.Message into a structured payload; pure, reaches for no bot/global + + async because attachments are read from discord's cdn (network i/o) — the spec wrote this + sync, but reading attachment bytes cannot be synchronous, so it is a coroutine (await it). + + the only tolerated swallow is per-attachment: a read failure warns and skips by default so + one bad attachment doesn't kill the parse; pass strict=True to raise on any such failure + instead""" + return { + "content": wrap_bare_links(sanitize_mentions(message.content or "")), + "mentions": _parse_mentions(message), + "embeds": [fit_embed(embed) for embed in message.embeds], + "attachments": await _parse_attachments(message, strict=strict), + "stickers": [sticker.name for sticker in message.stickers], + "poll": _parse_poll(message), + "components": _parse_components(message), + "reference": message.reference.message_id if message.reference else None, + } + + +def _parse_mentions(message: discord.Message) -> dict: + """collect user/role mentions plus everyone/here flags""" + content = message.content or "" + return { + "users": [user.id for user in message.mentions], + "roles": [role.id for role in message.role_mentions], + "everyone": bool(message.mention_everyone) or "@everyone" in content, + "here": "@here" in content, + } + + +async def _parse_attachments(message: discord.Message, *, strict: bool) -> "list[discord.File]": + """read each attachment into a discord.File; a read failure warns+skips unless strict""" + files: "list[discord.File]" = [] + for attachment in message.attachments: + try: + files.append(await attachment.to_file()) + except (discord.HTTPException, discord.NotFound, OSError) as exc: + if strict: + raise + log.warning("dpy_commons: skipping attachment %s: %s", attachment.filename, exc) + return files + + +def _parse_poll(message: discord.Message) -> "dict | None": + """extract a poll's question + option texts, or None if the message has no poll""" + poll = getattr(message, "poll", None) + if poll is None: + return None + return { + "question": _poll_text(poll.question), + "options": [_poll_text(answer) for answer in poll.answers], + } + + +def _poll_text(value: object) -> str: + """poll question/answer text across discord.py shapes (str, PollMedia, PollAnswer)""" + if isinstance(value, str): + return value + media = getattr(value, "media", None) + if media is not None and getattr(media, "text", None) is not None: + return media.text + return str(value) + + +def _parse_components(message: discord.Message) -> "list[dict]": + """flatten action-row buttons to {label, url} entries""" + buttons: "list[dict]" = [] + for row in message.components: + for child in getattr(row, "children", []): + label = getattr(child, "label", None) + url = getattr(child, "url", None) + if label is not None or url is not None: + buttons.append({"label": label, "url": url}) + return buttons diff --git a/src/dpy_commons/prompts.py b/src/dpy_commons/prompts.py new file mode 100644 index 0000000..3fda4e8 --- /dev/null +++ b/src/dpy_commons/prompts.py @@ -0,0 +1,218 @@ +"""interactive await-prompts: throw a prompt anywhere, await it, get the chosen VALUE back +right there — no on_interaction listener, no view subclass, no state plumbing + +built on a discord.py ``ui.View`` that internally ``await view.wait()``s and resolves to the +selected value. the caller never touches discord's interaction machinery: ``confirm`` returns +a bool (or None on timeout), ``choose`` returns the mapped value (or None). both scope to a +user, disable their components after resolve/timeout, and fail loud on send/edit errors while +treating a timeout as a normal None return. +""" +from __future__ import annotations + +import logging +from typing import Any, Optional, Union + +import discord + +log = logging.getLogger(__name__) + +_MAX_BUTTONS = 5 +_NOT_FOR_YOU = "this prompt isn't for you" + +Destination = Union[discord.abc.Messageable, discord.Interaction] +EmojiInput = Union[str, discord.Emoji, discord.PartialEmoji] + + +def _coerce_emoji(value: "Optional[EmojiInput]") -> "Optional[Union[str, discord.PartialEmoji]]": + """accept a unicode emoji, a custom-emoji string '<:name:id>', or a discord Emoji/ + PartialEmoji, and return what a component emoji= field accepts""" + if value is None: + return None + if isinstance(value, (discord.Emoji, discord.PartialEmoji)): + return value + if isinstance(value, str): + if value.startswith("<") and value.endswith(">"): + return discord.PartialEmoji.from_str(value) + return value + raise ValueError(f"unsupported emoji input: {type(value).__name__}") + + +def _infer_user(destination: Destination, user: "Optional[discord.abc.User]") -> "Optional[discord.abc.User]": + """derive the invoker to scope the prompt to, from an explicit user, an Interaction, or a + ctx-like destination; None means anyone may click""" + if user is not None: + return user + if isinstance(destination, discord.Interaction): + return destination.user + author = getattr(destination, "author", None) + return author + + +class _ValueButton(discord.ui.Button): + """a button that resolves the parent prompt to its bound value on click""" + + def __init__(self, view: "_PromptView", value: Any, *, label=None, emoji=None, style=discord.ButtonStyle.secondary): + super().__init__(label=label, emoji=emoji, style=style) + self._prompt = view + self._value = value + + async def callback(self, interaction: discord.Interaction) -> None: + """resolve the prompt if the clicker is allowed, else tell them it isn't theirs""" + if not await self._prompt._allowed(interaction): + return + await interaction.response.defer() + self._prompt._resolve(self._value) + + +class _ValueSelect(discord.ui.Select): + """a select whose chosen option resolves the parent prompt to a mapped value""" + + def __init__(self, view: "_PromptView", mapping: "dict[str, Any]", placeholder: str): + super().__init__(placeholder=placeholder, min_values=1, max_values=1) + self._prompt = view + self._mapping = mapping + + async def callback(self, interaction: discord.Interaction) -> None: + """resolve to the mapped value of the selected option""" + if not await self._prompt._allowed(interaction): + return + await interaction.response.defer() + self._prompt._resolve(self._mapping[self.values[0]]) + + +class _PromptView(discord.ui.View): + """view backing an await-prompt: captures the resolved value and stops on first valid + interaction or timeout""" + + def __init__(self, *, user: "Optional[discord.abc.User]", timeout: float): + super().__init__(timeout=timeout) + self._user = user + self.result: Any = None + + async def _allowed(self, interaction: discord.Interaction) -> bool: + """true when the clicker may resolve the prompt; otherwise send an ephemeral notice""" + if self._user is not None and interaction.user.id != self._user.id: + await interaction.response.send_message(_NOT_FOR_YOU, ephemeral=True) + return False + return True + + def _resolve(self, value: Any) -> None: + """record the result and stop waiting""" + self.result = value + self.stop() + + +async def _send_prompt(destination: Destination, content: str, view: discord.ui.View) -> discord.Message: + """send the prompt to a Messageable or via an Interaction, returning the sent message""" + if isinstance(destination, discord.Interaction): + if destination.response.is_done(): + return await destination.followup.send(content, view=view, wait=True) + await destination.response.send_message(content, view=view) + return await destination.original_response() + return await destination.send(content, view=view) + + +async def _finish(message: discord.Message, view: _PromptView, cleanup: bool) -> None: + """disable the components and edit the message, or delete it when cleanup is set; edit/ + delete failures on an already-gone message are not errors""" + if cleanup: + try: + await message.delete() + except discord.NotFound: + pass + return + for child in view.children: + child.disabled = True + try: + await message.edit(view=view) + except discord.NotFound: + pass + + +async def confirm( + destination: Destination, + prompt: str, + *, + user: "Optional[discord.abc.User]" = None, + timeout: float = 60.0, + yes: "EmojiInput" = "✅", + no: "EmojiInput" = "❌", + cleanup: bool = False, +) -> "Optional[bool]": + """send prompt with a yes and a no button, await the click, and return True / False / + None(timeout); scopes to user, disables the buttons after, and fails loud on send/edit""" + scoped = _infer_user(destination, user) + view = _PromptView(user=scoped, timeout=timeout) + view.add_item(_ValueButton(view, True, emoji=_coerce_emoji(yes), style=discord.ButtonStyle.success)) + view.add_item(_ValueButton(view, False, emoji=_coerce_emoji(no), style=discord.ButtonStyle.danger)) + + message = await _send_prompt(destination, prompt, view) + await view.wait() + await _finish(message, view, cleanup) + return view.result + + +async def choose( + destination: Destination, + prompt: str, + options: "dict[Any, Any]", + *, + user: "Optional[discord.abc.User]" = None, + timeout: float = 60.0, + cleanup: bool = False, +) -> "Optional[Any]": + """render a button per option (or a select dropdown for >5 options or long labels), await + the click, and return the chosen option's VALUE or None(timeout); options maps an emoji + (unicode / '<:name:id>' / Emoji) or a label to a return value""" + if not options: + raise ValueError("choose requires at least one option") + + scoped = _infer_user(destination, user) + view = _PromptView(user=scoped, timeout=timeout) + keys = list(options.keys()) + use_select = len(keys) > _MAX_BUTTONS or any(_is_long_label(k) for k in keys) + + if use_select: + mapping: "dict[str, Any]" = {} + select = _ValueSelect(view, mapping, placeholder=prompt[:100]) + for i, key in enumerate(keys): + token = str(i) + mapping[token] = options[key] + label, emoji = _split_key(key) + select.add_option(label=label, value=token, emoji=emoji) + view.add_item(select) + else: + for key in keys: + label, emoji = _split_key(key) + view.add_item(_ValueButton(view, options[key], label=label if not emoji else None, emoji=emoji)) + + message = await _send_prompt(destination, prompt, view) + await view.wait() + await _finish(message, view, cleanup) + return view.result + + +def _is_long_label(key: Any) -> bool: + """true when a plain-text label is too long for a button (>80 chars)""" + if isinstance(key, str) and not (key.startswith("<") and key.endswith(">")): + return len(key) > 80 + return False + + +def _split_key(key: Any) -> "tuple[Optional[str], Optional[Union[str, discord.PartialEmoji]]]": + """turn an options key into (label, emoji): an emoji-looking key becomes the emoji with no + label, everything else is a text label""" + if isinstance(key, (discord.Emoji, discord.PartialEmoji)): + return None, key + if isinstance(key, str): + if key.startswith("<") and key.endswith(">"): + return None, discord.PartialEmoji.from_str(key) + if _looks_unicode_emoji(key): + return None, key + return key, None + return str(key), None + + +def _looks_unicode_emoji(value: str) -> bool: + """heuristic: a short non-ascii token is treated as a unicode emoji key""" + return bool(value) and len(value) <= 4 and not value.isascii() diff --git a/src/dpy_commons/send.py b/src/dpy_commons/send.py new file mode 100644 index 0000000..aa1cbf3 --- /dev/null +++ b/src/dpy_commons/send.py @@ -0,0 +1,55 @@ +"""limit-safe send that composes the text + embed helpers: chunk the content across +messages, fit every embed and split into <=10 groups, and send as many messages as needed""" +from __future__ import annotations + +from typing import Optional + +import discord + +from .embeds import fit_embed, split_embeds +from .text import chunk_text + + +async def safe_send( + destination: discord.abc.Messageable, + content: "Optional[str]" = None, + *, + embeds: "Optional[list[discord.Embed]]" = None, + **kwargs, +) -> "list[discord.Message]": + """send that never fails on a discord limit: chunk_text the content across messages, + fit_embed every embed and split_embeds into <=10 groups, and send as many messages as + needed; returns the sent Messages, fails loud on perms/HTTP errors (only limit-handling is + automatic) + + extra kwargs ride only on the FIRST message so per-send options (files, reference, etc.) + aren't duplicated across the split""" + content_chunks = chunk_text(content) if content else [] + embed_groups = split_embeds([fit_embed(embed) for embed in embeds]) if embeds else [] + + messages: "list[discord.Message]" = [] + first = True + + for i, chunk in enumerate(content_chunks): + # attach the first embed group to the last content message so a single content+embeds + # call collapses to one message when it fits + group = embed_groups.pop(0) if (i == len(content_chunks) - 1 and embed_groups) else None + messages.append(await _send(destination, chunk, group, first, kwargs)) + first = False + + for group in embed_groups: + messages.append(await _send(destination, None, group, first, kwargs)) + first = False + + if not messages: + messages.append(await _send(destination, content or "", None, True, kwargs)) + + return messages + + +async def _send(destination, content, embeds, first, kwargs) -> discord.Message: + """send one message; first-message kwargs are applied once then dropped""" + extra = dict(kwargs) if first else {} + if embeds: + extra["embeds"] = embeds + return await destination.send(content=content, **extra) diff --git a/src/dpy_commons/text.py b/src/dpy_commons/text.py new file mode 100644 index 0000000..1b56e79 --- /dev/null +++ b/src/dpy_commons/text.py @@ -0,0 +1,124 @@ +"""text / formatting helpers: chunking over the message cap, monospace tables, discord +dynamic timestamps, and human-readable durations, plus the shared link-wrap / truncate +primitives the embed helpers reuse""" +from __future__ import annotations + +import re +from datetime import datetime, timezone +from typing import Optional, Sequence + +from .limits import MSG_LIMIT + +_ELLIPSIS = "…" +_TIMESTAMP_STYLES = ("t", "T", "d", "D", "f", "F", "R") + +_URL_RE = re.compile(r"https?://[^\s<>]+", re.IGNORECASE) +_DISCORD_HOST_RE = re.compile( + r"^https?://(?:[a-z0-9-]+\.)*discord(?:app)?\.(?:com|gg|media)\b", re.IGNORECASE +) + + +def truncate(text: str, limit: int) -> str: + """return text clipped to limit chars, appending an ellipsis where it cut""" + if len(text) <= limit: + return text + if limit <= 1: + return _ELLIPSIS[:limit] + return text[: limit - 1] + _ELLIPSIS + + +def wrap_bare_links(text: str) -> str: + """wrap non-discord http(s) links in <> so discord does not unfurl them; discord links + (which the client renders specially) are left bare, and links already inside <> are not + double-wrapped""" + if not text: + return text + + def repl(match: "re.Match[str]") -> str: + url = match.group(0) + start = match.start() + if start > 0 and text[start - 1] == "<": + return url + if _DISCORD_HOST_RE.match(url): + return url + return f"<{url}>" + + return _URL_RE.sub(repl, text) + + +def chunk_text(text: str, limit: int = MSG_LIMIT) -> list[str]: + """split text into pieces each <= limit, breaking on newline boundaries where possible, + then word boundaries, only mid-word as a last resort; no content is lost""" + if limit < 1: + raise ValueError(f"limit must be >= 1, got {limit}") + if len(text) <= limit: + return [text] if text else [] + + chunks: list[str] = [] + remaining = text + while len(remaining) > limit: + window = remaining[:limit] + split = window.rfind("\n") + if split <= 0: + split = window.rfind(" ") + if split <= 0: + split = limit + chunks.append(remaining[:split]) + remaining = remaining[split:] + if remaining.startswith(("\n", " ")): + remaining = remaining[1:] + if remaining: + chunks.append(remaining) + return chunks + + +def format_table(rows: "Sequence[Sequence]", headers: "Optional[Sequence]" = None) -> str: + """render rows as an aligned monospace table wrapped in a code block, column widths + auto-sized; raises ValueError on ragged rows""" + matrix = [[str(cell) for cell in row] for row in rows] + width = len(headers) if headers is not None else (len(matrix[0]) if matrix else 0) + for i, row in enumerate(matrix): + if len(row) != width: + raise ValueError(f"row {i} has {len(row)} cells, expected {width}") + + head = [str(h) for h in headers] if headers is not None else None + widths = [0] * width + for row in ([head] if head else []) + matrix: + for c, cell in enumerate(row): + widths[c] = max(widths[c], len(cell)) + + def render(row: "list[str]") -> str: + return " ".join(cell.ljust(widths[c]) for c, cell in enumerate(row)).rstrip() + + lines = [] + if head: + lines.append(render(head)) + lines.append(" ".join("-" * widths[c] for c in range(width))) + lines.extend(render(row) for row in matrix) + return "```\n" + "\n".join(lines) + "\n```" + + +def discord_timestamp(dt: datetime, style: str = "f") -> str: + """return discord's dynamic timestamp markup for a datetime; raises + ValueError on an invalid style (one of t/T/d/D/f/F/R)""" + if style not in _TIMESTAMP_STYLES: + raise ValueError(f"invalid timestamp style '{style}'; expected one of {_TIMESTAMP_STYLES}") + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return f"" + + +def humanize_delta(seconds: float) -> str: + """human-readable duration ('2h 5m', '3d 4h') from a seconds count; shows the two most + significant non-zero units, or '0s' for zero""" + total = int(abs(seconds)) + sign = "-" if seconds < 0 else "" + units = (("d", 86400), ("h", 3600), ("m", 60), ("s", 1)) + parts = [] + for label, size in units: + if total >= size: + value, total = divmod(total, size) + parts.append(f"{value}{label}") + if not parts: + return "0s" + return sign + " ".join(parts[:2])