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,7 @@
from mcstatus.server import BedrockServer, JavaServer, MCServer
__all__ = [
"BedrockServer",
"JavaServer",
"MCServer",
]

View file

@ -0,0 +1,202 @@
from __future__ import annotations
import dns.resolver
import sys
import json
import argparse
import socket
import dataclasses
from typing import TYPE_CHECKING
from mcstatus import JavaServer, BedrockServer
from mcstatus.responses import JavaStatusResponse
from mcstatus.motd import Motd
if TYPE_CHECKING:
from typing_extensions import TypeAlias
SupportedServers: TypeAlias = "JavaServer | BedrockServer"
PING_PACKET_FAIL_WARNING = (
"warning: contacting {address} failed with a 'ping' packet but succeeded with a 'status' packet,\n"
" this is likely a bug in the server-side implementation.\n"
' (note: ping packet failed due to "{ping_exc}")\n'
" for more details, see: https://mcstatus.readthedocs.io/en/stable/pages/faq/\n"
)
QUERY_FAIL_WARNING = (
"The server did not respond to the query protocol."
"\nPlease ensure that the server has enable-query turned on,"
" and that the necessary port (same as server-port unless query-port is set) is open in any firewall(s)."
"\nSee https://minecraft.wiki/w/Query for further information."
)
def _motd(motd: Motd) -> str:
"""Formats MOTD for human-readable output, with leading line break
if multiline."""
s = motd.to_ansi()
return f"\n{s}" if "\n" in s else f" {s}"
def _kind(serv: SupportedServers) -> str:
if isinstance(serv, JavaServer):
return "Java"
elif isinstance(serv, BedrockServer):
return "Bedrock"
else:
raise ValueError(f"unsupported server for kind: {serv}")
def _ping_with_fallback(server: SupportedServers) -> float:
# bedrock doesn't have ping method
if isinstance(server, BedrockServer):
return server.status().latency
# try faster ping packet first, falling back to status with a warning.
ping_exc = None
try:
return server.ping(tries=1)
except Exception as e:
ping_exc = e
latency = server.status().latency
address = f"{server.address.host}:{server.address.port}"
print(
PING_PACKET_FAIL_WARNING.format(address=address, ping_exc=ping_exc),
file=sys.stderr,
)
return latency
def ping_cmd(server: SupportedServers) -> int:
print(_ping_with_fallback(server))
return 0
def status_cmd(server: SupportedServers) -> int:
response = server.status()
java_res = response if isinstance(response, JavaStatusResponse) else None
if not java_res:
player_sample = ""
elif java_res.players.sample is not None:
player_sample = str([f"{player.name} ({player.id})" for player in java_res.players.sample])
else:
player_sample = "No players online"
if player_sample:
player_sample = " " + player_sample
print(f"version: {_kind(server)} {response.version.name} (protocol {response.version.protocol})")
print(f"motd:{_motd(response.motd)}")
print(f"players: {response.players.online}/{response.players.max}{player_sample}")
print(f"ping: {response.latency:.2f} ms")
return 0
def json_cmd(server: SupportedServers) -> int:
data = {"online": False, "kind": _kind(server)}
status_res = query_res = exn = None
try:
status_res = server.status(tries=1)
except Exception as e:
exn = exn or e
try:
if isinstance(server, JavaServer):
query_res = server.query(tries=1)
except Exception as e:
exn = exn or e
# construct 'data' dict outside try/except to ensure data processing errors
# are noticed.
data["online"] = bool(status_res or query_res)
if not data["online"]:
assert exn, "server offline but no exception?"
data["error"] = str(exn)
if status_res is not None:
data["status"] = dataclasses.asdict(status_res)
# ensure we are overwriting the motd and not making a new dict field
assert "motd" in data["status"], "motd field missing. has it been renamed?"
data["status"]["motd"] = status_res.motd.simplify().to_minecraft()
if query_res is not None:
# TODO: QueryResponse is not (yet?) a dataclass
data["query"] = qdata = {}
qdata["ip"] = query_res.raw["hostip"]
qdata["port"] = query_res.raw["hostport"]
qdata["map"] = query_res.map_name
qdata["plugins"] = query_res.software.plugins
qdata["raw"] = query_res.raw
json.dump(data, sys.stdout)
return 0
def query_cmd(server: SupportedServers) -> int:
if not isinstance(server, JavaServer):
print("The 'query' protocol is only supported by Java servers.", file=sys.stderr)
return 1
try:
response = server.query()
except socket.timeout:
print(QUERY_FAIL_WARNING, file=sys.stderr)
return 1
print(f"host: {response.raw['hostip']}:{response.raw['hostport']}")
print(f"software: {_kind(server)} {response.software.version} {response.software.brand}")
print(f"motd:{_motd(response.motd)}")
print(f"plugins: {response.software.plugins}")
print(f"players: {response.players.online}/{response.players.max} {response.players.list}")
return 0
def main(argv: list[str] = sys.argv[1:]) -> int:
parser = argparse.ArgumentParser(
"mcstatus",
description="""
mcstatus provides an easy way to query 1.7 or newer Minecraft servers for any
information they can expose. It provides three modes of access: query, status,
ping and json.
""",
)
parser.add_argument("address", help="The address of the server.")
parser.add_argument("--bedrock", help="Specifies that 'address' is a Bedrock server (default: Java).", action="store_true")
subparsers = parser.add_subparsers(title="commands", description="Command to run, defaults to 'status'.")
parser.set_defaults(func=status_cmd)
subparsers.add_parser("ping", help="Ping server for latency.").set_defaults(func=ping_cmd)
subparsers.add_parser("status", help="Prints server status.").set_defaults(func=status_cmd)
subparsers.add_parser(
"query", help="Prints detailed server information. Must be enabled in servers' server.properties file."
).set_defaults(func=query_cmd)
subparsers.add_parser(
"json",
help="Prints server status and query in json.",
).set_defaults(func=json_cmd)
args = parser.parse_args(argv)
lookup = JavaServer.lookup if not args.bedrock else BedrockServer.lookup
try:
server = lookup(args.address)
return args.func(server)
except (socket.timeout, socket.gaierror, dns.resolver.NoNameservers, ConnectionError, TimeoutError) as e:
# catch and hide traceback for expected user-facing errors
print(f"Error: {e!r}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,258 @@
from __future__ import annotations
import ipaddress
import sys
import warnings
from pathlib import Path
from typing import NamedTuple, TYPE_CHECKING
from urllib.parse import urlparse
import dns.resolver
import mcstatus.dns
if TYPE_CHECKING:
from typing_extensions import Self
__all__ = ("Address", "async_minecraft_srv_address_lookup", "minecraft_srv_address_lookup")
def _valid_urlparse(address: str) -> tuple[str, int | None]:
"""Parses a string address like 127.0.0.1:25565 into host and port parts
If the address doesn't have a specified port, None will be returned instead.
:raises ValueError:
Unable to resolve hostname of given address
"""
tmp = urlparse("//" + address)
if not tmp.hostname:
raise ValueError(f"Invalid address '{address}', can't parse.")
return tmp.hostname, tmp.port
class _AddressBase(NamedTuple):
"""Intermediate NamedTuple class representing an address.
We can't extend this class directly, since NamedTuples are slotted and
read-only, however child classes can extend __new__, allowing us do some
needed processing on child classes derived from this base class.
"""
host: str
port: int
class Address(_AddressBase):
"""Extension of a :class:`~typing.NamedTuple` of :attr:`.host` and :attr:`.port`, for storing addresses.
This class inherits from :class:`tuple`, and is fully compatible with all functions
which require pure ``(host, port)`` address tuples, but on top of that, it includes
some neat functionalities, such as validity ensuring, alternative constructors
for easy quick creation and methods handling IP resolving.
.. note::
The class is not a part of a Public API, but attributes :attr:`host` and :attr:`port` are a part of Public API.
"""
def __init__(self, host: str, port: int):
# We don't pass the host & port args to super's __init__, because NamedTuples handle
# everything from __new__ and the passed self already has all of the parameters set.
super().__init__()
self._cached_ip: ipaddress.IPv4Address | ipaddress.IPv6Address | None = None
# Make sure the address is valid
self._ensure_validity(self.host, self.port)
@staticmethod
def _ensure_validity(host: object, port: object) -> None:
if not isinstance(host, str):
raise TypeError(f"Host must be a string address, got {type(host)} ({host!r})")
if not isinstance(port, int):
raise TypeError(f"Port must be an integer port number, got {type(port)} ({port!r})")
if port > 65535 or port < 0:
raise ValueError(f"Port must be within the allowed range (0-2^16), got {port!r}")
@classmethod
def from_tuple(cls, tup: tuple[str, int]) -> Self:
"""Construct the class from a regular tuple of ``(host, port)``, commonly used for addresses."""
return cls(host=tup[0], port=tup[1])
@classmethod
def from_path(cls, path: Path, *, default_port: int | None = None) -> Self:
"""Construct the class from a :class:`~pathlib.Path` object.
If path has a port specified, use it, if not fall back to ``default_port`` kwarg.
In case ``default_port`` isn't provided and port wasn't specified, raise :exc:`ValueError`.
"""
address = str(path)
return cls.parse_address(address, default_port=default_port)
@classmethod
def parse_address(cls, address: str, *, default_port: int | None = None) -> Self:
"""Parses a string address like ``127.0.0.1:25565`` into :attr:`.host` and :attr:`.port` parts.
If the address has a port specified, use it, if not, fall back to ``default_port`` kwarg.
:raises ValueError:
Either the address isn't valid and can't be parsed,
or it lacks a port and ``default_port`` wasn't specified.
"""
hostname, port = _valid_urlparse(address)
if port is None:
if default_port is not None:
port = default_port
else:
raise ValueError(
f"Given address '{address}' doesn't contain port and default_port wasn't specified, can't parse."
)
return cls(host=hostname, port=port)
def resolve_ip(self, lifetime: float | None = None) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
"""Resolves a hostname's A record into an IP address.
If the host is already an IP, this resolving is skipped
and host is returned directly.
:param lifetime:
How many seconds a query should run before timing out.
Default value for this is inherited from :func:`dns.resolver.resolve`.
:raises dns.exception.DNSException:
One of the exceptions possibly raised by :func:`dns.resolver.resolve`.
Most notably this will be :exc:`dns.exception.Timeout` and :exc:`dns.resolver.NXDOMAIN`
"""
if self._cached_ip is not None:
return self._cached_ip
host = self.host
if self.host == "localhost" and sys.platform == "darwin":
host = "127.0.0.1"
warnings.warn(
"On macOS because of some mysterious reasons we can't resolve localhost into IP. "
"Please, replace 'localhost' with '127.0.0.1' (or '::1' for IPv6) in your code to remove this warning.",
category=RuntimeWarning,
stacklevel=2,
)
try:
ip = ipaddress.ip_address(host)
except ValueError:
# ValueError is raised if the given address wasn't valid
# this means it's a hostname and we should try to resolve
# the A record
ip_addr = mcstatus.dns.resolve_a_record(self.host, lifetime=lifetime)
ip = ipaddress.ip_address(ip_addr)
self._cached_ip = ip
return self._cached_ip
async def async_resolve_ip(self, lifetime: float | None = None) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
"""Resolves a hostname's A record into an IP address.
See the docstring for :meth:`.resolve_ip` for further info. This function is purely
an async alternative to it.
"""
if self._cached_ip is not None:
return self._cached_ip
host = self.host
if self.host == "localhost" and sys.platform == "darwin":
host = "127.0.0.1"
warnings.warn(
"On macOS because of some mysterious reasons we can't resolve localhost into IP. "
"Please, replace 'localhost' with '127.0.0.1' (or '::1' for IPv6) in your code to remove this warning.",
category=RuntimeWarning,
stacklevel=2,
)
try:
ip = ipaddress.ip_address(host)
except ValueError:
# ValueError is raised if the given address wasn't valid
# this means it's a hostname and we should try to resolve
# the A record
ip_addr = await mcstatus.dns.async_resolve_a_record(self.host, lifetime=lifetime)
ip = ipaddress.ip_address(ip_addr)
self._cached_ip = ip
return self._cached_ip
def minecraft_srv_address_lookup(
address: str,
*,
default_port: int | None = None,
lifetime: float | None = None,
) -> Address:
"""Lookup the SRV record for a Minecraft server.
Firstly it parses the address, if it doesn't include port, tries SRV record, and if it's not there,
falls back on ``default_port``.
This function essentially mimics the address field of a Minecraft Java server. It expects an address like
``192.168.0.100:25565``, if this address does contain a port, it will simply use it. If it doesn't, it will try
to perform an SRV lookup, which if found, will contain the info on which port to use. If there's no SRV record,
this will fall back to the given ``default_port``.
:param address:
The same address which would be used in minecraft's server address field.
Can look like: ``127.0.0.1``, or ``192.168.0.100:12345``, or ``mc.hypixel.net``, or ``example.com:12345``.
:param lifetime:
How many seconds a query should run before timing out.
Default value for this is inherited from :func:`dns.resolver.resolve`.
:raises ValueError:
Either the address isn't valid and can't be parsed,
or it lacks a port, SRV record isn't present, and ``default_port`` wasn't specified.
"""
host, port = _valid_urlparse(address)
# If we found a port in the address, there's nothing more we need
if port is not None:
return Address(host, port)
# Otherwise, try to check for an SRV record, pointing us to the
# port which we should use. If there's no such record, fall back
# to the default_port (if it's defined).
try:
host, port = mcstatus.dns.resolve_mc_srv(host, lifetime=lifetime)
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
if default_port is None:
raise ValueError(
f"Given address '{address}' doesn't contain port, doesn't have an SRV record pointing to a port,"
" and default_port wasn't specified, can't parse."
)
port = default_port
return Address(host, port)
async def async_minecraft_srv_address_lookup(
address: str,
*,
default_port: int | None = None,
lifetime: float | None = None,
) -> Address:
"""Just an async alternative to :func:`.minecraft_srv_address_lookup`, check it for more details."""
host, port = _valid_urlparse(address)
# If we found a port in the address, there's nothing more we need
if port is not None:
return Address(host, port)
# Otherwise, try to check for an SRV record, pointing us to the
# port which we should use. If there's no such record, fall back
# to the default_port (if it's defined).
try:
host, port = await mcstatus.dns.async_resolve_mc_srv(host, lifetime=lifetime)
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
if default_port is None:
raise ValueError(
f"Given address '{address}' doesn't contain port, doesn't have an SRV record pointing to a port,"
" and default_port wasn't specified, can't parse."
)
port = default_port
return Address(host, port)

View file

@ -0,0 +1,66 @@
from __future__ import annotations
import asyncio
import socket
import struct
from time import perf_counter
import asyncio_dgram
from mcstatus.address import Address
from mcstatus.responses import BedrockStatusResponse
class BedrockServerStatus:
request_status_data = bytes.fromhex(
# see https://minecraft.wiki/w/RakNet#Unconnected_Ping
"01" + "0000000000000000" + "00ffff00fefefefefdfdfdfd12345678" + "0000000000000000"
)
def __init__(self, address: Address, timeout: float = 3):
self.address = address
self.timeout = timeout
@staticmethod
def parse_response(data: bytes, latency: float) -> BedrockStatusResponse:
data = data[1:]
name_length = struct.unpack(">H", data[32:34])[0]
decoded_data = data[34 : 34 + name_length].decode().split(";")
return BedrockStatusResponse.build(decoded_data, latency)
def read_status(self) -> BedrockStatusResponse:
start = perf_counter()
data = self._read_status()
end = perf_counter()
return self.parse_response(data, (end - start) * 1000)
def _read_status(self) -> bytes:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(self.timeout)
s.sendto(self.request_status_data, self.address)
data, _ = s.recvfrom(2048)
return data
async def read_status_async(self) -> BedrockStatusResponse:
start = perf_counter()
data = await self._read_status_async()
end = perf_counter()
return self.parse_response(data, (end - start) * 1000)
async def _read_status_async(self) -> bytes:
stream = None
try:
conn = asyncio_dgram.connect(self.address)
stream = await asyncio.wait_for(conn, timeout=self.timeout)
await asyncio.wait_for(stream.send(self.request_status_data), timeout=self.timeout)
data, _ = await asyncio.wait_for(stream.recv(), timeout=self.timeout)
finally:
if stream is not None:
stream.close()
return data

View file

@ -0,0 +1,94 @@
from __future__ import annotations
from typing import cast
import dns.asyncresolver
import dns.resolver
from dns.rdatatype import RdataType
from dns.rdtypes.IN.A import A as ARecordAnswer
from dns.rdtypes.IN.SRV import SRV as SRVRecordAnswer # noqa: N811 # constant imported as non constant (it's class)
def resolve_a_record(hostname: str, lifetime: float | None = None) -> str:
"""Perform a DNS resolution for an A record to given hostname
:param hostname: The address to resolve for.
:return: The resolved IP address from the A record
:raises dns.exception.DNSException:
One of the exceptions possibly raised by :func:`dns.resolver.resolve`.
Most notably this will be :exc:`dns.exception.Timeout`, :exc:`dns.resolver.NXDOMAIN`
and :exc:`dns.resolver.NoAnswer`
"""
answers = dns.resolver.resolve(hostname, RdataType.A, lifetime=lifetime, search=True)
# There should only be one answer here, though in case the server
# does actually point to multiple IPs, we just pick the first one
answer = cast(ARecordAnswer, answers[0])
ip = str(answer).rstrip(".")
return ip
async def async_resolve_a_record(hostname: str, lifetime: float | None = None) -> str:
"""Asynchronous alternative to :func:`.resolve_a_record`.
For more details, check it.
"""
answers = await dns.asyncresolver.resolve(hostname, RdataType.A, lifetime=lifetime, search=True)
# There should only be one answer here, though in case the server
# does actually point to multiple IPs, we just pick the first one
answer = cast(ARecordAnswer, answers[0])
ip = str(answer).rstrip(".")
return ip
def resolve_srv_record(query_name: str, lifetime: float | None = None) -> tuple[str, int]:
"""Perform a DNS resolution for SRV record pointing to the Java Server.
:param query_name: The address to resolve for.
:return: A tuple of host string and port number
:raises dns.exception.DNSException:
One of the exceptions possibly raised by :func:`dns.resolver.resolve`.
Most notably this will be :exc:`dns.exception.Timeout`, :exc:`dns.resolver.NXDOMAIN`
and :exc:`dns.resolver.NoAnswer`
"""
answers = dns.resolver.resolve(query_name, RdataType.SRV, lifetime=lifetime, search=True)
# There should only be one answer here, though in case the server
# does actually point to multiple IPs, we just pick the first one
answer = cast(SRVRecordAnswer, answers[0])
host = str(answer.target).rstrip(".")
port = int(answer.port)
return host, port
async def async_resolve_srv_record(query_name: str, lifetime: float | None = None) -> tuple[str, int]:
"""Asynchronous alternative to :func:`.resolve_srv_record`.
For more details, check it.
"""
answers = await dns.asyncresolver.resolve(query_name, RdataType.SRV, lifetime=lifetime, search=True)
# There should only be one answer here, though in case the server
# does actually point to multiple IPs, we just pick the first one
answer = cast(SRVRecordAnswer, answers[0])
host = str(answer.target).rstrip(".")
port = int(answer.port)
return host, port
def resolve_mc_srv(hostname: str, lifetime: float | None = None) -> tuple[str, int]:
"""Resolve SRV record for a minecraft server on given hostname.
:param str hostname: The address, without port, on which an SRV record is present.
:return: Obtained target and port from the SRV record, on which the server should live on.
:raises dns.exception.DNSException:
One of the exceptions possibly raised by :func:`dns.resolver.resolve`.
Most notably this will be :exc:`dns.exception.Timeout`, :exc:`dns.resolver.NXDOMAIN`
and :exc:`dns.resolver.NoAnswer`.
"""
return resolve_srv_record("_minecraft._tcp." + hostname, lifetime=lifetime)
async def async_resolve_mc_srv(hostname: str, lifetime: float | None = None) -> tuple[str, int]:
"""Asynchronous alternative to :func:`.resolve_mc_srv`.
For more details, check it.
"""
return await async_resolve_srv_record("_minecraft._tcp." + hostname, lifetime=lifetime)

View file

@ -0,0 +1,262 @@
"""Decoder for data from Forge, that is included into a response object.
After 1.18.1, Forge started to compress its mod data into a
UTF-16 string that represents binary data containing data like
the forge mod loader network version, a big list of channels
that all the forge mods use, and a list of mods the server has.
Before 1.18.1, the mod data was in `forgeData` attribute inside
a response object. We support this implementation too.
For more information see this file from forge itself:
https://github.com/MinecraftForge/MinecraftForge/blob/54b08d2711a15418130694342a3fe9a5dfe005d2/src/main/java/net/minecraftforge/network/ServerStatusPing.java#L27-L73
"""
from __future__ import annotations
from dataclasses import dataclass
from io import StringIO
from typing import Final, TYPE_CHECKING
from mcstatus.protocol.connection import BaseConnection, BaseReadSync, Connection
from mcstatus.utils import or_none
VERSION_FLAG_IGNORE_SERVER_ONLY: Final = 0b1
IGNORE_SERVER_ONLY: Final = "<not required for client>"
if TYPE_CHECKING:
from typing_extensions import Self, TypedDict
class RawForgeDataChannel(TypedDict):
res: str
"""Channel name and ID (for example ``fml:handshake``)."""
version: str
"""Channel version (for example ``1.2.3.4``)."""
required: bool
"""Is this channel required for client to join?"""
class RawForgeDataMod(TypedDict, total=False):
modid: str
modId: str
modmarker: str
"""Mod version."""
version: str
class RawForgeData(TypedDict, total=False):
fmlNetworkVersion: int
channels: list[RawForgeDataChannel]
mods: list[RawForgeDataMod]
modList: list[RawForgeDataMod]
d: str
truncated: bool
else:
RawForgeDataChannel = dict
RawForgeDataMod = dict
RawForgeData = dict
@dataclass(frozen=True)
class ForgeDataChannel:
name: str
"""Channel name and ID (for example ``fml:handshake``)."""
version: str
"""Channel version (for example ``1.2.3.4``)."""
required: bool
"""Is this channel required for client to join?"""
@classmethod
def build(cls, raw: RawForgeDataChannel) -> Self:
"""Build an object about Forge channel from raw response.
:param raw: ``channel`` element in raw forge response :class:`dict`.
:return: :class:`ForgeDataChannel` object.
"""
return cls(name=raw["res"], version=raw["version"], required=raw["required"])
@classmethod
def decode(cls, buffer: Connection, mod_id: str | None = None) -> Self:
"""Decode an object about Forge channel from decoded optimized buffer.
:param buffer: :class:`Connection` object from UTF-16 encoded binary data.
:param mod_id: Optional mod id prefix :class:`str`.
:return: :class:`ForgeDataChannel` object.
"""
channel_identifier = buffer.read_utf()
if mod_id is not None:
channel_identifier = f"{mod_id}:{channel_identifier}"
version = buffer.read_utf()
client_required = buffer.read_bool()
return cls(
name=channel_identifier,
version=version,
required=client_required,
)
@dataclass(frozen=True)
class ForgeDataMod:
name: str
marker: str
@classmethod
def build(cls, raw: RawForgeDataMod) -> Self:
"""Build an object about Forge mod from raw response.
:param raw: ``mod`` element in raw forge response :class:`dict`.
:return: :class:`ForgeDataMod` object.
"""
# In FML v1, modmarker was version instead.
mod_version = or_none(raw.get("modmarker"), raw.get("version"))
if mod_version is None:
raise KeyError(f"Mod version in Forge mod data must be provided. Mod info: {raw}")
# In FML v2, modid was modId instead. At least one of the two should exist.
mod_id = or_none(raw.get("modid"), raw.get("modId"))
if mod_id is None:
raise KeyError(f"Mod ID in Forge mod data must be provided. Mod info: {raw}.")
return cls(name=mod_id, marker=mod_version)
@classmethod
def decode(cls, buffer: Connection) -> tuple[Self, list[ForgeDataChannel]]:
"""Decode data about a Forge mod from decoded optimized buffer.
:param buffer: :class:`Connection` object from UTF-16 encoded binary data.
:return: :class:`tuple` object of :class:`ForgeDataMod` object and :class:`list` of :class:`ForgeDataChannel` objects.
"""
channel_version_flags = buffer.read_varint()
channel_count = channel_version_flags >> 1
is_server = channel_version_flags & VERSION_FLAG_IGNORE_SERVER_ONLY != 0
mod_id = buffer.read_utf()
mod_version = IGNORE_SERVER_ONLY
if not is_server:
mod_version = buffer.read_utf()
channels: list[ForgeDataChannel] = []
for _ in range(channel_count):
channels.append(ForgeDataChannel.decode(buffer, mod_id))
return cls(name=mod_id, marker=mod_version), channels
class StringBuffer(BaseReadSync, BaseConnection):
"""String Buffer for reading utf-16 encoded binary data."""
__slots__ = ("received", "stringio")
def __init__(self, stringio: StringIO) -> None:
self.stringio = stringio
self.received = bytearray()
def read(self, length: int) -> bytearray:
"""Read length bytes from ``self``, and return a byte array."""
data = bytearray()
while self.received and len(data) < length:
data.append(self.received.pop(0))
while len(data) < length:
result = self.stringio.read(1)
if not result:
raise IOError(f"Not enough data to read! {len(data)} < {length}")
data.extend(result.encode("utf-16be"))
while len(data) > length:
self.received.append(data.pop())
return data
def remaining(self) -> int:
"""Return number of reads remaining."""
return len(self.stringio.getvalue()) - self.stringio.tell() + len(self.received)
def read_optimized_size(self) -> int:
"""Read encoded data length."""
return self.read_short() | (self.read_short() << 15)
def read_optimized_buffer(self) -> Connection:
"""Read encoded buffer."""
size = self.read_optimized_size()
buffer = Connection()
value, bits = 0, 0
while buffer.remaining() < size:
if bits < 8 and self.remaining():
# Ignoring sign bit
value |= (self.read_short() & 0x7FFF) << bits
bits += 15
buffer.receive((value & 0xFF).to_bytes(1, "big"))
value >>= 8
bits -= 8
return buffer
@dataclass(frozen=True)
class ForgeData:
fml_network_version: int
"""Forge Mod Loader network version."""
channels: list[ForgeDataChannel]
"""List of channels, both for mods and non-mods."""
mods: list[ForgeDataMod]
"""List of mods"""
truncated: bool
"""Is the mods list and or channel list incomplete?"""
@staticmethod
def _decode_optimized(string: str) -> Connection:
"""Decode buffer from UTF-16 optimized binary data ``string``."""
with StringIO(string) as text:
str_buffer = StringBuffer(text)
return str_buffer.read_optimized_buffer()
@classmethod
def build(cls, raw: RawForgeData) -> Self | None:
"""Build an object about Forge mods from raw response.
:param raw: ``forgeData`` attribute in raw response :class:`dict`.
:return: :class:`ForgeData` object.
"""
fml_network_version = raw.get("fmlNetworkVersion", 1)
# see https://github.com/MinecraftForge/MinecraftForge/blob/7d0330eb08299935714e34ac651a293e2609aa86/src/main/java/net/minecraftforge/network/ServerStatusPing.java#L27-L73 # noqa: E501 # line too long
if "d" not in raw:
mod_list = raw.get("mods") or raw.get("modList")
if mod_list is None:
raise KeyError("Neither `mods` or `modList` keys exist.")
return cls(
fml_network_version=fml_network_version,
channels=[ForgeDataChannel.build(channel) for channel in raw.get("channels", ())],
mods=[ForgeDataMod.build(mod) for mod in mod_list],
truncated=False,
)
buffer = cls._decode_optimized(raw["d"])
channels: list[ForgeDataChannel] = []
mods: list[ForgeDataMod] = []
truncated = buffer.read_bool()
mod_count = buffer.read_ushort()
try:
for _ in range(mod_count):
mod, mod_channels = ForgeDataMod.decode(buffer)
channels.extend(mod_channels)
mods.append(mod)
non_mod_channel_count = buffer.read_varint()
for _ in range(non_mod_channel_count):
channels.append(ForgeDataChannel.decode(buffer))
except IOError:
if not truncated:
raise # If answer wasn't truncated, we lost some data on the way
return cls(
fml_network_version=fml_network_version,
channels=channels,
mods=mods,
truncated=truncated,
)

View file

@ -0,0 +1,225 @@
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, squash_nearby_strings
from mcstatus.motd.transformers import AnsiTransformer, HtmlTransformer, MinecraftTransformer, PlainTransformer
if t.TYPE_CHECKING:
from typing_extensions import Self
from mcstatus.responses import RawJavaResponseMotd, RawJavaResponseMotdWhenDict # circular import
else:
RawJavaResponseMotdWhenDict = dict
__all__ = ["Motd"]
MOTD_COLORS_RE = re.compile(r"([\xA7|&][0-9A-FK-OR])", re.IGNORECASE)
@dataclass(frozen=True)
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, # type: ignore # later, we overwrite the type
*,
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())
if isinstance(element, dict)
else auto_add + cls._parse_as_str(element, bedrock=bedrock)
)
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 `color` field in
# https://minecraft.wiki/w/Java_Edition_protocol/Chat?oldid=2763811#Shared_between_all_components
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]
parsed = squash_nearby_strings(parsed)
return self.__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)

View file

@ -0,0 +1,126 @@
from __future__ import annotations
import typing as t
from dataclasses import dataclass
from enum import Enum
if t.TYPE_CHECKING:
from typing_extensions import Self, TypeAlias
class Formatting(Enum):
"""Enum for Formatting codes.
See `Minecraft wiki <https://minecraft.wiki/w/Formatting_codes#Formatting_codes>`__
for more info.
.. note::
:attr:`.STRIKETHROUGH` and :attr:`.UNDERLINED` don't work on Bedrock, which our parser
doesn't keep it in mind. See `MCPE-41729 <https://bugs.mojang.com/browse/MCPE-41729>`_.
"""
BOLD = "l"
ITALIC = "o"
UNDERLINED = "n"
STRIKETHROUGH = "m"
OBFUSCATED = "k"
RESET = "r"
class MinecraftColor(Enum):
"""Enum for Color codes.
See `Minecraft wiki <https://minecraft.wiki/w/Formatting_codes#Color_codes>`_
for more info.
"""
BLACK = "0"
DARK_BLUE = "1"
DARK_GREEN = "2"
DARK_AQUA = "3"
DARK_RED = "4"
DARK_PURPLE = "5"
GOLD = "6"
GRAY = "7"
DARK_GRAY = "8"
BLUE = "9"
GREEN = "a"
AQUA = "b"
RED = "c"
LIGHT_PURPLE = "d"
YELLOW = "e"
WHITE = "f"
# Only for bedrock
MINECOIN_GOLD = "g"
@dataclass(frozen=True)
class WebColor:
"""Raw HTML color from MOTD.
Can be found in MOTD when someone uses gradient.
.. note:: Actually supported in Minecraft 1.16+ only.
"""
hex: str
rgb: tuple[int, int, int]
@classmethod
def from_hex(cls, hex: str) -> Self:
"""Construct web color using hex color string.
:raises ValueError: Invalid hex color string.
:returns: New :class:`WebColor` instance.
"""
hex = hex.lstrip("#")
if len(hex) not in (3, 6):
raise ValueError(f"Got too long/short hex color: {'#' + hex!r}")
if len(hex) == 3:
hex = "{0}{0}{1}{1}{2}{2}".format(*hex)
try:
rgb = t.cast("tuple[int, int, int]", tuple(int(hex[i : i + 2], 16) for i in (0, 2, 4)))
except ValueError:
raise ValueError(f"Failed to parse given hex color: {'#' + hex!r}")
return cls.from_rgb(rgb)
@classmethod
def from_rgb(cls, rgb: tuple[int, int, int]) -> Self:
"""Construct web color using rgb color tuple.
:raises ValueError: When RGB color is out of its 8-bit range.
:returns: New :class:`WebColor` instance.
"""
cls._check_rgb(rgb)
hex = "#{:02x}{:02x}{:02x}".format(*rgb)
return cls(hex, rgb)
@staticmethod
def _check_rgb(rgb: tuple[int, int, int]) -> None:
index_to_color_name = {0: "red", 1: "green", 2: "blue"}
for index, value in enumerate(rgb):
if not 255 >= value >= 0:
color_name = index_to_color_name[index]
raise ValueError(f"RGB color byte out of its 8-bit range (0-255) for {color_name} ({value=})")
@dataclass(frozen=True)
class TranslationTag:
"""Represents a ``translate`` field in server's answer.
This just exists, but is completely ignored by our transformers.
You can find translation tags in :attr:`Motd.parsed <mcstatus.motd.Motd.parsed>` attribute.
.. seealso:: `Minecraft's wiki. <https://minecraft.wiki/w/Raw_JSON_text_format#Translated_Text>`__
"""
id: str
ParsedMotdComponent: TypeAlias = "Formatting | MinecraftColor | WebColor | TranslationTag | str"

View file

@ -0,0 +1,194 @@
from __future__ import annotations
import typing as t
from collections.abc import Sequence
from mcstatus.motd.components import Formatting, MinecraftColor, ParsedMotdComponent, WebColor
_PARSED_MOTD_COMPONENTS_TYPEVAR = t.TypeVar("_PARSED_MOTD_COMPONENTS_TYPEVAR", bound="list[ParsedMotdComponent]")
def get_unused_elements(parsed: Sequence[ParsedMotdComponent]) -> set[int]:
"""Get indices of all items which are unused and can be safely removed from the MOTD.
This is a wrapper method around several unused item collection methods.
"""
to_remove: set[int] = set()
for simplifier in [
get_double_items,
get_double_colors,
get_formatting_before_color,
get_meaningless_resets_and_colors,
get_empty_text,
get_end_non_text,
]:
to_remove.update(simplifier(parsed))
return to_remove
def squash_nearby_strings(parsed: _PARSED_MOTD_COMPONENTS_TYPEVAR) -> _PARSED_MOTD_COMPONENTS_TYPEVAR:
"""Squash duplicate strings together.
Note that this function doesn't create a copy of passed array, it modifies it.
This is what those typevars are for in the function signature.
"""
# in order to not break indexes, we need to fill values and then remove them after the loop
fillers: set[int] = set()
for index, item in enumerate(parsed):
if not isinstance(item, str):
continue
try:
next_item = parsed[index + 1]
except IndexError: # Last item (without any next item)
break
if isinstance(next_item, str):
parsed[index + 1] = item + next_item
fillers.add(index)
for already_removed, index_to_remove in enumerate(fillers):
parsed.pop(index_to_remove - already_removed)
return parsed
def get_double_items(parsed: Sequence[ParsedMotdComponent]) -> set[int]:
"""Get indices of all doubled items that can be removed.
Removes any items that are followed by an item of the same kind (compared using ``__eq__``).
"""
to_remove: set[int] = set()
for index, item in enumerate(parsed):
try:
next_item = parsed[index + 1]
except IndexError: # Last item (without any next item)
break
if isinstance(item, (Formatting, MinecraftColor, WebColor)) and item == next_item:
to_remove.add(index)
return to_remove
def get_double_colors(parsed: Sequence[ParsedMotdComponent]) -> set[int]:
"""Get indices of all doubled color items.
As colors (obviously) override each other, we only ever care about the last one, ignore
the previous ones. (for example: specifying red color, then orange, then yellow, then some text
will just result in yellow text)
"""
to_remove: set[int] = set()
prev_color: int | None = None
for index, item in enumerate(parsed):
if isinstance(item, (MinecraftColor, WebColor)):
# If we found a color after another, remove the previous color
if prev_color is not None:
to_remove.add(prev_color)
prev_color = index
# If we find a string, that's what our color we found previously applies to,
# set prev_color to None, marking this color as used
if isinstance(item, str):
prev_color = None
return to_remove
def get_formatting_before_color(parsed: Sequence[ParsedMotdComponent]) -> set[int]:
"""Obtain indices of all unused formatting items before colors.
Colors override any formatting before them, meaning we only ever care about the color, and can
ignore all formatting before it. (For example: specifying bold formatting, then italic, then yellow,
will just result in yellow text.)
"""
to_remove: set[int] = set()
collected_formattings = []
for index, item in enumerate(parsed):
# Collect the indices of formatting items
if isinstance(item, Formatting):
collected_formattings.append(index)
# Only run checks if we have some collected formatting items
if len(collected_formattings) == 0:
continue
# If there's a string after some formattings, the formattings apply to it.
# This means they're not unused, remove them.
if isinstance(item, str) and not item.isspace():
collected_formattings = []
continue
# If there's a color after some formattings, these formattings will be overridden
# as colors reset everything. This makes these formattings pointless, mark them
# for removal.
if isinstance(item, (MinecraftColor, WebColor)):
to_remove.update(collected_formattings)
collected_formattings = []
return to_remove
def get_empty_text(parsed: Sequence[ParsedMotdComponent]) -> set[int]:
"""Get indices of all empty text items.
Empty strings in motd serve no purpose and can be marked for removal.
"""
to_remove: set[int] = set()
for index, item in enumerate(parsed):
if isinstance(item, str) and len(item) == 0:
to_remove.add(index)
return to_remove
def get_end_non_text(parsed: Sequence[ParsedMotdComponent]) -> set[int]:
"""Get indices of all trailing items, found after the last text component.
Any color/formatting items only make sense when they apply to some text.
If there are some at the end, after the last text, they're pointless and
can be removed.
"""
to_remove: set[int] = set()
for rev_index, item in enumerate(reversed(parsed)):
# The moment we find our last string, stop the loop
if isinstance(item, str):
break
# Remove any color/formatting that doesn't apply to text
if isinstance(item, (MinecraftColor, WebColor, Formatting)):
index = len(parsed) - 1 - rev_index
to_remove.add(index)
return to_remove
def get_meaningless_resets_and_colors(parsed: Sequence[ParsedMotdComponent]) -> set[int]:
to_remove: set[int] = set()
active_color: MinecraftColor | WebColor | None = None
active_formatting: Formatting | None = None
for index, item in enumerate(parsed):
if isinstance(item, (MinecraftColor, WebColor)):
if active_color == item:
to_remove.add(index)
active_color = item
continue
if isinstance(item, Formatting):
if item == Formatting.RESET:
if active_color is None and active_formatting is None:
to_remove.add(index)
continue
active_color, active_formatting = None, None
continue
if active_formatting == item:
to_remove.add(index)
active_formatting = item
return to_remove

View file

@ -0,0 +1,223 @@
from __future__ import annotations
import abc
import typing as t
from collections.abc import Callable, Sequence
from mcstatus.motd.components import Formatting, MinecraftColor, ParsedMotdComponent, TranslationTag, WebColor
_HOOK_RETURN_TYPE = t.TypeVar("_HOOK_RETURN_TYPE")
_END_RESULT_TYPE = t.TypeVar("_END_RESULT_TYPE")
class BaseTransformer(abc.ABC, t.Generic[_HOOK_RETURN_TYPE, _END_RESULT_TYPE]):
"""Base motd transformer class.
Motd transformer is responsible for providing a way to generate an alternative representation
of motd, such as one that is able to be printed in the terminal.
"""
def transform(self, motd_components: Sequence[ParsedMotdComponent]) -> _END_RESULT_TYPE:
return self._format_output([handled for component in motd_components for handled in self._handle_component(component)])
@abc.abstractmethod
def _format_output(self, results: list[_HOOK_RETURN_TYPE]) -> _END_RESULT_TYPE: ...
def _handle_component(
self, component: ParsedMotdComponent
) -> tuple[_HOOK_RETURN_TYPE, _HOOK_RETURN_TYPE] | tuple[_HOOK_RETURN_TYPE]:
handler: Callable[[ParsedMotdComponent], _HOOK_RETURN_TYPE] = {
MinecraftColor: self._handle_minecraft_color,
WebColor: self._handle_web_color,
Formatting: self._handle_formatting,
TranslationTag: self._handle_translation_tag,
str: self._handle_str,
}[type(component)]
additional = None
if isinstance(component, MinecraftColor):
additional = self._handle_formatting(Formatting.RESET)
return (additional, handler(component)) if additional is not None else (handler(component),)
@abc.abstractmethod
def _handle_str(self, element: str, /) -> _HOOK_RETURN_TYPE: ...
@abc.abstractmethod
def _handle_translation_tag(self, _: TranslationTag, /) -> _HOOK_RETURN_TYPE: ...
@abc.abstractmethod
def _handle_web_color(self, element: WebColor, /) -> _HOOK_RETURN_TYPE: ...
@abc.abstractmethod
def _handle_formatting(self, element: Formatting, /) -> _HOOK_RETURN_TYPE: ...
@abc.abstractmethod
def _handle_minecraft_color(self, element: MinecraftColor, /) -> _HOOK_RETURN_TYPE: ...
class NothingTransformer(BaseTransformer[str, str]):
"""Transformer that transforms all elements into empty strings.
This transformer acts as a base for other transformers with string result type.
"""
def _format_output(self, results: list[str]) -> str:
return "".join(results)
def _handle_str(self, element: str, /) -> str:
return ""
def _handle_minecraft_color(self, element: MinecraftColor, /) -> str:
return ""
def _handle_web_color(self, element: WebColor, /) -> str:
return ""
def _handle_formatting(self, element: Formatting, /) -> str:
return ""
def _handle_translation_tag(self, element: TranslationTag, /) -> str:
return ""
class PlainTransformer(NothingTransformer):
def _handle_str(self, element: str, /) -> str:
return element
class MinecraftTransformer(PlainTransformer):
def _handle_component(self, component: ParsedMotdComponent) -> tuple[str, str] | tuple[str]:
result = super()._handle_component(component)
if len(result) == 2:
return (result[1],)
return result
def _handle_minecraft_color(self, element: MinecraftColor, /) -> str:
return "§" + element.value
def _handle_formatting(self, element: Formatting, /) -> str:
return "§" + element.value
class HtmlTransformer(PlainTransformer):
"""Formatter for HTML variant of a MOTD.
.. warning::
You should implement obfuscated CSS class yourself (name - ``obfuscated``).
See `this answer <https://stackoverflow.com/a/30313558>`_ as example.
"""
FORMATTING_TO_HTML_TAGS = {
Formatting.BOLD: "b",
Formatting.STRIKETHROUGH: "s",
Formatting.ITALIC: "i",
Formatting.UNDERLINED: "u",
}
MINECRAFT_COLOR_TO_RGB_BEDROCK = {
MinecraftColor.BLACK: ((0, 0, 0), (0, 0, 0)),
MinecraftColor.DARK_BLUE: ((0, 0, 170), (0, 0, 42)),
MinecraftColor.DARK_GREEN: ((0, 170, 0), (0, 42, 0)),
MinecraftColor.DARK_AQUA: ((0, 170, 170), (0, 42, 42)),
MinecraftColor.DARK_RED: ((170, 0, 0), (42, 0, 0)),
MinecraftColor.DARK_PURPLE: ((170, 0, 170), (42, 0, 42)),
MinecraftColor.GOLD: ((255, 170, 0), (64, 42, 0)),
MinecraftColor.GRAY: ((170, 170, 170), (42, 42, 42)),
MinecraftColor.DARK_GRAY: ((85, 85, 85), (21, 21, 21)),
MinecraftColor.BLUE: ((85, 85, 255), (21, 21, 63)),
MinecraftColor.GREEN: ((85, 255, 85), (21, 63, 21)),
MinecraftColor.AQUA: ((85, 255, 255), (21, 63, 63)),
MinecraftColor.RED: ((255, 85, 85), (63, 21, 21)),
MinecraftColor.LIGHT_PURPLE: ((255, 85, 255), (63, 21, 63)),
MinecraftColor.YELLOW: ((255, 255, 85), (63, 63, 21)),
MinecraftColor.WHITE: ((255, 255, 255), (63, 63, 63)),
MinecraftColor.MINECOIN_GOLD: ((221, 214, 5), (55, 53, 1)),
}
MINECRAFT_COLOR_TO_RGB_JAVA = MINECRAFT_COLOR_TO_RGB_BEDROCK.copy()
MINECRAFT_COLOR_TO_RGB_JAVA[MinecraftColor.GOLD] = ((255, 170, 0), (42, 42, 0))
def __init__(self, *, bedrock: bool = False) -> None:
self.bedrock = bedrock
self.on_reset: list[str] = []
def transform(self, motd_components: Sequence[ParsedMotdComponent]) -> str:
self.on_reset = []
return super().transform(motd_components)
def _format_output(self, results: list[str]) -> str:
return "<p>" + super()._format_output(results) + "".join(self.on_reset) + "</p>"
def _handle_minecraft_color(self, element: MinecraftColor, /) -> str:
color_map = self.MINECRAFT_COLOR_TO_RGB_BEDROCK if self.bedrock else self.MINECRAFT_COLOR_TO_RGB_JAVA
fg_color, bg_color = color_map[element]
self.on_reset.append("</span>")
return f"<span style='color:rgb{fg_color};text-shadow:0 0 1px rgb{bg_color}'>"
def _handle_web_color(self, element: WebColor, /) -> str:
self.on_reset.append("</span>")
return f"<span style='color:rgb{element.rgb}'>"
def _handle_formatting(self, element: Formatting, /) -> str:
if element is Formatting.RESET:
to_return = "".join(self.on_reset)
self.on_reset = []
return to_return
if element is Formatting.OBFUSCATED:
self.on_reset.append("</span>")
return "<span class=obfuscated>"
tag_name = self.FORMATTING_TO_HTML_TAGS[element]
self.on_reset.append(f"</{tag_name}>")
return f"<{tag_name}>"
class AnsiTransformer(PlainTransformer):
FORMATTING_TO_ANSI_TAGS = {
Formatting.BOLD: "1",
Formatting.STRIKETHROUGH: "9",
Formatting.ITALIC: "3",
Formatting.UNDERLINED: "4",
Formatting.OBFUSCATED: "5",
}
MINECRAFT_COLOR_TO_RGB = {
MinecraftColor.BLACK: (0, 0, 0),
MinecraftColor.DARK_BLUE: (0, 0, 170),
MinecraftColor.DARK_GREEN: (0, 170, 0),
MinecraftColor.DARK_AQUA: (0, 170, 170),
MinecraftColor.DARK_RED: (170, 0, 0),
MinecraftColor.DARK_PURPLE: (170, 0, 170),
MinecraftColor.GOLD: (255, 170, 0),
MinecraftColor.GRAY: (170, 170, 170),
MinecraftColor.DARK_GRAY: (85, 85, 85),
MinecraftColor.BLUE: (85, 85, 255),
MinecraftColor.GREEN: (85, 255, 85),
MinecraftColor.AQUA: (85, 255, 255),
MinecraftColor.RED: (255, 85, 85),
MinecraftColor.LIGHT_PURPLE: (255, 85, 255),
MinecraftColor.YELLOW: (255, 255, 85),
MinecraftColor.WHITE: (255, 255, 255),
MinecraftColor.MINECOIN_GOLD: (221, 214, 5),
}
def ansi_color(self, color: tuple[int, int, int] | MinecraftColor) -> str:
"""Transform RGB color to ANSI color code."""
if isinstance(color, MinecraftColor):
color = self.MINECRAFT_COLOR_TO_RGB[color]
return "\033[38;2;{0};{1};{2}m".format(*color)
def _format_output(self, results: list[str]) -> str:
return "\033[0m" + super()._format_output(results) + "\033[0m"
def _handle_minecraft_color(self, element: MinecraftColor, /) -> str:
return self.ansi_color(element)
def _handle_web_color(self, element: WebColor, /) -> str:
return self.ansi_color(element.rgb)
def _handle_formatting(self, element: Formatting, /) -> str:
if element is Formatting.RESET:
return "\033[0m"
return "\033[" + self.FORMATTING_TO_ANSI_TAGS[element] + "m"

View file

@ -0,0 +1,124 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Awaitable
from dataclasses import dataclass, field
import json
import random
from time import perf_counter
from typing import final
from mcstatus.address import Address
from mcstatus.protocol.connection import Connection, TCPAsyncSocketConnection, TCPSocketConnection
from mcstatus.responses import JavaStatusResponse, RawJavaResponse
@dataclass
class _BaseServerPinger(ABC):
connection: TCPSocketConnection | TCPAsyncSocketConnection
address: Address
version: int = 47
ping_token: int = field(default_factory=lambda: random.randint(0, (1 << 63) - 1))
def handshake(self) -> None:
"""Writes the initial handshake packet to the connection."""
packet = Connection()
packet.write_varint(0)
packet.write_varint(self.version)
packet.write_utf(self.address.host)
packet.write_ushort(self.address.port)
packet.write_varint(1) # Intention to query status
self.connection.write_buffer(packet)
@abstractmethod
def read_status(self) -> JavaStatusResponse | Awaitable[JavaStatusResponse]:
"""Make a status request and parse the response."""
raise NotImplementedError
@abstractmethod
def test_ping(self) -> float | Awaitable[float]:
"""Send a ping token and measure the latency."""
raise NotImplementedError
def _handle_status_response(self, response: Connection, start: float, end: float) -> JavaStatusResponse:
"""Given a response buffer (already read from connection), parse and build the JavaStatusResponse."""
if response.read_varint() != 0:
raise IOError("Received invalid status response packet.")
try:
raw: RawJavaResponse = json.loads(response.read_utf())
except ValueError:
raise IOError("Received invalid JSON")
try:
latency_ms = (end - start) * 1000
return JavaStatusResponse.build(raw, latency=latency_ms)
except KeyError as e:
raise IOError(f"Received invalid status response: {e!r}")
def _handle_ping_response(self, response: Connection, start: float, end: float) -> float:
"""Given a ping response buffer, validate token and compute latency."""
if response.read_varint() != 1:
raise IOError("Received invalid ping response packet.")
received_token = response.read_long()
if received_token != self.ping_token:
raise IOError(f"Received mangled ping response (expected token {self.ping_token}, got {received_token})")
return (end - start) * 1000
@final
@dataclass
class ServerPinger(_BaseServerPinger):
connection: TCPSocketConnection # pyright: ignore[reportIncompatibleVariableOverride]
def read_status(self) -> JavaStatusResponse:
"""Send the status request and read the response."""
request = Connection()
request.write_varint(0) # Request status
self.connection.write_buffer(request)
start = perf_counter()
response = self.connection.read_buffer()
end = perf_counter()
return self._handle_status_response(response, start, end)
def test_ping(self) -> float:
"""Send a ping token and measure the latency."""
request = Connection()
request.write_varint(1) # Test ping
request.write_long(self.ping_token)
start = perf_counter()
self.connection.write_buffer(request)
response = self.connection.read_buffer()
end = perf_counter()
return self._handle_ping_response(response, start, end)
@final
@dataclass
class AsyncServerPinger(_BaseServerPinger):
connection: TCPAsyncSocketConnection # pyright: ignore[reportIncompatibleVariableOverride]
async def read_status(self) -> JavaStatusResponse:
"""Send the status request and read the response."""
request = Connection()
request.write_varint(0) # Request status
self.connection.write_buffer(request)
start = perf_counter()
response = await self.connection.read_buffer()
end = perf_counter()
return self._handle_status_response(response, start, end)
async def test_ping(self) -> float:
"""Send a ping token and measure the latency."""
request = Connection()
request.write_varint(1) # Test ping
request.write_long(self.ping_token)
start = perf_counter()
self.connection.write_buffer(request)
response = await self.connection.read_buffer()
end = perf_counter()
return self._handle_ping_response(response, start, end)

View file

@ -0,0 +1,690 @@
from __future__ import annotations
import asyncio
import errno
import socket
import struct
from abc import ABC, abstractmethod
from collections.abc import Iterable
from ctypes import c_int32 as signed_int32
from ctypes import c_int64 as signed_int64
from ctypes import c_uint32 as unsigned_int32
from ctypes import c_uint64 as unsigned_int64
from ipaddress import ip_address
from typing import TYPE_CHECKING, cast
import asyncio_dgram
from mcstatus.address import Address
if TYPE_CHECKING:
from typing_extensions import Self, SupportsIndex, TypeAlias
BytesConvertable: TypeAlias = "SupportsIndex | Iterable[SupportsIndex]"
def ip_type(address: int | str) -> int | None:
"""Determinate what IP version is.
:param address:
A string or integer, the IP address. Either IPv4 or IPv6 addresses may be supplied.
Integers less than 2**32 will be considered to be IPv4 by default.
:return: ``4`` or ``6`` if the IP is IPv4 or IPv6, respectively. :obj:`None` if the IP is invalid.
"""
try:
return ip_address(address).version
except ValueError:
return None
class BaseWriteSync(ABC):
"""Base synchronous write class"""
__slots__ = ()
@abstractmethod
def write(self, data: Connection | str | bytearray | bytes) -> None:
"""Write data to ``self``."""
def __repr__(self) -> str:
return f"<{self.__class__.__name__} Object>"
@staticmethod
def _pack(format_: str, data: int) -> bytes:
"""Pack data in with format in big-endian mode."""
return struct.pack(">" + format_, data)
def write_varint(self, value: int) -> None:
"""Write varint with value ``value`` to ``self``.
:param value: Maximum is ``2 ** 31 - 1``, minimum is ``-(2 ** 31)``.
:raises ValueError: If value is out of range.
"""
remaining = unsigned_int32(value).value
for _ in range(5):
if not remaining & -0x80: # remaining & ~0x7F == 0:
self.write(struct.pack("!B", remaining))
if value > 2**31 - 1 or value < -(2**31):
break
return
self.write(struct.pack("!B", remaining & 0x7F | 0x80))
remaining >>= 7
raise ValueError(f'The value "{value}" is too big to send in a varint')
def write_varlong(self, value: int) -> None:
"""Write varlong with value ``value`` to ``self``.
:param value: Maximum is ``2 ** 63 - 1``, minimum is ``-(2 ** 63)``.
:raises ValueError: If value is out of range.
"""
remaining = unsigned_int64(value).value
for _ in range(10):
if not remaining & -0x80: # remaining & ~0x7F == 0:
self.write(struct.pack("!B", remaining))
if value > 2**63 - 1 or value < -(2**31):
break
return
self.write(struct.pack("!B", remaining & 0x7F | 0x80))
remaining >>= 7
raise ValueError(f'The value "{value}" is too big to send in a varlong')
def write_utf(self, value: str) -> None:
"""Write varint of length of ``value`` up to 32767 bytes, then write ``value`` encoded with ``UTF-8``."""
self.write_varint(len(value))
self.write(bytearray(value, "utf8"))
def write_ascii(self, value: str) -> None:
"""Write value encoded with ``ISO-8859-1``, then write an additional ``0x00`` at the end."""
self.write(bytearray(value, "ISO-8859-1"))
self.write(bytearray.fromhex("00"))
def write_short(self, value: int) -> None:
"""Write 2 bytes for value ``-32768 - 32767``."""
self.write(self._pack("h", value))
def write_ushort(self, value: int) -> None:
"""Write 2 bytes for value ``0 - 65535 (2 ** 16 - 1)``."""
self.write(self._pack("H", value))
def write_int(self, value: int) -> None:
"""Write 4 bytes for value ``-2147483648 - 2147483647``."""
self.write(self._pack("i", value))
def write_uint(self, value: int) -> None:
"""Write 4 bytes for value ``0 - 4294967295 (2 ** 32 - 1)``."""
self.write(self._pack("I", value))
def write_long(self, value: int) -> None:
"""Write 8 bytes for value ``-9223372036854775808 - 9223372036854775807``."""
self.write(self._pack("q", value))
def write_ulong(self, value: int) -> None:
"""Write 8 bytes for value ``0 - 18446744073709551613 (2 ** 64 - 1)``."""
self.write(self._pack("Q", value))
def write_bool(self, value: bool) -> None:
"""Write 1 byte for boolean `True` or `False`"""
self.write(self._pack("?", value))
def write_buffer(self, buffer: "Connection") -> None:
"""Flush buffer, then write a varint of the length of the buffer's data, then write buffer data."""
data = buffer.flush()
self.write_varint(len(data))
self.write(data)
class BaseWriteAsync(ABC):
"""Base synchronous write class"""
__slots__ = ()
@abstractmethod
async def write(self, data: Connection | str | bytearray | bytes) -> None:
"""Write data to ``self``."""
def __repr__(self) -> str:
return f"<{self.__class__.__name__} Object>"
@staticmethod
def _pack(format_: str, data: int) -> bytes:
"""Pack data in with format in big-endian mode."""
return struct.pack(">" + format_, data)
async def write_varint(self, value: int) -> None:
"""Write varint with value ``value`` to ``self``.
:param value: Maximum is ``2 ** 31 - 1``, minimum is ``-(2 ** 31)``.
:raises ValueError: If value is out of range.
"""
remaining = unsigned_int32(value).value
for _ in range(5):
if not remaining & -0x80: # remaining & ~0x7F == 0:
await self.write(struct.pack("!B", remaining))
if value > 2**31 - 1 or value < -(2**31):
break
return
await self.write(struct.pack("!B", remaining & 0x7F | 0x80))
remaining >>= 7
raise ValueError(f'The value "{value}" is too big to send in a varint')
async def write_varlong(self, value: int) -> None:
"""Write varlong with value ``value`` to ``self``.
:param value: Maximum is ``2 ** 63 - 1``, minimum is ``-(2 ** 63)``.
:raises ValueError: If value is out of range.
"""
remaining = unsigned_int64(value).value
for _ in range(10):
if not remaining & -0x80: # remaining & ~0x7F == 0:
await self.write(struct.pack("!B", remaining))
if value > 2**63 - 1 or value < -(2**31):
break
return
await self.write(struct.pack("!B", remaining & 0x7F | 0x80))
remaining >>= 7
raise ValueError(f'The value "{value}" is too big to send in a varlong')
async def write_utf(self, value: str) -> None:
"""Write varint of length of ``value`` up to 32767 bytes, then write ``value`` encoded with ``UTF-8``."""
await self.write_varint(len(value))
await self.write(bytearray(value, "utf8"))
async def write_ascii(self, value: str) -> None:
"""Write value encoded with ``ISO-8859-1``, then write an additional ``0x00`` at the end."""
await self.write(bytearray(value, "ISO-8859-1"))
await self.write(bytearray.fromhex("00"))
async def write_short(self, value: int) -> None:
"""Write 2 bytes for value ``-32768 - 32767``."""
await self.write(self._pack("h", value))
async def write_ushort(self, value: int) -> None:
"""Write 2 bytes for value ``0 - 65535 (2 ** 16 - 1)``."""
await self.write(self._pack("H", value))
async def write_int(self, value: int) -> None:
"""Write 4 bytes for value ``-2147483648 - 2147483647``."""
await self.write(self._pack("i", value))
async def write_uint(self, value: int) -> None:
"""Write 4 bytes for value ``0 - 4294967295 (2 ** 32 - 1)``."""
await self.write(self._pack("I", value))
async def write_long(self, value: int) -> None:
"""Write 8 bytes for value ``-9223372036854775808 - 9223372036854775807``."""
await self.write(self._pack("q", value))
async def write_ulong(self, value: int) -> None:
"""Write 8 bytes for value ``0 - 18446744073709551613 (2 ** 64 - 1)``."""
await self.write(self._pack("Q", value))
async def write_bool(self, value: bool) -> None:
"""Write 1 byte for boolean `True` or `False`"""
await self.write(self._pack("?", value))
async def write_buffer(self, buffer: "Connection") -> None:
"""Flush buffer, then write a varint of the length of the buffer's data, then write buffer data."""
data = buffer.flush()
await self.write_varint(len(data))
await self.write(data)
class BaseReadSync(ABC):
"""Base synchronous read class"""
__slots__ = ()
@abstractmethod
def read(self, length: int) -> bytearray:
"""Read length bytes from ``self``, and return a byte array."""
def __repr__(self) -> str:
return f"<{self.__class__.__name__} Object>"
@staticmethod
def _unpack(format_: str, data: bytes) -> int:
"""Unpack data as bytes with format in big-endian."""
return struct.unpack(">" + format_, bytes(data))[0]
def read_varint(self) -> int:
"""Read varint from ``self`` and return it.
:param value: Maximum is ``2 ** 31 - 1``, minimum is ``-(2 ** 31)``.
:raises IOError: If varint received is out of range.
"""
result = 0
for i in range(5):
part = self.read(1)[0]
result |= (part & 0x7F) << (7 * i)
if not part & 0x80:
return signed_int32(result).value
raise IOError("Received varint is too big!")
def read_varlong(self) -> int:
"""Read varlong from ``self`` and return it.
:param value: Maximum is ``2 ** 63 - 1``, minimum is ``-(2 ** 63)``.
:raises IOError: If varint received is out of range.
"""
result = 0
for i in range(10):
part = self.read(1)[0]
result |= (part & 0x7F) << (7 * i)
if not part & 0x80:
return signed_int64(result).value
raise IOError("Received varlong is too big!")
def read_utf(self) -> str:
"""Read up to 32767 bytes by reading a varint, then decode bytes as ``UTF-8``."""
length = self.read_varint()
return self.read(length).decode("utf8")
def read_ascii(self) -> str:
"""Read ``self`` until last value is not zero, then return that decoded with ``ISO-8859-1``"""
result = bytearray()
while len(result) == 0 or result[-1] != 0:
result.extend(self.read(1))
return result[:-1].decode("ISO-8859-1")
def read_short(self) -> int:
"""Return ``-32768 - 32767``. Read 2 bytes."""
return self._unpack("h", self.read(2))
def read_ushort(self) -> int:
"""Return ``0 - 65535 (2 ** 16 - 1)``. Read 2 bytes."""
return self._unpack("H", self.read(2))
def read_int(self) -> int:
"""Return ``-2147483648 - 2147483647``. Read 4 bytes."""
return self._unpack("i", self.read(4))
def read_uint(self) -> int:
"""Return ``0 - 4294967295 (2 ** 32 - 1)``. 4 bytes read."""
return self._unpack("I", self.read(4))
def read_long(self) -> int:
"""Return ``-9223372036854775808 - 9223372036854775807``. Read 8 bytes."""
return self._unpack("q", self.read(8))
def read_ulong(self) -> int:
"""Return ``0 - 18446744073709551613 (2 ** 64 - 1)``. Read 8 bytes."""
return self._unpack("Q", self.read(8))
def read_bool(self) -> bool:
"""Return `True` or `False`. Read 1 byte."""
return cast(bool, self._unpack("?", self.read(1)))
def read_buffer(self) -> "Connection":
"""Read a varint for length, then return a new connection from length read bytes."""
length = self.read_varint()
result = Connection()
result.receive(self.read(length))
return result
class BaseReadAsync(ABC):
"""Asynchronous Read connection base class."""
__slots__ = ()
@abstractmethod
async def read(self, length: int) -> bytearray:
"""Read length bytes from ``self``, return a byte array."""
def __repr__(self) -> str:
return f"<{self.__class__.__name__} Object>"
@staticmethod
def _unpack(format_: str, data: bytes) -> int:
"""Unpack data as bytes with format in big-endian."""
return struct.unpack(">" + format_, bytes(data))[0]
async def read_varint(self) -> int:
"""Read varint from ``self`` and return it.
:param value: Maximum is ``2 ** 31 - 1``, minimum is ``-(2 ** 31)``.
:raises IOError: If varint received is out of range.
"""
result = 0
for i in range(5):
part = (await self.read(1))[0]
result |= (part & 0x7F) << 7 * i
if not part & 0x80:
return signed_int32(result).value
raise IOError("Received a varint that was too big!")
async def read_varlong(self) -> int:
"""Read varlong from ``self`` and return it.
:param value: Maximum is ``2 ** 63 - 1``, minimum is ``-(2 ** 63)``.
:raises IOError: If varint received is out of range.
"""
result = 0
for i in range(10):
part = (await self.read(1))[0]
result |= (part & 0x7F) << (7 * i)
if not part & 0x80:
return signed_int64(result).value
raise IOError("Received varlong is too big!")
async def read_utf(self) -> str:
"""Read up to 32767 bytes by reading a varint, then decode bytes as ``UTF-8``."""
length = await self.read_varint()
return (await self.read(length)).decode("utf8")
async def read_ascii(self) -> str:
"""Read ``self`` until last value is not zero, then return that decoded with ``ISO-8859-1``"""
result = bytearray()
while len(result) == 0 or result[-1] != 0:
result.extend(await self.read(1))
return result[:-1].decode("ISO-8859-1")
async def read_short(self) -> int:
"""Return ``-32768 - 32767``. Read 2 bytes."""
return self._unpack("h", await self.read(2))
async def read_ushort(self) -> int:
"""Return ``0 - 65535 (2 ** 16 - 1)``. Read 2 bytes."""
return self._unpack("H", await self.read(2))
async def read_int(self) -> int:
"""Return ``-2147483648 - 2147483647``. Read 4 bytes."""
return self._unpack("i", await self.read(4))
async def read_uint(self) -> int:
"""Return ``0 - 4294967295 (2 ** 32 - 1)``. 4 bytes read."""
return self._unpack("I", await self.read(4))
async def read_long(self) -> int:
"""Return ``-9223372036854775808 - 9223372036854775807``. Read 8 bytes."""
return self._unpack("q", await self.read(8))
async def read_ulong(self) -> int:
"""Return ``0 - 18446744073709551613 (2 ** 64 - 1)``. Read 8 bytes."""
return self._unpack("Q", await self.read(8))
async def read_bool(self) -> bool:
"""Return `True` or `False`. Read 1 byte."""
return cast(bool, self._unpack("?", await self.read(1)))
async def read_buffer(self) -> Connection:
"""Read a varint for length, then return a new connection from length read bytes."""
length = await self.read_varint()
result = Connection()
result.receive(await self.read(length))
return result
class BaseConnection:
"""Base Connection class. Implements flush, receive, and remaining."""
__slots__ = ()
def __repr__(self) -> str:
return f"<{self.__class__.__name__} Object>"
def flush(self) -> bytearray:
"""Raise :exc:`TypeError`, unsupported."""
raise TypeError(f"{self.__class__.__name__} does not support flush()")
def receive(self, data: BytesConvertable | bytearray) -> None:
"""Raise :exc:`TypeError`, unsupported."""
raise TypeError(f"{self.__class__.__name__} does not support receive()")
def remaining(self) -> int:
"""Raise :exc:`TypeError`, unsupported."""
raise TypeError(f"{self.__class__.__name__} does not support remaining()")
class BaseSyncConnection(BaseConnection, BaseReadSync, BaseWriteSync):
"""Base synchronous read and write class"""
__slots__ = ()
class BaseAsyncReadSyncWriteConnection(BaseConnection, BaseReadAsync, BaseWriteSync):
"""Base asynchronous read and synchronous write class"""
__slots__ = ()
class BaseAsyncConnection(BaseConnection, BaseReadAsync, BaseWriteAsync):
"""Base asynchronous read and write class"""
__slots__ = ()
class Connection(BaseSyncConnection):
"""Base connection class."""
__slots__ = ("received", "sent")
def __init__(self) -> None:
self.sent = bytearray()
self.received = bytearray()
def read(self, length: int) -> bytearray:
"""Return :attr:`.received` up to length bytes, then cut received up to that point."""
if len(self.received) < length:
raise IOError(f"Not enough data to read! {len(self.received)} < {length}")
result = self.received[:length]
self.received = self.received[length:]
return result
def write(self, data: Connection | str | bytearray | bytes) -> None:
"""Extend :attr:`.sent` from ``data``."""
if isinstance(data, Connection):
data = data.flush()
if isinstance(data, str):
data = bytearray(data, "utf-8")
self.sent.extend(data)
def receive(self, data: BytesConvertable | bytearray) -> None:
"""Extend :attr:`.received` with ``data``."""
if not isinstance(data, bytearray):
data = bytearray(data)
self.received.extend(data)
def remaining(self) -> int:
"""Return length of :attr:`.received`."""
return len(self.received)
def flush(self) -> bytearray:
"""Return :attr:`.sent`, also clears :attr:`.sent`."""
result, self.sent = self.sent, bytearray()
return result
def copy(self) -> "Connection":
"""Return a copy of ``self``"""
new = self.__class__()
new.receive(self.received)
new.write(self.sent)
return new
class SocketConnection(BaseSyncConnection):
"""Socket connection."""
__slots__ = ("socket",)
def __init__(self) -> None:
# These will only be None until connect is called, ignore the None type assignment
self.socket: socket.socket = None # type: ignore[assignment]
def close(self) -> None:
"""Close :attr:`.socket`."""
if self.socket is not None: # If initialized
try:
self.socket.shutdown(socket.SHUT_RDWR)
except OSError as exception: # Socket wasn't connected (nothing to shut down)
if exception.errno != errno.ENOTCONN:
raise
self.socket.close()
def __enter__(self) -> Self:
return self
def __exit__(self, *_) -> None:
self.close()
class TCPSocketConnection(SocketConnection):
"""TCP Connection to address. Timeout defaults to 3 seconds."""
__slots__ = ()
def __init__(self, addr: tuple[str | None, int], timeout: float = 3):
super().__init__()
self.socket = socket.create_connection(addr, timeout=timeout)
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
def read(self, length: int) -> bytearray:
"""Return length bytes read from :attr:`.socket`. Raises :exc:`IOError` when server doesn't respond."""
result = bytearray()
while len(result) < length:
new = self.socket.recv(length - len(result))
if len(new) == 0:
raise IOError("Server did not respond with any information!")
result.extend(new)
return result
def write(self, data: Connection | str | bytes | bytearray) -> None:
"""Send data on :attr:`.socket`."""
if isinstance(data, Connection):
data = bytearray(data.flush())
elif isinstance(data, str):
data = bytearray(data, "utf-8")
self.socket.send(data)
class UDPSocketConnection(SocketConnection):
"""UDP Connection class"""
__slots__ = ("addr",)
def __init__(self, addr: Address, timeout: float = 3):
super().__init__()
self.addr = addr
self.socket = socket.socket(
socket.AF_INET if ip_type(addr[0]) == 4 else socket.AF_INET6,
socket.SOCK_DGRAM,
)
self.socket.settimeout(timeout)
def remaining(self) -> int:
"""Always return ``65535`` (``2 ** 16 - 1``)."""
return 65535
def read(self, length: int) -> bytearray:
"""Return up to :meth:`.remaining` bytes. Length does nothing here."""
result = bytearray()
while len(result) == 0:
result.extend(self.socket.recvfrom(self.remaining())[0])
return result
def write(self, data: Connection | str | bytes | bytearray) -> None:
"""Use :attr:`.socket` to send data to :attr:`.addr`."""
if isinstance(data, Connection):
data = bytearray(data.flush())
elif isinstance(data, str):
data = bytearray(data, "utf-8")
self.socket.sendto(data, self.addr)
class TCPAsyncSocketConnection(BaseAsyncReadSyncWriteConnection):
"""Asynchronous TCP Connection class"""
__slots__ = ("_addr", "reader", "timeout", "writer")
def __init__(self, addr: Address, timeout: float = 3) -> None:
# These will only be None until connect is called, ignore the None type assignment
self.reader: asyncio.StreamReader = None # type: ignore[assignment]
self.writer: asyncio.StreamWriter = None # type: ignore[assignment]
self.timeout: float = timeout
self._addr = addr
async def connect(self) -> None:
"""Use :mod:`asyncio` to open a connection to address. Timeout is in seconds."""
conn = asyncio.open_connection(*self._addr)
self.reader, self.writer = await asyncio.wait_for(conn, timeout=self.timeout)
if self.writer is not None: # it might be None in unittest
sock: socket.socket = self.writer.transport.get_extra_info("socket")
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
async def read(self, length: int) -> bytearray:
"""Read up to ``length`` bytes from :attr:`.reader`."""
result = bytearray()
while len(result) < length:
new = await asyncio.wait_for(self.reader.read(length - len(result)), timeout=self.timeout)
if len(new) == 0:
raise IOError("Socket did not respond with any information!")
result.extend(new)
return result
def write(self, data: Connection | str | bytes | bytearray) -> None:
"""Write data to :attr:`.writer`."""
if isinstance(data, Connection):
data = bytearray(data.flush())
elif isinstance(data, str):
data = bytearray(data, "utf-8")
self.writer.write(data)
def close(self) -> None:
"""Close :attr:`.writer`."""
if self.writer is not None: # If initialized
self.writer.close()
async def __aenter__(self) -> Self:
await self.connect()
return self
async def __aexit__(self, *_) -> None:
self.close()
class UDPAsyncSocketConnection(BaseAsyncConnection):
"""Asynchronous UDP Connection class"""
__slots__ = ("_addr", "stream", "timeout")
def __init__(self, addr: Address, timeout: float = 3) -> None:
# This will only be None until connect is called, ignore the None type assignment
self.stream: asyncio_dgram.aio.DatagramClient = None # type: ignore[assignment]
self.timeout: float = timeout
self._addr = addr
async def connect(self) -> None:
"""Connect to address. Timeout is in seconds."""
conn = asyncio_dgram.connect(self._addr)
self.stream = await asyncio.wait_for(conn, timeout=self.timeout)
def remaining(self) -> int:
"""Always return ``65535`` (``2 ** 16 - 1``)."""
return 65535
async def read(self, length: int) -> bytearray:
"""Read from :attr:`.stream`. Length does nothing here."""
data, remote_addr = await asyncio.wait_for(self.stream.recv(), timeout=self.timeout)
return bytearray(data)
async def write(self, data: Connection | str | bytes | bytearray) -> None:
"""Send data with :attr:`.stream`."""
if isinstance(data, Connection):
data = bytearray(data.flush())
elif isinstance(data, str):
data = bytearray(data, "utf-8")
await self.stream.send(data)
def close(self) -> None:
"""Close :attr:`.stream`."""
if self.stream is not None: # If initialized
self.stream.close()
async def __aenter__(self) -> Self:
await self.connect()
return self
async def __aexit__(self, *_) -> None:
self.close()

View file

@ -0,0 +1,145 @@
from __future__ import annotations
import random
import re
import struct
from abc import abstractmethod
from collections.abc import Awaitable
from dataclasses import dataclass, field
from typing import ClassVar, final
from mcstatus.protocol.connection import Connection, UDPAsyncSocketConnection, UDPSocketConnection
from mcstatus.responses import QueryResponse, RawQueryResponse
@dataclass
class _BaseServerQuerier:
MAGIC_PREFIX: ClassVar = bytearray.fromhex("FEFD")
PADDING: ClassVar = bytearray.fromhex("00000000")
PACKET_TYPE_CHALLENGE: ClassVar = 9
PACKET_TYPE_QUERY: ClassVar = 0
connection: UDPSocketConnection | UDPAsyncSocketConnection
challenge: int = field(init=False, default=0)
@staticmethod
def _generate_session_id() -> int:
# minecraft only supports lower 4 bits
return random.randint(0, 2**31) & 0x0F0F0F0F
def _create_packet(self) -> Connection:
packet = Connection()
packet.write(self.MAGIC_PREFIX)
packet.write(struct.pack("!B", self.PACKET_TYPE_QUERY))
packet.write_uint(self._generate_session_id())
packet.write_int(self.challenge)
packet.write(self.PADDING)
return packet
def _create_handshake_packet(self) -> Connection:
packet = Connection()
packet.write(self.MAGIC_PREFIX)
packet.write(struct.pack("!B", self.PACKET_TYPE_CHALLENGE))
packet.write_uint(self._generate_session_id())
return packet
@abstractmethod
def _read_packet(self) -> Connection | Awaitable[Connection]:
raise NotImplementedError
@abstractmethod
def handshake(self) -> None | Awaitable[None]:
raise NotImplementedError
@abstractmethod
def read_query(self) -> QueryResponse | Awaitable[QueryResponse]:
raise NotImplementedError
def _parse_response(self, response: Connection) -> tuple[RawQueryResponse, list[str]]:
"""Transform the connection object (the result) into dict which is passed to the QueryResponse constructor.
:return: A tuple with two elements. First is `raw` answer and second is list of players.
"""
response.read(len("splitnum") + 3)
data = {}
while True:
key = response.read_ascii()
if key == "hostname": # hostname is actually motd in the query protocol
match = re.search(
b"(.*?)\x00(hostip|hostport|game_id|gametype|map|maxplayers|numplayers|plugins|version)",
response.received,
flags=re.DOTALL,
)
motd = match.group(1) if match else ""
# Since the query protocol does not properly support unicode, the motd is still not resolved
# correctly; however, this will avoid other parameter parsing errors.
data[key] = response.read(len(motd)).decode("ISO-8859-1")
response.read(1) # ignore null byte
elif len(key) == 0:
response.read(1)
break
else:
value = response.read_ascii()
data[key] = value
response.read(len("player_") + 2)
players_list = []
while True:
player = response.read_ascii()
if len(player) == 0:
break
players_list.append(player)
return RawQueryResponse(**data), players_list
@final
@dataclass
class ServerQuerier(_BaseServerQuerier):
connection: UDPSocketConnection # pyright: ignore[reportIncompatibleVariableOverride]
def _read_packet(self) -> Connection:
packet = Connection()
packet.receive(self.connection.read(self.connection.remaining()))
packet.read(1 + 4)
return packet
def handshake(self) -> None:
self.connection.write(self._create_handshake_packet())
packet = self._read_packet()
self.challenge = int(packet.read_ascii())
def read_query(self) -> QueryResponse:
request = self._create_packet()
self.connection.write(request)
response = self._read_packet()
return QueryResponse.build(*self._parse_response(response))
@final
@dataclass
class AsyncServerQuerier(_BaseServerQuerier):
connection: UDPAsyncSocketConnection # pyright: ignore[reportIncompatibleVariableOverride]
async def _read_packet(self) -> Connection:
packet = Connection()
packet.receive(await self.connection.read(self.connection.remaining()))
packet.read(1 + 4)
return packet
async def handshake(self) -> None:
await self.connection.write(self._create_handshake_packet())
packet = await self._read_packet()
self.challenge = int(packet.read_ascii())
async def read_query(self) -> QueryResponse:
request = self._create_packet()
await self.connection.write(request)
response = await self._read_packet()
return QueryResponse.build(*self._parse_response(response))

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

View file

@ -0,0 +1,230 @@
from __future__ import annotations
from abc import ABC
from typing import TYPE_CHECKING
from mcstatus.address import Address, async_minecraft_srv_address_lookup, minecraft_srv_address_lookup
from mcstatus.bedrock_status import BedrockServerStatus
from mcstatus.pinger import AsyncServerPinger, ServerPinger
from mcstatus.protocol.connection import (
TCPAsyncSocketConnection,
TCPSocketConnection,
UDPAsyncSocketConnection,
UDPSocketConnection,
)
from mcstatus.querier import AsyncServerQuerier, QueryResponse, ServerQuerier
from mcstatus.responses import BedrockStatusResponse, JavaStatusResponse
from mcstatus.utils import retry
if TYPE_CHECKING:
from typing_extensions import Self
__all__ = ["BedrockServer", "JavaServer", "MCServer"]
class MCServer(ABC):
"""Base abstract class for a general minecraft server.
This class only contains the basic logic shared across both java and bedrock versions,
it doesn't include any version specific settings and it can't be used to make any requests.
"""
DEFAULT_PORT: int
def __init__(self, host: str, port: int | None = None, timeout: float = 3):
"""
:param host: The host/ip of the minecraft server.
:param port: The port that the server is on.
:param timeout: The timeout in seconds before failing to connect.
"""
if port is None:
port = self.DEFAULT_PORT
self.address = Address(host, port)
self.timeout = timeout
@classmethod
def lookup(cls, address: str, timeout: float = 3) -> Self:
"""Mimics minecraft's server address field.
:param address: The address of the Minecraft server, like ``example.com:19132``
:param timeout: The timeout in seconds before failing to connect.
"""
addr = Address.parse_address(address, default_port=cls.DEFAULT_PORT)
return cls(addr.host, addr.port, timeout=timeout)
class JavaServer(MCServer):
"""Base class for a Minecraft Java Edition server."""
DEFAULT_PORT = 25565
def __init__(self, host: str, port: int | None = None, timeout: float = 3, query_port: int | None = None):
"""
:param host: The host/ip of the minecraft server.
:param port: The port that the server is on.
:param timeout: The timeout in seconds before failing to connect.
:param query_port: Typically the same as ``port`` but can be different.
"""
super().__init__(host, port, timeout)
if query_port is None:
query_port = port or self.DEFAULT_PORT
self.query_port = query_port
_ = Address(host, self.query_port) # Ensure query_port is valid
@classmethod
def lookup(cls, address: str, timeout: float = 3) -> Self:
"""Mimics minecraft's server address field.
With Java servers, on top of just parsing the address, we also check the
DNS records for an SRV record that points to the server, which is the same
behavior as with minecraft's server address field for Java. This DNS record
resolution is happening synchronously (see :meth:`.async_lookup`).
:param address: The address of the Minecraft server, like ``example.com:25565``.
:param timeout: The timeout in seconds before failing to connect.
"""
addr = minecraft_srv_address_lookup(address, default_port=cls.DEFAULT_PORT, lifetime=timeout)
return cls(addr.host, addr.port, timeout=timeout)
@classmethod
async def async_lookup(cls, address: str, timeout: float = 3) -> Self:
"""Asynchronous alternative to :meth:`.lookup`.
For more details, check the :meth:`JavaServer.lookup() <.lookup>` docstring.
"""
addr = await async_minecraft_srv_address_lookup(address, default_port=cls.DEFAULT_PORT, lifetime=timeout)
return cls(addr.host, addr.port, timeout=timeout)
def ping(self, **kwargs) -> float:
"""Checks the latency between a Minecraft Java Edition server and the client (you).
Note that most non-vanilla implementations fail to respond to a ping
packet unless a status packet is sent first. Expect ``OSError: Server
did not respond with any information!`` in those cases. The workaround
is to use the latency provided with :meth:`.status` as ping time.
:param kwargs: Passed to a :class:`~mcstatus.pinger.ServerPinger` instance.
:return: The latency between the Minecraft Server and you.
"""
with TCPSocketConnection(self.address, self.timeout) as connection:
return self._retry_ping(connection, **kwargs)
@retry(tries=3)
def _retry_ping(self, connection: TCPSocketConnection, **kwargs) -> float:
pinger = ServerPinger(connection, address=self.address, **kwargs)
pinger.handshake()
return pinger.test_ping()
async def async_ping(self, **kwargs) -> float:
"""Asynchronously checks the latency between a Minecraft Java Edition server and the client (you).
Note that most non-vanilla implementations fail to respond to a ping
packet unless a status packet is sent first. Expect ``OSError: Server
did not respond with any information!`` in those cases. The workaround
is to use the latency provided with :meth:`.async_status` as ping time.
:param kwargs: Passed to a :class:`~mcstatus.pinger.AsyncServerPinger` instance.
:return: The latency between the Minecraft Server and you.
"""
async with TCPAsyncSocketConnection(self.address, self.timeout) as connection:
return await self._retry_async_ping(connection, **kwargs)
@retry(tries=3)
async def _retry_async_ping(self, connection: TCPAsyncSocketConnection, **kwargs) -> float:
pinger = AsyncServerPinger(connection, address=self.address, **kwargs)
pinger.handshake()
ping = await pinger.test_ping()
return ping
def status(self, **kwargs) -> JavaStatusResponse:
"""Checks the status of a Minecraft Java Edition server via the status protocol.
:param kwargs: Passed to a :class:`~mcstatus.pinger.ServerPinger` instance.
:return: Status information in a :class:`~mcstatus.responses.JavaStatusResponse` instance.
"""
with TCPSocketConnection(self.address, self.timeout) as connection:
return self._retry_status(connection, **kwargs)
@retry(tries=3)
def _retry_status(self, connection: TCPSocketConnection, **kwargs) -> JavaStatusResponse:
pinger = ServerPinger(connection, address=self.address, **kwargs)
pinger.handshake()
result = pinger.read_status()
return result
async def async_status(self, **kwargs) -> JavaStatusResponse:
"""Asynchronously checks the status of a Minecraft Java Edition server via the status protocol.
:param kwargs: Passed to a :class:`~mcstatus.pinger.AsyncServerPinger` instance.
:return: Status information in a :class:`~mcstatus.responses.JavaStatusResponse` instance.
"""
async with TCPAsyncSocketConnection(self.address, self.timeout) as connection:
return await self._retry_async_status(connection, **kwargs)
@retry(tries=3)
async def _retry_async_status(self, connection: TCPAsyncSocketConnection, **kwargs) -> JavaStatusResponse:
pinger = AsyncServerPinger(connection, address=self.address, **kwargs)
pinger.handshake()
result = await pinger.read_status()
return result
def query(self, *, tries: int = 3) -> QueryResponse:
"""Checks the status of a Minecraft Java Edition server via the query protocol.
:param tries: The number of times to retry if an error is encountered.
:return: Query information in a :class:`~mcstatus.querier.QueryResponse` instance.
"""
ip = str(self.address.resolve_ip())
return self._retry_query(Address(ip, self.query_port), tries=tries)
@retry(tries=3)
def _retry_query(self, addr: Address, **_kwargs) -> QueryResponse:
with UDPSocketConnection(addr, self.timeout) as connection:
querier = ServerQuerier(connection)
querier.handshake()
return querier.read_query()
async def async_query(self, *, tries: int = 3) -> QueryResponse:
"""Asynchronously checks the status of a Minecraft Java Edition server via the query protocol.
:param tries: The number of times to retry if an error is encountered.
:return: Query information in a :class:`~mcstatus.querier.QueryResponse` instance.
"""
ip = str(await self.address.async_resolve_ip())
return await self._retry_async_query(Address(ip, self.query_port), tries=tries)
@retry(tries=3)
async def _retry_async_query(self, address: Address, **_kwargs) -> QueryResponse:
async with UDPAsyncSocketConnection(address, self.timeout) as connection:
querier = AsyncServerQuerier(connection)
await querier.handshake()
return await querier.read_query()
class BedrockServer(MCServer):
"""Base class for a Minecraft Bedrock Edition server."""
DEFAULT_PORT = 19132
@retry(tries=3)
def status(self, **kwargs) -> BedrockStatusResponse:
"""Checks the status of a Minecraft Bedrock Edition server.
:param kwargs: Passed to a :class:`~mcstatus.bedrock_status.BedrockServerStatus` instance.
:return: Status information in a :class:`~mcstatus.responses.BedrockStatusResponse` instance.
"""
return BedrockServerStatus(self.address, self.timeout, **kwargs).read_status()
@retry(tries=3)
async def async_status(self, **kwargs) -> BedrockStatusResponse:
"""Asynchronously checks the status of a Minecraft Bedrock Edition server.
:param kwargs: Passed to a :class:`~mcstatus.bedrock_status.BedrockServerStatus` instance.
:return: Status information in a :class:`~mcstatus.responses.BedrockStatusResponse` instance.
"""
return await BedrockServerStatus(self.address, self.timeout, **kwargs).read_status_async()

View file

@ -0,0 +1,35 @@
from mcstatus.responses import (
BaseStatusPlayers,
BaseStatusResponse,
BaseStatusVersion,
BedrockStatusPlayers,
BedrockStatusResponse,
BedrockStatusVersion,
JavaStatusPlayer,
JavaStatusPlayers,
JavaStatusResponse,
JavaStatusVersion,
)
# __all__ is frozen on the moment of deprecation
__all__ = [
"BaseStatusPlayers",
"BaseStatusResponse",
"BaseStatusVersion",
"BedrockStatusPlayers",
"BedrockStatusResponse",
"BedrockStatusVersion",
"JavaStatusPlayer",
"JavaStatusPlayers",
"JavaStatusResponse",
"JavaStatusVersion",
]
import warnings
warnings.warn(
"`mcstatus.status_response` is deprecated, and will be removed at 2025-12, use `mcstatus.responses` instead",
DeprecationWarning,
stacklevel=2,
)

View file

@ -0,0 +1,219 @@
from __future__ import annotations
import inspect
import warnings
from collections.abc import Callable, Iterable
from functools import wraps
from typing import Any, TYPE_CHECKING, TypeVar, cast, overload
if TYPE_CHECKING:
from typing_extensions import ParamSpec, Protocol
P = ParamSpec("P")
P2 = ParamSpec("P2")
else:
Protocol = object
P = []
T = TypeVar("T")
R = TypeVar("R")
R2 = TypeVar("R2")
def retry(tries: int, exceptions: tuple[type[BaseException]] = (Exception,)) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""Decorator that re-runs given function ``tries`` times if error occurs.
The amount of tries will either be the value given to the decorator,
or if tries is present in keyword arguments on function call, this
specified value will take precedence.
If the function fails even after all the retries, raise the last
exception that the function raised.
.. note::
Even if the previous failures caused a different exception, this will only raise the last one.
"""
def decorate(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
async def async_wrapper(
*args: P.args,
tries: int = tries, # type: ignore # (No support for adding kw-only args)
**kwargs: P.kwargs,
) -> R:
last_exc: BaseException
for _ in range(tries):
try:
return await func(*args, **kwargs) # type: ignore # (We know func is awaitable here)
except exceptions as exc:
last_exc = exc
else:
raise last_exc # type: ignore # (This won't actually be unbound)
@wraps(func)
def sync_wrapper(
*args: P.args,
tries: int = tries, # type: ignore # (No support for adding kw-only args)
**kwargs: P.kwargs,
) -> R:
last_exc: BaseException
for _ in range(tries):
try:
return func(*args, **kwargs)
except exceptions as exc:
last_exc = exc
else:
raise last_exc # type: ignore # (This won't actually be unbound)
# We cast here since pythons typing doesn't support adding keyword-only arguments to signature
# (Support for this was a rejected idea https://peps.python.org/pep-0612/#concatenating-keyword-parameters)
if inspect.iscoroutinefunction(func):
return cast("Callable[P, R]", async_wrapper)
return cast("Callable[P, R]", sync_wrapper)
return decorate
class DeprecatedReturn(Protocol):
@overload
def __call__(self, __x: type[T]) -> type[T]: ...
@overload
def __call__(self, __x: Callable[P, R]) -> Callable[P, R]: ...
@overload
def deprecated(
obj: Callable[P, R],
*,
replacement: str | None = None,
version: str | None = None,
date: str | None = None,
msg: str | None = None,
) -> Callable[P, R]: ...
@overload
def deprecated(
obj: type[T],
*,
replacement: str | None = None,
version: str | None = None,
date: str | None = None,
msg: str | None = None,
methods: Iterable[str],
) -> type[T]: ...
@overload
def deprecated(
obj: None = None,
*,
replacement: str | None = None,
version: str | None = None,
date: str | None = None,
msg: str | None = None,
methods: Iterable[str] | None = None,
) -> DeprecatedReturn: ...
def deprecated(
obj: Any = None,
*,
replacement: str | None = None,
version: str | None = None,
date: str | None = None,
msg: str | None = None,
methods: Iterable[str] | None = None,
) -> Callable | type[object]:
if date is not None and version is not None:
raise ValueError("Expected removal timeframe can either be a date, or a version, not both.")
def decorate_func(func: Callable[P2, R2], warn_message: str) -> Callable[P2, R2]:
@wraps(func)
def wrapper(*args: P2.args, **kwargs: P2.kwargs) -> R2:
warnings.warn(warn_message, category=DeprecationWarning, stacklevel=2)
return func(*args, **kwargs)
return wrapper
@overload
def decorate(obj: type[T]) -> type[T]: ...
@overload
def decorate(obj: Callable[P, R]) -> Callable[P, R]: ...
def decorate(obj: Callable[P, R] | type[T]) -> Callable[P, R] | type[T]:
# Construct and send the warning message
name = getattr(obj, "__qualname__", obj.__name__)
warn_message = f"'{name}' is deprecated and is expected to be removed"
if version is not None:
warn_message += f" in {version}"
elif date is not None:
warn_message += f" on {date}"
else:
warn_message += " eventually"
if replacement is not None:
warn_message += f", use '{replacement}' instead"
warn_message += "."
if msg is not None:
warn_message += f" ({msg})"
# If we're deprecating class, deprecate it's methods and return the class
if inspect.isclass(obj):
obj = cast("type[T]", obj)
if methods is None:
raise ValueError("When deprecating a class, you need to specify 'methods' which will get the notice")
for method in methods:
new_func = decorate_func(getattr(obj, method), warn_message)
setattr(obj, method, new_func)
return obj
# Regular function deprecation
if methods is not None:
raise ValueError("Methods can only be specified when decorating a class, not a function")
return decorate_func(obj, warn_message)
# In case the decorator was used like @deprecated, instead of @deprecated()
# we got the object already, pass it over to the local decorate function
# This can happen since all of the arguments are optional and can be omitted
if obj:
return decorate(obj)
return decorate
def or_none(*args: T) -> T | None:
"""Return the first non-None argument.
This function is similar to the standard inline ``or`` operator, while
treating falsey values (such as ``0``, ``''``, or ``False``) as valid
results rather than skipping them. It only skips ``None`` values.
This is useful when selecting between optional values that may be empty
but still meaningful.
Example:
.. code-block:: py
>>> or_none("", 0, "fallback")
''
>>> or_none(None, None, "value")
'value'
>>> or_none(None, None)
None
This is often useful when working with dict.get, e.g.:
.. code-block:: py
>>> mydict = {"a": ""}
>>> mydict.get("a") or mydict.get("b")
None # expected ''!
>>> or_none(mydict.get("a"), mydict.get("b"))
''
"""
for arg in args:
if arg is not None:
return arg
return None