Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add _(to|from)_json() methods to all option types #11

Merged
merged 5 commits into from
Mar 5, 2025
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
4 changes: 4 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
118 changes: 117 additions & 1 deletion options.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -147,6 +172,9 @@ class HiddenOption[J: JSON](ValueOption[J]):
init=False,
)

def _from_json(self, value: JSON) -> None:
self.value = cast(J, value)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in this case I get the cast since it's narrowing


def save(self) -> None:
"""Saves the settings of the mod this option is associated with."""
if self.mod is None:
Expand Down Expand Up @@ -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]):
Expand Down Expand Up @@ -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]):
Expand All @@ -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]):
Expand All @@ -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):
Expand All @@ -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.
Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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):
Expand All @@ -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",
)
101 changes: 9 additions & 92 deletions settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down