-
Notifications
You must be signed in to change notification settings - Fork 2
/
mod_factory.py
362 lines (296 loc) · 13.5 KB
/
mod_factory.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
import contextlib
import functools
import inspect
import operator
import tomllib
from collections.abc import Callable, Sequence
from pathlib import Path
from types import ModuleType
from typing import Any, TypedDict
from unrealsdk import logging
from .command import AbstractCommand
from .dot_sdkmod import open_in_mod_dir
from .hook import HookType
from .keybinds import KeybindType
from .mod import CoopSupport, Game, Mod, ModType
from .mod_list import deregister_mod, mod_list, register_mod
from .options import BaseOption, GroupedOption, NestedOption
from .settings import SETTINGS_DIR
def build_mod(
*,
cls: type[Mod] = Mod,
deregister_same_settings: bool = True,
inject_version_from_pyproject: bool = True,
version_info_parser: Callable[[str], tuple[int, ...]] = (
lambda v: tuple(int(x) for x in v.split("."))
),
name: str | None = None,
author: str | None = None,
description: str | None = None,
version: str | None = None,
mod_type: ModType | None = None,
supported_games: Game | None = None,
coop_support: CoopSupport | None = None,
settings_file: Path | None = None,
keybinds: Sequence[KeybindType] | None = None,
options: Sequence[BaseOption] | None = None,
hooks: Sequence[HookType] | None = None,
commands: Sequence[AbstractCommand] | None = None,
auto_enable: bool | None = None,
on_enable: Callable[[], None] | None = None,
on_disable: Callable[[], None] | None = None,
) -> Mod:
"""
Factory function to create and register a mod.
Fields are gathered in three ways, in order of priority:
- Args directly to this function.
- A `pyproject.toml` in the same dir as the calling module.
- Variables in the calling module's scope. Note the ordering of these is not necessarily stable.
Arg | `pyproject.toml`, in priority order | Module Scope
----------------|--------------------------------------|--------------
name | tool.sdkmod.name, project.name |
author | project.authors[n].name ^1 | __author__
description | project.description |
version | tool.sdkmod.version, project.version | __version__
| project.version | __version_info__
mod_type | tool.sdkmod.mod_type ^2 |
supported_games | tool.sdkmod.supported_games ^3 |
coop_support | tool.sdkmod.coop_support ^4 |
settings_file | | f"{__name__}.json" in the settings dir
keybinds | | Keybind instances
options | | OptionBase instances ^5
hooks | | Hook instances
commands | | AbstractCommand instances
auto_enable | tool.sdkmod.auto_enable |
on_enable | | on_enable
on_disable | | on_disable
^1: Multiple authors are joined into a single string using commas + spaces.
^2: A string of one of the ModType enum value's name. Case sensitive.
^3: A list of strings of Game enum values' names. Case sensitive.
^4: A string of one of the CoopSupport enum value's name. Case sensitive.
^5: GroupedOption and NestedOption instances are deliberately ignored, to avoid possible issues
gathering their child options twice. They must be explicitly passed via the arg.
Missing fields are not passed on to the mod constructor - e.g. by never specifying supported
games, they won't be passed on and it will use the default, all of them.
Extra Args:
cls: The mod class to construct using. Can be used to select a subclass.
deregister_same_settings: If true, deregisters any existing mods that use the same settings
file. Useful so that reloading the module does not create multiple
entries in the mods menu.
inject_version_from_pyproject: If true, injects `__version__` and `__version_info__` back
into the module scope with values parsed from the
`pyproject.toml`. Does not overwrite existing values.
version_info_parser: A function which parses the `project.version` field into a tuple of
ints, for when injecting __version_info__. The default implementation
only supports basic dot-separated decimal numbers.
Returns:
The created mod object.
"""
module = inspect.getmodule(inspect.stack()[1].frame)
if module is None:
raise ValueError("Unable to find calling module when using build_mod factory!")
fields: ModFactoryFields = {
"name": name,
"author": author,
"description": description,
"version": version,
"_version_info": None,
"mod_type": mod_type,
"supported_games": supported_games,
"coop_support": coop_support,
"settings_file": settings_file,
"keybinds": keybinds,
"options": options,
"hooks": hooks,
"commands": commands,
"auto_enable": auto_enable,
"on_enable": on_enable,
"on_disable": on_disable,
}
update_fields_with_pyproject(module, fields)
if inject_version_from_pyproject:
if not hasattr(module, "__version__") and fields["version"] is not None:
module.__version__ = fields["version"] # type: ignore
if not hasattr(module, "__version_info__") and fields["_version_info"] is not None:
version_info = version_info_parser(fields["_version_info"])
module.__version_info__ = version_info # type: ignore
update_fields_with_module_attributes(module, fields)
update_fields_with_module_search(module, fields)
if deregister_same_settings and fields["settings_file"] is not None:
deregister_using_settings_file(fields["settings_file"])
# Strip out anything unspecified or private
kwargs = {k: v for k, v in fields.items() if v is not None and not k.startswith("_")}
mod = cls(**kwargs) # type: ignore
register_mod(mod)
return mod
# ==================================================================================================
class ModFactoryFields(TypedDict):
name: str | None
author: str | None
description: str | None
version: str | None
_version_info: str | None
mod_type: ModType | None
supported_games: Game | None
coop_support: CoopSupport | None
settings_file: Path | None
keybinds: Sequence[KeybindType] | None
options: Sequence[BaseOption] | None
hooks: Sequence[HookType] | None
commands: Sequence[AbstractCommand] | None
auto_enable: bool | None
on_enable: Callable[[], None] | None
on_disable: Callable[[], None] | None
def load_pyproject(module: ModuleType) -> dict[str, Any]:
"""
Tries to load a pyproject.toml in the same dir as the given module.
Properly handles modules from inside a `.sdkmod`.
Args:
module: The module to look up the pyproject of.
Returns:
The parsed toml data, or an empty dict if unable to find a pyproject.toml.
"""
pyproject = Path(inspect.getfile(module)).with_name("pyproject.toml")
try:
with open_in_mod_dir(pyproject, binary=True) as file:
return tomllib.load(file)
except (FileNotFoundError, tomllib.TOMLDecodeError):
return {}
def update_fields_with_pyproject_tool_sdkmod(
sdkmod: dict[str, Any],
fields: ModFactoryFields,
) -> None:
"""
Updates the mod factory fields with data from the `tools.sdkmod` section of a`pyproject.toml`.
Args:
sdkmod: The `tools.sdkmod` section.
fields: The current set of fields. Modified in place.
"""
for simple_field in ("name", "version", "auto_enable"):
if fields[simple_field] is None and simple_field in sdkmod:
fields[simple_field] = sdkmod[simple_field]
if fields["mod_type"] is None and "mod_type" in sdkmod:
fields["mod_type"] = ModType.__members__.get(sdkmod["mod_type"])
if fields["supported_games"] is None and "supported_games" in sdkmod:
valid_games = [Game[name] for name in sdkmod["supported_games"] if name in Game.__members__]
if valid_games:
fields["supported_games"] = functools.reduce(operator.or_, valid_games)
if fields["coop_support"] is None and "coop_support" in sdkmod:
fields["coop_support"] = CoopSupport.__members__.get(sdkmod["coop_support"])
def update_fields_with_pyproject_project(
project: dict[str, Any],
fields: ModFactoryFields,
) -> None:
"""
Updates the mod factory fields with data from the `project` section of a`pyproject.toml`.
Args:
project: The `project` section.
fields: The current set of fields. Modified in place.
"""
for simple_field, project_field in (
("name", "name"),
("version", "version"),
("description", "description"),
("_version_info", "version"),
):
if fields[simple_field] is None and project_field in project:
fields[simple_field] = project[project_field]
if fields["author"] is None and "authors" in project:
fields["author"] = ", ".join(
author["name"] for author in project["authors"] if "name" in author
)
def update_fields_with_pyproject(module: ModuleType, fields: ModFactoryFields) -> None:
"""
Updates the mod factory fields with data gathered from the `pyproject.toml`.
Args:
module: The calling module to search for the `pyproject.toml` of.
fields: The current set of fields. Modified in place.
"""
toml_data = load_pyproject(module)
# Check `tool.sdkmod` first, since we want it to have priority in cases we have multiple keys
if ("tool" in toml_data) and ("sdkmod" in toml_data["tool"]):
update_fields_with_pyproject_tool_sdkmod(toml_data["tool"]["sdkmod"], fields)
if "project" in toml_data:
update_fields_with_pyproject_project(toml_data["project"], fields)
def update_fields_with_module_attributes(module: ModuleType, fields: ModFactoryFields) -> None:
"""
Updates the mod factory fields with data gathered from top level attributes in the module.
Args:
module: The calling module to search through.
fields: The current set of fields. Modified in place.
"""
for simple_field, attr in (
("name", "__name__"),
("author", "__author__"),
("version", "__version__"),
("on_enable", "on_enable"),
("on_disable", "on_disable"),
):
if fields[simple_field] is not None:
continue
with contextlib.suppress(AttributeError):
fields[simple_field] = getattr(module, attr)
if fields["settings_file"] is None:
fields["settings_file"] = SETTINGS_DIR / (module.__name__ + ".json")
def update_fields_with_module_search( # noqa: C901 - difficult to split up
module: ModuleType,
fields: ModFactoryFields,
) -> None:
"""
Updates the mod factory fields with data gathered by searching through all vars in the module.
Args:
module: The calling module to search through.
fields: The current set of fields. Modified in place.
"""
need_to_search_module = False
new_keybinds: list[KeybindType] = []
if find_keybinds := fields["keybinds"] is None:
need_to_search_module = True
new_options: list[BaseOption] = []
if find_options := fields["options"] is None:
need_to_search_module = True
new_hooks: list[HookType] = []
if find_hooks := fields["hooks"] is None:
need_to_search_module = True
new_commands: list[AbstractCommand] = []
if find_commands := fields["commands"] is None:
need_to_search_module = True
if not need_to_search_module:
return
for _, value in inspect.getmembers(module):
match value:
case KeybindType() if find_keybinds:
new_keybinds.append(value)
case GroupedOption() | NestedOption() if find_options:
logging.dev_warning(
f"{module.__name__}: {type(value).__name__} instances must be explicitly"
f" specified in the options list!",
)
case BaseOption() if find_options:
new_options.append(value)
case HookType() if find_hooks:
hook: HookType = value
new_hooks.append(hook)
case AbstractCommand() if find_commands:
new_commands.append(value)
case _:
pass
# Only assign each field if we actually found something, so we keep using the mod constructor's
# default otherwise
if find_keybinds and new_keybinds:
fields["keybinds"] = new_keybinds
if find_options and new_options:
fields["options"] = new_options
if find_hooks and new_hooks:
fields["hooks"] = new_hooks
if find_commands and new_commands:
fields["commands"] = new_commands
def deregister_using_settings_file(settings_file: Path) -> None:
"""
Deregisters all mods using the given settings file.
Args:
settings_file: The settings file path to deregister mods using.
"""
mods_to_remove = [mod for mod in mod_list if mod.settings_file == settings_file]
for mod in mods_to_remove:
deregister_mod(mod)