[0-9]+(?:\.[0-9]+)*) # release segment
(?P # pre-release
[-_\.]?
(?P(a|b|c|rc|alpha|beta|pre|preview))
[-_\.]?
(?P[0-9]+)?
)?
(?P # post release
(?:-(?P[0-9]+))
|
(?:
[-_\.]?
(?Ppost|rev|r)
[-_\.]?
(?P[0-9]+)?
)
)?
(?P # dev release
[-_\.]?
(?Pdev)
[-_\.]?
(?P[0-9]+)?
)?
)
(?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))? # local version
\s*$""",
re.VERBOSE | re.IGNORECASE,
)
# Intentionally restricted to X.Y.Z, unlike PEP 440 release segments.
# Used only for user-supplied removal_version values parsing.
REMOVAL_VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)")
DEPRECATED_DIRECTIVE_RE = re.compile(r"^\s*\.\.\s+deprecated::\s*", flags=re.MULTILINE)
P = ParamSpec("P")
R = TypeVar("R")
@functools.lru_cache(maxsize=1)
def _get_project_version() -> tuple[int, int, int]:
"""Return the installed project version normalized to a 3-part release tuple.
The project version is obtained from :mod:`importlib.metadata` and parsed using the official PEP 440
version parsing regular expression.
All non-release components of the version (pre-releases, post-releases, development releases, and local
version identifiers) are intentionally ignored. The release version segment of the version is then
normalized to 3 components, padding with zeros if the actual version has less components, or truncating
if it has more. Any performed normalizing will emit a :exc:`RuntimeWarning`.
If the project version cannot be determined or parsed, ``(0, 0, 0)`` is returned and a runtime warning
is emitted.
"""
try:
_project_version = importlib.metadata.version(LIB_NAME)
except importlib.metadata.PackageNotFoundError:
# v0.0.0 will never mark things as already deprecated (removal_version will always be newer)
warnings.warn(f"Failed to get {LIB_NAME} project version, assuming v0.0.0", category=RuntimeWarning, stacklevel=1)
return (0, 0, 0)
m = VERSION_PATTERN_FULL.fullmatch(_project_version)
if m is None:
# This should never happen
warnings.warn(
f"Failed to parse {LIB_NAME} project version ({_project_version}), assuming v0.0.0",
category=RuntimeWarning,
stacklevel=1,
)
return (0, 0, 0)
if m["epoch"] is not None:
# we're not using epoch, and we don't expect to start doing so. If we do, the rest of this
# implementation would likely need to be changed anyways. Generally, this should never happen.
warnings.warn(f"Failed to parse {LIB_NAME} project version, assuming v0.0.0", category=RuntimeWarning, stacklevel=1)
return (0, 0, 0)
release = m["release"]
nums = [int(p) for p in release.split(".")]
if len(nums) < 3:
warnings.warn(
f"{LIB_NAME} version '{release}' has less than 3 release components; remaining components will become zeroes",
category=RuntimeWarning,
stacklevel=2,
)
nums.extend([0] * (3 - len(nums)))
elif len(nums) > 3:
warnings.warn(
f"{LIB_NAME} version '{release}' has more than 3 release components; extra components are ignored",
category=RuntimeWarning,
stacklevel=2,
)
nums = nums[:3]
return nums[0], nums[1], nums[2]
def deprecation_warn(
*,
obj_name: str,
removal_version: str | tuple[int, int, int],
replacement: str | None = None,
extra_msg: str | None = None,
stack_level: int = 2,
) -> None:
"""Produce an appropriate deprecation warning given the parameters.
If the currently installed project version is already past the specified deprecation version,
a :exc:`DeprecationWarning` will be raised as a full exception. Otherwise it will just get
emitted as a warning.
The deprecation message used will be constructed dynamically based on the input parameters.
:param obj_name: Name of the object that got deprecated (such as ``my_function``).
:param removal_version: Version at which this object should be considered as deprecated and should no longer be used.
:param replacement: A new alternative to this (now deprecated) object.
:param extra_msg: Additional message included in the deprecation warning/exception at the end.
:param stack_level: Stack level at which the warning is emitted.
.. note:
If the project version contains any additional qualifiers (e.g. pre-release, post-release, dev/local versions),
they will be ignored and the project version will be treated a simple stable (major, minor, micro) version.
"""
if isinstance(removal_version, str):
if m := REMOVAL_VERSION_RE.fullmatch(removal_version):
removal_version = (int(m[1]), int(m[2]), int(m[3]))
else:
raise ValueError(f"removal_version must follow regex pattern of: {REMOVAL_VERSION_RE.pattern}")
project_version = _get_project_version()
already_deprecated = project_version >= removal_version
msg = f"{obj_name}"
removal_version_str = ".".join(str(num) for num in removal_version)
if already_deprecated:
msg += f" is passed its removal version ({removal_version_str})"
else:
msg += f" is deprecated and scheduled for removal in {removal_version_str}"
if replacement is not None:
msg += f", use {replacement} instead"
msg += "."
if extra_msg is not None:
msg += f" ({extra_msg})"
if already_deprecated:
raise DeprecationWarning(msg)
warnings.warn(msg, category=DeprecationWarning, stacklevel=stack_level)
class DecoratorFunction(Protocol):
def __call__(self, /, func: Callable[P, R]) -> Callable[P, R]: ...
def deprecated(
*,
removal_version: str | tuple[int, int, int],
display_name: str | None = None,
replacement: str | None = None,
extra_msg: str | None = None,
no_docstring_check: bool = False,
) -> DecoratorFunction:
"""Mark an object as deprecated.
Decorator version of :func:`deprecation_warn` function.
If the currently installed project version is already past the specified deprecation version,
a :exc:`DeprecationWarning` will be raised as a full exception. Otherwise it will just get
emitted as a warning.
The deprecation message used will be constructed based on the input parameters.
:param display_name:
Name of the object that got deprecated (such as `my_function`).
By default, the object name is obtained automatically from ``__qualname__`` (falling back
to ``__name__``) of the decorated object. Setting this explicitly will override this obtained
name and the `display_name` will be used instead.
:param removal_version: Version at which this object should be considered as deprecated and should no longer be used.
:param replacement: A new alternative to this (now deprecated) object.
:param extra_msg: Additional message included in the deprecation warning/exception at the end.
:param no_docstring_check:
Disable a runtime check for the docstring of the decorated object containing ``.. deprecated::``.
.. note:
If the project version contains any additional qualifiers (e.g. pre-release, post-release, dev/local versions),
they will be ignored and the project version will be treated a simple stable (major, minor, micro) version.
"""
def inner(func: Callable[P, R]) -> Callable[P, R]:
obj_name = getattr(func, "__qualname__", func.__name__) if display_name is None else display_name
if not no_docstring_check:
obj_doc = func.__doc__ or ""
if DEPRECATED_DIRECTIVE_RE.search(obj_doc) is None:
raise ValueError("Deprecated object does not contain '.. deprecated::' sphinx directive in its docstring")
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
deprecation_warn(
obj_name=obj_name,
removal_version=removal_version,
replacement=replacement,
extra_msg=extra_msg,
stack_level=3,
)
return func(*args, **kwargs)
return wrapper
return inner
================================================
FILE: mcstatus/_utils/general.py
================================================
from typing import TypeVar
__all__ = ["or_none"]
T = TypeVar("T")
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
================================================
FILE: mcstatus/_utils/retry.py
================================================
from __future__ import annotations
import inspect
from functools import wraps
from typing import ParamSpec, TYPE_CHECKING, TypeVar, cast
if TYPE_CHECKING:
from collections.abc import Callable
__all__ = ["retry"]
T = TypeVar("T")
R = TypeVar("R")
P = ParamSpec("P")
P2 = ParamSpec("P2")
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.
""" # noqa: D401 # imperative mood
def decorate(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
async def async_wrapper(
*args: P.args,
tries: int = tries, # pyright: ignore[reportGeneralTypeIssues] # No support for adding kw-only args
**kwargs: P.kwargs,
) -> R:
last_exc: BaseException
for _ in range(tries):
try:
return await func(*args, **kwargs) # pyright: ignore[reportGeneralTypeIssues] # We know func is awaitable here
except exceptions as exc: # noqa: PERF203 # try-except within a loop
last_exc = exc
# This won't actually be unbound
raise last_exc # pyright: ignore[reportGeneralTypeIssues,reportPossiblyUnboundVariable]
@wraps(func)
def sync_wrapper(
*args: P.args,
tries: int = tries, # pyright: ignore[reportGeneralTypeIssues] # 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: # noqa: PERF203 # try-except within a loop
last_exc = exc
# This won't actually be unbound
raise last_exc # pyright: ignore[reportGeneralTypeIssues,reportPossiblyUnboundVariable]
# 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
================================================
FILE: mcstatus/motd/__init__.py
================================================
from __future__ import annotations
import re
import typing as t
from dataclasses import dataclass
from mcstatus.motd._simplifies import get_unused_elements, squash_nearby_strings
from mcstatus.motd._transformers import AnsiTransformer, HtmlTransformer, MinecraftTransformer, PlainTransformer
from mcstatus.motd.components import Formatting, MinecraftColor, ParsedMotdComponent, TranslationTag, WebColor
if t.TYPE_CHECKING:
from typing_extensions import Self
from mcstatus.responses._raw import RawJavaResponseMotd, RawJavaResponseMotdWhenDict
__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, returning back the received server response unmodified."""
bedrock: bool = False
"""Is the server Bedrock Edition?"""
@classmethod
def parse(
cls,
raw: RawJavaResponseMotd, # pyright: ignore[reportRedeclaration] # 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 # pyright: ignore[reportAttributeAccessIssue] # 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 as e:
raise ValueError(f"Unable to parse color: {color!r}, report this!") from e
def simplify(self) -> Self:
"""Create new MOTD without unused elements.
After parsing, the MOTD may contain some unused elements, like empty strings, or formatting/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.
Example:
``&0Hello &oWorld`` turns into ``Hello World``.
"""
return PlainTransformer().transform(self.parsed)
def to_minecraft(self) -> str:
"""Transform MOTD to the Minecraft representation.
.. note:: This will always use ``§``, even if in original MOTD used ``&``.
Example:
.. code-block:: python
>>> Motd.parse("&0Hello &oWorld")
"§0Hello §oWorld"
"""
return MinecraftTransformer().transform(self.parsed)
def to_html(self) -> str:
"""Transform MOTD to the HTML format.
The result is always wrapped in a ```` tag, if you need to remove it,
just do ``result.removeprefix("
").removesuffix("
")``.
.. note::
You should implement the "obfuscated" CSS class yourself using this snippet:
.. code-block:: javascript
const obfuscatedCharacters =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789`~!@#$%^&*()-_=+[]\\"';:<>,./?";
const obfuscatedElems = document.querySelectorAll(".obfuscated");
if (obfuscatedElems !== undefined) {
const render = () => {
obfuscatedElems.forEach((elem) => {
let value = "";
for (let i = 0, l = elem.innerText.length; i < l; i++) {
value += obfuscatedCharacters.charAt(
Math.floor(Math.random() * obfuscatedCharacters.length),
);
}
elem.innerText = value;
});
setTimeout(render, 50);
};
render();
}
Also do note that this formatting does not make sense with
non-monospace fonts.
Example:
``&6Hello&o from &rAnother &kWorld`` turns into
.. code-block:: html
Hello from
Another World
""" # noqa: D301 # Use `r"""` if any backslashes in a docstring
return HtmlTransformer(bedrock=self.bedrock).transform(self.parsed)
def to_ansi(self) -> str:
"""Transform MOTD to the ANSI 24-bit format.
ANSI is mostly used for printing colored text in the terminal.
"Obfuscated" formatting (``&k``) is shown as a blinking one.
.. seealso:: https://en.wikipedia.org/wiki/ANSI_escape_code.
"""
return AnsiTransformer(bedrock=self.bedrock).transform(self.parsed)
================================================
FILE: mcstatus/motd/_simplifies.py
================================================
from __future__ import annotations
import typing as t
from mcstatus.motd.components import Formatting, MinecraftColor, ParsedMotdComponent, WebColor
if t.TYPE_CHECKING:
from collections.abc import Sequence
__all__ = [
"get_double_colors",
"get_double_items",
"get_empty_text",
"get_end_non_text",
"get_formatting_before_color",
"get_meaningless_resets_and_colors",
"get_unused_elements",
"squash_nearby_strings",
]
_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
================================================
FILE: mcstatus/motd/_transformers.py
================================================
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
__all__ = [
"AnsiTransformer",
"HtmlTransformer",
"MinecraftTransformer",
"PlainTransformer",
]
if t.TYPE_CHECKING:
from collections.abc import Callable, Sequence
_HOOK_RETURN_TYPE = t.TypeVar("_HOOK_RETURN_TYPE")
_END_RESULT_TYPE = t.TypeVar("_END_RESULT_TYPE")
# MinecraftColor: (foreground, background) # noqa: ERA001 # commented-out code
_SHARED_MINECRAFT_COLOR_TO_RGB = {
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)),
}
_MINECRAFT_COLOR_TO_RGB_JAVA = _SHARED_MINECRAFT_COLOR_TO_RGB.copy()
_MINECRAFT_COLOR_TO_RGB_JAVA[MinecraftColor.GRAY] = ((170, 170, 170), (42, 42, 42))
_MINECRAFT_COLOR_TO_RGB_BEDROCK = _SHARED_MINECRAFT_COLOR_TO_RGB.copy()
_MINECRAFT_COLOR_TO_RGB_BEDROCK.update(
{
MinecraftColor.GRAY: ((198, 198, 198), (49, 49, 49)),
MinecraftColor.MINECOIN_GOLD: ((221, 214, 5), (55, 53, 1)),
MinecraftColor.MATERIAL_QUARTZ: ((227, 212, 209), (56, 53, 52)),
MinecraftColor.MATERIAL_IRON: ((206, 202, 202), (51, 50, 50)),
MinecraftColor.MATERIAL_NETHERITE: ((68, 58, 59), (17, 14, 14)),
MinecraftColor.MATERIAL_REDSTONE: ((151, 22, 7), (37, 5, 1)),
MinecraftColor.MATERIAL_COPPER: ((180, 104, 77), (45, 26, 19)),
MinecraftColor.MATERIAL_GOLD: ((222, 177, 45), (55, 44, 11)),
MinecraftColor.MATERIAL_EMERALD: ((17, 159, 54), (4, 40, 13)),
MinecraftColor.MATERIAL_DIAMOND: ((44, 186, 168), (11, 46, 42)),
MinecraftColor.MATERIAL_LAPIS: ((33, 73, 123), (8, 18, 30)),
MinecraftColor.MATERIAL_AMETHYST: ((154, 92, 198), (38, 23, 49)),
MinecraftColor.MATERIAL_RESIN: ((235, 114, 20), (59, 29, 5)),
}
)
class _BaseTransformer(abc.ABC, t.Generic[_HOOK_RETURN_TYPE, _END_RESULT_TYPE]):
"""Base MOTD transformer class.
Transformers are responsible for providing a way to generate an alternative
representation of MOTD, for example, as HTML.
The methods ``_handle_*`` handle each
:type:`~mcstatus.motd.components.ParsedMotdComponent` individually.
"""
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):
_FORMATTING_TO_HTML_TAGS: t.ClassVar = {
Formatting.BOLD: "b",
Formatting.STRIKETHROUGH: "s",
Formatting.ITALIC: "i",
Formatting.UNDERLINED: "u",
}
# TODO: When dropping v13 support, make sure to drop the default value for the bedrock arg
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 "" + super()._format_output(results) + "".join(self.on_reset) + "
"
def _handle_str(self, element: str, /) -> str:
return element.replace("\n", "
")
def _handle_minecraft_color(self, element: MinecraftColor, /) -> str:
color_map = _MINECRAFT_COLOR_TO_RGB_BEDROCK if self.bedrock else _MINECRAFT_COLOR_TO_RGB_JAVA
fg_color, bg_color = color_map[element]
self.on_reset.append("")
return f""
def _handle_web_color(self, element: WebColor, /) -> str:
self.on_reset.append("")
return f""
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("")
return ""
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: t.ClassVar = {
Formatting.BOLD: "1",
Formatting.STRIKETHROUGH: "9",
Formatting.ITALIC: "3",
Formatting.UNDERLINED: "4",
Formatting.OBFUSCATED: "5",
}
_MINECRAFT_COLOR_TO_RGB_JAVA: t.ClassVar = {
key: foreground for key, (foreground, _background) in _MINECRAFT_COLOR_TO_RGB_JAVA.items()
}
_MINECRAFT_COLOR_TO_RGB_BEDROCK: t.ClassVar = {
key: foreground for key, (foreground, _background) in _MINECRAFT_COLOR_TO_RGB_BEDROCK.items()
}
# TODO: When dropping v13 support, make sure to drop the default value for the bedrock arg
def __init__(self, *, bedrock: bool = True) -> None:
self.bedrock = bedrock
def ansi_color(self, color: tuple[int, int, int] | MinecraftColor) -> str:
"""Transform RGB color to ANSI color code."""
if isinstance(color, MinecraftColor):
color_to_rgb = self._MINECRAFT_COLOR_TO_RGB_BEDROCK if self.bedrock else self._MINECRAFT_COLOR_TO_RGB_JAVA
color = color_to_rgb[color]
return "\033[38;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"
================================================
FILE: mcstatus/motd/components.py
================================================
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
__all__ = [
"Formatting",
"MinecraftColor",
"ParsedMotdComponent",
"TranslationTag",
"WebColor",
]
# NOTE: keep in sync with the definition in docs (`docs/api/motd_parsing.rst`)
# the autodocs plugin does not support type aliases yet, so those have to be
# defined manually in docs
ParsedMotdComponent: t.TypeAlias = "Formatting | MinecraftColor | WebColor | TranslationTag | str"
class Formatting(Enum):
"""Enum for Formatting codes.
See `Minecraft wiki `__
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 `_.
"""
BOLD = "l"
ITALIC = "o"
UNDERLINED = "n"
STRIKETHROUGH = "m"
OBFUSCATED = "k"
RESET = "r"
class MinecraftColor(Enum):
"""Enum for Color codes.
See `Minecraft wiki `_
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"
MATERIAL_QUARTZ = "h"
MATERIAL_IRON = "i"
MATERIAL_NETHERITE = "j"
MATERIAL_REDSTONE = "m"
MATERIAL_COPPER = "n"
MATERIAL_GOLD = "p"
MATERIAL_EMERALD = "q"
MATERIAL_DIAMOND = "s"
MATERIAL_LAPIS = "t"
MATERIAL_AMETHYST = "u"
MATERIAL_RESIN = "v"
@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: # noqa: A002 # shadowing a hex builtin
"""Construct web color using hex color string.
:raises ValueError: Invalid hex color string.
:returns: New :class:`WebColor` instance.
"""
hex = hex.lstrip("#") # noqa: A001 # shadowing a hex builtin
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) # noqa: A001 # shadowing a hex builtin
try:
rgb = t.cast("tuple[int, int, int]", tuple(int(hex[i : i + 2], 16) for i in (0, 2, 4)))
except ValueError as e:
raise ValueError(f"Failed to parse given hex color: {'#' + hex!r}") from e
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) # noqa: A001 # shadowing a hex builtin
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 ` attribute.
.. seealso:: `Minecraft's wiki. `__
"""
id: str
================================================
FILE: mcstatus/py.typed
================================================
================================================
FILE: mcstatus/responses/__init__.py
================================================
from mcstatus.responses.base import BaseStatusPlayers, BaseStatusResponse, BaseStatusVersion
from mcstatus.responses.bedrock import BedrockStatusPlayers, BedrockStatusResponse, BedrockStatusVersion
from mcstatus.responses.forge import ForgeData, ForgeDataChannel, ForgeDataMod
from mcstatus.responses.java import JavaStatusPlayer, JavaStatusPlayers, JavaStatusResponse, JavaStatusVersion
from mcstatus.responses.legacy import LegacyStatusPlayers, LegacyStatusResponse, LegacyStatusVersion
from mcstatus.responses.query import QueryPlayers, QueryResponse, QuerySoftware
__all__ = [
"BaseStatusPlayers",
"BaseStatusResponse",
"BaseStatusVersion",
"BedrockStatusPlayers",
"BedrockStatusResponse",
"BedrockStatusVersion",
"ForgeData",
"ForgeDataChannel",
"ForgeDataMod",
"JavaStatusPlayer",
"JavaStatusPlayers",
"JavaStatusResponse",
"JavaStatusVersion",
"LegacyStatusPlayers",
"LegacyStatusResponse",
"LegacyStatusVersion",
"QueryPlayers",
"QueryResponse",
"QuerySoftware",
]
================================================
FILE: mcstatus/responses/_raw.py
================================================
from __future__ import annotations
from typing import Literal, TYPE_CHECKING, TypeAlias, TypedDict
if TYPE_CHECKING:
from typing_extensions import NotRequired
__all__ = [
"RawForgeData",
"RawForgeDataChannel",
"RawForgeDataMod",
"RawJavaResponse",
"RawJavaResponseMotd",
"RawJavaResponseMotdWhenDict",
"RawJavaResponsePlayer",
"RawJavaResponsePlayers",
"RawJavaResponseVersion",
"RawQueryResponse",
]
RawJavaResponseMotd: TypeAlias = "RawJavaResponseMotdWhenDict | list[RawJavaResponseMotdWhenDict | str] | str"
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
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
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
================================================
FILE: mcstatus/responses/base.py
================================================
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
from typing_extensions import Self
from mcstatus.motd import Motd
__all__ = [
"BaseStatusPlayers",
"BaseStatusResponse",
"BaseStatusVersion",
]
@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: Any, **kwargs: Any) -> 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 ` 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 ` and not
:class:`dict`).
"""
as_dict = asdict(self)
as_dict["motd"] = self.motd.simplify().to_minecraft()
return as_dict
@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 BaseStatusVersion(ABC):
"""A class for storing version information."""
name: str
"""The version name, like ``1.19.3``.
See `Minecraft wiki `__
for complete list.
"""
protocol: int
"""The protocol version, like ``761``.
See `Minecraft wiki `__.
"""
================================================
FILE: mcstatus/responses/bedrock.py
================================================
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, TYPE_CHECKING
from mcstatus._utils import deprecated
from mcstatus.motd import Motd
from mcstatus.responses.base import BaseStatusPlayers, BaseStatusResponse, BaseStatusVersion
if TYPE_CHECKING:
from typing_extensions import Self
__all__ = [
"BedrockStatusPlayers",
"BedrockStatusResponse",
"BedrockStatusVersion",
]
@dataclass(frozen=True)
class BedrockStatusResponse(BaseStatusResponse):
"""The response object for :meth:`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 BedrockStatusPlayers(BaseStatusPlayers):
"""Class for storing information about players on the server."""
@dataclass(frozen=True)
class BedrockStatusVersion(BaseStatusVersion):
"""A class for storing version information."""
name: str
"""The version name, like ``1.19.60``.
See `Minecraft wiki `__
for complete list.
"""
brand: str
"""``MCPE`` or ``MCEE`` for Education Edition."""
@property
@deprecated(replacement="name", removal_version="13.0.0")
def version(self) -> str:
"""
.. deprecated:: 12.0.0
Will be removed in 13.0.0, use :attr:`.name` instead.
""" # noqa: D205, D212 # no summary line
return self.name
================================================
FILE: mcstatus/responses/forge.py
================================================
"""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
if TYPE_CHECKING:
from typing_extensions import Self
from mcstatus.responses._raw import RawForgeData, RawForgeDataChannel, RawForgeDataMod
__all__ = [
"ForgeData",
"ForgeDataChannel",
"ForgeDataMod",
]
_VERSION_FLAG_IGNORE_SERVER_ONLY: Final = 0b1
_IGNORE_SERVER_ONLY: Final = ""
@dataclass(frozen=True)
class ForgeDataChannel:
"""A single Forge data channel."""
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:
"""A single Forge mod."""
name: str
"""A mod name."""
marker: str
"""A mod marker. Usually a version."""
@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 = [ForgeDataChannel.decode(buffer, mod_id) for _ in range(channel_count)]
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 OSError(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:
"""Class for storing information about Forge mods."""
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:
"""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()
channels.extend(ForgeDataChannel.decode(buffer) for _ in range(non_mod_channel_count))
except OSError:
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,
)
================================================
FILE: mcstatus/responses/java.py
================================================
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from mcstatus.motd import Motd
from mcstatus.responses.base import BaseStatusPlayers, BaseStatusResponse, BaseStatusVersion
from mcstatus.responses.forge import ForgeData
if TYPE_CHECKING:
from typing_extensions import Self
from mcstatus.responses._raw import RawJavaResponse, RawJavaResponsePlayer, RawJavaResponsePlayers, RawJavaResponseVersion
__all__ = [
"JavaStatusPlayer",
"JavaStatusPlayers",
"JavaStatusResponse",
"JavaStatusVersion",
]
@dataclass(frozen=True)
class JavaStatusResponse(BaseStatusResponse):
"""The response object for :meth:`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 `_,
`22w17a changelog, where this was added `_.
.. versionadded:: 11.1.0
"""
icon: str | None
"""The icon of the server. In `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 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: list[JavaStatusPlayer] | None = None
if (sample_raw := raw.get("sample")) is not None:
sample = [JavaStatusPlayer.build(player) for player in sample_raw]
return cls(
online=raw["online"],
max=raw["max"],
sample=sample,
)
@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 `_ 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 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"])
================================================
FILE: mcstatus/responses/legacy.py
================================================
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from mcstatus.motd import Motd
from mcstatus.responses.base import BaseStatusPlayers, BaseStatusResponse, BaseStatusVersion
if TYPE_CHECKING:
from typing_extensions import Self
__all__ = [
"LegacyStatusPlayers",
"LegacyStatusResponse",
"LegacyStatusVersion",
]
@dataclass(frozen=True)
class LegacyStatusResponse(BaseStatusResponse):
"""The response object for :meth:`LegacyServerStatus.status() `."""
players: LegacyStatusPlayers
version: LegacyStatusVersion
"""The version information, only populates for servers >=12w42b (1.4 onwards)."""
@classmethod
def build(cls, decoded_data: list[str], 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:`LegacyStatusResponse` object.
"""
return cls(
players=LegacyStatusPlayers(
online=int(decoded_data[3]),
max=int(decoded_data[4]),
),
version=LegacyStatusVersion(
name=decoded_data[1],
protocol=int(decoded_data[0]),
),
motd=Motd.parse(decoded_data[2]),
latency=latency,
)
@dataclass(frozen=True)
class LegacyStatusPlayers(BaseStatusPlayers):
"""Class for storing information about players on the server."""
@dataclass(frozen=True)
class LegacyStatusVersion(BaseStatusVersion):
"""A class for storing version information."""
name: str
"""The version name, like ``1.19.3``.
See `Minecraft wiki `__
for complete list.
Will be ``<1.4`` for older releases, as those did not send version
information.
"""
protocol: int
"""The protocol version, like ``761``.
See `Minecraft wiki `__.
``-1`` means 1.3 and lower, before 1.4 servers did not send information
about its version.
"""
================================================
FILE: mcstatus/responses/query.py
================================================
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any, TYPE_CHECKING
from mcstatus._utils import deprecated
from mcstatus.motd import Motd
if TYPE_CHECKING:
from typing_extensions import Self
from mcstatus.responses._raw import RawQueryResponse
__all__ = [
"QueryPlayers",
"QueryResponse",
"QuerySoftware",
]
@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"],
)
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 ` 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 ` 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", removal_version="13.0.0")
def map(self) -> str | None:
"""
.. deprecated:: 12.0.0
Will be removed in 13.0.0, use :attr:`.map_name` instead.
""" # noqa: D205, D212 # no summary line
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", removal_version="13.0.0")
def names(self) -> list[str]:
"""
.. deprecated:: 12.0.0
Will be removed in 13.0.0, use :attr:`.list` instead.
""" # noqa: D205, D212 # no summary line
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
================================================
FILE: mcstatus/server.py
================================================
from __future__ import annotations
from abc import ABC
from typing import TYPE_CHECKING
from mcstatus._net.address import Address, async_minecraft_srv_address_lookup, minecraft_srv_address_lookup
from mcstatus._protocol.bedrock_client import BedrockClient
from mcstatus._protocol.connection import (
TCPAsyncSocketConnection,
TCPSocketConnection,
UDPAsyncSocketConnection,
UDPSocketConnection,
)
from mcstatus._protocol.java_client import AsyncJavaClient, JavaClient
from mcstatus._protocol.legacy_client import AsyncLegacyClient, LegacyClient
from mcstatus._protocol.query_client import AsyncQueryClient, QueryClient
from mcstatus._utils import retry
if TYPE_CHECKING:
from typing_extensions import Self
from mcstatus.responses import BedrockStatusResponse, JavaStatusResponse, LegacyStatusResponse, QueryResponse
__all__ = ["BedrockServer", "JavaServer", "LegacyServer", "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) -> 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.
""" # noqa: D205, D212 # no summary line
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 BaseJavaServer(MCServer):
"""Base class for a Minecraft Java Edition server.
.. versionadded:: 12.1.0
"""
DEFAULT_PORT = 25565
@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)
class JavaServer(BaseJavaServer):
"""Base class for a 1.7+ Minecraft Java Edition server."""
def __init__(self, host: str, port: int | None = None, timeout: float = 3, query_port: int | None = 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.
""" # noqa: D205, D212 # no summary line
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
def ping(self, *, tries: int = 3, version: int = 47, ping_token: int | None = None) -> float:
"""Check 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 tries: The number of times to retry if an error is encountered.
:param version: Version of the client, see https://minecraft.wiki/w/Protocol_version#List_of_protocol_versions.
:param ping_token: Token of the packet, default is a random number.
:return: The latency between the Minecraft Server and you.
"""
with TCPSocketConnection(self.address, self.timeout) as connection:
return self._retry_ping(connection, tries=tries, version=version, ping_token=ping_token)
@retry(tries=3)
def _retry_ping(
self,
connection: TCPSocketConnection,
*,
tries: int = 3, # noqa: ARG002 # unused argument
version: int,
ping_token: int | None,
) -> float:
java_client = JavaClient(
connection,
address=self.address,
version=version,
ping_token=ping_token, # pyright: ignore[reportArgumentType] # None is not assignable to int
)
java_client.handshake()
return java_client.test_ping()
async def async_ping(self, *, tries: int = 3, version: int = 47, ping_token: int | None = None) -> float:
"""Asynchronously check 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 tries: The number of times to retry if an error is encountered.
:param version: Version of the client, see https://minecraft.wiki/w/Protocol_version#List_of_protocol_versions.
:param ping_token: Token of the packet, default is a random number.
: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, tries=tries, version=version, ping_token=ping_token)
@retry(tries=3)
async def _retry_async_ping(
self,
connection: TCPAsyncSocketConnection,
*,
tries: int = 3, # noqa: ARG002 # unused argument
version: int,
ping_token: int | None,
) -> float:
java_client = AsyncJavaClient(
connection,
address=self.address,
version=version,
ping_token=ping_token, # pyright: ignore[reportArgumentType] # None is not assignable to int
)
java_client.handshake()
ping = await java_client.test_ping()
return ping
def status(self, *, tries: int = 3, version: int = 47, ping_token: int | None = None) -> JavaStatusResponse:
"""Check the status of a Minecraft Java Edition server via the status protocol.
:param tries: The number of times to retry if an error is encountered.
:param version: Version of the client, see https://minecraft.wiki/w/Protocol_version#List_of_protocol_versions.
:param ping_token: Token of the packet, default is a random number.
:return: Status information in a :class:`~mcstatus.responses.JavaStatusResponse` instance.
"""
with TCPSocketConnection(self.address, self.timeout) as connection:
return self._retry_status(connection, tries=tries, version=version, ping_token=ping_token)
@retry(tries=3)
def _retry_status(
self,
connection: TCPSocketConnection,
*,
tries: int = 3, # noqa: ARG002 # unused argument
version: int,
ping_token: int | None,
) -> JavaStatusResponse:
java_client = JavaClient(
connection,
address=self.address,
version=version,
ping_token=ping_token, # pyright: ignore[reportArgumentType] # None is not assignable to int
)
java_client.handshake()
result = java_client.read_status()
return result
async def async_status(self, *, tries: int = 3, version: int = 47, ping_token: int | None = None) -> JavaStatusResponse:
"""Asynchronously check the status of a Minecraft Java Edition server via the status protocol.
:param tries: The number of times to retry if an error is encountered.
:param version: Version of the client, see https://minecraft.wiki/w/Protocol_version#List_of_protocol_versions.
:param ping_token: Token of the packet, default is a random number.
: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, tries=tries, version=version, ping_token=ping_token)
@retry(tries=3)
async def _retry_async_status(
self,
connection: TCPAsyncSocketConnection,
*,
tries: int = 3, # noqa: ARG002 # unused argument
version: int,
ping_token: int | None,
) -> JavaStatusResponse:
java_client = AsyncJavaClient(
connection,
address=self.address,
version=version,
ping_token=ping_token, # pyright: ignore[reportArgumentType] # None is not assignable to int
)
java_client.handshake()
result = await java_client.read_status()
return result
def query(self, *, tries: int = 3) -> QueryResponse:
"""Check 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.responses.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, tries: int = 3) -> QueryResponse: # noqa: ARG002 # unused argument
with UDPSocketConnection(addr, self.timeout) as connection:
query_client = QueryClient(connection)
query_client.handshake()
return query_client.read_query()
async def async_query(self, *, tries: int = 3) -> QueryResponse:
"""Asynchronously check 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.responses.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, tries: int = 3) -> QueryResponse: # noqa: ARG002 # unused argument
async with UDPAsyncSocketConnection(address, self.timeout) as connection:
query_client = AsyncQueryClient(connection)
await query_client.handshake()
return await query_client.read_query()
class LegacyServer(BaseJavaServer):
"""Base class for a pre-1.7 Minecraft Java Edition server.
.. versionadded:: 12.1.0
"""
@retry(tries=3)
def status(self, *, tries: int = 3) -> LegacyStatusResponse: # noqa: ARG002 # unused argument
"""Check the status of a pre-1.7 Minecraft Java Edition server.
:param tries: The number of times to retry if an error is encountered.
:return: Status information in a :class:`~mcstatus.responses.LegacyStatusResponse` instance.
"""
with TCPSocketConnection(self.address, self.timeout) as connection:
return LegacyClient(connection).read_status()
@retry(tries=3)
async def async_status(self, *, tries: int = 3) -> LegacyStatusResponse: # noqa: ARG002 # unused argument
"""Asynchronously check the status of a pre-1.7 Minecraft Java Edition server.
:param tries: The number of times to retry if an error is encountered.
:return: Status information in a :class:`~mcstatus.responses.LegacyStatusResponse` instance.
"""
async with TCPAsyncSocketConnection(self.address, self.timeout) as connection:
return await AsyncLegacyClient(connection).read_status()
class BedrockServer(MCServer):
"""Base class for a Minecraft Bedrock Edition server."""
DEFAULT_PORT = 19132
@retry(tries=3)
def status(self, *, tries: int = 3) -> BedrockStatusResponse: # noqa: ARG002 # unused argument
"""Check the status of a Minecraft Bedrock Edition server.
:param tries: The number of times to retry if an error is encountered.
:return: Status information in a :class:`~mcstatus.responses.BedrockStatusResponse` instance.
"""
return BedrockClient(self.address, self.timeout).read_status()
@retry(tries=3)
async def async_status(self, *, tries: int = 3) -> BedrockStatusResponse: # noqa: ARG002 # unused argument
"""Asynchronously check the status of a Minecraft Bedrock Edition server.
:param tries: The number of times to retry if an error is encountered.
:return: Status information in a :class:`~mcstatus.responses.BedrockStatusResponse` instance.
"""
return await BedrockClient(self.address, self.timeout).read_status_async()
================================================
FILE: pyproject.toml
================================================
[project]
name = "mcstatus"
dynamic = ["version"]
license = "Apache-2.0"
description = "A library to query Minecraft Servers for their status and capabilities."
readme = "README.md"
authors = [
{ name = "Nathan Adams", email = "dinnerbone@dinnerbone.com" },
{ name = "ItsDrike", email = "itsdrike@protonmail.com" },
{ name = "PerchunPak", email = "perchunpak@gmail.com" },
]
maintainers = [
{ name = "Kevin Tindall", email = "kevinkjt2000@gmail.com" },
{ name = "ItsDrike", email = "itsdrike@protonmail.com" },
{ name = "PerchunPak", email = "perchunpak@gmail.com" },
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Games/Entertainment",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: System :: Monitoring",
"Typing :: Typed",
]
keywords = ["minecraft", "protocol"]
requires-python = ">=3.10"
dependencies = ["asyncio-dgram>=2.1.2", "dnspython>=2.4.2"]
[project.urls]
Documentation = "https://mcstatus.readthedocs.io"
"Source code" = "https://github.com/py-mine/mcstatus"
[tool.uv]
default-groups = ["dev", "lint", "test", "docs"]
[dependency-groups]
dev = ["poethepoet~=0.44.0"]
lint = [
"pre-commit~=4.5.1",
"ruff~=0.15.0",
"pyright==1.1.408",
"typing-extensions~=4.15.0",
]
test = [
"pytest~=9.0.2",
"pytest-asyncio~=1.3.0",
"pytest-cov~=7.1.0",
"pytest-rerunfailures~=16.1",
"coverage~=7.13.1",
"typing-extensions~=4.15.0",
# the actual versions are in the `release` group
"uv-dynamic-versioning",
"hatchling",
]
docs = ["docs; python_version >= '3.12'"]
release = ["hatchling~=1.29.0", "uv-dynamic-versioning~=0.14.0"]
[tool.poe.tasks]
_lint_ruff = "ruff check ."
_lint_pyright = "pyright ."
[tool.poe.tasks.build]
cmd = "uv build"
help = "Builds the whl and tar.gz distributions"
[tool.poe.tasks.docs]
cmd = "make -C docs/ html"
help = "Generates documentation locally in html format"
[tool.poe.tasks.format]
cmd = "ruff format"
help = "Runs automatic formatting tools"
[tool.poe.tasks.lint]
sequence = ["_lint_pyright", "_lint_ruff"]
help = "Runs linter tools"
[tool.poe.tasks.pre-commit]
cmd = "pre-commit run --all-files"
help = "Executes commit hook checks on all files"
[tool.poe.tasks.release]
cmd = "uv publish"
deps = ["pre-commit", "test", "build"]
help = "Requires all the checks to pass before building and publishing"
[tool.poe.tasks.test]
cmd = "pytest"
help = "Runs the unit tests"
[tool.pytest.ini_options]
minversion = "6.0"
tmp_path_retention_policy = "failed"
addopts = "--strict-markers --doctest-modules --cov=mcstatus --cov-append --cov-branch --cov-report=term-missing -vvv --asyncio-mode=strict"
testpaths = ["tests"]
# Remove deprecation warning
asyncio_default_fixture_loop_scope = "function"
[tool.pyright]
pythonPlatform = "All"
pythonVersion = "3.10"
typeCheckingMode = "standard"
disableBytesTypePromotions = false
enableTypeIgnoreComments = false
reportUnnecessaryTypeIgnoreComment = true
[tool.ruff]
target-version = "py310"
line-length = 127
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"EM", # Very weird rules for using exceptions
"FIX", # Line contains TODO, consider resolving the issue
"COM812", # Missing trailing comma (in multiline lists/tuples/...)
"D203", # Blank line required before class docstring
"D213", # Multi-line docstring summary should start at the second line
"PLR2004", # Magic value used in comparison
"RET504", # Unnecessary assignment to a variable before `return` statement
"S101", # Use of `assert` detected
"S311", # Standard pseudo-random generators are not suitable for cryptographic purposes
"TD002", # Missing author in TODO
"TD003", # Missing issue link for this TODO
"TRY003", # Avoid specifying long messages outside the exception class
"ISC002", # Multi line implicit string concatenation
# Ruff often identifies private modules as public, because we don't prefix them with `_`
"D100", # Missing docstring in public module
"D101", # Missing docstring in public class
"D102", # Missing docstring in public method
"D103", # Missing docstring in public function
"D104", # Missing docstring in public package
"D105", # Missing docstring in magic method
"D106", # Missing docstring in public nested class
"D107", # Missing docstring in __init__
"D413", # Missing blank line after last section
]
[tool.ruff.lint.per-file-ignores]
"tests/**" = [
"ANN", # flake8-annotations
"ARG001", # Unused function argument
"ARG002", # Unused method argument
"FBT001", # Boolean-typed positional argument in function definition
"SLF001", # Private member accessed
"T201", # Usage of `print`
]
"docs/**" = [
"INP001", # Implicit namespace package
]
"docs/examples/code/**" = [
"FA", # flake8-future-annotations
"BLE001", # Do not catch blind exception: `Exception`
"T201", # Usage of `print`
"TC001", # Move application import into a type-checking block
]
[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "all"
banned-module-level-imports = ["typing_extensions"]
[tool.ruff.lint.flake8-tidy-imports.banned-api]
"mcstatus._compat".msg = "Deprecated compatibility shims should not be imported"
[tool.ruff.lint.isort]
order-by-type = false
case-sensitive = true
combine-as-imports = true
# Redundant rules with ruff-format
force-single-line = false # forces all imports to appear on their own line
force-wrap-aliases = false # Split imports with multiple members and at least one alias
lines-after-imports = -1 # The number of blank lines to place after imports
lines-between-types = 0 # Number of lines to place between "direct" and import from imports
split-on-trailing-comma = false # if last member of multiline import has a comma, don't fold it to single line
[tool.ruff.lint.flake8-annotations]
allow-star-arg-any = true
[tool.ruff.lint.pylint]
max-args = 10
[tool.ruff.lint.flake8-builtins]
ignorelist = ["id", "copyright"]
[tool.ruff.format]
line-ending = "lf"
[project.scripts]
mcstatus = "mcstatus.__main__:main"
[build-system]
requires = ["hatchling", "uv-dynamic-versioning"]
build-backend = "hatchling.build"
[tool.uv.sources]
docs = { path = "docs" }
[tool.hatch.version]
source = "uv-dynamic-versioning"
[tool.uv-dynamic-versioning]
metadata = false
fallback-version = "0.0.0"
[tool.hatch.build.targets.sdist.force-include]
"mcstatus/_compat/status_response.py" = "mcstatus/status_response.py"
"mcstatus/_compat/forge_data.py" = "mcstatus/forge_data.py"
"mcstatus/_compat/motd_transformers.py" = "mcstatus/motd/transformers.py"
================================================
FILE: tests/__init__.py
================================================
================================================
FILE: tests/helpers.py
================================================
import collections.abc as c
import importlib.metadata
from contextlib import contextmanager
from functools import wraps
from unittest.mock import patch
from mcstatus._utils.deprecation import LIB_NAME, _get_project_version
@contextmanager
def patch_project_version(version: str | None) -> c.Iterator[None]:
"""Patch the project version reported by ``importlib.metadata.version``.
This is used to simulate different project versions for testing purposes.
If ``version`` is ``None``, a :exc:`PackageNotFoundError` will be raised
when trying to get the project version.
"""
orig_version_func = importlib.metadata.version
@wraps(orig_version_func)
def patched_version_func(distribution_name: str) -> str:
if distribution_name == LIB_NAME:
if version is None:
raise importlib.metadata.PackageNotFoundError
return version
return orig_version_func(distribution_name)
_get_project_version.cache_clear()
with patch.object(importlib.metadata, "version", new=patched_version_func):
try:
yield
finally:
_get_project_version.cache_clear()
================================================
FILE: tests/motd/__init__.py
================================================
================================================
FILE: tests/motd/conftest.py
================================================
import pytest
@pytest.fixture(scope="session")
def source_java() -> dict:
"""Return ultimate dict with almost all possible aspects, which we should support.
If feature can handle all from this dict, it's fully tested.
Parser should have more tests, on additional features.
"""
return {
"extra": [
{"text": "1"},
{"color": "#b3eeff", "text": "2"},
{"obfuscated": True, "color": "black", "text": "3"},
{"bold": True, "strikethrough": True, "color": "dark_blue", "text": "4"},
{"italic": True, "color": "dark_green", "text": "5"},
{"underlined": True, "color": "dark_aqua", "text": "6"},
{"color": "dark_aqua", "text": "7"},
{"color": "dark_red", "text": "8"},
{"color": "dark_purple", "text": "9"},
{"color": "gold", "text": "10"},
{"color": "gray", "text": "11"},
{"color": "dark_gray", "text": "12"},
{"color": "blue", "text": "13"},
{"color": "green", "text": "14"},
{"color": "aqua", "text": "15"},
{"color": "red", "text": "16"},
{"color": "light_purple", "text": "17"},
{"color": "yellow", "text": "18"},
{"color": "white", "text": "19"},
{"color": "reset", "text": "20"},
{"translate": "some.random.string"},
],
"text": "top",
}
@pytest.fixture(scope="session")
def source_bedrock() -> dict:
"""Return ultimate dict with almost all possible aspects, which we should support.
If feature can handle all from this dict, it's fully tested.
Parser should have more tests, on additional features.
"""
return {
"extra": [
{"text": "1"},
{"color": "#b3eeff", "text": "2"},
{"obfuscated": True, "color": "black", "text": "3"},
{"bold": True, "strikethrough": True, "color": "dark_blue", "text": "4"},
{"italic": True, "color": "dark_green", "text": "5"},
{"underlined": True, "color": "dark_aqua", "text": "6"},
{"color": "dark_aqua", "text": "7"},
{"color": "dark_red", "text": "8"},
{"color": "dark_purple", "text": "9"},
{"color": "gold", "text": "10"},
{"color": "gray", "text": "11"},
{"color": "dark_gray", "text": "12"},
{"color": "blue", "text": "13"},
{"color": "green", "text": "14"},
{"color": "aqua", "text": "15"},
{"color": "red", "text": "16"},
{"color": "light_purple", "text": "17"},
{"color": "yellow", "text": "18"},
{"color": "white", "text": "19"},
{"color": "minecoin_gold", "text": "20"},
{"color": "material_quartz", "text": "21"},
{"color": "material_iron", "text": "22"},
{"color": "material_netherite", "text": "23"},
{"color": "material_redstone", "text": "24"},
{"color": "material_copper", "text": "25"},
{"color": "material_gold", "text": "26"},
{"color": "material_emerald", "text": "27"},
{"color": "material_diamond", "text": "28"},
{"color": "material_lapis", "text": "29"},
{"color": "material_amethyst", "text": "30"},
{"color": "material_resin", "text": "31"},
{"color": "reset", "text": "32"},
{"translate": "some.random.string"},
],
"text": "top",
}
================================================
FILE: tests/motd/test_components.py
================================================
from __future__ import annotations
import pytest
from mcstatus.motd.components import WebColor
class TestWebColor:
@pytest.mark.parametrize(
("hex_", "rgb"),
[
("#bfff00", (191, 255, 0)),
("#00ff80", (0, 255, 128)),
("#4000ff", (64, 0, 255)),
],
)
def test_hex_to_rgb_correct(self, hex_, rgb):
assert WebColor.from_hex(hex=hex_).rgb == rgb
@pytest.mark.parametrize(
("hex_", "rgb"),
[
("#bfff00", (191, 255, 0)),
("#00ff80", (0, 255, 128)),
("#4000ff", (64, 0, 255)),
],
)
def test_rgb_to_hex_correct(self, hex_, rgb):
assert WebColor.from_rgb(rgb=rgb).hex == hex_
def test_hex_in_output_has_number_sign(self):
assert WebColor.from_hex(hex="#bfff00").hex == "#bfff00"
assert WebColor.from_hex(hex="4000ff").hex == "#4000ff"
def test_fail_on_incorrect_hex(self):
with pytest.raises(ValueError, match=r"^Failed to parse given hex color: '#!!!!!!'$"):
WebColor.from_hex(hex="#!!!!!!")
@pytest.mark.parametrize("length", [0, 1, 2, 4, 5, 7, 8, 9, 10])
def test_fail_on_too_long_or_too_short_hex(self, length: int):
color = "a" * length
with pytest.raises(ValueError, match=f"^Got too long/short hex color: '#{color}'$"):
WebColor.from_hex(hex="a" * length)
def test_fail_on_incorrect_rgb(self):
with pytest.raises(ValueError, match=r"^RGB color byte out of its 8-bit range \(0-255\) for red \(value=-23\)$"):
WebColor.from_rgb(rgb=(-23, 699, 1000))
def test_3_symbols_hex(self):
assert WebColor.from_hex("a1b").hex == "#aa11bb"
================================================
FILE: tests/motd/test_motd.py
================================================
from __future__ import annotations
import pytest
from mcstatus.motd import Motd
from mcstatus.motd.components import Formatting, MinecraftColor, TranslationTag, WebColor
from mcstatus.responses._raw import RawJavaResponseMotdWhenDict
class TestMotdParse:
def test_correct_result(self, source_bedrock):
assert Motd.parse(source_bedrock) == Motd(
[
"top", Formatting.RESET,
"1", Formatting.RESET,
WebColor.from_hex(hex="#b3eeff"), "2", Formatting.RESET,
MinecraftColor.BLACK, Formatting.OBFUSCATED, "3", Formatting.RESET,
MinecraftColor.DARK_BLUE, Formatting.BOLD, Formatting.STRIKETHROUGH, "4", Formatting.RESET,
MinecraftColor.DARK_GREEN, Formatting.ITALIC, "5", Formatting.RESET,
MinecraftColor.DARK_AQUA, Formatting.UNDERLINED, "6", Formatting.RESET,
MinecraftColor.DARK_AQUA, "7", Formatting.RESET,
MinecraftColor.DARK_RED, "8", Formatting.RESET,
MinecraftColor.DARK_PURPLE, "9", Formatting.RESET,
MinecraftColor.GOLD, "10", Formatting.RESET,
MinecraftColor.GRAY, "11", Formatting.RESET,
MinecraftColor.DARK_GRAY, "12", Formatting.RESET,
MinecraftColor.BLUE, "13", Formatting.RESET,
MinecraftColor.GREEN, "14", Formatting.RESET,
MinecraftColor.AQUA, "15", Formatting.RESET,
MinecraftColor.RED, "16", Formatting.RESET,
MinecraftColor.LIGHT_PURPLE, "17", Formatting.RESET,
MinecraftColor.YELLOW, "18", Formatting.RESET,
MinecraftColor.WHITE, "19", Formatting.RESET,
MinecraftColor.MINECOIN_GOLD, "20", Formatting.RESET,
MinecraftColor.MATERIAL_QUARTZ, "21", Formatting.RESET,
MinecraftColor.MATERIAL_IRON, "22", Formatting.RESET,
MinecraftColor.MATERIAL_NETHERITE, "23", Formatting.RESET,
MinecraftColor.MATERIAL_REDSTONE, "24", Formatting.RESET,
MinecraftColor.MATERIAL_COPPER, "25", Formatting.RESET,
MinecraftColor.MATERIAL_GOLD, "26", Formatting.RESET,
MinecraftColor.MATERIAL_EMERALD, "27", Formatting.RESET,
MinecraftColor.MATERIAL_DIAMOND, "28", Formatting.RESET,
MinecraftColor.MATERIAL_LAPIS, "29", Formatting.RESET,
MinecraftColor.MATERIAL_AMETHYST, "30", Formatting.RESET,
MinecraftColor.MATERIAL_RESIN, "31", Formatting.RESET,
Formatting.RESET, "32", Formatting.RESET,
TranslationTag("some.random.string"), Formatting.RESET,
],
raw=source_bedrock,
) # fmt: skip
@pytest.mark.parametrize("bedrock", [True, False])
def test_bedrock_parameter_nothing_changes(self, bedrock: bool):
assert Motd.parse([{"color": "minecoin_gold", "text": " "}], bedrock=bedrock).parsed == [
Formatting.RESET,
MinecraftColor.MINECOIN_GOLD,
" ",
Formatting.RESET,
]
@pytest.mark.parametrize(("bedrock", "expected"), [(True, MinecraftColor.MINECOIN_GOLD), (False, "&g")])
def test_parse_as_str_ignore_minecoin_gold_on_java(self, bedrock: bool, expected):
assert Motd.parse("&g", bedrock=bedrock).parsed == [expected]
def test_parse_incorrect_color_passes(self):
"""See `https://github.com/py-mine/mcstatus/pull/335#discussion_r985084188`_."""
assert Motd.parse("&z").parsed == ["&z"]
def test_parse_uppercase_passes(self):
assert Motd.parse("&A").parsed == ["", MinecraftColor.GREEN, ""]
@pytest.mark.parametrize(
("input_", "expected"), [("", [""]), ([], [Formatting.RESET]), ({"extra": [], "text": ""}, ["", Formatting.RESET])]
)
def test_empty_input_also_empty_raw(self, input_, expected):
assert Motd.parse(input_).parsed == expected
def test_top_level_formatting_applies_to_all_in_extra(self) -> None:
"""As described `here `_."""
assert Motd.parse({"text": "top", "bold": True, "extra": [{"color": "red", "text": "not top"}]}).parsed == [
Formatting.BOLD,
"top",
Formatting.RESET,
Formatting.BOLD,
MinecraftColor.RED,
"not top",
Formatting.RESET,
]
def test_top_level_formatting_can_be_overwrote(self) -> None:
"""As described `here `_."""
assert Motd.parse(
{"text": "bold", "bold": True, "extra": [{"color": "red", "bold": False, "text": "not bold"}]}
).parsed == [
Formatting.BOLD,
"bold",
Formatting.RESET,
MinecraftColor.RED,
"not bold",
Formatting.RESET,
]
def test_top_level_formatting_applies_to_string_inside_extra(self) -> None:
"""Although, it is probably a bug in some modded cores, Minecraft supports it, and we should as well.
See `#711 `_.
"""
assert Motd.parse({"text": "top", "bold": True, "extra": ["not top"]}).parsed == [
Formatting.BOLD,
"top",
Formatting.RESET,
Formatting.BOLD,
"not top",
]
def test_formatting_key_set_to_false_here_without_it_being_set_to_true_before(self) -> None:
"""Some servers set the formatting keys to false here, even without it ever being set to true before.
See `https://github.com/py-mine/mcstatus/pull/335#discussion_r985086953`_.
"""
assert Motd.parse({"color": "red", "bold": False, "text": "not bold"}).parsed == [
MinecraftColor.RED,
"not bold",
Formatting.RESET,
]
def test_translate_string(self):
assert Motd.parse(RawJavaResponseMotdWhenDict(translate="the key")).parsed == [
TranslationTag("the key"),
Formatting.RESET,
]
def test_short_text_is_not_considered_as_color(self):
"""See `https://github.com/py-mine/mcstatus/pull/335#discussion_r984535349`_."""
assert Motd.parse("a").parsed == ["a"]
def test_text_field_contains_formatting(self):
"""See `https://github.com/py-mine/mcstatus/pull/335#issuecomment-1264191303`_."""
assert Motd.parse({"text": "&aHello!"}).parsed == ["", MinecraftColor.GREEN, "Hello!", Formatting.RESET]
def test_invalid_raw_input(self):
obj = object()
with pytest.raises(
TypeError,
match=f"^Expected list, string or dict data, got \\({obj!r}\\), report this!$",
):
Motd.parse(obj) # pyright: ignore[reportArgumentType]
def test_invalid_color(self):
with pytest.raises(ValueError, match=r"^Unable to parse color: 'a', report this!$"):
Motd._parse_color("a")
def test_multiple_times_nested_extras(self):
"""See `https://discord.com/channels/936788458939224094/938591600160956446/1062860329597534258`_."""
motd = Motd.parse(
{
"extra": [
{
"extra": [
{"extra": [{"text": "1"}]},
{"extra": [{"text": "2"}]},
{"extra": [{"text": "3"}]},
]
},
{
"extra": [
{"extra": [{"text": "4"}]},
{"extra": [{"text": "5"}]},
{"extra": [{"text": "6"}]},
]
},
{
"extra": [
{"extra": [{"text": "7"}]},
{"extra": [{"text": "8"}]},
{"extra": [{"text": "9"}]},
]
},
]
}
)
assert motd.parsed == [
Formatting.RESET, Formatting.RESET, Formatting.RESET,
"1",
Formatting.RESET, Formatting.RESET,
"2",
Formatting.RESET, Formatting.RESET,
"3",
Formatting.RESET, Formatting.RESET, Formatting.RESET,
"4",
Formatting.RESET, Formatting.RESET,
"5",
Formatting.RESET, Formatting.RESET,
"6",
Formatting.RESET, Formatting.RESET, Formatting.RESET,
"7",
Formatting.RESET, Formatting.RESET,
"8",
Formatting.RESET, Formatting.RESET,
"9",
Formatting.RESET,
] # fmt: skip
def test_raw_attribute(self, source_bedrock):
motd = Motd.parse(source_bedrock)
assert motd.raw == source_bedrock
================================================
FILE: tests/motd/test_simplifies.py
================================================
from __future__ import annotations
from contextlib import ExitStack
from unittest import mock
import pytest
from mcstatus.motd import Motd
from mcstatus.motd._simplifies import (
get_double_colors,
get_double_items,
get_empty_text,
get_end_non_text,
get_formatting_before_color,
get_meaningless_resets_and_colors,
get_unused_elements,
)
from mcstatus.motd.components import Formatting, MinecraftColor, TranslationTag, WebColor
class TestMotdSimplifies:
def test_get_unused_elements_call_every_simplifier(self):
with ExitStack() as stack:
mocked = [
stack.enter_context(mock.patch("mcstatus.motd._simplifies." + simplifier))
for simplifier in [
get_double_items.__name__,
get_double_colors.__name__,
get_formatting_before_color.__name__,
get_empty_text.__name__,
get_end_non_text.__name__,
]
]
get_unused_elements([])
for simplifier in mocked:
simplifier.assert_called()
def test_simplify_returns_new_instance(self):
parsed = ["", Formatting.RESET]
obj = Motd(parsed.copy(), raw="")
assert obj.simplify().parsed == []
assert obj.parsed == parsed
def test_simplifies_work(self):
get_unused_elements(["a", "b", "c"])
def test_simplify_runs_few_times(self):
"""See `https://github.com/py-mine/mcstatus/pull/335#discussion_r1051658497`_."""
obj = Motd([Formatting.BOLD, "", Formatting.RESET, "", MinecraftColor.RED, ""], raw="")
assert obj.simplify() == Motd([], raw="")
@pytest.mark.parametrize("first", [MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
@pytest.mark.parametrize("second", [MinecraftColor.BLUE, WebColor.from_hex(hex="#dd0220")])
def test_get_double_colors(self, first, second):
assert get_double_colors([first, second]) == {0}
@pytest.mark.parametrize("first", [MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
@pytest.mark.parametrize("second", [MinecraftColor.BLUE, WebColor.from_hex(hex="#dd0220")])
@pytest.mark.parametrize("third", [MinecraftColor.BLUE, WebColor.from_hex(hex="dd0220")])
def test_get_double_colors_with_three_items(self, first, second, third):
assert get_double_colors([first, second, third]) == {0, 1}
@pytest.mark.parametrize("first", [MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
@pytest.mark.parametrize("second", [MinecraftColor.BLUE, WebColor.from_hex(hex="#dd0220")])
def test_get_double_colors_with_no_double_colors(self, first, second):
assert get_double_colors([first, "", second]) == set()
@pytest.mark.parametrize("item", [Formatting.BOLD, MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
def test_get_double_items(self, item):
assert get_double_items([item, item]) == {0}
@pytest.mark.parametrize("item", [Formatting.BOLD, MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
def test_get_double_items_with_three_items(self, item):
assert get_double_items([item, item, item]) == {0, 1}
@pytest.mark.parametrize("item", [Formatting.BOLD, MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
def test_get_double_items_with_no_double_items(self, item):
assert get_double_items([item, "", item]) == set()
@pytest.mark.parametrize("last_item", [MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
def test_get_formatting_before_color(self, last_item):
assert get_formatting_before_color([Formatting.BOLD, last_item]) == {0}
@pytest.mark.parametrize("first_item", [Formatting.RESET, MinecraftColor.RED, WebColor.from_hex(hex="#ff0000"), "abc"])
def test_get_formatting_before_color_without_formatting_before_color(self, first_item):
assert get_formatting_before_color([first_item, "abc", MinecraftColor.WHITE]) == set()
def test_skip_get_formatting_before_color(self):
assert get_formatting_before_color(["abc", Formatting.BOLD, "def", Formatting.RESET, "ghi"]) == set()
@pytest.mark.parametrize("last_item", [MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
def test_get_formatting_before_color_if_space_between(self, last_item):
assert get_formatting_before_color([Formatting.BOLD, " ", last_item]) == {0}
def test_get_empty_text_removes_empty_string(self):
assert get_empty_text([Formatting.BOLD, "", Formatting.RESET, "", MinecraftColor.RED, ""]) == {1, 3, 5}
def test_two_formattings_before_minecraft_color(self):
"""See `https://github.com/py-mine/mcstatus/pull/335#discussion_r1048476090`_."""
assert get_formatting_before_color([Formatting.BOLD, Formatting.ITALIC, MinecraftColor.RED]) == {0, 1}
def test_two_formattings_one_by_one(self):
obj = Motd([Formatting.BOLD, Formatting.ITALIC], raw="")
assert obj.simplify().parsed == []
@pytest.mark.parametrize("item", [Formatting.RESET, MinecraftColor.RED, WebColor.from_hex(hex="#ff1234")])
def test_dont_remove_empty_text(self, item):
assert get_empty_text([item]) == set()
@pytest.mark.parametrize("last_item", [Formatting.RESET, MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
def test_non_text_in_the_end(self, last_item):
assert get_end_non_text(["abc", Formatting.BOLD, "def", Formatting.RESET, "ghi", last_item]) == {5}
def test_translation_tag_in_the_end(self):
assert get_end_non_text(["abc", Formatting.BOLD, "def", Formatting.RESET, "ghi", TranslationTag("key")]) == set()
@pytest.mark.parametrize("item", [Formatting.BOLD, MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
def test_meaningless_resets_and_colors_active(self, item):
assert get_meaningless_resets_and_colors([item, "foo", item, "bar"]) == {2}
def test_meaningless_resets_and_colors_reset_nothing(self):
assert get_meaningless_resets_and_colors(["foo", Formatting.RESET, "bar"]) == {1}
@pytest.mark.parametrize("item", [Formatting.BOLD, MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
def test_meaningless_resets_and_colors_resets(self, item):
assert get_meaningless_resets_and_colors([item, "foo", Formatting.RESET, item, "bar"]) == set()
def test_no_conflict_on_poping_items(self):
"""See `https://github.com/py-mine/mcstatus/pull/335#discussion_r1045303652`_."""
obj = Motd(["0", "1"], raw="")
call_count = 0
def remove_first_element(*_, **__):
nonlocal call_count
call_count += 1
if call_count in (1, 2):
return {0}
return set()
with ExitStack() as stack:
for simplifier in [
get_double_items.__name__,
get_double_colors.__name__,
get_formatting_before_color.__name__,
get_empty_text.__name__,
get_end_non_text.__name__,
]:
stack.enter_context(mock.patch("mcstatus.motd._simplifies." + simplifier, remove_first_element))
assert obj.simplify().parsed == ["1"]
def test_simplify_function_provides_the_same_raw(self):
obj = object()
assert Motd([], raw=obj).simplify().raw is obj # pyright: ignore[reportArgumentType]
def test_simplify_do_not_remove_string_contains_only_spaces(self):
"""Those can be used as delimiters."""
assert Motd([" " * 20], raw="").simplify().parsed == [" " * 20]
def test_simplify_meaningless_resets_and_colors(self):
assert Motd.parse("&a1&a2&a3").simplify().parsed == [MinecraftColor.GREEN, "123"]
def test_remove_formatting_reset_if_there_was_no_color_or_formatting(self):
motd = Motd.parse({"text": "123", "extra": [{"text": "123"}]})
assert motd.parsed == ["123", Formatting.RESET, "123", Formatting.RESET]
assert motd.simplify().parsed == ["123123"]
def test_squash_nearby_strings(self):
assert Motd(["123", "123", "123"], raw="").simplify().parsed == ["123123123"]
================================================
FILE: tests/motd/test_transformers.py
================================================
# ruff: noqa: FBT003 # boolean positional value in `result` fixture
from __future__ import annotations
import typing
import pytest
from mcstatus.motd import Motd
from mcstatus.motd._transformers import _NothingTransformer
if typing.TYPE_CHECKING:
from collections.abc import Callable
from mcstatus.responses._raw import RawJavaResponseMotd
def test_nothing_transformer():
assert _NothingTransformer().transform(Motd.parse("&1a&bfoo&r").parsed) == ""
class TestMotdPlain:
@pytest.fixture(scope="class")
def result(self) -> Callable[[str | RawJavaResponseMotd], str]:
return lambda text: Motd.parse(text).to_plain()
def test_plain_text(self, result):
assert result("plain") == "plain"
def test_removes_colors(self, result):
assert result("&1<ext") == "text"
def test_skip_web_colors(self, result):
assert result({"extra": [{"color": "#4000ff", "text": "colored text"}], "text": ""}) == "colored text"
def test_skip_minecraft_colors(self, result):
assert result({"extra": [{"color": "red", "text": "colored text"}], "text": ""}) == "colored text"
class TestMotdMinecraft:
@pytest.fixture(scope="class")
def result(self) -> Callable[[str | RawJavaResponseMotd], str]:
return lambda text: Motd.parse(text).to_minecraft()
@pytest.mark.parametrize("motd", ["&1&2&3", "§123§5bc", "§1§2§3"])
def test_return_the_same(self, motd: str, result):
assert result(motd) == motd.replace("&", "§")
def test_skip_web_colors(self, result):
assert result({"extra": [{"color": "#4000ff", "text": "colored text"}], "text": ""}) == "§rcolored text§r"
class TestMotdHTML:
@pytest.fixture(scope="class")
def result(self) -> Callable[[str, bool], str]:
return lambda text, bedrock: Motd.parse(text, bedrock=bedrock).to_html()
def test_correct_output_java(self, result: Callable[[str | dict, bool], str], source_java):
assert result(source_java, False) == (
"top"
"12"
"3"
""
"4"
"5"
"6"
"7"
"8"
"9"
"10"
"11"
"12"
"13"
"14"
"15"
"16"
"17"
"18"
"19"
"20
"
)
def test_correct_output_bedrock(self, result: Callable[[str | dict, bool], str], source_bedrock):
assert result(source_bedrock, True) == (
"top"
"12"
"3"
""
"4"
"5"
"6"
"7"
"8"
"9"
"10"
"11"
"12"
"13"
"14"
"15"
"16"
"17"
"18"
"19"
"20"
"21"
"22"
"23"
"24"
"25"
"26"
"27"
"28"
"29"
"30"
"31"
"32
"
)
def test_new_line_is_br_tag(self):
motd = Motd.parse("Some cool\ntext")
assert motd.to_html() == "Some cool
text
"
class TestMotdAnsi:
@pytest.fixture(scope="class")
def result(self) -> Callable[[str, bool], str]:
return lambda text, bedrock: Motd.parse(text, bedrock=bedrock).to_ansi()
def test_correct_output_java(self, result: Callable[[str | dict, bool], str], source_java):
assert result(source_java, False) == (
"\033[0mtop\033[0m"
"1\033[0m"
"\033[38;2;179;238;255m2\033[0m\033[0m"
"\033[38;2;0;0;0m\033[5m3\033[0m\033[0m"
"\033[38;2;0;0;170m\033[1m\033[9m4\033[0m\033[0m"
"\033[38;2;0;170;0m\033[3m5\033[0m\033[0m"
"\033[38;2;0;170;170m\033[4m6\033[0m\033[0m"
"\033[38;2;0;170;170m7\033[0m\033[0m"
"\033[38;2;170;0;0m8\033[0m\033[0m"
"\033[38;2;170;0;170m9\033[0m\033[0m"
"\033[38;2;255;170;0m10\033[0m\033[0m"
"\033[38;2;170;170;170m11\033[0m\033[0m"
"\033[38;2;85;85;85m12\033[0m\033[0m"
"\033[38;2;85;85;255m13\033[0m\033[0m"
"\033[38;2;85;255;85m14\033[0m\033[0m"
"\033[38;2;85;255;255m15\033[0m\033[0m"
"\033[38;2;255;85;85m16\033[0m\033[0m"
"\033[38;2;255;85;255m17\033[0m\033[0m"
"\033[38;2;255;255;85m18\033[0m\033[0m"
"\033[38;2;255;255;255m19\033[0m\033[0m"
"20\033[0m"
"\033[0m\033[0m"
)
def test_correct_output_bedrock(self, result: Callable[[str | dict, bool], str], source_bedrock):
assert result(source_bedrock, True) == (
"\033[0mtop\033[0m"
"1\033[0m"
"\033[38;2;179;238;255m2\033[0m\033[0m"
"\033[38;2;0;0;0m\033[5m3\033[0m\033[0m"
"\033[38;2;0;0;170m\033[1m\033[9m4\033[0m\033[0m"
"\033[38;2;0;170;0m\033[3m5\033[0m\033[0m"
"\033[38;2;0;170;170m\033[4m6\033[0m\033[0m"
"\033[38;2;0;170;170m7\033[0m\033[0m"
"\033[38;2;170;0;0m8\033[0m\033[0m"
"\033[38;2;170;0;170m9\033[0m\033[0m"
"\033[38;2;255;170;0m10\033[0m\033[0m"
"\033[38;2;198;198;198m11\033[0m\033[0m"
"\033[38;2;85;85;85m12\033[0m\033[0m"
"\033[38;2;85;85;255m13\033[0m\033[0m"
"\033[38;2;85;255;85m14\033[0m\033[0m"
"\033[38;2;85;255;255m15\033[0m\033[0m"
"\033[38;2;255;85;85m16\033[0m\033[0m"
"\033[38;2;255;85;255m17\033[0m\033[0m"
"\033[38;2;255;255;85m18\033[0m\033[0m"
"\033[38;2;255;255;255m19\033[0m\033[0m"
"\033[38;2;221;214;5m20\033[0m\033[0m"
"\033[38;2;227;212;209m21\033[0m\033[0m"
"\033[38;2;206;202;202m22\033[0m\033[0m"
"\033[38;2;68;58;59m23\033[0m\033[0m"
"\033[38;2;151;22;7m24\033[0m\033[0m"
"\033[38;2;180;104;77m25\033[0m\033[0m"
"\033[38;2;222;177;45m26\033[0m\033[0m"
"\033[38;2;17;159;54m27\033[0m\033[0m"
"\033[38;2;44;186;168m28\033[0m\033[0m"
"\033[38;2;33;73;123m29\033[0m\033[0m"
"\033[38;2;154;92;198m30\033[0m\033[0m"
"\033[38;2;235;114;20m31\033[0m\033[0m"
"32\033[0m"
"\033[0m\033[0m"
)
================================================
FILE: tests/net/__init__.py
================================================
================================================
FILE: tests/net/test_address.py
================================================
from __future__ import annotations
import ipaddress
import sys
from pathlib import Path
from typing import cast
from unittest.mock import MagicMock, Mock, patch
import dns.resolver
import pytest
from dns.rdatatype import RdataType
from mcstatus._net.address import Address, async_minecraft_srv_address_lookup, minecraft_srv_address_lookup
class TestSRVLookup:
@pytest.mark.parametrize("exception", [dns.resolver.NXDOMAIN, dns.resolver.NoAnswer])
def test_address_no_srv(self, exception):
with patch("dns.resolver.resolve") as resolve:
resolve.side_effect = [exception]
address = minecraft_srv_address_lookup("example.org", default_port=25565, lifetime=3)
resolve.assert_called_once_with("_minecraft._tcp.example.org", RdataType.SRV, lifetime=3, search=True)
assert address.host == "example.org"
assert address.port == 25565
@pytest.mark.parametrize("exception", [dns.resolver.NXDOMAIN, dns.resolver.NoAnswer])
def test_address_no_srv_no_default_port(self, exception):
with patch("dns.resolver.resolve") as resolve:
resolve.side_effect = [exception]
with pytest.raises(ValueError, match=r"^Given address 'example.org' doesn't contain port"):
minecraft_srv_address_lookup("example.org", lifetime=3)
resolve.assert_called_once_with("_minecraft._tcp.example.org", RdataType.SRV, lifetime=3, search=True)
def test_address_with_srv(self):
with patch("dns.resolver.resolve") as resolve:
answer = Mock()
answer.target = "different.example.org."
answer.port = 12345
resolve.return_value = [answer]
address = minecraft_srv_address_lookup("example.org", lifetime=3)
resolve.assert_called_once_with("_minecraft._tcp.example.org", RdataType.SRV, lifetime=3, search=True)
assert address.host == "different.example.org"
assert address.port == 12345
@pytest.mark.asyncio
@pytest.mark.parametrize("exception", [dns.resolver.NXDOMAIN, dns.resolver.NoAnswer])
async def test_async_address_no_srv(self, exception):
with patch("dns.asyncresolver.resolve") as resolve:
resolve.side_effect = [exception]
address = await async_minecraft_srv_address_lookup("example.org", default_port=25565, lifetime=3)
resolve.assert_called_once_with("_minecraft._tcp.example.org", RdataType.SRV, lifetime=3, search=True)
assert address.host == "example.org"
assert address.port == 25565
@pytest.mark.asyncio
@pytest.mark.parametrize("exception", [dns.resolver.NXDOMAIN, dns.resolver.NoAnswer])
async def test_async_address_no_srv_no_default_port(self, exception):
with patch("dns.asyncresolver.resolve") as resolve:
resolve.side_effect = [exception]
with pytest.raises(ValueError, match=r"^Given address 'example.org' doesn't contain port"):
await async_minecraft_srv_address_lookup("example.org", lifetime=3)
resolve.assert_called_once_with("_minecraft._tcp.example.org", RdataType.SRV, lifetime=3, search=True)
@pytest.mark.asyncio
async def test_async_address_with_srv(self):
with patch("dns.asyncresolver.resolve") as resolve:
answer = Mock()
answer.target = "different.example.org."
answer.port = 12345
resolve.return_value = [answer]
address = await async_minecraft_srv_address_lookup("example.org", lifetime=3)
resolve.assert_called_once_with("_minecraft._tcp.example.org", RdataType.SRV, lifetime=3, search=True)
assert address.host == "different.example.org"
assert address.port == 12345
class TestAddressValidity:
@pytest.mark.parametrize(
("address", "port"),
[
("example.org", 25565),
("192.168.0.100", 54321),
("2345:0425:2CA1:0000:0000:0567:5673:23b5", 100),
("2345:0425:2CA1::0567:5673:23b5", 12345),
],
)
def test_address_validation_valid(self, address, port):
Address._ensure_validity(address, port)
@pytest.mark.parametrize(
("address", "port"),
[
("example.org", 100_000),
("example.org", -1),
],
)
def test_address_validation_range(self, address, port):
with pytest.raises(ValueError, match=f"^Port must be within the allowed range \\(0-2\\^16\\), got {port}$"):
Address._ensure_validity(address, port)
def test_address_validation_port_invalid_type(self):
with pytest.raises(TypeError, match=r"^Port must be an integer port number, got \('25565'\)$"):
Address._ensure_validity("example.org", "25565")
@pytest.mark.parametrize(
("address", "port"),
[(25565, "example.org"), (0, 0)],
)
def test_address_validation_host_invalid_type(self, address, port):
with pytest.raises(TypeError, match=f"^Host must be a string address, got {type(address)!r} \\({address!r}\\)$"):
Address._ensure_validity(address, port)
def test_address_host_invalid_format(self):
with pytest.raises(ValueError, match=r"^Invalid address 'hello@#', can't parse\.$"):
Address.parse_address("hello@#")
class TestAddressConstructing:
def test_init_constructor(self):
addr = Address("example.org", 25565)
assert addr.host == "example.org"
assert addr.port == 25565
def test_tuple_behavior(self):
addr = Address("example.org", 25565)
assert isinstance(addr, tuple)
assert len(addr) == 2
assert addr[0] == "example.org"
assert addr[1] == 25565
def test_from_tuple_constructor(self):
addr = Address.from_tuple(("example.org", 12345))
assert addr.host == "example.org"
assert addr.port == 12345
def test_from_path_constructor(self):
addr = Address.from_path(Path("example.org:25565"))
assert addr.host == "example.org"
assert addr.port == 25565
def test_address_with_port_no_default(self):
addr = Address.parse_address("example.org:25565")
assert addr.host == "example.org"
assert addr.port == 25565
def test_address_with_port_default(self):
addr = Address.parse_address("example.org:25565", default_port=12345)
assert addr.host == "example.org"
assert addr.port == 25565
def test_address_without_port_default(self):
addr = Address.parse_address("example.org", default_port=12345)
assert addr.host == "example.org"
assert addr.port == 12345
def test_address_without_port(self):
with pytest.raises(
ValueError,
match=r"^Given address 'example.org' doesn't contain port and default_port wasn't specified, can't parse.$",
):
Address.parse_address("example.org")
def test_address_with_invalid_port(self):
with pytest.raises(ValueError, match=r"^Port could not be cast to integer value as 'port'$"):
Address.parse_address("example.org:port")
def test_address_with_multiple_ports(self):
with pytest.raises(ValueError, match=r"^Port could not be cast to integer value as '12345:25565'$"):
Address.parse_address("example.org:12345:25565")
class TestAddressIPResolving:
def setup_method(self):
self.host_addr = Address("example.org", 25565)
self.ipv4_addr = Address("1.1.1.1", 25565)
self.ipv6_addr = Address("::1", 25565)
def test_ip_resolver_with_hostname(self):
with patch("dns.resolver.resolve") as resolve:
answer = MagicMock()
cast("MagicMock", answer.__str__).return_value = "48.225.1.104."
resolve.return_value = [answer]
resolved_ip = self.host_addr.resolve_ip(lifetime=3)
resolve.assert_called_once_with(self.host_addr.host, RdataType.A, lifetime=3, search=True)
assert isinstance(resolved_ip, ipaddress.IPv4Address)
assert str(resolved_ip) == "48.225.1.104"
@pytest.mark.asyncio
async def test_async_ip_resolver_with_hostname(self):
with patch("dns.asyncresolver.resolve") as resolve:
answer = MagicMock()
cast("MagicMock", answer.__str__).return_value = "48.225.1.104."
resolve.return_value = [answer]
resolved_ip = await self.host_addr.async_resolve_ip(lifetime=3)
resolve.assert_called_once_with(self.host_addr.host, RdataType.A, lifetime=3, search=True)
assert isinstance(resolved_ip, ipaddress.IPv4Address)
assert str(resolved_ip) == "48.225.1.104"
@pytest.mark.parametrize("ip_version", ["ipv4_addr", "ipv6_addr"])
def test_ip_resolver_cache(self, ip_version: str):
with patch("dns.resolver.resolve"), patch("ipaddress.ip_address") as resolve:
assert getattr(self, ip_version).resolve_ip(lifetime=3) is getattr(self, ip_version).resolve_ip(lifetime=3)
resolve.assert_called_once() # Make sure we didn't needlessly try to resolve
@pytest.mark.asyncio
@pytest.mark.parametrize("ip_version", ["ipv4_addr", "ipv6_addr"])
async def test_async_ip_resolver_cache(self, ip_version: str):
with patch("dns.resolver.resolve"), patch("ipaddress.ip_address") as resolve:
assert await getattr(self, ip_version).async_resolve_ip(lifetime=3) is await getattr(
self, ip_version
).async_resolve_ip(lifetime=3)
resolve.assert_called_once() # Make sure we didn't needlessly try to resolve
def test_ip_resolver_with_ipv4(self):
with patch("dns.resolver.resolve") as resolve:
resolved_ip = self.ipv4_addr.resolve_ip(lifetime=3)
resolve.assert_not_called() # Make sure we didn't needlessly try to resolve
assert isinstance(resolved_ip, ipaddress.IPv4Address)
assert str(resolved_ip) == self.ipv4_addr.host
@pytest.mark.asyncio
async def test_async_ip_resolver_with_ipv4(self):
with patch("dns.asyncresolver.resolve") as resolve:
resolved_ip = await self.ipv4_addr.async_resolve_ip(lifetime=3)
resolve.assert_not_called() # Make sure we didn't needlessly try to resolve
assert isinstance(resolved_ip, ipaddress.IPv4Address)
assert str(resolved_ip) == self.ipv4_addr.host
def test_ip_resolver_with_ipv6(self):
with patch("dns.resolver.resolve") as resolve:
resolved_ip = self.ipv6_addr.resolve_ip(lifetime=3)
resolve.assert_not_called() # Make sure we didn't needlessly try to resolve
assert isinstance(resolved_ip, ipaddress.IPv6Address)
assert str(resolved_ip) == self.ipv6_addr.host
@pytest.mark.asyncio
async def test_async_ip_resolver_with_ipv6(self):
with patch("dns.asyncresolver.resolve") as resolve:
resolved_ip = await self.ipv6_addr.async_resolve_ip(lifetime=3)
resolve.assert_not_called() # Make sure we didn't needlessly try to resolve
assert isinstance(resolved_ip, ipaddress.IPv6Address)
assert str(resolved_ip) == self.ipv6_addr.host
def test_resolve_localhost(self):
addr = Address("localhost", 25565)
context_manager = pytest.warns(RuntimeWarning) if sys.platform == "darwin" else MagicMock()
with context_manager:
assert addr.resolve_ip() == ipaddress.ip_address("127.0.0.1")
@pytest.mark.asyncio
async def test_async_resolve_localhost(self):
addr = Address("localhost", 25565)
context_manager = pytest.warns(RuntimeWarning) if sys.platform == "darwin" else MagicMock()
with context_manager:
assert await addr.async_resolve_ip() == ipaddress.ip_address("127.0.0.1")
================================================
FILE: tests/protocol/__init__.py
================================================
================================================
FILE: tests/protocol/test_async_support.py
================================================
from inspect import iscoroutinefunction
from mcstatus._protocol.connection import TCPAsyncSocketConnection, UDPAsyncSocketConnection
def test_is_completely_asynchronous():
conn = TCPAsyncSocketConnection
assertions = 0
for attribute in dir(conn):
if attribute.startswith("read_"):
assert iscoroutinefunction(getattr(conn, attribute))
assertions += 1
assert assertions > 0, "None of the read_* attributes were async"
def test_query_is_completely_asynchronous():
conn = UDPAsyncSocketConnection
assertions = 0
for attribute in dir(conn):
if attribute.startswith("read_"):
assert iscoroutinefunction(getattr(conn, attribute))
assertions += 1
assert assertions > 0, "None of the read_* attributes were async"
================================================
FILE: tests/protocol/test_bedrock_client.py
================================================
import sys
import time
from unittest import mock
import pytest
from mcstatus._net.address import Address
from mcstatus._protocol.bedrock_client import BedrockClient
from mcstatus.responses import BedrockStatusResponse
def test_bedrock_response_is_expected_type():
data = (
b"\x1c\x00\x00\x00\x00\x00\x00\x00\x004GT\x00\xb8\x83D\xde\x00\xff\xff\x00\xfe\xfe\xfe\xfe\xfd\xfd\xfd\xfd"
b"\x124Vx\x00wMCPE;\xc2\xa7r\xc2\xa74G\xc2\xa7r\xc2\xa76a\xc2\xa7r\xc2\xa7ey\xc2\xa7r\xc2\xa72B\xc2\xa7r\xc2"
b"\xa71o\xc2\xa7r\xc2\xa79w\xc2\xa7r\xc2\xa7ds\xc2\xa7r\xc2\xa74e\xc2\xa7r\xc2\xa76r;422;;1;69;376707197539105"
b"3022;;Default;1;19132;-1;"
)
parsed = BedrockClient.parse_response(data, 1)
assert isinstance(parsed, BedrockStatusResponse)
@pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32"))
def test_latency_is_real_number():
"""``time.perf_counter`` returns fractional seconds, we must convert it to milliseconds."""
def mocked_read_status():
time.sleep(0.001)
return mock.DEFAULT
bedrock_client = BedrockClient(Address("localhost", 25565))
with (
mock.patch.object(bedrock_client, "_read_status") as mocked_read,
mock.patch.object(bedrock_client, "parse_response") as mocked_parse_response,
):
mocked_read.side_effect = mocked_read_status
bedrock_client.read_status()
# we slept 1ms, so this should be always ~1.
assert mocked_parse_response.call_args[0][1] >= 1
@pytest.mark.asyncio
@pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32"))
async def test_async_latency_is_real_number():
"""``time.perf_counter`` returns fractional seconds, we must convert it to milliseconds."""
def mocked_read_status():
time.sleep(0.001)
return mock.DEFAULT
bedrock_client = BedrockClient(Address("localhost", 25565))
with (
mock.patch.object(bedrock_client, "_read_status_async") as mocked_read,
mock.patch.object(bedrock_client, "parse_response") as mocked_parse_response,
):
mocked_read.side_effect = mocked_read_status
await bedrock_client.read_status_async()
# we slept 1ms, so this should be always ~1.
assert mocked_parse_response.call_args[0][1] >= 1
================================================
FILE: tests/protocol/test_connection.py
================================================
from unittest.mock import Mock, patch
import pytest
from mcstatus._net.address import Address
from mcstatus._protocol.connection import Connection, TCPSocketConnection, UDPSocketConnection
class TestConnection:
connection: Connection
def setup_method(self):
self.connection = Connection()
def test_flush(self):
self.connection.sent = bytearray.fromhex("7FAABB")
assert self.connection.flush() == bytearray.fromhex("7FAABB")
assert self.connection.sent == bytearray()
def test_receive(self):
self.connection.receive(bytearray.fromhex("7F"))
self.connection.receive(bytearray.fromhex("AABB"))
assert self.connection.received == bytearray.fromhex("7FAABB")
def test_remaining(self):
self.connection.receive(bytearray.fromhex("7F"))
self.connection.receive(bytearray.fromhex("AABB"))
assert self.connection.remaining() == 3
def test_send(self):
self.connection.write(bytearray.fromhex("7F"))
self.connection.write(bytearray.fromhex("AABB"))
assert self.connection.flush() == bytearray.fromhex("7FAABB")
def test_read(self):
self.connection.receive(bytearray.fromhex("7FAABB"))
assert self.connection.read(2) == bytearray.fromhex("7FAA")
assert self.connection.read(1) == bytearray.fromhex("BB")
def _assert_varint_read_write(self, hexstr, value) -> None:
self.connection.receive(bytearray.fromhex(hexstr))
assert self.connection.read_varint() == value
self.connection.write_varint(value)
assert self.connection.flush() == bytearray.fromhex(hexstr)
def test_varint_cases(self):
self._assert_varint_read_write("00", 0)
self._assert_varint_read_write("01", 1)
self._assert_varint_read_write("0F", 15)
self._assert_varint_read_write("FFFFFFFF07", 2147483647)
self._assert_varint_read_write("FFFFFFFF0F", -1)
self._assert_varint_read_write("8080808008", -2147483648)
def test_read_invalid_varint(self):
self.connection.receive(bytearray.fromhex("FFFFFFFF80"))
with pytest.raises(IOError, match=r"^Received varint is too big!$"):
self.connection.read_varint()
def test_write_invalid_varint(self):
with pytest.raises(ValueError, match=r'^The value "2147483648" is too big to send in a varint$'):
self.connection.write_varint(2147483648)
with pytest.raises(ValueError, match=r'^The value "-2147483649" is too big to send in a varint$'):
self.connection.write_varint(-2147483649)
def test_read_utf(self):
self.connection.receive(bytearray.fromhex("0D48656C6C6F2C20776F726C6421"))
assert self.connection.read_utf() == "Hello, world!"
def test_write_utf(self):
self.connection.write_utf("Hello, world!")
assert self.connection.flush() == bytearray.fromhex("0D48656C6C6F2C20776F726C6421")
def test_read_empty_utf(self):
self.connection.write_utf("")
assert self.connection.flush() == bytearray.fromhex("00")
def test_read_ascii(self):
self.connection.receive(bytearray.fromhex("48656C6C6F2C20776F726C642100"))
assert self.connection.read_ascii() == "Hello, world!"
def test_write_ascii(self):
self.connection.write_ascii("Hello, world!")
assert self.connection.flush() == bytearray.fromhex("48656C6C6F2C20776F726C642100")
def test_read_empty_ascii(self):
self.connection.write_ascii("")
assert self.connection.flush() == bytearray.fromhex("00")
def test_read_short_negative(self):
self.connection.receive(bytearray.fromhex("8000"))
assert self.connection.read_short() == -32768
def test_write_short_negative(self):
self.connection.write_short(-32768)
assert self.connection.flush() == bytearray.fromhex("8000")
def test_read_short_positive(self):
self.connection.receive(bytearray.fromhex("7FFF"))
assert self.connection.read_short() == 32767
def test_write_short_positive(self):
self.connection.write_short(32767)
assert self.connection.flush() == bytearray.fromhex("7FFF")
def test_read_ushort_positive(self):
self.connection.receive(bytearray.fromhex("8000"))
assert self.connection.read_ushort() == 32768
def test_write_ushort_positive(self):
self.connection.write_ushort(32768)
assert self.connection.flush() == bytearray.fromhex("8000")
def test_read_int_negative(self):
self.connection.receive(bytearray.fromhex("80000000"))
assert self.connection.read_int() == -2147483648
def test_write_int_negative(self):
self.connection.write_int(-2147483648)
assert self.connection.flush() == bytearray.fromhex("80000000")
def test_read_int_positive(self):
self.connection.receive(bytearray.fromhex("7FFFFFFF"))
assert self.connection.read_int() == 2147483647
def test_write_int_positive(self):
self.connection.write_int(2147483647)
assert self.connection.flush() == bytearray.fromhex("7FFFFFFF")
def test_read_uint_positive(self):
self.connection.receive(bytearray.fromhex("80000000"))
assert self.connection.read_uint() == 2147483648
def test_write_uint_positive(self):
self.connection.write_uint(2147483648)
assert self.connection.flush() == bytearray.fromhex("80000000")
def test_read_long_negative(self):
self.connection.receive(bytearray.fromhex("8000000000000000"))
assert self.connection.read_long() == -9223372036854775808
def test_write_long_negative(self):
self.connection.write_long(-9223372036854775808)
assert self.connection.flush() == bytearray.fromhex("8000000000000000")
def test_read_long_positive(self):
self.connection.receive(bytearray.fromhex("7FFFFFFFFFFFFFFF"))
assert self.connection.read_long() == 9223372036854775807
def test_write_long_positive(self):
self.connection.write_long(9223372036854775807)
assert self.connection.flush() == bytearray.fromhex("7FFFFFFFFFFFFFFF")
def test_read_ulong_positive(self):
self.connection.receive(bytearray.fromhex("8000000000000000"))
assert self.connection.read_ulong() == 9223372036854775808
def test_write_ulong_positive(self):
self.connection.write_ulong(9223372036854775808)
assert self.connection.flush() == bytearray.fromhex("8000000000000000")
@pytest.mark.parametrize(("as_bytes", "as_bool"), [("01", True), ("00", False)])
def test_read_bool(self, as_bytes: str, as_bool: bool) -> None:
self.connection.receive(bytearray.fromhex(as_bytes))
assert self.connection.read_bool() is as_bool
@pytest.mark.parametrize(("as_bytes", "as_bool"), [("01", True), ("00", False)])
def test_write_bool(self, as_bytes: str, as_bool: bool) -> None:
self.connection.write_bool(as_bool)
assert self.connection.flush() == bytearray.fromhex(as_bytes)
def test_read_buffer(self):
self.connection.receive(bytearray.fromhex("027FAA"))
buffer = self.connection.read_buffer()
assert buffer.received == bytearray.fromhex("7FAA")
assert self.connection.flush() == bytearray()
def test_write_buffer(self):
buffer = Connection()
buffer.write(bytearray.fromhex("7FAA"))
self.connection.write_buffer(buffer)
assert self.connection.flush() == bytearray.fromhex("027FAA")
def test_read_empty(self):
self.connection.received = bytearray()
with pytest.raises(IOError, match=r"^Not enough data to read! 0 < 1$"):
self.connection.read(1)
def test_read_not_enough(self):
self.connection.received = bytearray(b"a")
with pytest.raises(IOError, match=r"^Not enough data to read! 1 < 2$"):
self.connection.read(2)
class TestTCPSocketConnection:
@pytest.fixture(scope="class")
def connection(self):
test_addr = Address("localhost", 1234)
socket = Mock()
socket.recv = Mock()
socket.send = Mock()
with patch("socket.create_connection") as create_connection:
create_connection.return_value = socket
with TCPSocketConnection(test_addr) as connection:
yield connection
def test_flush(self, connection):
with pytest.raises(TypeError, match=r"^TCPSocketConnection does not support flush\(\)$"):
connection.flush()
def test_receive(self, connection):
with pytest.raises(TypeError, match=r"^TCPSocketConnection does not support receive\(\)$"):
connection.receive("")
def test_remaining(self, connection):
with pytest.raises(TypeError, match=r"^TCPSocketConnection does not support remaining\(\)$"):
connection.remaining()
def test_read(self, connection):
connection.socket.recv.return_value = bytearray.fromhex("7FAA")
assert connection.read(2) == bytearray.fromhex("7FAA")
def test_read_empty(self, connection):
connection.socket.recv.return_value = bytearray()
with pytest.raises(IOError, match=r"^Server did not respond with any information!$"):
connection.read(1)
def test_read_not_enough(self, connection):
connection.socket.recv.side_effect = [bytearray(b"a"), bytearray()]
with pytest.raises(IOError, match=r"^Server did not respond with any information!$"):
connection.read(2)
def test_write(self, connection):
connection.write(bytearray.fromhex("7FAA"))
connection.socket.send.assert_called_once_with(bytearray.fromhex("7FAA"))
class TestUDPSocketConnection:
@pytest.fixture(scope="class")
def connection(self):
test_addr = Address("localhost", 1234)
socket = Mock()
socket.recvfrom = Mock()
socket.sendto = Mock()
with patch("socket.socket") as create_socket:
create_socket.return_value = socket
with UDPSocketConnection(test_addr) as connection:
yield connection
def test_flush(self, connection):
with pytest.raises(TypeError, match=r"^UDPSocketConnection does not support flush\(\)$"):
connection.flush()
def test_receive(self, connection):
with pytest.raises(TypeError, match=r"^UDPSocketConnection does not support receive\(\)$"):
connection.receive("")
def test_remaining(self, connection):
assert connection.remaining() == 65535
def test_read(self, connection):
connection.socket.recvfrom.return_value = [bytearray.fromhex("7FAA")]
assert connection.read(2) == bytearray.fromhex("7FAA")
def test_read_empty(self, connection):
connection.socket.recvfrom.return_value = []
with pytest.raises(IndexError, match=r"^list index out of range$"):
connection.read(1)
def test_write(self, connection):
connection.write(bytearray.fromhex("7FAA"))
connection.socket.sendto.assert_called_once_with(
bytearray.fromhex("7FAA"),
Address("localhost", 1234),
)
================================================
FILE: tests/protocol/test_java_client.py
================================================
import sys
import time
from unittest import mock
import pytest
from mcstatus._net.address import Address
from mcstatus._protocol.connection import Connection
from mcstatus._protocol.java_client import JavaClient
class TestJavaClient:
def setup_method(self):
self.java_client = JavaClient(
Connection(), # pyright: ignore[reportArgumentType]
address=Address("localhost", 25565),
version=44,
)
def test_handshake(self):
self.java_client.handshake()
assert self.java_client.connection.flush() == bytearray.fromhex("0F002C096C6F63616C686F737463DD01")
def test_read_status(self):
self.java_client.connection.receive(
bytearray.fromhex(
"7200707B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A7B2"
"26D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E382D70726531222C22"
"70726F746F636F6C223A34347D7D"
)
)
status = self.java_client.read_status()
assert status.raw == {
"description": "A Minecraft Server",
"players": {"max": 20, "online": 0},
"version": {"name": "1.8-pre1", "protocol": 44},
}
assert self.java_client.connection.flush() == bytearray.fromhex("0100")
def test_read_status_invalid_json(self):
self.java_client.connection.receive(bytearray.fromhex("0300017B"))
with pytest.raises(IOError, match=r"^Received invalid JSON$"):
self.java_client.read_status()
def test_read_status_invalid_reply(self):
self.java_client.connection.receive(
# no motd, see also #922
bytearray.fromhex(
"4F004D7B22706C6179657273223A7B226D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616"
"D65223A22312E382D70726531222C2270726F746F636F6C223A34347D7D"
)
)
self.java_client.read_status()
def test_read_status_invalid_status(self):
self.java_client.connection.receive(bytearray.fromhex("0105"))
with pytest.raises(IOError, match=r"^Received invalid status response packet.$"):
self.java_client.read_status()
def test_test_ping(self):
self.java_client.connection.receive(bytearray.fromhex("09010000000000DD7D1C"))
self.java_client.ping_token = 14515484
assert self.java_client.test_ping() >= 0
assert self.java_client.connection.flush() == bytearray.fromhex("09010000000000DD7D1C")
def test_test_ping_invalid(self):
self.java_client.connection.receive(bytearray.fromhex("011F"))
self.java_client.ping_token = 14515484
with pytest.raises(IOError, match=r"^Received invalid ping response packet.$"):
self.java_client.test_ping()
def test_test_ping_wrong_token(self):
self.java_client.connection.receive(bytearray.fromhex("09010000000000DD7D1C"))
self.java_client.ping_token = 12345
with pytest.raises(IOError, match=r"^Received mangled ping response \(expected token 12345, got 14515484\)$"):
self.java_client.test_ping()
@pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32"))
def test_latency_is_real_number(self):
"""``time.perf_counter`` returns fractional seconds, we must convert it to milliseconds."""
def mocked_read_buffer():
time.sleep(0.001)
return mock.DEFAULT
with mock.patch.object(Connection, "read_buffer") as mocked:
mocked.side_effect = mocked_read_buffer
mocked.return_value.read_varint.return_value = 0
mocked.return_value.read_utf.return_value = """
{
"description": "A Minecraft Server",
"players": {"max": 20, "online": 0},
"version": {"name": "1.8-pre1", "protocol": 44}
}
"""
java_client = JavaClient(
Connection(), # pyright: ignore[reportArgumentType]
address=Address("localhost", 25565),
version=44,
)
java_client.connection.receive(
bytearray.fromhex(
"7200707B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A"
"7B226D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E382D70726531"
"222C2270726F746F636F6C223A34347D7D"
)
)
# we slept 1ms, so this should be always ~1.
assert java_client.read_status().latency >= 1
@pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32"))
def test_test_ping_is_in_milliseconds(self):
"""``time.perf_counter`` returns fractional seconds, we must convert it to milliseconds."""
def mocked_read_buffer():
time.sleep(0.001)
return mock.DEFAULT
with mock.patch.object(Connection, "read_buffer") as mocked:
mocked.side_effect = mocked_read_buffer
mocked.return_value.read_varint.return_value = 1
mocked.return_value.read_long.return_value = 123456789
java_client = JavaClient(
Connection(), # pyright: ignore[reportArgumentType]
address=Address("localhost", 25565),
version=44,
ping_token=123456789,
)
java_client.connection.receive(
bytearray.fromhex(
"7200707B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A"
"7B226D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E382D70726531"
"222C2270726F746F636F6C223A34347D7D"
)
)
# we slept 1ms, so this should be always ~1.
assert java_client.test_ping() >= 1
================================================
FILE: tests/protocol/test_java_client_async.py
================================================
import asyncio
import sys
import time
from unittest import mock
import pytest
from mcstatus._net.address import Address
from mcstatus._protocol.connection import Connection
from mcstatus._protocol.java_client import AsyncJavaClient
def async_decorator(f):
def wrapper(*args, **kwargs):
return asyncio.run(f(*args, **kwargs))
return wrapper
class FakeAsyncConnection(Connection):
async def read_buffer(self): # pyright: ignore[reportIncompatibleMethodOverride]
return super().read_buffer()
class TestAsyncJavaClient:
def setup_method(self):
self.java_client = AsyncJavaClient(
FakeAsyncConnection(), # pyright: ignore[reportArgumentType]
address=Address("localhost", 25565),
version=44,
)
def test_handshake(self):
self.java_client.handshake()
assert self.java_client.connection.flush() == bytearray.fromhex("0F002C096C6F63616C686F737463DD01")
def test_read_status(self):
self.java_client.connection.receive(
bytearray.fromhex(
"7200707B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A7B2"
"26D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E382D70726531222C22"
"70726F746F636F6C223A34347D7D"
)
)
status = async_decorator(self.java_client.read_status)()
assert status.raw == {
"description": "A Minecraft Server",
"players": {"max": 20, "online": 0},
"version": {"name": "1.8-pre1", "protocol": 44},
}
assert self.java_client.connection.flush() == bytearray.fromhex("0100")
def test_read_status_invalid_json(self):
self.java_client.connection.receive(bytearray.fromhex("0300017B"))
with pytest.raises(IOError, match=r"^Received invalid JSON$"):
async_decorator(self.java_client.read_status)()
def test_read_status_invalid_reply(self):
self.java_client.connection.receive(
bytearray.fromhex(
"4F004D7B22706C6179657273223A7B226D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616"
"D65223A22312E382D70726531222C2270726F746F636F6C223A34347D7D"
)
)
async_decorator(self.java_client.read_status)()
def test_read_status_invalid_status(self):
self.java_client.connection.receive(bytearray.fromhex("0105"))
with pytest.raises(IOError, match=r"^Received invalid status response packet.$"):
async_decorator(self.java_client.read_status)()
def test_test_ping(self):
self.java_client.connection.receive(bytearray.fromhex("09010000000000DD7D1C"))
self.java_client.ping_token = 14515484
assert async_decorator(self.java_client.test_ping)() >= 0
assert self.java_client.connection.flush() == bytearray.fromhex("09010000000000DD7D1C")
def test_test_ping_invalid(self):
self.java_client.connection.receive(bytearray.fromhex("011F"))
self.java_client.ping_token = 14515484
with pytest.raises(IOError, match=r"^Received invalid ping response packet.$"):
async_decorator(self.java_client.test_ping)()
def test_test_ping_wrong_token(self):
self.java_client.connection.receive(bytearray.fromhex("09010000000000DD7D1C"))
self.java_client.ping_token = 12345
with pytest.raises(IOError, match=r"^Received mangled ping response \(expected token 12345, got 14515484\)$"):
async_decorator(self.java_client.test_ping)()
@pytest.mark.asyncio
@pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32"))
async def test_latency_is_real_number(self):
"""``time.perf_counter`` returns fractional seconds, we must convert it to milliseconds."""
def mocked_read_buffer():
time.sleep(0.001)
return mock.DEFAULT
with mock.patch.object(FakeAsyncConnection, "read_buffer") as mocked:
mocked.side_effect = mocked_read_buffer
# overwrite `async` here
mocked.return_value.read_varint = lambda: 0
mocked.return_value.read_utf = lambda: (
"""
{
"description": "A Minecraft Server",
"players": {"max": 20, "online": 0},
"version": {"name": "1.8-pre1", "protocol": 44}
}
"""
)
java_client = AsyncJavaClient(
FakeAsyncConnection(), # pyright: ignore[reportArgumentType]
address=Address("localhost", 25565),
version=44,
)
java_client.connection.receive(
bytearray.fromhex(
"7200707B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A"
"7B226D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E382D70726531"
"222C2270726F746F636F6C223A34347D7D"
)
)
# we slept 1ms, so this should be always ~1.
assert (await java_client.read_status()).latency >= 1
@pytest.mark.asyncio
@pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32"))
async def test_test_ping_is_in_milliseconds(self):
"""``time.perf_counter`` returns fractional seconds, we must convert it to milliseconds."""
def mocked_read_buffer():
time.sleep(0.001)
return mock.DEFAULT
with mock.patch.object(FakeAsyncConnection, "read_buffer") as mocked:
mocked.side_effect = mocked_read_buffer
mocked.return_value.read_varint = lambda: 1 # overwrite `async` here
mocked.return_value.read_long = lambda: 123456789 # overwrite `async` here
java_client = AsyncJavaClient(
FakeAsyncConnection(), # pyright: ignore[reportArgumentType]
address=Address("localhost", 25565),
version=44,
ping_token=123456789,
)
# we slept 1ms, so this should be always ~1.
assert await java_client.test_ping() >= 1
================================================
FILE: tests/protocol/test_legacy_client.py
================================================
import pytest
from mcstatus._protocol.connection import Connection
from mcstatus._protocol.legacy_client import LegacyClient
from mcstatus.motd import Motd
from mcstatus.responses.legacy import LegacyStatusPlayers, LegacyStatusResponse, LegacyStatusVersion
def test_invalid_kick_reason():
with pytest.raises(IOError, match=r"^Received invalid kick packet reason$"):
LegacyClient.parse_response("Invalid Reason".encode("UTF-16BE"), 123.0)
@pytest.mark.parametrize(
("response", "expected"),
[
(
"A Minecraft Server§0§20".encode("UTF-16BE"),
LegacyStatusResponse(
players=LegacyStatusPlayers(online=0, max=20),
version=LegacyStatusVersion(name="<1.4", protocol=-1),
motd=Motd.parse("A Minecraft Server"),
latency=123.0,
),
),
(
"§1\x0051\x001.4.7\x00A Minecraft Server\x000\x0020".encode("UTF-16BE"),
LegacyStatusResponse(
players=LegacyStatusPlayers(online=0, max=20),
version=LegacyStatusVersion(name="1.4.7", protocol=51),
motd=Motd.parse("A Minecraft Server"),
latency=123.0,
),
),
],
ids=["b1.8", "1.4.7"],
)
def test_parse_response(response: bytes, expected: LegacyStatusResponse):
assert LegacyClient.parse_response(response, 123.0) == expected
def test_invalid_packet_id():
socket = Connection()
socket.receive(bytearray.fromhex("00"))
server = LegacyClient(socket)
with pytest.raises(IOError, match=r"^Received invalid packet ID$"):
server.read_status()
================================================
FILE: tests/protocol/test_query_client.py
================================================
from unittest.mock import Mock
from mcstatus._protocol.connection import Connection
from mcstatus._protocol.query_client import QueryClient
from mcstatus.motd import Motd
class TestQueryClient:
def setup_method(self):
self.query_client = QueryClient(Connection()) # pyright: ignore[reportArgumentType]
def test_handshake(self):
self.query_client.connection.receive(bytearray.fromhex("090000000035373033353037373800"))
self.query_client.handshake()
conn_bytes = self.query_client.connection.flush()
assert conn_bytes[:3] == bytearray.fromhex("FEFD09")
assert self.query_client.challenge == 570350778
def test_query(self):
self.query_client.connection.receive(
bytearray.fromhex(
"00000000000000000000000000000000686f73746e616d650041204d696e656372616674205365727665720067616d6574797"
"06500534d500067616d655f6964004d494e4543524146540076657273696f6e00312e3800706c7567696e7300006d61700077"
"6f726c64006e756d706c61796572730033006d6178706c617965727300323000686f7374706f727400323535363500686f737"
"46970003139322e3136382e35362e31000001706c617965725f000044696e6e6572626f6e6500446a696e6e69626f6e650053"
"746576650000"
)
)
response = self.query_client.read_query()
conn_bytes = self.query_client.connection.flush()
assert conn_bytes[:3] == bytearray.fromhex("FEFD00")
assert conn_bytes[7:] == bytearray.fromhex("0000000000000000")
assert response.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",
}
assert response.players.list == ["Dinnerbone", "Djinnibone", "Steve"]
def test_query_handles_unorderd_map_response(self):
self.query_client.connection.receive(
bytearray(
b"\x00\x00\x00\x00\x00GeyserMC\x00\x80\x00hostname\x00Geyser\x00hostip\x001.1.1.1\x00plugins\x00\x00numplayers"
b"\x001\x00gametype\x00SMP\x00maxplayers\x00100\x00hostport\x0019132\x00version\x00Geyser"
b" (git-master-0fd903e) 1.18.10\x00map\x00Geyser\x00game_id\x00MINECRAFT\x00\x00\x01player_\x00\x00\x00"
)
)
response = self.query_client.read_query()
self.query_client.connection.flush()
assert response.raw["game_id"] == "MINECRAFT"
assert response.motd == Motd.parse("Geyser")
assert response.software.version == "Geyser (git-master-0fd903e) 1.18.10"
def test_query_handles_unicode_motd_with_nulls(self):
self.query_client.connection.receive(
bytearray(
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00hostname\x00\x00*K\xd5\x00gametype\x00SMP"
b"\x00game_id\x00MINECRAFT\x00version\x001.16.5\x00plugins\x00Paper on 1.16.5-R0.1-SNAPSHOT\x00map\x00world"
b"\x00numplayers\x000\x00maxplayers\x0020\x00hostport\x0025565\x00hostip\x00127.0.1.1\x00\x00\x01player_\x00"
b"\x00\x00"
)
)
response = self.query_client.read_query()
self.query_client.connection.flush()
assert response.raw["game_id"] == "MINECRAFT"
assert response.motd == Motd.parse("\x00*KÕ")
def test_query_handles_unicode_motd_with_2a00_at_the_start(self):
self.query_client.connection.receive(
bytearray.fromhex(
"00000000000000000000000000000000686f73746e616d6500006f746865720067616d657479706500534d500067616d655f6964004d"
"494e4543524146540076657273696f6e00312e31382e3100706c7567696e7300006d617000776f726c64006e756d706c617965727300"
"30006d6178706c617965727300323000686f7374706f727400323535363500686f73746970003137322e31372e302e32000001706c61"
"7965725f000000"
)
)
response = self.query_client.read_query()
self.query_client.connection.flush()
assert response.raw["game_id"] == "MINECRAFT"
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".
def test_session_id(self):
def session_id():
return 0x01010101
self.query_client.connection.receive(bytearray.fromhex("090000000035373033353037373800"))
self.query_client._generate_session_id = Mock()
self.query_client._generate_session_id = session_id
self.query_client.handshake()
conn_bytes = self.query_client.connection.flush()
assert conn_bytes[:3] == bytearray.fromhex("FEFD09")
assert conn_bytes[3:] == session_id().to_bytes(4, byteorder="big")
assert self.query_client.challenge == 570350778
================================================
FILE: tests/protocol/test_query_client_async.py
================================================
from mcstatus._protocol.connection import Connection
from mcstatus._protocol.query_client import AsyncQueryClient
from tests.protocol.test_java_client_async import async_decorator
class FakeUDPAsyncConnection(Connection):
async def read(self, length): # pyright: ignore[reportIncompatibleMethodOverride]
return super().read(length)
async def write(self, data): # pyright: ignore[reportIncompatibleMethodOverride]
return super().write(data)
class TestAsyncQueryClient:
def setup_method(self):
self.query_client = AsyncQueryClient(FakeUDPAsyncConnection()) # pyright: ignore[reportArgumentType]
def test_handshake(self):
self.query_client.connection.receive(bytearray.fromhex("090000000035373033353037373800"))
async_decorator(self.query_client.handshake)()
conn_bytes = self.query_client.connection.flush()
assert conn_bytes[:3] == bytearray.fromhex("FEFD09")
assert self.query_client.challenge == 570350778
def test_query(self):
self.query_client.connection.receive(
bytearray.fromhex(
"00000000000000000000000000000000686f73746e616d650041204d696e656372616674205365727665720067616d6574797"
"06500534d500067616d655f6964004d494e4543524146540076657273696f6e00312e3800706c7567696e7300006d61700077"
"6f726c64006e756d706c61796572730033006d6178706c617965727300323000686f7374706f727400323535363500686f737"
"46970003139322e3136382e35362e31000001706c617965725f000044696e6e6572626f6e6500446a696e6e69626f6e650053"
"746576650000"
)
)
response = async_decorator(self.query_client.read_query)()
conn_bytes = self.query_client.connection.flush()
assert conn_bytes[:3] == bytearray.fromhex("FEFD00")
assert conn_bytes[7:] == bytearray.fromhex("0000000000000000")
assert response.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",
}
assert response.players.list == ["Dinnerbone", "Djinnibone", "Steve"]
================================================
FILE: tests/protocol/test_timeout.py
================================================
import asyncio
import typing
from asyncio.exceptions import TimeoutError as AsyncioTimeoutError
from unittest.mock import patch
import pytest
from mcstatus._net.address import Address
from mcstatus._protocol.connection import TCPAsyncSocketConnection
class FakeAsyncStream(asyncio.StreamReader):
async def read(self, *args, **kwargs) -> typing.NoReturn:
await asyncio.sleep(2)
raise NotImplementedError("tests are designed to timeout before reaching this line")
async def fake_asyncio_asyncio_open_connection(hostname: str, port: int):
return FakeAsyncStream(), None
class TestAsyncSocketConnection:
@pytest.mark.asyncio
async def test_tcp_socket_read(self):
with patch("asyncio.open_connection", fake_asyncio_asyncio_open_connection):
async with TCPAsyncSocketConnection(Address("dummy_address", 1234), timeout=0.01) as tcp_async_socket:
with pytest.raises(AsyncioTimeoutError):
await tcp_async_socket.read(10)
================================================
FILE: tests/responses/__init__.py
================================================
from __future__ import annotations
import abc
from typing import Any, ClassVar, TYPE_CHECKING, TypeVar, cast
import pytest
if TYPE_CHECKING:
from mcstatus.responses import BaseStatusResponse
__all__ = ["BaseResponseTest"]
_T = TypeVar("_T", bound="type[BaseResponseTest]")
class BaseResponseTest(abc.ABC):
EXPECTED_VALUES: ClassVar[list[tuple[str, Any]] | None] = None
EXPECTED_TYPES: ClassVar[list[tuple[str, type]] | None] = None
ATTRIBUTES_IN: ClassVar[list[str] | None] = None
# if we don't specify item in raw answer, target field will be None
# a first element is a list with fields to remove, and attribute that
# must be None. a dict is a raw answer to pass into `build` method
OPTIONAL_FIELDS: ClassVar[tuple[list[tuple[str, str]], dict[str, Any]] | None] = None
def _validate(self) -> None:
"""Perform checks to validate the class."""
if self.EXPECTED_TYPES is not None and self.EXPECTED_VALUES is not None:
expected_values_keys = list(dict(self.EXPECTED_VALUES).keys())
for key in dict(self.EXPECTED_TYPES):
if key in expected_values_keys:
raise ValueError("You can't test the type of attribute, if already testing its value.")
if self.ATTRIBUTES_IN is not None and (self.EXPECTED_VALUES is not None or self.EXPECTED_TYPES is not None):
if self.EXPECTED_VALUES and self.EXPECTED_TYPES:
to_dict = self.EXPECTED_VALUES.copy()
to_dict.extend(self.EXPECTED_TYPES)
already_checked_attributes = dict(to_dict).keys()
else:
to_dict = cast("list[tuple[str, type]]", self.EXPECTED_VALUES or self.EXPECTED_TYPES)
already_checked_attributes = dict(to_dict).keys()
for attribute_name in self.ATTRIBUTES_IN:
if attribute_name in already_checked_attributes:
raise ValueError("You can't test the type availability, if already testing its value/type.")
@abc.abstractmethod
@pytest.fixture(scope="class")
def build(self) -> BaseStatusResponse: ...
# implementations for tests
def test_values_of_attributes(self, build: BaseStatusResponse, field: str, value: Any) -> None:
assert getattr(build, field) == value
def test_types_of_attributes(self, build: BaseStatusResponse, field: str, type_: type) -> None:
assert isinstance(getattr(build, field), type_)
def test_attribute_in(self, build: BaseStatusResponse, field: str) -> None:
assert hasattr(build, field)
def test_optional_field_turns_into_none(self, build: BaseStatusResponse, to_remove: str, attribute_name: str) -> None:
raw = cast("tuple", self.OPTIONAL_FIELDS)[1]
del raw[to_remove]
assert getattr(type(build).build(raw), attribute_name) is None
def _dependency_table(self) -> dict[str, bool]:
# a key in the dict must be a name of a test implementation.
# and a value of the dict is a bool. if it's false - we
# "delete" a test from the class.
return {
"test_values_of_attributes": self.EXPECTED_VALUES is not None,
"test_types_of_attributes": self.EXPECTED_TYPES is not None,
"test_attribute_in": self.ATTRIBUTES_IN is not None,
"test_optional_field_turns_into_none": self.OPTIONAL_FIELDS is not None,
}
def _marks_table(self) -> dict[str, tuple[str, tuple[Any, ...]]]:
# hooks in conftest.py parses this table
# a key in the dict must be a name of a test implementation.
# and a value of the dict is a tuple, where first element is
# a name of mark to apply to the test, and second element is
# positional arguments, which passed to the mark
return {
"test_values_of_attributes": ("parametrize", ("field,value", self.EXPECTED_VALUES)),
"test_types_of_attributes": ("parametrize", ("field,type_", self.EXPECTED_TYPES)),
"test_attribute_in": ("parametrize", ("field", self.ATTRIBUTES_IN)),
"test_optional_field_turns_into_none": (
"parametrize",
("to_remove,attribute_name", self.OPTIONAL_FIELDS[0] if self.OPTIONAL_FIELDS is not None else ()),
),
}
@staticmethod
def construct(class_: _T) -> _T:
instance: BaseResponseTest = class_() # pyright: ignore[reportAssignmentType]
instance._validate()
for implementation_name, meet_dependencies in instance._dependency_table().items():
if not meet_dependencies:
# delattr works only with initialized classes,
# hopefully overwriting with None doesn't have this limitation
setattr(class_, implementation_name, None)
return class_
================================================
FILE: tests/responses/conftest.py
================================================
from __future__ import annotations
import typing
import pytest
from tests.responses import BaseResponseTest
if typing.TYPE_CHECKING:
from _pytest.python import Function, Metafunc
def pytest_generate_tests(metafunc: Metafunc) -> None:
if metafunc.cls is not None and issubclass(typing.cast("type", metafunc.cls), BaseResponseTest):
instance = typing.cast("type", metafunc.cls)()
if metafunc.definition.name not in instance._marks_table():
return
marker_name, args = instance._marks_table()[metafunc.definition.name]
if marker_name != "parametrize":
return # other markers will be handled in `pytest_collection_modifyitems`
metafunc.parametrize(*args)
def pytest_collection_modifyitems(items: list[Function]) -> None:
for item in items:
if isinstance(item.instance, BaseResponseTest):
if item.obj.__name__ not in item.instance._marks_table():
continue
marker_name, args = item.instance._marks_table()[item.obj.__name__]
if marker_name == "parametrize":
continue
item.add_marker(getattr(pytest.mark, marker_name)(*args))
================================================
FILE: tests/responses/test_base.py
================================================
import pytest
from mcstatus.responses import BaseStatusResponse
class TestMCStatusResponse:
def test_raises_not_implemented_error_on_build(self):
with pytest.raises(NotImplementedError):
BaseStatusResponse.build({"foo": "bar"}) # pyright: ignore[reportAbstractUsage]
================================================
FILE: tests/responses/test_bedrock.py
================================================
import typing as t
import pytest
from mcstatus.motd import Motd
from mcstatus.responses import BedrockStatusPlayers, BedrockStatusResponse, BedrockStatusVersion
from tests.helpers import patch_project_version
from tests.responses import BaseResponseTest
@pytest.fixture(scope="module")
def build():
return BedrockStatusResponse.build(
[
"MCPE",
"§r§4G§r§6a§r§ey§r§2B§r§1o§r§9w§r§ds§r§4e§r§6r",
"422",
"1.18.100500",
"1",
"69",
"3767071975391053022",
"map name here",
"Default",
"1",
"19132",
"-1",
"3",
],
123.0,
)
@BaseResponseTest.construct
class TestBedrockStatusResponse(BaseResponseTest):
EXPECTED_VALUES: t.ClassVar = [
("motd", Motd.parse("§r§4G§r§6a§r§ey§r§2B§r§1o§r§9w§r§ds§r§4e§r§6r", bedrock=True)),
("latency", 123.0),
("map_name", "map name here"),
("gamemode", "Default"),
]
EXPECTED_TYPES: t.ClassVar = [
("players", BedrockStatusPlayers),
("version", BedrockStatusVersion),
]
@pytest.fixture(scope="class")
def build(self, build):
return build
@pytest.mark.parametrize(("field", "pop_index"), [("map_name", 7), ("gamemode", 7), ("gamemode", 8)])
def test_optional_parameters_is_none(self, field, pop_index):
parameters = [
"MCPE",
"§r§4G§r§6a§r§ey§r§2B§r§1o§r§9w§r§ds§r§4e§r§6r",
"422",
"1.18.100500",
"1",
"69",
"3767071975391053022",
"map name here",
"Default",
]
parameters.pop(pop_index)
# remove all variables after `pop_index`
if len(parameters) - 1 == pop_index:
parameters.pop(pop_index)
build = BedrockStatusResponse.build(parameters, 123.0)
assert getattr(build, field) is None
def test_as_dict(self, build: BedrockStatusResponse):
assert build.as_dict() == {
"gamemode": "Default",
"latency": 123.0,
"map_name": "map name here",
"motd": "§4G§6a§ey§2B§1o§9w§ds§4e§6r",
"players": {"max": 69, "online": 1},
"version": {"brand": "MCPE", "name": "1.18.100500", "protocol": 422},
}
def test_description_alias(self, build: BedrockStatusResponse):
assert build.description == "§r§4G§r§6a§r§ey§r§2B§r§1o§r§9w§r§ds§r§4e§r§6r"
@BaseResponseTest.construct
class TestBedrockStatusPlayers(BaseResponseTest):
EXPECTED_VALUES: t.ClassVar = [("online", 1), ("max", 69)]
@pytest.fixture(scope="class")
def build(self, build):
return build.players
@BaseResponseTest.construct
class TestBedrockStatusVersion(BaseResponseTest):
EXPECTED_VALUES: t.ClassVar = [("name", "1.18.100500"), ("protocol", 422), ("brand", "MCPE")]
@pytest.fixture(scope="class")
def build(self, build):
return build.version
def test_deprecated_version_alias(self, build: BedrockStatusVersion):
with (
patch_project_version("0.0.0"),
pytest.deprecated_call(
match=(
r"^BedrockStatusVersion\.version is deprecated and scheduled for removal in 13\.0\.0, "
r"use name instead\.$"
),
),
):
assert build.version == build.name
================================================
FILE: tests/responses/test_forge_data.py
================================================
import typing as t
import pytest
from mcstatus.responses import JavaStatusResponse
from mcstatus.responses._raw import RawForgeData, RawJavaResponse
from mcstatus.responses.forge import ForgeData, ForgeDataChannel, ForgeDataMod
from tests.responses import BaseResponseTest
JAVA_RAW_RESPONSE: RawJavaResponse = {
"players": {"max": 20, "online": 0},
"version": {"name": "1.8-pre1", "protocol": 44},
"description": "A Minecraft Server",
"enforcesSecureChat": True,
"favicon": "data:image/png;base64,foo",
}
@BaseResponseTest.construct
class TestForgeDataV1(BaseResponseTest):
RAW: t.ClassVar = {
"type": "FML",
"modList": [
{"modid": "minecraft", "version": "1.12.2"},
{"modid": "mcp", "version": "9.42"},
{"modid": "FML", "version": "8.0.99.99"},
{"modid": "forge", "version": "14.23.5.2859"},
],
"channels": [
{"res": "fml:handshake", "version": "1.2.3.4", "required": True},
],
}
EXPECTED_VALUES: t.ClassVar = [
("fml_network_version", 1),
("channels", [ForgeDataChannel(name="fml:handshake", version="1.2.3.4", required=True)]),
(
"mods",
[
ForgeDataMod(name="minecraft", marker="1.12.2"),
ForgeDataMod(name="mcp", marker="9.42"),
ForgeDataMod(name="FML", marker="8.0.99.99"),
ForgeDataMod(name="forge", marker="14.23.5.2859"),
],
),
("truncated", False),
]
@pytest.fixture(scope="class")
def build(self) -> ForgeData:
return ForgeData.build(self.RAW) # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict
@BaseResponseTest.construct
class TestForgeDataV2(BaseResponseTest):
RAW: t.ClassVar = {
"fmlNetworkVersion": 2,
"channels": [
{"res": "fml:handshake", "version": "1.2.3.4", "required": True},
],
"mods": [
{"modId": "forge", "modmarker": "ANY"},
{"modId": "fusion", "modmarker": ""},
],
}
EXPECTED_VALUES: t.ClassVar = [
("fml_network_version", 2),
("channels", [ForgeDataChannel(name="fml:handshake", version="1.2.3.4", required=True)]),
("mods", [ForgeDataMod(name="forge", marker="ANY"), ForgeDataMod(name="fusion", marker="")]),
("truncated", False),
]
@pytest.fixture(scope="class")
def build(self) -> ForgeData:
return ForgeData.build(self.RAW) # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict
@BaseResponseTest.construct
class TestForgeDataV3(BaseResponseTest):
RAW: t.ClassVar = {
"channels": [],
"mods": [],
"truncated": False,
"fmlNetworkVersion": 3,
"d": bytes.fromhex(
"5e0000e0a084e390a4e78d8be39996e2b98ce1a698ccbae2b8b1e681a4e492b8e2a191e29ba7e6b2aee5a"
"999e3a8b9e789a5e0b088e384b5e0a69ae28280e6b2aee5a999e3a8b9e789a5e0b088e384b5e0a69ae581"
"80e6b380e5b29be38ab3e48483e38a9ce580b1e2ad8be79ca6e6b9abe1b29be392bae69daee68886e482b"
"8e2a081dcb0e2b68ee5b49ae1a281e384ae02"
).decode("utf8"),
}
EXPECTED_VALUES: t.ClassVar = [
("fml_network_version", 3),
(
"channels",
[
ForgeDataChannel(name="minecraft:unregister", version="FML3", required=True),
ForgeDataChannel(name="minecraft:register", version="FML3", required=True),
ForgeDataChannel(name="forge:tier_sorting", version="1.0", required=False),
ForgeDataChannel(name="forge:split", version="1.1", required=True),
],
),
(
"mods",
[
ForgeDataMod(name="minecraft", marker="1.20.1"),
ForgeDataMod(name="forge", marker="ANY"),
],
),
("truncated", False),
]
@pytest.fixture(scope="class")
def build(self) -> ForgeData:
return ForgeData.build(self.RAW) # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict
class TestForgeDataMod:
def test_build_with_empty_input(self):
with pytest.raises(KeyError, match=r"^'Mod version in Forge mod data must be provided\. Mod info: {}'$"):
ForgeDataMod.build({})
def test_build_without_mod_id(self):
with pytest.raises(
KeyError, match=r"^\"Mod ID in Forge mod data must be provided\. Mod info: {'modmarker': 'foo'}\.\"$"
):
ForgeDataMod.build({"modmarker": "foo"})
@BaseResponseTest.construct
class TestForgeData(BaseResponseTest):
EXPECTED_VALUES: t.ClassVar = [
("fml_network_version", 3),
(
"channels",
[
ForgeDataChannel(
name="cyclopscore:channel_main",
version="1.0.0",
required=True,
),
ForgeDataChannel(
name="supermartijn642configlib:sync_configs",
version="1",
required=False,
),
ForgeDataChannel(
name="alexsmobs:main_channel",
version="1",
required=False,
),
ForgeDataChannel(
name="sophisticatedcore:channel",
version="1",
required=False,
),
ForgeDataChannel(
name="rftoolsbase:rftoolsbase",
version="1.0",
required=True,
),
ForgeDataChannel(
name="irongenerators:irongenerators",
version="1",
required=False,
),
ForgeDataChannel(
name="xaeroworldmap:main",
version="1.0",
required=True,
),
ForgeDataChannel(
name="cookingforblockheads:network",
version="1.0",
required=False,
),
ForgeDataChannel(name="xnet:xnet", version="1.0", required=True),
ForgeDataChannel(
name="placebo:placebo",
version="1.0.0",
required=True,
),
ForgeDataChannel(
name="citadel:main_channel",
version="1",
required=False,
),
ForgeDataChannel(
name="sophisticatedbackpacks:channel",
version="1",
required=False,
),
ForgeDataChannel(
name="buildinggadgets:main",
version="4",
required=False,
),
ForgeDataChannel(
name="mekanismgenerators:mekanismgenerators",
version="10.2.5",
required=False,
),
ForgeDataChannel(
name="waila:networking",
version="1.0.0",
required=True,
),
ForgeDataChannel(
name="shetiphiancore:main_channel",
version="1.0.0",
required=False,
),
ForgeDataChannel(
name="dummmmmmy:dummychannel",
version="1",
required=False,
),
ForgeDataChannel(
name="supplementaries:network",
version="1",
required=False,
),
ForgeDataChannel(
name="refinedstorage:main_channel",
version="1",
required=False,
),
ForgeDataChannel(name="corpse:default", version="1.0.0", required=True),
ForgeDataChannel(
name="ping:ping_channel",
version="PING1",
required=True,
),
ForgeDataChannel(
name="ironfurnaces:ironfurnaces_network",
version="1.0",
required=True,
),
ForgeDataChannel(name="botania:main", version="0", required=False),
ForgeDataChannel(name="curios:main", version="1", required=False), # codespell:ignore curios
ForgeDataChannel(name="patchouli:main", version="1", required=False),
ForgeDataChannel(name="camera:default", version="1.0.0", required=True),
ForgeDataChannel(
name="libnonymous:channel",
version="1.0",
required=True,
),
ForgeDataChannel(
name="elevatorid:main_channel",
version="1",
required=False,
),
ForgeDataChannel(name="worldedit:cui", version="1", required=True),
ForgeDataChannel(name="worldedit:internal", version="1", required=True),
ForgeDataChannel(name="cfm:network", version="1", required=False),
ForgeDataChannel(
name="architectury:network",
version="1",
required=True,
),
ForgeDataChannel(name="trashcans:main", version="1", required=False),
ForgeDataChannel(name="jei:channel", version="1.0.0", required=True),
ForgeDataChannel(name="ae2:main", version="1", required=True),
ForgeDataChannel(
name="mekanism:mekanism",
version="10.2.5",
required=False,
),
ForgeDataChannel(name="bdlib:multiblock", version="2", required=False),
ForgeDataChannel(name="bdlib:misc", version="1", required=False),
ForgeDataChannel(name="create:main", version="1", required=False),
ForgeDataChannel(
name="waystones:network",
version="1.0",
required=False,
),
ForgeDataChannel(name="comforts:main", version="1", required=False),
ForgeDataChannel(
name="naturescompass:naturescompass",
version="1.0",
required=True,
),
ForgeDataChannel(
name="storagenetwork:main_channel",
version="1",
required=False,
),
ForgeDataChannel(name="cofh_core:general", version="1", required=True),
ForgeDataChannel(
name="mcjtylib:mcjtylib",
version="1.0",
required=True,
),
ForgeDataChannel(
name="mininggadgets:main_network_channel",
version="2",
required=False,
),
ForgeDataChannel(
name="crafttweaker:main",
version="1.0.0",
required=False,
),
ForgeDataChannel(name="akashictome:main", version="1", required=False),
ForgeDataChannel(
name="forge:tier_sorting",
version="1.0",
required=False,
),
ForgeDataChannel(name="forge:split", version="1.1", required=True),
ForgeDataChannel(
name="colossalchests:channel_main",
version="1.0.0",
required=True,
),
ForgeDataChannel(name="selene:network", version="1", required=False),
ForgeDataChannel(
name="craftingtweaks:network",
version="1.0",
required=False,
),
ForgeDataChannel(
name="minecraft:unregister",
version="FML3",
required=True,
),
ForgeDataChannel(
name="minecraft:register",
version="FML3",
required=True,
),
ForgeDataChannel(name="titanium:network", version="1.0", required=True),
ForgeDataChannel(
name="easy_villagers:default",
version="1.0.0",
required=True,
),
ForgeDataChannel(name="pipez:default", version="1.0.0", required=True),
ForgeDataChannel(name="mantle:network", version="1", required=False),
ForgeDataChannel(name="quark:main", version="1", required=False),
ForgeDataChannel(
name="xaerominimap:main",
version="1.0",
required=True,
),
ForgeDataChannel(
name="fastbench:channel",
version="4.6.0",
required=True,
),
ForgeDataChannel(name="polymorph:main", version="1", required=False),
ForgeDataChannel(
name="storagedrawers:main_channel",
version="1",
required=False,
),
ForgeDataChannel(
name="enercell:network",
version="0.0.0",
required=False,
),
ForgeDataChannel(name="appleskin:sync", version="1", required=True),
ForgeDataChannel(
name="modularrouters:main_channel",
version="2",
required=False,
),
ForgeDataChannel(
name="the_vault:network",
version="0.26.0",
required=False,
),
ForgeDataChannel(
name="modernui:fluxnetworks",
version="707",
required=False,
),
],
),
(
"mods",
[
ForgeDataMod(name="rsrequestify", marker="2.2.0"),
ForgeDataMod(name="cyclopscore", marker="1.15.1"),
ForgeDataMod(name="auudio", marker="1.0.3"),
ForgeDataMod(name="auxiliaryblocks", marker="1.18.2-0.0.14"),
ForgeDataMod(name="supermartijn642configlib", marker="1.1.6"),
ForgeDataMod(name="alexsmobs", marker="1.18.6"),
ForgeDataMod(name="architects_palette", marker="1.1.2"),
ForgeDataMod(name="cagerium", marker="1.18.2-1.1.0"),
ForgeDataMod(name="mcwwindows", marker="2.0.3"),
ForgeDataMod(
name="sophisticatedcore",
marker="1.18.2-0.5.32.179",
),
ForgeDataMod(name="thermal", marker="1.6.3.28"),
ForgeDataMod(name="rftoolsbase", marker="1.18-3.0.9"),
ForgeDataMod(name="initialinventory", marker="6.0.8"),
ForgeDataMod(name="irongenerators", marker="2.0.1"),
ForgeDataMod(name="xaeroworldmap", marker="1.25.1"),
ForgeDataMod(name="cookingforblockheads", marker="12.0.2"),
ForgeDataMod(
name="controlling",
marker="",
),
ForgeDataMod(name="xnet", marker="1.18-4.0.5"),
ForgeDataMod(name="placebo", marker="6.4.1"),
ForgeDataMod(name="citadel", marker="1.11.3"),
ForgeDataMod(name="powah", marker="3.0.1-beta"),
ForgeDataMod(name="bookshelf", marker="13.2.50"),
ForgeDataMod(name="lootbeams", marker="1.18.1"),
ForgeDataMod(
name="sophisticatedbackpacks",
marker="1.18.2-3.18.35.752",
),
ForgeDataMod(name="twigs", marker="1.1.4-patch1+1.18.2"),
ForgeDataMod(
name="buildinggadgets",
marker="3.13.0-build.5+mc1.18.2}",
),
ForgeDataMod(name="darkutils", marker="10.0.5"),
ForgeDataMod(name="mcwdoors", marker="1.0.6"),
ForgeDataMod(name="waddles", marker="1.18.2-0.8.19"),
ForgeDataMod(name="mekanismgenerators", marker="10.2.5"),
ForgeDataMod(name="balm", marker="3.2.0+0"),
ForgeDataMod(name="waila", marker=""),
ForgeDataMod(name="jeresources", marker="0.14.1.171"),
ForgeDataMod(
name="cloth_config",
marker="",
),
ForgeDataMod(name="shetiphiancore", marker="3.10.10"),
ForgeDataMod(name="dummmmmmy", marker="1.18-1.5.2"),
ForgeDataMod(name="supplementaries", marker="1.18.2-1.5.13"),
ForgeDataMod(name="refinedstorage", marker="1.10.2"),
ForgeDataMod(name="konkrete", marker="1.3.3"),
ForgeDataMod(name="easy_piglins", marker="1.18.2-1.0.0"),
ForgeDataMod(name="corpse", marker="1.18.2-1.0.2"),
ForgeDataMod(name="packmenu", marker=""),
ForgeDataMod(name="mcwbridges", marker="2.0.3"),
ForgeDataMod(name="torchmaster", marker="18.1.0"),
ForgeDataMod(name="compressium", marker="1.4.2-build.9+mc1.18.2"),
ForgeDataMod(name="ping", marker="1.18-1.8.0"),
ForgeDataMod(name="ironfurnaces", marker="3.3.1"),
ForgeDataMod(name="mcwtrpdoors", marker="1.0.6"),
ForgeDataMod(name="mcwfences", marker="1.0.5"),
ForgeDataMod(name="supermartijn642corelib", marker="1.0.19"),
ForgeDataMod(name="simplylight", marker="1.18.2-1.4.2-build.31"),
ForgeDataMod(name="botania", marker="1.18.2-434"),
ForgeDataMod(name="highlighter", marker="ANY"),
ForgeDataMod(name="spark", marker=""),
ForgeDataMod(name="curios", marker="1.18.2-5.0.7.1"), # codespell:ignore curios
ForgeDataMod(name="patchouli", marker="1.18.2-71.1"),
ForgeDataMod(name="camera", marker="1.18.2-1.0.4"),
ForgeDataMod(name="blockcarpentry", marker="1.18-0.3.0"),
ForgeDataMod(name="thermal_foundation", marker="1.6.3.28"),
ForgeDataMod(name="thermal_expansion", marker="1.6.3.13"),
ForgeDataMod(name="libnonymous", marker="2.1.0"),
ForgeDataMod(name="elevatorid", marker="1.18.2-1.8.4"),
ForgeDataMod(name="runelic", marker="11.0.1"),
ForgeDataMod(
name="worldedit",
marker="",
),
ForgeDataMod(name="cfm", marker="7.0.0-pre29"),
ForgeDataMod(name="architectury", marker="4.9.84"),
ForgeDataMod(name="weirdinggadget", marker="2.2.11"),
ForgeDataMod(name="mcwfurnitures", marker="3.0.0"),
ForgeDataMod(name="trashcans", marker="1.0.15"),
ForgeDataMod(name="mcwlights", marker="1.0.3"),
ForgeDataMod(name="cucumber", marker="5.1.2"),
ForgeDataMod(name="snad", marker="1.18.2-1.22.04.15a"),
ForgeDataMod(name="jei", marker="9.7.0.209"),
ForgeDataMod(name="ae2", marker="11.1.4"),
ForgeDataMod(name="mekanism", marker="10.2.5"),
ForgeDataMod(name="bdlib", marker="1.19.3.7"),
ForgeDataMod(name="create", marker="0.5.0.d"),
ForgeDataMod(name="waystones", marker="10.1.0"),
ForgeDataMod(name="clumps", marker="8.0.0+10"),
ForgeDataMod(name="shutupexperimentalsettings", marker="1.0.5"),
ForgeDataMod(name="comforts", marker="1.18.2-5.0.0.4"),
ForgeDataMod(name="naturescompass", marker="1.18.2-1.9.7-forge"),
ForgeDataMod(name="storagenetwork", marker="1.18.2-1.6.1"),
ForgeDataMod(name="framedcompactdrawers", marker="1.18-4.1.0"),
ForgeDataMod(name="decorative_blocks", marker="2.1.0"),
ForgeDataMod(name="botanypots", marker="8.0.12"),
ForgeDataMod(name="ftbbackups2", marker="1.0.17"),
ForgeDataMod(name="cofh_core", marker="1.6.4.21"),
ForgeDataMod(name="mcjtylib", marker="1.18-6.0.15"),
ForgeDataMod(name="ispawner", marker="1.0"),
ForgeDataMod(name="everycomp", marker="1.18.2-1.5.7"),
ForgeDataMod(name="jeitweaker", marker="3.0.0.8"),
ForgeDataMod(name="terralith", marker="0.0NONE"),
ForgeDataMod(name="mininggadgets", marker="1.11.0"),
ForgeDataMod(name="crafttweaker", marker="9.1.197"),
ForgeDataMod(name="akashictome", marker="1.5-20"),
ForgeDataMod(name="forge", marker="ANY"),
ForgeDataMod(name="colossalchests", marker="1.8.3"),
ForgeDataMod(name="selene", marker="1.18.2-1.17.9"),
ForgeDataMod(name="drippyloadingscreen", marker="1.6.4"),
ForgeDataMod(
name="craftingtweaks",
marker="",
),
ForgeDataMod(name="minecraft", marker="1.18.2"),
ForgeDataMod(name="terrablender", marker="1.18.2-1.1.0.102"),
ForgeDataMod(
name="sophisticatedbackpacksvh",
marker="1.18.2-1.0.4.12",
),
ForgeDataMod(name="mousetweaks", marker="ANY"),
ForgeDataMod(name="titanium", marker="3.5.6"),
ForgeDataMod(name="jade", marker=""),
ForgeDataMod(name="createtweaker", marker="2.0.0.17"),
ForgeDataMod(name="easy_villagers", marker="1.18.2-1.0.10"),
ForgeDataMod(name="pipez", marker="1.18.2-1.1.5"),
ForgeDataMod(name="iceberg", marker="ANY"),
ForgeDataMod(name="flywheel", marker=""),
ForgeDataMod(name="mantle", marker="1.9.27"),
ForgeDataMod(name="ecologics", marker="1.7.3"),
ForgeDataMod(name="quark", marker="3.2-358"),
ForgeDataMod(name="xaerominimap", marker="22.11.1"),
ForgeDataMod(name="pigpen", marker="8.0.1"),
ForgeDataMod(name="fastbench", marker="6.0.2"),
ForgeDataMod(name="polymorph", marker="1.18.2-0.44"),
ForgeDataMod(name="autoreglib", marker="1.7-53"),
ForgeDataMod(name="storagedrawers", marker="10.2.1"),
ForgeDataMod(name="fluxnetworks", marker="7.0.7.8"),
ForgeDataMod(name="neoncraft2", marker="2.2"),
ForgeDataMod(name="enercell", marker="0.0NONE"),
ForgeDataMod(name="appleskin", marker="2.4.0+mc1.18"),
ForgeDataMod(
name="ferritecore",
marker="",
),
ForgeDataMod(name="modularrouters", marker="9.1.1-93"),
ForgeDataMod(name="refinedstorageaddons", marker="0.8.2"),
ForgeDataMod(name="openloader", marker="12.0.1"),
ForgeDataMod(name="the_vault", marker="1.18.2-2.0.10.869"),
],
),
("truncated", False),
]
@pytest.fixture(scope="class")
def build(self) -> ForgeData:
value = ForgeData.build(
RawForgeData(
{
"channels": [],
"d": (
bytes.fromhex(
"e0ba8b0000c484e4a0b0e18e9be19997e2baaee1b399e392bae7a5a6e6908ae4a2b8c5b1e380a3e2b1a1e1a39ee39eb6e"
"78db0e5bb86e19789e0a0b3e18ba3e49aa6e0b18be38686e685a8e5b39ce38695e6abbbe19896e2b78de0b181e1a097e3"
"80ae02d098e2aeabe19987e2b7ade0b181e1a097e38caee1b880e59684e4af83e19b86e4b0ade1b99ce398b1e68dafe69"
"b96e490b5e0a5b1e68e83e29985e0b08be1a097e384aed1a8e4b1a0ceabe29997e2b6aee1b298e392bae6b9aae6a1ace0"
"b388e78dbbe199a6e0b3ade1a99bcab1e2b8b1e5b1a2e38398e4ae98e39ba7e6aface1af98e38cb7e69da9cba6c384e4a"
"090e49890e0b2ade5b39ee39eb6e78da2e6888ce492b8e78781e48da2e2b6a1e1a998e2beb7e6a1a3e5b382e196b9e0ad"
"a3cc90e48080e1a184e386b9e6a5a8e4aba8e5868de7ae9be19c85e2b68ce1b499e38abae38485e6899ce4a2b8e48081e"
"198b0e2b3ace5b299e3aab4e0b1ade5b1a2e68384e185b1e18b93e29786e0ae8c18e6b48ae6bb86e2979de28db3e79bb6"
"e2b9aee0b281e1a097e38caee28884e3b78ce48e83e39a96e2ba8ee5a39ae3a8b0e691a5e5bb86e19789e0a28be18ba3e"
"49c86e4b28be1a096e394aee6999ce3a388e3a689e78e93e0b1a0e1a19ae39cb7e6b1a5e6888200e280b8e59a87e2b98c"
"e1a19bd0b6e2b8b1e5b1ace3a38ce48691e380a3e4b981e5b499e39eb7e78dace48b84e1978de0a193e18ba3e29c86e0b"
"38be1a097e3a4aee69096e58699e7adbbe39b86e2b18ee5b398c6b2e2b8b1c9a0e48080e78d88e49a96e2b4aee5ac98e3"
"9cb4e695b6e6a39ce4a6bde2af8be68da0e49885e0b88bdc81e789a9e5b39ee1969de2adb3e19ca6e6ba8ce5b29bcab9e"
"2b8b2e5b1a0e3a384e18d88e69bb7e2b3ade5ae99e3a4b2e791a1e6939ed78dc688e580a0e2bc81e1a598e39eb9e6bdb7"
"e5a3a4e39691cc8be181a7e49786e0b58ce1a297e6b484e58b82e0b6b9e78688e18c8240e5a385e39eb7e6a5abe4bb9ce"
"3b699e18e93e79b86e6b1ade5a89ae382b2e78da4e6888ce3a388e78681e78ca2e2b780e5b499e39ebbe6adb2e68886e4"
"82b8e0a081e382b0e4b7ace1b49be39eb9e6b1ace5b392e0a69de480a0e59ba7e4ba8ce0b182e1a297e2b4b8e5b1a8e3a"
"380e286a9e69e80e0b2ade4839de19c98c4b0e0b884e38780e1ac8be29996e2b7ace0b681e1a897e384aee6808ed6b1e2"
"ac9be798a6e282ade0ae8ce19c98c4b0e0b884e2968ce0aea3e59986e4b68ce0b181e1a297e2b8b1e1a1a6d6b4e78d8be"
"397b6e2b48ce1ae98e38ab7c5ac62e19080e7ae80e19db6e4b48ce0b382e1a097e384aee4919ae58695cc8be28290e6b7"
"ace5ab9be390b9e6b1a5e0bb8ce4b384e185b1e58ba3d886c980e39eb6e791afe4ab84e39685e38e9be68c90d8a5e4ae8"
"ec498e78c96e6839ee296a1e28e9be39a97e0b0ace1a59de384b2e68da1e68396e0b685e1ad9be184a7e29786e0b88ce1"
"a497e38cade6899ce3a3a0e2a699e78ba3e49aa6e4878ce390b1e6b9a1e4ab9cd6b1c688e58080e6ba80e5a99de3a6b3e"
"38493e6899ce582b8c5a9e49897e0b1aee4b19ae1a295e384aee5b1b0e0a388e181b8e19d96e0b68de1a999e38eb7e685"
"a7e4bb88e58695e48e9be68cb1e698a5e0ae8ce19a98e795a2e5a392e3a691e5a6a9e39b92e498ace0b18be19c9ce7b4b"
"2e5a888e29685e0adb3cd80e28080e5a482e3a4b0e795abe58ba8e4b6b1e0a0b3e68c83e49885e0b58bd080e68dade4a3"
"aee3b6bde1ae93e18197d786e0ae8c1be79c87e4a382e38691e1acabe18397e29786e0b88ce1a497e380ade7819ce492b"
"8e18789e584a0e6b2ade1a19ae392b7e6b5b3e4ab8ee196b9e0ae93e79d86e6b98de4869ce1a098e388aee6a99ce39188"
"e5acabe69896e6b4ade5ad9ce38ab3e695aee48ba4e3b791e1ae93e181a7e49886e0b28be1aa9700e49088e38685e3ada"
"be68cb0e49985e4b08be1a095d483e48baee386a5e58c8be59ba0e6ba8ce1af9de396b9e6b9a9e0ab8ee3a384e78681e1"
"8c82e68080e5aa82e3a4b2e78da5e6ab9ee0b789e1acabc2a7e29786e0b48ce1a297e384aee689aee38084e68c98e49bb"
"6e6b48ee5a397e39cb7e6a5a6d38ee4b0b8e2ad83e19d86e0b88de5a99ae39cb0e6bda3e4aba4e4b09de0a5b1e68c83d8"
"a5e48c8ce382b6e6b9a9e49abed6a1e78db3e49996e282ade0ae8ce19c9830e18884e59690e6adabe59b96e2b6ade1b99"
"be1a285e384aee5a9b0e3a384e786a9e48ca2e2b281e5ad9de3b2b6e6a1a3e5b382e196b9e0ada3cc90e68180e5b383e3"
"a0bae6b1b0e5ab8ae3a695e0aea3e19ca6e6b2ade48d9ce19c98e3a0b1e6919ce492b4e2a5b1e18ba3e699a6e5ae81e3a"
"8b2e6bdb7e59ba4e49085e18081e283a0e4b2aee1a999e38ab7e78da4e5bba8d789e2acbbe181a6e29786e0b08ce1a497"
"e6b48ce58b82e7b6b9e48c9ae69896e2b78de5ac99e1a28000e59890e3a6bde18d9be49997e2b2aee0b181e1a697e38ca"
"ee1a080d694e4ae9bd7b7e6b4aee5ac99e39cb4e0b1b3e5b1a2e68384e185b1e18b93d786e0ae8cc498e68c86e6939ee4"
"b781e68cabe68c90d8a5e0ae8ee19a99e2b8b1e5b1a0e1b388e2aca0e199a6e0baace5b49be1a282e380aee6819cd0844"
"0e39897e2b5ace1a59be3aab7e0a880e49b9ae0a79de4ae93e79986e6b2acc59ce19c99e2b8b066e580ace18dbbd8b7e2"
"b6ade1b398e38abad9b2e781a2e492b8c5b1e38083e6b1a1e1ad9be3a4b8e78da5e58ba6e39795e0a2b3e48ba3e49786e"
"0ad8ce3aab1e6b1a9e5b388e2b3a4e1ada9e68c96d8a5e0ae8ec499e78084e5b392e2a69de78688cc92e296a7e0ae8ce1"
"9c9ce0b0b0e58ba0e1b6b9e1abbbe19a86e4b78ce1a59bcab6e4a590e0ba9cd385e68090e29a90e4b7aee5a69be3a4bae"
"685aee4ab86e1978de78698e68cb2d8a5e1a985e39eb9e699aee693aad6b9e2ac9be79cb6e2b78be5b499e39ebbe6adb2"
"e68886e482b809e582b0e6b1ade1b49de3a0b9e6bda4e6939ee1978de78688e68c82db85e48980e386b6e699b7e5b38ae"
"1968de2ae9be68c90e49885e0b58be0ac80e795b3e4aba0e39789e18c8be19d87e4b58de0b69be1a49ae6bda3e4aba4e2"
"96b1e38c93e68c90e49885e4b18b1ce78c8be5ab92e38781e68f8be79a96e0b48ce4959de19c98e3a0b1e6919ce492b4e"
"285b1e28ba3e496a6e5b598e398b4e2b9a4e689a6e1b088e7ac90e19d86e2b78ce1a19ae1a285e384aee5b1b0e39388e1"
"a6a1e48d83e2b6a0e1a998c2b730e19880e296a0e48cbbe19b86e0b3ade5b49ae3a4b2e48483e38a9ce19085ce98e2989"
"7e4b5aee48680e3aab1e6a5b2e69b9ee490b9e0a5b1e68e83e29985e0b58be1a097e39caee6899ce39090e4ac8be19ba6"
"d8a0e48280e3a084e791a1e58386e596bde4ada3e182b6e29786e0b88ce1a497e39cade5b1a2e18384e0ada8e69a96e28"
"0ad0ccc81e685a3e4ab9ad789e0a1a3e18ba3e49c86e4b28be1a296e380aee6a19ce1809ce38cabe59896e0b68ee4859d"
"e19c98e2b8b0c9a0e3a080e68c90e39bb6e6b5ace1a198e3a0b9e6b9a5e693a8e2a7a5e78688cc92d6a7e4ae8ce19c993"
"0e6a0a4e196a1e6ae93e49896e4afade5af99e39cbae685a4e58ba8e3a6bde0a183e68ba3e69786e0ae8ce1b099e18480"
"e583a8e4a695e0adabe79b86e0b2abe5b09ee39cb0e6a5b3e5b39ee490a1e385b1e38ba3e29786e0b38cd681e6a5ace5b"
"384e3a6bde6af8be59bb6e2b9aee0b281e1a297e380aee4988ed6a1e78db3e49996e281ade0ae8cc298e0a882e5a38ae5"
"a695e28c8be29bb7e0b4aee48c99e19c98e3a0b1e6919ce492b4e485b1e48ba3e28686e5a19be39cb4e68d9fe48b90e3a"
"6b9e68cabe1809606c780e3aab9e695aee58b98e1a68de0a688cba3e29786e4858ce3ae84e789afe4a398e18695e28d8b"
"e380b7e2baace4819ac298e6a488e6a39ce4a695e0adb3e19b86e298a0e48280e38681e6b5a6e6b896e482b8c5b1cb93e"
"2b98ee4b299ce9ce695aee6bba8e4a6bde0ad9bcc9040e1a183e386b9e6a5a8e4aba8e5868de18eabe69e97e49a80e0b9"
"8be1b097dcb4e4ab9ce5b791e18dbbe19ab7e298a000e3ae87e6a5a5e4a3a4e3a6a5e3acbbe49896e2b3ace1b499e1a48"
"3e388aee6899cc384e6a1a8e798b6e2b38ee1b29de392b7e795b4e4aba4e1978de78698e68c82e49885c980e3a4bae78d"
"a1e49b90e3a685e38e9be68c90e49885e4b18bc89ae685ade5b392e4908501e58290e6b1ade5ac9de38eb4e791a8e0aba"
"6e3a384e78681ccb2e68480e5b598e3aab1e689ade6938ae59095e0a5b1e28ba306e1b381e382b7e189a4e5b1a2e68384"
"e185b1e18b93e49786e0b28ce1a097e2b8b4e6a9a2e0a684e58098e19996e284ade4ae8ee19c9be2b8b0e681a4e1b3a4e"
"48c98e69896e2b78de5ac99e1a282e380aee6819ce0a084e0a098e29996e28386e0b18ce1a297e390aee5a888e29685e0"
"adb3e18c9040e5ad82e396b2e6b9a1e69b92e1a6b5c688e28ba3e29786e4888de38ab6e685abe58b9ce3978de0a0b3e68"
"c83e49985e0b58bc880e68885e5a388e0a6a5e0a183e18ba3e49ca6e0b38be1ae97e6b48ae5a3aae29791e68c93e39bb6"
"e2b5ace0b280c880e6a5ade49ba6e49085e18081e381a0e2b98ce1a199e38abae38087e6a99ce482b8e285b1e58186e2b"
"0ade5ae9ae1a280c880e6b892e69685e28e9be69bb7e6b2ade4869ce1a098e384aee6819ce3a09ce28cabe79db7e6b98d"
"e4839ae19c9830e0b080e3868ce6aeabe39c86c48ee0ae8ee19c98e2acb0e681a2e6a080e48e98e49d96e0baaee1a59ce"
"3a0bce789a5e5ab92e3a695e0aea3e39b86e0b2aee5b49de39cb4e78da7e6888ae482b8e2a5b123e6b1a1e1ad9be39eb3"
"e791b2e1b3a6e3a384e48689e28ba3e296a6e0ae8de19c98e2b8b0e0a1a8d6b4e78d8be18096e48086ce80e382b7e795b"
"4e4aba4e0b78de6adbbe19c86e6b9ace4929ce19c98e3a0b1e6919ce492b4e4a5b1e78ba3e496a6e1af99e38eb9e0b9a5"
"e48b9ce59791e2ae93e39cb6e2b7ace5b09be3a6b0cdb3e5b1a2d380e78090e49cb0e4b7aee5a19ce38ab3e695aee6bba"
"8e4a6bde68d9be68c90d8a5e0ae8ee19a99e2b8b1e5b1ace38384e0ada8e69a96e6afade5a898e39cb0e695aecb98c384"
"e28080e299a1e2b0aee1a59be386b2e6b5afe48ba0e5868de18ca3e79897e4b2aee1b39ce1a285e384aee5a9b0e3a390e"
"78689cc82c8a0e5a599e39eb1e685b2e58ba8e19799e18bbbe79b86e6b1ade5b39ae1a482e384aee6819ce2a080e7ac90"
"e19d86e2b78ce5b09ee3a8b7d9b3e5b1b0e3a380e18689e38083e0b381e1a29de382b1e6ada3e683aae4a78de0a0b1cba"
"3e29786e0b78cd281e6bda3e5838ce0b5bde18dbbd997e498a1e0b68be1a897e388aee0b9a2e1969ce2adb3e19ca6e2b6"
"8ce4b180c480e6b488e59386e69791e4ada3e398a6e498a1e0b18be19a9ce2b8b6e5b1a0e59384e6a181e298b6e2ba8de"
"5ac9ee384b4e38483e6819c04e4a180dcb6e6b0aee5ae9de3a4b2e38483e6819ce29080e38ca8e29997e6bcaee5af98e3"
"a0b6e3848ce6899ce3a3a0e6a691e68c92e49aa5e0b78bd480e695aae6a392e1979de5ac8be29996e683aee0ae8ce19c9"
"8e2b8b070e580a4e18cabe19ca7e2b68ce1b49aceb4e2b8b0e1b1a0e3a4bde188aae58390e4b4ade1a99be38eb7e685a7"
"e4bb88e58695e38e9be68c90e298a5e0ae8ce0a898e685ade5b392e3a5bde28cabe79db7e6b98de59f9ae390b1e6b9a1e"
"4ab9cd6b1c690e480a0e4b1a1e1a19ce3a8b3e79db4e48b8ae196ade3ae93e68e90e498a5e4b18be1ae9ce6b484e58b82"
"e196b9e78688e68c82d885e48280e38285e685abe583a6e0b6a5e7aea3e59b96e2838ce4ae8ce19a9ae380b2e5a888e29"
"685e0adb3cc90e28280e5a681e3a4b7e695a7c886e694b9e281a2e59a97e6b98ce5b397e3a4b7e6a5b4e4bb9ce4908dc5"
"b1e58083e0b9a0e5ac9ce3a8b4e38483e6899ce0a084e1a1b0e49bb6e6b7ade5b39ce398b0e6a1a3e69b8ae4b791e0a0a"
"bcba3e69787e48c8ce390b1e6b9a1e4ab9ce7b6b1e0adaae69a96e282ade0ae8ce19c98c4b0e0b084e1978ce2ada3e59b"
"a6e286ace4ae8ce1b098e388aee6899ae492b8e786b9e78e92e2b780e5b499e39ebbe6adb2e6888200e28298e19ca6e0b"
"88de1b99ce39eb6e691a1e5b392e4b69de18c9be59997e2b78ce0b181e1ac97e390aee1b086e4a68ce38c8be19d86e6b7"
"8de5b499e38abbe6ada1e0bba6e196b8e3aea3e29bb7e6b5aee0b180e1a097d080e5a892e3a6a5e1acabe19ca6e0b38ce"
"4869de19c98e3a0b1e6919ce590a8e18db3e79997e6b4ace5b49ce3a4b2e49884e1a29ad38de18180e79997e6b4ace5b4"
"9ce3a4b2e49884e1a29ad38de68080e59d80e4b98ce1a19ce398b1e6b9a5e4ab88e48789e78688cc92e49787e4ad8ce19"
"c98e2b8b1e5b1a0e48384c691e38680e0b7aee5a89ce3a6b4e6a5b4e48b86e19791e18ca3e39896e0b5ace5a19ce396b1"
"e799b3e1bb90e3a384e48689e28ba3e296a6e0ae8ce19c98e2b8b4e691a2e2b080e7ada8e39d96e0b2aee5b79de382b2e"
"78dabc886e694b9e48092e19d80e2ba8de5ae98e3aab4d5ade5b1a6e3a394e3a6b1e59ba0e6ba8ce1af9de396b9e38483"
"e6819cd084e580a0e49896e0b2ace48d80e3a4b1e685a5e4aba8e5b791e0acabe59ab6e0b98ce0b282e1a097e380aee68"
"99ce0a39ce2a1b0e39896e6bcaee5b697e398b4e685ace4ab8ee4b789e0a1abe18ba3e49c86e4b28be1a296e380aee689"
"9ce1b380e2aca0e199a6e0baace5b49be1a282e380aee6819ce0a08428da97e4b2aee48c9ee19c98e3a0b1e6919ce492b"
"4e0a5b1e58ba3c3a6e1a599e382b3e6b1b5e0aba8e3a384e78681e18c82e68080e5a981e38ab1e695a2e4bba4d08de4a9"
"b215e0b381e5b99be390bbe695a5d398e39098e78c8be49d86e4b2ade0b181e1b297e388aee0b9aee196b8e3aea3e29bb"
"7e2b5aee0b18000e69489e5bb86e3b6b1e4acbbe398b6e282aee4ae8ce19c9bc8b3e6888ad795e5ae93e381b6e49786e4"
"ad8ce1aa99d0b8e48b9ae3a6a5e0a08be28083c680e5a19ee3a4b2e6b5afe5b392e396a5cc8be281b7e49986e4b18be19"
"c98d0b1e48b9ae3a6a5e0a09bcba326c680e392b8e781a7e5b38ae68095c5b1e18ba3e28186e5a682e3a6b0e689b4e5b3"
"8ae2868de380abcba3e49786e4878ce390b1e6b9a1e4ab9ce196b1e786a0e68da2e29885e48280e3a084e6b1afe5abb2e"
"4a6bde48e83e182b6e29786e0b88ce1a497e380ade6a19ce18390e0ada8e69a96e280ad0cd480e795a1e5bba8e19789e6"
"8cbbe29a96e2838ce4ae8ce19a9be38cb5e1b084e5878ce18dbbe79897e0b2ace5b299e3aeb0e789a5e0b3a6e48384e18"
"5b1e18ba3e28686e5a19be39cb4e68d9fe48b90e3a6b9e68cabe1809606cc80e398b3e7a1b5e4ab9ce5b791e18dbbe39a"
"b7e683aee0ae8de19c98e2b8b770e3a0a8e7acabe39ba6e2b98ce1a698e1a4bae38883e6919ce28088e78ca8e29996e2b"
"1aee1ac99ceb6e2b8b0e1b1a0e3a4bde3a8aae59ba0e6ba8ce1af9de396b9e38085e6819ce482b8e18081e18290e0b88c"
"e5ac9ce3a6b2e6a5abe1a39ce3a388e786a1e38c82e6b6a5e0b198e1a297d0b8e78ba6e0b6b9e0a08be18093e485a0e1a"
"599e3a4b9e791a9e49b8ae4a6bde18cabe583a0e0b7ade1b599e382b6e789b2e6ab9ee19791e1ae93e18287e29787e4ae"
"8ce19a98e38cb9e5a898e29685e7adb3d8b5e4b0ade5ae9be398b2e3888100e4a190e38cabe69a96e0b2ade1b399e39eb"
"ae685b2e4ab8ee18685e7aca3e39ba6c2aee0ae8ce19c9c32e5b894e19781e68db3e19bb6e2b28ce1b299e1a283e2b8b2"
"e5b1a0e0a384e28188e59a87e4aface5a19de398bae185b4e5b1a2e68384e185b1e28b93d786e4ae8ce1a098e3a0aee78"
"9ace3a09ce28cabe79db7e6b98dc69ae19c98e398b2e6819cd080e6a2a8e49bb6e4b2ace5ae9ce392bae698bae6ab98e3"
"a7a1e28cabe79db7e6b98de5b39ae1ae81e39cb000"
)
).decode("utf-8"),
"fmlNetworkVersion": 3,
"mods": [],
"truncated": True,
}
)
)
assert value is not None
return value
def test_build_with_empty_input(self):
with pytest.raises(KeyError, match=r"^'Neither `mods` or `modList` keys exist\.'$"):
ForgeData.build({})
@pytest.mark.parametrize("key", ["forgeData", "modinfo"])
def test_java_status_response_forge_data_is_none(key):
# should not raise
JavaStatusResponse.build(
JAVA_RAW_RESPONSE | {key: None}, # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict
)
@pytest.mark.parametrize(
("key", "raw"),
[
("forgeData", TestForgeDataV2.RAW),
("modinfo", TestForgeDataV1.RAW),
],
)
def test_java_status_response_forge_data(key: str, raw: bytes) -> None:
assert JavaStatusResponse.build(
JAVA_RAW_RESPONSE | {key: raw}, # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict
).forge_data == ForgeData.build(raw) # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict
================================================
FILE: tests/responses/test_java.py
================================================
import typing as t
import pytest
from mcstatus.motd import Motd
from mcstatus.responses import JavaStatusPlayer, JavaStatusPlayers, JavaStatusResponse, JavaStatusVersion
from tests.responses import BaseResponseTest
@BaseResponseTest.construct
class TestJavaStatusResponse(BaseResponseTest):
RAW: t.ClassVar = {
"players": {"max": 20, "online": 0},
"version": {"name": "1.8-pre1", "protocol": 44},
"description": "A Minecraft Server",
"enforcesSecureChat": True,
"favicon": "data:image/png;base64,foo",
}
EXPECTED_VALUES: t.ClassVar = [
("players", JavaStatusPlayers(0, 20, None)),
("version", JavaStatusVersion("1.8-pre1", 44)),
("motd", Motd.parse("A Minecraft Server", bedrock=False)),
("latency", 0),
("enforces_secure_chat", True),
("icon", "data:image/png;base64,foo"),
("raw", RAW),
("forge_data", None),
]
OPTIONAL_FIELDS: t.ClassVar = (
[("favicon", "icon"), ("enforcesSecureChat", "enforces_secure_chat")],
{
"players": {"max": 20, "online": 0},
"version": {"name": "1.8-pre1", "protocol": 44},
"description": "A Minecraft Server",
"enforcesSecureChat": True,
"favicon": "data:image/png;base64,foo",
},
)
@pytest.fixture(scope="class")
def build(self) -> JavaStatusResponse:
return JavaStatusResponse.build(self.RAW) # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict
def test_as_dict(self, build: JavaStatusResponse):
assert build.as_dict() == {
"enforces_secure_chat": True,
"forge_data": None,
"icon": "data:image/png;base64,foo",
"latency": 0,
"motd": "A Minecraft Server",
"players": {"max": 20, "online": 0, "sample": None},
"raw": {
"description": "A Minecraft Server",
"enforcesSecureChat": True,
"favicon": "data:image/png;base64,foo",
"players": {"max": 20, "online": 0},
"version": {"name": "1.8-pre1", "protocol": 44},
},
"version": {"name": "1.8-pre1", "protocol": 44},
}
def test_description_alias(self, build: JavaStatusResponse):
assert build.description == "A Minecraft Server"
@BaseResponseTest.construct
class TestJavaStatusPlayers(BaseResponseTest):
EXPECTED_VALUES: t.ClassVar = [
("max", 20),
("online", 0),
(
"sample",
[
JavaStatusPlayer("foo", "0b3717c4-f45d-47c8-b8e2-3d9ff6f93a89"),
JavaStatusPlayer("bar", "61699b2e-d327-4a01-9f1e-0ea8c3f06bc6"),
JavaStatusPlayer("baz", "40e8d003-8872-412d-b09a-4431a5afcbd4"),
],
),
]
OPTIONAL_FIELDS: t.ClassVar = (
[("sample", "sample")],
{
"max": 20,
"online": 0,
"sample": [
{"name": "foo", "id": "0b3717c4-f45d-47c8-b8e2-3d9ff6f93a89"},
{"name": "bar", "id": "61699b2e-d327-4a01-9f1e-0ea8c3f06bc6"},
{"name": "baz", "id": "40e8d003-8872-412d-b09a-4431a5afcbd4"},
],
},
)
@pytest.fixture(scope="class")
def build(self) -> JavaStatusPlayers:
return JavaStatusPlayers.build(
{
"max": 20,
"online": 0,
"sample": [
{"name": "foo", "id": "0b3717c4-f45d-47c8-b8e2-3d9ff6f93a89"},
{"name": "bar", "id": "61699b2e-d327-4a01-9f1e-0ea8c3f06bc6"},
{"name": "baz", "id": "40e8d003-8872-412d-b09a-4431a5afcbd4"},
],
}
)
def test_empty_sample_turns_into_empty_list(self) -> None:
assert JavaStatusPlayers.build({"max": 20, "online": 0, "sample": []}).sample == []
def test_java_status_players_sample_is_none(self) -> None:
# should not raise
assert (
JavaStatusPlayers.build(
{
"online": 1,
"max": 123,
"sample": None,
}
).sample
is None
)
@BaseResponseTest.construct
class TestJavaStatusPlayer(BaseResponseTest):
EXPECTED_VALUES: t.ClassVar = [("name", "foo"), ("id", "0b3717c4-f45d-47c8-b8e2-3d9ff6f93a89")]
@pytest.fixture(scope="class")
def build(self) -> JavaStatusPlayer:
return JavaStatusPlayer.build({"name": "foo", "id": "0b3717c4-f45d-47c8-b8e2-3d9ff6f93a89"})
def test_id_field_the_same_as_uuid(self) -> None:
unique = object()
build = JavaStatusPlayer.build({"name": "foo", "id": unique}) # pyright: ignore[reportArgumentType]
assert build.id is build.uuid
assert build.uuid is unique
@BaseResponseTest.construct
class TestJavaStatusVersion(BaseResponseTest):
EXPECTED_VALUES: t.ClassVar = [("name", "1.8-pre1"), ("protocol", 44)]
@pytest.fixture(scope="class")
def build(self) -> JavaStatusVersion:
return JavaStatusVersion.build({"name": "1.8-pre1", "protocol": 44})
================================================
FILE: tests/responses/test_legacy.py
================================================
import typing as t
import pytest
from mcstatus.motd import Motd
from mcstatus.responses import LegacyStatusPlayers, LegacyStatusResponse, LegacyStatusVersion
from tests.responses import BaseResponseTest
@pytest.fixture(scope="module")
def build():
return LegacyStatusResponse.build(
[
"47",
"1.4.2",
"A Minecraft Server",
"0",
"20",
],
123.0,
)
@BaseResponseTest.construct
class TestLegacyStatusResponse(BaseResponseTest):
EXPECTED_VALUES: t.ClassVar = [
("motd", Motd.parse("A Minecraft Server")),
("latency", 123.0),
]
EXPECTED_TYPES: t.ClassVar = [
("players", LegacyStatusPlayers),
("version", LegacyStatusVersion),
]
@pytest.fixture(scope="class")
def build(self, build):
return build
def test_as_dict(self, build: LegacyStatusResponse):
assert build.as_dict() == {
"latency": 123.0,
"motd": "A Minecraft Server",
"players": {"max": 20, "online": 0},
"version": {"name": "1.4.2", "protocol": 47},
}
@BaseResponseTest.construct
class TestLegacyStatusPlayers(BaseResponseTest):
EXPECTED_VALUES: t.ClassVar = [("online", 0), ("max", 20)]
@pytest.fixture(scope="class")
def build(self, build):
return build.players
@BaseResponseTest.construct
class TestLegacyStatusVersion(BaseResponseTest):
EXPECTED_VALUES: t.ClassVar = [("name", "1.4.2"), ("protocol", 47)]
@pytest.fixture(scope="class")
def build(self, build):
return build.version
================================================
FILE: tests/responses/test_query.py
================================================
import typing as t
import pytest
from mcstatus.motd import Motd
from mcstatus.responses import QueryPlayers, QueryResponse, QuerySoftware
from mcstatus.responses._raw import RawQueryResponse
from tests.helpers import patch_project_version
from tests.responses import BaseResponseTest
@BaseResponseTest.construct
class TestQueryResponse(BaseResponseTest):
RAW: t.ClassVar[RawQueryResponse] = RawQueryResponse(
hostname="A Minecraft Server",
gametype="GAME TYPE", # pyright: ignore[reportArgumentType] # different from the hardcoded value
game_id="GAME ID", # pyright: ignore[reportArgumentType] # different from the hardcoded value
version="1.8",
plugins="",
map="world",
numplayers="3",
maxplayers="20",
hostport="9999",
hostip="192.168.56.1",
)
RAW_PLAYERS: t.ClassVar = ["Dinnerbone", "Djinnibone", "Steve"]
EXPECTED_VALUES: t.ClassVar = [
("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"),
]
@pytest.fixture(scope="class")
def build(self):
return QueryResponse.build(raw=self.RAW, players_list=self.RAW_PLAYERS)
def test_as_dict(self, build: QueryResponse):
assert build.as_dict() == {
"game_id": "GAME ID",
"game_type": "GAME TYPE",
"ip": "192.168.56.1",
"map_name": "world",
"motd": "A Minecraft Server",
"players": {
"list": [
"Dinnerbone",
"Djinnibone",
"Steve",
],
"max": 20,
"online": 3,
},
"port": 9999,
"raw": {
"game_id": "GAME ID",
"gametype": "GAME TYPE",
"hostip": "192.168.56.1",
"hostname": "A Minecraft Server",
"hostport": "9999",
"map": "world",
"maxplayers": "20",
"numplayers": "3",
"plugins": "",
"version": "1.8",
},
"software": {
"brand": "vanilla",
"plugins": [],
"version": "1.8",
},
}
def test_deprecated_map_alias(self, build: QueryResponse):
with (
patch_project_version("0.0.0"),
pytest.deprecated_call(
match=r"^QueryResponse\.map is deprecated and scheduled for removal in 13\.0\.0, use map_name instead\.$",
),
):
assert build.map == build.map_name
@BaseResponseTest.construct
class TestQueryPlayers(BaseResponseTest):
EXPECTED_VALUES: t.ClassVar = [
("online", 3),
("max", 20),
("list", ["Dinnerbone", "Djinnibone", "Steve"]),
]
@pytest.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"],
)
def test_deprecated_names_alias(self, build: QueryPlayers):
with (
patch_project_version("0.0.0"),
pytest.deprecated_call(
match=(
r"^QueryPlayers\.names is deprecated and scheduled for removal in 13\.0\.0, "
r"use 'list' attribute instead\.$"
),
),
):
assert build.names == build.list
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 == []
================================================
FILE: tests/test_cli.py
================================================
import contextlib
import io
import json
import os
import socket
from unittest import mock
from unittest.mock import patch
import pytest
from mcstatus import BedrockServer, JavaServer, LegacyServer
from mcstatus.__main__ import PING_PACKET_FAIL_WARNING, QUERY_FAIL_WARNING, main as main_under_test
from mcstatus.responses import BedrockStatusResponse, JavaStatusResponse, LegacyStatusResponse, QueryResponse
from mcstatus.responses._raw import RawJavaResponse
JAVA_RAW_RESPONSE: RawJavaResponse = {
"players": {"max": 20, "online": 0},
"version": {"name": "1.8-pre1", "protocol": 44},
"description": "A Minecraft Server",
"enforcesSecureChat": True,
"favicon": "data:image/png;base64,foo",
}
QUERY_RAW_RESPONSE = [
{
"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",
},
["Dinnerbone", "Djinnibone", "Steve"],
]
LEGACY_RAW_RESPONSE = [
"47",
"1.4.2",
"A Minecraft Server",
"0",
"20",
]
BEDROCK_RAW_RESPONSE = [
"MCPE",
"§r§4G§r§6a§r§ey§r§2B§r§1o§r§9w§r§ds§r§4e§r§6r",
"422",
"1.18.100500",
"1",
"69",
"3767071975391053022",
"map name here",
"Default",
"1",
"19132",
"-1",
"3",
]
# NOTE: if updating this, be sure to change other occurrences of this help text!
# to update, use: `COLUMNS=100000 poetry run mcstatus --help`
EXPECTED_HELP_OUTPUT = """
usage: mcstatus [-h] [--bedrock | --legacy] address {ping,status,query,json} ...
mcstatus provides an easy way to query Minecraft servers for any information they can expose. It provides three modes of access: query, status, ping and json.
positional arguments:
address The address of the server.
options:
-h, --help show this help message and exit
--bedrock Specifies that 'address' is a Bedrock server (default: Java).
--legacy Specifies that 'address' is a pre-1.7 Java server (default: 1.7+).
commands:
Command to run, defaults to 'status'.
{ping,status,query,json}
ping Ping server for latency.
status Prints server status.
query Prints detailed server information. Must be enabled in servers' server.properties file.
json Prints server status and query in json.
""" # noqa: E501 (line length)
@contextlib.contextmanager
def patch_stdout_stderr():
outpatch = patch("sys.stdout", new=io.StringIO())
errpatch = patch("sys.stderr", new=io.StringIO())
with outpatch as out, errpatch as err:
yield out, err
@pytest.fixture
def mock_network_requests():
with (
patch("mcstatus.server.JavaServer.lookup", return_value=JavaServer("example.com", port=25565)),
patch("mcstatus.server.JavaServer.ping", return_value=0),
patch("mcstatus.server.JavaServer.status", return_value=JavaStatusResponse.build(JAVA_RAW_RESPONSE)),
patch("mcstatus.server.JavaServer.query", return_value=QueryResponse.build(*QUERY_RAW_RESPONSE)),
patch("mcstatus.server.LegacyServer.lookup", return_value=LegacyServer("example.com", port=25565)),
patch(
"mcstatus.server.LegacyServer.status", return_value=LegacyStatusResponse.build(LEGACY_RAW_RESPONSE, latency=123)
),
patch("mcstatus.server.BedrockServer.lookup", return_value=BedrockServer("example.com", port=25565)),
patch(
"mcstatus.server.BedrockServer.status",
return_value=(BedrockStatusResponse.build(BEDROCK_RAW_RESPONSE, latency=123)),
),
):
yield
def normalise_help_output(s: str) -> str:
"""Normalises the output of `mcstatus --help`.
A work around some discrepancies between Python versions while still
retaining meaningful information for comparison.
"""
elided = "[...]:"
s = s.strip()
# drop lines which end in ":". these argparse section headings vary between python versions.
# it is just a small style change, so it doesn't matter so much to do `sys.version_info` check
return "\n".join(ln if not ln.endswith(":") else elided for ln in s.splitlines())
# NOTE: for premature exits in argparse, we must catch SystemExit.
# for ordinary exits in the CLI code, we can simply inspect the return value.
def test_no_args():
with patch_stdout_stderr() as (out, err), pytest.raises(SystemExit, match=r"^2$") as exn:
main_under_test([])
assert out.getvalue() == ""
assert "usage: " in err.getvalue()
assert exn.value.code != 0
def test_help():
with patch_stdout_stderr() as (out, err), pytest.raises(SystemExit, match=r"^0$") as exn:
main_under_test(["--help"])
assert "usage: " in out.getvalue()
assert err.getvalue() == ""
assert exn.value.code == 0
@mock.patch.dict(os.environ, {"COLUMNS": "100000"}) # prevent line-wrapping in --help output
def test_help_matches_recorded_output():
with patch_stdout_stderr() as (out, err), pytest.raises(SystemExit, match=r"^0$"):
main_under_test(["--help"])
assert normalise_help_output(out.getvalue()) == normalise_help_output(EXPECTED_HELP_OUTPUT)
assert err.getvalue() == ""
def test_one_argument_is_status(mock_network_requests):
with patch_stdout_stderr() as (out, err):
assert main_under_test(["example.com"]) == 0
assert out.getvalue() == (
"version: Java 1.8-pre1 (protocol 44)\n"
"motd: \x1b[0mA Minecraft Server\x1b[0m\n"
"players: 0/20\n"
"ping: 0.00 ms\n"
) # fmt: skip
assert err.getvalue() == ""
def test_status(mock_network_requests):
with patch_stdout_stderr() as (out, err):
assert main_under_test(["example.com", "status"]) == 0
assert out.getvalue() == (
"version: Java 1.8-pre1 (protocol 44)\n"
"motd: \x1b[0mA Minecraft Server\x1b[0m\n"
"players: 0/20\n"
"ping: 0.00 ms\n"
) # fmt: skip
assert err.getvalue() == ""
def test_status_with_sample(mock_network_requests):
raw_response = JAVA_RAW_RESPONSE.copy()
raw_response["players"] = JAVA_RAW_RESPONSE["players"].copy()
raw_response["players"]["sample"] = [
{"name": "foo", "id": "497dcba3-ecbf-4587-a2dd-5eb0665e6880"},
{"name": "bar", "id": "50e14f43-dd4e-412f-864d-78943ea28d91"},
{"name": "baz", "id": "7edb3b2e-869c-485b-af70-76a934e0fcfd"},
]
with (
patch("mcstatus.server.JavaServer.status", return_value=JavaStatusResponse.build(raw_response)),
patch_stdout_stderr() as (out, err),
):
assert main_under_test(["example.com", "status"]) == 0
assert out.getvalue() == (
"version: Java 1.8-pre1 (protocol 44)\n"
"motd: \x1b[0mA Minecraft Server\x1b[0m\n"
"players: 0/20\n"
" foo (497dcba3-ecbf-4587-a2dd-5eb0665e6880)\n"
" bar (50e14f43-dd4e-412f-864d-78943ea28d91)\n"
" baz (7edb3b2e-869c-485b-af70-76a934e0fcfd)\n"
"ping: 0.00 ms\n"
)
assert err.getvalue() == ""
def test_status_sample_empty_list(mock_network_requests):
raw_response = JAVA_RAW_RESPONSE.copy()
raw_response["players"] = JAVA_RAW_RESPONSE["players"].copy()
raw_response["players"]["sample"] = []
with (
patch("mcstatus.server.JavaServer.status", return_value=JavaStatusResponse.build(raw_response)),
patch_stdout_stderr() as (out, err),
):
assert main_under_test(["example.com", "status"]) == 0
assert out.getvalue() == (
"version: Java 1.8-pre1 (protocol 44)\n"
"motd: \x1b[0mA Minecraft Server\x1b[0m\n"
"players: 0/20\n"
"ping: 0.00 ms\n"
) # fmt: skip
assert err.getvalue() == ""
def test_status_bedrock(mock_network_requests):
with patch_stdout_stderr() as (out, err):
assert main_under_test(["example.com", "--bedrock", "status"]) == 0
assert out.getvalue() == (
"version: Bedrock 1.18.100500 (protocol 422)\n"
"motd: \x1b[0m\x1b[0m\x1b[0m\x1b[38;2;170;0;0mG\x1b[0m\x1b[0m\x1b[38;2;255;170;0ma\x1b[0m\x1b[0m\x1b[38;2;255;255;85m"
"y\x1b[0m\x1b[0m\x1b[38;2;0;170;0mB\x1b[0m\x1b[0m\x1b[38;2;0;0;170mo\x1b[0m\x1b[0m\x1b[38;2;85;85;255mw\x1b[0m\x1b[0m"
"\x1b[38;2;255;85;255ms\x1b[0m\x1b[0m\x1b[38;2;170;0;0me\x1b[0m\x1b[0m\x1b[38;2;255;170;0mr\x1b[0m\n"
"players: 1/69\n"
"ping: 123.00 ms\n"
)
assert err.getvalue() == ""
def test_status_legacy(mock_network_requests):
with patch_stdout_stderr() as (out, err):
assert main_under_test(["example.com", "--legacy", "status"]) == 0
assert out.getvalue() == (
"version: Java (pre-1.7) 1.4.2 (protocol 47)\nmotd: \x1b[0mA Minecraft Server\x1b[0m\nplayers: 0/20\nping: 123.00 ms\n"
)
assert err.getvalue() == ""
def test_status_offline(mock_network_requests):
with patch_stdout_stderr() as (out, err), patch("mcstatus.server.JavaServer.status", side_effect=TimeoutError):
assert main_under_test(["example.com", "status"]) == 1
assert out.getvalue() == ""
assert err.getvalue() == "Error: TimeoutError()\n"
def test_query(mock_network_requests):
with patch_stdout_stderr() as (out, err):
assert main_under_test(["example.com", "query"]) == 0
assert out.getvalue() == (
"host: 192.168.56.1:9999\n"
"software: Java 1.8 vanilla\n"
"motd: \x1b[0mA Minecraft Server\x1b[0m\n"
"plugins: []\n"
"players: 3/20 ['Dinnerbone', 'Djinnibone', 'Steve']\n"
)
assert err.getvalue() == ""
def test_query_offline(mock_network_requests):
with patch_stdout_stderr() as (out, err), patch("mcstatus.server.JavaServer.query", side_effect=socket.timeout):
assert main_under_test(["example.com", "query"]) != 0
assert out.getvalue() == ""
assert err.getvalue() == QUERY_FAIL_WARNING + "\n"
def test_query_on_bedrock(mock_network_requests):
with patch_stdout_stderr() as (out, err):
assert main_under_test(["example.com", "--bedrock", "query"]) != 0
assert out.getvalue() == ""
assert err.getvalue() == "The 'query' protocol is only supported by Java servers.\n"
def test_json(mock_network_requests):
with patch_stdout_stderr() as (out, err):
assert main_under_test(["example.com", "json"]) == 0
data = json.loads(out.getvalue())
assert data == {
"online": True,
"kind": "Java",
"status": {
"players": {"online": 0, "max": 20, "sample": None},
"version": {"name": "1.8-pre1", "protocol": 44},
"motd": "A Minecraft Server",
"latency": 0,
"raw": {
"players": {"max": 20, "online": 0},
"version": {"name": "1.8-pre1", "protocol": 44},
"description": "A Minecraft Server",
"enforcesSecureChat": True,
"favicon": "data:image/png;base64,foo",
},
"enforces_secure_chat": True,
"icon": "data:image/png;base64,foo",
"forge_data": None,
},
"query": {
"ip": "192.168.56.1",
"port": 9999,
"map_name": "world",
"motd": "A Minecraft Server",
"game_id": "GAME ID",
"game_type": "GAME TYPE",
"players": {
"list": [
"Dinnerbone",
"Djinnibone",
"Steve",
],
"max": 20,
"online": 3,
},
"software": {
"brand": "vanilla",
"plugins": [],
"version": "1.8",
},
"raw": {
"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",
},
},
}
assert err.getvalue() == ""
def test_ping(mock_network_requests):
with patch_stdout_stderr() as (out, err):
assert main_under_test(["example.com", "ping"]) == 0
assert float(out.getvalue()) == 0
assert err.getvalue() == ""
def test_ping_bedrock(mock_network_requests):
with patch_stdout_stderr() as (out, err):
assert main_under_test(["example.com", "--bedrock", "ping"]) == 0
assert float(out.getvalue()) == 123
assert err.getvalue() == ""
def test_ping_legacy(mock_network_requests):
with patch_stdout_stderr() as (out, err):
assert main_under_test(["example.com", "--legacy", "ping"]) == 0
assert float(out.getvalue()) == 123
assert err.getvalue() == ""
def test_ping_server_doesnt_support(mock_network_requests):
with patch_stdout_stderr() as (out, err), patch("mcstatus.server.JavaServer.ping", side_effect=TimeoutError("timeout")):
assert main_under_test(["example.com", "ping"]) == 0
assert float(out.getvalue()) == 0
assert err.getvalue() == PING_PACKET_FAIL_WARNING.format(address="example.com:25565", ping_exc="timeout") + "\n"
================================================
FILE: tests/test_compat.py
================================================
"""Tests for compatibility shims and build-time packaging behavior."""
import importlib
import os
import shutil
import sys
import tarfile
import zipfile
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Literal
import pytest
from hatchling.build import build_sdist, build_wheel
from tests.helpers import patch_project_version
@contextmanager
def _chdir(path: Path) -> Iterator[None]:
"""Temporarily change the working directory (Python 3.10 compatibility equivalent of ``contextlib.chdir``)."""
original = Path.cwd()
os.chdir(path)
try:
yield
finally:
os.chdir(original)
def _extractall_compat(tar: tarfile.TarFile, destination: Path) -> None:
"""Extract a tar archive in a way that works across Python versions and platforms.
This is a helper utility that avoids a deprecation warning from tarfile stdlib.
Python 3.14 deprecates ``TarFile.extractall`` without a filter, but Python 3.10 on
Windows does not accept the ``filter`` keyword. Use the secure filter when available,
and fall back to the legacy call only when the runtime rejects the keyword argument.
"""
try:
tar.extractall(destination, filter="data")
except TypeError as exc:
if "unexpected keyword argument 'filter'" not in str(exc):
raise
# This call is unsafe for malicious archives, due to path escapes (like files
# with ../foo getting placed outside of our destination) but we know what we
# built and this is only used within unit-tests, so it's not really important
# to be strict about handling this.
tar.extractall(destination) # noqa: S202
@pytest.mark.parametrize("raises", [False, True])
@pytest.mark.parametrize(
("module", "msg_pattern"),
[
("mcstatus._compat.forge_data", r"use mcstatus\.responses\.forge instead"),
("mcstatus._compat.motd_transformers", r"MOTD Transformers are no longer a part of mcstatus public API"),
("mcstatus._compat.status_response", r"use mcstatus\.responses instead"),
],
)
def test_deprecated_import_path(raises: bool, module: str, msg_pattern: str):
"""Test that the compatibility shims emit deprecation warnings at import time.
Note that this does NOT test the actual inclusion of the compatibility modules into
the source tree at build time. This test intentionally only uses the _compat imports,
as the shim files are only included on build time, which means testing those directly
would fail.
"""
# importlib.import_module caches module, if it didn't raise
sys.modules.pop(module, None)
context_manager = (
pytest.raises(DeprecationWarning, match=msg_pattern) if raises else pytest.deprecated_call(match=msg_pattern)
)
with patch_project_version("100.0.0" if raises else "0.0.0"), context_manager:
importlib.import_module(module)
@pytest.fixture(scope="session")
def sdist_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Build an sdist once and return the path of the temporary directory where it exists."""
source_root = Path(__file__).resolve().parent.parent
tmp_dir = tmp_path_factory.mktemp("build")
tmp_path = Path(tmp_dir)
build_root = tmp_path / "mcstatus"
shutil.copytree(
source_root,
build_root,
ignore=shutil.ignore_patterns(
".git",
".venv",
"__pycache__",
".pytest_cache",
"dist",
"build",
"_build",
".ruff_cache",
),
)
dist_dir = tmp_path / "dist"
dist_dir.mkdir()
# Build from a clean temp copy so we validate the sdist contents.
with _chdir(build_root):
sdist_name = build_sdist(str(dist_dir))
sdist_path = dist_dir / sdist_name
return sdist_path
@pytest.fixture(scope="session")
def sdist_member_names(sdist_path: Path) -> set[str]:
"""Build an sdist once and return all archive member names."""
with tarfile.open(sdist_path, "r:gz") as tar:
tar_names = set(tar.getnames())
return tar_names
@pytest.fixture(scope="session")
def wheel_member_names(sdist_path: Path, tmp_path_factory: pytest.TempPathFactory) -> set[str]:
"""Build a wheel once and return all archive member names."""
tmp_path = tmp_path_factory.mktemp("wheel-build")
# Extract the sdist files first
sdist_extract_root = tmp_path / "sdist"
sdist_extract_root.mkdir()
with tarfile.open(sdist_path, "r:gz") as tar:
_extractall_compat(tar, sdist_extract_root)
# Get the first (and only) subdir inside of the sdist extract directory.
# This will contain the sdist files from this specific build (e.g. mcstatus-0.0.0).
sdist_root = next(path for path in sdist_extract_root.iterdir() if path.is_dir())
wheel_build_dir = tmp_path / "dist"
wheel_build_dir.mkdir()
# Build the wheel from the sdist content to ensure compat shims persist.
with _chdir(sdist_root):
wheel_name = build_wheel(str(wheel_build_dir))
wheel_path = wheel_build_dir / wheel_name
with zipfile.ZipFile(wheel_path) as wheel:
wheel_names = set(wheel.namelist())
return wheel_names
@pytest.mark.parametrize("member_names_from", ["sdist", "wheel"])
@pytest.mark.parametrize(
"expected_path",
[
"mcstatus/status_response.py",
"mcstatus/forge_data.py",
"mcstatus/motd/transformers.py",
],
)
def test_includes_compat_shims(
sdist_member_names: set[str],
wheel_member_names: set[str],
member_names_from: Literal["sdist", "wheel"],
expected_path: str,
) -> None:
"""Assert the built wheel and sdist both bundle compatibility shims into their legacy paths."""
member_names = sdist_member_names if member_names_from == "sdist" else wheel_member_names
assert any(name.endswith(expected_path) for name in member_names)
================================================
FILE: tests/test_server.py
================================================
from __future__ import annotations
import asyncio
from typing import SupportsIndex, TYPE_CHECKING, TypeAlias
from unittest.mock import call, patch
import pytest
import pytest_asyncio
from mcstatus._net.address import Address
from mcstatus._protocol.connection import BaseAsyncReadSyncWriteConnection, Connection
from mcstatus.server import BedrockServer, JavaServer, LegacyServer
if TYPE_CHECKING:
from collections.abc import Iterable
BytesConvertable: TypeAlias = "SupportsIndex | Iterable[SupportsIndex]"
class AsyncConnection(BaseAsyncReadSyncWriteConnection):
def __init__(self) -> None:
self.sent = bytearray()
self.received = bytearray()
async 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 OSError(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
class MockProtocolFactory(asyncio.Protocol):
transport: asyncio.Transport
def __init__(self, data_expected_to_receive, data_to_respond_with):
self.data_expected_to_receive = data_expected_to_receive
self.data_to_respond_with = data_to_respond_with
def connection_made(self, transport: asyncio.Transport): # pyright: ignore[reportIncompatibleMethodOverride]
print("connection_made")
self.transport = transport
def connection_lost(self, exc):
print("connection_lost")
self.transport.close()
def data_received(self, data):
assert self.data_expected_to_receive in data
self.transport.write(self.data_to_respond_with)
def eof_received(self):
print("eof_received")
def pause_writing(self):
print("pause_writing")
def resume_writing(self):
print("resume_writing")
@pytest_asyncio.fixture()
async def create_mock_packet_server():
event_loop = asyncio.get_running_loop()
servers = []
async def create_server(port, data_expected_to_receive, data_to_respond_with):
server = await event_loop.create_server(
lambda: MockProtocolFactory(data_expected_to_receive, data_to_respond_with),
host="localhost",
port=port,
)
servers.append(server)
return server
yield create_server
for server in servers:
server.close()
await server.wait_closed()
class TestBedrockServer:
def setup_method(self):
self.server = BedrockServer("localhost")
def test_default_port(self):
assert self.server.address.port == 19132
def test_lookup_constructor(self):
s = BedrockServer.lookup("example.org")
assert s.address.host == "example.org"
assert s.address.port == 19132
class TestAsyncJavaServer:
@pytest.mark.asyncio
async def test_async_ping(self, unused_tcp_port, create_mock_packet_server):
await create_mock_packet_server(
port=unused_tcp_port,
data_expected_to_receive=bytearray.fromhex("09010000000001C54246"),
data_to_respond_with=bytearray.fromhex("0F002F096C6F63616C686F737463DD0109010000000001C54246"),
)
minecraft_server = JavaServer("localhost", port=unused_tcp_port)
latency = await minecraft_server.async_ping(ping_token=29704774, version=47)
assert latency >= 0
@pytest.mark.asyncio
async def test_async_lookup_constructor(self):
s = await JavaServer.async_lookup("example.org:3333")
assert s.address.host == "example.org"
assert s.address.port == 3333
def test_java_server_with_query_port():
with patch("mcstatus.server.JavaServer._retry_query") as patched_query_func:
server = JavaServer("localhost", query_port=12345)
server.query()
assert server.query_port == 12345
assert patched_query_func.call_args == call(Address("127.0.0.1", port=12345), tries=3)
@pytest.mark.asyncio
async def test_java_server_with_query_port_async():
with patch("mcstatus.server.JavaServer._retry_async_query") as patched_query_func:
server = JavaServer("localhost", query_port=12345)
await server.async_query()
assert server.query_port == 12345
assert patched_query_func.call_args == call(Address("127.0.0.1", port=12345), tries=3)
class TestJavaServer:
def setup_method(self):
self.socket = Connection()
self.server = JavaServer("localhost")
def test_default_port(self):
assert self.server.address.port == 25565
def test_ping(self):
self.socket.receive(bytearray.fromhex("09010000000001C54246"))
with patch("mcstatus.server.TCPSocketConnection") as connection:
connection.return_value.__enter__.return_value = self.socket
latency = self.server.ping(ping_token=29704774, version=47)
assert self.socket.flush() == bytearray.fromhex("0F002F096C6F63616C686F737463DD0109010000000001C54246")
assert self.socket.remaining() == 0, "Data is pending to be read, but should be empty"
assert latency >= 0
def test_ping_retry(self):
# Use a blank mock for the connection, we don't want to actually create any connections
with patch("mcstatus.server.TCPSocketConnection"), patch("mcstatus.server.JavaClient") as java_client:
java_client.side_effect = [RuntimeError, RuntimeError, RuntimeError]
with pytest.raises(RuntimeError, match=r"^$"):
self.server.ping()
assert java_client.call_count == 3
def test_status(self):
self.socket.receive(
bytearray.fromhex(
"6D006B7B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A7B2"
"26D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E38222C2270726F746F"
"636F6C223A34377D7D"
)
)
with patch("mcstatus.server.TCPSocketConnection") as connection:
connection.return_value.__enter__.return_value = self.socket
info = self.server.status(version=47)
assert self.socket.flush() == bytearray.fromhex("0F002F096C6F63616C686F737463DD010100")
assert self.socket.remaining() == 0, "Data is pending to be read, but should be empty"
assert info.raw == {
"description": "A Minecraft Server",
"players": {"max": 20, "online": 0},
"version": {"name": "1.8", "protocol": 47},
}
assert info.latency >= 0
def test_status_retry(self):
# Use a blank mock for the connection, we don't want to actually create any connections
with patch("mcstatus.server.TCPSocketConnection"), patch("mcstatus.server.JavaClient") as java_client:
java_client.side_effect = [RuntimeError, RuntimeError, RuntimeError]
with pytest.raises(RuntimeError, match=r"^$"):
self.server.status()
assert java_client.call_count == 3
def test_query(self):
self.socket.receive(bytearray.fromhex("090000000035373033353037373800"))
self.socket.receive(
bytearray.fromhex(
"00000000000000000000000000000000686f73746e616d650041204d696e656372616674205365727665720067616d6574797"
"06500534d500067616d655f6964004d494e4543524146540076657273696f6e00312e3800706c7567696e7300006d61700077"
"6f726c64006e756d706c61796572730033006d6178706c617965727300323000686f7374706f727400323535363500686f737"
"46970003139322e3136382e35362e31000001706c617965725f000044696e6e6572626f6e6500446a696e6e69626f6e650053"
"746576650000"
)
)
with patch("mcstatus._protocol.connection.Connection.remaining") as mock_remaining:
mock_remaining.side_effect = [15, 208]
with (
patch("mcstatus.server.UDPSocketConnection") as connection,
patch.object(self.server.address, "resolve_ip") as resolve_ip,
):
connection.return_value.__enter__.return_value = self.socket
resolve_ip.return_value = "127.0.0.1"
info = self.server.query()
conn_bytes = self.socket.flush()
assert conn_bytes[:3] == bytearray.fromhex("FEFD09")
assert info.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",
}
def test_query_retry(self):
# Use a blank mock for the connection, we don't want to actually create any connections
with patch("mcstatus.server.UDPSocketConnection"), patch("mcstatus.server.QueryClient") as query_client:
query_client.side_effect = [RuntimeError, RuntimeError, RuntimeError]
with pytest.raises(RuntimeError, match=r"^$"), patch.object(self.server.address, "resolve_ip") as resolve_ip: # noqa: PT012
resolve_ip.return_value = "127.0.0.1"
self.server.query()
assert query_client.call_count == 3
def test_lookup_constructor(self):
s = JavaServer.lookup("example.org:4444")
assert s.address.host == "example.org"
assert s.address.port == 4444
class TestLegacyServer:
def setup_method(self):
self.socket = Connection()
self.server = LegacyServer("localhost")
def test_default_port(self):
assert self.server.address.port == 25565
def test_lookup_constructor(self):
s = LegacyServer.lookup("example.org:4444")
assert s.address.host == "example.org"
assert s.address.port == 4444
def test_status(self):
self.socket.receive(
bytearray.fromhex(
"ff002300a70031000000340037000000"
"31002e0034002e003200000041002000"
"4d0069006e0065006300720061006600"
"74002000530065007200760065007200"
"000030000000320030"
)
)
with patch("mcstatus.server.TCPSocketConnection") as connection:
connection.return_value.__enter__.return_value = self.socket
info = self.server.status()
assert self.socket.flush() == bytearray.fromhex("fe01fa")
assert self.socket.remaining() == 0, "Data is pending to be read, but should be empty"
assert info.as_dict() == {
"latency": info.latency,
"motd": "A Minecraft Server",
"players": {"max": 20, "online": 0},
"version": {"name": "1.4.2", "protocol": 47},
}
assert info.latency >= 0
class TestAsyncLegacyServer:
def setup_method(self):
self.socket = AsyncConnection()
self.server = LegacyServer("localhost")
@pytest.mark.asyncio
async def test_async_lookup_constructor(self):
s = await LegacyServer.async_lookup("example.org:3333")
assert s.address.host == "example.org"
assert s.address.port == 3333
@pytest.mark.asyncio
async def test_async_status(self):
self.socket.receive(
bytearray.fromhex(
"ff002300a70031000000340037000000"
"31002e0034002e003200000041002000"
"4d0069006e0065006300720061006600"
"74002000530065007200760065007200"
"000030000000320030"
)
)
with patch("mcstatus.server.TCPAsyncSocketConnection") as connection:
connection.return_value.__aenter__.return_value = self.socket
info = await self.server.async_status()
assert self.socket.flush() == bytearray.fromhex("fe01fa")
assert self.socket.remaining() == 0, "Data is pending to be read, but should be empty"
assert info.as_dict() == {
"latency": info.latency,
"motd": "A Minecraft Server",
"players": {"max": 20, "online": 0},
"version": {"name": "1.4.2", "protocol": 47},
}
assert info.latency >= 0
================================================
FILE: tests/utils/__init__.py
================================================
================================================
FILE: tests/utils/test_deprecation.py
================================================
from __future__ import annotations
import re
import warnings
import pytest
from mcstatus._utils.deprecation import _get_project_version, deprecated, deprecation_warn
from tests.helpers import patch_project_version
LIB_NAME = "mcstatus"
def test_invalid_lib_version():
with (
patch_project_version("foo bar"),
pytest.warns(match=f"^Failed to parse {LIB_NAME} project version \\(foo bar\\), assuming v0\\.0\\.0$"),
):
_get_project_version()
def test_epoch_in_lib_version():
with (
patch_project_version("2!1.2.3"),
pytest.warns(
match=f"^Failed to parse {LIB_NAME} project version, assuming v0\\.0\\.0$",
),
):
_get_project_version()
@pytest.mark.parametrize("removal_version", ["0.9.0", (0, 9, 0)])
def test_deprecation_warn_produces_error(removal_version: str | tuple[int, int, int]):
"""Test deprecation_warn with older removal_version than current version produces exception."""
with (
patch_project_version("1.0.0"),
pytest.raises(
DeprecationWarning,
match=r"^test is passed its removal version \(0\.9\.0\)\.$",
),
):
deprecation_warn(obj_name="test", removal_version=removal_version)
@pytest.mark.parametrize("removal_version", ["1.0.1", (1, 0, 1)])
def test_deprecation_warn_produces_warning(removal_version: str | tuple[int, int, int]):
"""Test deprecation_warn with newer removal_version than current version produces warning."""
with (
patch_project_version("1.0.0"),
pytest.deprecated_call(
match=r"^test is deprecated and scheduled for removal in 1\.0\.1\.$",
),
):
deprecation_warn(obj_name="test", removal_version=removal_version)
def test_deprecation_invalid_removal_version():
"""Test deprecation_warn with invalid removal_version."""
pattern = re.escape(r"(\d+)\.(\d+)\.(\d+)")
with (
patch_project_version("1.0.0"),
pytest.raises(
ValueError,
match=f"^removal_version must follow regex pattern of: {pattern}$",
),
):
deprecation_warn(obj_name="test", removal_version="foo!")
def test_deprecation_warn_unknown_version():
"""Test deprecation_warn with unknown project version.
This could occur if the project wasn't installed as a package. (e.g. when running directly from
source, like via a git submodule.)
"""
with (
patch_project_version(None),
pytest.warns(match=f"Failed to get {LIB_NAME} project version", expected_warning=RuntimeWarning),
pytest.deprecated_call(match=r"^test is deprecated and scheduled for removal in 1\.0\.0\.$"),
):
deprecation_warn(obj_name="test", removal_version="1.0.0")
def test_deprecation_decorator_warn():
"""Check deprecated decorator triggers a deprecation warning."""
with patch_project_version("1.0.0"):
@deprecated(display_name="func", removal_version="1.0.1")
def func(x: object) -> object:
"""Return input value.
.. deprecated:: 0.0.1
"""
return x
with pytest.deprecated_call(match=r"^func is deprecated and scheduled for removal in 1\.0\.1\.$"):
assert func(5) == 5
def test_deprecation_decorator_inferred_name():
"""Check deprecated decorator properly infers qualified name of decorated object shown in warning."""
with patch_project_version("1.0.0"):
@deprecated(removal_version="1.0.1")
def func(x: object) -> object:
"""Return input value.
.. deprecated:: 0.0.1
"""
return x
qualname = r"test_deprecation_decorator_inferred_name\.\.func"
with pytest.deprecated_call(match=rf"^{qualname} is deprecated and scheduled for removal in 1\.0\.1\.$"):
assert func(5) == 5
def test_deprecation_decorator_missing_docstring_directive():
"""Check deprecated decorator validates a docstring contains a deprecation directive."""
with (
patch_project_version("1.0.0"),
pytest.raises(
ValueError,
match=r"^Deprecated object does not contain '\.\. deprecated::' sphinx directive in its docstring$",
),
):
@deprecated(display_name="func", removal_version="1.0.1")
def func(x: object) -> object:
return x
def test_deprecation_decorator_no_docstring_check_opt_out():
"""Check deprecated decorator can skip docstring validation when requested."""
with patch_project_version("1.0.0"):
@deprecated(display_name="func", removal_version="1.0.1", no_docstring_check=True)
def func(x: object) -> object:
return x
with pytest.deprecated_call(match=r"^func is deprecated and scheduled for removal in 1\.0\.1\.$"):
assert func(5) == 5
@pytest.mark.parametrize(
("version", "expected"),
[
("1.2.3", (1, 2, 3)),
("0.0.1", (0, 0, 1)),
("1.0.0", (1, 0, 0)),
("10.20.30", (10, 20, 30)),
("1.2.3rc1", (1, 2, 3)),
("1.2.3-rc1", (1, 2, 3)),
("1.2.3.post1", (1, 2, 3)),
("1.2.3-1", (1, 2, 3)),
("1.2.3.dev4", (1, 2, 3)),
("1.2.3+local", (1, 2, 3)),
("1.2.3rc1.post2.dev3+loc.1", (1, 2, 3)),
],
)
def test_project_version_non_normalized_parsing(version: str, expected: tuple[int, int, int]):
"""Ensure PEP440 release versions get parsed out properly, with non-release components are ignored."""
with patch_project_version(version), warnings.catch_warnings():
warnings.simplefilter("error") # raise warnings as errors (test there are no warnings)
assert _get_project_version() == expected
@pytest.mark.parametrize(
("version", "expected", "warning"),
[
(
"1.2",
(1, 2, 0),
f"{LIB_NAME} version '1.2' has less than 3 release components; remaining components will become zeroes",
),
(
"1.2.3.4",
(1, 2, 3),
f"{LIB_NAME} version '1.2.3.4' has more than 3 release components; extra components are ignored",
),
],
ids=["1.2", "1.2.3.4"],
)
def test_project_version_normalizes_release_components(
version: str,
expected: tuple[int, int, int],
warning: str,
):
"""Ensure release segments normalize to a 3-component version and warn."""
with patch_project_version(version), pytest.warns(RuntimeWarning, match=rf"^{re.escape(warning)}$"):
assert _get_project_version() == expected
================================================
FILE: tests/utils/test_general.py
================================================
import pytest
from mcstatus._utils.general import or_none
@pytest.mark.parametrize(
("a", "b", "result"),
[
(None, None, None),
(None, "", ""),
("", None, ""),
("a", "b", "a"),
],
)
def test_or_none(a, b, result):
assert or_none(a, b) == result
def test_or_none_many_arguments():
assert or_none(*([None] * 100 + ["value"])) == "value"
================================================
FILE: tests/utils/test_retry.py
================================================
import pytest
from mcstatus._utils.retry import retry
from tests.protocol.test_java_client_async import async_decorator
def test_sync_success():
x = -1
@retry(tries=2)
def func():
nonlocal x
x += 1
return 5 / x
y = func()
assert x == 1
assert y == 5
def test_sync_fail():
x = -1
@retry(tries=2)
def func():
nonlocal x
x += 1
if x == 0:
raise OSError("First error")
if x == 1:
raise RuntimeError("Second error")
# We should get the last exception on failure (not OSError)
with pytest.raises(RuntimeError, match=r"^Second error$"):
func()
def test_async_success():
x = -1
@retry(tries=2)
async def func():
nonlocal x
x += 1
return 5 / x
y = async_decorator(func)()
assert x == 1
assert y == 5
def test_async_fail():
x = -1
@retry(tries=2)
async def func():
nonlocal x
x += 1
if x == 0:
raise OSError("First error")
if x == 1:
raise RuntimeError("Second error")
# We should get the last exception on failure (not OSError)
with pytest.raises(RuntimeError, match=r"^Second error$"):
async_decorator(func)()