Changed code to support older Python versions
This commit is contained in:
parent
eb92d2d36f
commit
582458cdd0
5027 changed files with 794942 additions and 4 deletions
7
venv/lib/python3.11/site-packages/mcstatus/__init__.py
Normal file
7
venv/lib/python3.11/site-packages/mcstatus/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from mcstatus.server import BedrockServer, JavaServer, MCServer
|
||||
|
||||
__all__ = [
|
||||
"BedrockServer",
|
||||
"JavaServer",
|
||||
"MCServer",
|
||||
]
|
||||
202
venv/lib/python3.11/site-packages/mcstatus/__main__.py
Normal file
202
venv/lib/python3.11/site-packages/mcstatus/__main__.py
Normal 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())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
258
venv/lib/python3.11/site-packages/mcstatus/address.py
Normal file
258
venv/lib/python3.11/site-packages/mcstatus/address.py
Normal 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)
|
||||
66
venv/lib/python3.11/site-packages/mcstatus/bedrock_status.py
Normal file
66
venv/lib/python3.11/site-packages/mcstatus/bedrock_status.py
Normal 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
|
||||
94
venv/lib/python3.11/site-packages/mcstatus/dns.py
Normal file
94
venv/lib/python3.11/site-packages/mcstatus/dns.py
Normal 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)
|
||||
262
venv/lib/python3.11/site-packages/mcstatus/forge_data.py
Normal file
262
venv/lib/python3.11/site-packages/mcstatus/forge_data.py
Normal 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,
|
||||
)
|
||||
225
venv/lib/python3.11/site-packages/mcstatus/motd/__init__.py
Normal file
225
venv/lib/python3.11/site-packages/mcstatus/motd/__init__.py
Normal 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)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
126
venv/lib/python3.11/site-packages/mcstatus/motd/components.py
Normal file
126
venv/lib/python3.11/site-packages/mcstatus/motd/components.py
Normal 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"
|
||||
194
venv/lib/python3.11/site-packages/mcstatus/motd/simplifies.py
Normal file
194
venv/lib/python3.11/site-packages/mcstatus/motd/simplifies.py
Normal 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
|
||||
223
venv/lib/python3.11/site-packages/mcstatus/motd/transformers.py
Normal file
223
venv/lib/python3.11/site-packages/mcstatus/motd/transformers.py
Normal 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"
|
||||
124
venv/lib/python3.11/site-packages/mcstatus/pinger.py
Normal file
124
venv/lib/python3.11/site-packages/mcstatus/pinger.py
Normal 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)
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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()
|
||||
0
venv/lib/python3.11/site-packages/mcstatus/py.typed
Normal file
0
venv/lib/python3.11/site-packages/mcstatus/py.typed
Normal file
145
venv/lib/python3.11/site-packages/mcstatus/querier.py
Normal file
145
venv/lib/python3.11/site-packages/mcstatus/querier.py
Normal 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))
|
||||
517
venv/lib/python3.11/site-packages/mcstatus/responses.py
Normal file
517
venv/lib/python3.11/site-packages/mcstatus/responses.py
Normal 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
|
||||
230
venv/lib/python3.11/site-packages/mcstatus/server.py
Normal file
230
venv/lib/python3.11/site-packages/mcstatus/server.py
Normal 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()
|
||||
|
|
@ -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,
|
||||
)
|
||||
219
venv/lib/python3.11/site-packages/mcstatus/utils.py
Normal file
219
venv/lib/python3.11/site-packages/mcstatus/utils.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue