From 96b3cc6e8f0ad143ad9419f55aa81d99e2c7b738 Mon Sep 17 00:00:00 2001 From: Justin99x Date: Tue, 4 Mar 2025 10:37:06 -0800 Subject: [PATCH 1/4] Add _(to|from)_json helpers to option classes --- options.py | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++- settings.py | 94 +++++------------------------------------------ 2 files changed, 111 insertions(+), 86 deletions(-) diff --git a/options.py b/options.py index 822f2cd..36d2e48 100644 --- a/options.py +++ b/options.py @@ -1,7 +1,7 @@ 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 +from typing import TYPE_CHECKING, Any, Literal, Self, cast from unrealsdk import logging @@ -45,6 +45,14 @@ class BaseOption(ABC): def __init__(self) -> None: raise NotImplementedError + @abstractmethod + def _to_json(self) -> JSON: + raise NotImplementedError + + @abstractmethod + def _from_json(self, value: JSON) -> None: + raise NotImplementedError + def __post_init__(self) -> None: if self.display_name is None: # type: ignore self.display_name = self.identifier @@ -81,6 +89,9 @@ class ValueOption[J: JSON](BaseOption): def __init__(self) -> None: raise NotImplementedError + def _to_json(self) -> JSON: + return cast(JSON, self.value) + def __post_init__(self) -> None: super().__post_init__() self.default_value = self.value @@ -147,6 +158,9 @@ class HiddenOption[J: JSON](ValueOption[J]): init=False, ) + def _from_json(self, value: JSON) -> None: + self.value = cast(J, value) + def save(self) -> None: """Saves the settings of the mod this option is associated with.""" if self.mod is None: @@ -186,6 +200,17 @@ class SliderOption(ValueOption[float]): step: float = 1 is_integer: bool = True + def _from_json(self, value: JSON) -> None: + try: + self.value = float(value) # type: ignore + if self.is_integer: + self.value = round(self.value) + except ValueError: + logging.error( + f"'{value}' is not a valid value for option '{self.identifier}', sticking" + f" with the default", + ) + @dataclass class SpinnerOption(ValueOption[str]): @@ -214,6 +239,15 @@ class SpinnerOption(ValueOption[str]): choices: list[str] wrap_enabled: bool = False + def _from_json(self, value: JSON) -> None: + value = str(value) + if value in self.choices: + self.value = value + else: + logging.error( + f"'{value}' is not a valid value for option '{self.identifier}', sticking" + f" with the default", + ) @dataclass class BoolOption(ValueOption[bool]): @@ -240,6 +274,12 @@ class BoolOption(ValueOption[bool]): true_text: str | None = None false_text: str | None = None + def _from_json(self, value: JSON) -> None: + # Special case a false string + if isinstance(value, str) and value.strip().lower() == "false": + value = False + + self.value = bool(value) @dataclass class DropdownOption(ValueOption[str]): @@ -266,6 +306,16 @@ class DropdownOption(ValueOption[str]): choices: list[str] + def _from_json(self, value: JSON) -> None: + value = str(value) + if value in self.choices: + self.value = value + else: + logging.error( + f"'{value}' is not a valid value for option '{self.identifier}', sticking" + f" with the default", + ) + @dataclass class ButtonOption(BaseOption): @@ -291,6 +341,12 @@ class ButtonOption(BaseOption): _: KW_ONLY on_press: Callable[[Self], None] | None = None + def _to_json(self) -> JSON: + raise NotImplementedError("_to_json should never be called on a ButtonOption") + + def _from_json(self, value: JSON) -> None: + raise NotImplementedError("_from_json should never be called on a ButtonOption") + def __call__(self, on_press: Callable[[Self], None]) -> Self: """ Sets the on press callback. @@ -339,6 +395,12 @@ class KeybindOption(ValueOption[str | None]): is_rebindable: bool = True + def _from_json(self, value: JSON) -> None: + if value is None: + self.value = None + else: + self.value = str(value) + @classmethod def from_keybind(cls, bind: KeybindType) -> Self: """ @@ -388,6 +450,25 @@ class GroupedOption(BaseOption): children: Sequence[BaseOption] + def _to_json(self) -> JSON: + grouped_option_dict: Mapping[str, JSON] = {} + for option in self.children: + if isinstance(option, ButtonOption): + continue + grouped_option_dict[option.identifier] = option._to_json() + return grouped_option_dict + + def _from_json(self, value: JSON) -> None: + if isinstance(value, Mapping): + for option in self.children: + if option.identifier not in value: + continue + option._from_json(value[option.identifier]) + else: + logging.error( + f"'{value}' is not a valid value for option '{self.identifier}', sticking" + f" with the default", + ) @dataclass class NestedOption(BaseOption): @@ -411,3 +492,23 @@ class NestedOption(BaseOption): """ children: Sequence[BaseOption] + + def _to_json(self) -> JSON: + grouped_option_dict: Mapping[str, JSON] = {} + for option in self.children: + if isinstance(option, ButtonOption): + continue + grouped_option_dict[option.identifier] = option._to_json() + return grouped_option_dict + + def _from_json(self, value: JSON) -> None: + if isinstance(value, Mapping): + for option in self.children: + if option.identifier not in value: + continue + option._from_json(value[option.identifier]) + else: + logging.error( + f"'{value}' is not a valid value for option '{self.identifier}', sticking" + f" with the default", + ) diff --git a/settings.py b/settings.py index d6d01a9..cc157e1 100644 --- a/settings.py +++ b/settings.py @@ -2,23 +2,12 @@ import json from collections.abc import Mapping, Sequence -from typing import TYPE_CHECKING, TypedDict, cast - -from unrealsdk import logging +from typing import TYPE_CHECKING, TypedDict from . import MODS_DIR from .options import ( BaseOption, - BoolOption, ButtonOption, - DropdownOption, - GroupedOption, - HiddenOption, - KeybindOption, - NestedOption, - SliderOption, - SpinnerOption, - ValueOption, ) if TYPE_CHECKING: @@ -36,7 +25,7 @@ class BasicModSettings(TypedDict, total=False): keybinds: dict[str, str | None] -def load_options_dict( # noqa: C901 - imo the match is rated too highly, but it's getting there +def load_options_dict( options: Sequence[BaseOption], settings: Mapping[str, JSON], ) -> None: @@ -53,58 +42,7 @@ def load_options_dict( # noqa: C901 - imo the match is rated too highly, but it value = settings[option.identifier] - match option: - case HiddenOption(): - option.value = value - - # For all other option types, try validate the type before setting it, we don't want - # a "malicious" settings file to corrupt the types at runtime - - case BoolOption(): - # Special case a false string - if isinstance(value, str) and value.strip().lower() == "false": - value = False - - option.value = bool(value) - case SliderOption(): - try: - # Some of the JSON types won't support float conversion suppress the type - # error and catch the exception instead - option.value = float(value) # type: ignore - if option.is_integer: - option.value = round(option.value) - except ValueError: - logging.error( - f"'{value}' is not a valid value for option '{option.identifier}', sticking" - f" with the default", - ) - case DropdownOption() | SpinnerOption(): - value = str(value) - if value in option.choices: - option.value = value - else: - logging.error( - f"'{value}' is not a valid value for option '{option.identifier}', sticking" - f" with the default", - ) - case GroupedOption() | NestedOption(): - if isinstance(value, Mapping): - load_options_dict(option.children, value) - else: - logging.error( - f"'{value}' is not a valid value for option '{option.identifier}', sticking" - f" with the default", - ) - case KeybindOption(): - if value is None: - option.value = None - else: - option.value = str(value) - - case _: - logging.error( - f"Couldn't load settings for unknown option type {type(option).__name__}", - ) + option._from_json(value) # type: ignore def default_load_mod_settings(self: Mod) -> None: @@ -148,26 +86,12 @@ def create_options_dict(options: Sequence[BaseOption]) -> dict[str, JSON]: """ settings: dict[str, JSON] = {} for option in options: - match option: - case ValueOption(): - # The generics mean the type of value is technically unknown here - value = cast(JSON, option.value) # type: ignore - settings[option.identifier] = value - - case GroupedOption() | NestedOption(): - settings[option.identifier] = create_options_dict(option.children) - - # Button option is the only standard option which is not abstract, but also not a value, - # and doesn't have children. - # Just no-op it so that it doesn't show an error - case ButtonOption(): - pass - - case _: - logging.error( - f"Couldn't save settings for unknown option type {type(option).__name__}", - ) - + # Button option is the only standard option which is not abstract, but also not a value, + # and doesn't have children. + # Just no-op it so that it doesn't show an error + if isinstance(option, ButtonOption): + continue + settings[option.identifier] = option._to_json() # type: ignore return settings From d1fa1f232d383bc25368d44b3c43efa91e051d5a Mon Sep 17 00:00:00 2001 From: Justin99x Date: Tue, 4 Mar 2025 16:34:37 -0800 Subject: [PATCH 2/4] Update changelog and version for _(to|from)_json methods --- Readme.md | 4 ++++ __init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Readme.md b/Readme.md index d6f908f..cfe5459 100644 --- a/Readme.md +++ b/Readme.md @@ -19,6 +19,10 @@ game specific things: # Changelog +### v1.9 +- Added `_(to|from)_json()` methods to all options. +- Changed settings saving and loading to use above methods. + ### v1.8 - Fixed that nested and grouped options' children would not get their `.mod` attribute set. diff --git a/__init__.py b/__init__.py index 060462a..52f4244 100644 --- a/__init__.py +++ b/__init__.py @@ -8,7 +8,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, 8) +__version_info__: tuple[int, int] = (1, 9) __version__: str = f"{__version_info__[0]}.{__version_info__[1]}" __author__: str = "bl-sdk" From 926b144722975902fa2f20af400bb9039782f6b5 Mon Sep 17 00:00:00 2001 From: Justin99x Date: Wed, 5 Mar 2025 10:02:38 -0800 Subject: [PATCH 3/4] Better handling of ButtonOption _to|from_json --- options.py | 51 ++++++++++++++++++++++++++++++++++++++++----------- settings.py | 23 +++++++++-------------- 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/options.py b/options.py index 36d2e48..60fe343 100644 --- a/options.py +++ b/options.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Mapping, Sequence from dataclasses import KW_ONLY, dataclass, field +from types import EllipsisType from typing import TYPE_CHECKING, Any, Literal, Self, cast from unrealsdk import logging @@ -46,11 +47,24 @@ def __init__(self) -> None: raise NotImplementedError @abstractmethod - def _to_json(self) -> JSON: + def _to_json(self) -> JSON | EllipsisType: + """ + Turns this option into a JSON value. + + Returns: + This option's JSON representation, or Ellipsis if it should be considered to have no + value. + """ raise NotImplementedError @abstractmethod def _from_json(self, value: JSON) -> None: + """ + Assigns this option's value, based on a previously retrieved JSON value. + + Args: + value: The JSON value to assign. + """ raise NotImplementedError def __post_init__(self) -> None: @@ -89,8 +103,14 @@ class ValueOption[J: JSON](BaseOption): def __init__(self) -> None: raise NotImplementedError - def _to_json(self) -> JSON: - return cast(JSON, self.value) + def _to_json(self) -> J: + """ + Turns this option into a JSON value. + + Returns: + This option's JSON representation. + """ + return self.value def __post_init__(self) -> None: super().__post_init__() @@ -202,7 +222,7 @@ class SliderOption(ValueOption[float]): def _from_json(self, value: JSON) -> None: try: - self.value = float(value) # type: ignore + self.value = float(value) # type: ignore if self.is_integer: self.value = round(self.value) except ValueError: @@ -249,6 +269,7 @@ def _from_json(self, value: JSON) -> None: f" with the default", ) + @dataclass class BoolOption(ValueOption[bool]): """ @@ -281,6 +302,7 @@ def _from_json(self, value: JSON) -> None: self.value = bool(value) + @dataclass class DropdownOption(ValueOption[str]): """ @@ -341,11 +363,17 @@ class ButtonOption(BaseOption): _: KW_ONLY on_press: Callable[[Self], None] | None = None - def _to_json(self) -> JSON: - raise NotImplementedError("_to_json should never be called on a ButtonOption") + def _to_json(self) -> EllipsisType: + """ + A dummy method to adhere to BaseOption interface, while indicating no JSON representation. + + Returns: + Ellipsis, indicating that ButtonOption cannot be represented as a value. + """ + return ... def _from_json(self, value: JSON) -> None: - raise NotImplementedError("_from_json should never be called on a ButtonOption") + pass def __call__(self, on_press: Callable[[Self], None]) -> Self: """ @@ -453,9 +481,9 @@ class GroupedOption(BaseOption): def _to_json(self) -> JSON: grouped_option_dict: Mapping[str, JSON] = {} for option in self.children: - if isinstance(option, ButtonOption): + if option._to_json() == ...: continue - grouped_option_dict[option.identifier] = option._to_json() + grouped_option_dict[option.identifier] = cast(JSON, option._to_json()) return grouped_option_dict def _from_json(self, value: JSON) -> None: @@ -470,6 +498,7 @@ def _from_json(self, value: JSON) -> None: f" with the default", ) + @dataclass class NestedOption(BaseOption): """ @@ -496,9 +525,9 @@ class NestedOption(BaseOption): def _to_json(self) -> JSON: grouped_option_dict: Mapping[str, JSON] = {} for option in self.children: - if isinstance(option, ButtonOption): + if option._to_json() == ...: continue - grouped_option_dict[option.identifier] = option._to_json() + grouped_option_dict[option.identifier] = cast(JSON, option._to_json()) return grouped_option_dict def _from_json(self, value: JSON) -> None: diff --git a/settings.py b/settings.py index cc157e1..050c763 100644 --- a/settings.py +++ b/settings.py @@ -2,16 +2,13 @@ import json from collections.abc import Mapping, Sequence -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING, TypedDict, cast from . import MODS_DIR -from .options import ( - BaseOption, - ButtonOption, -) if TYPE_CHECKING: from .mod import Mod + from .options import BaseOption type JSON = Mapping[str, JSON] | Sequence[JSON] | str | int | float | bool | None @@ -42,7 +39,7 @@ def load_options_dict( value = settings[option.identifier] - option._from_json(value) # type: ignore + option._from_json(value) # type: ignore def default_load_mod_settings(self: Mod) -> None: @@ -84,14 +81,12 @@ def create_options_dict(options: Sequence[BaseOption]) -> dict[str, JSON]: Returns: The options' values in dict form. """ - settings: dict[str, JSON] = {} - for option in options: - # Button option is the only standard option which is not abstract, but also not a value, - # and doesn't have children. - # Just no-op it so that it doesn't show an error - if isinstance(option, ButtonOption): - continue - settings[option.identifier] = option._to_json() # type: ignore + settings: dict[str, JSON] = { + option.identifier: cast(JSON, option._to_json()) # type: ignore + for option in options + if option._to_json() != ... # type: ignore + } + return settings From 9ccbac7750d7bb5fc0c875b30dff95a2cf44e44b Mon Sep 17 00:00:00 2001 From: apple1417 Date: Thu, 6 Mar 2025 12:08:23 +1300 Subject: [PATCH 4/4] minor clean up of handing ellipsis --- options.py | 34 ++++++++++------------------------ settings.py | 10 ++++------ 2 files changed, 14 insertions(+), 30 deletions(-) diff --git a/options.py b/options.py index 60fe343..ce1968d 100644 --- a/options.py +++ b/options.py @@ -104,12 +104,6 @@ def __init__(self) -> None: raise NotImplementedError def _to_json(self) -> J: - """ - Turns this option into a JSON value. - - Returns: - This option's JSON representation. - """ return self.value def __post_init__(self) -> None: @@ -364,12 +358,6 @@ class ButtonOption(BaseOption): on_press: Callable[[Self], None] | None = None def _to_json(self) -> EllipsisType: - """ - A dummy method to adhere to BaseOption interface, while indicating no JSON representation. - - Returns: - Ellipsis, indicating that ButtonOption cannot be represented as a value. - """ return ... def _from_json(self, value: JSON) -> None: @@ -479,12 +467,11 @@ class GroupedOption(BaseOption): children: Sequence[BaseOption] def _to_json(self) -> JSON: - grouped_option_dict: Mapping[str, JSON] = {} - for option in self.children: - if option._to_json() == ...: - continue - grouped_option_dict[option.identifier] = cast(JSON, option._to_json()) - return grouped_option_dict + return { + option.identifier: child_json + for option in self.children + if (child_json := option._to_json()) is not ... + } def _from_json(self, value: JSON) -> None: if isinstance(value, Mapping): @@ -523,12 +510,11 @@ class NestedOption(BaseOption): children: Sequence[BaseOption] def _to_json(self) -> JSON: - grouped_option_dict: Mapping[str, JSON] = {} - for option in self.children: - if option._to_json() == ...: - continue - grouped_option_dict[option.identifier] = cast(JSON, option._to_json()) - return grouped_option_dict + return { + option.identifier: child_json + for option in self.children + if (child_json := option._to_json()) is not ... + } def _from_json(self, value: JSON) -> None: if isinstance(value, Mapping): diff --git a/settings.py b/settings.py index 050c763..5c29d13 100644 --- a/settings.py +++ b/settings.py @@ -2,7 +2,7 @@ import json from collections.abc import Mapping, Sequence -from typing import TYPE_CHECKING, TypedDict, cast +from typing import TYPE_CHECKING, TypedDict from . import MODS_DIR @@ -81,14 +81,12 @@ def create_options_dict(options: Sequence[BaseOption]) -> dict[str, JSON]: Returns: The options' values in dict form. """ - settings: dict[str, JSON] = { - option.identifier: cast(JSON, option._to_json()) # type: ignore + return { + option.identifier: child_json for option in options - if option._to_json() != ... # type: ignore + if (child_json := option._to_json()) is not ... # pyright: ignore[reportPrivateUsage] } - return settings - def default_save_mod_settings(self: Mod) -> None: """Default implementation for Mod.save_settings."""