Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ game specific things:

# Changelog

### v1.12
- Deprecated `ValueOption.on_change`, as well as setting it via `__call__`, in favour of separate,
more explicit, `on_change_anytime` and `on_change_while_enabled` callbacks.

- Added a `HookType.pause()` context manager. This may sometimes be preferable over the
`unrealsdk.hooks.prevent_hooking_direct_calls()` context manager.

### v1.11
- Added extra typing overloads to the `keybind` factory, to allow cases such as
`keybind("name", callback=func)`
Expand Down
2 changes: 1 addition & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from .dot_sdkmod import open_in_mod_dir

# Need to define a few things first to avoid circular imports
__version_info__: tuple[int, int] = (1, 11)
__version_info__: tuple[int, int] = (1, 12)
__version__: str = f"{__version_info__[0]}.{__version_info__[1]}"
__author__: str = "bl-sdk"

Expand Down
183 changes: 148 additions & 35 deletions options.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from abc import ABC, abstractmethod
from collections.abc import Callable, Mapping, Sequence
from dataclasses import KW_ONLY, dataclass, field
from typing import TYPE_CHECKING, Any, Literal, Self, cast
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, NewType, Self, cast, overload

from unrealsdk import logging

Expand Down Expand Up @@ -90,8 +91,10 @@ class ValueOption[J: JSON](BaseOption):
description: A short description about the option.
description_title: The title of the description. Defaults to copying the display name.
is_hidden: If true, the option will not be shown in the options menu.
on_change: If not None, a callback to run before updating the value. Passed a reference to
the option object and the new value. May be set using decorator syntax.
on_change_anytime: If not None, a callback to run before updating the value. Passed a
reference to the option object and the new value.
on_change_while_enabled: As above, but only called when this option has an associated mod
object, if that mod is enabled.
Extra Attributes:
mod: The mod this option stores it's settings in, or None if not (yet) associated with one.
default_value: What the value was originally when registered. Does not update on change.
Expand All @@ -100,7 +103,8 @@ class ValueOption[J: JSON](BaseOption):
value: J
default_value: J = field(init=False)
_: KW_ONLY
on_change: Callable[[Self, J], None] | None = None
on_change_anytime: Callable[[Self, J], None] | None = None
on_change_while_enabled: Callable[[Self, J], None] | None = None

@abstractmethod
def __init__(self) -> None:
Expand All @@ -113,40 +117,134 @@ def __post_init__(self) -> None:
super().__post_init__()
self.default_value = self.value

if self.on_change is not None:
if self.on_change_anytime is not None:
warnings.warn(
"Passed both an on_change (deprecated) and an on_change_anytime arg, using the"
" latter.",
category=DeprecationWarning,
# The stack depth is inconsistent due to other types in this file calling
# super.__post_init__
skip_file_prefixes=(str(Path(__file__).parent),),
)
else:
warnings.warn(
"The on_change arg is deprecated, use on_change_anytime or"
" on_change_while_enabled instead.",
category=DeprecationWarning,
skip_file_prefixes=(str(Path(__file__).parent),),
)
self.on_change_anytime = self.on_change

def __setattr__(self, name: str, value: Any) -> None:
# Simpler to use `__setattr__` than a property to detect value changes
if (
name == "value"
and self.on_change is not None
and not hasattr(self, "_on_change_recursion_guard")
):
if name == "value" and not hasattr(self, "_on_change_recursion_guard"):
self._on_change_recursion_guard = True
self.on_change(self, value)

if self.on_change_anytime is not None:
self.on_change_anytime(self, value)
if (
self.on_change_while_enabled is not None
and self.mod is not None
and self.mod.is_enabled
):
self.on_change_while_enabled(self, value)

del self._on_change_recursion_guard

super().__setattr__(name, value)

def __call__(self, on_change: Callable[[Self, J], None]) -> Self:
@overload
def set_on_change(
self,
/,
*,
anytime: bool = False,
while_enabled: bool = True,
) -> Callable[[Callable[[Self, J], None]], Self]: ...

@overload
def set_on_change(
self,
on_change_anytime: Callable[[Self, J], None],
/,
*,
anytime: bool = False,
while_enabled: bool = True,
) -> Self: ...

def set_on_change(
self,
on_change: Callable[[Self, J], None] | None = None,
/,
*,
anytime: bool = False,
while_enabled: bool = True,
) -> Self | Callable[[Callable[[Self, J], None]], Self]:
"""
Sets the on change callback.
Decorator factory to set the on_change callbacks.

This allows this class to be constructed using decorator syntax, though note it is *not* a
decorator, it returns itself so must be the outermost level.
Note this is *not* a true decorator, it returns the option, so must be the outermost level.

Args:
on_change: The callback to set.
anytime: If True, sets the on_change_anytime callback.
while_enabled: If True, sets the on_change_while_enabled callback.
Returns:
This option instance.
"""
if self.on_change is not None:
warnings.warn(
f"{self.__class__.__qualname__}.__call__ was called on an option which already has"
f" a on change callback.",
stacklevel=2,
)

self.on_change = on_change
return self
def decorator(on_change: Callable[[Self, J], None] | None) -> Self:
attrs: list[str] = []
if anytime:
attrs.append("on_change_anytime")
if while_enabled:
attrs.append("on_change_while_enabled")

if not attrs:
raise ValueError("One of anytime or while_enabled must be True")

for attr in attrs:
if getattr(self, attr) is not None:
warnings.warn(
f"{self.__class__.__qualname__}.set_on_change was called on an option which"
f" already has an {attr} callback.",
# Stack depth is inconsistent depending on if we were called directly or not
skip_file_prefixes=(str(Path(__file__).parent),),
)

setattr(self, attr, on_change)
return self

if on_change is None:
return decorator
return decorator(on_change)

if TYPE_CHECKING:
_Deprecated = NewType("_Deprecated", None)

@warnings.deprecated(
"Setting the on-change callback via __call__ is deprecated, use set_on_change instead",
)
def __call__(self, on_change: _Deprecated) -> Self: # noqa: D102
return self.set_on_change(on_change, anytime=True, while_enabled=False) # type: ignore

@property
@warnings.deprecated(
"on_change is deprecated, use on_change_anytime or on_change_while_enabled instead",
)
def on_change(self) -> _Deprecated: # noqa: D102 # pyright: ignore[reportRedeclaration]
return self.on_change_anytime # type: ignore

@on_change.setter
@warnings.deprecated(
"on_change is deprecated, use on_change_anytime or on_change_while_enabled instead",
)
def on_change(self, value: _Deprecated) -> None: # pyright: ignore[reportRedeclaration]
self.on_change_anytime = value

# Must come after the properties
on_change: _Deprecated = field(default=None, repr=False) # type: ignore


@dataclass
Expand All @@ -163,13 +261,18 @@ class HiddenOption[J: JSON](ValueOption[J]):
display_name: The option name to use for display. Defaults to copying the identifier.
description: A short description about the option.
description_title: The title of the description. Defaults to copying the display name.
on_change_anytime: If not None, a callback to run before updating the value. Passed a
reference to the option object and the new value.
on_change_while_enabled: As above, but only called when this option has an associated mod
object, if that mod is enabled.
Extra Attributes:
is_hidden: Always true.
mod: The mod this option stores it's settings in, or None if not (yet) associated with one.
"""

# Need to redefine on change so that it binds to J@HiddenOption, not J@ValueOption
on_change: Callable[[Self, J], None] | None = None # pyright: ignore[reportIncompatibleVariableOverride]
# Need to redefine on change so that they bind to J@HiddenOption, not J@ValueOption
on_change_anytime: Callable[[Self, J], None] | None = None # pyright: ignore[reportIncompatibleVariableOverride]
on_change_while_enabled: Callable[[Self, J], None] | None = None # pyright: ignore[reportIncompatibleVariableOverride]

is_hidden: Literal[True] = field( # pyright: ignore[reportIncompatibleVariableOverride]
default=True,
Expand Down Expand Up @@ -207,8 +310,10 @@ class SliderOption(ValueOption[float]):
description: A short description about the option.
description_title: The title of the description. Defaults to copying the display name.
is_hidden: If true, the option will not be shown in the options menu.
on_change: If not None, a callback to run before updating the value. Passed a reference to
the option object and the new value. May be set using decorator syntax.
on_change_anytime: If not None, a callback to run before updating the value. Passed a
reference to the option object and the new value.
on_change_while_enabled: As above, but only called when this option has an associated mod
object, if that mod is enabled.
Extra Attributes:
mod: The mod this option stores it's settings in, or None if not (yet) associated with one.
default_value: What the value was originally when registered. Does not update on change.
Expand Down Expand Up @@ -267,8 +372,10 @@ class SpinnerOption(ValueOption[str]):
description: A short description about the option.
description_title: The title of the description. Defaults to copying the display name.
is_hidden: If true, the option will not be shown in the options menu.
on_change: If not None, a callback to run before updating the value. Passed a reference to
the option object and the new value. May be set using decorator syntax.
on_change_anytime: If not None, a callback to run before updating the value. Passed a
reference to the option object and the new value.
on_change_while_enabled: As above, but only called when this option has an associated mod
object, if that mod is enabled.
Extra Attributes:
mod: The mod this option stores it's settings in, or None if not (yet) associated with one.
default_value: What the value was originally when registered. Does not update on change.
Expand Down Expand Up @@ -303,8 +410,10 @@ class BoolOption(ValueOption[bool]):
description: A short description about the option.
description_title: The title of the description. Defaults to copying the display name.
is_hidden: If true, the option will not be shown in the options menu.
on_change: If not None, a callback to run before updating the value. Passed a reference to
the option object and the new value. May be set using decorator syntax.
on_change_anytime: If not None, a callback to run before updating the value. Passed a
reference to the option object and the new value.
on_change_while_enabled: As above, but only called when this option has an associated mod
object, if that mod is enabled.
Extra Attributes:
mod: The mod this option stores it's settings in, or None if not (yet) associated with one.
default_value: What the value was originally when registered. Does not update on change.
Expand Down Expand Up @@ -337,8 +446,10 @@ class DropdownOption(ValueOption[str]):
description: A short description about the option.
description_title: The title of the description. Defaults to copying the display name.
is_hidden: If true, the option will not be shown in the options menu.
on_change: If not None, a callback to run before updating the value. Passed a reference to
the option object and the new value. May be set using decorator syntax.
on_change_anytime: If not None, a callback to run before updating the value. Passed a
reference to the option object and the new value.
on_change_while_enabled: As above, but only called when this option has an associated mod
object, if that mod is enabled.
Extra Attributes:
mod: The mod this option stores it's settings in, or None if not (yet) associated with one.
default_value: What the value was originally when registered. Does not update on change.
Expand Down Expand Up @@ -427,8 +538,10 @@ class KeybindOption(ValueOption[str | None]):
description: A short description about the option.
description_title: The title of the description. Defaults to copying the display name.
is_hidden: If true, the option will not be shown in the options menu.
on_change: If not None, a callback to run before updating the value. Passed a reference to
the option object and the new value. May be set using decorator syntax.
on_change_anytime: If not None, a callback to run before updating the value. Passed a
reference to the option object and the new value.
on_change_while_enabled: As above, but only called when this option has an associated mod
object, if that mod is enabled.
Extra Attributes:
mod: The mod this option stores it's settings in, or None if not (yet) associated with one.
default_value: What the value was originally when registered. Does not update on change.
Expand Down Expand Up @@ -462,7 +575,7 @@ def from_keybind(cls, bind: KeybindType) -> Self:
description=bind.description,
description_title=bind.description_title,
is_hidden=bind.is_hidden,
on_change=lambda _, new_key: setattr(bind, "key", new_key),
on_change_anytime=lambda _, new_key: setattr(bind, "key", new_key),
)
option.default_value = bind.default_key
return option
Expand Down
Loading