[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
mcstatus-14.0.0/mcstatus/_utils/general.py 0000664 0000000 0000000 00000002055 15217522771 0020603 0 ustar 00root root 0000000 0000000 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
mcstatus-14.0.0/mcstatus/_utils/retry.py 0000664 0000000 0000000 00000005374 15217522771 0020342 0 ustar 00root root 0000000 0000000 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, reportUnknownVariableType] # 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
mcstatus-14.0.0/mcstatus/motd/ 0000775 0000000 0000000 00000000000 15217522771 0016256 5 ustar 00root root 0000000 0000000 mcstatus-14.0.0/mcstatus/motd/__init__.py 0000664 0000000 0000000 00000000135 15217522771 0020366 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from mcstatus.motd._motd import Motd
__all__ = ["Motd"]
mcstatus-14.0.0/mcstatus/motd/_motd.py 0000664 0000000 0000000 00000026731 15217522771 0017743 0 ustar 00root root 0000000 0000000 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 (
BedrockFormatting,
BedrockMinecraftColor,
InvalidFormatting,
JavaFormatting,
JavaMinecraftColor,
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-Z])", 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: RawJavaResponseMotd
if isinstance(raw, str):
original_raw = raw
elif hasattr(raw, "copy"):
original_raw = raw.copy()
else: # pragma: no cover
# we should never reach this, unless the type expectation wasn't met.
# in that case, the isinstance checks below will fail and this will end
# in a TypeError
original_raw = raw
if isinstance(raw, list):
raw: RawJavaResponseMotdWhenDict = {"extra": raw}
if isinstance(raw, str):
parsed = cls._parse_as_str(raw, bedrock=bedrock)
elif isinstance(raw, dict): # pyright: ignore[reportUnnecessaryIsInstance]
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 may return a lot of unnecessary noise, like extra
:attr:`Formatting.RESET`. Use :meth:`Motd.simplify` to remove it.
:param raw: Raw MOTD, directly from server.
:param bedrock: Is server Bedrock Edition?
If it isn't, ignores any color that starts with ``MATERIAL`` or
:attr:`MinecraftColor.MINECOIN_GOLD`.
:returns: :obj:`ParsedMotdComponent` list, which need to be passed to ``__init__``.
"""
parsed_motd: list[ParsedMotdComponent] = []
color_enum = BedrockMinecraftColor if bedrock else JavaMinecraftColor
formatting_enum = BedrockFormatting if bedrock else JavaFormatting
split_raw = _MOTD_COLORS_RE.split(raw)
for element in split_raw:
if not element:
continue
clean_element = element.lstrip("&§").lower()
standardized_element = element.replace("&", "§").lower()
if standardized_element.startswith("§"):
try:
parsed_motd.append(color_enum(clean_element))
except ValueError:
try:
parsed_motd.append(formatting_enum(clean_element))
except ValueError:
parsed_motd.append(InvalidFormatting(clean_element))
else:
parsed_motd.append(element)
return parsed_motd
@classmethod
def _parse_as_dict(
cls,
item: RawJavaResponseMotdWhenDict,
*,
bedrock: bool,
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__``.
"""
formatting_enum = BedrockFormatting if bedrock else JavaFormatting
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, bedrock=bedrock))
for style_key, style_val in formatting_enum.__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_enum.RESET)
if "extra" in item:
auto_add = list(filter(lambda e: type(e) is formatting_enum and e != formatting_enum.RESET, parsed_motd))
for element in item["extra"]:
parsed_motd.extend(
cls._parse_as_dict(element, bedrock=bedrock, 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, *, bedrock: bool) -> ParsedMotdComponent:
"""Parse a color string."""
color_enum = BedrockMinecraftColor if bedrock else JavaMinecraftColor
formatting_enum = BedrockFormatting if bedrock else JavaFormatting
try:
return color_enum[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_enum.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(bedrock=self.bedrock).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(bedrock=self.bedrock).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)
mcstatus-14.0.0/mcstatus/motd/_simplifies.py 0000664 0000000 0000000 00000015314 15217522771 0021137 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import typing as t
from mcstatus.motd.components import (
AnyFormatting,
AnyMinecraftColor,
BedrockMinecraftColor,
JavaMinecraftColor,
ParsedMotdComponent,
WebColor,
)
if t.TYPE_CHECKING:
from collections.abc import Sequence
__all__ = [
"get_double_colors",
"get_double_items",
"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_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 # intentional mutation during iteration # noqa: B909
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, (AnyFormatting, AnyMinecraftColor, 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, (AnyMinecraftColor, 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: list[int] = []
for index, item in enumerate(parsed):
# Collect the indices of formatting items
if isinstance(item, AnyFormatting):
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, (AnyMinecraftColor, WebColor)):
to_remove.update(collected_formattings)
collected_formattings = []
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, (AnyMinecraftColor, AnyFormatting, WebColor)):
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: AnyMinecraftColor | WebColor | None = None
active_formatting: AnyFormatting | None = None
for index, item in enumerate(parsed):
if isinstance(item, (JavaMinecraftColor, BedrockMinecraftColor, WebColor)):
if active_color == item:
to_remove.add(index)
active_color = item
continue
if isinstance(item, AnyFormatting):
if item.name == "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
mcstatus-14.0.0/mcstatus/motd/_transformers.py 0000664 0000000 0000000 00000024623 15217522771 0021523 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import abc
import typing as t
from mcstatus.motd.components import (
AnyFormatting,
AnyMinecraftColor,
BedrockFormatting,
BedrockMinecraftColor,
InvalidFormatting,
JavaFormatting,
JavaMinecraftColor,
ParsedMotdComponent,
TranslationTag,
WebColor,
)
if t.TYPE_CHECKING:
from collections.abc import Sequence
from typing_extensions import override
else:
override = lambda f: f # noqa: E731
__all__ = [
"AnsiTransformer",
"HtmlTransformer",
"MinecraftTransformer",
"PlainTransformer",
]
_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 = {
"BLACK": ((0, 0, 0), (0, 0, 0)),
"DARK_BLUE": ((0, 0, 170), (0, 0, 42)),
"DARK_GREEN": ((0, 170, 0), (0, 42, 0)),
"DARK_AQUA": ((0, 170, 170), (0, 42, 42)),
"DARK_RED": ((170, 0, 0), (42, 0, 0)),
"DARK_PURPLE": ((170, 0, 170), (42, 0, 42)),
"GOLD": ((255, 170, 0), (64, 42, 0)),
"GRAY": ((170, 170, 170), (42, 42, 42)),
"DARK_GRAY": ((85, 85, 85), (21, 21, 21)),
"BLUE": ((85, 85, 255), (21, 21, 63)),
"GREEN": ((85, 255, 85), (21, 63, 21)),
"AQUA": ((85, 255, 255), (21, 63, 63)),
"RED": ((255, 85, 85), (63, 21, 21)),
"LIGHT_PURPLE": ((255, 85, 255), (63, 21, 63)),
"YELLOW": ((255, 255, 85), (63, 63, 21)),
"WHITE": ((255, 255, 255), (63, 63, 63)),
}
_MINECRAFT_COLOR_TO_RGB_JAVA = _SHARED_MINECRAFT_COLOR_TO_RGB.copy()
_MINECRAFT_COLOR_TO_RGB_JAVA["GRAY"] = ((170, 170, 170), (42, 42, 42))
_MINECRAFT_COLOR_TO_RGB_BEDROCK = _SHARED_MINECRAFT_COLOR_TO_RGB.copy()
_MINECRAFT_COLOR_TO_RGB_BEDROCK.update(
{
"GRAY": ((198, 198, 198), (49, 49, 49)),
"MINECOIN_GOLD": ((221, 214, 5), (55, 53, 1)),
"MATERIAL_QUARTZ": ((227, 212, 209), (56, 53, 52)),
"MATERIAL_IRON": ((206, 202, 202), (51, 50, 50)),
"MATERIAL_NETHERITE": ((68, 58, 59), (17, 14, 14)),
"MATERIAL_REDSTONE": ((151, 22, 7), (37, 5, 1)),
"MATERIAL_COPPER": ((180, 104, 77), (45, 26, 19)),
"MATERIAL_GOLD": ((222, 177, 45), (55, 44, 11)),
"MATERIAL_EMERALD": ((17, 159, 54), (4, 40, 13)),
"MATERIAL_DIAMOND": ((44, 186, 168), (11, 46, 42)),
"MATERIAL_LAPIS": ((33, 73, 123), (8, 18, 30)),
"MATERIAL_AMETHYST": ((154, 92, 198), (38, 23, 49)),
"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 __init__(self, *, bedrock: bool) -> None:
self.bedrock: bool = bedrock
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]:
color_enum = BedrockMinecraftColor if self.bedrock else JavaMinecraftColor
formatting_enum = BedrockFormatting if self.bedrock else JavaFormatting
def handler(component: ParsedMotdComponent) -> _HOOK_RETURN_TYPE:
if type(component) is color_enum:
return self._handle_minecraft_color(component)
if type(component) is WebColor:
return self._handle_web_color(component)
if type(component) is formatting_enum:
return self._handle_formatting(component)
if type(component) is InvalidFormatting:
return self._handle_invalid_formatting(component)
if type(component) is TranslationTag:
return self._handle_translation_tag(component)
if type(component) is str:
return self._handle_str(component)
raise RuntimeError(f"Invalid component type: {type(component)}: {component!r}")
additional = None
if isinstance(component, color_enum):
additional = self._handle_formatting(formatting_enum.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: AnyFormatting, /) -> _HOOK_RETURN_TYPE: ...
@abc.abstractmethod
def _handle_invalid_formatting(self, element: InvalidFormatting, /) -> _HOOK_RETURN_TYPE: ...
@abc.abstractmethod
def _handle_minecraft_color(self, element: AnyMinecraftColor, /) -> _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.
"""
@override
def _format_output(self, results: list[str]) -> str:
return "".join(results)
@override
def _handle_str(self, _element: str, /) -> str:
return ""
@override
def _handle_minecraft_color(self, _element: AnyMinecraftColor, /) -> str:
return ""
@override
def _handle_web_color(self, _element: WebColor, /) -> str:
return ""
@override
def _handle_formatting(self, _element: AnyFormatting, /) -> str:
return ""
@override
def _handle_invalid_formatting(self, _element: InvalidFormatting, /) -> str:
return ""
@override
def _handle_translation_tag(self, _element: TranslationTag, /) -> str:
return ""
class PlainTransformer(_NothingTransformer):
@override
def _handle_str(self, element: str, /) -> str:
return element
@t.final
class MinecraftTransformer(PlainTransformer):
@override
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
@override
def _handle_minecraft_color(self, element: AnyMinecraftColor, /) -> str:
return "§" + element.value
@override
def _handle_formatting(self, element: AnyFormatting, /) -> str:
return "§" + element.value
# future compatibility
@override
def _handle_invalid_formatting(self, element: InvalidFormatting, /) -> str:
return "§" + element.value
@t.final
class HtmlTransformer(PlainTransformer):
_FORMATTING_TO_HTML_TAGS: t.ClassVar = {
"BOLD": "b",
"STRIKETHROUGH": "s",
"ITALIC": "i",
"UNDERLINED": "u",
}
def __init__(self, *, bedrock: bool) -> None:
super().__init__(bedrock=bedrock)
self.on_reset: list[str] = []
@override
def transform(self, motd_components: Sequence[ParsedMotdComponent]) -> str:
self.on_reset = []
return super().transform(motd_components)
@override
def _format_output(self, results: list[str]) -> str:
return "" + super()._format_output(results) + "".join(self.on_reset) + "
"
@override
def _handle_str(self, element: str, /) -> str:
return element.replace("\n", "
")
@override
def _handle_minecraft_color(self, element: AnyMinecraftColor, /) -> str:
color_map = _MINECRAFT_COLOR_TO_RGB_BEDROCK if self.bedrock else _MINECRAFT_COLOR_TO_RGB_JAVA
fg_color, bg_color = color_map[element.name]
self.on_reset.append("")
return f""
@override
def _handle_web_color(self, element: WebColor, /) -> str:
self.on_reset.append("")
return f""
@override
def _handle_formatting(self, element: AnyFormatting, /) -> str:
if element.name == "RESET":
to_return = "".join(self.on_reset)
self.on_reset = []
return to_return
if element.name == "OBFUSCATED":
self.on_reset.append("")
return ""
tag_name = self._FORMATTING_TO_HTML_TAGS[element.name]
self.on_reset.append(f"{tag_name}>")
return f"<{tag_name}>"
@t.final
class AnsiTransformer(PlainTransformer):
_FORMATTING_TO_ANSI_TAGS: t.ClassVar = {
"BOLD": "1",
"STRIKETHROUGH": "9",
"ITALIC": "3",
"UNDERLINED": "4",
"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()
}
def __init__(self, *, bedrock: bool) -> None:
super().__init__(bedrock=bedrock)
def ansi_color(self, color: tuple[int, int, int] | AnyMinecraftColor) -> str:
"""Transform RGB color to ANSI color code."""
if isinstance(color, AnyMinecraftColor):
color_to_rgb = self._MINECRAFT_COLOR_TO_RGB_BEDROCK if self.bedrock else self._MINECRAFT_COLOR_TO_RGB_JAVA
color = color_to_rgb[color.name]
return "\033[38;2;{};{};{}m".format(*color)
@override
def _format_output(self, results: list[str]) -> str:
return "\033[0m" + super()._format_output(results) + "\033[0m"
@override
def _handle_minecraft_color(self, element: AnyMinecraftColor, /) -> str:
return self.ansi_color(element)
@override
def _handle_web_color(self, element: WebColor, /) -> str:
return self.ansi_color(element.rgb)
@override
def _handle_formatting(self, element: AnyFormatting, /) -> str:
if element.name == "RESET":
return "\033[0m"
return "\033[" + self._FORMATTING_TO_ANSI_TAGS[element.name] + "m"
mcstatus-14.0.0/mcstatus/motd/components.py 0000664 0000000 0000000 00000012604 15217522771 0021020 0 ustar 00root root 0000000 0000000 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__ = [
"AnyFormatting",
"AnyMinecraftColor",
"BedrockFormatting",
"BedrockMinecraftColor",
"InvalidFormatting",
"JavaFormatting",
"JavaMinecraftColor",
"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 = "AnyFormatting | AnyMinecraftColor | InvalidFormatting | WebColor | TranslationTag | str"
@dataclass(frozen=True)
class InvalidFormatting:
"""Unrecognized color formatting.
This might be because the color returned is genuinely invalid, or because
this is a new color which mcstatus doesn't yet recognize.
"""
value: str
class JavaFormatting(Enum):
"""Enum for Java Formatting codes.
See `Minecraft wiki `__
for more info.
"""
BOLD = "l"
ITALIC = "o"
UNDERLINED = "n"
STRIKETHROUGH = "m"
OBFUSCATED = "k"
RESET = "r"
class BedrockFormatting(Enum):
"""Enum for Bedrock Formatting codes.
The same as :class:`.JavaFormatting`, but excludes
:attr:`JavaFormatting.UNDERLINED` and :attr:`JavaFormatting.STRIKETHROUGH`,
as they don't work on Bedrock (see `MCPE-41729 `_)
See `Minecraft wiki `__
for more info.
"""
BOLD = "l"
ITALIC = "o"
OBFUSCATED = "k"
RESET = "r"
class JavaMinecraftColor(Enum):
"""Enum for Java 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"
class BedrockMinecraftColor(Enum):
"""Enum for Bedrock 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"
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"
# having these as unions, means that we can use `isinstance`
AnyFormatting: t.TypeAlias = JavaFormatting | BedrockFormatting
AnyMinecraftColor: t.TypeAlias = JavaMinecraftColor | BedrockMinecraftColor
@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
mcstatus-14.0.0/mcstatus/py.typed 0000664 0000000 0000000 00000000000 15217522771 0017000 0 ustar 00root root 0000000 0000000 mcstatus-14.0.0/mcstatus/responses/ 0000775 0000000 0000000 00000000000 15217522771 0017334 5 ustar 00root root 0000000 0000000 mcstatus-14.0.0/mcstatus/responses/__init__.py 0000664 0000000 0000000 00000002032 15217522771 0021442 0 ustar 00root root 0000000 0000000 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",
]
mcstatus-14.0.0/mcstatus/responses/_raw.py 0000664 0000000 0000000 00000004561 15217522771 0020644 0 ustar 00root root 0000000 0000000 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
mcstatus-14.0.0/mcstatus/responses/base.py 0000664 0000000 0000000 00000005640 15217522771 0020625 0 ustar 00root root 0000000 0000000 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 `__.
"""
mcstatus-14.0.0/mcstatus/responses/bedrock.py 0000664 0000000 0000000 00000004553 15217522771 0021326 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import dataclass
from typing import Any, TYPE_CHECKING
from mcstatus.motd import Motd
from mcstatus.responses.base import BaseStatusPlayers, BaseStatusResponse, BaseStatusVersion
if TYPE_CHECKING:
from typing_extensions import Self, override
else:
override = lambda f: f # noqa: E731
__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."""
@override
@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."""
mcstatus-14.0.0/mcstatus/responses/forge.py 0000664 0000000 0000000 00000021466 15217522771 0021021 0 ustar 00root root 0000000 0000000 """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, final
from mcstatus._protocol.io.base_io import BaseSyncReader, StructFormat
from mcstatus._protocol.io.buffer import Buffer
from mcstatus._utils import or_none
if TYPE_CHECKING:
from typing_extensions import Self, override
from mcstatus.responses._raw import RawForgeData, RawForgeDataChannel, RawForgeDataMod
else:
override = lambda f: f # noqa: E731
__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: Buffer, mod_id: str | None = None) -> Self:
"""Decode an object about Forge channel from decoded optimized buffer.
:param buffer: :class:`Buffer` 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_value(StructFormat.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: Buffer) -> tuple[Self, list[ForgeDataChannel]]:
"""Decode data about a Forge mod from decoded optimized buffer.
:param buffer: :class:`Buffer` 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
@final
class _StringBuffer(BaseSyncReader):
"""String Buffer for reading utf-16 encoded binary data."""
__slots__ = ("received", "stringio")
def __init__(self, stringio: StringIO) -> None:
self.stringio = stringio
self.received = bytearray()
@override
def read(self, length: int, /) -> bytes:
"""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 bytes(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_value(StructFormat.SHORT) | (self.read_value(StructFormat.SHORT) << 15)
def read_optimized_buffer(self) -> Buffer:
"""Read encoded buffer."""
size = self.read_optimized_size()
buffer = Buffer()
value, bits = 0, 0
while buffer.remaining < size:
if bits < 8 and self.remaining():
# Ignoring sign bit
value |= (self.read_value(StructFormat.SHORT) & 0x7FFF) << bits
bits += 15
buffer.write((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) -> Buffer:
"""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
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_value(StructFormat.BOOL)
mod_count = buffer.read_value(StructFormat.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,
)
mcstatus-14.0.0/mcstatus/responses/java.py 0000664 0000000 0000000 00000013771 15217522771 0020640 0 ustar 00root root 0000000 0000000 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, override
from mcstatus.responses._raw import RawJavaResponse, RawJavaResponsePlayer, RawJavaResponsePlayers, RawJavaResponseVersion
else:
override = lambda f: f # noqa: E731
__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."""
@override
@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"):
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"])
mcstatus-14.0.0/mcstatus/responses/legacy.py 0000664 0000000 0000000 00000004351 15217522771 0021155 0 ustar 00root root 0000000 0000000 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, override
else:
override = lambda f: f # noqa: E731
__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)."""
@override
@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.
"""
mcstatus-14.0.0/mcstatus/responses/query.py 0000664 0000000 0000000 00000011002 15217522771 0021045 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any, TYPE_CHECKING
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
@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,
)
@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
mcstatus-14.0.0/mcstatus/server.py 0000664 0000000 0000000 00000034143 15217522771 0017200 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from abc import ABC
from typing import ClassVar, TYPE_CHECKING, final
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.io.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, override
from mcstatus.responses import BedrockStatusResponse, JavaStatusResponse, LegacyStatusResponse, QueryResponse
else:
override = lambda f: f # noqa: E731
__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: ClassVar[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 = Address(host, port)
self.timeout: float = 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: ClassVar[int] = 25565
@override
@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)
@final
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
)
await 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
)
await 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()
@final
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()
@final
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()
mcstatus-14.0.0/pyproject.toml 0000664 0000000 0000000 00000022075 15217522771 0016372 0 ustar 00root root 0000000 0000000 [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.46.0"]
lint = [
"pre-commit~=4.6.0",
"ruff~=0.15.0",
"basedpyright~=1.39.2",
"typing-extensions~=4.15.0",
]
test = [
"pytest~=9.1.0",
"pytest-asyncio~=1.4.0",
"pytest-cov~=7.1.0",
"pytest-rerunfailures~=16.1",
"coverage~=7.14.0",
"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.30.1", "uv-dynamic-versioning~=0.14.0"]
[tool.poe.tasks]
_lint_ruff = "ruff check ."
_lint_pyright = "basedpyright ."
[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"
asyncio_mode = "auto"
tmp_path_retention_policy = "failed"
addopts = "--strict-markers --doctest-modules --cov -vvv"
testpaths = ["tests"]
# Remove deprecation warning
asyncio_default_fixture_loop_scope = "function"
[tool.coverage.report]
precision = 2 # number of decimal places
fail_under = 80
show_missing = true
skip_covered = false
skip_empty = false
sort = "cover"
exclude_lines = [
"\\#\\s*pragma: no cover",
"^\\s*if (t\\.|typing\\.)?TYPE_CHECKING:",
"^\\s*@(t|typing\\.)?overload",
"^\\s*@(abc\\.)?abstractmethod",
"^\\s*def __repr__\\(",
"^\\s*class .*\\bProtocol\\):",
"^\\s*raise NotImplementedError",
"^\\s*return NotImplemented",
"^\\s*\\.\\.\\.\\s",
"^\\s*if __name__ == \"__main__\":",
]
[tool.coverage.run]
relative_files = true
parallel = true
branch = true
timid = false
source = ["mcstatus"]
[tool.basedpyright]
pythonPlatform = "All"
pythonVersion = "3.10"
typeCheckingMode = "all"
# Diagnostic behavior settings
strictListInference = false
strictDictionaryInference = false
strictSetInference = false
strictParameterNoneValue = true
analyzeUnannotatedFunctions = true
enableTypeIgnoreComments = false
enableExperimentalFeatures = false
# Diagnostic rules
reportAny = false
reportExplicitAny = false
reportImplicitStringConcatenation = false
reportUnreachable = "hint"
reportUnusedParameter = "hint"
reportUnannotatedClassAttribute = true # might be too strict
reportUnknownVariableType = true # might be too strict
reportUnknownMemberType = true # might be too strict
reportUnknownArgumentType = true # might be too strict
reportUnknownParameterType = true # might be too strict
reportUnknownLambdaType = false
reportMissingTypeStubs = "information"
reportMissingParameterType = false # ruff's flake8-annotations (ANN) already covers this + gives us more control
reportIncompatibleUnannotatedOverride = false # https://github.com/DetachHead/basedpyright/issues/1349
reportUninitializedInstanceVariable = false # consider enabling
executionEnvironments = [
{ root = "tests", extraPaths = ["."], reportPrivateUsage = false },
{ root = "docs/examples", extraPaths = ["."], reportUnusedCallResult = false }
]
[tool.ruff]
target-version = "py310"
line-length = 127
[tool.ruff.lint]
select = ["ALL"]
preview = true
ignore = [
"EM", # Very weird rules for using exceptions
"FIX", # Line contains TODO, consider resolving the issue
"CPY001", # Missing copyright notice at top of file
"COM812", # Missing trailing comma (in multiline lists/tuples/...)
"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
"PLC1901", # Explicit comparison against empty string (already falsy)
"PLR6301", # Method could be a function, class method, or static method -> https://github.com/astral-sh/ruff/issues/24713
"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__
"D203", # Blank line required before class docstring
"D213", # Multi-line docstring summary should start at the second line
"D413", # Missing blank line after last section
"DOC201", # Return is not documented in docstring
"DOC402", # Yield not documented in docstring
"DOC501", # Raised exception missing from docstring
]
[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`
"PLC2701", # Private name import from external module (mcstatus is external to tests/)
"PLR6301", # Method could be a function, class method, or static method
"PLR0904", # Too many public methods
"RUF067", # __init__ module should only contain docstrings and re-exports
]
"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/forge_data.py" = "mcstatus/forge_data.py"
mcstatus-14.0.0/tests/ 0000775 0000000 0000000 00000000000 15217522771 0014612 5 ustar 00root root 0000000 0000000 mcstatus-14.0.0/tests/__init__.py 0000664 0000000 0000000 00000000000 15217522771 0016711 0 ustar 00root root 0000000 0000000 mcstatus-14.0.0/tests/helpers.py 0000664 0000000 0000000 00000002221 15217522771 0016623 0 ustar 00root root 0000000 0000000 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.Generator[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()
mcstatus-14.0.0/tests/motd/ 0000775 0000000 0000000 00000000000 15217522771 0015555 5 ustar 00root root 0000000 0000000 mcstatus-14.0.0/tests/motd/__init__.py 0000664 0000000 0000000 00000000000 15217522771 0017654 0 ustar 00root root 0000000 0000000 mcstatus-14.0.0/tests/motd/conftest.py 0000664 0000000 0000000 00000004436 15217522771 0017763 0 ustar 00root root 0000000 0000000 import pytest
from mcstatus.responses._raw import RawJavaResponseMotd
@pytest.fixture(scope="session")
def source_java() -> RawJavaResponseMotd:
"""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() -> RawJavaResponseMotd:
"""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 (
"1"
"§0§k2"
"§1§l3"
"§2§o4"
"§35"
"§46"
"§57"
"§68"
"§79"
"§810"
"§911"
"§a12"
"§b13"
"§c14"
"§d15"
"§e16"
"§f17"
"§g18"
"§h19"
"§i20"
"§j21"
"§m22"
"§n23"
"§p24"
"§q25"
"§s26"
"§t27"
"§u28"
"§v29"
"§z30"
"§r31"
) # fmt: skip
mcstatus-14.0.0/tests/motd/test_components.py 0000664 0000000 0000000 00000003357 15217522771 0021363 0 ustar 00root root 0000000 0000000 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_: str, rgb: tuple[int, int, int]):
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_: str, rgb: tuple[int, int, int]):
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"
mcstatus-14.0.0/tests/motd/test_motd_parse.py 0000664 0000000 0000000 00000022111 15217522771 0021320 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import pytest
from mcstatus.motd import Motd
from mcstatus.motd.components import (
BedrockFormatting,
BedrockMinecraftColor,
InvalidFormatting,
JavaFormatting,
JavaMinecraftColor,
ParsedMotdComponent,
TranslationTag,
)
from mcstatus.responses._raw import RawJavaResponseMotd, RawJavaResponseMotdWhenDict
class TestMotdParse:
def test_correct_result_bedrock(self, source_bedrock: RawJavaResponseMotd):
assert Motd.parse(source_bedrock, bedrock=True) == Motd(
[
"1",
BedrockMinecraftColor.BLACK, BedrockFormatting.OBFUSCATED, "2",
BedrockMinecraftColor.DARK_BLUE, BedrockFormatting.BOLD, "3",
BedrockMinecraftColor.DARK_GREEN, BedrockFormatting.ITALIC, "4",
BedrockMinecraftColor.DARK_AQUA, "5",
BedrockMinecraftColor.DARK_RED, "6",
BedrockMinecraftColor.DARK_PURPLE, "7",
BedrockMinecraftColor.GOLD, "8",
BedrockMinecraftColor.GRAY, "9",
BedrockMinecraftColor.DARK_GRAY, "10",
BedrockMinecraftColor.BLUE, "11",
BedrockMinecraftColor.GREEN, "12",
BedrockMinecraftColor.AQUA, "13",
BedrockMinecraftColor.RED, "14",
BedrockMinecraftColor.LIGHT_PURPLE, "15",
BedrockMinecraftColor.YELLOW, "16",
BedrockMinecraftColor.WHITE, "17",
BedrockMinecraftColor.MINECOIN_GOLD, "18",
BedrockMinecraftColor.MATERIAL_QUARTZ, "19",
BedrockMinecraftColor.MATERIAL_IRON, "20",
BedrockMinecraftColor.MATERIAL_NETHERITE, "21",
BedrockMinecraftColor.MATERIAL_REDSTONE, "22",
BedrockMinecraftColor.MATERIAL_COPPER, "23",
BedrockMinecraftColor.MATERIAL_GOLD, "24",
BedrockMinecraftColor.MATERIAL_EMERALD, "25",
BedrockMinecraftColor.MATERIAL_DIAMOND, "26",
BedrockMinecraftColor.MATERIAL_LAPIS, "27",
BedrockMinecraftColor.MATERIAL_AMETHYST, "28",
BedrockMinecraftColor.MATERIAL_RESIN, "29",
InvalidFormatting("z"), "30",
BedrockFormatting.RESET, "31",
],
bedrock=True,
raw=source_bedrock,
) # fmt: skip
@pytest.mark.parametrize(
("bedrock", "expected"), [(True, BedrockMinecraftColor.MINECOIN_GOLD), (False, InvalidFormatting("g"))]
)
def test_parse_as_str_ignore_minecoin_gold_on_java(self, bedrock: bool, expected: ParsedMotdComponent):
assert Motd.parse("&g", bedrock=bedrock).parsed == [expected]
@pytest.mark.parametrize(
("bedrock", "expected"), [(True, BedrockMinecraftColor.MATERIAL_IRON), (False, InvalidFormatting("i"))]
)
def test_parse_as_str_ignore_material_colors_on_java(self, bedrock: bool, expected: ParsedMotdComponent):
assert Motd.parse("&i", bedrock=bedrock).parsed == [expected]
@pytest.mark.parametrize(
("bedrock", "expected"), [(True, BedrockMinecraftColor.MATERIAL_COPPER), (False, JavaFormatting.UNDERLINED)]
)
def test_parse_as_str_underlined_on_java(self, bedrock: bool, expected: ParsedMotdComponent):
assert Motd.parse("&n", bedrock=bedrock).parsed == [expected]
def test_parse_incorrect_formatting(self):
assert Motd.parse("&z").parsed == [InvalidFormatting("z")]
def test_parse_uppercase_passes(self):
assert Motd.parse("&A").parsed == [JavaMinecraftColor.GREEN]
@pytest.mark.parametrize(
("input_", "expected"),
[("", []), ([], [JavaFormatting.RESET]), ({"extra": [], "text": ""}, [JavaFormatting.RESET])],
)
def test_empty_input_also_empty_raw(self, input_: RawJavaResponseMotd, expected: list[ParsedMotdComponent]):
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 == [
JavaFormatting.BOLD,
"top",
JavaFormatting.RESET,
JavaFormatting.BOLD,
JavaMinecraftColor.RED,
"not top",
JavaFormatting.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 == [
JavaFormatting.BOLD,
"bold",
JavaFormatting.RESET,
JavaMinecraftColor.RED,
"not bold",
JavaFormatting.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 == [
JavaFormatting.BOLD,
"top",
JavaFormatting.RESET,
JavaFormatting.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 == [
JavaMinecraftColor.RED,
"not bold",
JavaFormatting.RESET,
]
def test_translate_string(self):
assert Motd.parse(RawJavaResponseMotdWhenDict(translate="the key")).parsed == [
TranslationTag("the key"),
JavaFormatting.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 == [JavaMinecraftColor.GREEN, "Hello!", JavaFormatting.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_parse_invalid_color(self):
with pytest.raises(ValueError, match=r"^Unable to parse color: 'a', report this!$"):
_ = Motd._parse_color("a", bedrock=False)
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 == [
JavaFormatting.RESET, JavaFormatting.RESET, JavaFormatting.RESET,
"1",
JavaFormatting.RESET, JavaFormatting.RESET,
"2",
JavaFormatting.RESET, JavaFormatting.RESET,
"3",
JavaFormatting.RESET, JavaFormatting.RESET, JavaFormatting.RESET,
"4",
JavaFormatting.RESET, JavaFormatting.RESET,
"5",
JavaFormatting.RESET, JavaFormatting.RESET,
"6",
JavaFormatting.RESET, JavaFormatting.RESET, JavaFormatting.RESET,
"7",
JavaFormatting.RESET, JavaFormatting.RESET,
"8",
JavaFormatting.RESET, JavaFormatting.RESET,
"9",
JavaFormatting.RESET,
] # fmt: skip
def test_raw_attribute(self, source_bedrock: RawJavaResponseMotd):
motd = Motd.parse(source_bedrock, bedrock=True)
assert motd.raw == source_bedrock
mcstatus-14.0.0/tests/motd/test_simplifies.py 0000664 0000000 0000000 00000020247 15217522771 0021337 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from contextlib import ExitStack
from typing import Any
from unittest import mock
import pytest
from mcstatus.motd import Motd
from mcstatus.motd._simplifies import (
get_double_colors,
get_double_items,
get_end_non_text,
get_formatting_before_color,
get_meaningless_resets_and_colors,
get_unused_elements,
)
from mcstatus.motd.components import JavaFormatting, JavaMinecraftColor, ParsedMotdComponent, 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_end_non_text.__name__,
]
]
_ = get_unused_elements([])
for simplifier in mocked:
simplifier.assert_called()
def test_simplify_returns_new_instance(self):
parsed: list[ParsedMotdComponent] = [JavaFormatting.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([JavaFormatting.BOLD, JavaFormatting.RESET, JavaMinecraftColor.RED], raw="")
assert obj.simplify() == Motd([], raw="")
@pytest.mark.parametrize("first", [JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
@pytest.mark.parametrize("second", [JavaMinecraftColor.BLUE, WebColor.from_hex(hex="#dd0220")])
def test_get_double_colors(self, first: ParsedMotdComponent, second: ParsedMotdComponent):
assert get_double_colors([first, second]) == {0}
@pytest.mark.parametrize("first", [JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
@pytest.mark.parametrize("second", [JavaMinecraftColor.BLUE, WebColor.from_hex(hex="#dd0220")])
@pytest.mark.parametrize("third", [JavaMinecraftColor.BLUE, WebColor.from_hex(hex="dd0220")])
def test_get_double_colors_with_three_items(
self,
first: ParsedMotdComponent,
second: ParsedMotdComponent,
third: ParsedMotdComponent,
):
assert get_double_colors([first, second, third]) == {0, 1}
@pytest.mark.parametrize("first", [JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
@pytest.mark.parametrize("second", [JavaMinecraftColor.BLUE, WebColor.from_hex(hex="#dd0220")])
def test_get_double_colors_with_no_double_colors(self, first: ParsedMotdComponent, second: ParsedMotdComponent):
assert get_double_colors([first, "foo", second]) == set()
@pytest.mark.parametrize("item", [JavaFormatting.BOLD, JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
def test_get_double_items(self, item: ParsedMotdComponent):
assert get_double_items([item, item]) == {0}
@pytest.mark.parametrize("item", [JavaFormatting.BOLD, JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
def test_get_double_items_with_three_items(self, item: ParsedMotdComponent):
assert get_double_items([item, item, item]) == {0, 1}
@pytest.mark.parametrize("item", [JavaFormatting.BOLD, JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
def test_get_double_items_with_no_double_items(self, item: ParsedMotdComponent):
assert get_double_items([item, "foo", item]) == set()
@pytest.mark.parametrize("last_item", [JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
def test_get_formatting_before_color(self, last_item: ParsedMotdComponent):
assert get_formatting_before_color([JavaFormatting.BOLD, last_item]) == {0}
@pytest.mark.parametrize(
"first_item", [JavaFormatting.RESET, JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000"), "abc"]
)
def test_get_formatting_before_color_without_formatting_before_color(self, first_item: ParsedMotdComponent):
assert get_formatting_before_color([first_item, "abc", JavaMinecraftColor.WHITE]) == set()
def test_skip_get_formatting_before_color(self):
assert get_formatting_before_color(["abc", JavaFormatting.BOLD, "def", JavaFormatting.RESET, "ghi"]) == set()
@pytest.mark.parametrize("last_item", [JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
def test_get_formatting_before_color_if_space_between(self, last_item: ParsedMotdComponent):
assert get_formatting_before_color([JavaFormatting.BOLD, " ", last_item]) == {0}
def test_two_formattings_before_minecraft_color(self):
"""See `https://github.com/py-mine/mcstatus/pull/335#discussion_r1048476090`_."""
assert get_formatting_before_color([JavaFormatting.BOLD, JavaFormatting.ITALIC, JavaMinecraftColor.RED]) == {0, 1}
def test_two_formattings_one_by_one(self):
obj = Motd([JavaFormatting.BOLD, JavaFormatting.ITALIC], raw="")
assert obj.simplify().parsed == []
@pytest.mark.parametrize("last_item", [JavaFormatting.RESET, JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
def test_non_text_in_the_end(self, last_item: ParsedMotdComponent):
assert get_end_non_text(["abc", JavaFormatting.BOLD, "def", JavaFormatting.RESET, "ghi", last_item]) == {5}
def test_translation_tag_in_the_end(self):
assert (
get_end_non_text(["abc", JavaFormatting.BOLD, "def", JavaFormatting.RESET, "ghi", TranslationTag("key")]) == set()
)
@pytest.mark.parametrize("item", [JavaFormatting.BOLD, JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
def test_meaningless_resets_and_colors_active(self, item: ParsedMotdComponent):
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", JavaFormatting.RESET, "bar"]) == {1}
@pytest.mark.parametrize("item", [JavaFormatting.BOLD, JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")])
def test_meaningless_resets_and_colors_resets(self, item: ParsedMotdComponent):
assert get_meaningless_resets_and_colors([item, "foo", JavaFormatting.RESET, item, "bar"]) == set()
def test_no_conflict_on_popping_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(*_: Any, **__: Any) -> set[int]:
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_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 == [JavaMinecraftColor.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", JavaFormatting.RESET, "123", JavaFormatting.RESET]
assert motd.simplify().parsed == ["123123"]
def test_squash_nearby_strings(self):
assert Motd(["123", "123", "123"], raw="").simplify().parsed == ["123123123"]
mcstatus-14.0.0/tests/motd/test_transformers.py 0000664 0000000 0000000 00000023570 15217522771 0021722 0 ustar 00root root 0000000 0000000 # ruff: noqa: FBT003 # boolean positional value in `result` fixture
from __future__ import annotations
import typing
from collections.abc import Callable
import pytest
from mcstatus.motd import Motd
from mcstatus.motd._transformers import _NothingTransformer
from mcstatus.responses._raw import RawJavaResponseMotd
MotdParseFunc: typing.TypeAlias = Callable[[RawJavaResponseMotd], str]
MotdParseFuncBedrockFlag: typing.TypeAlias = Callable[[RawJavaResponseMotd, bool], str]
def test_nothing_transformer():
assert _NothingTransformer(bedrock=False).transform(Motd.parse("&1a&bfo&zo&r").parsed) == ""
class TestMotdPlain:
@pytest.fixture(scope="class")
@staticmethod
def result() -> MotdParseFunc:
return lambda text: Motd.parse(text).to_plain()
def test_plain_text(self, result: MotdParseFunc):
assert result("plain") == "plain"
def test_removes_colors(self, result: MotdParseFunc):
assert result("&1<ext") == "text"
def test_skip_web_colors(self, result: MotdParseFunc):
assert result({"extra": [{"color": "#4000ff", "text": "colored text"}], "text": ""}) == "colored text"
def test_skip_minecraft_colors(self, result: MotdParseFunc):
assert result({"extra": [{"color": "red", "text": "colored text"}], "text": ""}) == "colored text"
class TestMotdMinecraft:
@pytest.fixture(scope="class")
@staticmethod
def result() -> MotdParseFunc:
return lambda text: Motd.parse(text).to_minecraft()
@pytest.mark.parametrize("motd", ["&1&2&3&z", "§123§5bc&z", "§1§2§3§z"])
@staticmethod
def test_return_the_same(motd: str, result: MotdParseFunc):
assert result(motd) == motd.replace("&", "§")
def test_skip_web_colors(self, result: MotdParseFunc):
assert result({"extra": [{"color": "#4000ff", "text": "colored text"}], "text": ""}) == "§rcolored text§r"
class TestMotdHTML:
@pytest.fixture(scope="class")
@staticmethod
def result() -> MotdParseFuncBedrockFlag:
return lambda text, bedrock: Motd.parse(text, bedrock=bedrock).to_html()
def test_correct_output_java(self, result: MotdParseFuncBedrockFlag, source_java: RawJavaResponseMotd):
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: MotdParseFuncBedrockFlag, source_bedrock: RawJavaResponseMotd):
assert result(source_bedrock, True) == (
""
"1"
"2"
"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"
"2930"
"31"
"
"
)
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")
@staticmethod
def result() -> MotdParseFuncBedrockFlag:
return lambda text, bedrock: Motd.parse(text, bedrock=bedrock).to_ansi()
def test_correct_output_java(self, result: MotdParseFuncBedrockFlag, source_java: RawJavaResponseMotd):
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: MotdParseFuncBedrockFlag, source_bedrock: RawJavaResponseMotd):
assert result(source_bedrock, True) == (
"\033[0m"
"1\033[0m"
"\033[38;2;0;0;0m\033[5m2"
"\033[0m\033[38;2;0;0;170m\033[1m3"
"\033[0m\033[38;2;0;170;0m\033[3m4"
"\033[0m\033[38;2;0;170;170m5"
"\033[0m\033[38;2;170;0;0m6"
"\033[0m\033[38;2;170;0;170m7"
"\033[0m\033[38;2;255;170;0m8"
"\033[0m\033[38;2;198;198;198m9"
"\033[0m\033[38;2;85;85;85m10"
"\033[0m\033[38;2;85;85;255m11"
"\033[0m\033[38;2;85;255;85m12"
"\033[0m\033[38;2;85;255;255m13"
"\033[0m\033[38;2;255;85;85m14"
"\033[0m\033[38;2;255;85;255m15"
"\033[0m\033[38;2;255;255;85m16"
"\033[0m\033[38;2;255;255;255m17"
"\033[0m\033[38;2;221;214;5m18"
"\033[0m\033[38;2;227;212;209m19"
"\033[0m\033[38;2;206;202;202m20"
"\033[0m\033[38;2;68;58;59m21"
"\033[0m\033[38;2;151;22;7m22"
"\033[0m\033[38;2;180;104;77m23"
"\033[0m\033[38;2;222;177;45m24"
"\033[0m\033[38;2;17;159;54m25"
"\033[0m\033[38;2;44;186;168m26"
"\033[0m\033[38;2;33;73;123m27"
"\033[0m\033[38;2;154;92;198m28"
"\033[0m\033[38;2;235;114;20m29"
"30\033[0m31\033[0m"
)
mcstatus-14.0.0/tests/net/ 0000775 0000000 0000000 00000000000 15217522771 0015400 5 ustar 00root root 0000000 0000000 mcstatus-14.0.0/tests/net/__init__.py 0000664 0000000 0000000 00000000000 15217522771 0017477 0 ustar 00root root 0000000 0000000 mcstatus-14.0.0/tests/net/test_address.py 0000664 0000000 0000000 00000027251 15217522771 0020445 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import ipaddress
import sys
from pathlib import Path
from typing import Any, TYPE_CHECKING, cast, final
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
if TYPE_CHECKING:
from dns.exception import DNSException
@final
class TestSRVLookup:
@pytest.mark.parametrize("exception", [dns.resolver.NXDOMAIN, dns.resolver.NoAnswer])
def test_address_no_srv(self, exception: DNSException):
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: DNSException):
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.parametrize("exception", [dns.resolver.NXDOMAIN, dns.resolver.NoAnswer])
async def test_async_address_no_srv(self, exception: DNSException):
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.parametrize("exception", [dns.resolver.NXDOMAIN, dns.resolver.NoAnswer])
async def test_async_address_no_srv_no_default_port(self, exception: DNSException):
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)
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
@final
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: str, port: int):
Address._ensure_validity(address, port)
@pytest.mark.parametrize(
("address", "port"),
[
("example.org", 100_000),
("example.org", -1),
],
)
def test_address_validation_range(self, address: str, port: int):
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: Any, port: Any):
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@#")
@final
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")
@final
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"
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.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
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
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")
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")
mcstatus-14.0.0/tests/protocol/ 0000775 0000000 0000000 00000000000 15217522771 0016453 5 ustar 00root root 0000000 0000000 mcstatus-14.0.0/tests/protocol/__init__.py 0000664 0000000 0000000 00000000000 15217522771 0020552 0 ustar 00root root 0000000 0000000 mcstatus-14.0.0/tests/protocol/helpers.py 0000664 0000000 0000000 00000012411 15217522771 0020466 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import asyncio
from typing import Any, ParamSpec, TYPE_CHECKING, TypeVar, final
from mcstatus._protocol.io.buffer import Buffer
from mcstatus._protocol.io.connection import BaseAsyncConnection, BaseSyncConnection
if TYPE_CHECKING:
from collections.abc import Callable, Coroutine
from typing_extensions import override
else:
override = lambda f: f # noqa: E731
P = ParamSpec("P")
T = TypeVar("T")
def async_decorator(f: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]:
"""Wrap an async callable so it can be invoked from synchronous tests."""
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
"""Execute the wrapped coroutine function using ``asyncio.run``."""
return asyncio.run(f(*args, **kwargs))
return wrapper
@final
class SyncBufferConnection(BaseSyncConnection):
"""In-memory synchronous stream-style transport used by protocol tests."""
def __init__(self, incoming: bytes | bytearray = b"") -> None:
"""Initialize in-memory read/write buffers with optional incoming bytes."""
self.sent = Buffer()
self.received = Buffer(incoming)
@override
def read(self, length: int, /) -> bytes:
"""Read ``length`` bytes from the queued incoming data."""
return self.received.read(length)
@override
def write(self, data: bytes | bytearray, /) -> None:
"""Append outgoing payload data to the send buffer."""
self.sent.write(data)
def receive(self, data: bytes | bytearray) -> None:
"""Queue incoming payload data for subsequent reads."""
self.received.write(data)
def remaining(self) -> int:
"""Return unread byte count from the incoming buffer."""
return self.received.remaining
def flush(self) -> bytes:
"""Return and clear all bytes written to the send buffer."""
return self.sent.flush()
@final
class AsyncBufferConnection(BaseAsyncConnection):
"""In-memory asynchronous stream-style transport used by protocol tests."""
def __init__(self, incoming: bytes | bytearray = b"") -> None:
"""Initialize in-memory read/write buffers with optional incoming bytes."""
self.sent = Buffer()
self.received = Buffer(incoming)
@override
async def read(self, length: int, /) -> bytes:
"""Read ``length`` bytes from the queued incoming data."""
return self.received.read(length)
@override
async def write(self, data: bytes | bytearray, /) -> None:
"""Append outgoing payload data to the send buffer."""
self.sent.write(data)
def receive(self, data: bytes | bytearray) -> None:
"""Queue incoming payload data for subsequent reads."""
self.received.write(data)
def remaining(self) -> int:
"""Return unread byte count from the incoming buffer."""
return self.received.remaining
def flush(self) -> bytes:
"""Return and clear all bytes written to the send buffer."""
return self.sent.flush()
@final
class SyncDatagramConnection(BaseSyncConnection):
"""Datagram-like synchronous transport returning one queued packet per read."""
def __init__(self) -> None:
self.sent = Buffer()
self.received: list[bytes] = []
@override
def read(self, _length: int, /) -> bytes:
"""Pop and return the next queued datagram payload."""
if not self.received:
raise OSError("No datagram data to read.")
return self.received.pop(0)
@override
def write(self, data: bytes | bytearray, /) -> None:
"""Append outgoing datagram payload to the send buffer."""
self.sent.write(data)
def receive(self, data: bytes | bytearray) -> None:
"""Queue one incoming datagram payload for future reads."""
self.received.append(bytes(data))
def remaining(self) -> int:
"""Return size of the next queued datagram, or ``0`` when empty."""
if not self.received:
return 0
return len(self.received[0])
def flush(self) -> bytes:
"""Return and clear all bytes written to the send buffer."""
return self.sent.flush()
@final
class AsyncDatagramConnection(BaseAsyncConnection):
"""Datagram-like asynchronous transport returning one queued packet per read."""
def __init__(self) -> None:
self.sent = Buffer()
self.received: list[bytes] = []
@override
async def read(self, _length: int, /) -> bytes:
"""Pop and return the next queued datagram payload."""
if not self.received:
raise OSError("No datagram data to read.")
return self.received.pop(0)
@override
async def write(self, data: bytes | bytearray, /) -> None:
"""Append outgoing datagram payload to the send buffer."""
self.sent.write(data)
def receive(self, data: bytes | bytearray) -> None:
"""Queue one incoming datagram payload for future reads."""
self.received.append(bytes(data))
def remaining(self) -> int:
"""Return size of the next queued datagram, or ``0`` when empty."""
if not self.received:
return 0
return len(self.received[0])
def flush(self) -> bytes:
"""Return and clear all bytes written to the send buffer."""
return self.sent.flush()
mcstatus-14.0.0/tests/protocol/test_base_io.py 0000664 0000000 0000000 00000040072 15217522771 0021470 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import struct
from inspect import isawaitable
from typing import TYPE_CHECKING, TypeVar, overload
from unittest.mock import AsyncMock, Mock
import pytest
from mcstatus._protocol.io.base_io import INT_FORMATS_TYPE, StructFormat
from mcstatus._protocol.io.buffer import Buffer
from tests.protocol.helpers import AsyncBufferConnection, SyncBufferConnection
if TYPE_CHECKING:
from collections.abc import Awaitable
ConnectionClass = type[SyncBufferConnection] | type[AsyncBufferConnection]
T = TypeVar("T")
@pytest.fixture(
params=[
pytest.param(SyncBufferConnection, id="sync"),
pytest.param(AsyncBufferConnection, id="async"),
]
)
def conn_cls(request: pytest.FixtureRequest) -> ConnectionClass:
"""Provide a parametrized sync/async connection class for each test."""
return request.param
@overload
async def maybe_await(value: Awaitable[T], /) -> T: ...
@overload
async def maybe_await(value: T, /) -> T: ...
async def maybe_await(value: Awaitable[T] | T, /) -> T:
"""Return a value directly or await it when needed.
This keeps test calls explicit and type-checkable for both sync and async IO APIs.
"""
if isawaitable(value):
return await value
return value
@pytest.mark.parametrize(
("fmt", "value", "expected"),
[
pytest.param(StructFormat.UBYTE, 0, b"\x00", id="ubyte-0"),
pytest.param(StructFormat.UBYTE, 15, b"\x0f", id="ubyte-15"),
pytest.param(StructFormat.UBYTE, 255, b"\xff", id="ubyte-max"),
pytest.param(StructFormat.BYTE, 0, b"\x00", id="byte-0"),
pytest.param(StructFormat.BYTE, 15, b"\x0f", id="byte-15"),
pytest.param(StructFormat.BYTE, 127, b"\x7f", id="byte-max"),
pytest.param(StructFormat.BYTE, -20, b"\xec", id="byte-neg-20"),
pytest.param(StructFormat.BYTE, -128, b"\x80", id="byte-min"),
],
)
async def test_write_value_matches_reference(
conn_cls: ConnectionClass,
fmt: INT_FORMATS_TYPE,
value: int,
expected: bytes,
):
conn = conn_cls()
_ = await maybe_await(conn.write_value(fmt, value))
assert conn.flush() == expected
async def test_write_value_char_uses_single_byte(conn_cls: ConnectionClass):
conn = conn_cls()
_ = await maybe_await(conn.write_value(StructFormat.CHAR, b"a"))
assert conn.flush() == b"a"
async def test_write_value_char_rejects_non_single_byte(conn_cls: ConnectionClass):
conn = conn_cls()
with pytest.raises(struct.error):
_ = await maybe_await(conn.write_value(StructFormat.CHAR, b"ab"))
@pytest.mark.parametrize(
("fmt", "value"),
[
pytest.param(StructFormat.UBYTE, -1, id="ubyte-neg"),
pytest.param(StructFormat.UBYTE, 256, id="ubyte-overflow"),
pytest.param(StructFormat.BYTE, -129, id="byte-underflow"),
pytest.param(StructFormat.BYTE, 128, id="byte-overflow"),
],
)
async def test_write_value_rejects_out_of_range(conn_cls: ConnectionClass, fmt: INT_FORMATS_TYPE, value: int):
conn = conn_cls()
with pytest.raises(struct.error):
_ = await maybe_await(conn.write_value(fmt, value))
@pytest.mark.parametrize(
("encoded", "fmt", "expected"),
[
pytest.param(b"\x00", StructFormat.UBYTE, 0, id="ubyte-0"),
pytest.param(b"\x0f", StructFormat.UBYTE, 15, id="ubyte-15"),
pytest.param(b"\xff", StructFormat.UBYTE, 255, id="ubyte-max"),
pytest.param(b"\x00", StructFormat.BYTE, 0, id="byte-0"),
pytest.param(b"\x0f", StructFormat.BYTE, 15, id="byte-15"),
pytest.param(b"\x7f", StructFormat.BYTE, 127, id="byte-max"),
pytest.param(b"\xec", StructFormat.BYTE, -20, id="byte-neg-20"),
pytest.param(b"\x80", StructFormat.BYTE, -128, id="byte-min"),
],
)
async def test_read_value_matches_reference(
conn_cls: ConnectionClass,
encoded: bytes,
fmt: INT_FORMATS_TYPE,
expected: int,
):
conn = conn_cls(encoded)
assert await maybe_await(conn.read_value(fmt)) == expected
async def test_read_value_char_returns_bytes(conn_cls: ConnectionClass):
conn = conn_cls(b"a")
value = await maybe_await(conn.read_value(StructFormat.CHAR))
assert value == b"a"
assert isinstance(value, bytes)
@pytest.mark.parametrize(
("number", "expected"),
[
pytest.param(0, b"\x00", id="0"),
pytest.param(127, b"\x7f", id="127"),
pytest.param(128, b"\x80\x01", id="128"),
pytest.param(255, b"\xff\x01", id="255"),
pytest.param(1_000_000, b"\xc0\x84\x3d", id="1m"),
pytest.param((2**31) - 1, b"\xff\xff\xff\xff\x07", id="max-32"),
],
)
async def test_write_varuint_matches_reference(conn_cls: ConnectionClass, number: int, expected: bytes):
conn = conn_cls()
_ = await maybe_await(conn._write_varuint(number))
assert conn.flush() == expected
@pytest.mark.parametrize(
("encoded", "expected"),
[
pytest.param(b"\x00", 0, id="0"),
pytest.param(b"\x7f", 127, id="127"),
pytest.param(b"\x80\x01", 128, id="128"),
pytest.param(b"\xff\x01", 255, id="255"),
pytest.param(b"\xc0\x84\x3d", 1_000_000, id="1m"),
pytest.param(b"\xff\xff\xff\xff\x07", (2**31) - 1, id="max-32"),
],
)
async def test_read_varuint_matches_reference(conn_cls: ConnectionClass, encoded: bytes, expected: int):
conn = conn_cls(encoded)
assert await maybe_await(conn._read_varuint()) == expected
@pytest.mark.parametrize(
("number", "max_bits"),
[
(-1, 128),
(-1, 1),
(2**16, 16),
(2**32, 32),
],
)
async def test_write_varuint_rejects_out_of_range(conn_cls: ConnectionClass, number: int, max_bits: int):
conn = conn_cls()
with pytest.raises(ValueError, match=r"outside of the range of"):
_ = await maybe_await(conn._write_varuint(number, max_bits=max_bits))
@pytest.mark.parametrize(
("encoded", "max_bits"),
[
(b"\x80\x80\x04", 16),
(b"\x80\x80\x80\x80\x10", 32),
],
)
async def test_read_varuint_rejects_out_of_range(conn_cls: ConnectionClass, encoded: bytes, max_bits: int):
conn = conn_cls(encoded)
with pytest.raises(OSError, match=r"outside the range of"):
_ = await maybe_await(conn._read_varuint(max_bits=max_bits))
@pytest.mark.parametrize(
("encoded", "max_bits", "max_bytes"),
[
(b"\x80\x80\x80\x00", 16, 3),
(b"\x80\x80\x80\x80\x80\x00", 32, 5),
],
)
async def test_read_varuint_rejects_too_many_bytes(
conn_cls: ConnectionClass,
encoded: bytes,
max_bits: int,
max_bytes: int,
):
conn = conn_cls(encoded)
with pytest.raises(
OSError,
match=rf"^Received varint had too many bytes for {max_bits}-bit int \(continuation bit set on byte {max_bytes}\)\.$",
):
_ = await maybe_await(conn._read_varuint(max_bits=max_bits))
@pytest.mark.parametrize(
("number", "expected"),
[
pytest.param(127, b"\x7f", id="127"),
pytest.param(16_384, b"\x80\x80\x01", id="16384"),
pytest.param(-128, b"\x80\xff\xff\xff\x0f", id="-128"),
pytest.param(-16_383, b"\x81\x80\xff\xff\x0f", id="-16383"),
],
)
async def test_write_varint_matches_reference(conn_cls: ConnectionClass, number: int, expected: bytes):
conn = conn_cls()
_ = await maybe_await(conn.write_varint(number))
assert conn.flush() == expected
@pytest.mark.parametrize(
("encoded", "expected"),
[
pytest.param(b"\x7f", 127, id="127"),
pytest.param(b"\x80\x80\x01", 16_384, id="16384"),
pytest.param(b"\x80\xff\xff\xff\x0f", -128, id="-128"),
pytest.param(b"\x81\x80\xff\xff\x0f", -16_383, id="-16383"),
],
)
async def test_read_varint_matches_reference(conn_cls: ConnectionClass, encoded: bytes, expected: int):
conn = conn_cls(encoded)
assert await maybe_await(conn.read_varint()) == expected
@pytest.mark.parametrize(
("number", "expected"),
[
pytest.param(127, b"\x7f", id="127"),
pytest.param(16_384, b"\x80\x80\x01", id="16384"),
pytest.param(-128, b"\x80\xff\xff\xff\xff\xff\xff\xff\xff\x01", id="-128"),
pytest.param(-16_383, b"\x81\x80\xff\xff\xff\xff\xff\xff\xff\x01", id="-16383"),
],
)
async def test_write_varlong_matches_reference(conn_cls: ConnectionClass, number: int, expected: bytes):
conn = conn_cls()
_ = await maybe_await(conn.write_varlong(number))
assert conn.flush() == expected
@pytest.mark.parametrize(
("encoded", "expected"),
[
pytest.param(b"\x7f", 127, id="127"),
pytest.param(b"\x80\x80\x01", 16_384, id="16384"),
pytest.param(b"\x80\xff\xff\xff\xff\xff\xff\xff\xff\x01", -128, id="-128"),
pytest.param(b"\x81\x80\xff\xff\xff\xff\xff\xff\xff\x01", -16_383, id="-16383"),
],
)
async def test_read_varlong_matches_reference(conn_cls: ConnectionClass, encoded: bytes, expected: int):
conn = conn_cls(encoded)
assert await maybe_await(conn.read_varlong()) == expected
@pytest.mark.parametrize("number", [0, 1, 127, 16_384, -1, -(2**31), (2**31) - 1])
async def test_varint_roundtrip(conn_cls: ConnectionClass, number: int):
conn = conn_cls()
_ = await maybe_await(conn.write_varint(number))
conn.receive(conn.flush())
assert await maybe_await(conn.read_varint()) == number
@pytest.mark.parametrize("number", [127, 16_384, -128, -16_383, -(2**63), (2**63) - 1])
async def test_varlong_roundtrip(conn_cls: ConnectionClass, number: int):
conn = conn_cls()
_ = await maybe_await(conn.write_varlong(number))
conn.receive(conn.flush())
assert await maybe_await(conn.read_varlong()) == number
@pytest.mark.parametrize("number", [-(2**63) - 1, 2**63])
async def test_write_varlong_rejects_out_of_range(conn_cls: ConnectionClass, number: int):
conn = conn_cls()
with pytest.raises(ValueError, match=r"out of range"):
_ = await maybe_await(conn.write_varlong(number))
async def test_optional_helpers(conn_cls: ConnectionClass):
conn = conn_cls()
if isinstance(conn, AsyncBufferConnection):
writer = AsyncMock(return_value="written")
assert await conn.write_optional(None, writer) is None
writer.assert_not_awaited()
assert conn.flush() == b"\x00"
assert await conn.write_optional("value", writer) == "written"
writer.assert_awaited_once_with("value")
assert conn.flush() == b"\x01"
reader = AsyncMock(return_value="parsed")
conn.receive(b"\x00")
assert await conn.read_optional(reader) is None
reader.assert_not_awaited()
conn.receive(b"\x01")
assert await conn.read_optional(reader) == "parsed"
reader.assert_awaited_once_with()
return
writer = Mock(return_value="written")
assert conn.write_optional(None, writer) is None
writer.assert_not_called()
assert conn.flush() == b"\x00"
assert conn.write_optional("value", writer) == "written"
writer.assert_called_once_with("value")
assert conn.flush() == b"\x01"
reader = Mock(return_value="parsed")
conn.receive(b"\x00")
assert conn.read_optional(reader) is None
reader.assert_not_called()
conn.receive(b"\x01")
assert conn.read_optional(reader) == "parsed"
reader.assert_called_once_with()
async def test_write_and_read_ascii(conn_cls: ConnectionClass):
conn = conn_cls()
_ = await maybe_await(conn.write_ascii("hello"))
conn.receive(conn.flush())
assert await maybe_await(conn.read_ascii()) == "hello"
async def test_write_and_read_bytearray(conn_cls: ConnectionClass):
conn = conn_cls()
data = b"\x00\x01hello\xff"
_ = await maybe_await(conn.write_bytearray(data))
conn.receive(conn.flush())
assert await maybe_await(conn.read_bytearray()) == data
@pytest.mark.parametrize(
("data", "expected"),
[
pytest.param(b"", b"\x00", id="empty"),
pytest.param(b"\x01", b"\x01\x01", id="single"),
pytest.param(b"hello\x00world", b"\x0bhello\x00world", id="with-null"),
pytest.param(b"\x01\x02\x03four\x05", b"\x08\x01\x02\x03four\x05", id="mixed"),
],
)
async def test_write_bytearray_matches_reference(conn_cls: ConnectionClass, data: bytes, expected: bytes):
conn = conn_cls()
_ = await maybe_await(conn.write_bytearray(data))
assert conn.flush() == expected
@pytest.mark.parametrize(
("encoded", "expected"),
[
pytest.param(b"\x00", b"", id="empty"),
pytest.param(b"\x01\x01", b"\x01", id="single"),
pytest.param(b"\x0bhello\x00world", b"hello\x00world", id="with-null"),
pytest.param(b"\x08\x01\x02\x03four\x05", b"\x01\x02\x03four\x05", id="mixed"),
],
)
async def test_read_bytearray_matches_reference(conn_cls: ConnectionClass, encoded: bytes, expected: bytes):
conn = conn_cls(encoded)
assert await maybe_await(conn.read_bytearray()) == expected
async def test_read_bytearray_rejects_negative_length(conn_cls: ConnectionClass):
conn = conn_cls(b"\xff\xff\xff\xff\x0f")
with pytest.raises(OSError, match=r"^Length prefix for byte arrays must be non-negative, got -1\.$"):
_ = await maybe_await(conn.read_bytearray())
@pytest.mark.parametrize(
("value", "expected"),
[
pytest.param("test", b"test\x00", id="test"),
pytest.param("a" * 100, b"a" * 100 + b"\x00", id="100-as"),
pytest.param("", b"\x00", id="empty"),
],
)
async def test_write_ascii_matches_reference(conn_cls: ConnectionClass, value: str, expected: bytes):
conn = conn_cls()
_ = await maybe_await(conn.write_ascii(value))
assert conn.flush() == expected
@pytest.mark.parametrize(
("encoded", "expected"),
[
pytest.param(b"test\x00", "test", id="test"),
pytest.param(b"a" * 100 + b"\x00", "a" * 100, id="100-as"),
pytest.param(b"\x00", "", id="empty"),
],
)
async def test_read_ascii_matches_reference(conn_cls: ConnectionClass, encoded: bytes, expected: str):
conn = conn_cls(encoded)
assert await maybe_await(conn.read_ascii()) == expected
@pytest.mark.parametrize(
("value", "expected"),
[
pytest.param("test", b"\x04test", id="test"),
pytest.param("a" * 100, b"\x64" + b"a" * 100, id="100-as"),
pytest.param("", b"\x00", id="empty"),
pytest.param("नमस्ते", b"\x12" + "नमस्ते".encode(), id="hindi"),
],
)
async def test_write_utf_matches_reference(conn_cls: ConnectionClass, value: str, expected: bytes):
conn = conn_cls()
_ = await maybe_await(conn.write_utf(value))
assert conn.flush() == expected
@pytest.mark.parametrize(
("encoded", "expected"),
[
pytest.param(b"\x04test", "test", id="test"),
pytest.param(b"\x64" + b"a" * 100, "a" * 100, id="100-as"),
pytest.param(b"\x00", "", id="empty"),
pytest.param(b"\x12" + "नमस्ते".encode(), "नमस्ते", id="hindi"),
],
)
async def test_read_utf_matches_reference(conn_cls: ConnectionClass, encoded: bytes, expected: str):
conn = conn_cls(encoded)
assert await maybe_await(conn.read_utf()) == expected
async def test_write_utf_rejects_too_many_characters(conn_cls: ConnectionClass):
conn = conn_cls()
with pytest.raises(ValueError, match=r"Maximum character limit for writing strings is 32767 characters"):
_ = await maybe_await(conn.write_utf("a" * 32768))
async def test_read_utf_rejects_too_many_bytes(conn_cls: ConnectionClass):
payload = Buffer()
payload.write_varint(131069)
conn = conn_cls(payload)
with pytest.raises(OSError, match=r"Maximum read limit for utf strings is 131068 bytes, got 131069"):
_ = await maybe_await(conn.read_utf())
async def test_read_utf_rejects_negative_length(conn_cls: ConnectionClass):
conn = conn_cls(b"\xff\xff\xff\xff\x0f")
with pytest.raises(OSError, match=r"^Length prefix for utf strings must be non-negative, got -1\.$"):
_ = await maybe_await(conn.read_utf())
async def test_read_utf_rejects_too_many_characters(conn_cls: ConnectionClass):
text = "a" * 32768
payload = Buffer()
payload.write_varint(len(text.encode("utf-8")))
payload.write(text.encode("utf-8"))
conn = conn_cls(payload)
with pytest.raises(OSError, match=r"Maximum read limit for utf strings is 32767 characters, got 32768"):
_ = await maybe_await(conn.read_utf())
mcstatus-14.0.0/tests/protocol/test_base_io_twos_complement.py 0000664 0000000 0000000 00000003756 15217522771 0024777 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import pytest
from mcstatus._protocol.io.base_io import from_twos_complement, to_twos_complement
TWOS_COMPLEMENT_CASES = [
(-128, 8, 0x80),
(-2, 8, 0xFE),
(-1, 8, 0xFF),
(0, 8, 0x00),
(1, 8, 0x01),
(127, 8, 0x7F),
(-(2**15), 16, 0x8000),
(-1, 16, 0xFFFF),
((2**15) - 1, 16, 0x7FFF),
(-(2**31), 32, 0x80000000),
(-1, 32, 0xFFFFFFFF),
((2**31) - 1, 32, 0x7FFFFFFF),
(-(2**63), 64, 0x8000000000000000),
(-9_876_543_210_123_456, 64, 0xFFDCE956165A0F40),
(-1, 64, 0xFFFFFFFFFFFFFFFF),
(0, 64, 0x0000000000000000),
(9_876_543_210_123_456, 64, 0x002316A9E9A5F0C0),
((2**63) - 1, 64, 0x7FFFFFFFFFFFFFFF),
]
@pytest.mark.parametrize(
("number", "bits", "expected_twos"),
TWOS_COMPLEMENT_CASES,
)
def test_to_twos_complement_matches_expected_values(number: int, bits: int, expected_twos: int):
assert to_twos_complement(number, bits=bits) == expected_twos
@pytest.mark.parametrize(
("twos_value", "bits", "expected_number"),
[(twos_value, bits, number) for number, bits, twos_value in TWOS_COMPLEMENT_CASES],
)
def test_from_twos_complement_matches_expected_values(twos_value: int, bits: int, expected_number: int):
assert from_twos_complement(twos_value, bits=bits) == expected_number
@pytest.mark.parametrize(
("number", "bits"),
[
(-129, 8),
(128, 8),
(-(2**31) - 1, 32),
(2**31, 32),
(-(2**63) - 1, 64),
(2**63, 64),
],
)
def test_to_twos_complement_rejects_out_of_range(number: int, bits: int):
with pytest.raises(ValueError, match=r"out of range"):
_ = to_twos_complement(number, bits=bits)
@pytest.mark.parametrize(
("number", "bits"),
[
(-1, 8),
(256, 8),
(2**32, 32),
(2**64, 64),
],
)
def test_from_twos_complement_rejects_out_of_range(number: int, bits: int):
with pytest.raises(ValueError, match=r"out of range"):
_ = from_twos_complement(number, bits=bits)
mcstatus-14.0.0/tests/protocol/test_bedrock_client.py 0000664 0000000 0000000 00000004366 15217522771 0023044 0 ustar 00root root 0000000 0000000 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.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
mcstatus-14.0.0/tests/protocol/test_connection.py 0000664 0000000 0000000 00000030013 15217522771 0022220 0 ustar 00root root 0000000 0000000 from collections.abc import Iterator
from typing import cast, final
from unittest.mock import Mock, patch
import pytest
from mcstatus._net.address import Address
from mcstatus._protocol.io.base_io import StructFormat
from mcstatus._protocol.io.buffer import Buffer
from mcstatus._protocol.io.connection import TCPSocketConnection, UDPSocketConnection
@final
class TestBuffer:
def setup_method(self):
self.connection = Buffer()
def test_flush(self):
self.connection.write(bytearray.fromhex("7FAABB"))
assert self.connection.flush() == bytearray.fromhex("7FAABB")
assert self.connection == bytearray()
def test_remaining(self):
self.connection.write(bytearray.fromhex("7FAABB"))
assert self.connection.remaining == 3
def test_reset(self):
self.connection.write(b"abcdef")
assert self.connection.read(3) == b"abc"
self.connection.reset()
assert self.connection.read(6) == b"abcdef"
def test_clear_only_already_read(self):
self.connection.write(b"abcdef")
assert self.connection.read(2) == b"ab"
self.connection.clear(only_already_read=True)
assert self.connection == bytearray(b"cdef")
assert self.connection.remaining == 4
def test_unread_view(self):
self.connection.write(b"abcdef")
assert self.connection.read(2) == b"ab"
assert bytes(self.connection.unread_view()) == b"cdef"
def test_flush_only_returns_unread_data(self):
self.connection.write(b"abcdef")
assert self.connection.read(2) == b"ab"
assert self.connection.flush() == b"cdef"
assert self.connection == bytearray()
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.write(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: str, value: int) -> None:
self.connection.write(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.write(bytearray.fromhex("FFFFFFFF10"))
with pytest.raises(IOError, match=r"^Received varint was outside the range of 32-bit int.$"):
_ = self.connection.read_varint()
def test_write_invalid_varint(self):
with pytest.raises(
ValueError,
match=r"^Can't convert number 2147483648 into 32-bit twos complement format - out of range$",
):
self.connection.write_varint(2147483648)
with pytest.raises(
ValueError,
match=r"^Can't convert number -2147483649 into 32-bit twos complement format - out of range$",
):
self.connection.write_varint(-2147483649)
def test_read_utf(self):
self.connection.write(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.write(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.write(bytearray.fromhex("8000"))
assert self.connection.read_value(StructFormat.SHORT) == -32768
def test_write_short_negative(self):
self.connection.write_value(StructFormat.SHORT, -32768)
assert self.connection.flush() == bytearray.fromhex("8000")
def test_read_short_positive(self):
self.connection.write(bytearray.fromhex("7FFF"))
assert self.connection.read_value(StructFormat.SHORT) == 32767
def test_write_short_positive(self):
self.connection.write_value(StructFormat.SHORT, 32767)
assert self.connection.flush() == bytearray.fromhex("7FFF")
def test_read_ushort_positive(self):
self.connection.write(bytearray.fromhex("8000"))
assert self.connection.read_value(StructFormat.USHORT) == 32768
def test_write_ushort_positive(self):
self.connection.write_value(StructFormat.USHORT, 32768)
assert self.connection.flush() == bytearray.fromhex("8000")
def test_read_int_negative(self):
self.connection.write(bytearray.fromhex("80000000"))
assert self.connection.read_value(StructFormat.INT) == -2147483648
def test_write_int_negative(self):
self.connection.write_value(StructFormat.INT, -2147483648)
assert self.connection.flush() == bytearray.fromhex("80000000")
def test_read_int_positive(self):
self.connection.write(bytearray.fromhex("7FFFFFFF"))
assert self.connection.read_value(StructFormat.INT) == 2147483647
def test_write_int_positive(self):
self.connection.write_value(StructFormat.INT, 2147483647)
assert self.connection.flush() == bytearray.fromhex("7FFFFFFF")
def test_read_uint_positive(self):
self.connection.write(bytearray.fromhex("80000000"))
assert self.connection.read_value(StructFormat.UINT) == 2147483648
def test_write_uint_positive(self):
self.connection.write_value(StructFormat.UINT, 2147483648)
assert self.connection.flush() == bytearray.fromhex("80000000")
def test_read_long_negative(self):
self.connection.write(bytearray.fromhex("8000000000000000"))
assert self.connection.read_value(StructFormat.LONGLONG) == -9223372036854775808
def test_write_long_negative(self):
self.connection.write_value(StructFormat.LONGLONG, -9223372036854775808)
assert self.connection.flush() == bytearray.fromhex("8000000000000000")
def test_read_long_positive(self):
self.connection.write(bytearray.fromhex("7FFFFFFFFFFFFFFF"))
assert self.connection.read_value(StructFormat.LONGLONG) == 9223372036854775807
def test_write_long_positive(self):
self.connection.write_value(StructFormat.LONGLONG, 9223372036854775807)
assert self.connection.flush() == bytearray.fromhex("7FFFFFFFFFFFFFFF")
def test_read_ulong_positive(self):
self.connection.write(bytearray.fromhex("8000000000000000"))
assert self.connection.read_value(StructFormat.ULONGLONG) == 9223372036854775808
def test_write_ulong_positive(self):
self.connection.write_value(StructFormat.ULONGLONG, 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.write(bytearray.fromhex(as_bytes))
assert self.connection.read_value(StructFormat.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_value(StructFormat.BOOL, as_bool)
assert self.connection.flush() == bytearray.fromhex(as_bytes)
def test_read_bytearray(self):
self.connection.write(bytearray.fromhex("027FAA"))
assert self.connection.read_bytearray() == bytearray.fromhex("7FAA")
def test_write_bytearray(self):
self.connection.write_bytearray(bytearray.fromhex("7FAA"))
assert self.connection.flush() == bytearray.fromhex("027FAA")
def test_read_empty(self):
with pytest.raises(
IOError,
match=r"^Requested to read more data than available. Read 0 bytes: bytearray\(b''\), out of 1 requested bytes.$",
):
_ = self.connection.read(1)
def test_read_not_enough(self):
self.connection.write(bytearray(b"a"))
with pytest.raises(
IOError,
match=r"^Requested to read more data than available. Read 1 bytes: bytearray\(b'a'\), out of 2 requested bytes.$",
):
_ = self.connection.read(2)
def test_read_negative_length(self):
with pytest.raises(IOError, match=r"^Requested to read a negative amount of data: -1\.$"):
_ = self.connection.read(-1)
class TestTCPSocketConnection:
@pytest.fixture(scope="class")
@staticmethod
def connection() -> Iterator[TCPSocketConnection]:
test_addr = Address("localhost", 1234)
socket = Mock()
socket.recv = Mock()
socket.sendall = Mock()
with patch("socket.create_connection") as create_connection:
create_connection.return_value = socket
with TCPSocketConnection(test_addr) as connection:
yield connection
def test_read(self, connection: TCPSocketConnection):
cast("Mock", connection.socket.recv).return_value = bytearray.fromhex("7FAA")
assert connection.read(2) == bytearray.fromhex("7FAA")
def test_read_empty(self, connection: TCPSocketConnection):
cast("Mock", 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: TCPSocketConnection):
cast("Mock", 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: TCPSocketConnection):
connection.write(bytearray.fromhex("7FAA"))
cast("Mock", connection.socket.sendall).assert_called_once_with(bytearray.fromhex("7FAA"))
class TestUDPSocketConnection:
@pytest.fixture(scope="class")
@staticmethod
def connection() -> Iterator[UDPSocketConnection]:
test_addr = Address("127.0.0.1", 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_remaining(self, connection: UDPSocketConnection):
assert connection.remaining == 65535
def test_read(self, connection: UDPSocketConnection):
cast("Mock", connection.socket.recvfrom).return_value = [bytearray.fromhex("7FAA")]
assert connection.read(2) == bytearray.fromhex("7FAA")
def test_read_empty(self, connection: UDPSocketConnection):
cast("Mock", connection.socket.recvfrom).return_value = []
with pytest.raises(IndexError, match=r"^list index out of range$"):
_ = connection.read(1)
def test_write(self, connection: UDPSocketConnection):
connection.write(bytearray.fromhex("7FAA"))
cast("Mock", connection.socket.sendto).assert_called_once_with(
bytearray.fromhex("7FAA"),
Address("127.0.0.1", 1234),
)
mcstatus-14.0.0/tests/protocol/test_java_client.py 0000664 0000000 0000000 00000012510 15217522771 0022342 0 ustar 00root root 0000000 0000000 import sys
import time
from typing import final
from unittest import mock
import pytest
from mcstatus._net.address import Address
from mcstatus._protocol.java_client import JavaClient
from tests.protocol.helpers import SyncBufferConnection
@final
class TestJavaClient:
def setup_method(self):
self.connection = SyncBufferConnection()
self.java_client = JavaClient(
self.connection, # pyright: ignore[reportArgumentType]
address=Address("localhost", 25565),
version=44,
)
def test_handshake(self):
self.java_client.handshake()
assert self.connection.flush() == bytearray.fromhex("0F002C096C6F63616C686F737463DD01")
def test_read_status(self):
self.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.connection.flush() == bytearray.fromhex("0100")
def test_read_status_invalid_json(self):
self.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.connection.receive(
# no motd, see also #922
bytearray.fromhex(
"4F004D7B22706C6179657273223A7B226D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616"
"D65223A22312E382D70726531222C2270726F746F636F6C223A34347D7D"
)
)
_ = self.java_client.read_status()
def test_read_status_invalid_status(self):
self.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.connection.receive(bytearray.fromhex("09010000000000DD7D1C"))
self.java_client.ping_token = 14515484
assert self.java_client.test_ping() >= 0
assert self.connection.flush() == bytearray.fromhex("09010000000000DD7D1C")
def test_test_ping_invalid(self):
self.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.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()
# Windows CI can occasionally measure <1ms despite a 1ms sleep;
# see https://github.com/py-mine/mcstatus/issues/442.
@pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32"))
def test_latency_is_real_number(self):
self.connection.receive(
bytearray.fromhex(
"7200707B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A7B2"
"26D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E382D70726531222C22"
"70726F746F636F6C223A34347D7D"
)
)
def mocked_read_bytearray() -> bytes:
time.sleep(0.001)
return original_read_bytearray()
original_read_bytearray = self.connection.read_bytearray
with mock.patch.object(self.connection, "read_bytearray", side_effect=mocked_read_bytearray):
# Latency should be in milliseconds, so somewhere just above 1
#
# We give it a pretty big leeway with the max here, as the MacOS CI runs can
# sometimes take quite long (upwards of 10s).
latency = self.java_client.read_status().latency
assert 1 <= latency <= 20
# Windows CI can occasionally measure <1ms despite a 1ms sleep;
# see https://github.com/py-mine/mcstatus/issues/442.
@pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32"))
def test_test_ping_is_in_milliseconds(self):
self.java_client.ping_token = 14515484
self.connection.receive(bytearray.fromhex("09010000000000DD7D1C"))
def mocked_read_bytearray() -> bytes:
time.sleep(0.001)
return original_read_bytearray()
original_read_bytearray = self.connection.read_bytearray
with mock.patch.object(self.connection, "read_bytearray", side_effect=mocked_read_bytearray):
# Latency should be in milliseconds, so somewhere just above 1
#
# We give it a pretty big leeway with the max here, as the MacOS CI runs can
# sometimes take quite long (upwards of 10s).
latency = self.java_client.test_ping()
assert 1 <= latency <= 20
mcstatus-14.0.0/tests/protocol/test_java_client_async.py 0000664 0000000 0000000 00000013330 15217522771 0023540 0 ustar 00root root 0000000 0000000 import asyncio
import sys
from typing import final
from unittest import mock
import pytest
from mcstatus._net.address import Address
from mcstatus._protocol.java_client import AsyncJavaClient
from tests.protocol.helpers import AsyncBufferConnection, async_decorator
@final
class TestAsyncJavaClient:
def setup_method(self):
self.connection = AsyncBufferConnection()
self.java_client = AsyncJavaClient(
self.connection, # pyright: ignore[reportArgumentType]
address=Address("localhost", 25565),
version=44,
)
def test_handshake(self):
async_decorator(self.java_client.handshake)()
assert self.connection.flush() == bytearray.fromhex("0F002C096C6F63616C686F737463DD01")
def test_read_status(self):
self.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.connection.flush() == bytearray.fromhex("0100")
def test_read_status_invalid_json(self):
self.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.connection.receive(
bytearray.fromhex(
"4F004D7B22706C6179657273223A7B226D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616"
"D65223A22312E382D70726531222C2270726F746F636F6C223A34347D7D"
)
)
_ = async_decorator(self.java_client.read_status)()
def test_read_status_invalid_status(self):
self.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.connection.receive(bytearray.fromhex("09010000000000DD7D1C"))
self.java_client.ping_token = 14515484
assert async_decorator(self.java_client.test_ping)() >= 0
assert self.connection.flush() == bytearray.fromhex("09010000000000DD7D1C")
def test_test_ping_invalid(self):
self.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.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)()
# Windows CI can occasionally measure <1ms despite a 1ms sleep;
# see https://github.com/py-mine/mcstatus/issues/442.
@pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32"))
async def test_latency_is_real_number(self):
self.connection.receive(
bytearray.fromhex(
"7200707B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A7B2"
"26D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E382D70726531222C22"
"70726F746F636F6C223A34347D7D"
)
)
async def mocked_read_bytearray() -> bytes:
"""Delay reads while still delegating to the real async connection implementation."""
await asyncio.sleep(0.001)
return await original_read_bytearray()
original_read_bytearray = self.connection.read_bytearray
with mock.patch.object(self.connection, "read_bytearray", side_effect=mocked_read_bytearray):
# Latency should be in milliseconds, so somewhere just above 1
#
# We give it a pretty big leeway with the max here, as the MacOS CI runs can
# sometimes take quite long (upwards of 10s).
latency = (await self.java_client.read_status()).latency
assert 1 <= latency <= 20
# Windows CI can occasionally measure <1ms despite a 1ms sleep;
# see https://github.com/py-mine/mcstatus/issues/442.
@pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32"))
async def test_test_ping_is_in_milliseconds(self):
self.java_client.ping_token = 14515484
self.connection.receive(bytearray.fromhex("09010000000000DD7D1C"))
async def mocked_read_bytearray() -> bytes:
"""Delay reads while still delegating to the real async connection implementation."""
await asyncio.sleep(0.001)
return await original_read_bytearray()
original_read_bytearray = self.connection.read_bytearray
with mock.patch.object(self.connection, "read_bytearray", side_effect=mocked_read_bytearray):
# Latency should be in milliseconds, so somewhere just above 1
#
# We give it a pretty big leeway with the max here, as the MacOS CI runs can
# sometimes take quite long (upwards of 10s).
latency = await self.java_client.test_ping()
assert 1 <= latency <= 20
mcstatus-14.0.0/tests/protocol/test_legacy_client.py 0000664 0000000 0000000 00000003220 15217522771 0022663 0 ustar 00root root 0000000 0000000 import pytest
from mcstatus._protocol.legacy_client import LegacyClient
from mcstatus.motd import Motd
from mcstatus.responses.legacy import LegacyStatusPlayers, LegacyStatusResponse, LegacyStatusVersion
from tests.protocol.helpers import SyncBufferConnection
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 = SyncBufferConnection()
socket.receive(bytearray.fromhex("00"))
server = LegacyClient(socket)
with pytest.raises(IOError, match=r"^Received invalid packet ID$"):
_ = server.read_status()
mcstatus-14.0.0/tests/protocol/test_query_client.py 0000664 0000000 0000000 00000011717 15217522771 0022576 0 ustar 00root root 0000000 0000000 from typing import final
from unittest.mock import Mock
from mcstatus._protocol.query_client import QueryClient
from mcstatus.motd import Motd
from tests.protocol.helpers import SyncDatagramConnection
@final
class TestQueryClient:
def setup_method(self):
self.connection = SyncDatagramConnection()
self.query_client = QueryClient(self.connection) # pyright: ignore[reportArgumentType]
def test_handshake(self):
self.connection.receive(bytearray.fromhex("090000000035373033353037373800"))
self.query_client.handshake()
conn_bytes = self.connection.flush()
assert conn_bytes[:3] == bytearray.fromhex("FEFD09")
assert self.query_client.challenge == 570350778
def test_query(self):
self.connection.receive(
bytearray.fromhex(
"00000000000000000000000000000000686f73746e616d650041204d696e656372616674205365727665720067616d6574797"
"06500534d500067616d655f6964004d494e4543524146540076657273696f6e00312e3800706c7567696e7300006d61700077"
"6f726c64006e756d706c61796572730033006d6178706c617965727300323000686f7374706f727400323535363500686f737"
"46970003139322e3136382e35362e31000001706c617965725f000044696e6e6572626f6e6500446a696e6e69626f6e650053"
"746576650000"
)
)
response = self.query_client.read_query()
conn_bytes = self.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.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.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.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.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.connection.receive(
bytearray.fromhex(
"00000000000000000000000000000000686f73746e616d6500006f746865720067616d657479706500534d500067616d655f6964004d"
"494e4543524146540076657273696f6e00312e31382e3100706c7567696e7300006d617000776f726c64006e756d706c617965727300"
"30006d6178706c617965727300323000686f7374706f727400323535363500686f73746970003137322e31372e302e32000001706c61"
"7965725f000000"
)
)
response = self.query_client.read_query()
_ = self.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.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.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
mcstatus-14.0.0/tests/protocol/test_query_client_async.py 0000664 0000000 0000000 00000003765 15217522771 0023777 0 ustar 00root root 0000000 0000000 from typing import final
from mcstatus._protocol.query_client import AsyncQueryClient
from tests.protocol.helpers import AsyncDatagramConnection, async_decorator
@final
class TestAsyncQueryClient:
def setup_method(self):
self.connection = AsyncDatagramConnection()
self.query_client = AsyncQueryClient(self.connection) # pyright: ignore[reportArgumentType]
def test_handshake(self):
self.connection.receive(bytearray.fromhex("090000000035373033353037373800"))
async_decorator(self.query_client.handshake)()
conn_bytes = self.connection.flush()
assert conn_bytes[:3] == bytearray.fromhex("FEFD09")
assert self.query_client.challenge == 570350778
def test_query(self):
self.connection.receive(
bytearray.fromhex(
"00000000000000000000000000000000686f73746e616d650041204d696e656372616674205365727665720067616d6574797"
"06500534d500067616d655f6964004d494e4543524146540076657273696f6e00312e3800706c7567696e7300006d61700077"
"6f726c64006e756d706c61796572730033006d6178706c617965727300323000686f7374706f727400323535363500686f737"
"46970003139322e3136382e35362e31000001706c617965725f000044696e6e6572626f6e6500446a696e6e69626f6e650053"
"746576650000"
)
)
response = async_decorator(self.query_client.read_query)()
conn_bytes = self.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"]
mcstatus-14.0.0/tests/protocol/test_timeout.py 0000664 0000000 0000000 00000006410 15217522771 0021553 0 ustar 00root root 0000000 0000000 import asyncio
import typing
from asyncio.exceptions import TimeoutError as AsyncioTimeoutError
from unittest.mock import AsyncMock, Mock, patch
import pytest
from mcstatus._net.address import Address
from mcstatus._protocol.io.connection import TCPAsyncSocketConnection
if typing.TYPE_CHECKING:
from typing_extensions import override
else:
override = lambda f: f # noqa: E731
class FakeAsyncStream(asyncio.StreamReader):
@override
async def read(self, *args: typing.Any, **kwargs: typing.Any) -> 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): # should be async without await # noqa: RUF029
return FakeAsyncStream(), None
class TestAsyncSocketConnection:
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)
async def test_tcp_socket_read_partial_data_then_eof(self):
"""Raise when the stream ends after only partial payload delivery."""
tcp_async_socket = TCPAsyncSocketConnection(Address("dummy_address", 1234), timeout=0.01)
tcp_async_socket.reader = Mock(read=AsyncMock(side_effect=[b"a", b""]))
with pytest.raises(
OSError,
match=(
r"^Server stopped responding \(got 1 bytes, but expected 2 bytes\). "
r"Partial obtained data: bytearray\(b'a'\)$"
),
):
_ = await tcp_async_socket.read(2)
async def test_tcp_socket_read_eof_without_any_data(self):
"""Raise when the stream immediately ends without returning any data."""
tcp_async_socket = TCPAsyncSocketConnection(Address("dummy_address", 1234), timeout=0.01)
tcp_async_socket.reader = Mock(read=AsyncMock(return_value=b""))
with pytest.raises(OSError, match=r"^Server did not respond with any information!$"):
_ = await tcp_async_socket.read(2)
async def test_tcp_socket_write_awaits_drain(self):
"""Ensure writes await ``drain`` so buffered data is flushed."""
writer = Mock()
writer.write = Mock()
writer.drain = AsyncMock()
tcp_async_socket = TCPAsyncSocketConnection(Address("dummy_address", 1234), timeout=0.01)
tcp_async_socket.writer = writer
await tcp_async_socket.write(b"hello")
writer.write.assert_called_once_with(b"hello")
writer.drain.assert_awaited_once_with()
async def test_tcp_socket_close_waits_for_writer(self):
"""Ensure close awaits ``wait_closed`` on the stream writer."""
writer = Mock()
writer.close = Mock()
writer.wait_closed = AsyncMock()
tcp_async_socket = TCPAsyncSocketConnection(Address("dummy_address", 1234), timeout=0.01)
tcp_async_socket.writer = typing.cast("asyncio.StreamWriter", writer)
await tcp_async_socket.close()
writer.close.assert_called_once_with()
writer.wait_closed.assert_awaited_once_with()
mcstatus-14.0.0/tests/responses/ 0000775 0000000 0000000 00000000000 15217522771 0016633 5 ustar 00root root 0000000 0000000 mcstatus-14.0.0/tests/responses/__init__.py 0000664 0000000 0000000 00000011415 15217522771 0020746 0 ustar 00root root 0000000 0000000 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:
assert self.OPTIONAL_FIELDS is not None
raw = 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_
mcstatus-14.0.0/tests/responses/conftest.py 0000664 0000000 0000000 00000002250 15217522771 0021031 0 ustar 00root root 0000000 0000000 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:
cls_ = typing.cast("type[typing.Any] | None", metafunc.cls)
if cls_ is not None and issubclass(cls_, BaseResponseTest):
instance = 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))
mcstatus-14.0.0/tests/responses/test_base.py 0000664 0000000 0000000 00000000453 15217522771 0021160 0 ustar 00root root 0000000 0000000 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]
mcstatus-14.0.0/tests/responses/test_bedrock.py 0000664 0000000 0000000 00000006425 15217522771 0021664 0 ustar 00root root 0000000 0000000 import typing as t
import pytest
from mcstatus.motd import Motd
from mcstatus.responses import (
BaseStatusPlayers,
BaseStatusResponse,
BaseStatusVersion,
BedrockStatusPlayers,
BedrockStatusResponse,
BedrockStatusVersion,
)
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,
)
@t.final
@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")
@staticmethod
def build(build: BedrockStatusResponse) -> BedrockStatusResponse:
return build
@pytest.mark.parametrize(("field", "pop_index"), [("map_name", 7), ("gamemode", 7), ("gamemode", 8)])
def test_optional_parameters_is_none(self, field: str, pop_index: int):
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"
@t.final
@BaseResponseTest.construct
class TestBedrockStatusPlayers(BaseResponseTest):
EXPECTED_VALUES: t.ClassVar = [("online", 1), ("max", 69)]
@pytest.fixture(scope="class")
@staticmethod
def build(build: BaseStatusResponse) -> BaseStatusPlayers:
return build.players
@t.final
@BaseResponseTest.construct
class TestBedrockStatusVersion(BaseResponseTest):
EXPECTED_VALUES: t.ClassVar = [("name", "1.18.100500"), ("protocol", 422), ("brand", "MCPE")]
@pytest.fixture(scope="class")
@staticmethod
def build(build: BaseStatusResponse) -> BaseStatusVersion:
return build.version
mcstatus-14.0.0/tests/responses/test_forge_data.py 0000664 0000000 0000000 00000120557 15217522771 0022351 0 ustar 00root root 0000000 0000000 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",
}
@t.final
@BaseResponseTest.construct
class TestForgeDataV1(BaseResponseTest):
RAW: t.ClassVar[dict[str, t.Any]] = {
"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")
@classmethod
def build(cls) -> ForgeData:
return ForgeData.build(cls.RAW) # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict
@t.final
@BaseResponseTest.construct
class TestForgeDataV2(BaseResponseTest):
RAW: t.ClassVar[dict[str, t.Any]] = {
"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")
@classmethod
def build(cls) -> ForgeData:
return ForgeData.build(cls.RAW) # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict
@t.final
@BaseResponseTest.construct
class TestForgeDataV3(BaseResponseTest):
RAW: t.ClassVar[dict[str, t.Any]] = {
"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")
@classmethod
def build(cls) -> ForgeData:
return ForgeData.build(cls.RAW) # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict
@t.final
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"})
@t.final
@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")
@staticmethod
def build() -> 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: str):
# 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
mcstatus-14.0.0/tests/responses/test_java.py 0000664 0000000 0000000 00000012323 15217522771 0021166 0 ustar 00root root 0000000 0000000 import typing as t
import pytest
from mcstatus.motd import Motd
from mcstatus.responses import JavaStatusPlayer, JavaStatusPlayers, JavaStatusResponse, JavaStatusVersion
from tests.responses import BaseResponseTest
@t.final
@BaseResponseTest.construct
class TestJavaStatusResponse(BaseResponseTest):
RAW: t.ClassVar[dict[str, t.Any]] = {
"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")
@classmethod
def build(cls) -> JavaStatusResponse:
return JavaStatusResponse.build(cls.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"
@t.final
@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")
@staticmethod
def build() -> 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
)
@t.final
@BaseResponseTest.construct
class TestJavaStatusPlayer(BaseResponseTest):
EXPECTED_VALUES: t.ClassVar = [("name", "foo"), ("id", "0b3717c4-f45d-47c8-b8e2-3d9ff6f93a89")]
@pytest.fixture(scope="class")
@staticmethod
def build() -> 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
@t.final
@BaseResponseTest.construct
class TestJavaStatusVersion(BaseResponseTest):
EXPECTED_VALUES: t.ClassVar = [("name", "1.8-pre1"), ("protocol", 44)]
@pytest.fixture(scope="class")
@staticmethod
def build() -> JavaStatusVersion:
return JavaStatusVersion.build({"name": "1.8-pre1", "protocol": 44})
mcstatus-14.0.0/tests/responses/test_legacy.py 0000664 0000000 0000000 00000003426 15217522771 0021515 0 ustar 00root root 0000000 0000000 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,
)
@t.final
@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")
@staticmethod
def build(build: LegacyStatusResponse) -> LegacyStatusResponse:
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},
}
@t.final
@BaseResponseTest.construct
class TestLegacyStatusPlayers(BaseResponseTest):
EXPECTED_VALUES: t.ClassVar = [("online", 0), ("max", 20)]
@pytest.fixture(scope="class")
@staticmethod
def build(build: LegacyStatusResponse) -> LegacyStatusPlayers:
return build.players
@t.final
@BaseResponseTest.construct
class TestLegacyStatusVersion(BaseResponseTest):
EXPECTED_VALUES: t.ClassVar = [("name", "1.4.2"), ("protocol", 47)]
@pytest.fixture(scope="class")
@staticmethod
def build(build: LegacyStatusResponse) -> LegacyStatusVersion:
return build.version
mcstatus-14.0.0/tests/responses/test_query.py 0000664 0000000 0000000 00000007702 15217522771 0021417 0 ustar 00root root 0000000 0000000 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.responses import BaseResponseTest
@t.final
@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")
@classmethod
def build(cls):
return QueryResponse.build(raw=cls.RAW, players_list=cls.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",
},
}
@t.final
@BaseResponseTest.construct
class TestQueryPlayers(BaseResponseTest):
EXPECTED_VALUES: t.ClassVar = [
("online", 3),
("max", 20),
("list", ["Dinnerbone", "Djinnibone", "Steve"]),
]
@pytest.fixture(scope="class")
@staticmethod
def build():
return QueryPlayers.build(
raw={
"hostname": "A Minecraft Server",
"gametype": "SMP",
"game_id": "MINECRAFT",
"version": "1.8",
"plugins": "",
"map": "world",
"numplayers": "3",
"maxplayers": "20",
"hostport": "25565",
"hostip": "192.168.56.1",
},
players_list=["Dinnerbone", "Djinnibone", "Steve"],
)
class TestQuerySoftware:
def test_vanilla(self):
software = QuerySoftware.build("1.8", "")
assert software.brand == "vanilla"
assert software.version == "1.8"
assert software.plugins == []
def test_modded(self):
software = QuerySoftware.build("1.8", "A modded server: Foo 1.0; Bar 2.0; Baz 3.0")
assert software.brand == "A modded server"
assert software.plugins == ["Foo 1.0", "Bar 2.0", "Baz 3.0"]
def test_modded_no_plugins(self):
software = QuerySoftware.build("1.8", "A modded server")
assert software.brand == "A modded server"
assert software.plugins == []
mcstatus-14.0.0/tests/test_cli.py 0000664 0000000 0000000 00000032767 15217522771 0017011 0 ustar 00root root 0000000 0000000 import contextlib
import io
import json
import os
import socket
from collections.abc import Generator
from typing import Any
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: list[Any] = [
{
"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() -> Generator[None]:
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: None):
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: None):
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: None):
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: None):
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: None):
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: None):
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: None):
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: None):
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: None):
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: None):
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: None):
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: None):
with patch_stdout_stderr() as (out, err):
assert main_under_test(["example.com", "ping"]) == 0
assert float(out.getvalue()) == 0 # this floating comparison is safe # noqa: RUF069
assert err.getvalue() == ""
def test_ping_bedrock(mock_network_requests: None):
with patch_stdout_stderr() as (out, err):
assert main_under_test(["example.com", "--bedrock", "ping"]) == 0
assert float(out.getvalue()) == 123 # this floating comparison is safe # noqa: RUF069
assert err.getvalue() == ""
def test_ping_legacy(mock_network_requests: None):
with patch_stdout_stderr() as (out, err):
assert main_under_test(["example.com", "--legacy", "ping"]) == 0
assert float(out.getvalue()) == 123 # this floating comparison is safe # noqa: RUF069
assert err.getvalue() == ""
def test_ping_server_doesnt_support(mock_network_requests: None):
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 # this floating comparison is safe # noqa: RUF069
assert err.getvalue() == PING_PACKET_FAIL_WARNING.format(address="example.com:25565", ping_exc="timeout") + "\n"
mcstatus-14.0.0/tests/test_compat.py 0000664 0000000 0000000 00000013115 15217522771 0017507 0 ustar 00root root 0000000 0000000 """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 Generator
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) -> Generator[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"),
],
)
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/forge_data.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)
mcstatus-14.0.0/tests/test_server.py 0000664 0000000 0000000 00000026767 15217522771 0017553 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, final
from unittest.mock import call, patch
import pytest
import pytest_asyncio
from mcstatus._net.address import Address
from mcstatus.server import BedrockServer, JavaServer, LegacyServer
from tests.protocol.helpers import AsyncBufferConnection, SyncBufferConnection, SyncDatagramConnection
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from typing_extensions import override
else:
override = lambda f: f # noqa: E731
@final
class MockProtocolFactory(asyncio.Protocol):
transport: asyncio.Transport
def __init__(self, data_expected_to_receive: bytes, data_to_respond_with: bytes):
self.data_expected_to_receive = data_expected_to_receive
self.data_to_respond_with = data_to_respond_with
@override
def connection_made(self, transport: asyncio.Transport): # pyright: ignore[reportIncompatibleMethodOverride]
print("connection_made")
self.transport = transport
@override
def connection_lost(self, exc):
print("connection_lost")
self.transport.close()
@override
def data_received(self, data):
assert self.data_expected_to_receive in data
self.transport.write(self.data_to_respond_with)
@override
def eof_received(self):
print("eof_received")
@override
def pause_writing(self):
print("pause_writing")
@override
def resume_writing(self):
print("resume_writing")
@pytest_asyncio.fixture
async def create_mock_packet_server():
"""Create a temporary asyncio packet servers used by tests."""
event_loop = asyncio.get_running_loop()
servers: list[asyncio.Server] = []
async def create_server(port: int, data_expected_to_receive: bytes, data_to_respond_with: bytes) -> asyncio.Server:
"""Start a server that validates one request pattern and returns a fixed payload."""
server = await event_loop.create_server(
lambda: MockProtocolFactory(data_expected_to_receive, data_to_respond_with),
host="127.0.0.1",
port=port,
)
servers.append(server)
return server
yield create_server
for server in servers:
server.close()
await server.wait_closed()
@final
class TestBedrockServer:
def setup_method(self):
self.server = BedrockServer("127.0.0.1")
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
@final
class TestAsyncJavaServer:
async def test_async_ping(
self,
unused_tcp_port: int,
create_mock_packet_server: Callable[..., Awaitable[asyncio.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("127.0.0.1", port=unused_tcp_port)
latency = await minecraft_server.async_ping(ping_token=29704774, version=47)
assert latency >= 0
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("127.0.0.1", 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)
async def test_java_server_with_query_port_async():
with patch("mcstatus.server.JavaServer._retry_async_query") as patched_query_func:
server = JavaServer("127.0.0.1", 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)
@final
class TestJavaServer:
def setup_method(self):
self.socket = SyncBufferConnection()
self.server = JavaServer("127.0.0.1")
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("0F002F093132372E302E302E3163DD0109010000000001C54246")
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("0F002F093132372E302E302E3163DD010100")
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):
socket = SyncDatagramConnection()
socket.receive(bytearray.fromhex("090000000035373033353037373800"))
socket.receive(
bytearray.fromhex(
"00000000000000000000000000000000686f73746e616d650041204d696e656372616674205365727665720067616d6574797"
"06500534d500067616d655f6964004d494e4543524146540076657273696f6e00312e3800706c7567696e7300006d61700077"
"6f726c64006e756d706c61796572730033006d6178706c617965727300323000686f7374706f727400323535363500686f737"
"46970003139322e3136382e35362e31000001706c617965725f000044696e6e6572626f6e6500446a696e6e69626f6e650053"
"746576650000"
)
)
with patch("mcstatus.server.UDPSocketConnection") as connection:
connection.return_value.__enter__.return_value = socket
info = self.server.query()
conn_bytes = 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"^$"):
_ = 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
@final
class TestLegacyServer:
def setup_method(self):
self.socket = SyncBufferConnection()
self.server = LegacyServer("127.0.0.1")
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
@final
class TestAsyncLegacyServer:
def setup_method(self):
self.socket = AsyncBufferConnection()
self.server = LegacyServer("127.0.0.1")
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
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
mcstatus-14.0.0/tests/utils/ 0000775 0000000 0000000 00000000000 15217522771 0015752 5 ustar 00root root 0000000 0000000 mcstatus-14.0.0/tests/utils/__init__.py 0000664 0000000 0000000 00000000000 15217522771 0020051 0 ustar 00root root 0000000 0000000 mcstatus-14.0.0/tests/utils/test_deprecation.py 0000664 0000000 0000000 00000014752 15217522771 0021671 0 ustar 00root root 0000000 0000000 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: # pyright: ignore[reportUnusedFunction]
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
mcstatus-14.0.0/tests/utils/test_general.py 0000664 0000000 0000000 00000000665 15217522771 0021007 0 ustar 00root root 0000000 0000000 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: object | None, b: object | None, result: object | None):
assert or_none(a, b) == result
def test_or_none_many_arguments():
assert or_none(*([None] * 100 + ["value"])) == "value"
mcstatus-14.0.0/tests/utils/test_retry.py 0000664 0000000 0000000 00000002524 15217522771 0020533 0 ustar 00root root 0000000 0000000 import pytest
from mcstatus._utils.retry import retry
from tests.protocol.helpers 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(): # should be async without await # noqa: RUF029
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(): # should be async without await # noqa: RUF029
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)()
mcstatus-14.0.0/uv.lock 0000664 0000000 0000000 00000535201 15217522771 0014762 0 ustar 00root root 0000000 0000000 version = 1
revision = 3
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.12'",
"python_full_version < '3.12'",
]
[[package]]
name = "accessible-pygments"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pygments", marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" },
]
[[package]]
name = "alabaster"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" },
]
[[package]]
name = "asyncio-dgram"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/92/531067e931af346b13cab79ab9be2619b043e54d611f413809e71119e093/asyncio_dgram-3.0.0.tar.gz", hash = "sha256:bd0937807a44451d799573b32400702187fd0bba6136f7cf306e9327c0c20f3e", size = 26500, upload-time = "2026-01-21T19:40:18.547Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/a1/ad5d53a50d07b4da934c5ff7db8fc3cfd091660b0f78174499644c72523f/asyncio_dgram-3.0.0-py3-none-any.whl", hash = "sha256:a4113061e6a7fbeee928d49c56cb61b68ca4a2fbee37f7e97280bbc72323ba8e", size = 7092, upload-time = "2026-01-21T19:40:17.309Z" },
]
[[package]]
name = "babel"
version = "2.18.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" },
]
[[package]]
name = "backports-asyncio-runner"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
]
[[package]]
name = "basedpyright"
version = "1.39.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodejs-wheel-binaries" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d2/62/8550c75850b2185df984d1de437b4805b039ba856cacbee2966236203133/basedpyright-1.39.8.tar.gz", hash = "sha256:bb1a86d4d71425d52d1501b317fe23d45527baed06bd5d5e1a07cd4b60d07b55", size = 25488230, upload-time = "2026-06-14T09:05:30.177Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/94/878454aefe94328ba7ad808ecd63da8311aae1198da46cfb29f5cfe130a8/basedpyright-1.39.8-py3-none-any.whl", hash = "sha256:a79d89928064bd9023d429b50c625d87d023bacc2fe3932ef6c7bd13b5426048", size = 13182726, upload-time = "2026-06-14T09:05:26.368Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve", marker = "python_full_version >= '3.12'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" },
]
[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
name = "cfgv"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" },
{ url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" },
{ url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" },
{ url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" },
{ url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" },
{ url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" },
{ url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" },
{ url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" },
{ url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" },
{ url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" },
{ url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" },
{ url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" },
{ url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" },
{ url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" },
{ url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" },
{ url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" },
{ url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" },
{ url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" },
{ url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" },
{ url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" },
{ url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" },
{ url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" },
{ url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" },
{ url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" },
{ url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" },
{ url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" },
{ url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" },
{ url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" },
{ url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" },
{ url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" },
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.14.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/a3/3834a5564fe8f32154cd7032400d3c2f9c565b2a373fa671f2bbdad6f634/coverage-7.14.2.tar.gz", hash = "sha256:7a2da3d81cfe17c18038c6d98e6592aa9147d596d056119b0ee612c3c8bd5230", size = 923982, upload-time = "2026-06-20T14:49:30.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/7f/551ebe25fa3de95ebbd3528b01ffd672b418e9c521b8555f85fb8aca21f8/coverage-7.14.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:59b75818e3046e9319143157f3dc4b43679a550c2060a17cbf3e39cc0b552925", size = 220230, upload-time = "2026-06-20T14:47:09.177Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ec/a444a1a21b46e54298357977d8ab6c388e5755bc79effaf587808fdb405f/coverage-7.14.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66b08ba4c5cbf0eaa2e9692b203073f198d5d469d8b15d1c7a4854ce7032b2e2", size = 220750, upload-time = "2026-06-20T14:47:11.243Z" },
{ url = "https://files.pythonhosted.org/packages/38/e5/0dce79f914e31fa0810ab770b06cc638fe5137af259649fafd4daeb2c8e5/coverage-7.14.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:70f266b536c590060b707dddfb6cf9f17e24fd30b992242e774543d256265c43", size = 247487, upload-time = "2026-06-20T14:47:12.564Z" },
{ url = "https://files.pythonhosted.org/packages/52/bb/aaca2c75ca6a5da71c3f413ac5920fe9f6e1aad387dae52a3315adf313e0/coverage-7.14.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb40cac5b1a6378fdccc99268f1033112ee4636e4fd9aaf240f6930d1fcea12c", size = 249316, upload-time = "2026-06-20T14:47:13.938Z" },
{ url = "https://files.pythonhosted.org/packages/37/b3/b45f5edd19ab9f79f7c6a4da2b4d1bd5969e0d7605fe197b60405c7129da/coverage-7.14.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c301fe9990cb5c081bf4881cb498743807c8e0e93fad7b85c02788456492ef8", size = 251182, upload-time = "2026-06-20T14:47:15.144Z" },
{ url = "https://files.pythonhosted.org/packages/f6/7c/ad5fd04da4565e7c9ad35080a5fb4762ed2d0f4893ac7eff6a1e7364e79f/coverage-7.14.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d67b0462c8a3c3d93033e7c79cacdfc57d08e5220d9115bcb24a23edf5a5900d", size = 253095, upload-time = "2026-06-20T14:47:16.468Z" },
{ url = "https://files.pythonhosted.org/packages/00/f9/41279b303e8773e1fd9a621a80159c7ed7b643dd9c7e85fd7fc3c88ebdaa/coverage-7.14.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e763087828ee9644f0c89c57f9b75f0a50fdf3e8f5d8fac5cfc351337e89a99", size = 248203, upload-time = "2026-06-20T14:47:17.758Z" },
{ url = "https://files.pythonhosted.org/packages/6c/ad/205dddd96954fcf7c7f0b509af614c0edc2a547115ad2fb52a8fc9cbaa41/coverage-7.14.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6d4da2baab6d96ceedd9176b3c142e1198b0310bc8dc04e18a3caab65c3a322c", size = 249223, upload-time = "2026-06-20T14:47:19.276Z" },
{ url = "https://files.pythonhosted.org/packages/3f/da/46c437176338ece41effbbd07d2941378db04b3e618dce68197d59a870b4/coverage-7.14.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ab565a405bfdea61260145d8cc987aa66d1998fd0e0ccd4348008f4e6a39ee33", size = 247226, upload-time = "2026-06-20T14:47:20.613Z" },
{ url = "https://files.pythonhosted.org/packages/0c/a7/42dfcb471dedb51d366762528e53f232427e8d897b92a9106b4aacec74fc/coverage-7.14.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c13230b688fbb9122251b74daa092175811eb64cb7bd1c98e2c8193dfa2b0bd5", size = 251039, upload-time = "2026-06-20T14:47:22.027Z" },
{ url = "https://files.pythonhosted.org/packages/3c/29/987df1a9f8843d3b1f50cb47cca0b10c983e84a3b1b9f6965796f07efa4e/coverage-7.14.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:014c83ba1ec97993cfe94e77fe6b56daa76bc0c218b86938971574c28942d044", size = 247497, upload-time = "2026-06-20T14:47:23.374Z" },
{ url = "https://files.pythonhosted.org/packages/8f/1c/919b6624c35161d183c43f57fca52ebcc5a59a7b3fa52fe0d0c3067469f5/coverage-7.14.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6caf54ffbf84b30470a8118f275afee9234e616572e4e41bae1dc19198c37294", size = 248100, upload-time = "2026-06-20T14:47:24.621Z" },
{ url = "https://files.pythonhosted.org/packages/6f/15/acbc7b5a6184f92d4875b820477f0bbb3a87371408f5ef73a575bdd3b8e1/coverage-7.14.2-cp310-cp310-win32.whl", hash = "sha256:4bf9d8a35f77df5638c61b5012ba5225109ec1cc15bc5eb097036b3c3cc939f3", size = 222282, upload-time = "2026-06-20T14:47:25.893Z" },
{ url = "https://files.pythonhosted.org/packages/2f/3f/944e24fdb2e88549b58bfd5a51a3a66481cf21154c7aa1a494597c870125/coverage-7.14.2-cp310-cp310-win_amd64.whl", hash = "sha256:c1f17a8caebe0facd4556b1e0adfe0987c17feebed88e7bb6b5365c45c84c5d6", size = 222910, upload-time = "2026-06-20T14:47:27.331Z" },
{ url = "https://files.pythonhosted.org/packages/04/d5/d0e511247f84fa88ae7da68403cbd3bf9d2a5fc48f5d6618a6846b275632/coverage-7.14.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:909f265c8c41f04c824bf741b2601fdcb56cab4bf56e018996b6494192ba0f58", size = 220352, upload-time = "2026-06-20T14:47:28.61Z" },
{ url = "https://files.pythonhosted.org/packages/03/4a/ecaff6db72e6c1782ca51336e391393f1e9cc6e4412d6c3da8b7d5075adf/coverage-7.14.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c8102deaf911938233f760426e6a5e287388521de95111d5c8de26c8a1028924", size = 220855, upload-time = "2026-06-20T14:47:29.972Z" },
{ url = "https://files.pythonhosted.org/packages/34/9a/cf950cd8e8df06ee5941276e69f81647005360421be523d5ca18f658e143/coverage-7.14.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:851f49e7bd7d1cdaf328f3133942b252d5e3d3380690131f423cba8e435b87f5", size = 251276, upload-time = "2026-06-20T14:47:31.413Z" },
{ url = "https://files.pythonhosted.org/packages/9d/08/f973be32c9a095e4bb2d3a7bdcb2f9c117e39d4062471ffffae3623f6c51/coverage-7.14.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04cb445bed86aaf00aaa97d41a8b6e30f100f21e81c34caaec4efc684cb57768", size = 253189, upload-time = "2026-06-20T14:47:32.727Z" },
{ url = "https://files.pythonhosted.org/packages/96/aa/f3a50952ba553d442d94b793e5dede25d426b02e5e011e9a9dd225c002d3/coverage-7.14.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7471bc920d97c51c37ea8127f13b2adca43c3d78c53313b26a1f428e99d2c254", size = 255299, upload-time = "2026-06-20T14:47:34.019Z" },
{ url = "https://files.pythonhosted.org/packages/e0/29/9a4c491986f4d637ed64961ae56721661fc21b6b767d280848d0c708756a/coverage-7.14.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:da5057e1bb257c967feee8ba67f3ebf379e801c7717f238b3d8c9caf00fc8f93", size = 257255, upload-time = "2026-06-20T14:47:35.397Z" },
{ url = "https://files.pythonhosted.org/packages/dd/61/d2a5b48007f6a212f321c36cf5486feb80505d2d00dfb1163aad2da71197/coverage-7.14.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33c0da852e8a40246cd8e20cf3b2fc17ca52a45e9b5f7983c93db26f5d24b87b", size = 251417, upload-time = "2026-06-20T14:47:36.677Z" },
{ url = "https://files.pythonhosted.org/packages/ea/25/8df66ae25b401d4529e1d0617af20d9695d171ea4ffec4ca9dffc5dc37b7/coverage-7.14.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f48a85bb437fab7782021c40bfee6b15146928b96960d008ace41b6901a0f21d", size = 252991, upload-time = "2026-06-20T14:47:38.027Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7b/16bdc9116dd8bf412a421a7227daa65ad9f12bef0685b13c1bd1c12e6d4c/coverage-7.14.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f44e7579a769a21d5b5e3166916bfe30ee175aaffff750324cbb11be2dbec5ad", size = 251051, upload-time = "2026-06-20T14:47:39.26Z" },
{ url = "https://files.pythonhosted.org/packages/0a/f8/b7dbed84274dcc69ddb9c0fe72ec1260830473e0d6c299dcf087a0567f7c/coverage-7.14.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:78853ca3c6ca2f012daa2b07dbabbb8db0f09d4dbe8ee828d294b3445d3f4cd8", size = 254817, upload-time = "2026-06-20T14:47:40.995Z" },
{ url = "https://files.pythonhosted.org/packages/c6/07/4659e6bed01a25a0effb4952e8e75fd157038fe5f2829b0f69c6811c2033/coverage-7.14.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c9c2795ee3692097ff226ab806005d36bb9691fca9b35353542b57ea749cc830", size = 250772, upload-time = "2026-06-20T14:47:42.306Z" },
{ url = "https://files.pythonhosted.org/packages/26/f4/45019da4cd6cd1df3042476447449d62a76a201f6b3556aa40ac31bce20b/coverage-7.14.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2f5cc48a845d755b6db236f8c29c2b54773eb4c7e4ee2ead43812d73718784b0", size = 251679, upload-time = "2026-06-20T14:47:43.703Z" },
{ url = "https://files.pythonhosted.org/packages/92/e5/76d75fa2ffe0285d3f2608d1bb241fc245cf98fe614d52118427dd6ccdaa/coverage-7.14.2-cp311-cp311-win32.whl", hash = "sha256:9c61cb7eaabcfa609c5bc0f5ff5869d72a2f02f17994e5fba5f971de516f3c82", size = 222445, upload-time = "2026-06-20T14:47:45.137Z" },
{ url = "https://files.pythonhosted.org/packages/57/59/696c64547e5c8b9ed31532e9c7a5f9b6474054da93f8ab07f8baf7365c57/coverage-7.14.2-cp311-cp311-win_amd64.whl", hash = "sha256:e715909b0966d1774d8a26e14e2f4a3ae75909dca526901c6306286b2dcbfbdc", size = 222922, upload-time = "2026-06-20T14:47:46.67Z" },
{ url = "https://files.pythonhosted.org/packages/63/72/646a28100462996c11b98e27d6786cd61f48100d1479804846a3e1e5bf9b/coverage-7.14.2-cp311-cp311-win_arm64.whl", hash = "sha256:9193f7150937a4fd836b10eaa123e15d98e961d1fabac07e60adf2d4785f888a", size = 222468, upload-time = "2026-06-20T14:47:48.119Z" },
{ url = "https://files.pythonhosted.org/packages/d0/d9/bdd141aa2c605096a8ef63b8435fd4f5fec78946a3cb7b9145840ec78291/coverage-7.14.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:37c94712e533ea06f0b1e4d934811c520b1914ce0e4da3916220717aa7a86bc6", size = 220528, upload-time = "2026-06-20T14:47:49.652Z" },
{ url = "https://files.pythonhosted.org/packages/02/97/d24ae7d2afc62c54a36313d4dedb655c9afbba3003f0f7f1ae81e97af31f/coverage-7.14.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c050bbc7bba94c77e4ed7438f4fda1babe98ab145691d80aa6f60df934a1468b", size = 220883, upload-time = "2026-06-20T14:47:51.036Z" },
{ url = "https://files.pythonhosted.org/packages/f8/0e/d8f00efd3df0d63e6843ebcbade9e4119d60f5376753c9705d84b014c775/coverage-7.14.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a7af571767a2ee342a171c16fc1b1a07a0bf511606d381703fb7cf397fe49d46", size = 252395, upload-time = "2026-06-20T14:47:52.627Z" },
{ url = "https://files.pythonhosted.org/packages/1c/1c/ab9510dfe1a16a35a10f90efad0d9a9cf61b9876973752968f2ba882f73f/coverage-7.14.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8b4910cce599cd2438f8da65f5ef199a70a1cdb6ab314926df78271ca5954240", size = 255131, upload-time = "2026-06-20T14:47:54.235Z" },
{ url = "https://files.pythonhosted.org/packages/ba/dd/70171e9371003b33dc6b20f527ac216ff91bbe5c1088e754eb8950d79193/coverage-7.14.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c33e9e4878972f430b0cc06de3bf2a28d054a9efb4f8426d27de0d9cb81396ff", size = 256246, upload-time = "2026-06-20T14:47:55.61Z" },
{ url = "https://files.pythonhosted.org/packages/0f/80/a68b1dd81d5c011e17fd6ab0d707d33297df1d0c618114b9b750a2219c80/coverage-7.14.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7967ea55c6dea6becba4d5870e2fa0aa4915a8be7ebff1bb79e6207aa75ce8d", size = 258504, upload-time = "2026-06-20T14:47:56.979Z" },
{ url = "https://files.pythonhosted.org/packages/8e/7b/40baaa946189f5317cd77d484e39b9b0727d02ebada0a12162374f2faee2/coverage-7.14.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1322f237c2979b84096f4239c17828ff17fea6b3bbe96c44381c5f587c44c26", size = 252808, upload-time = "2026-06-20T14:47:58.418Z" },
{ url = "https://files.pythonhosted.org/packages/d5/05/b19517b09c43d1e8591de6c13178b0c03166c31e1adbebda378e64c66b9a/coverage-7.14.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:77849525340c99f516d793dddbcee16b18d50af892ac43c8de1a6f343d41e3b5", size = 254166, upload-time = "2026-06-20T14:48:00.004Z" },
{ url = "https://files.pythonhosted.org/packages/ae/f5/6e65da5957e041d2094a9b97736628dd80160f1cc007a50790bbb2668c1a/coverage-7.14.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ef11695493ec3f06f7b2678ca274bcabb4ca04057317df268ddbfd8b05f661a8", size = 252310, upload-time = "2026-06-20T14:48:01.458Z" },
{ url = "https://files.pythonhosted.org/packages/2d/de/01b5274f0db63175b04d9354eff68d2d268b8b57a1b2db7d3dcb1f2c9dbb/coverage-7.14.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8134f0e0723e080d1c27bbe8fc149f0162e429fa1852482150015d0fce83eaf1", size = 256379, upload-time = "2026-06-20T14:48:02.981Z" },
{ url = "https://files.pythonhosted.org/packages/71/d6/9a2ffbca41e2f8f86f61e8b78b86afa433ec8cdeac4908ace93a28fe3ff0/coverage-7.14.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:914eead2b843fc357f733b3fe39cc94f1b53d466e8cfe03080b1ed9d24ccfc73", size = 251880, upload-time = "2026-06-20T14:48:04.463Z" },
{ url = "https://files.pythonhosted.org/packages/e3/ff/20bd54a43c88c08f474e6cb355a97e024e38412873ef0a581629abe1e26f/coverage-7.14.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e4b2d5e847fb7958583b74910cc19e5ec4ece514487385677b26433b2546116e", size = 253753, upload-time = "2026-06-20T14:48:05.99Z" },
{ url = "https://files.pythonhosted.org/packages/35/2a/2b3482c30d8344f301d8df6ff232a321f2ab87d5ac97ba21891a68638131/coverage-7.14.2-cp312-cp312-win32.whl", hash = "sha256:e753db9e40dda7302e0ac3e1e6e1325fb7f7b4694f87a7314ab15dd5d57911a7", size = 222584, upload-time = "2026-06-20T14:48:07.361Z" },
{ url = "https://files.pythonhosted.org/packages/f6/5e/83934ffff147edd313fe925db426e8f7ccad9e4663262eb5c4db4e345658/coverage-7.14.2-cp312-cp312-win_amd64.whl", hash = "sha256:d32e5ca5f16dafb269ee50b60d32b00c704b3f6f78e238105f1d94a3a5f24bf5", size = 223118, upload-time = "2026-06-20T14:48:08.837Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ee/616b4f38a34f076f3045d3eedfa764d34d82e6a6cc6b300acb0f1ff22a98/coverage-7.14.2-cp312-cp312-win_arm64.whl", hash = "sha256:dc366f158e2fb2add9d4e57338ca48f12611024278688ee657eb0b853fcb5de5", size = 222504, upload-time = "2026-06-20T14:48:10.436Z" },
{ url = "https://files.pythonhosted.org/packages/6d/09/b5b334c27960e7aac0003b96491bada7838dc641099fa64a1a598abf33cd/coverage-7.14.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e5f077641a6713ce9d38df9e85d4fb9e008677fc0775cbaeb32ddfc3b319d4ca", size = 220552, upload-time = "2026-06-20T14:48:11.847Z" },
{ url = "https://files.pythonhosted.org/packages/79/20/879a000c319b4df7b50e4d688c0f7c0f6b5ac9d7b18848cbc00eabf26efe/coverage-7.14.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0907f39b49ae818fe8af50aaa0f19afbc8ca164aea0865181ca7af17a3ac690b", size = 220919, upload-time = "2026-06-20T14:48:13.397Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b7/326dded4371bab60f42215797944a356e4d81a3cee106121c7f7dd531604/coverage-7.14.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734d47669118d75c28981e562d4530ceb77342d31ffef6def5edd5ad4f05d7b", size = 251917, upload-time = "2026-06-20T14:48:14.931Z" },
{ url = "https://files.pythonhosted.org/packages/eb/14/b3232ba218a0d1a70883d2675f18ff465de9e8e5e3346e81dc2b079838bd/coverage-7.14.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d9a1b5813d00ea6151f6ccf64d1fa16892771dfdda12ba87162d15ec4ea3e1e", size = 254515, upload-time = "2026-06-20T14:48:16.545Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7a/d77bcbee1cad71b42776574114b462225cc9125b4982f43da1b66adc850f/coverage-7.14.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f0a80f4c8ac3f774210b1cc1bc0e31e75502f2818dda9a144ff90e702c4d91d", size = 255749, upload-time = "2026-06-20T14:48:18.214Z" },
{ url = "https://files.pythonhosted.org/packages/86/86/97377937b29e9e44a1529bb20cb74dbcf80ed9006d87d7e742ff69e44b67/coverage-7.14.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e66f3f22d6c1515ce70f2e7c3e9c6f3ff0ff33480125c9f9c53e8f6508e30f", size = 257882, upload-time = "2026-06-20T14:48:19.7Z" },
{ url = "https://files.pythonhosted.org/packages/c1/a4/0fc8fe68bc505450bb068a2823ac7797bd8495240ccb8b4a5a1da1ee7e62/coverage-7.14.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6a2c37c3114f87ca7f10113756026eecb49656514debad600dcbec21f355ccea", size = 252144, upload-time = "2026-06-20T14:48:21.176Z" },
{ url = "https://files.pythonhosted.org/packages/8d/4a/450094ddc41ab0d2eb4a0457b3856400ea3329568d1303696e85de099ae6/coverage-7.14.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b16a7959d04b1497281c062c180413565c3f3469211d78799ad5b9a75f67796", size = 253882, upload-time = "2026-06-20T14:48:22.701Z" },
{ url = "https://files.pythonhosted.org/packages/d0/28/2f6ae6d98265d9aa6bac311c4a93403675905b03aca95dc4373080279d75/coverage-7.14.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6466c6999545cf00c4c142dfcbbf2db396dc735f005dcf8f91d57e351a79472b", size = 251846, upload-time = "2026-06-20T14:48:24.295Z" },
{ url = "https://files.pythonhosted.org/packages/c2/6e/707281468400794d52874e8fb5e38ff7578a0ff32ed49fe4fe85f192d0fc/coverage-7.14.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c60915ebb8f562317ba5ff6b8c32e25c0882289b201a9f2fb2987f91efd95d8", size = 256002, upload-time = "2026-06-20T14:48:26.015Z" },
{ url = "https://files.pythonhosted.org/packages/c2/83/5e963120de4011257a950ce4cfb7fc833ddf3fee19db495268d3dec28154/coverage-7.14.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:33b830850488acbcd358c78a4fecfafe7031667b4da8ddff5546295dc962cdeb", size = 251665, upload-time = "2026-06-20T14:48:27.654Z" },
{ url = "https://files.pythonhosted.org/packages/e9/78/66b482cd525083bcc0bc894c16db79dabac37490065b53b07d6e8ab77202/coverage-7.14.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d0f845539230b8269aec902bc978b0cc403f52f002d18a04492efc943404d0bc", size = 253435, upload-time = "2026-06-20T14:48:29.354Z" },
{ url = "https://files.pythonhosted.org/packages/e6/61/0663fb8cb530c8b11819b920109694eee95a3b22960a9495be0200f657f1/coverage-7.14.2-cp313-cp313-win32.whl", hash = "sha256:a8ac51a2e441e9119b9395f4d893fbc4934c64c8ba58be9b9eaa85591249e548", size = 222591, upload-time = "2026-06-20T14:48:31.142Z" },
{ url = "https://files.pythonhosted.org/packages/a6/47/1536d2b009c2848c3682500f497053f4645e70911afe02f594000997831a/coverage-7.14.2-cp313-cp313-win_amd64.whl", hash = "sha256:039b264cdb31c44b48f9821e2afbf8f37df49e0fb837e24a942918b36c567e31", size = 223134, upload-time = "2026-06-20T14:48:32.696Z" },
{ url = "https://files.pythonhosted.org/packages/28/9a/33ba4f335dd60bb34350318283d784f46018070e67b7d4df7c910ec9d9a0/coverage-7.14.2-cp313-cp313-win_arm64.whl", hash = "sha256:7f2ef591e381cc36b8e53334e1b842c760c520c8a52d01e8626209400e93fe6a", size = 222529, upload-time = "2026-06-20T14:48:34.237Z" },
{ url = "https://files.pythonhosted.org/packages/fc/bc/120390669817ede714ab141ae0a2a73240fd7354aac992c41dc0bd19570f/coverage-7.14.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7a0d1f026b72d627fa5c8a57cbc86ad209b64aa2a65833c83b290ace5cbee126", size = 220593, upload-time = "2026-06-20T14:48:35.755Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a3/7f1cfacd76af91e585f7ad689d7168002b444ed2a8ce59f2daaff10089b5/coverage-7.14.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4d2b86f81c1c9310a7e774e3cc9e927a3d0bf583ecbfa01498dd626930025428", size = 220925, upload-time = "2026-06-20T14:48:37.35Z" },
{ url = "https://files.pythonhosted.org/packages/e7/10/6514b2525bb672eb8b43703e46d061d694111db21efe7609db722df2233f/coverage-7.14.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d76bdc1f9396ae70a55d050cf9743d88141c62ce0a22a3f627fab1d11c2f8bc6", size = 251974, upload-time = "2026-06-20T14:48:39.109Z" },
{ url = "https://files.pythonhosted.org/packages/23/b4/4533091541c6620ecd68115bbfa1c61265b775618adef3a5fd137f4582e9/coverage-7.14.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cda36d8e7bfd63b3e44e75163265429caa5d935b672b00f71bccc8c010518c64", size = 254479, upload-time = "2026-06-20T14:48:40.871Z" },
{ url = "https://files.pythonhosted.org/packages/06/af/e251a143d5d106385dbca696c553afab6b69f7f6bc376a34e089cc0b8b32/coverage-7.14.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0904f3b79d7b845bef0715afe1900da634d12b97f05b9479cb472880ca07cb9c", size = 255824, upload-time = "2026-06-20T14:48:42.608Z" },
{ url = "https://files.pythonhosted.org/packages/9c/53/9e5876e60efbaa79d743d1948a5015ddc05b808db1cd62228acf83e87d43/coverage-7.14.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6795ca4198d6cb7fc2c6163214f6555a6bc5f0ae1e268e76139dec4b37c4499", size = 258139, upload-time = "2026-06-20T14:48:44.263Z" },
{ url = "https://files.pythonhosted.org/packages/85/5a/d35a4f431fb594e46b81cad4a13b470b017e918f347c1c0b260f7494fa1e/coverage-7.14.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c41e9b60fc0fa57f5d73306417d2f9d668202cca6944f9435878c55a5e7ae213", size = 252002, upload-time = "2026-06-20T14:48:45.961Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e2/f5b304c8139c606c4f1b230d3a257d0c88edfbbdf06c58364f07625dc45c/coverage-7.14.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419d2aadd5746efc2e9df0f33c05570d8192e6f6a6098ab05acce586f44ce8a5", size = 253832, upload-time = "2026-06-20T14:48:47.582Z" },
{ url = "https://files.pythonhosted.org/packages/86/bc/bbbd283daa6be4f68aad4ad4066fd39ae98e4174db8c03ab26c5803d6234/coverage-7.14.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1c5d273c5f1411c0d26c4f066c398d4a434b1f97bb5fa409189bedce86d4add4", size = 251799, upload-time = "2026-06-20T14:48:49.42Z" },
{ url = "https://files.pythonhosted.org/packages/69/8d/0745fceb89c9e5f7dd8ed243d97dc8561b7a95545741e2409d2b34654824/coverage-7.14.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5fe465bc691264adce601527a972990c1174075d86bcbe9968fd20c95e0b1948", size = 256075, upload-time = "2026-06-20T14:48:51.065Z" },
{ url = "https://files.pythonhosted.org/packages/a2/a0/441d9a5255cf021ab41ee00c014a4607d1c72d5e5bef0a4fdaa5be86a907/coverage-7.14.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6fbb61617af1c56f95d53170ae9fa6c9aef6de1abd02fcc50064bfc672efb18d", size = 251612, upload-time = "2026-06-20T14:48:52.653Z" },
{ url = "https://files.pythonhosted.org/packages/50/37/3d19c5e32d4a529c068eb296abfa3e455bd2c0f9311ecf26280f408ff8e0/coverage-7.14.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e1eff22b831dfd5694989cc1f0789980f18391f614ac67c851af9a8e6d25e9ba", size = 253270, upload-time = "2026-06-20T14:48:54.3Z" },
{ url = "https://files.pythonhosted.org/packages/3d/b0/54dd13937297518da6d092cc2c39d9340ec2194bdfa92e0a64694d643e23/coverage-7.14.2-cp314-cp314-win32.whl", hash = "sha256:58e91be0a233adef698d3e6be54f10401bb91fd7854c0d4c4d50e0d3711e72f1", size = 222796, upload-time = "2026-06-20T14:48:56.084Z" },
{ url = "https://files.pythonhosted.org/packages/51/45/7a10e0909919686e335fdd95869cfb222d55243ebff27dc5cf59ca259a1f/coverage-7.14.2-cp314-cp314-win_amd64.whl", hash = "sha256:d8429bf97906bfe6c61f9dbfb3342e0d88120da61939da8bd04f830cc3eab3b8", size = 223285, upload-time = "2026-06-20T14:48:57.729Z" },
{ url = "https://files.pythonhosted.org/packages/2e/03/9cb197eb4b3d1a2eccb2537c226a93c80522c5b8afc5dd93e1993d7bb021/coverage-7.14.2-cp314-cp314-win_arm64.whl", hash = "sha256:13609d9d77249447aa73357b14831b0f3b95f275026c9ff20dd105f981f53a0c", size = 222712, upload-time = "2026-06-20T14:48:59.413Z" },
{ url = "https://files.pythonhosted.org/packages/d6/3c/e59f498511080d20bf866b0af9eeab820feb91547dae2084cb9bb7fb0e58/coverage-7.14.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9818486c2bac88ae931df7e04905ee29bef49fd218c00f5f02bed4855254a101", size = 221325, upload-time = "2026-06-20T14:49:01.447Z" },
{ url = "https://files.pythonhosted.org/packages/d3/37/8d7955f7e701e69198bd0a0132ea76518c078a635b930a4924e2ccfa70f0/coverage-7.14.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:58055adffabfa243516a197aa9f85f0dd56d905b0fba1a10193269759c29ccb0", size = 221594, upload-time = "2026-06-20T14:49:03.13Z" },
{ url = "https://files.pythonhosted.org/packages/34/7a/6738e1e1533ce8ec4e2e472696eefdd4723864d7efaa140e433053bf576a/coverage-7.14.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:535747dbc200349d7fb434cffcb28e770f0290f69b225f56dc3803aa7210cdea", size = 262957, upload-time = "2026-06-20T14:49:04.829Z" },
{ url = "https://files.pythonhosted.org/packages/35/c4/d1be863cd39e0955904315fece67c5c23e046563f5eea0ceac16c547a759/coverage-7.14.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:420c66e35d85c0ca5dc6a38147d83ef239762542900e5921ebbdb89333c540ea", size = 265081, upload-time = "2026-06-20T14:49:07.018Z" },
{ url = "https://files.pythonhosted.org/packages/72/7f/412df3c3c251284a11834287fd6f7e3bb98c528c53e030589e9344a3ef80/coverage-7.14.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2cf17b33773be446a588551ea6a746b2d70dd0bc90dc31f1dd7648975a63c6b", size = 267500, upload-time = "2026-06-20T14:49:08.709Z" },
{ url = "https://files.pythonhosted.org/packages/54/68/7d0764e83459455384d5c04179ce2d2a837bef01b9ba463079c6e8b31361/coverage-7.14.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:adb4a5fef041f7179bb264203add873c147d169cf2f8d0adae89ff2e51271bac", size = 268619, upload-time = "2026-06-20T14:49:10.405Z" },
{ url = "https://files.pythonhosted.org/packages/14/68/1292164ac70cbcc86ac3982da31a6fbb42bb4bcebf6e5cf73c99cfcfd50d/coverage-7.14.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c012ec357dec9408a83dad5541172a63c5cfa1421709f2e5811480d31ae1b28", size = 262066, upload-time = "2026-06-20T14:49:12.257Z" },
{ url = "https://files.pythonhosted.org/packages/20/44/fd6fdf3f63b6e00a1a9230022d072ded5189576001685706aa6524187c65/coverage-7.14.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dacd0ecd08fda3cb2f85b60cabea7da326dcb2fc15fbb23a88830a80144cc9f2", size = 264953, upload-time = "2026-06-20T14:49:14.13Z" },
{ url = "https://files.pythonhosted.org/packages/39/29/e803fea3da89eaeb5b6b41b3ccd039fe9f3300a900e3803baac1a998529f/coverage-7.14.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f27e980f2feba5dfe7a32b22b125470de69c0bd113c75e16165de909a777f512", size = 262555, upload-time = "2026-06-20T14:49:15.803Z" },
{ url = "https://files.pythonhosted.org/packages/32/3c/b360e48ac68e3236c04cb83658382e7f5be7efbbec2e1faae3dcca432783/coverage-7.14.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:105c00efb65c863630b2b63cbf7b8267e4da2d44b62284efbb19a03b04c337d4", size = 266289, upload-time = "2026-06-20T14:49:17.962Z" },
{ url = "https://files.pythonhosted.org/packages/59/12/1ed6d9274d599c586e2d1aa9818765dcdae6bb52aa88afa2fcd868398191/coverage-7.14.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:571173fa04c8e8d6235ab32ae67fecca97777e2e1b4a1a30f3022c34e397c1c1", size = 261402, upload-time = "2026-06-20T14:49:19.708Z" },
{ url = "https://files.pythonhosted.org/packages/44/17/eb6cf12a4538cda937aefbeabb15377a8a30b377b484e63d31c9da790966/coverage-7.14.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e532f34d42d1a421fa00ed6b7735d14ac2e340256c1bad26a5e1dc1252b0bed7", size = 263715, upload-time = "2026-06-20T14:49:21.427Z" },
{ url = "https://files.pythonhosted.org/packages/8a/ca/4bafdb9d372ab05d6ed3a63e7f00d3195d169d0afea00f617c026e386c19/coverage-7.14.2-cp314-cp314t-win32.whl", hash = "sha256:243971550fb46c3039257f75e65610002d84304c505f609bbd9779e20a653a0a", size = 223103, upload-time = "2026-06-20T14:49:23.24Z" },
{ url = "https://files.pythonhosted.org/packages/35/cb/0765dbd9011d2e47315f1da31e62c5fe231f04a6ec8da213e64c4505896d/coverage-7.14.2-cp314-cp314t-win_amd64.whl", hash = "sha256:60fb0ca084a92da96474b8b405a7ea76dfecac3c68db54383e7934b6f3871169", size = 223934, upload-time = "2026-06-20T14:49:25.347Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ce/373dde027ecd0ae54511430fe7569f838d3a0376b70333ba9fd20c76b836/coverage-7.14.2-cp314-cp314t-win_arm64.whl", hash = "sha256:36a0a3f42ed7dfdbca2a69a541519ffd5064a5692152fc0018109e74370d7345", size = 223249, upload-time = "2026-06-20T14:49:27.241Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5e/a8ba14ceb014f39bd5e3f7077150718c7de61c01ce326bfe7e8eae9b19b2/coverage-7.14.2-py3-none-any.whl", hash = "sha256:04d92589e481a8b68a005a5a1e0646a91c76f322c397c4635298c57cf63699b5", size = 212325, upload-time = "2026-06-20T14:49:28.991Z" },
]
[package.optional-dependencies]
toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[package]]
name = "distlib"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "docs"
version = "0.0.0"
source = { directory = "docs" }
dependencies = [
{ name = "furo", marker = "python_full_version >= '3.12'" },
{ name = "m2r2", marker = "python_full_version >= '3.12'" },
{ name = "packaging", marker = "python_full_version >= '3.12'" },
{ name = "sphinx", marker = "python_full_version >= '3.12'" },
{ name = "sphinx-autodoc-typehints", marker = "python_full_version >= '3.12'" },
{ name = "uv-dynamic-versioning", marker = "python_full_version >= '3.12'" },
]
[package.metadata]
requires-dist = [
{ name = "furo", specifier = ">=2025.7.19" },
{ name = "m2r2", specifier = "~=0.3.4" },
{ name = "packaging", specifier = "~=26.0" },
{ name = "sphinx", specifier = "~=9.1.0" },
{ name = "sphinx-autodoc-typehints", specifier = "~=3.12.0" },
{ name = "uv-dynamic-versioning", specifier = "~=0.14.0" },
]
[[package]]
name = "docutils"
version = "0.22.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" },
]
[[package]]
name = "dunamai"
version = "1.26.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/67/d5611975faaa5e4a920f4b19e4caccd5df0facb925687850f1e45f5876f2/dunamai-1.26.1.tar.gz", hash = "sha256:3b46007bd65b00b4824ead0a1aee365fd22d0ec2b9c219497d4fd48f52860c8b", size = 45567, upload-time = "2026-04-04T14:07:11.17Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-py3-none-any.whl", hash = "sha256:2727d939c5b4257cb01ea404372803b477f5176e5a347c43beaf89cd5072e853", size = 27332, upload-time = "2026-04-04T14:07:10.079Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "filelock"
version = "3.29.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" },
]
[[package]]
name = "furo"
version = "2025.12.19"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "accessible-pygments", marker = "python_full_version >= '3.12'" },
{ name = "beautifulsoup4", marker = "python_full_version >= '3.12'" },
{ name = "pygments", marker = "python_full_version >= '3.12'" },
{ name = "sphinx", marker = "python_full_version >= '3.12'" },
{ name = "sphinx-basic-ng", marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz", hash = "sha256:188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7", size = 1661473, upload-time = "2025-12-19T17:34:40.889Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" },
]
[[package]]
name = "hatchling"
version = "1.30.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
{ name = "pathspec" },
{ name = "pluggy" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "trove-classifiers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/4c/8717ccb844b4fa5a5ba6352e97d743ed24e9a22cf90b7c109c17030a46a1/hatchling-1.30.1.tar.gz", hash = "sha256:eee4fd45357f72ebb3d7a42e5d72cfb5e29ed426d79e8836288926c4258d5f2e", size = 56929, upload-time = "2026-06-02T00:09:41.487Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/49/2797ec0ef88008a653a8867bb8d1e5c223cd2df8e40390dd5c6a0279cbc5/hatchling-1.30.1-py3-none-any.whl", hash = "sha256:161eacafb3c6f91526e92116d21426369f2c36e98c36a864f11a96345ad4ee31", size = 77489, upload-time = "2026-06-02T00:09:40.139Z" },
]
[[package]]
name = "identify"
version = "2.6.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "imagesize"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "m2r2"
version = "0.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "docutils", marker = "python_full_version >= '3.12'" },
{ name = "mistune", marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/a9/ab03ff21972cfb2e6ea766b1dc015c2c081af70f8f956806e948c62044ba/m2r2-0.3.4.tar.gz", hash = "sha256:e278f5f337e9aa7b2080fcc3e94b051bda9615b02e36c6fb3f23ff019872f043", size = 17082, upload-time = "2025-05-01T16:55:53.244Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/50/5b1d70de548fb1e11e15ea9ebec0480365462d202f98b66ad7e154284327/m2r2-0.3.4-py3-none-any.whl", hash = "sha256:1a445514af8a229496bfb1380c52da8dd38313e48600359ee92b2c9d2e4df34a", size = 11199, upload-time = "2025-05-01T16:55:51.87Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" },
{ url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" },
{ url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
{ url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
{ url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
{ url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" },
{ url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
{ url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
{ url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mcstatus"
source = { editable = "." }
dependencies = [
{ name = "asyncio-dgram" },
{ name = "dnspython" },
]
[package.dev-dependencies]
dev = [
{ name = "poethepoet" },
]
docs = [
{ name = "docs", marker = "python_full_version >= '3.12'" },
]
lint = [
{ name = "basedpyright" },
{ name = "pre-commit" },
{ name = "ruff" },
{ name = "typing-extensions" },
]
release = [
{ name = "hatchling" },
{ name = "uv-dynamic-versioning" },
]
test = [
{ name = "coverage" },
{ name = "hatchling" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-rerunfailures" },
{ name = "typing-extensions" },
{ name = "uv-dynamic-versioning" },
]
[package.metadata]
requires-dist = [
{ name = "asyncio-dgram", specifier = ">=2.1.2" },
{ name = "dnspython", specifier = ">=2.4.2" },
]
[package.metadata.requires-dev]
dev = [{ name = "poethepoet", specifier = "~=0.46.0" }]
docs = [{ name = "docs", marker = "python_full_version >= '3.12'", directory = "docs" }]
lint = [
{ name = "basedpyright", specifier = "~=1.39.2" },
{ name = "pre-commit", specifier = "~=4.6.0" },
{ name = "ruff", specifier = "~=0.15.0" },
{ name = "typing-extensions", specifier = "~=4.15.0" },
]
release = [
{ name = "hatchling", specifier = "~=1.30.1" },
{ name = "uv-dynamic-versioning", specifier = "~=0.14.0" },
]
test = [
{ name = "coverage", specifier = "~=7.14.0" },
{ name = "hatchling" },
{ name = "pytest", specifier = "~=9.1.0" },
{ name = "pytest-asyncio", specifier = "~=1.4.0" },
{ name = "pytest-cov", specifier = "~=7.1.0" },
{ name = "pytest-rerunfailures", specifier = "~=16.1" },
{ name = "typing-extensions", specifier = "~=4.15.0" },
{ name = "uv-dynamic-versioning" },
]
[[package]]
name = "mistune"
version = "0.8.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/a4/509f6e7783ddd35482feda27bc7f72e65b5e7dc910eca4ab2164daf9c577/mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e", size = 58322, upload-time = "2018-10-11T06:59:27.908Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/ec/4b43dae793655b7d8a25f76119624350b4d65eb663459eb9603d7f1f0345/mistune-0.8.4-py2.py3-none-any.whl", hash = "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4", size = 16220, upload-time = "2018-10-11T06:59:26.044Z" },
]
[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]]
name = "nodejs-wheel-binaries"
version = "24.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/22/2a5beb4e21417c73233d9f65cf6f3e96e891b80d2f550a8f630ebc6b88c6/nodejs_wheel_binaries-24.16.0.tar.gz", hash = "sha256:c973cb69dc5fd16e6f6dc6e579e2c3d5534e2a1f57619dddf5ba070efa7dde37", size = 8056, upload-time = "2026-05-30T16:52:09.807Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/d1/68b43b53cd0fa83ae6fd406705023ca988d9e0ca41c724d82e66fbeb2ef6/nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:d9f8f677dcf30e37ac244f07869726abe043f01eb0f45722b1df31cc2af7093c", size = 55666374, upload-time = "2026-05-30T16:51:39.588Z" },
{ url = "https://files.pythonhosted.org/packages/e9/b2/40a989159599080da485de966c4c2d207e852ac7aa7864702626d96c8bf5/nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:3d0370fe7120ce9697a4f60d40480d2bd8808d9f30131458d5afc0040d4e5a51", size = 55838487, upload-time = "2026-05-30T16:51:43.383Z" },
{ url = "https://files.pythonhosted.org/packages/d7/a7/cd42174fb5ff6faff7fa8d326a18914d8f232098ab5de055b57c16fa13ca/nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:85dc92bbb79c851569c5925dcc2a4c915a034efab375f99e4e7e6bbe9cca8342", size = 60179540, upload-time = "2026-05-30T16:51:47.036Z" },
{ url = "https://files.pythonhosted.org/packages/2b/95/c8a1f9ae140aa28df8744d984d01d4b3af7cdd6555af12127f40ceb45a7d/nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:2f3036292811514ba847b3708492644764f88a833ac425c5f55007014308ddfd", size = 60716262, upload-time = "2026-05-30T16:51:50.711Z" },
{ url = "https://files.pythonhosted.org/packages/64/c9/7c35b3737f59e36d0249c265397b7bff570519b95301d6e16ea361e904ad/nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:db8a8a76ebd2b28ecbfc9ad464baa3707241b9e050a30e2efdf6f60c0f886502", size = 62230592, upload-time = "2026-05-30T16:51:55Z" },
{ url = "https://files.pythonhosted.org/packages/04/96/d931255cf9d11a84d6b54d882dba7434646467d568ccf070ea3418638df3/nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f1a3d8f7b4491cbbd023ba3fc4e901fcca2d9fb80d57f24ba3890de8b1dbac03", size = 62841759, upload-time = "2026-05-30T16:51:59.407Z" },
{ url = "https://files.pythonhosted.org/packages/a2/7b/8b7a3f41bc255411be30b6d7d288aab8ffd9ea2055db8555ced3548007b9/nodejs_wheel_binaries-24.16.0-py2.py3-none-win_amd64.whl", hash = "sha256:bb136be9944f0662dcf1120f45193a6b75b13fac378971a95cc42c9f879a81aa", size = 42027734, upload-time = "2026-05-30T16:52:03.348Z" },
{ url = "https://files.pythonhosted.org/packages/17/66/1ed71f1f529b8ca727d42c7ceb9db0bef145ce4a13dfc86fb50aa44f3be6/nodejs_wheel_binaries-24.16.0-py2.py3-none-win_arm64.whl", hash = "sha256:8308940b5edd0a50dc5267ea36ba21c9f668e83fe0d9f293937174d3a7e31c36", size = 39714528, upload-time = "2026-05-30T16:52:06.421Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pastel"
version = "0.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/76/f1/4594f5e0fcddb6953e5b8fe00da8c317b8b41b547e2b3ae2da7512943c62/pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d", size = 7555, upload-time = "2020-09-16T19:21:12.43Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "platformdirs"
version = "4.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "poethepoet"
version = "0.46.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pastel" },
{ name = "pyyaml" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/f5/d501fcb67e450fd3fae9db06050420c0c6043758cfa8c30ba40278211265/poethepoet-0.46.0.tar.gz", hash = "sha256:daf8469031879ef59ef0b34fdba83574d65e41eb9186e20cd0f7c89ce479b030", size = 117276, upload-time = "2026-05-15T15:52:02.548Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/01/a9d6ea30351919298d3dc237172ae6a60ce0224ecaaee289b671ab979c13/poethepoet-0.46.0-py3-none-any.whl", hash = "sha256:dc6d770a14792d124abac9066c5a707876027d1878ac9ca26cf57e9b2a96dc89", size = 150581, upload-time = "2026-05-15T15:52:01.118Z" },
]
[[package]]
name = "pre-commit"
version = "4.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
{ name = "identify" },
{ name = "nodeenv" },
{ name = "pyyaml" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage", extra = ["toml"] },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "pytest-rerunfailures"
version = "16.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4d/f0/74f8e685be7ecd1572c1256132f18fce3a665d7e07649a3f23b7eb2d3bec/pytest_rerunfailures-16.3.tar.gz", hash = "sha256:37c9b1231c8083e9f4e724f50f7a21241822f9516c15c700ebbf218d6452355c", size = 34148, upload-time = "2026-05-22T06:51:22.292Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/98/58a71d68d3126d7f6a6ed1944c37ec207a4ff3dc66cad3bed7b59d38df61/pytest_rerunfailures-16.3-py3-none-any.whl", hash = "sha256:6bdfb8ffb46c46072e6c16bdedee38b6c13eac620d9415ed5b63152cbf283170", size = 15396, upload-time = "2026-05-22T06:51:20.547Z" },
]
[[package]]
name = "python-discovery"
version = "1.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi", marker = "python_full_version >= '3.12'" },
{ name = "charset-normalizer", marker = "python_full_version >= '3.12'" },
{ name = "idna", marker = "python_full_version >= '3.12'" },
{ name = "urllib3", marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "roman-numerals"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" },
]
[[package]]
name = "ruff"
version = "0.15.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" },
{ url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" },
{ url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" },
{ url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" },
{ url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" },
{ url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" },
{ url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" },
{ url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" },
{ url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" },
{ url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" },
{ url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" },
{ url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" },
{ url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" },
{ url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" },
{ url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" },
]
[[package]]
name = "snowballstemmer"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" },
]
[[package]]
name = "soupsieve"
version = "2.8.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
]
[[package]]
name = "sphinx"
version = "9.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alabaster", marker = "python_full_version >= '3.12'" },
{ name = "babel", marker = "python_full_version >= '3.12'" },
{ name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" },
{ name = "docutils", marker = "python_full_version >= '3.12'" },
{ name = "imagesize", marker = "python_full_version >= '3.12'" },
{ name = "jinja2", marker = "python_full_version >= '3.12'" },
{ name = "packaging", marker = "python_full_version >= '3.12'" },
{ name = "pygments", marker = "python_full_version >= '3.12'" },
{ name = "requests", marker = "python_full_version >= '3.12'" },
{ name = "roman-numerals", marker = "python_full_version >= '3.12'" },
{ name = "snowballstemmer", marker = "python_full_version >= '3.12'" },
{ name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" },
{ name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" },
{ name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" },
{ name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" },
{ name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" },
{ name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" },
]
[[package]]
name = "sphinx-autodoc-typehints"
version = "3.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sphinx", marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/99/f2/d99787d34d881b2352c04ce02bcdf0a61adf54b389c86d438b7a8ac3ae20/sphinx_autodoc_typehints-3.12.0.tar.gz", hash = "sha256:6571eb33c72cdc616a9945730fcb43c5bcf3685e79f1e8aea144735e43d5230d", size = 83874, upload-time = "2026-06-25T15:44:47.786Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/28/fa0f2ff73b8ca7987ebaa107d369c95459741ac73567e5079b4a881a981b/sphinx_autodoc_typehints-3.12.0-py3-none-any.whl", hash = "sha256:1a639b7cf71be13c4d8a4f0b552dcfcdf32ff39ac33ecb47398acb85863c2faf", size = 42548, upload-time = "2026-06-25T15:44:46.306Z" },
]
[[package]]
name = "sphinx-basic-ng"
version = "1.0.0b2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sphinx", marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" },
]
[[package]]
name = "sphinxcontrib-applehelp"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" },
]
[[package]]
name = "sphinxcontrib-devhelp"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" },
]
[[package]]
name = "sphinxcontrib-htmlhelp"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" },
]
[[package]]
name = "sphinxcontrib-jsmath"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" },
]
[[package]]
name = "sphinxcontrib-qthelp"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" },
]
[[package]]
name = "sphinxcontrib-serializinghtml"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "tomlkit"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" },
]
[[package]]
name = "trove-classifiers"
version = "2026.6.1.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/e3/7ca82ee24c82d344584abd5b8637b3bd056f2900226e8d82fc22f1184b92/trove_classifiers-2026.6.1.19.tar.gz", hash = "sha256:c5132b4b61a829d11cfbd2d72e97f20a45ed6edb95e45c5efdeb5e00836b2745", size = 17059, upload-time = "2026-06-01T19:41:34.649Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl", hash = "sha256:ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3", size = 14211, upload-time = "2026-06-01T19:41:33.434Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]
name = "uv-dynamic-versioning"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dunamai" },
{ name = "hatchling" },
{ name = "jinja2" },
{ name = "tomlkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/15/ef/63270118de5af8f45ba417946290b63f86b0b2a7d07d739d5dc619462711/uv_dynamic_versioning-0.14.0.tar.gz", hash = "sha256:574fbc07e87ace45c01d55967ad3b864871257b98ff5b8ac87c261227ac8db5b", size = 47203, upload-time = "2026-03-22T04:53:36.374Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/39/35773a629ac27d8803ff5ed86bde89d06f77041d7afa0a06cdc584ee8c6f/uv_dynamic_versioning-0.14.0-py3-none-any.whl", hash = "sha256:e087c346a786e98d41292ac2315180fb700cedfb30565fc973d64ce11a112387", size = 12172, upload-time = "2026-03-22T04:53:35.063Z" },
]
[[package]]
name = "virtualenv"
version = "21.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
{ name = "python-discovery" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" },
]