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" diff --git a/options.py b/options.py index 822f2cd..ce1968d 100644 --- a/options.py +++ b/options.py @@ -1,7 +1,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 +from types import EllipsisType +from typing import TYPE_CHECKING, Any, Literal, Self, cast from unrealsdk import logging @@ -45,6 +46,27 @@ class BaseOption(ABC): def __init__(self) -> None: raise NotImplementedError + @abstractmethod + 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: if self.display_name is None: # type: ignore self.display_name = self.identifier @@ -81,6 +103,9 @@ class ValueOption[J: JSON](BaseOption): def __init__(self) -> None: raise NotImplementedError + def _to_json(self) -> J: + return self.value + def __post_init__(self) -> None: super().__post_init__() self.default_value = self.value @@ -147,6 +172,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 +214,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 +253,16 @@ 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 +289,13 @@ 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 +322,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 +357,12 @@ class ButtonOption(BaseOption): _: KW_ONLY on_press: Callable[[Self], None] | None = None + def _to_json(self) -> EllipsisType: + return ... + + def _from_json(self, value: JSON) -> None: + pass + def __call__(self, on_press: Callable[[Self], None]) -> Self: """ Sets the on press callback. @@ -339,6 +411,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 +466,25 @@ class GroupedOption(BaseOption): children: Sequence[BaseOption] + def _to_json(self) -> JSON: + 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): + 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 +508,22 @@ class NestedOption(BaseOption): """ children: Sequence[BaseOption] + + def _to_json(self) -> JSON: + 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): + 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..5c29d13 100644 --- a/settings.py +++ b/settings.py @@ -2,27 +2,13 @@ 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: from .mod import Mod + from .options import BaseOption type JSON = Mapping[str, JSON] | Sequence[JSON] | str | int | float | bool | None @@ -36,7 +22,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 +39,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: @@ -146,29 +81,11 @@ 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: - 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__}", - ) - - return settings + return { + option.identifier: child_json + for option in options + if (child_json := option._to_json()) is not ... # pyright: ignore[reportPrivateUsage] + } def default_save_mod_settings(self: Mod) -> None: