Core: Add settings API ("auto settings") for host.yaml (#1871)

* Add settings API ("auto settings") for host.yaml

* settings: no BOM when saving

* settings: fix saving / groups resetting themselves

* settings: fix AutoWorldRegister import

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>

* Lufia2: settings: clean up imports

* settings: more consistent class naming

* Docs: update world api for settings api refactor

* settings: fix access from World instance

* settings: update migration timeline

* Docs: Apply suggestions from code review

Co-authored-by: Zach Parks <zach@alliware.com>

* Settings: correctly resolve .exe in UserPath and LocalPath

---------

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>
Co-authored-by: Zach Parks <zach@alliware.com>
This commit is contained in:
black-sliver
2023-07-05 22:39:35 +02:00
committed by GitHub
parent d8a8997684
commit 827444f5a4
34 changed files with 1455 additions and 412 deletions

187
docs/settings api.md Normal file
View File

@@ -0,0 +1,187 @@
# Archipelago Settings API
The settings API describes how to use installation-wide config and let the user configure them, like paths, etc. using
host.yaml. For the player settings / player yamls see [options api.md](options api.md).
The settings API replaces `Utils.get_options()` and `Utils.get_default_options()`
as well as the predefined `host.yaml` in the repository.
For backwards compatibility with APWorlds, some interfaces are kept for now and will produce a warning when being used.
## Config File
Settings use options.yaml (manual override), if that exists, or host.yaml (the default) otherwise.
The files are searched for in the current working directory, if different from install directory, and in `user_path`,
which either points to the installation directory, if writable, or to %home%/Archipelago otherwise.
**Examples:**
* C:\Program Data\Archipelago\options.yaml
* C:\Program Data\Archipelago\host.yaml
* path\to\code\repository\host.yaml
* ~/Archipelago/host.yaml
Using the settings API, AP can update the config file or create a new one with default values and comments,
if it does not exist.
## Global Settings
All non-world-specific settings are defined directly in settings.py.
Each value needs to have a default. If the default should be `None`, define it as `typing.Optional` and assign `None`.
To access a "global" config value, with correct typing, use one of
```python
from settings import get_settings, GeneralOptions, FolderPath
from typing import cast
x = get_settings().general_options.output_path
y = cast(GeneralOptions, get_settings()["general_options"]).output_path
z = cast(FolderPath, get_settings()["general_options"]["output_path"])
```
## World Settings
Worlds can define the top level key to use by defining `settings_key: ClassVar[str]` in their World class.
It defaults to `{folder_name}_options` if undefined, i.e. `worlds/factorio/...` defaults to `factorio_options`.
Worlds define the layout of their config section using type annotation of the variable `settings` in the class.
The type has to inherit from `settings.Group`. Each value in the config can have a comment by subclassing a built-in
type. Some helper types are defined in `settings.py`, see [Types](#Types) for a list.```
Inside the class code, you can then simply use `self.settings.rom_file` to get the value.
In case of paths they will automatically be read as absolute file paths. No need to use user_path or local_path.
```python
import settings
from worlds.AutoWorld import World
class MyGameSettings(settings.Group):
class RomFile(settings.SNESRomPath):
"""Description that is put into host.yaml"""
description = "My Game US v1.0 ROM File" # displayed in the file browser
copy_to = "MyGame.sfc" # instead of storing the path, copy to AP dir
md5s = ["..."]
rom_file: RomFile = RomFile("MyGame.sfc") # definition and default value
class MyGameWorld(World):
...
settings: MyGameSettings
...
def something(self):
pass # use self.settings.rom_file here
```
## Types
When writing the host.yaml, the code will down cast the values to builtins.
When reading the host.yaml, the code will upcast the values to what is defined in the type annotations.
E.g. an IntEnum becomes int when saving and will construct the IntEnum when loading.
Types that can not be down cast to / up cast from a builtin can not be used except for Group, which will be converted
to/from a dict.
`bool` is a special case, see settings.py: ServerOptions.disable_item_cheat for an example.
Below are some predefined types that can be used if they match your requirements:
### Group
A section / dict in the config file. Behaves similar to a dataclass.
Type annotation and default assignment define how loading, saving and default values behave.
It can be accessed using attributes or as a dict: `group["a"]` is equivalent to `group.a`.
In worlds, this should only be used for the top level to avoid issues when upgrading/migrating.
### Bool
Since `bool` can not be subclassed, use the `settings.Bool` helper in a `typing.Union` to get a comment in host.yaml.
```python
import settings
import typing
class MySettings(settings.Group):
class MyBool(settings.Bool):
"""Doc string"""
my_value: typing.Union[MyBool, bool] = True
```
### UserFilePath
Path to a single file. Automatically resolves as user_path:
Source folder or AP install path on Windows. ~/Archipelago for the AppImage.
Will open a file browser if the file is missing when in GUI mode.
#### class method validate(cls, path: str)
Override this and raise ValueError if validation fails.
Checks the file against [md5s](#md5s) by default.
#### is_exe: bool
Resolves to an executable (varying file extension based on platform)
#### description: Optional\[str\]
Human-readable name to use in file browser
#### copy_to: Optional\[str\]
Instead of storing the path, copy the file.
#### md5s: List[Union[str, bytes]]
Provide md5 hashes as hex digests or raw bytes for automatic validation.
### UserFolderPath
Same as [UserFilePath](#UserFilePath), but for a folder instead of a file.
### LocalFilePath
Same as [UserFilePath](#UserFilePath), but resolves as local_path:
path inside the AP dir or Appimage even if read-only.
### LocalFolderPath
Same as [LocalFilePath](#LocalFilePath), but for a folder instead of a file.
### OptionalUserFilePath, OptionalUserFolderPath, OptionalLocalFilePath, OptionalLocalFolderPath
Same as UserFilePath, UserFolderPath, LocalFilePath, LocalFolderPath but does not open a file browser if missing.
### SNESRomPath
Specialized [UserFilePath](#UserFilePath) that ignores an optional 512 byte header when validating.
## Caveats
### Circular Imports
Because the settings are defined on import, code that runs on import can not use settings since that would result in
circular / partial imports. Instead, the code should fetch from settings on demand during generation.
"Global" settings are populated immediately, while worlds settings are lazy loaded, so if really necessary,
"global" settings could be used in global scope of worlds.
### APWorld Backwards Compatibility
APWorlds that want to be compatible with both stable and dev versions, have two options:
1. use the old Utils.get_options() API until Archipelago 0.4.2 is out
2. add some sort of compatibility code to your world that mimics the new API

View File

@@ -91,10 +91,13 @@ added to the `World` object for easy access.
### World Options
Any AP installation can provide settings for a world, for example a ROM file,
accessible through `Utils.get_options()['<world>_options']['<option>']`.
Any AP installation can provide settings for a world, for example a ROM file, accessible through `self.settings.option`
or `cls.settings.option` (new API) or `Utils.get_options()["<world>_options"]["<option>"]` (deprecated).
Users can set those in their `host.yaml` file.
Users can set those in their `host.yaml` file. Some options may automatically open a file browser if a file is missing.
Refer to [settings api.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/settings%20api.md)
for details.
### Locations
@@ -349,6 +352,8 @@ class MyGameWorld(World):
```python
# world/mygame/__init__.py
import settings
import typing
from .Options import mygame_options # the options we defined earlier
from .Items import mygame_items # data used below to add items to the World
from .Locations import mygame_locations # same as above
@@ -356,16 +361,27 @@ from worlds.AutoWorld import World
from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification
from Utils import get_options, output_path
class MyGameItem(Item): # or from Items import MyGameItem
game = "My Game" # name of the game/world this item is from
class MyGameLocation(Location): # or from Locations import MyGameLocation
game = "My Game" # name of the game/world this location is in
class MyGameSettings(settings.Group):
class RomFile(settings.SNESRomPath):
"""Insert help text for host.yaml here."""
rom_file: RomFile = RomFile("MyGame.sfc")
class MyGameWorld(World):
"""Insert description of the world/game here."""
game = "My Game" # name of the game/world
option_definitions = mygame_options # options the player can set
settings: typing.ClassVar[MyGameSettings] # will be automatically assigned from type hint
topology_present = True # show path to required location checks in spoiler
# ID of first item and location, could be hard-coded but code may be easier
@@ -668,7 +684,7 @@ def generate_output(self, output_directory: str):
"fix_xyz_glitch": self.multiworld.fix_xyz_glitch[self.player].value
}
# point to a ROM specified by the installation
src = Utils.get_options()["mygame_options"]["rom_file"]
src = self.settings.rom_file
# or point to worlds/mygame/data/mod_template
src = os.path.join(os.path.dirname(__file__), "data", "mod_template")
# generate output path