"""
Various utility functions.
IMPORTANT: These should be standalone and not rely on any Arcade imports
"""
import platform
import sys
from collections.abc import Callable, Generator, Iterable, MutableSequence, Sequence
from itertools import chain
from pathlib import Path
from typing import Any, Generic, TypeVar
from arcade.types import AsFloat, Point2
__all__ = [
"as_type",
"type_name",
"copy_dunders_unimplemented",
"is_iterable",
"is_nonstr_iterable",
"is_str_or_noniterable",
"grow_sequence",
"is_pyodide",
"is_raspberry_pi",
"get_raspberry_pi_info",
]
# Since this module forbids importing from the rest of
# Arcade, we make our own local type variables.
_T = TypeVar("_T")
_TType = TypeVar("_TType", bound=type)
[docs]
class Chain(Generic[_T]):
"""A reusable OOP version of :py:class:`itertools.chain`.
In some cases (physics engines), we need to iterate over multiple
sequences of objects repeatedly. This class provides a way to do so
which:
* Is non-exhausting
* Avoids copying all items into one joined list
Arguments:
components: The sequences of items to join.
"""
def __init__(self, *components: Sequence[_T]):
self.components: list[Sequence[_T]] = list(components)
def __iter__(self) -> Generator[_T, None, None]:
yield from chain.from_iterable(self.components)
[docs]
def as_type(item: Any) -> type:
"""If item is not a type, return its type. Otherwise, return item as-is.
Args:
item: A :py:class:`type` or instance of one.
"""
if isinstance(item, type):
return item
else:
return item.__class__
[docs]
def type_name(item: Any) -> str:
"""Get the name of item if it's a type or the name of its type if it's an instance.
This is meant to help shorten debugging-related code and developer
utilities. It isn't meant to be a performant tool.
Args:
item: A :py:class:`type` or an instance of one.
"""
if isinstance(item, type):
return item.__name__
else:
return item.__class__.__name__
[docs]
def is_iterable(item: Any) -> bool:
"""Use :py:func:`iter` to infer whether ``item`` is iterable.
.. tip:: An empty iterable still counts as an iterable.
This relies on the following:
#. Python's :py:func:`iter` raises a :py:class:`TypeError` if
``item`` is not iterable
#. try/catch blocks are fast
If you are still concerned about performance, you may want to inline
the contents of this function locally since function calls may have
more overhead than try/catch blocks on your Python version.
Args:
item:
An object to pass to the built-in :py:func:`iter` function.
Returns:
``True`` if the item appears to be iterable.
"""
try:
_ = iter(item)
return True
except TypeError:
return False
[docs]
def is_nonstr_iterable(item: Any) -> bool:
"""``True`` if ``item`` is an iterable other than a :py:class:`str`.
In addition to calling this function ``if`` and ``elif`` statements,
you can also pass it as an argument to other functions. These include:
* The :py:func:`.grow_sequence` utility function
* Python's built-in filter function
.. note:: This is the opposite of :py:func:`is_str_or_noniterable`.
Args:
item: Any object.
Returns:
Whether ``item`` is a non-string iterable.
"""
return not isinstance(item, str) and is_iterable(item)
[docs]
def is_str_or_noniterable(item: Any) -> bool:
"""``True`` if ``item`` is a string or non-iterable.
In addition to calling this function in ``if`` and ``elif`` statements,
you can also pass it as an argument to other functions. These include:
* The :py:func:`.grow_sequence` utility function
* Python's built-in :py:func:`filter` function
.. note:: This is the opposite of :py:func:`is_str_or_noniterable`.
Args:
item: Any object.
Returns:
``True`` if ``item`` is a :py:class:`str` or a non-iterable
object.
"""
return isinstance(item, str) or not is_iterable(item)
[docs]
def grow_sequence(
destination: MutableSequence[_T],
source: _T | Iterable[_T],
append_if: Callable[[_T | Iterable[_T]], bool] = is_str_or_noniterable,
) -> None:
"""Append when ``append_if(to_add)`` is ``True``, extend otherwise.
If performance is critical, consider inlining this code. This function
is meant as:
* a companion to :py:data:`arcade.types.OneOrIterableOf`
* an abbreviation for repetitive if-blocks in config and settings menus
The default ``append_if`` value is the :py:func:`.is_str_or_noniterable`
function in this module. You can pass any :py:func:`~typing.Callable`
which returns:
* ``True`` if we should :py:meth:`append <list.append>`
* ``False`` if we should :py:meth:`extend <list.extend>`
This includes both the :py:class:`callable` function and your own custom
functions. For example:
.. code-block:: python
dest = []
def _validate_and_choose(item) -> bool:
\"\"\"Raises an exception if data is invalid or a bool if not.\"\"\"
...
# Okay values
grow_sequence(dest, MyType(), append_when=_validate_and_choose)
grow_sequence(dest, [MyType(), MyType()], append_when=_validate_and_choose)
# Raises an exception
grow_sequence(dest, BadType(), append_when=_validate_and_choose)
Args:
destination:
A :py:func:`list` or other :py:class:`~typing.MutableSequence`
to append to or extend.
source:
A value source we'll use to grow the ``destination``
sequence.
append_if:
A :py:func:`callable` which returns ``True`` when ``source``
should be appended.
"""
if append_if(source):
destination.append(source) # type: ignore
else:
destination.extend(source) # type: ignore
[docs]
def copy_dunders_unimplemented(decorated_type: _TType) -> _TType:
"""Decorator stubs dunders raising :py:class:`NotImplementedError`.
Temp fixes https://github.com/pythonarcade/arcade/issues/2074 by
stubbing the following instance methods:
* :py:meth:`object.__copy__` (used by :py:func:`copy.copy`)
* :py:meth:`object.__deepcopy__` (used by :py:func:`copy.deepcopy`)
Example usage:
.. code-block:: python
import copy
from arcade,utils import copy_dunders_unimplemented
from arcade.hypothetical_module import HypotheticalNasty
# Example usage
@copy_dunders_unimplemented
class CantCopy:
def __init__(self, nasty_state: HypotheticalNasty):
self.nasty_state = nasty_state
instance = CantCopy(HypotheticalNasty())
# These raise NotImplementedError
this_line_raises = copy.deepcopy(instance)
this_line_also_raises = copy.copy(instance)
"""
def __copy__(self): # noqa
raise NotImplementedError(
f"{self.__class__.__name__} does not implement __copy__, but"
f"you may implement it on a custom subclass."
)
decorated_type.__copy__ = __copy__ # type: ignore
def __deepcopy__(self, memo): # noqa
raise NotImplementedError(
f"{self.__class__.__name__} does not implement __deepcopy__,"
f" but you may implement it on a custom subclass."
)
decorated_type.__deepcopy__ = __deepcopy__ # type: ignore
return decorated_type
[docs]
def is_pyodide() -> bool:
return False
[docs]
def is_raspberry_pi() -> bool:
"""Determine if the host is a raspberry pi."""
return get_raspberry_pi_info()[0]
[docs]
def get_raspberry_pi_info() -> tuple[bool, str, str]:
"""
Determine if the host is a raspberry pi with additional info.
Returned tuple format is::
bool (is host a raspi)
str (architecture)
str (model name)
"""
# The platform for raspi should always be linux
if not sys.platform == "linux":
return False, "", ""
# armv7l is raspi 32 bit
# aarch64 is raspi 64 bit
architecture = platform.machine()
model_name = ""
# Check for model info file
MODEL_PATH = Path("/sys/firmware/devicetree/base/model")
if MODEL_PATH.exists():
try:
model_name = MODEL_PATH.read_text()[:-1]
if "raspberry pi" in model_name.lower():
return True, architecture, model_name
except Exception:
pass
return False, "", ""
[docs]
def unpack_asfloat_or_point(value: AsFloat | Point2) -> Point2:
"""
A utility method that converts a float or int into a Point2, or
validates that an iterable is a Point2.
.. note:: This should be inlined in hot code paths
Args:
value: The value to test.
Returns:
A Point2 that is either equal to value, or is equal to (value, value)
"""
if isinstance(value, float | int):
x = y = value
else:
try:
x, y = value
except ValueError:
raise ValueError(
"value must be a float, int, or tuple-like which unpacks as two float-like values"
)
except TypeError:
raise TypeError(
"value must be a float, int, or tuple-like unpacks as two float-like values"
)
return x, y