Skip to content

Commit

Permalink
Rewrite MOTD parser
Browse files Browse the repository at this point in the history
  • Loading branch information
PerchunPak committed Apr 15, 2023
1 parent 7e7b7f4 commit ab53f18
Show file tree
Hide file tree
Showing 18 changed files with 1,307 additions and 160 deletions.
4 changes: 3 additions & 1 deletion docs/api/basic.rst
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,13 @@ For Java Server
The list of plugins. Can be empty if hidden.

.. attribute:: motd
:type: str
:type: ~mcstatus.motd.Motd
:canonical: mcstatus.querier.QueryResponse.motd

The MOTD of the server. Also known as description.

.. seealso:: :doc:`/api/motd_parsing`.

.. attribute:: map
:type: str
:canonical: mcstatus.querier.QueryResponse.map
Expand Down
48 changes: 48 additions & 0 deletions docs/api/motd_parsing.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
MOTD Parsing
============

We provide a really powerful system to parse servers MOTDs.


The main class
--------------

Firstly there is the main class, which you get directly from :meth:`status <mcstatus.server.MCStatus.status>` methods.

.. autoclass:: mcstatus.motd.Motd
:members:
:undoc-members:


Components
----------

Those are used in :attr:`~mcstatus.motd.Motd.parsed` field.

.. automodule:: mcstatus.motd.components
:members:
:undoc-members:


Transformers
------------

These are basic transformers, that you can use to show a MOTD in different places (like browser or even terminal).

.. automodule:: mcstatus.motd.transformers
:members:
:undoc-members:
:private-members:
:exclude-members: HtmlTransformer, AnsiTransformer, _abc_impl

.. autoclass:: HtmlTransformer
:members:
:undoc-members:
:private-members:
:exclude-members: _abc_impl, FORMATTING_TO_HTML_TAGS, MINECRAFT_COLOR_TO_RGB_BEDROCK, MINECRAFT_COLOR_TO_RGB_JAVA

.. autoclass:: AnsiTransformer
:members:
:undoc-members:
:private-members:
:exclude-members: _abc_impl, FORMATTING_TO_ANSI_TAGS, MINECRAFT_COLOR_TO_RGB
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Content
:caption: API Documentation

api/basic.rst
api/motd_parsing.rst
api/internal.rst


Expand Down
214 changes: 214 additions & 0 deletions mcstatus/motd/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
from __future__ import annotations

import re
import typing as t
from dataclasses import dataclass

from mcstatus.motd.components import Formatting, MinecraftColor, ParsedMotdComponent, TranslationTag, WebColor
from mcstatus.motd.simplifies import get_unused_elements
from mcstatus.motd.transformers import AnsiTransformer, HtmlTransformer, MinecraftTransformer, PlainTransformer

if t.TYPE_CHECKING:
from typing_extensions import Self

from mcstatus.status_response import RawJavaResponseMotd, RawJavaResponseMotdWhenDict # circular import
else:
RawJavaResponseMotdWhenDict = dict

__all__ = ["Motd"]

MOTD_COLORS_RE = re.compile(r"([\xA7|&][0-9A-FK-OR])", re.IGNORECASE)


@dataclass
class Motd:
"""Represents parsed MOTD."""

parsed: list[ParsedMotdComponent]
"""Parsed MOTD, which then will be transformed.
Bases on this attribute, you can easily write your own MOTD-to-something parser.
"""
raw: RawJavaResponseMotd
"""MOTD in raw format, just like the server gave."""
bedrock: bool = False
"""Is server Bedrock Edition? Some details may change in work of this class."""

@classmethod
def parse(cls, raw: RawJavaResponseMotd, *, bedrock: bool = False) -> Self:
"""Parse a raw MOTD to less raw MOTD (:attr:`.parsed` attribute).
:param raw: Raw MOTD, directly from server.
:param bedrock: Is server Bedrock Edition? Nothing changes here, just sets attribute.
:returns: New :class:`.Motd` instance.
"""
original_raw = raw.copy() if hasattr(raw, "copy") else raw # type: ignore # Cannot access "copy" for type "str"
if isinstance(raw, list):
raw = RawJavaResponseMotdWhenDict(**{"extra": raw})

if isinstance(raw, str):
parsed = cls._parse_as_str(raw, bedrock=bedrock)
elif isinstance(raw, dict):
parsed = cls._parse_as_dict(raw, bedrock=bedrock)
else:
raise TypeError(f"Expected list, string or dict data, got {raw.__class__!r} ({raw!r}), report this!")

return cls(parsed, original_raw, bedrock)

@staticmethod
def _parse_as_str(raw: str, *, bedrock: bool = False) -> list[ParsedMotdComponent]:
"""Parse a MOTD when it's string.
.. note:: This method returns a lot of empty strings, use :meth:`Motd.simplify` to remove them.
:param raw: Raw MOTD, directly from server.
:param bedrock: Is server Bedrock Edition?
Ignores :attr:`MinecraftColor.MINECOIN_GOLD` if it's :obj:`False`.
:returns: :obj:`ParsedMotdComponent` list, which need to be passed to ``__init__``.
"""
parsed_motd: list[ParsedMotdComponent] = []

split_raw = MOTD_COLORS_RE.split(raw)
for element in split_raw:
clean_element = element.lstrip("&§").lower()
standardized_element = element.replace("&", "§").lower()

if standardized_element == "§g" and not bedrock:
parsed_motd.append(element) # minecoin_gold on java server, treat as string
continue

if standardized_element.startswith("§"):
try:
parsed_motd.append(MinecraftColor(clean_element))
except ValueError:
try:
parsed_motd.append(Formatting(clean_element))
except ValueError:
# just a text
parsed_motd.append(element)
else:
parsed_motd.append(element)

return parsed_motd

@classmethod
def _parse_as_dict(
cls,
item: RawJavaResponseMotdWhenDict,
*,
bedrock: bool = False,
auto_add: list[ParsedMotdComponent] | None = None,
) -> list[ParsedMotdComponent]:
"""Parse a MOTD when it's dict.
:param item: :class:`dict` directly from the server.
:param bedrock: Is the server Bedrock Edition?
Nothing does here, just going to :meth:`._parse_as_str` while parsing ``text`` field.
:param auto_add: Values to add on this item.
Most time, this is :class:`Formatting` from top level.
:returns: :obj:`ParsedMotdComponent` list, which need to be passed to ``__init__``.
"""
parsed_motd: list[ParsedMotdComponent] = auto_add if auto_add is not None else []

if (color := item.get("color")) is not None:
parsed_motd.append(cls._parse_color(color))

for style_key, style_val in Formatting.__members__.items():
lowered_style_key = style_key.lower()
if item.get(lowered_style_key) is False:
try:
parsed_motd.remove(style_val)
except ValueError:
# some servers set the formatting keys to false here, even without it ever being set to true before
continue
elif item.get(lowered_style_key) is not None:
parsed_motd.append(style_val)

if (text := item.get("text")) is not None:
parsed_motd.extend(cls._parse_as_str(text, bedrock=bedrock))
if (translate := item.get("translate")) is not None:
parsed_motd.append(TranslationTag(translate))
parsed_motd.append(Formatting.RESET)

if "extra" in item:
auto_add = list(filter(lambda e: type(e) is Formatting and e != Formatting.RESET, parsed_motd))

for element in item["extra"]:
parsed_motd.extend(cls._parse_as_dict(element, auto_add=auto_add.copy()))

return parsed_motd

@staticmethod
def _parse_color(color: str) -> ParsedMotdComponent:
"""Parse a color string."""
try:
return MinecraftColor[color.upper()]
except KeyError:
if color == "reset":
# Minecraft servers actually can't return {"reset": True}, instead, they treat
# reset as a color and set {"color": "reset"}. However logically, reset is
# a formatting, and it resets both color and other formatting, so we use
# `Formatting.RESET` here.
#
# see https://wiki.vg/Chat#Shared_between_all_components, `color` field
return Formatting.RESET

# Last attempt: try parsing as HTML (hex rgb) color. Some servers use these to
# achieve gradients.
try:
return WebColor.from_hex(color)
except ValueError:
raise ValueError(f"Unable to parse color: {color!r}, report this!")

def simplify(self) -> Self:
"""Create new MOTD without unused elements.
After parsing, the MOTD may contain some unused elements, like empty strings, or formattings/colors
that don't apply to anything. This method is responsible for creating a new motd with all such elements
removed, providing a much cleaner representation.
:returns: New simplified MOTD, with any unused elements removed.
"""
parsed = self.parsed.copy()
old_parsed: list[ParsedMotdComponent] | None = None

while parsed != old_parsed:
old_parsed = parsed.copy()
unused_elements = get_unused_elements(parsed)
parsed = [el for index, el in enumerate(parsed) if index not in unused_elements]

return __class__(parsed, self.raw, bedrock=self.bedrock)

def to_plain(self) -> str:
"""Get plain text from a MOTD, without any colors/formatting.
This is just a shortcut to :class:`~mcstatus.motd.transformers.PlainTransformer`.
"""
return PlainTransformer().transform(self.parsed)

def to_minecraft(self) -> str:
"""Get Minecraft variant from a MOTD.
This is just a shortcut to :class:`~mcstatus.motd.transformers.MinecraftTransformer`.
.. note:: This will always use ``§``, even if in original MOTD used ``&``.
"""
return MinecraftTransformer().transform(self.parsed)

def to_html(self) -> str:
"""Get HTML from a MOTD.
This is just a shortcut to :class:`~mcstatus.motd.transformers.HtmlTransformer`.
"""
return HtmlTransformer(bedrock=self.bedrock).transform(self.parsed)

def to_ansi(self) -> str:
"""Get ANSI variant from a MOTD.
This is just a shortcut to :class:`~mcstatus.motd.transformers.AnsiTransformer`.
.. note:: We support only ANSI 24 bit colors, please implement your own transformer if you need other standards.
.. seealso:: https://en.wikipedia.org/wiki/ANSI_escape_code
"""
return AnsiTransformer().transform(self.parsed)
Loading

0 comments on commit ab53f18

Please sign in to comment.