diff --git a/docs/api/basic.rst b/docs/api/basic.rst index 7fbe47c0..cee512e4 100644 --- a/docs/api/basic.rst +++ b/docs/api/basic.rst @@ -60,85 +60,23 @@ For Java Server :inherited-members: :exclude-members: build -.. module:: mcstatus.querier - -.. class:: QueryResponse - :canonical: mcstatus.querier.QueryResponse - - The response object for :meth:`JavaServer.query() `. - - .. class:: Players - :canonical: mcstatus.querier.QueryResponse.Players - - Class for storing information about players on the server. - - .. attribute:: online - :type: int - :canonical: mcstatus.querier.QueryResponse.Players.online - - The number of online players. - - .. attribute:: max - :type: int - :canonical: mcstatus.querier.QueryResponse.Players.max - - The maximum allowed number of players (server slots). - - .. attribute:: names - :type: list[str] - :canonical: mcstatus.querier.QueryResponse.Players.names - - The list of online players. - - .. class:: Software - :canonical: mcstatus.querier.QueryResponse.Software - - Class for storing information about software on the server. - - .. attribute:: version - :type: str - :canonical: mcstatus.querier.QueryResponse.Software.version - - The version of the software. - - .. attribute:: brand - :type: str - :value: "vanilla" - :canonical: mcstatus.querier.QueryResponse.Software.brand - - The brand of the software. Like `Paper `_ or `Spigot `_. - - .. attribute:: plugins - :type: list[str] - :canonical: mcstatus.querier.QueryResponse.Software.plugins - - The list of plugins. Can be empty if hidden. - - .. attribute:: motd - :type: ~mcstatus.motd.Motd - :canonical: mcstatus.querier.QueryResponse.motd - - The MOTD of the server. Also known as description. - - .. seealso:: :doc:`/api/motd_parsing`. - - .. attribute:: map - :type: str - :canonical: mcstatus.querier.QueryResponse.map - - The name of the map. - - .. attribute:: players - :type: ~QueryResponse.Players - :canonical: mcstatus.querier.QueryResponse.players - - The players information. +.. autoclass:: mcstatus.responses.QueryResponse() + :members: + :undoc-members: + :inherited-members: + :exclude-members: build - .. attribute:: software - :type: ~QueryResponse.Software - :canonical: mcstatus.querier.QueryResponse.software +.. autoclass:: mcstatus.responses.QueryPlayers() + :members: + :undoc-members: + :inherited-members: + :exclude-members: build - The software information. +.. autoclass:: mcstatus.responses.QuerySoftware() + :members: + :undoc-members: + :inherited-members: + :exclude-members: build For Bedrock Servers diff --git a/docs/examples/code/player_list_from_query_with_fallback_on_status.py b/docs/examples/code/player_list_from_query_with_fallback_on_status.py index 7b8dffcf..ad520767 100644 --- a/docs/examples/code/player_list_from_query_with_fallback_on_status.py +++ b/docs/examples/code/player_list_from_query_with_fallback_on_status.py @@ -3,8 +3,8 @@ server = JavaServer.lookup("play.hypixel.net") query = server.query() -if query.players.names: - print("Players online:", ", ".join(query.players.names)) +if query.players.list: + print("Players online:", ", ".join(query.players.list)) else: status = server.status() diff --git a/mcstatus/__main__.py b/mcstatus/__main__.py index e1244752..8f4c6a56 100644 --- a/mcstatus/__main__.py +++ b/mcstatus/__main__.py @@ -44,7 +44,7 @@ def json(server: JavaServer) -> None: query_res = server.query(tries=1) # type: ignore[call-arg] # tries is supported with retry decorator data["host_ip"] = query_res.raw["hostip"] data["host_port"] = query_res.raw["hostport"] - data["map"] = query_res.map + data["map"] = query_res.map_name data["plugins"] = query_res.software.plugins except Exception: # TODO: Check what this actually excepts pass @@ -66,7 +66,7 @@ def query(server: JavaServer) -> None: print(f"software: v{response.software.version} {response.software.brand}") print(f"plugins: {response.software.plugins}") print(f'motd: "{response.motd}"') - print(f"players: {response.players.online}/{response.players.max} {response.players.names}") + print(f"players: {response.players.online}/{response.players.max} {', '.join(response.players.list)}") def main() -> None: diff --git a/mcstatus/querier.py b/mcstatus/querier.py index f5da4a5c..4906b8be 100644 --- a/mcstatus/querier.py +++ b/mcstatus/querier.py @@ -3,13 +3,9 @@ import random import re import struct -from typing import TYPE_CHECKING -from mcstatus.motd import Motd from mcstatus.protocol.connection import Connection, UDPAsyncSocketConnection, UDPSocketConnection - -if TYPE_CHECKING: - from typing_extensions import Self +from mcstatus.responses import QueryResponse, RawQueryResponse class ServerQuerier: @@ -60,91 +56,13 @@ def read_query(self) -> QueryResponse: self.connection.write(request) response = self._read_packet() - return QueryResponse.from_connection(response) - + return QueryResponse.build(*self._parse_response(response)) -class AsyncServerQuerier(ServerQuerier): - def __init__(self, connection: UDPAsyncSocketConnection): - # We do this to inform python about self.connection type (it's async) - super().__init__(connection) # type: ignore[arg-type] - self.connection: UDPAsyncSocketConnection - - async def _read_packet(self) -> Connection: - packet = Connection() - packet.receive(await self.connection.read(self.connection.remaining())) - packet.read(1 + 4) - return packet + 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. - 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.from_connection(response) - - -class QueryResponse: - """Documentation for this class is written by hand, without docstrings. - - This is because the class is not supposed to be auto-documented. - - Please see https://mcstatus.readthedocs.io/en/latest/api/basic/#mcstatus.querier.QueryResponse - for the actual documentation. - """ - - # THIS IS SO UNPYTHONIC - # it's staying just because the tests depend on this structure - class Players: - online: int - max: int - names: list[str] - - # TODO: It's a bit weird that we accept str for number parameters, just to convert them in init - def __init__(self, online: str | int, max: str | int, names: list[str]): - self.online = int(online) - self.max = int(max) - self.names = names - - class Software: - version: str - brand: str - plugins: list[str] - - def __init__(self, version: str, plugins: str): - self.version = version - self.brand = "vanilla" - self.plugins = [] - - if plugins: - parts = plugins.split(":", 1) - self.brand = parts[0].strip() - - if len(parts) == 2: - self.plugins = [s.strip() for s in parts[1].split(";")] - - motd: Motd - map: str - players: Players - software: Software - - def __init__(self, raw: dict[str, str], players: list[str]): - try: - self.raw = raw - self.motd = Motd.parse(raw["hostname"], bedrock=False) - self.map = raw["map"] - self.players = QueryResponse.Players(raw["numplayers"], raw["maxplayers"], players) - self.software = QueryResponse.Software(raw["version"], raw["plugins"]) - except KeyError: - raise ValueError("The provided data is not valid") - - @classmethod - def from_connection(cls, response: Connection) -> Self: + :return: A tuple with two elements. First is `raw` answer and second is list of players. + """ response.read(len("splitnum") + 1 + 1 + 1) data = {} @@ -170,11 +88,37 @@ def from_connection(cls, response: Connection) -> Self: response.read(len("player_") + 1 + 1) - players = [] + players_list = [] while True: player = response.read_ascii() if len(player) == 0: break - players.append(player) + players_list.append(player) + + return RawQueryResponse(**data), players_list - return cls(data, players) + +class AsyncServerQuerier(ServerQuerier): + def __init__(self, connection: UDPAsyncSocketConnection): + # We do this to inform python about self.connection type (it's async) + super().__init__(connection) # type: ignore[arg-type] + self.connection: UDPAsyncSocketConnection + + 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)) diff --git a/mcstatus/responses.py b/mcstatus/responses.py index 6786fafe..c30c9d46 100644 --- a/mcstatus/responses.py +++ b/mcstatus/responses.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, TYPE_CHECKING +from typing import Any, Literal, TYPE_CHECKING from mcstatus.forge_data import ForgeData, RawForgeData from mcstatus.motd import Motd @@ -46,12 +46,25 @@ class RawJavaResponse(TypedDict): modinfo: NotRequired[RawForgeData] 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 @@ -66,6 +79,7 @@ class RawJavaResponse(TypedDict): "JavaStatusPlayers", "JavaStatusResponse", "JavaStatusVersion", + "QueryResponse", ] @@ -340,3 +354,126 @@ def version(self) -> str: Will be removed 2023-12, use :attr:`.name` instead. """ return self.name + + +@dataclass(frozen=True) +class QueryResponse: + """The response object for :meth:`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"], + ) + + @property + @deprecated(replacement="map_name", date="2023-12") + def map(self) -> str | None: + """ + .. deprecated:: 11.0.0 + Will be removed 2023-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="2023-12") + def names(self) -> list[str]: + """ + .. deprecated:: 11.0.0 + Will be removed 2023-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 `_ or `Spigot `_.""" + 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 diff --git a/tests/responses/test_query.py b/tests/responses/test_query.py new file mode 100644 index 00000000..e2fb514c --- /dev/null +++ b/tests/responses/test_query.py @@ -0,0 +1,85 @@ +from pytest import fixture + +from mcstatus.motd import Motd +from mcstatus.responses import QueryPlayers, QueryResponse, QuerySoftware, RawQueryResponse +from tests.responses import BaseResponseTest + + +@BaseResponseTest.construct +class TestQueryResponse(BaseResponseTest): + RAW: RawQueryResponse = RawQueryResponse( + **{ # type: ignore # str cannot be assigned to Literal + "hostname": "A Minecraft Server", + "gametype": "GAME TYPE", + "game_id": "GAME ID", + "version": "1.8", + "plugins": "", + "map": "world", + "numplayers": "3", + "maxplayers": "20", + "hostport": "9999", + "hostip": "192.168.56.1", + } + ) + RAW_PLAYERS = ["Dinnerbone", "Djinnibone", "Steve"] + + EXPECTED_VALUES = [ + ("raw", RAW), + ("motd", Motd.parse("A Minecraft Server")), + ("map_name", "world"), + ("players", QueryPlayers(online=3, max=20, list=["Dinnerbone", "Djinnibone", "Steve"])), + ("software", QuerySoftware(version="1.8", brand="vanilla", plugins=[])), + ("ip", "192.168.56.1"), + ("port", 9999), + ("game_type", "GAME TYPE"), + ("game_id", "GAME ID"), + ] + + @fixture(scope="class") + def build(self): + return QueryResponse.build(raw=self.RAW, players_list=self.RAW_PLAYERS) + + +@BaseResponseTest.construct +class TestQueryPlayers(BaseResponseTest): + EXPECTED_VALUES = [ + ("online", 3), + ("max", 20), + ("list", ["Dinnerbone", "Djinnibone", "Steve"]), + ] + + @fixture(scope="class") + def build(self): + return QueryPlayers.build( + raw={ + "hostname": "A Minecraft Server", + "gametype": "SMP", + "game_id": "MINECRAFT", + "version": "1.8", + "plugins": "", + "map": "world", + "numplayers": "3", + "maxplayers": "20", + "hostport": "25565", + "hostip": "192.168.56.1", + }, + players_list=["Dinnerbone", "Djinnibone", "Steve"], + ) + + +class TestQuerySoftware: + def test_vanilla(self): + software = QuerySoftware.build("1.8", "") + assert software.brand == "vanilla" + assert software.version == "1.8" + assert software.plugins == [] + + def test_modded(self): + software = QuerySoftware.build("1.8", "A modded server: Foo 1.0; Bar 2.0; Baz 3.0") + assert software.brand == "A modded server" + assert software.plugins == ["Foo 1.0", "Bar 2.0", "Baz 3.0"] + + def test_modded_no_plugins(self): + software = QuerySoftware.build("1.8", "A modded server") + assert software.brand == "A modded server" + assert software.plugins == [] diff --git a/tests/test_async_querier.py b/tests/test_async_querier.py index a7b92a87..6fdc592a 100644 --- a/tests/test_async_querier.py +++ b/tests/test_async_querier.py @@ -48,4 +48,4 @@ def test_query(self): "hostport": "25565", "hostip": "192.168.56.1", } - assert response.players.names == ["Dinnerbone", "Djinnibone", "Steve"] + assert response.players.list == ["Dinnerbone", "Djinnibone", "Steve"] diff --git a/tests/test_querier.py b/tests/test_querier.py index 6b53f80d..143e0326 100644 --- a/tests/test_querier.py +++ b/tests/test_querier.py @@ -1,6 +1,6 @@ from mcstatus.motd import Motd from mcstatus.protocol.connection import Connection -from mcstatus.querier import QueryResponse, ServerQuerier +from mcstatus.querier import ServerQuerier class TestMinecraftQuerier: @@ -41,7 +41,7 @@ def test_query(self): "hostport": "25565", "hostip": "192.168.56.1", } - assert response.players.names == ["Dinnerbone", "Djinnibone", "Steve"] + assert response.players.list == ["Dinnerbone", "Djinnibone", "Steve"] def test_query_handles_unorderd_map_response(self): self.querier.connection.receive( @@ -89,53 +89,3 @@ def test_query_handles_unicode_motd_with_2a00_at_the_start(self): assert response.motd == Motd.parse("\x00other") # "\u2a00other" is actually what is expected, # but the query protocol for vanilla has a bug when it comes to unicode handling. # The status protocol correctly shows "⨀other". - - -class TestQueryResponse: - def setup_method(self): - self.raw = { - "hostname": "A Minecraft Server", - "gametype": "SMP", - "game_id": "MINECRAFT", - "version": "1.8", - "plugins": "", - "map": "world", - "numplayers": "3", - "maxplayers": "20", - "hostport": "25565", - "hostip": "192.168.56.1", - } - self.players = ["Dinnerbone", "Djinnibone", "Steve"] - - def test_valid(self): - response = QueryResponse(self.raw, self.players) - assert response.motd == Motd.parse("A Minecraft Server") - assert response.map == "world" - assert response.players.online == 3 - assert response.players.max == 20 - assert response.players.names == ["Dinnerbone", "Djinnibone", "Steve"] - assert response.software.brand == "vanilla" - assert response.software.version == "1.8" - assert response.software.plugins == [] - - def test_valid2(self): - players = QueryResponse.Players(5, 20, ["Dinnerbone", "Djinnibone", "Steve"]) - assert players.online == 5 - assert players.max == 20 - assert players.names == ["Dinnerbone", "Djinnibone", "Steve"] - - def test_vanilla(self): - software = QueryResponse.Software("1.8", "") - assert software.brand == "vanilla" - assert software.version == "1.8" - assert software.plugins == [] - - def test_modded(self): - software = QueryResponse.Software("1.8", "A modded server: Foo 1.0; Bar 2.0; Baz 3.0") - assert software.brand == "A modded server" - assert software.plugins == ["Foo 1.0", "Bar 2.0", "Baz 3.0"] - - def test_modded_no_plugins(self): - software = QueryResponse.Software("1.8", "A modded server") - assert software.brand == "A modded server" - assert software.plugins == []