From cb9db844ebb7639d0328d50dc19168edb70fc994 Mon Sep 17 00:00:00 2001 From: Misode Date: Fri, 17 Jan 2025 01:26:39 +0100 Subject: [PATCH 01/14] Initial working mcdoc command --- commanderbot/ext/mcdoc/__init__.py | 8 + commanderbot/ext/mcdoc/mcdoc_cog.py | 69 +++ commanderbot/ext/mcdoc/mcdoc_symbols.py | 45 ++ commanderbot/ext/mcdoc/mcdoc_types.py | 570 ++++++++++++++++++++++++ 4 files changed, 692 insertions(+) create mode 100644 commanderbot/ext/mcdoc/__init__.py create mode 100644 commanderbot/ext/mcdoc/mcdoc_cog.py create mode 100644 commanderbot/ext/mcdoc/mcdoc_symbols.py create mode 100644 commanderbot/ext/mcdoc/mcdoc_types.py diff --git a/commanderbot/ext/mcdoc/__init__.py b/commanderbot/ext/mcdoc/__init__.py new file mode 100644 index 0000000..f05d5bb --- /dev/null +++ b/commanderbot/ext/mcdoc/__init__.py @@ -0,0 +1,8 @@ +from discord.ext.commands import Bot + +from commanderbot.core.utils import is_commander_bot +from commanderbot.ext.mcdoc.mcdoc_cog import McdocCog + + +async def setup(bot: Bot): + await bot.add_cog(McdocCog(bot)) diff --git a/commanderbot/ext/mcdoc/mcdoc_cog.py b/commanderbot/ext/mcdoc/mcdoc_cog.py new file mode 100644 index 0000000..338513a --- /dev/null +++ b/commanderbot/ext/mcdoc/mcdoc_cog.py @@ -0,0 +1,69 @@ +from logging import Logger, getLogger +from typing import Optional + +import aiohttp + +from discord import Embed, Interaction +from discord.app_commands import ( + allowed_contexts, + allowed_installs, + command, + describe, +) +from discord.ext.commands import Bot, Cog + +from commanderbot.ext.mcdoc.mcdoc_symbols import McdocSymbol, McdocSymbols +from commanderbot.ext.mcdoc.mcdoc_types import McdocContext +from commanderbot.lib import constants + +MCDOC_SYMBOLS_URL = "https://api.spyglassmc.com/vanilla-mcdoc/symbols" +SPYGLASS_ICON_URL = "https://avatars.githubusercontent.com/u/74945225?s=64" + + +class McdocCog(Cog, name="commanderbot.ext.mcdoc"): + def __init__(self, bot: Bot): + self.bot: Bot = bot + self.log: Logger = getLogger(self.qualified_name) + self.symbols: Optional[McdocSymbols] = None + + async def _request_symbols(self) -> McdocSymbols: + headers: dict[str, str] = {"User-Agent": constants.USER_AGENT} + async with aiohttp.ClientSession() as session: + async with session.get( + MCDOC_SYMBOLS_URL, headers=headers, raise_for_status=True + ) as response: + data: dict = await response.json() + return McdocSymbols(data) + + @command(name="mcdoc", description="Query vanilla mcdoc types") + @describe( + query="The mcdoc identifier", + version="The Minecraft game version (defaults to the latest release)", + ) + @allowed_installs(guilds=True, users=True) + @allowed_contexts(guilds=True, dms=True, private_channels=True) + async def cmd_mcdoc(self, interaction: Interaction, query: str, version: Optional[str]): + # Respond to the interaction with a defer since the web request may take a while + await interaction.response.defer() + + # TODO: refresh this every so often + if self.symbols is None: + self.symbols = await self._request_symbols() + + symbol: Optional[McdocSymbol] = self.symbols.search(query) + + if not symbol: + await interaction.followup.send("Symbol not found") + return + + # TODO: un-hardcode the latest release version + ctx = McdocContext(version or "1.21.4", self.symbols.get) + + embed: Embed = Embed( + title=symbol.identifier.split("::")[-1], + description=symbol.typeDef.render(ctx), + color=0x00ACED, + ) + embed.set_footer(text=f"vanilla-mcdoc 路 {ctx.version}", icon_url=SPYGLASS_ICON_URL) + + await interaction.followup.send(embed=embed) diff --git a/commanderbot/ext/mcdoc/mcdoc_symbols.py b/commanderbot/ext/mcdoc/mcdoc_symbols.py new file mode 100644 index 0000000..f54e204 --- /dev/null +++ b/commanderbot/ext/mcdoc/mcdoc_symbols.py @@ -0,0 +1,45 @@ +from collections import defaultdict +from dataclasses import dataclass +from typing import Optional + +from commanderbot.ext.mcdoc.mcdoc_types import McdocType, deserialize_mcdoc + +@dataclass +class McdocSymbol: + identifier: str + typeDef: McdocType + + +class McdocSymbols: + def __init__(self, data: dict): + self.symbols = { + str(key): deserialize_mcdoc(typeDef) + for key, typeDef in data.get("mcdoc", {}).items() + } + self.dispatchers = { + str(registry): { + str(key): deserialize_mcdoc(typeDef) + for key, typeDef in members.items() + } + for registry, members in data.get("mcdoc/dispatcher", {}).items() + } + + self.names = defaultdict[str, list[str]](list) + for key in self.symbols: + name = key.split("::")[-1] + self.names[name].append(key) + self.names = dict(self.names) + + def search(self, query: str) -> Optional[McdocSymbol]: + if query in self.symbols: + return McdocSymbol(query, self.symbols[query]) + + if query in self.names: + identifier = self.names[query][0] + return McdocSymbol(identifier, self.symbols[identifier]) + + # TODO: search dispatchers + return None + + def get(self, path: str) -> Optional[McdocType]: + return self.symbols.get(path, None) diff --git a/commanderbot/ext/mcdoc/mcdoc_types.py b/commanderbot/ext/mcdoc/mcdoc_types.py new file mode 100644 index 0000000..5679eba --- /dev/null +++ b/commanderbot/ext/mcdoc/mcdoc_types.py @@ -0,0 +1,570 @@ +import json +from dataclasses import dataclass +from typing import Union, Optional, Any, Callable + +ICON_ANY = "<:any:1328878339305246761>" +ICON_BOOLEAN = "<:boolean:1328844824824254475>" +ICON_BYTE = "<:byte:1328844842469425264>" +ICON_BYTE_ARRAY = "<:byte_array:1328844856713412758>" +ICON_DOUBLE = "<:double:1328844873205547028>" +ICON_FLOAT = "<:float:1328844885276622858>" +ICON_INT = "<:int:1328844896903237634>" +ICON_INT_ARRAY = "<:int_array:1328844908898812004>" +ICON_LIST = "<:list:1328844919665856622>" +ICON_LONG = "<:long:1328844930998730812>" +ICON_LONG_ARRAY = "<:long_array:1328844941706793022>" +ICON_SHORT = "<:short:1328844953757028382>" +ICON_STRING = "<:string:1328844965161467956>" +ICON_STRUCT = "<:struct:1328844974661435546>" + +LITERAL_ICONS = { + "boolean": ICON_BOOLEAN, + "byte": ICON_BYTE, + "short": ICON_SHORT, + "int": ICON_INT, + "long": ICON_LONG, + "float": ICON_FLOAT, + "double": ICON_DOUBLE, + "string": ICON_STRING, +} + + +def cmp_version(a: str, b: str): + return float(a[2:]) - float(b[2:]) + + +class McdocContext: + def __init__(self, version: str, lookup: Callable[[str], Optional["McdocType"]]): + self.version = version + self.lookup = lookup + + def symbol(self, path: str): + return self.lookup(path) + + def filter(self, attributes: Optional[list["Attribute"]]): + if not attributes: + return True + since = next((a.value for a in attributes if a.name == "since"), None) + if since and cmp_version(self.version, since["value"]["value"]) < 0: + return False + until = next((a.value for a in attributes if a.name == "until"), None) + if until and cmp_version(self.version, until["value"]["value"]) >= 0: + return False + return True + + +@dataclass +class Attribute: + name: str + value: Optional[dict] + + +@dataclass(kw_only=True) +class McdocBaseType: + attributes: Optional[list[Attribute]] = None + + def icons(self, ctx: McdocContext) -> list[str]: + return [ICON_ANY] + + def prefix(self, ctx: McdocContext) -> str: + return "".join(set(self.icons(ctx))) + + def suffix(self, ctx: McdocContext) -> str: + return "" + + def body(self, ctx: McdocContext) -> str: + return "" + + def render(self, ctx: McdocContext) -> str: + result = self.prefix(ctx) + suffix = self.suffix(ctx) + if suffix: + result += f" {suffix}" if result else suffix + body = self.body(ctx) + if body: + result += f"\n{body}" if result else body + return result + + +@dataclass +class KeywordIndex: + keyword: str + + +@dataclass +class DynamicIndex: + accessor: list[str | KeywordIndex] + + +@dataclass +class StaticIndex: + value: str + + +@dataclass +class DispatcherType(McdocBaseType): + registry: str + parallelIndices: list[DynamicIndex | StaticIndex] + + def suffix(self, ctx): + return f"{self.registry}[{self.parallelIndices}]" + + +@dataclass +class StructTypePairField: + key: Union[str, "McdocType"] + type: "McdocType" + optional: bool = False + deprecated: bool = False + desc: Optional[str] = None + attributes: Optional[list[Attribute]] = None + + def is_deprecated(self, ctx: McdocContext): + if self.deprecated: + return True + deprecated = next((a.value for a in self.attributes or [] if a.name == "deprecated"), None) + if deprecated: + return cmp_version(ctx.version, deprecated["value"]["value"]) >= 0 + return False + + def render(self, ctx: McdocContext): + if isinstance(self.key, str): + key = self.key + elif isinstance(self.key, LiteralType): + key = str(self.key.value) + else: + return "" + key = f"`{key}`" + if self.is_deprecated(ctx): + key = f"~~{key}~~" + result = self.type.prefix(ctx) + result += f" {key}" if result else key + suffix = self.type.suffix(ctx) + if suffix: + result += f"?: {suffix}" if self.optional else f": {suffix}" + elif self.optional: + result += "?" + desc = self.desc.strip() if self.desc else "" + if desc: + result += "".join(f"\n-# {d.strip()}" for d in desc.split("\n") if d.strip()) + body = self.type.body(ctx) + if body: + result += f"\n{body}" + return result + + +@dataclass +class StructTypeSpreadField: + type: "McdocType" + attributes: Optional[list[Attribute]] = None + + def render(self, ctx: McdocContext): + match self.type: + case StructType(): + return self.type.body_flat(ctx) + case DispatcherType(parallelIndices=[DynamicIndex(accessor=[str(s)])]): + return f"*more fields depending on the value of `{s}`*" + + suffix = self.type.suffix(ctx) + if suffix: + return f"*all fields from {suffix}*" + + return "" + + +@dataclass +class StructType(McdocBaseType): + fields: list[StructTypePairField | StructTypeSpreadField] + + def icons(self, ctx): + return [ICON_STRUCT] + + def body_flat(self, ctx: McdocContext): + results = [] + for field in self.fields: + if ctx.filter(field.attributes): + result = field.render(ctx) + if result: + results.append(result) + if not results: + return "*no fields*" + return "\n\n".join(results) + + def body(self, ctx): + lines = self.body_flat(ctx) + return "".join(f"\n> {line}" for line in lines.split("\n")) + + def render(self, ctx): + return self.body_flat(ctx) + +@dataclass +class EnumTypeField: + identifier: str + value: str | float + desc: Optional[str] = None + attributes: Optional[list[Attribute]] = None + +@dataclass +class EnumType(McdocBaseType): + enumKind: str + values: list[EnumTypeField] + + def icons(self, ctx): + return [LITERAL_ICONS[self.enumKind]] + + def body_flat(self, ctx: McdocContext): + results = [] + for field in self.values: + if ctx.filter(field.attributes): + result = self.prefix(ctx) + result += f" `{field.identifier}`" + result += f" = {field.value}" + desc = field.desc.strip() if field.desc else "" + if desc: + result += "".join(f"\n-# {d.strip()}" for d in desc.split("\n") if d.strip()) + results.append(result) + if not results: + return "*no options*" + return "\n".join(results) + + def body(self, ctx): + lines = self.body_flat(ctx) + return "\n".join(f"> {line}" for line in lines.split("\n")) + + def render(self, ctx): + return self.body_flat(ctx) + + +@dataclass +class ReferenceType(McdocBaseType): + path: str + + def suffix(self, ctx): + return self.path.split("::")[-1] + + +@dataclass +class UnionType(McdocBaseType): + members: list["McdocType"] + + def filtered_members(self, ctx: McdocContext): + return [m for m in self.members if ctx.filter(m.attributes)] + + def icons(self, ctx): + all_icons: list[str] = [] + for member in self.filtered_members(ctx): + all_icons.extend(member.icons(ctx)) + return list(set(all_icons)) + + +@dataclass +class IndexedType(McdocBaseType): + parallelIndices: list[Any] + child: "McdocType" + + +@dataclass +class TemplateType(McdocBaseType): + child: "McdocType" + typeParams: list[dict[str, str]] + + +@dataclass +class ConcreteType(McdocBaseType): + child: "McdocType" + typeArgs: list["McdocType"] + + +@dataclass +class MappedType(McdocBaseType): + child: "McdocType" + mapping: dict[str, "McdocType"] + + +@dataclass +class StringType(McdocBaseType): + lengthRange: Optional[Any] = None + + def icons(self, ctx): + return [ICON_STRING] + + +@dataclass +class LiteralType(McdocBaseType): + kind: str + value: bool | str | float + + def icons(self, ctx): + return [LITERAL_ICONS.get(self.kind, ICON_ANY)] + + def suffix(self, ctx): + return json.dumps(self.value) + + +@dataclass +class AnyType(McdocBaseType): + def icons(self, ctx): + return [ICON_ANY] + + +@dataclass +class UnsafeType(McdocBaseType): + def icons(self, ctx): + return [ICON_ANY] + + +@dataclass +class BooleanType(McdocBaseType): + def icons(self, ctx): + return [ICON_BOOLEAN] + + +@dataclass +class ByteType(McdocBaseType): + valueRange: Optional[Any] = None + + def icons(self, ctx): + return [ICON_BYTE] + + +@dataclass +class ShortType(McdocBaseType): + valueRange: Optional[Any] = None + + def icons(self, ctx): + return [ICON_SHORT] + + +@dataclass +class IntType(McdocBaseType): + valueRange: Optional[Any] = None + + def icons(self, ctx): + return [ICON_INT] + + +@dataclass +class LongType(McdocBaseType): + valueRange: Optional[Any] = None + + def icons(self, ctx): + return [ICON_LONG] + + +@dataclass +class FloatType(McdocBaseType): + valueRange: Optional[Any] = None + + def icons(self, ctx): + return [ICON_FLOAT] + + +@dataclass +class DoubleType(McdocBaseType): + valueRange: Optional[Any] = None + + def icons(self, ctx): + return [ICON_DOUBLE] + + +@dataclass +class ByteArrayType(McdocBaseType): + valueRange: Optional[Any] = None + lengthRange: Optional[Any] = None + + def icons(self, ctx): + return [ICON_BYTE_ARRAY] + + +@dataclass +class IntArrayType(McdocBaseType): + valueRange: Optional[Any] = None + lengthRange: Optional[Any] = None + + def icons(self, ctx): + return [ICON_INT_ARRAY] + + +@dataclass +class LongArrayType(McdocBaseType): + valueRange: Optional[Any] = None + lengthRange: Optional[Any] = None + + def icons(self, ctx): + return [ICON_LONG_ARRAY] + + +@dataclass +class ListType(McdocBaseType): + item: "McdocType" + lengthRange: Optional[Any] = None + + def icons(self, ctx): + return [ICON_LIST] + + +@dataclass +class TupleType(McdocBaseType): + items: list["McdocType"] + + def icons(self, ctx): + return [ICON_LIST] + + +McdocType = Union[ + DispatcherType, + EnumType, + ListType, + LiteralType, + AnyType, + UnsafeType, + BooleanType, + ByteType, + ShortType, + IntType, + LongType, + FloatType, + DoubleType, + ByteArrayType, + IntArrayType, + LongArrayType, + ReferenceType, + StringType, + StructType, + TupleType, + UnionType, + IndexedType, + TemplateType, + ConcreteType, + MappedType +] + + +def deserialize_attributes(data: dict) -> list[Attribute]: + result: list[Attribute] = [] + for attr in data.get("attributes", []): + name = attr["name"] + value = attr.get("value", None) + result.append(Attribute(name=name,value=value)) + return result + + +def deserialize_mcdoc(data: dict) -> McdocType: + kind = data.get("kind") + + if kind == "dispatcher": + parallelIndices: list[DynamicIndex | StaticIndex] = [] + for index in data.get("parallelIndices", []): + if index["kind"] == "dynamic": + accessor: list[str | KeywordIndex] = [] + for part in index["accessor"]: + if isinstance(part, str): + accessor.append(part) + else: + accessor.append(KeywordIndex(part["keyword"])) + parallelIndices.append(DynamicIndex(accessor=accessor)) + elif index["kind"] == "static": + parallelIndices.append(StaticIndex(value=index["value"])) + return DispatcherType( + registry=data.get("registry", ""), + parallelIndices=parallelIndices, + attributes=deserialize_attributes(data), + ) + if kind == "struct": + fields = [] + for f in data.get("fields", []): + if "key" in f: + key = f["key"] + if isinstance(key, dict): + key = deserialize_mcdoc(key) + fields.append(StructTypePairField( + key=key, + type=deserialize_mcdoc(f["type"]), + optional=f.get("optional", False), + deprecated=f.get("deprecated", False), + desc=f.get("desc"), + attributes=deserialize_attributes(f), + )) + else: + fields.append(StructTypeSpreadField( + type=deserialize_mcdoc(f.get("type")), + attributes=deserialize_attributes(f), + )) + return StructType(fields, attributes=deserialize_attributes(data)) + if kind == "enum": + values = [] + for v in data.get("values", []): + values.append(EnumTypeField( + identifier=v["identifier"], + value=v["value"], + desc=v.get("desc"), + attributes=deserialize_attributes(v), + )) + return EnumType( + enumKind=data["enumKind"], + values=values, + attributes=deserialize_attributes(data), + ) + if kind == "literal": + return LiteralType( + kind=data["value"]["kind"], + value=data["value"]["value"], + attributes=deserialize_attributes(data) + ) + if kind == "any": + return AnyType(attributes=deserialize_attributes(data)) + if kind == "unsafe": + return AnyType(attributes=deserialize_attributes(data)) + if kind == "boolean": + return BooleanType(attributes=deserialize_attributes(data)) + if kind == "byte": + return ByteType(valueRange=data.get("valueRange"), attributes=deserialize_attributes(data)) + if kind == "short": + return ShortType(valueRange=data.get("valueRange"), attributes=deserialize_attributes(data)) + if kind == "int": + return IntType(valueRange=data.get("valueRange"), attributes=deserialize_attributes(data)) + if kind == "long": + return LongType(valueRange=data.get("valueRange"), attributes=deserialize_attributes(data)) + if kind == "float": + return FloatType(valueRange=data.get("valueRange"), attributes=deserialize_attributes(data)) + if kind == "double": + return DoubleType(valueRange=data.get("valueRange"), attributes=deserialize_attributes(data)) + if kind == "reference": + return ReferenceType(data.get("path", ""), attributes=deserialize_attributes(data)) + if kind == "union": + return UnionType( + members=[deserialize_mcdoc(member) for member in data.get("members", [])], + attributes=deserialize_attributes(data), + ) + if kind == "string": + return StringType(lengthRange=data.get("lengthRange"), attributes=deserialize_attributes(data)) + if kind == "list": + return ListType( + item=deserialize_mcdoc(data["item"]), + lengthRange=data.get("lengthRange"), + attributes=deserialize_attributes(data), + ) + if kind == "tuple": + return TupleType( + items=[deserialize_mcdoc(e) for e in data["items"]], + attributes=deserialize_attributes(data), + ) + if kind == "byte_array": + return ByteArrayType(attributes=deserialize_attributes(data)) # TODO + if kind == "int_array": + return IntArrayType(attributes=deserialize_attributes(data)) # TODO + if kind == "long_array": + return LongArrayType(attributes=deserialize_attributes(data)) # TODO + if kind == "template": + return TemplateType( + child=deserialize_mcdoc(data["child"]), + typeParams=[], # TODO + attributes=deserialize_attributes(data), + ) + if kind == "concrete": + return ConcreteType( + child=deserialize_mcdoc(data["child"]), + typeArgs=[], # TODO + attributes=deserialize_attributes(data), + ) + raise ValueError(f"Unknown kind: {kind}") From a88d901612827224be392af6e0235bafc5565a11 Mon Sep 17 00:00:00 2001 From: Misode Date: Sat, 18 Jan 2025 03:20:44 +0100 Subject: [PATCH 02/14] Improve type suffixes, numeric range, basic resource location --- commanderbot/ext/mcdoc/mcdoc_cog.py | 6 +- commanderbot/ext/mcdoc/mcdoc_types.py | 393 ++++++++++++++++++++++---- 2 files changed, 336 insertions(+), 63 deletions(-) diff --git a/commanderbot/ext/mcdoc/mcdoc_cog.py b/commanderbot/ext/mcdoc/mcdoc_cog.py index 338513a..17fb942 100644 --- a/commanderbot/ext/mcdoc/mcdoc_cog.py +++ b/commanderbot/ext/mcdoc/mcdoc_cog.py @@ -14,7 +14,7 @@ from commanderbot.ext.mcdoc.mcdoc_symbols import McdocSymbol, McdocSymbols from commanderbot.ext.mcdoc.mcdoc_types import McdocContext -from commanderbot.lib import constants +from commanderbot.lib import constants, AllowedMentions MCDOC_SYMBOLS_URL = "https://api.spyglassmc.com/vanilla-mcdoc/symbols" SPYGLASS_ICON_URL = "https://avatars.githubusercontent.com/u/74945225?s=64" @@ -62,8 +62,8 @@ async def cmd_mcdoc(self, interaction: Interaction, query: str, version: Optiona embed: Embed = Embed( title=symbol.identifier.split("::")[-1], description=symbol.typeDef.render(ctx), - color=0x00ACED, + color=0x2783E3, ) embed.set_footer(text=f"vanilla-mcdoc 路 {ctx.version}", icon_url=SPYGLASS_ICON_URL) - await interaction.followup.send(embed=embed) + await interaction.followup.send(embed=embed, allowed_mentions=AllowedMentions.none()) diff --git a/commanderbot/ext/mcdoc/mcdoc_types.py b/commanderbot/ext/mcdoc/mcdoc_types.py index 5679eba..3fa3885 100644 --- a/commanderbot/ext/mcdoc/mcdoc_types.py +++ b/commanderbot/ext/mcdoc/mcdoc_types.py @@ -34,9 +34,11 @@ def cmp_version(a: str, b: str): class McdocContext: - def __init__(self, version: str, lookup: Callable[[str], Optional["McdocType"]]): + def __init__(self, version: str, lookup: Callable[[str], Optional["McdocType"]], compact = False, depth = 0): self.version = version self.lookup = lookup + self.compact = compact + self.depth = depth def symbol(self, path: str): return self.lookup(path) @@ -51,6 +53,12 @@ def filter(self, attributes: Optional[list["Attribute"]]): if until and cmp_version(self.version, until["value"]["value"]) >= 0: return False return True + + def make_compact(self) -> "McdocContext": + return McdocContext(self.version, self.lookup, True, self.depth) + + def nested(self) -> "McdocContext": + return McdocContext(self.version, self.lookup, self.compact, self.depth + 1) @dataclass @@ -141,15 +149,16 @@ def render(self, ctx: McdocContext): result += f" {key}" if result else key suffix = self.type.suffix(ctx) if suffix: - result += f"?: {suffix}" if self.optional else f": {suffix}" + result += f"**?** {suffix}" if self.optional else f" {suffix}" elif self.optional: - result += "?" + result += "**?**" desc = self.desc.strip() if self.desc else "" if desc: result += "".join(f"\n-# {d.strip()}" for d in desc.split("\n") if d.strip()) - body = self.type.body(ctx) - if body: - result += f"\n{body}" + if ctx.depth < 1: + body = self.type.body(ctx.make_compact()) + if body: + result += f"\n{body}" return result @@ -176,23 +185,33 @@ def render(self, ctx: McdocContext): class StructType(McdocBaseType): fields: list[StructTypePairField | StructTypeSpreadField] + def filtered_fields(self, ctx: McdocContext): + return [f for f in self.fields if ctx.filter(f.attributes)] + def icons(self, ctx): return [ICON_STRUCT] + def suffix(self, ctx): + fields = self.filtered_fields(ctx) + if not fields: + return "*an empty object*" + return "*an object*" + def body_flat(self, ctx: McdocContext): results = [] - for field in self.fields: - if ctx.filter(field.attributes): - result = field.render(ctx) - if result: - results.append(result) + for field in self.filtered_fields(ctx): + result = field.render(ctx) + if result: + results.append(result) if not results: return "*no fields*" - return "\n\n".join(results) + joiner = "\n" if ctx.compact else "\n\n" + return joiner.join(results) def body(self, ctx): - lines = self.body_flat(ctx) - return "".join(f"\n> {line}" for line in lines.split("\n")) + lines = self.body_flat(ctx.nested()) + start = "" if ctx.compact else "\n" + return start + "\n".join(f"> {line}" for line in lines.split("\n")) def render(self, ctx): return self.body_flat(ctx) @@ -209,20 +228,32 @@ class EnumType(McdocBaseType): enumKind: str values: list[EnumTypeField] + def filtered_values(self, ctx: McdocContext): + return [v for v in self.values if ctx.filter(v.attributes)] + def icons(self, ctx): return [LITERAL_ICONS[self.enumKind]] + def suffix(self, ctx): + values = self.filtered_values(ctx) + if ctx.depth >= 1: + if not values: + return "*no options*" + return f"*one of: {', '.join(json.dumps(v.value) for v in values)}*" + return "*one of:*" + def body_flat(self, ctx: McdocContext): + if ctx.depth >= 1: + return "" results = [] - for field in self.values: - if ctx.filter(field.attributes): - result = self.prefix(ctx) - result += f" `{field.identifier}`" - result += f" = {field.value}" - desc = field.desc.strip() if field.desc else "" - if desc: - result += "".join(f"\n-# {d.strip()}" for d in desc.split("\n") if d.strip()) - results.append(result) + for field in self.filtered_values(ctx): + result = self.prefix(ctx) + result += f" `{field.identifier}`" + result += f" = {json.dumps(field.value)}" + desc = field.desc.strip() if field.desc else "" + if desc: + result += "".join(f"\n-# {d.strip()}" for d in desc.split("\n") if d.strip()) + results.append(result) if not results: return "*no options*" return "\n".join(results) @@ -239,8 +270,14 @@ def render(self, ctx): class ReferenceType(McdocBaseType): path: str + def icons(self, ctx): + typeDef = ctx.symbol(self.path) + if typeDef: + return typeDef.icons(ctx) + return super().icons(ctx) + def suffix(self, ctx): - return self.path.split("::")[-1] + return f"__{self.path.split('::')[-1]}__" @dataclass @@ -256,11 +293,36 @@ def icons(self, ctx): all_icons.extend(member.icons(ctx)) return list(set(all_icons)) + def suffix(self, ctx): + members = self.filtered_members(ctx) + if not members: + return "*nothing*" + if len(members) == 1: + return members[0].suffix(ctx) + if ctx.depth >= 1: + return f"*one of {len(members)} types*" + return "*one of:*" -@dataclass -class IndexedType(McdocBaseType): - parallelIndices: list[Any] - child: "McdocType" + def body(self, ctx): + if ctx.depth >= 1: + return "" + members = self.filtered_members(ctx) + if not members: + return "" + if len(members) == 1: + return members[0].body(ctx) + results: list[str] = [] + for member in members: + result = member.prefix(ctx) + suffix = member.suffix(ctx) + if suffix: + result += f" {suffix}" if result else suffix + if ctx.depth < 1: + body = member.body(ctx.make_compact().nested()) + if body: + result += f"\n{body}" if result else body + results.append(result) + return "\n".join(f"{'' if ctx.compact else '\n'}* {r}" for r in results) @dataclass @@ -268,26 +330,92 @@ class TemplateType(McdocBaseType): child: "McdocType" typeParams: list[dict[str, str]] + def icons(self, ctx): + return self.child.icons(ctx) + + def prefix(self, ctx): + return self.child.prefix(ctx) + + def suffix(self, ctx): + return self.child.suffix(ctx) + + def body(self, ctx): + return self.child.body(ctx) + + def render(self, ctx): + return self.child.render(ctx) + @dataclass class ConcreteType(McdocBaseType): child: "McdocType" typeArgs: list["McdocType"] + def icons(self, ctx): + return self.child.icons(ctx) + + def prefix(self, ctx): + return self.child.prefix(ctx) + + def suffix(self, ctx): + return self.child.suffix(ctx) + + def body(self, ctx): + return self.child.body(ctx) + + def render(self, ctx): + return self.child.render(ctx) + @dataclass -class MappedType(McdocBaseType): - child: "McdocType" - mapping: dict[str, "McdocType"] +class NumericRange(McdocBaseType): + min: Optional[float] + max: Optional[float] + minExcl: bool + maxExcl: bool + def render(self, ctx: McdocContext): + if self.min is None: + return f"below {self.max}" if self.maxExcl else f"at most {self.max}" + if self.max is None: + return f"above {self.min}" if self.minExcl else f"at least {self.min}" + if self.minExcl and self.maxExcl: + return f"between {self.min} and {self.max} (exclusive)" + if self.minExcl: + return f"above {self.min} and at most {self.max}" + if self.maxExcl: + return f"at least {self.min} and below {self.max}" + if self.min == self.max: + return f"exactly {self.min}" + return f"between {self.min} and {self.max} (inclusive)" @dataclass class StringType(McdocBaseType): - lengthRange: Optional[Any] = None + lengthRange: Optional[NumericRange] = None def icons(self, ctx): return [ICON_STRING] + def suffix(self, ctx): + result = "a string" + id = next((a.value for a in self.attributes or [] if a.name == "id"), None) + if id: + if id["kind"] == "literal": + registry = id["value"]["value"] + elif id["kind"] == "tree": + registry = id["values"]["registry"]["value"]["value"] # TODO + else: + registry = None + result = "a resource location" + if registry: + result += f" ({registry})" + regex_pattern = next((a.value for a in self.attributes or [] if a.name == "regex_pattern"), None) + if regex_pattern: + result = "a regex pattern" + if self.lengthRange: + result += f" with length {self.lengthRange.render(ctx)}" + return f"*{result}*" + @dataclass class LiteralType(McdocBaseType): @@ -296,7 +424,7 @@ class LiteralType(McdocBaseType): def icons(self, ctx): return [LITERAL_ICONS.get(self.kind, ICON_ANY)] - + def suffix(self, ctx): return json.dumps(self.value) @@ -306,102 +434,198 @@ class AnyType(McdocBaseType): def icons(self, ctx): return [ICON_ANY] + def suffix(self, ctx): + return "*anything*" + @dataclass class UnsafeType(McdocBaseType): def icons(self, ctx): return [ICON_ANY] + def suffix(self, ctx): + return "*anything*" + @dataclass class BooleanType(McdocBaseType): def icons(self, ctx): return [ICON_BOOLEAN] + def suffix(self, ctx): + return "*a boolean*" + @dataclass class ByteType(McdocBaseType): - valueRange: Optional[Any] = None + valueRange: Optional[NumericRange] = None def icons(self, ctx): return [ICON_BYTE] + def suffix(self, ctx): + result = "a byte" + if self.valueRange: + result += f" {self.valueRange.render(ctx)}" + return f"*{result}*" + @dataclass class ShortType(McdocBaseType): - valueRange: Optional[Any] = None + valueRange: Optional[NumericRange] = None def icons(self, ctx): return [ICON_SHORT] + def suffix(self, ctx): + result = "a short" + if self.valueRange: + result += f" {self.valueRange.render(ctx)}" + return f"*{result}*" + @dataclass class IntType(McdocBaseType): - valueRange: Optional[Any] = None + valueRange: Optional[NumericRange] = None def icons(self, ctx): return [ICON_INT] + def suffix(self, ctx): + result = "an int" + if self.valueRange: + result += f" {self.valueRange.render(ctx)}" + return f"*{result}*" + @dataclass class LongType(McdocBaseType): - valueRange: Optional[Any] = None + valueRange: Optional[NumericRange] = None def icons(self, ctx): return [ICON_LONG] + def suffix(self, ctx): + result = "a long" + if self.valueRange: + result += f" {self.valueRange.render(ctx)}" + return f"*{result}*" + @dataclass class FloatType(McdocBaseType): - valueRange: Optional[Any] = None + valueRange: Optional[NumericRange] = None def icons(self, ctx): return [ICON_FLOAT] + def suffix(self, ctx): + result = "a float" + if self.valueRange: + result += f" {self.valueRange.render(ctx)}" + return f"*{result}*" + @dataclass class DoubleType(McdocBaseType): - valueRange: Optional[Any] = None + valueRange: Optional[NumericRange] = None def icons(self, ctx): return [ICON_DOUBLE] + def suffix(self, ctx): + result = "a double" + if self.valueRange: + result += f" {self.valueRange.render(ctx)}" + return f"*{result}*" + @dataclass class ByteArrayType(McdocBaseType): - valueRange: Optional[Any] = None - lengthRange: Optional[Any] = None + valueRange: Optional[NumericRange] = None + lengthRange: Optional[NumericRange] = None def icons(self, ctx): return [ICON_BYTE_ARRAY] + def suffix(self, ctx): + result = "a byte array" + if self.lengthRange: + result += f" with length {self.lengthRange.render(ctx)}" + if self.valueRange: + if self.lengthRange: + result += ", and" + result += f" with values {self.valueRange.render(ctx)}" + return f"*{result}*" + @dataclass class IntArrayType(McdocBaseType): - valueRange: Optional[Any] = None - lengthRange: Optional[Any] = None + valueRange: Optional[NumericRange] = None + lengthRange: Optional[NumericRange] = None def icons(self, ctx): return [ICON_INT_ARRAY] + def suffix(self, ctx): + result = "an int array" + if self.lengthRange: + result += f" with length {self.lengthRange.render(ctx)}" + if self.valueRange: + if self.lengthRange: + result += ", and" + result += f" with values {self.valueRange.render(ctx)}" + return f"*{result}*" + @dataclass class LongArrayType(McdocBaseType): - valueRange: Optional[Any] = None - lengthRange: Optional[Any] = None + valueRange: Optional[NumericRange] = None + lengthRange: Optional[NumericRange] = None def icons(self, ctx): return [ICON_LONG_ARRAY] + def suffix(self, ctx): + result = "a long array" + if self.lengthRange: + result += f" with length {self.lengthRange.render(ctx)}" + if self.valueRange: + if self.lengthRange: + result += ", and" + result += f" with values {self.valueRange.render(ctx)}" + return f"*{result}*" + @dataclass class ListType(McdocBaseType): item: "McdocType" - lengthRange: Optional[Any] = None + lengthRange: Optional[NumericRange] = None def icons(self, ctx): return [ICON_LIST] + def suffix(self, ctx): + result = "a list" + if self.lengthRange: + if self.lengthRange.min == self.lengthRange.max: + result += f" of length {self.lengthRange.min}" + else: + result += f" with length {self.lengthRange.render(ctx)}" + else: + result += " of" + return f"*{result}:*" + + def body(self, ctx): + result = self.item.prefix(ctx) + suffix = self.item.suffix(ctx) + if suffix: + result += f" {suffix}" if result else suffix + body = self.item.body(ctx.make_compact().nested()) + if body: + result += f"\n{body}" if result else body + return "* " + result + @dataclass class TupleType(McdocBaseType): @@ -410,6 +634,9 @@ class TupleType(McdocBaseType): def icons(self, ctx): return [ICON_LIST] + def suffix(self, ctx): + return f"*a list of length {len(self.items)}:*" + McdocType = Union[ DispatcherType, @@ -433,10 +660,8 @@ def icons(self, ctx): StructType, TupleType, UnionType, - IndexedType, TemplateType, ConcreteType, - MappedType ] @@ -449,6 +674,18 @@ def deserialize_attributes(data: dict) -> list[Attribute]: return result +def deserialize_numeric_range(data: Optional[dict]) -> Optional[NumericRange]: + if data is None: + return None + kind = data.get("kind", 0) + return NumericRange( + min=data.get("min", None), + max=data.get("max", None), + minExcl=(kind & 0b10) != 0, + maxExcl=(kind & 0b01) != 0, + ) + + def deserialize_mcdoc(data: dict) -> McdocType: kind = data.get("kind") @@ -518,30 +755,54 @@ def deserialize_mcdoc(data: dict) -> McdocType: if kind == "boolean": return BooleanType(attributes=deserialize_attributes(data)) if kind == "byte": - return ByteType(valueRange=data.get("valueRange"), attributes=deserialize_attributes(data)) + return ByteType( + valueRange=deserialize_numeric_range(data.get("valueRange")), + attributes=deserialize_attributes(data), + ) if kind == "short": - return ShortType(valueRange=data.get("valueRange"), attributes=deserialize_attributes(data)) + return ShortType( + valueRange=deserialize_numeric_range(data.get("valueRange")), + attributes=deserialize_attributes(data), + ) if kind == "int": - return IntType(valueRange=data.get("valueRange"), attributes=deserialize_attributes(data)) + return IntType( + valueRange=deserialize_numeric_range(data.get("valueRange")), + attributes=deserialize_attributes(data), + ) if kind == "long": - return LongType(valueRange=data.get("valueRange"), attributes=deserialize_attributes(data)) + return LongType( + valueRange=deserialize_numeric_range(data.get("valueRange")), + attributes=deserialize_attributes(data), + ) if kind == "float": - return FloatType(valueRange=data.get("valueRange"), attributes=deserialize_attributes(data)) + return FloatType( + valueRange=deserialize_numeric_range(data.get("valueRange")), + attributes=deserialize_attributes(data), + ) if kind == "double": - return DoubleType(valueRange=data.get("valueRange"), attributes=deserialize_attributes(data)) + return DoubleType( + valueRange=deserialize_numeric_range(data.get("valueRange")), + attributes=deserialize_attributes(data), + ) if kind == "reference": - return ReferenceType(data.get("path", ""), attributes=deserialize_attributes(data)) + return ReferenceType( + path=data.get("path", ""), + attributes=deserialize_attributes(data), + ) if kind == "union": return UnionType( members=[deserialize_mcdoc(member) for member in data.get("members", [])], attributes=deserialize_attributes(data), ) if kind == "string": - return StringType(lengthRange=data.get("lengthRange"), attributes=deserialize_attributes(data)) + return StringType( + lengthRange=deserialize_numeric_range(data.get("lengthRange")), + attributes=deserialize_attributes(data), + ) if kind == "list": return ListType( item=deserialize_mcdoc(data["item"]), - lengthRange=data.get("lengthRange"), + lengthRange=deserialize_numeric_range(data.get("lengthRange")), attributes=deserialize_attributes(data), ) if kind == "tuple": @@ -550,11 +811,23 @@ def deserialize_mcdoc(data: dict) -> McdocType: attributes=deserialize_attributes(data), ) if kind == "byte_array": - return ByteArrayType(attributes=deserialize_attributes(data)) # TODO + return ByteArrayType( + lengthRange=deserialize_numeric_range(data.get("lengthRange")), + valueRange=deserialize_numeric_range(data.get("valueRange")), + attributes=deserialize_attributes(data), + ) if kind == "int_array": - return IntArrayType(attributes=deserialize_attributes(data)) # TODO + return IntArrayType( + lengthRange=deserialize_numeric_range(data.get("lengthRange")), + valueRange=deserialize_numeric_range(data.get("valueRange")), + attributes=deserialize_attributes(data), + ) if kind == "long_array": - return LongArrayType(attributes=deserialize_attributes(data)) # TODO + return LongArrayType( + lengthRange=deserialize_numeric_range(data.get("lengthRange")), + valueRange=deserialize_numeric_range(data.get("valueRange")), + attributes=deserialize_attributes(data), + ) if kind == "template": return TemplateType( child=deserialize_mcdoc(data["child"]), From d7fa6c6a1f5e8980a1157f8737598c7bac204c91 Mon Sep 17 00:00:00 2001 From: Misode Date: Sat, 18 Jan 2025 17:10:08 +0100 Subject: [PATCH 03/14] Template and concrete types --- commanderbot/ext/mcdoc/mcdoc_cog.py | 5 +- commanderbot/ext/mcdoc/mcdoc_types.py | 148 +++++++++++++++++++------- 2 files changed, 112 insertions(+), 41 deletions(-) diff --git a/commanderbot/ext/mcdoc/mcdoc_cog.py b/commanderbot/ext/mcdoc/mcdoc_cog.py index 17fb942..d9af714 100644 --- a/commanderbot/ext/mcdoc/mcdoc_cog.py +++ b/commanderbot/ext/mcdoc/mcdoc_cog.py @@ -53,14 +53,15 @@ async def cmd_mcdoc(self, interaction: Interaction, query: str, version: Optiona symbol: Optional[McdocSymbol] = self.symbols.search(query) if not symbol: - await interaction.followup.send("Symbol not found") + await interaction.followup.send(f"Symbol `{query}` not found") return # TODO: un-hardcode the latest release version ctx = McdocContext(version or "1.21.4", self.symbols.get) + name = symbol.identifier.split("::")[-1] embed: Embed = Embed( - title=symbol.identifier.split("::")[-1], + title=symbol.typeDef.title(name, ctx), description=symbol.typeDef.render(ctx), color=0x2783E3, ) diff --git a/commanderbot/ext/mcdoc/mcdoc_types.py b/commanderbot/ext/mcdoc/mcdoc_types.py index 3fa3885..b1a2fa3 100644 --- a/commanderbot/ext/mcdoc/mcdoc_types.py +++ b/commanderbot/ext/mcdoc/mcdoc_types.py @@ -1,6 +1,6 @@ import json from dataclasses import dataclass -from typing import Union, Optional, Any, Callable +from typing import Any, Callable, Mapping, Optional, Union ICON_ANY = "<:any:1328878339305246761>" ICON_BOOLEAN = "<:boolean:1328844824824254475>" @@ -28,17 +28,28 @@ "string": ICON_STRING, } +TEMPLATE_CHARS = ["馃嚘", "馃嚙", "馃嚚", "馃嚛", "馃嚜", "馃嚝", "馃嚞", "馃嚟", "馃嚠", "馃嚡", "馃嚢", "馃嚤", "馃嚥", "馃嚦", "馃嚧", "馃嚨", "馃嚩", "馃嚪", "馃嚫", "馃嚬", "馃嚭", "馃嚮", "馃嚰", "馃嚱", "馃嚲", "馃嚳"] def cmp_version(a: str, b: str): return float(a[2:]) - float(b[2:]) class McdocContext: - def __init__(self, version: str, lookup: Callable[[str], Optional["McdocType"]], compact = False, depth = 0): + def __init__( + self, + version: str, + lookup: Callable[[str], Optional["McdocType"]], + compact = False, + depth = 0, + type_mapping: Mapping[str, Union[str, "McdocType"]] = {}, + type_args: list["McdocType"] = [] + ): self.version = version self.lookup = lookup self.compact = compact self.depth = depth + self.type_mapping = type_mapping + self.type_args = type_args def symbol(self, path: str): return self.lookup(path) @@ -53,12 +64,18 @@ def filter(self, attributes: Optional[list["Attribute"]]): if until and cmp_version(self.version, until["value"]["value"]) >= 0: return False return True - + def make_compact(self) -> "McdocContext": - return McdocContext(self.version, self.lookup, True, self.depth) + return McdocContext(self.version, self.lookup, True, self.depth, self.type_mapping, self.type_args) def nested(self) -> "McdocContext": - return McdocContext(self.version, self.lookup, self.compact, self.depth + 1) + return McdocContext(self.version, self.lookup, self.compact, self.depth + 1, self.type_mapping, self.type_args) + + def with_type_mapping(self, mapping: Mapping[str, Union[str, "McdocType"]]) -> "McdocContext": + return McdocContext(self.version, self.lookup, self.compact, self.depth, mapping, self.type_args) + + def with_type_args(self, type_args: list["McdocType"]) -> "McdocContext": + return McdocContext(self.version, self.lookup, self.compact, self.depth, self.type_mapping, type_args) @dataclass @@ -71,11 +88,14 @@ class Attribute: class McdocBaseType: attributes: Optional[list[Attribute]] = None + def title(self, name: str, ctx: McdocContext) -> str: + return name + def icons(self, ctx: McdocContext) -> list[str]: return [ICON_ANY] def prefix(self, ctx: McdocContext) -> str: - return "".join(set(self.icons(ctx))) + return "".join(list(dict.fromkeys(self.icons(ctx)))) def suffix(self, ctx: McdocContext) -> str: return "" @@ -103,11 +123,17 @@ class KeywordIndex: class DynamicIndex: accessor: list[str | KeywordIndex] + def render(self): + return f"[{'.'.join(f'`{a}`' if isinstance(a, str) else f'%{a.keyword}' for a in self.accessor)}]" + @dataclass class StaticIndex: value: str + def render(self): + return self.value + @dataclass class DispatcherType(McdocBaseType): @@ -115,7 +141,7 @@ class DispatcherType(McdocBaseType): parallelIndices: list[DynamicIndex | StaticIndex] def suffix(self, ctx): - return f"{self.registry}[{self.parallelIndices}]" + return f"{self.registry}[{','.join(i.render() for i in self.parallelIndices)}]" @dataclass @@ -216,6 +242,7 @@ def body(self, ctx): def render(self, ctx): return self.body_flat(ctx) + @dataclass class EnumTypeField: identifier: str @@ -223,6 +250,7 @@ class EnumTypeField: desc: Optional[str] = None attributes: Optional[list[Attribute]] = None + @dataclass class EnumType(McdocBaseType): enumKind: str @@ -231,6 +259,9 @@ class EnumType(McdocBaseType): def filtered_values(self, ctx: McdocContext): return [v for v in self.values if ctx.filter(v.attributes)] + def title(self, name, ctx): + return f"enum {name}" + def icons(self, ctx): return [LITERAL_ICONS[self.enumKind]] @@ -271,12 +302,17 @@ class ReferenceType(McdocBaseType): path: str def icons(self, ctx): + if self.path in ctx.type_mapping: + mapped = ctx.type_mapping[self.path] + return mapped if isinstance(mapped, str) else mapped.icons(ctx) typeDef = ctx.symbol(self.path) if typeDef: return typeDef.icons(ctx) return super().icons(ctx) def suffix(self, ctx): + if self.path in ctx.type_mapping: + return "" return f"__{self.path.split('::')[-1]}__" @@ -291,7 +327,7 @@ def icons(self, ctx): all_icons: list[str] = [] for member in self.filtered_members(ctx): all_icons.extend(member.icons(ctx)) - return list(set(all_icons)) + return list(dict.fromkeys(all_icons)) def suffix(self, ctx): members = self.filtered_members(ctx) @@ -328,22 +364,46 @@ def body(self, ctx): @dataclass class TemplateType(McdocBaseType): child: "McdocType" - typeParams: list[dict[str, str]] + typeParams: list[str] + + def abstract_mapping(self): + mapping = dict[str, str]() + used_chars = set[str]() + for param in self.typeParams: + letter = param.split('::')[-1][0].upper() + preferred_char = TEMPLATE_CHARS[ord(letter) - ord('A')] + if preferred_char in used_chars: + for char in TEMPLATE_CHARS: + if char not in used_chars: + preferred_char = char + break + mapping[param] = preferred_char + return mapping + + def nest_context(self, ctx: McdocContext) -> McdocContext: + if ctx.type_args: + return ctx.with_type_mapping(dict(zip(self.typeParams, ctx.type_args))).with_type_args([]) + else: + return ctx.with_type_mapping(self.abstract_mapping()) + + def title(self, name, ctx) -> str: + mapping = self.abstract_mapping() + return f"{name} < {', '.join(mapping.values())} >" def icons(self, ctx): - return self.child.icons(ctx) + return self.child.icons(self.nest_context(ctx)) def prefix(self, ctx): - return self.child.prefix(ctx) + return self.child.prefix(self.nest_context(ctx)) def suffix(self, ctx): - return self.child.suffix(ctx) + return self.child.suffix(self.nest_context(ctx)) def body(self, ctx): - return self.child.body(ctx) + return self.child.body(self.nest_context(ctx)) def render(self, ctx): - return self.child.render(ctx) + return self.child.render(self.nest_context(ctx)) @dataclass @@ -351,20 +411,26 @@ class ConcreteType(McdocBaseType): child: "McdocType" typeArgs: list["McdocType"] + def nest_context(self, ctx: McdocContext) -> McdocContext: + return ctx.with_type_args(self.typeArgs) + def icons(self, ctx): - return self.child.icons(ctx) + return self.child.icons(self.nest_context(ctx)) def prefix(self, ctx): - return self.child.prefix(ctx) + return self.child.prefix(self.nest_context(ctx)) def suffix(self, ctx): - return self.child.suffix(ctx) + suffix = self.child.suffix(self.nest_context(ctx)) + if isinstance(self.child, ReferenceType): + suffix += f" < {', '.join(t.prefix(self.nest_context(ctx)) for t in self.typeArgs)} >" + return suffix def body(self, ctx): - return self.child.body(ctx) + return self.child.body(self.nest_context(ctx)) def render(self, ctx): - return self.child.render(ctx) + return self.child.render(self.nest_context(ctx)) @dataclass @@ -374,7 +440,7 @@ class NumericRange(McdocBaseType): minExcl: bool maxExcl: bool - def render(self, ctx: McdocContext): + def render(self): if self.min is None: return f"below {self.max}" if self.maxExcl else f"at most {self.max}" if self.max is None: @@ -389,6 +455,13 @@ def render(self, ctx: McdocContext): return f"exactly {self.min}" return f"between {self.min} and {self.max} (inclusive)" + def render_length(self): + if self.min == self.max: + return f"of length {self.min}" + else: + return f"with length {self.render()}" + + @dataclass class StringType(McdocBaseType): lengthRange: Optional[NumericRange] = None @@ -413,7 +486,7 @@ def suffix(self, ctx): if regex_pattern: result = "a regex pattern" if self.lengthRange: - result += f" with length {self.lengthRange.render(ctx)}" + result += f" with length {self.lengthRange.render()}" return f"*{result}*" @@ -466,7 +539,7 @@ def icons(self, ctx): def suffix(self, ctx): result = "a byte" if self.valueRange: - result += f" {self.valueRange.render(ctx)}" + result += f" {self.valueRange.render()}" return f"*{result}*" @@ -480,7 +553,7 @@ def icons(self, ctx): def suffix(self, ctx): result = "a short" if self.valueRange: - result += f" {self.valueRange.render(ctx)}" + result += f" {self.valueRange.render()}" return f"*{result}*" @@ -494,7 +567,7 @@ def icons(self, ctx): def suffix(self, ctx): result = "an int" if self.valueRange: - result += f" {self.valueRange.render(ctx)}" + result += f" {self.valueRange.render()}" return f"*{result}*" @@ -508,7 +581,7 @@ def icons(self, ctx): def suffix(self, ctx): result = "a long" if self.valueRange: - result += f" {self.valueRange.render(ctx)}" + result += f" {self.valueRange.render()}" return f"*{result}*" @@ -522,7 +595,7 @@ def icons(self, ctx): def suffix(self, ctx): result = "a float" if self.valueRange: - result += f" {self.valueRange.render(ctx)}" + result += f" {self.valueRange.render()}" return f"*{result}*" @@ -536,7 +609,7 @@ def icons(self, ctx): def suffix(self, ctx): result = "a double" if self.valueRange: - result += f" {self.valueRange.render(ctx)}" + result += f" {self.valueRange.render()}" return f"*{result}*" @@ -551,11 +624,11 @@ def icons(self, ctx): def suffix(self, ctx): result = "a byte array" if self.lengthRange: - result += f" with length {self.lengthRange.render(ctx)}" + result += f" {self.lengthRange.render_length()}" if self.valueRange: if self.lengthRange: result += ", and" - result += f" with values {self.valueRange.render(ctx)}" + result += f" with values {self.valueRange.render()}" return f"*{result}*" @@ -570,11 +643,11 @@ def icons(self, ctx): def suffix(self, ctx): result = "an int array" if self.lengthRange: - result += f" with length {self.lengthRange.render(ctx)}" + result += f" {self.lengthRange.render_length()}" if self.valueRange: if self.lengthRange: result += ", and" - result += f" with values {self.valueRange.render(ctx)}" + result += f" with values {self.valueRange.render()}" return f"*{result}*" @@ -589,11 +662,11 @@ def icons(self, ctx): def suffix(self, ctx): result = "a long array" if self.lengthRange: - result += f" with length {self.lengthRange.render(ctx)}" + result += f" {self.lengthRange.render_length()}" if self.valueRange: if self.lengthRange: result += ", and" - result += f" with values {self.valueRange.render(ctx)}" + result += f" with values {self.valueRange.render()}" return f"*{result}*" @@ -608,10 +681,7 @@ def icons(self, ctx): def suffix(self, ctx): result = "a list" if self.lengthRange: - if self.lengthRange.min == self.lengthRange.max: - result += f" of length {self.lengthRange.min}" - else: - result += f" with length {self.lengthRange.render(ctx)}" + result += f" {self.lengthRange.render_length()}" else: result += " of" return f"*{result}:*" @@ -831,13 +901,13 @@ def deserialize_mcdoc(data: dict) -> McdocType: if kind == "template": return TemplateType( child=deserialize_mcdoc(data["child"]), - typeParams=[], # TODO + typeParams=[t["path"] for t in data["typeParams"]], attributes=deserialize_attributes(data), ) if kind == "concrete": return ConcreteType( child=deserialize_mcdoc(data["child"]), - typeArgs=[], # TODO + typeArgs=[deserialize_mcdoc(t) for t in data["typeArgs"]], attributes=deserialize_attributes(data), ) raise ValueError(f"Unknown kind: {kind}") From 78e7b23e86e31e051063718df8ee00b2f510dd22 Mon Sep 17 00:00:00 2001 From: Misode Date: Sat, 18 Jan 2025 18:37:28 +0100 Subject: [PATCH 04/14] Improve nesting and string attributes --- commanderbot/ext/mcdoc/mcdoc_types.py | 104 +++++++++++++++++--------- 1 file changed, 68 insertions(+), 36 deletions(-) diff --git a/commanderbot/ext/mcdoc/mcdoc_types.py b/commanderbot/ext/mcdoc/mcdoc_types.py index b1a2fa3..dbcff46 100644 --- a/commanderbot/ext/mcdoc/mcdoc_types.py +++ b/commanderbot/ext/mcdoc/mcdoc_types.py @@ -1,5 +1,5 @@ import json -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Callable, Mapping, Optional, Union ICON_ANY = "<:any:1328878339305246761>" @@ -34,22 +34,14 @@ def cmp_version(a: str, b: str): return float(a[2:]) - float(b[2:]) +@dataclass class McdocContext: - def __init__( - self, - version: str, - lookup: Callable[[str], Optional["McdocType"]], - compact = False, - depth = 0, - type_mapping: Mapping[str, Union[str, "McdocType"]] = {}, - type_args: list["McdocType"] = [] - ): - self.version = version - self.lookup = lookup - self.compact = compact - self.depth = depth - self.type_mapping = type_mapping - self.type_args = type_args + version: str + lookup: Callable[[str], Optional["McdocType"]] + compact: bool = False + depth: int = 0 + type_mapping: Mapping[str, Union[str, "McdocType"]] = field(default_factory=dict) + type_args: list["McdocType"] = field(default_factory=list) def symbol(self, path: str): return self.lookup(path) @@ -65,6 +57,9 @@ def filter(self, attributes: Optional[list["Attribute"]]): return False return True + def allow_body(self): + return self.depth <= 2 + def make_compact(self) -> "McdocContext": return McdocContext(self.version, self.lookup, True, self.depth, self.type_mapping, self.type_args) @@ -88,6 +83,18 @@ class Attribute: class McdocBaseType: attributes: Optional[list[Attribute]] = None + def has_attr(self, name: str): + for a in self.attributes or []: + if a.name == name: + return True + return False + + def get_attr(self, name: str) -> Optional[dict]: + for a in self.attributes or []: + if a.name == name: + return a.value or dict() + return None + def title(self, name: str, ctx: McdocContext) -> str: return name @@ -181,7 +188,7 @@ def render(self, ctx: McdocContext): desc = self.desc.strip() if self.desc else "" if desc: result += "".join(f"\n-# {d.strip()}" for d in desc.split("\n") if d.strip()) - if ctx.depth < 1: + if ctx.allow_body(): body = self.type.body(ctx.make_compact()) if body: result += f"\n{body}" @@ -267,14 +274,14 @@ def icons(self, ctx): def suffix(self, ctx): values = self.filtered_values(ctx) - if ctx.depth >= 1: - if not values: - return "*no options*" - return f"*one of: {', '.join(json.dumps(v.value) for v in values)}*" - return "*one of:*" + if ctx.allow_body(): + return "*one of:*" + if not values: + return "*no options*" + return f"*one of: {', '.join(json.dumps(v.value) for v in values)}*" def body_flat(self, ctx: McdocContext): - if ctx.depth >= 1: + if not ctx.allow_body(): return "" results = [] for field in self.filtered_values(ctx): @@ -335,12 +342,12 @@ def suffix(self, ctx): return "*nothing*" if len(members) == 1: return members[0].suffix(ctx) - if ctx.depth >= 1: - return f"*one of {len(members)} types*" - return "*one of:*" + if ctx.allow_body(): + return "*one of:*" + return f"*one of {len(members)} types*" def body(self, ctx): - if ctx.depth >= 1: + if not ctx.allow_body(): return "" members = self.filtered_members(ctx) if not members: @@ -353,12 +360,13 @@ def body(self, ctx): suffix = member.suffix(ctx) if suffix: result += f" {suffix}" if result else suffix - if ctx.depth < 1: + if ctx.allow_body(): body = member.body(ctx.make_compact().nested()) if body: + body = "\n".join(f" {line}" for line in body.split("\n")) result += f"\n{body}" if result else body - results.append(result) - return "\n".join(f"{'' if ctx.compact else '\n'}* {r}" for r in results) + results.append(f"* {result}") + return "\n".join(f"{'' if ctx.compact else '\n'}{r}" for r in results) @dataclass @@ -471,8 +479,8 @@ def icons(self, ctx): def suffix(self, ctx): result = "a string" - id = next((a.value for a in self.attributes or [] if a.name == "id"), None) - if id: + id = self.get_attr("id") + if id is not None: if id["kind"] == "literal": registry = id["value"]["value"] elif id["kind"] == "tree": @@ -482,9 +490,32 @@ def suffix(self, ctx): result = "a resource location" if registry: result += f" ({registry})" - regex_pattern = next((a.value for a in self.attributes or [] if a.name == "regex_pattern"), None) - if regex_pattern: + elif self.has_attr("text_component"): + result = "a stringified text component" + elif self.has_attr("integer"): + result = "a stringified integer" + elif self.has_attr("regex_pattern"): result = "a regex pattern" + elif self.has_attr("uuid"): + result = "a hex uuid" + elif self.has_attr("color"): + result = "a hex color" + elif self.has_attr("nbt"): + result = "an SNBT string" + elif self.has_attr("nbt_path"): + result = "an NBT path" + elif self.has_attr("team"): + result = "a team name" + elif self.has_attr("objective"): + result = "a scoreboard objective" + elif self.has_attr("tag"): + result = "a command tag" + elif self.has_attr("translation_key"): + result = "a translation key" + elif self.has_attr("entity"): + result = "an entity selector" + elif self.has_attr("command"): + result = "a command" if self.lengthRange: result += f" with length {self.lengthRange.render()}" return f"*{result}*" @@ -693,8 +724,9 @@ def body(self, ctx): result += f" {suffix}" if result else suffix body = self.item.body(ctx.make_compact().nested()) if body: + body = "\n".join(f" {line}" for line in body.split("\n")) result += f"\n{body}" if result else body - return "* " + result + return f"* {result}" @dataclass @@ -705,7 +737,7 @@ def icons(self, ctx): return [ICON_LIST] def suffix(self, ctx): - return f"*a list of length {len(self.items)}:*" + return f"*a tuple of length {len(self.items)}:*" McdocType = Union[ From 32f832397739554c2f039901fc3c8b931ca2a7ee Mon Sep 17 00:00:00 2001 From: Misode Date: Sun, 19 Jan 2025 15:14:51 +0100 Subject: [PATCH 05/14] Dynamic fields --- commanderbot/ext/mcdoc/mcdoc_types.py | 72 +++++++++++++++------------ 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/commanderbot/ext/mcdoc/mcdoc_types.py b/commanderbot/ext/mcdoc/mcdoc_types.py index dbcff46..02d91f5 100644 --- a/commanderbot/ext/mcdoc/mcdoc_types.py +++ b/commanderbot/ext/mcdoc/mcdoc_types.py @@ -63,8 +63,8 @@ def allow_body(self): def make_compact(self) -> "McdocContext": return McdocContext(self.version, self.lookup, True, self.depth, self.type_mapping, self.type_args) - def nested(self) -> "McdocContext": - return McdocContext(self.version, self.lookup, self.compact, self.depth + 1, self.type_mapping, self.type_args) + def nested(self, diff=1) -> "McdocContext": + return McdocContext(self.version, self.lookup, self.compact, self.depth + diff, self.type_mapping, self.type_args) def with_type_mapping(self, mapping: Mapping[str, Union[str, "McdocType"]]) -> "McdocContext": return McdocContext(self.version, self.lookup, self.compact, self.depth, mapping, self.type_args) @@ -170,12 +170,12 @@ def is_deprecated(self, ctx: McdocContext): def render(self, ctx: McdocContext): if isinstance(self.key, str): - key = self.key + key = f"`{self.key}`" elif isinstance(self.key, LiteralType): - key = str(self.key.value) + key = f"`{str(self.key.value)}`" else: - return "" - key = f"`{key}`" + key = f"**[** {self.key.suffix(ctx) or '*a key*'} **]**" + self.optional = False if self.is_deprecated(ctx): key = f"~~{key}~~" result = self.type.prefix(ctx) @@ -211,7 +211,7 @@ def render(self, ctx: McdocContext): if suffix: return f"*all fields from {suffix}*" - return "" + return "*more fields*" @dataclass @@ -227,22 +227,25 @@ def icons(self, ctx): def suffix(self, ctx): fields = self.filtered_fields(ctx) if not fields: - return "*an empty object*" - return "*an object*" + return "an empty object" + return "an object" def body_flat(self, ctx: McdocContext): + fields = self.filtered_fields(ctx) + if not fields: + return "" results = [] - for field in self.filtered_fields(ctx): + for field in fields: result = field.render(ctx) if result: results.append(result) - if not results: - return "*no fields*" joiner = "\n" if ctx.compact else "\n\n" return joiner.join(results) def body(self, ctx): lines = self.body_flat(ctx.nested()) + if not lines: + return "" start = "" if ctx.compact else "\n" return start + "\n".join(f"> {line}" for line in lines.split("\n")) @@ -429,10 +432,18 @@ def prefix(self, ctx): return self.child.prefix(self.nest_context(ctx)) def suffix(self, ctx): - suffix = self.child.suffix(self.nest_context(ctx)) + result = self.child.suffix(self.nest_context(ctx)) if isinstance(self.child, ReferenceType): - suffix += f" < {', '.join(t.prefix(self.nest_context(ctx)) for t in self.typeArgs)} >" - return suffix + arg_ctx = self.nest_context(ctx).nested(99) + args = [] + for arg in self.typeArgs: + arg_result = arg.prefix(arg_ctx) + arg_suffix = arg.suffix(arg_ctx) + if arg_suffix: + arg_result += f" {arg_suffix}" + args.append(arg_result) + result += f" < {', '.join(args)} >" + return result def body(self, ctx): return self.child.body(self.nest_context(ctx)) @@ -485,11 +496,8 @@ def suffix(self, ctx): registry = id["value"]["value"] elif id["kind"] == "tree": registry = id["values"]["registry"]["value"]["value"] # TODO - else: - registry = None - result = "a resource location" if registry: - result += f" ({registry})" + result = f"a {registry}" elif self.has_attr("text_component"): result = "a stringified text component" elif self.has_attr("integer"): @@ -518,7 +526,7 @@ def suffix(self, ctx): result = "a command" if self.lengthRange: result += f" with length {self.lengthRange.render()}" - return f"*{result}*" + return result @dataclass @@ -557,7 +565,7 @@ def icons(self, ctx): return [ICON_BOOLEAN] def suffix(self, ctx): - return "*a boolean*" + return "a boolean" @dataclass @@ -571,7 +579,7 @@ def suffix(self, ctx): result = "a byte" if self.valueRange: result += f" {self.valueRange.render()}" - return f"*{result}*" + return result @dataclass @@ -585,7 +593,7 @@ def suffix(self, ctx): result = "a short" if self.valueRange: result += f" {self.valueRange.render()}" - return f"*{result}*" + return result @dataclass @@ -599,7 +607,7 @@ def suffix(self, ctx): result = "an int" if self.valueRange: result += f" {self.valueRange.render()}" - return f"*{result}*" + return result @dataclass @@ -613,7 +621,7 @@ def suffix(self, ctx): result = "a long" if self.valueRange: result += f" {self.valueRange.render()}" - return f"*{result}*" + return result @dataclass @@ -627,7 +635,7 @@ def suffix(self, ctx): result = "a float" if self.valueRange: result += f" {self.valueRange.render()}" - return f"*{result}*" + return result @dataclass @@ -641,7 +649,7 @@ def suffix(self, ctx): result = "a double" if self.valueRange: result += f" {self.valueRange.render()}" - return f"*{result}*" + return result @dataclass @@ -660,7 +668,7 @@ def suffix(self, ctx): if self.lengthRange: result += ", and" result += f" with values {self.valueRange.render()}" - return f"*{result}*" + return result @dataclass @@ -679,7 +687,7 @@ def suffix(self, ctx): if self.lengthRange: result += ", and" result += f" with values {self.valueRange.render()}" - return f"*{result}*" + return result @dataclass @@ -698,7 +706,7 @@ def suffix(self, ctx): if self.lengthRange: result += ", and" result += f" with values {self.valueRange.render()}" - return f"*{result}*" + return result @dataclass @@ -715,7 +723,7 @@ def suffix(self, ctx): result += f" {self.lengthRange.render_length()}" else: result += " of" - return f"*{result}:*" + return f"{result}:" def body(self, ctx): result = self.item.prefix(ctx) @@ -737,7 +745,7 @@ def icons(self, ctx): return [ICON_LIST] def suffix(self, ctx): - return f"*a tuple of length {len(self.items)}:*" + return f"a tuple of length {len(self.items)}" McdocType = Union[ From f3a19f46cd8cddba4a769e1a42aeba383c5e7f4b Mon Sep 17 00:00:00 2001 From: Misode Date: Sun, 19 Jan 2025 18:16:09 +0100 Subject: [PATCH 06/14] Implement searching for dispatched types --- commanderbot/ext/mcdoc/mcdoc_cog.py | 13 +++--- commanderbot/ext/mcdoc/mcdoc_symbols.py | 60 +++++++++++++++++++++---- commanderbot/ext/mcdoc/mcdoc_types.py | 52 +++++++++++++++------ 3 files changed, 96 insertions(+), 29 deletions(-) diff --git a/commanderbot/ext/mcdoc/mcdoc_cog.py b/commanderbot/ext/mcdoc/mcdoc_cog.py index d9af714..a8eb16a 100644 --- a/commanderbot/ext/mcdoc/mcdoc_cog.py +++ b/commanderbot/ext/mcdoc/mcdoc_cog.py @@ -12,7 +12,7 @@ ) from discord.ext.commands import Bot, Cog -from commanderbot.ext.mcdoc.mcdoc_symbols import McdocSymbol, McdocSymbols +from commanderbot.ext.mcdoc.mcdoc_symbols import McdocSymbols from commanderbot.ext.mcdoc.mcdoc_types import McdocContext from commanderbot.lib import constants, AllowedMentions @@ -50,18 +50,17 @@ async def cmd_mcdoc(self, interaction: Interaction, query: str, version: Optiona if self.symbols is None: self.symbols = await self._request_symbols() - symbol: Optional[McdocSymbol] = self.symbols.search(query) + symbol = self.symbols.search(query) - if not symbol: - await interaction.followup.send(f"Symbol `{query}` not found") + if isinstance(symbol, str): + await interaction.followup.send(symbol) return # TODO: un-hardcode the latest release version - ctx = McdocContext(version or "1.21.4", self.symbols.get) + ctx = McdocContext(version or "1.21.4", self.symbols) - name = symbol.identifier.split("::")[-1] embed: Embed = Embed( - title=symbol.typeDef.title(name, ctx), + title=symbol.title(ctx), description=symbol.typeDef.render(ctx), color=0x2783E3, ) diff --git a/commanderbot/ext/mcdoc/mcdoc_symbols.py b/commanderbot/ext/mcdoc/mcdoc_symbols.py index f54e204..07ff386 100644 --- a/commanderbot/ext/mcdoc/mcdoc_symbols.py +++ b/commanderbot/ext/mcdoc/mcdoc_symbols.py @@ -1,14 +1,30 @@ from collections import defaultdict from dataclasses import dataclass -from typing import Optional +from typing import Optional, Union + +from commanderbot.ext.mcdoc.mcdoc_types import McdocContext, McdocType, deserialize_mcdoc + + +@dataclass +class SymbolResult: + identifier: str + typeDef: McdocType + + def title(self, ctx: McdocContext): + name = self.identifier.split("::")[-1] + return self.typeDef.title(name, ctx) -from commanderbot.ext.mcdoc.mcdoc_types import McdocType, deserialize_mcdoc @dataclass -class McdocSymbol: +class DispatchResult: + registry: str identifier: str typeDef: McdocType + def title(self, ctx: McdocContext): + name = f"{self.registry.removeprefix("minecraft:")} [{self.identifier}]" + return self.typeDef.title(name, ctx) + class McdocSymbols: def __init__(self, data: dict): @@ -30,16 +46,42 @@ def __init__(self, data: dict): self.names[name].append(key) self.names = dict(self.names) - def search(self, query: str) -> Optional[McdocSymbol]: + def search(self, query: str) -> Union[SymbolResult, DispatchResult, str]: if query in self.symbols: - return McdocSymbol(query, self.symbols[query]) + return SymbolResult(query, self.symbols[query]) if query in self.names: identifier = self.names[query][0] - return McdocSymbol(identifier, self.symbols[identifier]) + return SymbolResult(identifier, self.symbols[identifier]) + + parts = query.split(" ") + if len(parts) == 2: + registry, identifier = parts + if ":" not in registry: + registry = f"minecraft:{registry}" + identifier = identifier.removeprefix("minecraft:") + map = self.dispatchers.get(registry, None) + if map: + if identifier in map: + return DispatchResult(registry, identifier, map[identifier]) + else: + return f"Dispatcher `{registry}` does not contain `{identifier}`." + else: + return f"Dispatcher `{registry}` not found." + + identifier = query.removeprefix("minecraft:") + resources = self.dispatchers.get("minecraft:resource", {}) + if query in resources: + return DispatchResult("resource", identifier, resources[identifier]) + + for registry, map in self.dispatchers.items(): + if identifier in map: + return DispatchResult(registry, identifier, map[identifier]) + + return f"Symbol `{query}` not found." - # TODO: search dispatchers - return None - def get(self, path: str) -> Optional[McdocType]: return self.symbols.get(path, None) + + def dispatch(self, registry: str, identifier: str) -> Optional[McdocType]: + return self.dispatchers.get(registry, {}).get(identifier, None) diff --git a/commanderbot/ext/mcdoc/mcdoc_types.py b/commanderbot/ext/mcdoc/mcdoc_types.py index 02d91f5..5babcf7 100644 --- a/commanderbot/ext/mcdoc/mcdoc_types.py +++ b/commanderbot/ext/mcdoc/mcdoc_types.py @@ -1,6 +1,6 @@ import json from dataclasses import dataclass, field -from typing import Any, Callable, Mapping, Optional, Union +from typing import Mapping, Protocol, Optional, Union ICON_ANY = "<:any:1328878339305246761>" ICON_BOOLEAN = "<:boolean:1328844824824254475>" @@ -30,22 +30,28 @@ TEMPLATE_CHARS = ["馃嚘", "馃嚙", "馃嚚", "馃嚛", "馃嚜", "馃嚝", "馃嚞", "馃嚟", "馃嚠", "馃嚡", "馃嚢", "馃嚤", "馃嚥", "馃嚦", "馃嚧", "馃嚨", "馃嚩", "馃嚪", "馃嚫", "馃嚬", "馃嚭", "馃嚮", "馃嚰", "馃嚱", "馃嚲", "馃嚳"] + def cmp_version(a: str, b: str): return float(a[2:]) - float(b[2:]) +class McdocLookup(Protocol): + def get(self, path: str) -> Optional["McdocType"]: + ... + + def dispatch(self, registry: str, identifier: str) -> Optional["McdocType"]: + ... + + @dataclass class McdocContext: version: str - lookup: Callable[[str], Optional["McdocType"]] + symbols: McdocLookup compact: bool = False depth: int = 0 type_mapping: Mapping[str, Union[str, "McdocType"]] = field(default_factory=dict) type_args: list["McdocType"] = field(default_factory=list) - def symbol(self, path: str): - return self.lookup(path) - def filter(self, attributes: Optional[list["Attribute"]]): if not attributes: return True @@ -61,16 +67,16 @@ def allow_body(self): return self.depth <= 2 def make_compact(self) -> "McdocContext": - return McdocContext(self.version, self.lookup, True, self.depth, self.type_mapping, self.type_args) + return McdocContext(self.version, self.symbols, True, self.depth, self.type_mapping, self.type_args) def nested(self, diff=1) -> "McdocContext": - return McdocContext(self.version, self.lookup, self.compact, self.depth + diff, self.type_mapping, self.type_args) + return McdocContext(self.version, self.symbols, self.compact, self.depth + diff, self.type_mapping, self.type_args) def with_type_mapping(self, mapping: Mapping[str, Union[str, "McdocType"]]) -> "McdocContext": - return McdocContext(self.version, self.lookup, self.compact, self.depth, mapping, self.type_args) + return McdocContext(self.version, self.symbols, self.compact, self.depth, mapping, self.type_args) def with_type_args(self, type_args: list["McdocType"]) -> "McdocContext": - return McdocContext(self.version, self.lookup, self.compact, self.depth, self.type_mapping, type_args) + return McdocContext(self.version, self.symbols, self.compact, self.depth, self.type_mapping, type_args) @dataclass @@ -147,9 +153,24 @@ class DispatcherType(McdocBaseType): registry: str parallelIndices: list[DynamicIndex | StaticIndex] + def title(self, name, ctx): + match self.parallelIndices: + case [StaticIndex(value)]: + typeDef = ctx.symbols.dispatch(self.registry, value) + if typeDef: + return typeDef.title(name, ctx) + return super().title(name, ctx) + def suffix(self, ctx): return f"{self.registry}[{','.join(i.render() for i in self.parallelIndices)}]" + def render(self, ctx): + match self.parallelIndices: + case [StaticIndex(value)]: + typeDef = ctx.symbols.dispatch(self.registry, value) + if typeDef: + return typeDef.render(ctx) + return super().render(ctx) @dataclass class StructTypePairField: @@ -311,11 +332,14 @@ def render(self, ctx): class ReferenceType(McdocBaseType): path: str + def title(self, name, ctx): + return f"{name} 路 {self.path.split('::')[-1]}" + def icons(self, ctx): if self.path in ctx.type_mapping: mapped = ctx.type_mapping[self.path] return mapped if isinstance(mapped, str) else mapped.icons(ctx) - typeDef = ctx.symbol(self.path) + typeDef = ctx.symbols.get(self.path) if typeDef: return typeDef.icons(ctx) return super().icons(ctx) @@ -325,6 +349,11 @@ def suffix(self, ctx): return "" return f"__{self.path.split('::')[-1]}__" + def render(self, ctx): + typeDef = ctx.symbols.get(self.path) + if typeDef: + return typeDef.render(ctx) + return super().render(ctx) @dataclass class UnionType(McdocBaseType): @@ -448,9 +477,6 @@ def suffix(self, ctx): def body(self, ctx): return self.child.body(self.nest_context(ctx)) - def render(self, ctx): - return self.child.render(self.nest_context(ctx)) - @dataclass class NumericRange(McdocBaseType): From 916851a1e76e80d577e5706f26e57966c25708a7 Mon Sep 17 00:00:00 2001 From: Misode Date: Sun, 19 Jan 2025 23:52:44 +0100 Subject: [PATCH 07/14] Refresh vanilla-mcdoc cache using ETag header --- commanderbot/ext/mcdoc/mcdoc_cog.py | 54 ++++++++++++++-------- commanderbot/ext/mcdoc/mcdoc_exceptions.py | 16 +++++++ commanderbot/ext/mcdoc/mcdoc_symbols.py | 15 +++--- 3 files changed, 57 insertions(+), 28 deletions(-) create mode 100644 commanderbot/ext/mcdoc/mcdoc_exceptions.py diff --git a/commanderbot/ext/mcdoc/mcdoc_cog.py b/commanderbot/ext/mcdoc/mcdoc_cog.py index a8eb16a..e1e10f0 100644 --- a/commanderbot/ext/mcdoc/mcdoc_cog.py +++ b/commanderbot/ext/mcdoc/mcdoc_cog.py @@ -1,7 +1,7 @@ from logging import Logger, getLogger from typing import Optional - import aiohttp +import re from discord import Embed, Interaction from discord.app_commands import ( @@ -14,6 +14,7 @@ from commanderbot.ext.mcdoc.mcdoc_symbols import McdocSymbols from commanderbot.ext.mcdoc.mcdoc_types import McdocContext +from commanderbot.ext.mcdoc.mcdoc_exceptions import RequestError from commanderbot.lib import constants, AllowedMentions MCDOC_SYMBOLS_URL = "https://api.spyglassmc.com/vanilla-mcdoc/symbols" @@ -26,14 +27,36 @@ def __init__(self, bot: Bot): self.log: Logger = getLogger(self.qualified_name) self.symbols: Optional[McdocSymbols] = None - async def _request_symbols(self) -> McdocSymbols: - headers: dict[str, str] = {"User-Agent": constants.USER_AGENT} - async with aiohttp.ClientSession() as session: - async with session.get( - MCDOC_SYMBOLS_URL, headers=headers, raise_for_status=True - ) as response: - data: dict = await response.json() - return McdocSymbols(data) + self._etag: Optional[str] = None + + async def _fetch_symbols(self) -> McdocSymbols: + try: + async with aiohttp.ClientSession() as session: + async with session.get( + MCDOC_SYMBOLS_URL, + headers={ + "User-Agent": constants.USER_AGENT, + "If-None-Match": self._etag or "", + }, + ) as response: + # Use cached symbols if they are still valid + if response.status == 304 and self.symbols: + return self.symbols + + if response.status != 200: + raise RequestError() + + # Store symbol data and ETag header + data: dict = await response.json() + self.symbols = McdocSymbols(data) + etag = response.headers.get("ETag") + if etag: + self._etag = etag.removeprefix('W/"').removesuffix('"') + + return self.symbols + + except aiohttp.ClientError: + raise RequestError() @command(name="mcdoc", description="Query vanilla mcdoc types") @describe( @@ -46,18 +69,11 @@ async def cmd_mcdoc(self, interaction: Interaction, query: str, version: Optiona # Respond to the interaction with a defer since the web request may take a while await interaction.response.defer() - # TODO: refresh this every so often - if self.symbols is None: - self.symbols = await self._request_symbols() - - symbol = self.symbols.search(query) - - if isinstance(symbol, str): - await interaction.followup.send(symbol) - return + symbols = await self._fetch_symbols() + symbol = symbols.search(query) # TODO: un-hardcode the latest release version - ctx = McdocContext(version or "1.21.4", self.symbols) + ctx = McdocContext(version or "1.21.4", symbols) embed: Embed = Embed( title=symbol.title(ctx), diff --git a/commanderbot/ext/mcdoc/mcdoc_exceptions.py b/commanderbot/ext/mcdoc/mcdoc_exceptions.py new file mode 100644 index 0000000..7899fc2 --- /dev/null +++ b/commanderbot/ext/mcdoc/mcdoc_exceptions.py @@ -0,0 +1,16 @@ +from commanderbot.lib import ResponsiveException + + +class McdocException(ResponsiveException): + pass + + +class RequestError(McdocException): + def __init__(self): + super().__init__(f"馃樀 Unable to fetch vanilla-mcdoc symbol data") + + +class QueryReturnedNoResults(McdocException): + def __init__(self, query: str): + self.query: str = query + super().__init__(f"馃様 Could not find any symbols matching `{self.query}`") diff --git a/commanderbot/ext/mcdoc/mcdoc_symbols.py b/commanderbot/ext/mcdoc/mcdoc_symbols.py index 07ff386..d2c6b9a 100644 --- a/commanderbot/ext/mcdoc/mcdoc_symbols.py +++ b/commanderbot/ext/mcdoc/mcdoc_symbols.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from typing import Optional, Union +from commanderbot.ext.mcdoc.mcdoc_exceptions import QueryReturnedNoResults from commanderbot.ext.mcdoc.mcdoc_types import McdocContext, McdocType, deserialize_mcdoc @@ -46,7 +47,7 @@ def __init__(self, data: dict): self.names[name].append(key) self.names = dict(self.names) - def search(self, query: str) -> Union[SymbolResult, DispatchResult, str]: + def search(self, query: str) -> Union[SymbolResult, DispatchResult]: if query in self.symbols: return SymbolResult(query, self.symbols[query]) @@ -61,13 +62,9 @@ def search(self, query: str) -> Union[SymbolResult, DispatchResult, str]: registry = f"minecraft:{registry}" identifier = identifier.removeprefix("minecraft:") map = self.dispatchers.get(registry, None) - if map: - if identifier in map: - return DispatchResult(registry, identifier, map[identifier]) - else: - return f"Dispatcher `{registry}` does not contain `{identifier}`." - else: - return f"Dispatcher `{registry}` not found." + if map and identifier in map: + return DispatchResult(registry, identifier, map[identifier]) + raise QueryReturnedNoResults(query) identifier = query.removeprefix("minecraft:") resources = self.dispatchers.get("minecraft:resource", {}) @@ -78,7 +75,7 @@ def search(self, query: str) -> Union[SymbolResult, DispatchResult, str]: if identifier in map: return DispatchResult(registry, identifier, map[identifier]) - return f"Symbol `{query}` not found." + raise QueryReturnedNoResults(query) def get(self, path: str) -> Optional[McdocType]: return self.symbols.get(path, None) From 0f75a28904eff44090c24c963fdabd320d7b0f97 Mon Sep 17 00:00:00 2001 From: Misode Date: Mon, 20 Jan 2025 01:20:26 +0100 Subject: [PATCH 08/14] Add bot options --- commanderbot/ext/mcdoc/__init__.py | 3 ++- commanderbot/ext/mcdoc/mcdoc_cog.py | 25 +++++++++++-------------- commanderbot/ext/mcdoc/mcdoc_options.py | 17 +++++++++++++++++ 3 files changed, 30 insertions(+), 15 deletions(-) create mode 100644 commanderbot/ext/mcdoc/mcdoc_options.py diff --git a/commanderbot/ext/mcdoc/__init__.py b/commanderbot/ext/mcdoc/__init__.py index f05d5bb..4a9d0fd 100644 --- a/commanderbot/ext/mcdoc/__init__.py +++ b/commanderbot/ext/mcdoc/__init__.py @@ -5,4 +5,5 @@ async def setup(bot: Bot): - await bot.add_cog(McdocCog(bot)) + assert is_commander_bot(bot) + await bot.add_configured_cog(__name__, McdocCog) diff --git a/commanderbot/ext/mcdoc/mcdoc_cog.py b/commanderbot/ext/mcdoc/mcdoc_cog.py index e1e10f0..8eb6b8b 100644 --- a/commanderbot/ext/mcdoc/mcdoc_cog.py +++ b/commanderbot/ext/mcdoc/mcdoc_cog.py @@ -15,45 +15,42 @@ from commanderbot.ext.mcdoc.mcdoc_symbols import McdocSymbols from commanderbot.ext.mcdoc.mcdoc_types import McdocContext from commanderbot.ext.mcdoc.mcdoc_exceptions import RequestError +from commanderbot.ext.mcdoc.mcdoc_options import McdocOptions from commanderbot.lib import constants, AllowedMentions -MCDOC_SYMBOLS_URL = "https://api.spyglassmc.com/vanilla-mcdoc/symbols" -SPYGLASS_ICON_URL = "https://avatars.githubusercontent.com/u/74945225?s=64" - class McdocCog(Cog, name="commanderbot.ext.mcdoc"): - def __init__(self, bot: Bot): + def __init__(self, bot: Bot, **options): self.bot: Bot = bot self.log: Logger = getLogger(self.qualified_name) - self.symbols: Optional[McdocSymbols] = None + self.options = McdocOptions.from_data(options) + self._symbols: Optional[McdocSymbols] = None self._etag: Optional[str] = None async def _fetch_symbols(self) -> McdocSymbols: try: async with aiohttp.ClientSession() as session: async with session.get( - MCDOC_SYMBOLS_URL, + self.options.symbols_url, headers={ "User-Agent": constants.USER_AGENT, "If-None-Match": self._etag or "", }, ) as response: # Use cached symbols if they are still valid - if response.status == 304 and self.symbols: - return self.symbols + if response.status == 304 and self._symbols: + return self._symbols if response.status != 200: raise RequestError() # Store symbol data and ETag header data: dict = await response.json() - self.symbols = McdocSymbols(data) - etag = response.headers.get("ETag") - if etag: - self._etag = etag.removeprefix('W/"').removesuffix('"') + self._symbols = McdocSymbols(data) + self.etag = response.headers.get("ETag") - return self.symbols + return self._symbols except aiohttp.ClientError: raise RequestError() @@ -80,6 +77,6 @@ async def cmd_mcdoc(self, interaction: Interaction, query: str, version: Optiona description=symbol.typeDef.render(ctx), color=0x2783E3, ) - embed.set_footer(text=f"vanilla-mcdoc 路 {ctx.version}", icon_url=SPYGLASS_ICON_URL) + embed.set_footer(text=f"vanilla-mcdoc 路 {ctx.version}", icon_url=self.options.icon_url) await interaction.followup.send(embed=embed, allowed_mentions=AllowedMentions.none()) diff --git a/commanderbot/ext/mcdoc/mcdoc_options.py b/commanderbot/ext/mcdoc/mcdoc_options.py new file mode 100644 index 0000000..a478bef --- /dev/null +++ b/commanderbot/ext/mcdoc/mcdoc_options.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from typing import Any, Optional, Self + +from commanderbot.lib import FromDataMixin + +@dataclass +class McdocOptions(FromDataMixin): + symbols_url: str + icon_url: Optional[str] + + @classmethod + def try_from_data(cls, data: Any) -> Optional[Self]: + if isinstance(data, dict): + return cls( + symbols_url=data["symbols_url"], + icon_url=data.get("icon_url", None), + ) From f50085330eb5a275e18b968833c461fdeafe9571 Mon Sep 17 00:00:00 2001 From: Misode Date: Mon, 20 Jan 2025 21:09:47 +0100 Subject: [PATCH 09/14] Refresh latest minecraft version --- commanderbot/ext/mcdoc/mcdoc_cog.py | 58 +++++++++++++++++++--- commanderbot/ext/mcdoc/mcdoc_exceptions.py | 13 ++++- commanderbot/ext/mcdoc/mcdoc_options.py | 2 + 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/commanderbot/ext/mcdoc/mcdoc_cog.py b/commanderbot/ext/mcdoc/mcdoc_cog.py index 8eb6b8b..99184ba 100644 --- a/commanderbot/ext/mcdoc/mcdoc_cog.py +++ b/commanderbot/ext/mcdoc/mcdoc_cog.py @@ -10,11 +10,12 @@ command, describe, ) +from discord.ext import tasks from discord.ext.commands import Bot, Cog from commanderbot.ext.mcdoc.mcdoc_symbols import McdocSymbols from commanderbot.ext.mcdoc.mcdoc_types import McdocContext -from commanderbot.ext.mcdoc.mcdoc_exceptions import RequestError +from commanderbot.ext.mcdoc.mcdoc_exceptions import InvalidVersionError, RequestSymbolsError, RequestVersionError from commanderbot.ext.mcdoc.mcdoc_options import McdocOptions from commanderbot.lib import constants, AllowedMentions @@ -25,9 +26,16 @@ def __init__(self, bot: Bot, **options): self.log: Logger = getLogger(self.qualified_name) self.options = McdocOptions.from_data(options) + self._latest_version: Optional[str] = None self._symbols: Optional[McdocSymbols] = None self._etag: Optional[str] = None + async def cog_load(self): + self._fetch_latest_version.start() + + async def cog_unload(self): + self._fetch_latest_version.stop() + async def _fetch_symbols(self) -> McdocSymbols: try: async with aiohttp.ClientSession() as session: @@ -43,7 +51,7 @@ async def _fetch_symbols(self) -> McdocSymbols: return self._symbols if response.status != 200: - raise RequestError() + raise RequestSymbolsError() # Store symbol data and ETag header data: dict = await response.json() @@ -53,7 +61,31 @@ async def _fetch_symbols(self) -> McdocSymbols: return self._symbols except aiohttp.ClientError: - raise RequestError() + raise RequestSymbolsError() + + @tasks.loop(hours=1) + async def _fetch_latest_version(self) -> str: + try: + # Try to update the version + async with aiohttp.ClientSession(raise_for_status=True) as session: + async with session.get(self.options.manifest_url, headers={ + "User-Agent": constants.USER_AGENT, + }) as response: + data: dict = await response.json() + release: str = data["latest"]["release"] + print(f"relase={release}") + self._latest_version = release + return self._latest_version + + except aiohttp.ClientError: + raise RequestVersionError() + + async def _get_latest_version(self, override: Optional[str]) -> str: + if override: + return override + if self._latest_version: + return self._latest_version + return await self._fetch_latest_version() @command(name="mcdoc", description="Query vanilla mcdoc types") @describe( @@ -66,17 +98,29 @@ async def cmd_mcdoc(self, interaction: Interaction, query: str, version: Optiona # Respond to the interaction with a defer since the web request may take a while await interaction.response.defer() + # Fetch the vanilla-mcdoc symbols and search for a symbol symbols = await self._fetch_symbols() symbol = symbols.search(query) - # TODO: un-hardcode the latest release version - ctx = McdocContext(version or "1.21.4", symbols) + # Use the version override or get the cached latest version + version = await self._get_latest_version(version) + + # Validate that the version number can be used to compare + if not version.startswith("1."): + raise InvalidVersionError(version) + try: + float(version[2:]) + except: + raise InvalidVersionError(version) + + # Create a context object used for rendering + ctx = McdocContext(version, symbols) embed: Embed = Embed( title=symbol.title(ctx), description=symbol.typeDef.render(ctx), - color=0x2783E3, + color=0x2783E3, # Spyglass blue ) - embed.set_footer(text=f"vanilla-mcdoc 路 {ctx.version}", icon_url=self.options.icon_url) + embed.set_footer(text=f"vanilla-mcdoc 路 {version}", icon_url=self.options.icon_url) await interaction.followup.send(embed=embed, allowed_mentions=AllowedMentions.none()) diff --git a/commanderbot/ext/mcdoc/mcdoc_exceptions.py b/commanderbot/ext/mcdoc/mcdoc_exceptions.py index 7899fc2..218699a 100644 --- a/commanderbot/ext/mcdoc/mcdoc_exceptions.py +++ b/commanderbot/ext/mcdoc/mcdoc_exceptions.py @@ -5,11 +5,22 @@ class McdocException(ResponsiveException): pass -class RequestError(McdocException): +class RequestSymbolsError(McdocException): def __init__(self): super().__init__(f"馃樀 Unable to fetch vanilla-mcdoc symbol data") +class RequestVersionError(McdocException): + def __init__(self): + super().__init__(f"馃樀 Unable to fetch the latest version number") + + +class InvalidVersionError(McdocException): + def __init__(self, version: str): + self.version: str = version + super().__init__(f"馃槵 Invalid version format `{self.version}`. Only release versions are allowed.") + + class QueryReturnedNoResults(McdocException): def __init__(self, query: str): self.query: str = query diff --git a/commanderbot/ext/mcdoc/mcdoc_options.py b/commanderbot/ext/mcdoc/mcdoc_options.py index a478bef..9db2af2 100644 --- a/commanderbot/ext/mcdoc/mcdoc_options.py +++ b/commanderbot/ext/mcdoc/mcdoc_options.py @@ -6,6 +6,7 @@ @dataclass class McdocOptions(FromDataMixin): symbols_url: str + manifest_url: str icon_url: Optional[str] @classmethod @@ -13,5 +14,6 @@ def try_from_data(cls, data: Any) -> Optional[Self]: if isinstance(data, dict): return cls( symbols_url=data["symbols_url"], + manifest_url=data["manifest_url"], icon_url=data.get("icon_url", None), ) From 2f5ccebf085386af970c51c73a83080b33b1a693 Mon Sep 17 00:00:00 2001 From: Misode Date: Tue, 4 Feb 2025 22:58:47 +0100 Subject: [PATCH 10/14] Use unique compact identifiers --- commanderbot/ext/mcdoc/mcdoc_cog.py | 3 +- commanderbot/ext/mcdoc/mcdoc_symbols.py | 53 ++++++++++++++++++++++--- commanderbot/ext/mcdoc/mcdoc_types.py | 7 +++- 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/commanderbot/ext/mcdoc/mcdoc_cog.py b/commanderbot/ext/mcdoc/mcdoc_cog.py index 99184ba..c1f4f01 100644 --- a/commanderbot/ext/mcdoc/mcdoc_cog.py +++ b/commanderbot/ext/mcdoc/mcdoc_cog.py @@ -73,7 +73,6 @@ async def _fetch_latest_version(self) -> str: }) as response: data: dict = await response.json() release: str = data["latest"]["release"] - print(f"relase={release}") self._latest_version = release return self._latest_version @@ -118,7 +117,7 @@ async def cmd_mcdoc(self, interaction: Interaction, query: str, version: Optiona embed: Embed = Embed( title=symbol.title(ctx), - description=symbol.typeDef.render(ctx), + description=symbol.body(ctx), color=0x2783E3, # Spyglass blue ) embed.set_footer(text=f"vanilla-mcdoc 路 {version}", icon_url=self.options.icon_url) diff --git a/commanderbot/ext/mcdoc/mcdoc_symbols.py b/commanderbot/ext/mcdoc/mcdoc_symbols.py index d2c6b9a..4c3e458 100644 --- a/commanderbot/ext/mcdoc/mcdoc_symbols.py +++ b/commanderbot/ext/mcdoc/mcdoc_symbols.py @@ -1,6 +1,7 @@ from collections import defaultdict from dataclasses import dataclass from typing import Optional, Union +import re from commanderbot.ext.mcdoc.mcdoc_exceptions import QueryReturnedNoResults from commanderbot.ext.mcdoc.mcdoc_types import McdocContext, McdocType, deserialize_mcdoc @@ -12,8 +13,11 @@ class SymbolResult: typeDef: McdocType def title(self, ctx: McdocContext): - name = self.identifier.split("::")[-1] + name = ctx.symbols.compact_path(self.identifier) return self.typeDef.title(name, ctx) + + def body(self, ctx: McdocContext): + return self.typeDef.render(ctx) @dataclass @@ -25,6 +29,21 @@ class DispatchResult: def title(self, ctx: McdocContext): name = f"{self.registry.removeprefix("minecraft:")} [{self.identifier}]" return self.typeDef.title(name, ctx) + + def body(self, ctx: McdocContext): + return self.typeDef.render(ctx) + + +@dataclass +class DisambiguationResult: + query: str + identifiers: list[str] + + def title(self, ctx: McdocContext): + return f"{len(self.identifiers)} results for {self.query}" + + def body(self, ctx: McdocContext): + return "\n".join([f"* {ctx.symbols.compact_path(i)}" for i in self.identifiers]) class McdocSymbols: @@ -43,17 +62,34 @@ def __init__(self, data: dict): self.names = defaultdict[str, list[str]](list) for key in self.symbols: - name = key.split("::")[-1] - self.names[name].append(key) + parts = key.split("::") + for i in range(len(parts)): + name = "::".join(parts[i:]) + self.names[name].append(key) self.names = dict(self.names) - def search(self, query: str) -> Union[SymbolResult, DispatchResult]: + self.unique_suffixes = dict[str, str]() + for name, keys in self.names.items(): + if len(keys) <= 1 or re.match(r"", name): + continue + for key in keys: + parts = key.split("::") + for i in reversed(range(len(parts)-1)): + suffix = "::".join(parts[i:]) + if not [k for k in keys if k is not key and k.endswith(f"::{suffix}")]: + self.unique_suffixes[key] = suffix + break + + def search(self, query: str) -> Union[SymbolResult, DispatchResult, DisambiguationResult]: if query in self.symbols: return SymbolResult(query, self.symbols[query]) if query in self.names: - identifier = self.names[query][0] - return SymbolResult(identifier, self.symbols[identifier]) + identifiers = self.names[query] + if len(identifiers) > 1: + return DisambiguationResult(query, identifiers) + elif len(identifiers) == 1: + return SymbolResult(identifiers[0], self.symbols[identifiers[0]]) parts = query.split(" ") if len(parts) == 2: @@ -77,6 +113,11 @@ def search(self, query: str) -> Union[SymbolResult, DispatchResult]: raise QueryReturnedNoResults(query) + def compact_path(self, path: str) -> str: + if path in self.unique_suffixes: + return self.unique_suffixes[path] + return path.split("::")[-1] + def get(self, path: str) -> Optional[McdocType]: return self.symbols.get(path, None) diff --git a/commanderbot/ext/mcdoc/mcdoc_types.py b/commanderbot/ext/mcdoc/mcdoc_types.py index 5babcf7..3dc7591 100644 --- a/commanderbot/ext/mcdoc/mcdoc_types.py +++ b/commanderbot/ext/mcdoc/mcdoc_types.py @@ -36,6 +36,9 @@ def cmp_version(a: str, b: str): class McdocLookup(Protocol): + def compact_path(self, path: str) -> str: + ... + def get(self, path: str) -> Optional["McdocType"]: ... @@ -333,7 +336,7 @@ class ReferenceType(McdocBaseType): path: str def title(self, name, ctx): - return f"{name} 路 {self.path.split('::')[-1]}" + return f"{name} 路 {ctx.symbols.compact_path(self.path)}" def icons(self, ctx): if self.path in ctx.type_mapping: @@ -347,7 +350,7 @@ def icons(self, ctx): def suffix(self, ctx): if self.path in ctx.type_mapping: return "" - return f"__{self.path.split('::')[-1]}__" + return f"__{ctx.symbols.compact_path(self.path)}__" def render(self, ctx): typeDef = ctx.symbols.get(self.path) From 78d65cf7589ac067f5a19dc36f55cbc032afff5d Mon Sep 17 00:00:00 2001 From: Misode Date: Wed, 5 Feb 2025 00:25:16 +0100 Subject: [PATCH 11/14] Fix incorrectly checking depth --- commanderbot/ext/mcdoc/mcdoc_types.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/commanderbot/ext/mcdoc/mcdoc_types.py b/commanderbot/ext/mcdoc/mcdoc_types.py index 3dc7591..c243097 100644 --- a/commanderbot/ext/mcdoc/mcdoc_types.py +++ b/commanderbot/ext/mcdoc/mcdoc_types.py @@ -395,8 +395,9 @@ def body(self, ctx): suffix = member.suffix(ctx) if suffix: result += f" {suffix}" if result else suffix - if ctx.allow_body(): - body = member.body(ctx.make_compact().nested()) + body_ctx = ctx.make_compact().nested() + if body_ctx.allow_body(): + body = member.body(body_ctx) if body: body = "\n".join(f" {line}" for line in body.split("\n")) result += f"\n{body}" if result else body @@ -759,10 +760,12 @@ def body(self, ctx): suffix = self.item.suffix(ctx) if suffix: result += f" {suffix}" if result else suffix - body = self.item.body(ctx.make_compact().nested()) - if body: - body = "\n".join(f" {line}" for line in body.split("\n")) - result += f"\n{body}" if result else body + body_ctx = ctx.make_compact().nested() + if body_ctx.allow_body(): + body = self.item.body(body_ctx) + if body: + body = "\n".join(f" {line}" for line in body.split("\n")) + result += f"\n{body}" if result else body return f"* {result}" From 46a839bf0adccbb506d75b199d96c9724d79aa78 Mon Sep 17 00:00:00 2001 From: Misode Date: Sat, 15 Feb 2025 17:27:07 +0100 Subject: [PATCH 12/14] Use application emoji manager --- commanderbot/ext/mcdoc/mcdoc_cog.py | 19 +++-- commanderbot/ext/mcdoc/mcdoc_exceptions.py | 6 ++ commanderbot/ext/mcdoc/mcdoc_options.py | 2 + commanderbot/ext/mcdoc/mcdoc_types.py | 84 ++++++++-------------- 4 files changed, 52 insertions(+), 59 deletions(-) diff --git a/commanderbot/ext/mcdoc/mcdoc_cog.py b/commanderbot/ext/mcdoc/mcdoc_cog.py index c1f4f01..3ccd991 100644 --- a/commanderbot/ext/mcdoc/mcdoc_cog.py +++ b/commanderbot/ext/mcdoc/mcdoc_cog.py @@ -1,9 +1,8 @@ from logging import Logger, getLogger from typing import Optional import aiohttp -import re -from discord import Embed, Interaction +from discord import Embed, Emoji, Interaction from discord.app_commands import ( allowed_contexts, allowed_installs, @@ -13,16 +12,17 @@ from discord.ext import tasks from discord.ext.commands import Bot, Cog +from commanderbot.core.commander_bot import CommanderBot from commanderbot.ext.mcdoc.mcdoc_symbols import McdocSymbols from commanderbot.ext.mcdoc.mcdoc_types import McdocContext -from commanderbot.ext.mcdoc.mcdoc_exceptions import InvalidVersionError, RequestSymbolsError, RequestVersionError +from commanderbot.ext.mcdoc.mcdoc_exceptions import InvalidVersionError, RequestSymbolsError, RequestVersionError, EmojiNotFoundError from commanderbot.ext.mcdoc.mcdoc_options import McdocOptions from commanderbot.lib import constants, AllowedMentions class McdocCog(Cog, name="commanderbot.ext.mcdoc"): - def __init__(self, bot: Bot, **options): - self.bot: Bot = bot + def __init__(self, bot: CommanderBot, **options): + self.bot: CommanderBot = bot self.log: Logger = getLogger(self.qualified_name) self.options = McdocOptions.from_data(options) @@ -86,6 +86,13 @@ async def _get_latest_version(self, override: Optional[str]) -> str: return self._latest_version return await self._fetch_latest_version() + def _get_emoji(self, type: str) -> Emoji: + name = self.options.emoji_prefix + type + emoji = self.bot.application_emojis.get(name) + if emoji is None: + raise EmojiNotFoundError(name) + return emoji + @command(name="mcdoc", description="Query vanilla mcdoc types") @describe( query="The mcdoc identifier", @@ -113,7 +120,7 @@ async def cmd_mcdoc(self, interaction: Interaction, query: str, version: Optiona raise InvalidVersionError(version) # Create a context object used for rendering - ctx = McdocContext(version, symbols) + ctx = McdocContext(version, symbols, self._get_emoji) embed: Embed = Embed( title=symbol.title(ctx), diff --git a/commanderbot/ext/mcdoc/mcdoc_exceptions.py b/commanderbot/ext/mcdoc/mcdoc_exceptions.py index 218699a..2e89a34 100644 --- a/commanderbot/ext/mcdoc/mcdoc_exceptions.py +++ b/commanderbot/ext/mcdoc/mcdoc_exceptions.py @@ -25,3 +25,9 @@ class QueryReturnedNoResults(McdocException): def __init__(self, query: str): self.query: str = query super().__init__(f"馃様 Could not find any symbols matching `{self.query}`") + + +class EmojiNotFoundError(McdocException): + def __init__(self, name: str): + self.name: str = name + super().__init__(f"馃樀 Unable to get application emoji `{name}`") diff --git a/commanderbot/ext/mcdoc/mcdoc_options.py b/commanderbot/ext/mcdoc/mcdoc_options.py index 9db2af2..f59fb77 100644 --- a/commanderbot/ext/mcdoc/mcdoc_options.py +++ b/commanderbot/ext/mcdoc/mcdoc_options.py @@ -7,6 +7,7 @@ class McdocOptions(FromDataMixin): symbols_url: str manifest_url: str + emoji_prefix: str icon_url: Optional[str] @classmethod @@ -15,5 +16,6 @@ def try_from_data(cls, data: Any) -> Optional[Self]: return cls( symbols_url=data["symbols_url"], manifest_url=data["manifest_url"], + emoji_prefix=data["emoji_prefix"], icon_url=data.get("icon_url", None), ) diff --git a/commanderbot/ext/mcdoc/mcdoc_types.py b/commanderbot/ext/mcdoc/mcdoc_types.py index c243097..274cc86 100644 --- a/commanderbot/ext/mcdoc/mcdoc_types.py +++ b/commanderbot/ext/mcdoc/mcdoc_types.py @@ -1,34 +1,7 @@ import json from dataclasses import dataclass, field -from typing import Mapping, Protocol, Optional, Union - -ICON_ANY = "<:any:1328878339305246761>" -ICON_BOOLEAN = "<:boolean:1328844824824254475>" -ICON_BYTE = "<:byte:1328844842469425264>" -ICON_BYTE_ARRAY = "<:byte_array:1328844856713412758>" -ICON_DOUBLE = "<:double:1328844873205547028>" -ICON_FLOAT = "<:float:1328844885276622858>" -ICON_INT = "<:int:1328844896903237634>" -ICON_INT_ARRAY = "<:int_array:1328844908898812004>" -ICON_LIST = "<:list:1328844919665856622>" -ICON_LONG = "<:long:1328844930998730812>" -ICON_LONG_ARRAY = "<:long_array:1328844941706793022>" -ICON_SHORT = "<:short:1328844953757028382>" -ICON_STRING = "<:string:1328844965161467956>" -ICON_STRUCT = "<:struct:1328844974661435546>" - -LITERAL_ICONS = { - "boolean": ICON_BOOLEAN, - "byte": ICON_BYTE, - "short": ICON_SHORT, - "int": ICON_INT, - "long": ICON_LONG, - "float": ICON_FLOAT, - "double": ICON_DOUBLE, - "string": ICON_STRING, -} - -TEMPLATE_CHARS = ["馃嚘", "馃嚙", "馃嚚", "馃嚛", "馃嚜", "馃嚝", "馃嚞", "馃嚟", "馃嚠", "馃嚡", "馃嚢", "馃嚤", "馃嚥", "馃嚦", "馃嚧", "馃嚨", "馃嚩", "馃嚪", "馃嚫", "馃嚬", "馃嚭", "馃嚮", "馃嚰", "馃嚱", "馃嚲", "馃嚳"] +from typing import Callable, Mapping, Protocol, Optional, Union +from discord import Emoji def cmp_version(a: str, b: str): @@ -50,6 +23,7 @@ def dispatch(self, registry: str, identifier: str) -> Optional["McdocType"]: class McdocContext: version: str symbols: McdocLookup + emojis: Callable[[str], Emoji] compact: bool = False depth: int = 0 type_mapping: Mapping[str, Union[str, "McdocType"]] = field(default_factory=dict) @@ -70,16 +44,16 @@ def allow_body(self): return self.depth <= 2 def make_compact(self) -> "McdocContext": - return McdocContext(self.version, self.symbols, True, self.depth, self.type_mapping, self.type_args) + return McdocContext(self.version, self.symbols, self.emojis, True, self.depth, self.type_mapping, self.type_args) def nested(self, diff=1) -> "McdocContext": - return McdocContext(self.version, self.symbols, self.compact, self.depth + diff, self.type_mapping, self.type_args) + return McdocContext(self.version, self.symbols, self.emojis, self.compact, self.depth + diff, self.type_mapping, self.type_args) def with_type_mapping(self, mapping: Mapping[str, Union[str, "McdocType"]]) -> "McdocContext": - return McdocContext(self.version, self.symbols, self.compact, self.depth, mapping, self.type_args) + return McdocContext(self.version, self.symbols, self.emojis, self.compact, self.depth, mapping, self.type_args) def with_type_args(self, type_args: list["McdocType"]) -> "McdocContext": - return McdocContext(self.version, self.symbols, self.compact, self.depth, self.type_mapping, type_args) + return McdocContext(self.version, self.symbols, self.emojis, self.compact, self.depth, self.type_mapping, type_args) @dataclass @@ -108,10 +82,11 @@ def title(self, name: str, ctx: McdocContext) -> str: return name def icons(self, ctx: McdocContext) -> list[str]: - return [ICON_ANY] + return ["any"] def prefix(self, ctx: McdocContext) -> str: - return "".join(list(dict.fromkeys(self.icons(ctx)))) + icons = list(dict.fromkeys(self.icons(ctx))) + return "".join([str(ctx.emojis(icon)) for icon in icons]) def suffix(self, ctx: McdocContext) -> str: return "" @@ -246,7 +221,7 @@ def filtered_fields(self, ctx: McdocContext): return [f for f in self.fields if ctx.filter(f.attributes)] def icons(self, ctx): - return [ICON_STRUCT] + return ["struct"] def suffix(self, ctx): fields = self.filtered_fields(ctx) @@ -297,7 +272,7 @@ def title(self, name, ctx): return f"enum {name}" def icons(self, ctx): - return [LITERAL_ICONS[self.enumKind]] + return [self.enumKind] def suffix(self, ctx): values = self.filtered_values(ctx) @@ -405,6 +380,9 @@ def body(self, ctx): return "\n".join(f"{'' if ctx.compact else '\n'}{r}" for r in results) +TEMPLATE_CHARS = ["馃嚘", "馃嚙", "馃嚚", "馃嚛", "馃嚜", "馃嚝", "馃嚞", "馃嚟", "馃嚠", "馃嚡", "馃嚢", "馃嚤", "馃嚥", "馃嚦", "馃嚧", "馃嚨", "馃嚩", "馃嚪", "馃嚫", "馃嚬", "馃嚭", "馃嚮", "馃嚰", "馃嚱", "馃嚲", "馃嚳"] + + @dataclass class TemplateType(McdocBaseType): child: "McdocType" @@ -516,7 +494,7 @@ class StringType(McdocBaseType): lengthRange: Optional[NumericRange] = None def icons(self, ctx): - return [ICON_STRING] + return ["string"] def suffix(self, ctx): result = "a string" @@ -565,7 +543,7 @@ class LiteralType(McdocBaseType): value: bool | str | float def icons(self, ctx): - return [LITERAL_ICONS.get(self.kind, ICON_ANY)] + return [self.kind] def suffix(self, ctx): return json.dumps(self.value) @@ -574,7 +552,7 @@ def suffix(self, ctx): @dataclass class AnyType(McdocBaseType): def icons(self, ctx): - return [ICON_ANY] + return ["any"] def suffix(self, ctx): return "*anything*" @@ -583,7 +561,7 @@ def suffix(self, ctx): @dataclass class UnsafeType(McdocBaseType): def icons(self, ctx): - return [ICON_ANY] + return ["any"] def suffix(self, ctx): return "*anything*" @@ -592,7 +570,7 @@ def suffix(self, ctx): @dataclass class BooleanType(McdocBaseType): def icons(self, ctx): - return [ICON_BOOLEAN] + return ["boolean"] def suffix(self, ctx): return "a boolean" @@ -603,7 +581,7 @@ class ByteType(McdocBaseType): valueRange: Optional[NumericRange] = None def icons(self, ctx): - return [ICON_BYTE] + return ["byte"] def suffix(self, ctx): result = "a byte" @@ -617,7 +595,7 @@ class ShortType(McdocBaseType): valueRange: Optional[NumericRange] = None def icons(self, ctx): - return [ICON_SHORT] + return ["short"] def suffix(self, ctx): result = "a short" @@ -631,7 +609,7 @@ class IntType(McdocBaseType): valueRange: Optional[NumericRange] = None def icons(self, ctx): - return [ICON_INT] + return ["int"] def suffix(self, ctx): result = "an int" @@ -645,7 +623,7 @@ class LongType(McdocBaseType): valueRange: Optional[NumericRange] = None def icons(self, ctx): - return [ICON_LONG] + return ["long"] def suffix(self, ctx): result = "a long" @@ -659,7 +637,7 @@ class FloatType(McdocBaseType): valueRange: Optional[NumericRange] = None def icons(self, ctx): - return [ICON_FLOAT] + return ["float"] def suffix(self, ctx): result = "a float" @@ -673,7 +651,7 @@ class DoubleType(McdocBaseType): valueRange: Optional[NumericRange] = None def icons(self, ctx): - return [ICON_DOUBLE] + return ["double"] def suffix(self, ctx): result = "a double" @@ -688,7 +666,7 @@ class ByteArrayType(McdocBaseType): lengthRange: Optional[NumericRange] = None def icons(self, ctx): - return [ICON_BYTE_ARRAY] + return ["byte_array"] def suffix(self, ctx): result = "a byte array" @@ -707,7 +685,7 @@ class IntArrayType(McdocBaseType): lengthRange: Optional[NumericRange] = None def icons(self, ctx): - return [ICON_INT_ARRAY] + return ["int_array"] def suffix(self, ctx): result = "an int array" @@ -726,7 +704,7 @@ class LongArrayType(McdocBaseType): lengthRange: Optional[NumericRange] = None def icons(self, ctx): - return [ICON_LONG_ARRAY] + return ["long_array"] def suffix(self, ctx): result = "a long array" @@ -745,7 +723,7 @@ class ListType(McdocBaseType): lengthRange: Optional[NumericRange] = None def icons(self, ctx): - return [ICON_LIST] + return ["list"] def suffix(self, ctx): result = "a list" @@ -774,7 +752,7 @@ class TupleType(McdocBaseType): items: list["McdocType"] def icons(self, ctx): - return [ICON_LIST] + return ["list"] def suffix(self, ctx): return f"a tuple of length {len(self.items)}" From 94eb46b96dab9c792e69347c728191f6eb7b6cb5 Mon Sep 17 00:00:00 2001 From: Misode Date: Tue, 18 Feb 2025 22:03:48 +0100 Subject: [PATCH 13/14] Address review comments --- commanderbot/ext/mcdoc/mcdoc_cog.py | 13 +++---------- commanderbot/ext/mcdoc/mcdoc_exceptions.py | 2 +- commanderbot/ext/mcdoc/mcdoc_options.py | 4 ++-- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/commanderbot/ext/mcdoc/mcdoc_cog.py b/commanderbot/ext/mcdoc/mcdoc_cog.py index 3ccd991..85e0c39 100644 --- a/commanderbot/ext/mcdoc/mcdoc_cog.py +++ b/commanderbot/ext/mcdoc/mcdoc_cog.py @@ -1,16 +1,14 @@ -from logging import Logger, getLogger from typing import Optional import aiohttp from discord import Embed, Emoji, Interaction from discord.app_commands import ( - allowed_contexts, allowed_installs, command, describe, ) from discord.ext import tasks -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Cog from commanderbot.core.commander_bot import CommanderBot from commanderbot.ext.mcdoc.mcdoc_symbols import McdocSymbols @@ -18,12 +16,12 @@ from commanderbot.ext.mcdoc.mcdoc_exceptions import InvalidVersionError, RequestSymbolsError, RequestVersionError, EmojiNotFoundError from commanderbot.ext.mcdoc.mcdoc_options import McdocOptions from commanderbot.lib import constants, AllowedMentions +from commanderbot.lib.predicates import is_convertable_to class McdocCog(Cog, name="commanderbot.ext.mcdoc"): def __init__(self, bot: CommanderBot, **options): self.bot: CommanderBot = bot - self.log: Logger = getLogger(self.qualified_name) self.options = McdocOptions.from_data(options) self._latest_version: Optional[str] = None @@ -99,7 +97,6 @@ def _get_emoji(self, type: str) -> Emoji: version="The Minecraft game version (defaults to the latest release)", ) @allowed_installs(guilds=True, users=True) - @allowed_contexts(guilds=True, dms=True, private_channels=True) async def cmd_mcdoc(self, interaction: Interaction, query: str, version: Optional[str]): # Respond to the interaction with a defer since the web request may take a while await interaction.response.defer() @@ -112,11 +109,7 @@ async def cmd_mcdoc(self, interaction: Interaction, query: str, version: Optiona version = await self._get_latest_version(version) # Validate that the version number can be used to compare - if not version.startswith("1."): - raise InvalidVersionError(version) - try: - float(version[2:]) - except: + if not version.startswith("1.") or not is_convertable_to(version[2:], float): raise InvalidVersionError(version) # Create a context object used for rendering diff --git a/commanderbot/ext/mcdoc/mcdoc_exceptions.py b/commanderbot/ext/mcdoc/mcdoc_exceptions.py index 2e89a34..b5073aa 100644 --- a/commanderbot/ext/mcdoc/mcdoc_exceptions.py +++ b/commanderbot/ext/mcdoc/mcdoc_exceptions.py @@ -30,4 +30,4 @@ def __init__(self, query: str): class EmojiNotFoundError(McdocException): def __init__(self, name: str): self.name: str = name - super().__init__(f"馃樀 Unable to get application emoji `{name}`") + super().__init__(f"馃樀 Unable to get application emoji `{self.name}`") diff --git a/commanderbot/ext/mcdoc/mcdoc_options.py b/commanderbot/ext/mcdoc/mcdoc_options.py index f59fb77..b9468f5 100644 --- a/commanderbot/ext/mcdoc/mcdoc_options.py +++ b/commanderbot/ext/mcdoc/mcdoc_options.py @@ -8,7 +8,7 @@ class McdocOptions(FromDataMixin): symbols_url: str manifest_url: str emoji_prefix: str - icon_url: Optional[str] + icon_url: Optional[str] = None @classmethod def try_from_data(cls, data: Any) -> Optional[Self]: @@ -17,5 +17,5 @@ def try_from_data(cls, data: Any) -> Optional[Self]: symbols_url=data["symbols_url"], manifest_url=data["manifest_url"], emoji_prefix=data["emoji_prefix"], - icon_url=data.get("icon_url", None), + icon_url=data.get("icon_url"), ) From 1b026dfbb573afe511d6f897ae8200491e786f17 Mon Sep 17 00:00:00 2001 From: Misode Date: Wed, 19 Feb 2025 21:15:52 +0100 Subject: [PATCH 14/14] Use interaction.delete_original_response --- commanderbot/ext/mcdoc/mcdoc_cog.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/commanderbot/ext/mcdoc/mcdoc_cog.py b/commanderbot/ext/mcdoc/mcdoc_cog.py index 85e0c39..be0dad8 100644 --- a/commanderbot/ext/mcdoc/mcdoc_cog.py +++ b/commanderbot/ext/mcdoc/mcdoc_cog.py @@ -101,16 +101,20 @@ async def cmd_mcdoc(self, interaction: Interaction, query: str, version: Optiona # Respond to the interaction with a defer since the web request may take a while await interaction.response.defer() - # Fetch the vanilla-mcdoc symbols and search for a symbol - symbols = await self._fetch_symbols() - symbol = symbols.search(query) - - # Use the version override or get the cached latest version - version = await self._get_latest_version(version) - - # Validate that the version number can be used to compare - if not version.startswith("1.") or not is_convertable_to(version[2:], float): - raise InvalidVersionError(version) + try: + # Fetch the vanilla-mcdoc symbols and search for a symbol + symbols = await self._fetch_symbols() + symbol = symbols.search(query) + + # Use the version override or get the cached latest version + version = await self._get_latest_version(version) + + # Validate that the version number can be used to compare + if not version.startswith("1.") or not is_convertable_to(version[2:], float): + raise InvalidVersionError(version) + except Exception as ex: + await interaction.delete_original_response() + raise ex # Create a context object used for rendering ctx = McdocContext(version, symbols, self._get_emoji)