Changed code to support older Python versions

This commit is contained in:
Malasaur 2025-12-01 23:27:09 +01:00
parent eb92d2d36f
commit 582458cdd0
5027 changed files with 794942 additions and 4 deletions

View file

@ -0,0 +1,517 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass
from typing import Any, Literal, TYPE_CHECKING
from mcstatus.forge_data import ForgeData, RawForgeData
from mcstatus.motd import Motd
if TYPE_CHECKING:
from typing_extensions import NotRequired, Self, TypeAlias, TypedDict
class RawJavaResponsePlayer(TypedDict):
name: str
id: str
class RawJavaResponsePlayers(TypedDict):
online: int
max: int
sample: NotRequired[list[RawJavaResponsePlayer] | None]
class RawJavaResponseVersion(TypedDict):
name: str
protocol: int
class RawJavaResponseMotdWhenDict(TypedDict, total=False):
text: str # only present if `translate` is set
translate: str # same to the above field
extra: list[RawJavaResponseMotdWhenDict | str]
color: str
bold: bool
strikethrough: bool
italic: bool
underlined: bool
obfuscated: bool
RawJavaResponseMotd: TypeAlias = "RawJavaResponseMotdWhenDict | list[RawJavaResponseMotdWhenDict | str] | str"
class RawJavaResponse(TypedDict):
description: RawJavaResponseMotd
players: RawJavaResponsePlayers
version: RawJavaResponseVersion
favicon: NotRequired[str]
forgeData: NotRequired[RawForgeData | None]
modinfo: NotRequired[RawForgeData | None]
enforcesSecureChat: NotRequired[bool]
class RawQueryResponse(TypedDict):
hostname: str
gametype: Literal["SMP"]
game_id: Literal["MINECRAFT"]
version: str
plugins: str
map: str
numplayers: str # can be transformed into `int`
maxplayers: str # can be transformed into `int`
hostport: str # can be transformed into `int`
hostip: str
else:
RawJavaResponsePlayer = dict
RawJavaResponsePlayers = dict
RawJavaResponseVersion = dict
RawJavaResponseMotdWhenDict = dict
RawJavaResponse = dict
RawQueryResponse = dict
from mcstatus.utils import deprecated
__all__ = [
"BaseStatusPlayers",
"BaseStatusResponse",
"BaseStatusVersion",
"BedrockStatusPlayers",
"BedrockStatusResponse",
"BedrockStatusVersion",
"JavaStatusPlayer",
"JavaStatusPlayers",
"JavaStatusResponse",
"JavaStatusVersion",
"QueryResponse",
]
@dataclass(frozen=True)
class BaseStatusResponse(ABC):
"""Class for storing shared data from a status response."""
players: BaseStatusPlayers
"""The players information."""
version: BaseStatusVersion
"""The version information."""
motd: Motd
"""Message Of The Day. Also known as description.
.. seealso:: :doc:`/api/motd_parsing`.
"""
latency: float
"""Latency between a server and the client (you). In milliseconds."""
@property
def description(self) -> str:
"""Alias to the :meth:`mcstatus.motd.Motd.to_minecraft` method."""
return self.motd.to_minecraft()
@classmethod
@abstractmethod
def build(cls, *args, **kwargs) -> Self:
"""Build BaseStatusResponse and check is it valid.
:param args: Arguments in specific realisation.
:param kwargs: Keyword arguments in specific realisation.
:return: :class:`BaseStatusResponse` object.
"""
raise NotImplementedError("You can't use abstract methods.")
def as_dict(self) -> dict[str, Any]:
"""Return the dataclass as JSON-serializable :class:`dict`.
Do note that this method doesn't return :class:`string <str>` but
:class:`dict`, so you can do some processing on returned value.
Difference from
:attr:`~mcstatus.responses.JavaStatusResponse.raw` is in that,
:attr:`~mcstatus.responses.JavaStatusResponse.raw` returns raw response
in the same format as we got it. This method returns the response
in a more user-friendly JSON serializable format (for example,
:attr:`~mcstatus.responses.BaseStatusResponse.motd` is returned as a
:func:`Minecraft string <mcstatus.motd.Motd.to_minecraft>` and not
:class:`dict`).
"""
as_dict = asdict(self)
as_dict["motd"] = self.motd.simplify().to_minecraft()
return as_dict
@dataclass(frozen=True)
class JavaStatusResponse(BaseStatusResponse):
"""The response object for :meth:`JavaServer.status() <mcstatus.server.JavaServer.status>`."""
raw: RawJavaResponse
"""Raw response from the server.
This is :class:`~typing.TypedDict` actually, please see sources to find what is here.
"""
players: JavaStatusPlayers
version: JavaStatusVersion
enforces_secure_chat: bool | None
"""Whether the server enforces secure chat (every message is signed up with a key).
.. seealso::
`Signed Chat explanation <https://gist.github.com/kennytv/ed783dd244ca0321bbd882c347892874>`_,
`22w17a changelog, where this was added <https://www.minecraft.net/nl-nl/article/minecraft-snapshot-22w17a>`_.
.. versionadded:: 11.1.0
"""
icon: str | None
"""The icon of the server. In `Base64 <https://en.wikipedia.org/wiki/Base64>`_ encoded PNG image format.
.. seealso:: :ref:`pages/faq:how to get server image?`
"""
forge_data: ForgeData | None
"""Forge mod data (mod list, channels, etc). Only present if this is a forge (modded) server."""
@classmethod
def build(cls, raw: RawJavaResponse, latency: float = 0) -> Self:
"""Build JavaStatusResponse and check is it valid.
:param raw: Raw response :class:`dict`.
:param latency: Time that server took to response (in milliseconds).
:raise ValueError: If the required keys (``players``, ``version``, ``description``) are not present.
:raise TypeError:
If the required keys (``players`` - :class:`dict`, ``version`` - :class:`dict`,
``description`` - :class:`str`) are not of the expected type.
:return: :class:`JavaStatusResponse` object.
"""
forge_data: ForgeData | None = None
if (raw_forge := raw.get("forgeData") or raw.get("modinfo")) and raw_forge is not None:
forge_data = ForgeData.build(raw_forge)
return cls(
raw=raw,
players=JavaStatusPlayers.build(raw["players"]),
version=JavaStatusVersion.build(raw["version"]),
motd=Motd.parse(raw.get("description", ""), bedrock=False),
enforces_secure_chat=raw.get("enforcesSecureChat"),
icon=raw.get("favicon"),
latency=latency,
forge_data=forge_data,
)
@dataclass(frozen=True)
class BedrockStatusResponse(BaseStatusResponse):
"""The response object for :meth:`BedrockServer.status() <mcstatus.server.BedrockServer.status>`."""
players: BedrockStatusPlayers
version: BedrockStatusVersion
map_name: str | None
"""The name of the map."""
gamemode: str | None
"""The name of the gamemode on the server."""
@classmethod
def build(cls, decoded_data: list[Any], latency: float) -> Self:
"""Build BaseStatusResponse and check is it valid.
:param decoded_data: Raw decoded response object.
:param latency: Latency of the request.
:return: :class:`BedrockStatusResponse` object.
"""
try:
map_name = decoded_data[7]
except IndexError:
map_name = None
try:
gamemode = decoded_data[8]
except IndexError:
gamemode = None
return cls(
players=BedrockStatusPlayers(
online=int(decoded_data[4]),
max=int(decoded_data[5]),
),
version=BedrockStatusVersion(
name=decoded_data[3],
protocol=int(decoded_data[2]),
brand=decoded_data[0],
),
motd=Motd.parse(decoded_data[1], bedrock=True),
latency=latency,
map_name=map_name,
gamemode=gamemode,
)
@dataclass(frozen=True)
class BaseStatusPlayers(ABC):
"""Class for storing information about players on the server."""
online: int
"""Current number of online players."""
max: int
"""The maximum allowed number of players (aka server slots)."""
@dataclass(frozen=True)
class JavaStatusPlayers(BaseStatusPlayers):
"""Class for storing information about players on the server."""
sample: list[JavaStatusPlayer] | None
"""List of players, who are online. If server didn't provide this, it will be :obj:`None`.
Actually, this is what appears when you hover over the slot count on the multiplayer screen.
.. note::
It's often empty or even contains some advertisement, because the specific server implementations or plugins can
disable providing this information or even change it to something custom.
There is nothing that ``mcstatus`` can to do here if the player sample was modified/disabled like this.
"""
@classmethod
def build(cls, raw: RawJavaResponsePlayers) -> Self:
"""Build :class:`JavaStatusPlayers` from raw response :class:`dict`.
:param raw: Raw response :class:`dict`.
:raise ValueError: If the required keys (``online``, ``max``) are not present.
:raise TypeError:
If the required keys (``online`` - :class:`int`, ``max`` - :class:`int`,
``sample`` - :class:`list`) are not of the expected type.
:return: :class:`JavaStatusPlayers` object.
"""
sample = None
if (sample := raw.get("sample")) is not None:
sample = [JavaStatusPlayer.build(player) for player in sample]
return cls(
online=raw["online"],
max=raw["max"],
sample=sample,
)
@dataclass(frozen=True)
class BedrockStatusPlayers(BaseStatusPlayers):
"""Class for storing information about players on the server."""
@dataclass(frozen=True)
class JavaStatusPlayer:
"""Class with information about a single player."""
name: str
"""Name of the player."""
id: str
"""ID of the player (in `UUID <https://en.wikipedia.org/wiki/Universally_unique_identifier>`_ format)."""
@property
def uuid(self) -> str:
"""Alias to :attr:`.id` field."""
return self.id
@classmethod
def build(cls, raw: RawJavaResponsePlayer) -> Self:
"""Build :class:`JavaStatusPlayer` from raw response :class:`dict`.
:param raw: Raw response :class:`dict`.
:raise ValueError: If the required keys (``name``, ``id``) are not present.
:raise TypeError: If the required keys (``name`` - :class:`str`, ``id`` - :class:`str`)
are not of the expected type.
:return: :class:`JavaStatusPlayer` object.
"""
return cls(name=raw["name"], id=raw["id"])
@dataclass(frozen=True)
class BaseStatusVersion(ABC):
"""A class for storing version information."""
name: str
"""The version name, like ``1.19.3``.
See `Minecraft wiki <https://minecraft.wiki/w/Java_Edition_version_history#Full_release>`__
for complete list.
"""
protocol: int
"""The protocol version, like ``761``.
See `Minecraft wiki <https://minecraft.wiki/w/Protocol_version#Java_Edition_2>`__.
"""
@dataclass(frozen=True)
class JavaStatusVersion(BaseStatusVersion):
"""A class for storing version information."""
@classmethod
def build(cls, raw: RawJavaResponseVersion) -> Self:
"""Build :class:`JavaStatusVersion` from raw response dict.
:param raw: Raw response :class:`dict`.
:raise ValueError: If the required keys (``name``, ``protocol``) are not present.
:raise TypeError: If the required keys (``name`` - :class:`str`, ``protocol`` - :class:`int`)
are not of the expected type.
:return: :class:`JavaStatusVersion` object.
"""
return cls(name=raw["name"], protocol=raw["protocol"])
@dataclass(frozen=True)
class BedrockStatusVersion(BaseStatusVersion):
"""A class for storing version information."""
name: str
"""The version name, like ``1.19.60``.
See `Minecraft wiki <https://minecraft.wiki/w/Bedrock_Edition_version_history#Bedrock_Edition>`__
for complete list.
"""
brand: str
"""``MCPE`` or ``MCEE`` for Education Edition."""
@property
@deprecated(replacement="name", date="2025-12")
def version(self) -> str:
"""
.. deprecated:: 12.0.0
Will be removed 2025-12, use :attr:`.name` instead.
"""
return self.name
@dataclass(frozen=True)
class QueryResponse:
"""The response object for :meth:`JavaServer.query() <mcstatus.server.JavaServer.query>`."""
raw: RawQueryResponse
"""Raw response from the server.
This is :class:`~typing.TypedDict` actually, please see sources to find what is here.
"""
motd: Motd
"""The MOTD of the server. Also known as description.
.. seealso:: :doc:`/api/motd_parsing`.
"""
map_name: str
"""The name of the map. Default is ``world``."""
players: QueryPlayers
"""The players information."""
software: QuerySoftware
"""The software information."""
ip: str
"""The IP address the server is listening/was contacted on."""
port: int
"""The port the server is listening/was contacted on."""
game_type: str = "SMP"
"""The game type of the server. Hardcoded to ``SMP`` (survival multiplayer)."""
game_id: str = "MINECRAFT"
"""The game ID of the server. Hardcoded to ``MINECRAFT``."""
@classmethod
def build(cls, raw: RawQueryResponse, players_list: list[str]) -> Self:
return cls(
raw=raw,
motd=Motd.parse(raw["hostname"], bedrock=False),
map_name=raw["map"],
players=QueryPlayers.build(raw, players_list),
software=QuerySoftware.build(raw["version"], raw["plugins"]),
ip=raw["hostip"],
port=int(raw["hostport"]),
game_type=raw["gametype"],
game_id=raw["game_id"],
)
def as_dict(self) -> dict[str, Any]:
"""Return the dataclass as JSON-serializable :class:`dict`.
Do note that this method doesn't return :class:`string <str>` but
:class:`dict`, so you can do some processing on returned value.
Difference from
:attr:`~mcstatus.responses.JavaStatusResponse.raw` is in that,
:attr:`~mcstatus.responses.JavaStatusResponse.raw` returns raw response
in the same format as we got it. This method returns the response
in a more user-friendly JSON serializable format (for example,
:attr:`~mcstatus.responses.BaseStatusResponse.motd` is returned as a
:func:`Minecraft string <mcstatus.motd.Motd.to_minecraft>` and not
:class:`dict`).
"""
as_dict = asdict(self)
as_dict["motd"] = self.motd.simplify().to_minecraft()
as_dict["players"] = asdict(self.players)
as_dict["software"] = asdict(self.software)
return as_dict
@property
@deprecated(replacement="map_name", date="2025-12")
def map(self) -> str | None:
"""
.. deprecated:: 12.0.0
Will be removed 2025-12, use :attr:`.map_name` instead.
"""
return self.map_name
@dataclass(frozen=True)
class QueryPlayers:
"""Class for storing information about players on the server."""
online: int
"""The number of online players."""
max: int
"""The maximum allowed number of players (server slots)."""
list: list[str]
"""The list of online players."""
@classmethod
def build(cls, raw: RawQueryResponse, players_list: list[str]) -> Self:
return cls(
online=int(raw["numplayers"]),
max=int(raw["maxplayers"]),
list=players_list,
)
@property
@deprecated(replacement="'list' attribute", date="2025-12")
def names(self) -> list[str]:
"""
.. deprecated:: 12.0.0
Will be removed 2025-12, use :attr:`.list` instead.
"""
return self.list
@dataclass(frozen=True)
class QuerySoftware:
"""Class for storing information about software on the server."""
version: str
"""The version of the software."""
brand: str
"""The brand of the software. Like `Paper <https://papermc.io>`_ or `Spigot <https://www.spigotmc.org>`_."""
plugins: list[str]
"""The list of plugins. Can be an empty list if hidden."""
@classmethod
def build(cls, version: str, plugins: str) -> Self:
brand, parsed_plugins = cls._parse_plugins(plugins)
return cls(
version=version,
brand=brand,
plugins=parsed_plugins,
)
@staticmethod
def _parse_plugins(plugins: str) -> tuple[str, list[str]]:
"""Parse plugins string to list.
Returns:
:class:`tuple` with two elements. First is brand of server (:attr:`.brand`)
and second is a list of :attr:`plugins`.
"""
brand = "vanilla"
parsed_plugins = []
if plugins:
parts = plugins.split(":", 1)
brand = parts[0].strip()
if len(parts) == 2:
parsed_plugins = [s.strip() for s in parts[1].split(";")]
return brand, parsed_plugins