From 1c0a720700e9c0482301a6c06950934c0976358d Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 8 Jun 2023 18:35:16 +0200 Subject: [PATCH] Restructure the project for new versioning approach In issue #45, it was decided that mcproto should not end up supporting multiple protocol versions, and instead it should only deal with the single (latest) version. This commit therefore restructures the project, removing all versioned components, keeping only a single version. This change also means a removal of the VersionMap ABC, which was used for PACKET_MAP construction for the various game states. As this is no longer the case, the current approach for now will be to construct the packet maps manually, which is indeed very annoying, and a better approach will need to be implemented later. --- README.md | 139 ++++---- changes/116.breaking.md | 4 + mcproto/packets/__init__.py | 11 +- .../packets/{v757 => handshaking}/__init__.py | 0 .../{v757 => }/handshaking/handshake.py | 0 mcproto/packets/interactions.py | 5 +- .../{v757/handshaking => login}/__init__.py | 0 mcproto/packets/{v757 => }/login/login.py | 4 +- mcproto/packets/map.py | 97 ------ .../{v757/login => status}/__init__.py | 0 mcproto/packets/{v757 => }/status/ping.py | 0 mcproto/packets/{v757 => }/status/status.py | 0 mcproto/types/{v757 => }/chat.py | 0 mcproto/types/{v757 => }/uuid.py | 0 mcproto/utils/version_map.py | 301 ------------------ .../mcproto/packets/handshaking}/__init__.py | 0 .../{v757 => }/handshaking/test_handshake.py | 2 +- .../mcproto/packets/login}/__init__.py | 0 .../packets/{v757 => }/login/test_login.py | 6 +- .../{v757/handshaking => status}/__init__.py | 0 .../packets/{v757 => }/status/test_ping.py | 2 +- .../packets/{v757 => }/status/test_status.py | 2 +- tests/mcproto/packets/v757/status/__init__.py | 1 - .../{packets/v757/login => types}/__init__.py | 0 tests/mcproto/types/{v757 => }/test_chat.py | 2 +- tests/mcproto/types/{v757 => }/test_uuid.py | 2 +- tests/mcproto/types/v757/__init__.py | 1 - 27 files changed, 75 insertions(+), 504 deletions(-) create mode 100644 changes/116.breaking.md rename mcproto/packets/{v757 => handshaking}/__init__.py (100%) rename mcproto/packets/{v757 => }/handshaking/handshake.py (100%) rename mcproto/packets/{v757/handshaking => login}/__init__.py (100%) rename mcproto/packets/{v757 => }/login/login.py (98%) delete mode 100644 mcproto/packets/map.py rename mcproto/packets/{v757/login => status}/__init__.py (100%) rename mcproto/packets/{v757 => }/status/ping.py (100%) rename mcproto/packets/{v757 => }/status/status.py (100%) rename mcproto/types/{v757 => }/chat.py (100%) rename mcproto/types/{v757 => }/uuid.py (100%) delete mode 100644 mcproto/utils/version_map.py rename {mcproto/packets/v757/status => tests/mcproto/packets/handshaking}/__init__.py (100%) rename tests/mcproto/packets/{v757 => }/handshaking/test_handshake.py (97%) rename {mcproto/types/v757 => tests/mcproto/packets/login}/__init__.py (100%) rename tests/mcproto/packets/{v757 => }/login/test_login.py (98%) rename tests/mcproto/packets/{v757/handshaking => status}/__init__.py (100%) rename tests/mcproto/packets/{v757 => }/status/test_ping.py (94%) rename tests/mcproto/packets/{v757 => }/status/test_status.py (97%) delete mode 100644 tests/mcproto/packets/v757/status/__init__.py rename tests/mcproto/{packets/v757/login => types}/__init__.py (100%) rename tests/mcproto/types/{v757 => }/test_chat.py (96%) rename tests/mcproto/types/{v757 => }/test_uuid.py (95%) delete mode 100644 tests/mcproto/types/v757/__init__.py diff --git a/README.md b/README.md index 22101001..4dbdc94a 100644 --- a/README.md +++ b/README.md @@ -186,66 +186,51 @@ def start(): ### Using packet classes for communication -#### Obtaining the packet map +The first thing you'll need to understand about packet classes in mcproto is that they're generally going to support +the latest minecraft version, and while any the versions are usually mostly compatible, mcproto does NOT guarantee +support for any older protocol versions. -The first thing you'll need to understand about packet classes in mcproto is that they're versioned depending on the -protocol version you're using. As we've already seen with minecraft packets, they're following a certain format, and -for given packet direction and game state, the packet numbers are unique. +#### Obtaining the packet map -This is how we can detect what packet is being received, but because of the different versions that the library can -support, we will need to use a packet map, that will contain all of the mappings for given protocol version, from -which, knowing the state and direction, we can get a dictionary with packet IDs as keys, and the individual packet -classes as values. +As we've already seen in the example before, packets follow certain format, and every packet has it's associated ID +number, direction (client->server or server->client), and game state (status/handshaking/login/play). The packet IDs +are unique to given direction and game state combination. -This dictionary is crucial to receiving packets, as it's the only thing that tells us which packet class should be -used, once we receive a packet and learn about the packet id. Otherwise we wouldn't know what to do with the data we -obtained. +For example in clientbound direction (packets sent from server to the client), when in the status game state, there +will always be unique ID numbers for the different packets. In this case, there would actually only be 2 packets here: +The Ping response packet, which has an ID of 1, and the Status response packet, with an ID of 0. -The first game state we'll be in, before doing anything will always be the handshaking state, so let's see how we could -generate this dictionary for this state, for all of the receiving (client bound) packets. +To receive a packet, we therefore need to know both the game state, and the direction, as only then are we able to +figure out what the type of packet it is. In mcproto, packet receiving therefore requires a "packet map", which is a +mapping (dictionary) of packet id -> packet class. In the future, all you'll need will be to know the direction and +game state, and the packet map will be obtained based on that, however right now, mcproto doesn't yet support that, +which means you'll need to define and pass over these packet maps yourself. Here's the packet map for that status +state, with clientbound direction: ```python -from mcproto.packets import PACKET_MAP -from mcproto.packets.abc import PacketDirection, GameState - -handshaking_packet_map = PACKET_MAP.make_id_map( - protocol_version=757, - direction=PacketDirection.CLIENTBOUND, - game_state=GameState.HANDSHAKING -) +from mcproto.packets.status.status import StatusResponse +from mcproto.packets.status.ping import PingPong -print(handshaking_packet_map) # {} +STATUS_CLIENTBOUND_MAP = { + PingPong.PACKET_ID: PingPong, + StatusResponse.PAKCET_ID: StatusResponse, +} ``` -Notice that the packet map is actually empty, and this is simply because there (currently) aren't any client bound -packets a server can send out for the handshaking game state. Let's see the status gamestate instead: +The first game state we'll be in, before doing anything will always be the handshaking state. However this state +actually only contains server bound packets, so the client-bound packet map for it would be an empty dict. -```python -status_packet_map = PACKET_MAP.make_id_map( - protocol_version=757, - direction=PacketDirection.CLIENTBOUND, - game_state=GameState.STATUS, -) - -print(status_packet_map) # Will print: -# {1: mcproto.packets.v757.status.ping.PingPong, 0: mcproto.packets.v757.status.status.StatusResponse} -``` +#### Building our own packets -Cool! These are all of the packets, with their IDs that the server can send in STATUS game state. - -#### Creating our own packets - - -Now, we could create a similar packet map for sending out the packets, and just use it to construct our packets, -however this is not the recommended approach, as it's kind of hard to remember all of the packet IDs, and (at least -currently) it means not having any typing information about what packet class will we get. For that reason, it's -recommended to import packets that we want to send out manually, like so: +Our first packet will always have to be a Handshake, this is the only packet in the entire handshaking state, and it's +a "gateway", after which we get moved to a different state, specifically, either to STATUS (to obtain information about +the server, such as motd, amount of players, or other details you'd see in the multiplayer screen in your MC client). ```python -from mcproto.packets.v757.handshaking.handshake import Handshake, NextState +from mcproto.packets.handshaking.handshake import Handshake, NextState my_handshake = Handshake( - # Once again, we use an old protocol version so that even older servers will work + # Once again, we use an old protocol version so that even older servers will respond protocol_version=47, server_address="mc.hypixel.net", server_port=25565, @@ -257,14 +242,14 @@ That's it! We've now constructed a full handshake packet with all of the data it from the example above, that we originally had to look at the protocol specification, find the handshake packet and construct it's data as a Buffer with all of these variables. -With these packet classes, you can simply follow your editor's function hints to see what this packet requires, pass it -in and the data will be constructed for you from these attributes, once we'll be to sending it. +With these packet classes, you can simply follow your editor's autocompletion to see what this packet requires, pass it +in and the data will be constructed for you from these attributes, without constantly cross-checking with the wiki. For completion, let's also construct the status request packet that we were sending to instruct the server to send us back the status response packet. ```python -from mcproto.packets.v757.status.status import StatusRequest +from mcproto.packets.status.status import StatusRequest my_status_request = StatusRequest() ``` @@ -286,29 +271,20 @@ async def main(): port = 25565 async with (await TCPAsyncConnection.make_client((ip, port), 2)) as connection: - await async_write_packet(connection, my_handshake) + await async_write_packet(connection, my_handshake) # my_handshake is a packet we've created in the example before ``` -How quick was that? Now compare this to the manual version. +Much easier than the manual version, isn't it? #### Receiving packets Alright, we might now know how to send a packet, but how do we receive one? Let's see: ```python -from mcproto.packets import PACKET_MAP - -# Let's prepare the packet map we'll be using, say we're already in the STATUS game state now -STATUS_PACKET_MAP = PACKET_MAP.make_id_map( - protocol_version=757, - direction=PacketDirection.CLIENTBOUND, - game_state=GameState.STATUS -) - # Let's say we already have a connection at this moment, after all, how else would # we've gotten into the STATUS game state. -# Also, let's do something different, let's say we have a synchronous connection +# Also, let's do something different, let's say we have a synchronous connection, just for fun from mcproto.connection import TCPSyncConnection conn: TCPSyncConnection @@ -316,12 +292,13 @@ conn: TCPSyncConnection # we'll use sync_read_packet here from mcproto.packets import sync_read_packet -packet = sync_read_packet(conn, STATUS_PACKET_MAP) +# But remember? To read a packet, we'll need to have that packet map, telling us which IDs represent +# which actual packet types. Let's pass in the one we've constructed before +packet = sync_read_packet(conn, STATUS_CLIENTBOUND_MAP) -# Cool! We've got back a packet, but what packet is it? Let's import the packet classes it could -# be and check against them -from mcproto.packets.v757.status.status import StatusResponse -from mcproto.packets.v757.status.ping import PingPong +# Cool! We've got back a packet, let's see what kind of packet we got back +from mcproto.packets.status.status import StatusResponse +from mcproto.packets.status.ping import PingPong if isinstance(packet, StatusResponse): ... @@ -333,21 +310,21 @@ else: #### Requesting status -Now let's actually do something meaningful, and replicate the entire example from the manual version using packets, -let's see just how much simpler it will be: +Alright, so let's actually try to put all of this knowledge together, and create something meaningful. Let's replicate +the status obtaining logic from the manual example, but with these new packet classes: ```python from mcproto.connection import TCPAsyncConnection -from mcproto.packets import async_write_packet, async_read_packet, PACKET_MAP +from mcproto.packets import async_write_packet, async_read_packet from mcproto.packets.abc import PacketDirection, GameState -from mcproto.packets.v757.handshaking.handshake import Handshake, NextState -from mcproto.packets.v757.status.status import StatusRequest, StatusResponse +from mcproto.packets.handshaking.handshake import Handshake, NextState +from mcproto.packets.status.status import StatusRequest, StatusResponse +from mcproto.packets.status.ping import PingPong -STATUS_PACKET_MAP = PACKET_MAP.make_id_map( - protocol_version=757, - direction=PacketDirection.CLIENTBOUND, - game_state=GameState.STATUS -) +STATUS_CLIENTBOUND_MAP = { + PingPong.PACKET_ID: PingPong, + StatusResponse.PAKCET_ID: StatusResponse, +} async def get_status(ip: str, port: int) -> dict: @@ -361,18 +338,20 @@ async def get_status(ip: str, port: int) -> dict: async with (await TCPAsyncConnection.make_client((ip, port), 2)) as connection: # We start out at HANDSHAKING game state - await async_write_packet(connection, handshake_packet) + await async_write_packet(connection, handshake_packet) # After sending the handshake, we told the server to now move us into the STATUS game state - await async_write_packet(connection, status_req_packet) + await async_write_packet(connection, status_req_packet) # Since we're still in STATUS game state, we use the status packet map when reading - packet = await async_read_packet(connection, STATUS_PACKET_MAP) + packet = await async_read_packet(connection, STATUS_CLIENTBOUND_MAP) # Now that we've got back the packet, we no longer need the connection, we won't be sending - # anything else. Let's just make sure it really is the packet we expected + # anything else, so let's get out of the context manager. + + # Now, we should always first make sure it really is the packet we expected if not isinstance(packet, StatusResponse): raise ValueError(f"We've got an unexpected packet back: {packet!r}") - # Now that we know we're dealing with a status response, let's get out it's data, and return in + # Since we know we really are dealing with a status response, let's get out it's data, and return it return packet.data ``` diff --git a/changes/116.breaking.md b/changes/116.breaking.md new file mode 100644 index 00000000..0ebbc47c --- /dev/null +++ b/changes/116.breaking.md @@ -0,0 +1,4 @@ +Restructure the project, moving to a single protocol version model +- This change does NOT have a deprecation period, and will very likely break most existing code-bases. However this change is necessary, as multi-version support was unsustainable (see issue #45 for more details) +- Any packets and types will no longer be present in versioned folders (mcproto.packets.v757.xxx), but rather be directly in the parent directory (mcproto.packets.xxx). +- This change doesn't affect manual communication with the server, connection, and basic IO writers/readers remain the same. diff --git a/mcproto/packets/__init__.py b/mcproto/packets/__init__.py index 1e3ca387..2fb26033 100644 --- a/mcproto/packets/__init__.py +++ b/mcproto/packets/__init__.py @@ -1,22 +1,13 @@ from __future__ import annotations from mcproto.packets.abc import ClientBoundPacket, GameState, Packet, PacketDirection, ServerBoundPacket -from mcproto.packets.interactions import ( - PACKET_MAP, - async_read_packet, - async_write_packet, - sync_read_packet, - sync_write_packet, -) -from mcproto.packets.map import PacketMap +from mcproto.packets.interactions import async_read_packet, async_write_packet, sync_read_packet, sync_write_packet __all__ = [ "ClientBoundPacket", "GameState", - "PACKET_MAP", "Packet", "PacketDirection", - "PacketMap", "ServerBoundPacket", "async_read_packet", "async_write_packet", diff --git a/mcproto/packets/v757/__init__.py b/mcproto/packets/handshaking/__init__.py similarity index 100% rename from mcproto/packets/v757/__init__.py rename to mcproto/packets/handshaking/__init__.py diff --git a/mcproto/packets/v757/handshaking/handshake.py b/mcproto/packets/handshaking/handshake.py similarity index 100% rename from mcproto/packets/v757/handshaking/handshake.py rename to mcproto/packets/handshaking/handshake.py diff --git a/mcproto/packets/interactions.py b/mcproto/packets/interactions.py index 84fd72c2..81edd268 100644 --- a/mcproto/packets/interactions.py +++ b/mcproto/packets/interactions.py @@ -6,10 +6,9 @@ from mcproto.buffer import Buffer from mcproto.packets.abc import Packet -from mcproto.packets.map import PacketMap from mcproto.protocol.base_io import BaseAsyncReader, BaseAsyncWriter, BaseSyncReader, BaseSyncWriter -__all__ = ["async_read_packet", "async_write_packet", "sync_read_packet", "sync_write_packet", "PACKET_MAP"] +__all__ = ["async_read_packet", "async_write_packet", "sync_read_packet", "sync_write_packet"] T_Packet = TypeVar("T_Packet", bound=Packet) @@ -24,8 +23,6 @@ # Since the read functions here require PACKET_MAP, we can't move these functions # directly into BaseWriter/BaseReader classes, as that would be a circular import -PACKET_MAP = PacketMap() - def _serialize_packet(packet: Packet, *, compressed: bool = False) -> Buffer: """Serialize the internal packet data, along with it's packet id.""" diff --git a/mcproto/packets/v757/handshaking/__init__.py b/mcproto/packets/login/__init__.py similarity index 100% rename from mcproto/packets/v757/handshaking/__init__.py rename to mcproto/packets/login/__init__.py diff --git a/mcproto/packets/v757/login/login.py b/mcproto/packets/login/login.py similarity index 98% rename from mcproto/packets/v757/login/login.py rename to mcproto/packets/login/login.py index dd4dbcf5..f140d166 100644 --- a/mcproto/packets/v757/login/login.py +++ b/mcproto/packets/login/login.py @@ -6,8 +6,8 @@ from mcproto.buffer import Buffer from mcproto.packets.abc import ClientBoundPacket, GameState, ServerBoundPacket -from mcproto.types.v757.chat import ChatMessage -from mcproto.types.v757.uuid import UUID +from mcproto.types.chat import ChatMessage +from mcproto.types.uuid import UUID __all__ = [ "LoginStart", diff --git a/mcproto/packets/map.py b/mcproto/packets/map.py deleted file mode 100644 index 50a0ab39..00000000 --- a/mcproto/packets/map.py +++ /dev/null @@ -1,97 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any, ClassVar, Literal, overload - -from typing_extensions import TypeGuard - -from mcproto.packets.abc import ClientBoundPacket, GameState, Packet, PacketDirection, ServerBoundPacket -from mcproto.utils.version_map import VersionMap, WalkableModuleData - -__all__ = ["PacketMap"] - - -class PacketMap(VersionMap["tuple[PacketDirection, GameState, int]", "type[Packet]"]): - SUPPORTED_VERSIONS: ClassVar[set[int]] = {757} - COMPATIBLE_FALLBACK_VERSIONS: ClassVar[set[int]] = set() - _SEARCH_DIR_QUALNAME: ClassVar[str] = "mcproto.packets" - - __slots__ = () - - @overload - def make_id_map( - self, - protocol_version: int, - direction: Literal[PacketDirection.CLIENTBOUND], - game_state: GameState, - ) -> dict[int, type[ClientBoundPacket]]: - ... - - @overload - def make_id_map( - self, - protocol_version: int, - direction: Literal[PacketDirection.SERVERBOUND], - game_state: GameState, - ) -> dict[int, type[ClientBoundPacket]]: - ... - - def make_id_map( - self, - protocol_version: int, - direction: PacketDirection, - game_state: GameState, - ) -> Mapping[int, type[Packet]]: - """Construct a dictionary mapping (packet ID -> packet class) for values matching given attributes.""" - res = {} - for (k_direction, k_game_state, k_packet_id), v in self.make_version_map(protocol_version).items(): - if k_direction is direction and k_game_state is game_state: - res[k_packet_id] = v - return res - - def _check_obj( - self, - obj: Any, # noqa: ANN401 - module_data: WalkableModuleData, - protocol_version: int, - ) -> TypeGuard[type[Packet]]: - """Determine whether a member object should be considered as a valid component for given protocol version. - - When versioned components are obtained, all of the members listed in any module's ``__all__`` are - considered. This function serves as a filter, identifying whether a potential member object should be - considered as one of the versioned components. - - .. note: - This function shouldn't include any checks on whether an object is already registered in the version - map (key collisions), these are handled during the collection in :meth:`.load_version`, all this - function is responsible for is checking whether this object is a valid component, components with - conflicting keys are still considered valid here, as they're handled elsewhere. - - However if there is some additional data that needs to be unique for a component to be valid, which - wouldn't be caught as a key collision, this function can raise a :exc:`ValueError`. - """ - return issubclass(obj, Packet) - - @classmethod - def _make_obtain_key( - cls, - obj: type[Packet], - module_data: WalkableModuleData, - protocol_version: int, - ) -> tuple[PacketDirection, GameState, int]: - """Construct a unique obtain key for given versioned component (``obj``) under given ``protocol_version``. - - .. note: - While the protocol version might be beneficial to know when constructing - the obtain key, it shouldn't be used directly as a part of the key, as the items - will already be split by their protocol versions, and this version will be known - at obtaining time. - """ - if issubclass(obj, ClientBoundPacket): - direction = PacketDirection.CLIENTBOUND - elif issubclass(obj, ServerBoundPacket): - direction = PacketDirection.SERVERBOUND - else: - raise ValueError("Invalid packet class: Neither server-bound not client-bound.") - - return direction, obj.GAME_STATE, obj.PACKET_ID diff --git a/mcproto/packets/v757/login/__init__.py b/mcproto/packets/status/__init__.py similarity index 100% rename from mcproto/packets/v757/login/__init__.py rename to mcproto/packets/status/__init__.py diff --git a/mcproto/packets/v757/status/ping.py b/mcproto/packets/status/ping.py similarity index 100% rename from mcproto/packets/v757/status/ping.py rename to mcproto/packets/status/ping.py diff --git a/mcproto/packets/v757/status/status.py b/mcproto/packets/status/status.py similarity index 100% rename from mcproto/packets/v757/status/status.py rename to mcproto/packets/status/status.py diff --git a/mcproto/types/v757/chat.py b/mcproto/types/chat.py similarity index 100% rename from mcproto/types/v757/chat.py rename to mcproto/types/chat.py diff --git a/mcproto/types/v757/uuid.py b/mcproto/types/uuid.py similarity index 100% rename from mcproto/types/v757/uuid.py rename to mcproto/types/uuid.py diff --git a/mcproto/utils/version_map.py b/mcproto/utils/version_map.py deleted file mode 100644 index 23ef9fe5..00000000 --- a/mcproto/utils/version_map.py +++ /dev/null @@ -1,301 +0,0 @@ -from __future__ import annotations - -import importlib -import pkgutil -import warnings -from abc import ABC, abstractmethod -from collections.abc import Hashable, Iterator, Sequence -from types import ModuleType -from typing import Any, ClassVar, Generic, NamedTuple, NoReturn, TypeVar - -from typing_extensions import TypeGuard - -from mcproto.utils.abc import RequiredParamsABCMixin - -__all__ = ["VersionMap", "WalkableModuleData"] - -K = TypeVar("K", bound=Hashable) -V = TypeVar("V") - - -class WalkableModuleData(NamedTuple): - module: ModuleType - info: pkgutil.ModuleInfo - member_names: Sequence[str] - - -class VersionMap(ABC, RequiredParamsABCMixin, Generic[K, V]): - """Base class for map classes that allow obtaining versioned implementations for same components. - - Some components (classes, functions, constants, ...) need to be versioned as they might change - between different protocol versions. :class:`.VersionMap` classes are responsible for finding the - implementation for given component that matches the requested version for. - - If the exact requested version is not found, the closest older/lesser version will be used instead, - however as this might cause issues, a user warning will be produced alongside this happening. - - .. note: - If you need typed implementations, you should import the versioned components directly, as due - to the dynamic nature of this map, and the changing nature of the versioned implementations, - no type information can be provided here. - - .. warning: - You are not expected to initialize these mapping classes yourself! That should be left for - the library to handle and provide a map for each individual versioned components. - """ - - # TODO: Look into generating stubs somehow to preserve the type information even with how - # dynamic this class is. (Could require making this class generic over a literal int, being - # the version). - - _REQUIRED_CLASS_VARS: ClassVar[Sequence[str]] = [ - "SUPPORTED_VERSIONS", - "_SEARCH_DIR_QUALNAME", - "COMPATIBLE_FALLBACK_VERSIONS", - ] - - #: Set of all versions that have complete implementations, with package for the version - SUPPORTED_VERSIONS: ClassVar[set[int]] - #: Set of all versions that are fully compatible with an older supported version that they safely fall back to - COMPATIBLE_FALLBACK_VERSIONS: ClassVar[set[int]] - #: Fully qualified name of the package, containing individual versioned packages - _SEARCH_DIR_QUALNAME: ClassVar[str] - - __slots__ = ("_version_map",) - - def __init__(self, preload_all: bool = False): - """ - :param preload_all: - By default, the individual versioned components are loaded lazily, once requested. - However if this is set to ``True``, the components from all :attr:`.SUPPORTED_VERSIONS` - will all get loaded on initialization. - """ - self._version_map: dict[int, dict[K, V]] = {} - - if preload_all: - for version in self.SUPPORTED_VERSIONS: - self.load_version(version) - - def load_version(self, protocol_version: int) -> None: - """Load all components for given protocol version. - - Go through all versioned components found for the requested version (from all submodules in the - package for given version.) and store them into an internal version map. Each component is stored - under it's corresponding obtain key. - - :raises ValueError: - Raised if a key collision occurs (obtain key of multiple distinct versioned components is the same). - """ - for key, member in self._walk_valid_components(protocol_version): - dct = self._version_map.setdefault(protocol_version, {}) - - if key in dct: - raise ValueError(f"Value {member} already registered (key collision: {key})") - - dct[key] = member - - def obtain(self, protocol_version: int, key: K) -> V: - """Obtain component for given protocol version, that matches the obtain key. - - This function works lazily, and only loads the required components for given ``protocol_version`` - if they're not already loaded. If loading does occr, the requested version is stored internally - and will simply be accessed next time this function is called with the same ``protocol_version``. - - Once loaded, a versioned component that matches the given obtain ``key`` is returned. - - .. note: - If given ``protocol_version`` isn't supported, a fallback version is picked - (see: :meth:`._get_supported_protocol_version`). - """ - protocol_version = self._get_supported_protocol_version(protocol_version) - - # Lazy loading: if this protocol version isn't in the version map, it wasn't yet loaded, do so now. - if protocol_version not in self._version_map: - self.load_version(protocol_version) - - return self._version_map[protocol_version][key] - - def make_version_map(self, protocol_version: int) -> dict[K, V]: - """Obtain a dictionary mapping of obtain key -> versioned component, with all components for given version. - - This function works lazily, and only loads the required components for given ``protocol_version`` - if they're not already loaded. If loading does occur, the requested version is stored internally and - will simply be accessed next time this function is called with the same ``protocol_version``. - - The returned dictionary is a copy of the internal one, as we don't want the user to be able to modify it. - - .. note: - If given ``protocol_version`` isn't supported, a fallback version is picked - (see: :meth:`._get_supported_protocol_version`). - """ - protocol_version = self._get_supported_protocol_version(protocol_version) - - # Lazy loading: if this protocol version isn't in the version map, it wasn't yet loaded, do so now. - if protocol_version not in self._version_map: - self.load_version(protocol_version) - - return self._version_map[protocol_version].copy() - - @abstractmethod - def _check_obj( - self, - obj: Any, # noqa: ANN401 - module_data: WalkableModuleData, - protocol_version: int, - ) -> TypeGuard[V]: - """Determine whether a member object should be considered as a valid component for given protocol version. - - When versioned components are obtained, all of the members listed in any module's ``__all__`` are - considered. This function serves as a filter, identifying whether a potential member object should be - considered as one of the versioned components. - - .. note: - This function shouldn't include any checks on whether an object is already registered in the version - map (key collisions), these are handled during the collection in :meth:`.load_version`, all this - function is responsible for is checking whether this object is a valid component, components with - conflicting keys are still considered valid here, as they're handled elsewhere. - - However if there is some additional data that needs to be unique for a component to be valid, which - wouldn't be caught as a key collision, this function can raise a :exc:`ValueError`. - """ - raise NotImplementedError - - @classmethod - @abstractmethod - def _make_obtain_key(cls, obj: V, module_data: WalkableModuleData, protocol_version: int) -> K: - """Construct a unique obtain key for given versioned component (``obj``) under given ``protocol_version``. - - .. note: - While the protocol version might be beneficial to know when constructing - the obtain key, it shouldn't be used directly as a part of the key, as the items - will already be split by their protocol versions, and this version will be known - at obtaining time. - """ - raise NotImplementedError - - @classmethod - def _get_package_module(cls, protocol_version: int) -> ModuleType: - """Obtain the package module containing all components specific to given protocol version. - - This relies on the :attr:`._SEARCH_DIR_QUALNAME` class variable, and naming scheme of - v{protocol_version} for the modules contained in this directory. - """ - module_name = f"{cls._SEARCH_DIR_QUALNAME}.v{protocol_version}" - try: - module = importlib.import_module(module_name) - except ModuleNotFoundError as exc: - raise ValueError( - f"Protocol version ({protocol_version}) isn't supported, yet it was listed as supported. Report this!" - ) from exc - - return module - - @classmethod - def _walk_submodules(cls, module: ModuleType) -> Iterator[WalkableModuleData]: - """Go over all submodules of given module, that specify ``__all__``. - - The yielded modules are expected to contain the versioned component items. - - If a submodule that doesn't define ``__all__`` is present, it will be skipped, as we don't - consider it walkable. (Walking over all variables defined in this module isn't viable, - since it would include imports etc. making defining ``__all__`` a requirement.) - """ - - def on_error(name: str) -> NoReturn: - raise ImportError(name=name) - - for module_info in pkgutil.walk_packages(module.__path__, f"{module.__name__}.", onerror=on_error): - imported_module = importlib.import_module(module_info.name) - - if not hasattr(imported_module, "__all__"): - continue - member_names = imported_module.__all__ - - if not isinstance(member_names, Sequence): - raise TypeError(f"Module {module_info.name!r}'s __all__ isn't defined as a sequence.") - - for member_name in member_names: - if not isinstance(member_name, str): - raise TypeError(f"Module {module_info.name!r}'s __all__ contains non-string item.") - - yield WalkableModuleData(imported_module, module_info, member_names) - - @classmethod - def _walk_members(cls, module_data: WalkableModuleData) -> Iterator[object]: - """Go over all members specified in ``__all__`` of given walkable (sub)module. - - :return: - Iterator yielding every object defined in ``__all__`` of given module. These objects - are obtained directly using ``getattr`` from the imported module. - - :raises TypeError: - Raised when an attribute defined in ``__all__`` can't be obtained using ``getattr``. - This would suggest the module has incorrectly defined ``__all__``, as it includes values - that aren't actually defined in the module. - """ - for member_name in module_data.member_names: - try: - member = getattr(module_data.module, member_name) - except AttributeError as exc: - module_name = module_data.info.name - raise TypeError(f"Member {member_name!r} of {module_name!r} module isn't defined.") from exc - - yield member - - def _walk_valid_components(self, protocol_version: int) -> Iterator[tuple[K, V]]: - """Go over all components/members belonging to given protocol version. - - This method walks over all submodules in the versioned module (see: :meth:`._get_package_module`), - in which we go over all members present in ``__all__`` of this submodule (see :meth:`._walk_submodules`), - after which a :meth:`_check_obj` check is performed, ensuring the obtained member/component is one that - should be versioned. - - :return: - A tuple containing the obtain key (see: :meth:`._make_obtain_key`), and the versioned component/member. - """ - version_module = self._get_package_module(protocol_version) - for module_data in self._walk_submodules(version_module): - for member in self._walk_members(module_data): - if self._check_obj(member, module_data, protocol_version): - key = self._make_obtain_key(member, module_data, protocol_version) - yield key, member - - def _get_supported_protocol_version(self, protocol_version: int) -> int: - """Given a ``protocol_version``, return closest older supported version, or the version itself. - - * If given ``protocol_version`` is one of :attr:`.SUPPORTED_VERSIONS`, return it. - * Otherwise, search for the closest older supported version to the provided ``protocol_version``. - * If there is no older version in the supported version, :exc:`ValueError` is raised - * If the requested version is in :attr:`.COMPATIBLE_FALLBACK_VERSIONS`, this fallback will be - considered safe, and no errors/warnings will be produced. - * Otherwise, the closest version will still be returned, but a :exc:`UserWarning` will be emitted - mentioning that this fallback occurred and that the version might not be fully compatible. - - :raises ValueError: - The requested version isn't supported, and there is no supported older version to fallback to. - """ - - if protocol_version in self.SUPPORTED_VERSIONS: - return protocol_version - - # Pick the most recent, but older version to the one that was requested - older_versions = filter(lambda version: version < protocol_version, self.SUPPORTED_VERSIONS) - try: - protocol_version_closest = max(older_versions) - except ValueError as exc: # No older protocol version found - raise ValueError( - f"Requested protocol version ({protocol_version}) isn't supported" - ", and no older version to fall back to was found." - ) from exc - - # Since using an older/lesser version to the one that was requested is a fallback behavior and - # could easily cause issues, emit a warning here, unless the version was explicitly marked compatible. - if protocol_version not in self.COMPATIBLE_FALLBACK_VERSIONS: - warnings.warn( - f"Falling back to older protocol version {protocol_version_closest}, " - f"as the requested version ({protocol_version}) isn't supported.", - category=UserWarning, - stacklevel=3, - ) - - return protocol_version_closest diff --git a/mcproto/packets/v757/status/__init__.py b/tests/mcproto/packets/handshaking/__init__.py similarity index 100% rename from mcproto/packets/v757/status/__init__.py rename to tests/mcproto/packets/handshaking/__init__.py diff --git a/tests/mcproto/packets/v757/handshaking/test_handshake.py b/tests/mcproto/packets/handshaking/test_handshake.py similarity index 97% rename from tests/mcproto/packets/v757/handshaking/test_handshake.py rename to tests/mcproto/packets/handshaking/test_handshake.py index 2ac7363a..85963fdf 100644 --- a/tests/mcproto/packets/v757/handshaking/test_handshake.py +++ b/tests/mcproto/packets/handshaking/test_handshake.py @@ -5,7 +5,7 @@ import pytest from mcproto.buffer import Buffer -from mcproto.packets.v757.handshaking.handshake import Handshake, NextState +from mcproto.packets.handshaking.handshake import Handshake, NextState @pytest.mark.parametrize( diff --git a/mcproto/types/v757/__init__.py b/tests/mcproto/packets/login/__init__.py similarity index 100% rename from mcproto/types/v757/__init__.py rename to tests/mcproto/packets/login/__init__.py diff --git a/tests/mcproto/packets/v757/login/test_login.py b/tests/mcproto/packets/login/test_login.py similarity index 98% rename from tests/mcproto/packets/v757/login/test_login.py rename to tests/mcproto/packets/login/test_login.py index d62c8fc4..f355cd15 100644 --- a/tests/mcproto/packets/v757/login/test_login.py +++ b/tests/mcproto/packets/login/test_login.py @@ -5,7 +5,7 @@ import pytest from mcproto.buffer import Buffer -from mcproto.packets.v757.login.login import ( +from mcproto.packets.login.login import ( LoginDisconnect, LoginEncryptionRequest, LoginEncryptionResponse, @@ -15,8 +15,8 @@ LoginStart, LoginSuccess, ) -from mcproto.types.v757.chat import ChatMessage -from mcproto.types.v757.uuid import UUID +from mcproto.types.chat import ChatMessage +from mcproto.types.uuid import UUID class TestLoginStart: diff --git a/tests/mcproto/packets/v757/handshaking/__init__.py b/tests/mcproto/packets/status/__init__.py similarity index 100% rename from tests/mcproto/packets/v757/handshaking/__init__.py rename to tests/mcproto/packets/status/__init__.py diff --git a/tests/mcproto/packets/v757/status/test_ping.py b/tests/mcproto/packets/status/test_ping.py similarity index 94% rename from tests/mcproto/packets/v757/status/test_ping.py rename to tests/mcproto/packets/status/test_ping.py index b0c42049..f7873ea2 100644 --- a/tests/mcproto/packets/v757/status/test_ping.py +++ b/tests/mcproto/packets/status/test_ping.py @@ -5,7 +5,7 @@ import pytest from mcproto.buffer import Buffer -from mcproto.packets.v757.status.ping import PingPong +from mcproto.packets.status.ping import PingPong @pytest.mark.parametrize( diff --git a/tests/mcproto/packets/v757/status/test_status.py b/tests/mcproto/packets/status/test_status.py similarity index 97% rename from tests/mcproto/packets/v757/status/test_status.py rename to tests/mcproto/packets/status/test_status.py index ad0f3e60..73fe89dc 100644 --- a/tests/mcproto/packets/v757/status/test_status.py +++ b/tests/mcproto/packets/status/test_status.py @@ -6,7 +6,7 @@ import pytest from mcproto.buffer import Buffer -from mcproto.packets.v757.status.status import StatusResponse +from mcproto.packets.status.status import StatusResponse @pytest.mark.parametrize( diff --git a/tests/mcproto/packets/v757/status/__init__.py b/tests/mcproto/packets/v757/status/__init__.py deleted file mode 100644 index 9d48db4f..00000000 --- a/tests/mcproto/packets/v757/status/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/tests/mcproto/packets/v757/login/__init__.py b/tests/mcproto/types/__init__.py similarity index 100% rename from tests/mcproto/packets/v757/login/__init__.py rename to tests/mcproto/types/__init__.py diff --git a/tests/mcproto/types/v757/test_chat.py b/tests/mcproto/types/test_chat.py similarity index 96% rename from tests/mcproto/types/v757/test_chat.py rename to tests/mcproto/types/test_chat.py index 0398a6c3..fe12f5d9 100644 --- a/tests/mcproto/types/v757/test_chat.py +++ b/tests/mcproto/types/test_chat.py @@ -3,7 +3,7 @@ import pytest from mcproto.buffer import Buffer -from mcproto.types.v757.chat import ChatMessage, RawChatMessage, RawChatMessageDict +from mcproto.types.chat import ChatMessage, RawChatMessage, RawChatMessageDict @pytest.mark.parametrize( diff --git a/tests/mcproto/types/v757/test_uuid.py b/tests/mcproto/types/test_uuid.py similarity index 95% rename from tests/mcproto/types/v757/test_uuid.py rename to tests/mcproto/types/test_uuid.py index 4db80dd5..5a7de11d 100644 --- a/tests/mcproto/types/v757/test_uuid.py +++ b/tests/mcproto/types/test_uuid.py @@ -3,7 +3,7 @@ import pytest from mcproto.buffer import Buffer -from mcproto.types.v757.uuid import UUID +from mcproto.types.uuid import UUID @pytest.mark.parametrize( diff --git a/tests/mcproto/types/v757/__init__.py b/tests/mcproto/types/v757/__init__.py deleted file mode 100644 index 9d48db4f..00000000 --- a/tests/mcproto/types/v757/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations