moved safe_eval from Track.py to safe_eval.py, allowed getattr with no function limitation, added track_formatting to mod_config.json to customize the track text format for the menu, the race and the filename. Added a Combobox on the install menu for the extension

This commit is contained in:
Faraphel 2022-06-12 23:21:09 +02:00
parent 31a28c3cf1
commit 70ade3dc67
11 changed files with 356 additions and 124 deletions

View file

@ -1,5 +1,5 @@
{
"version": "0.11",
"version": "v0.12",
"name": "Mario Kart Wii Faraphel",
"nickname": "MKWF",
"variant": "60",
@ -67,16 +67,17 @@
},
"tags_cups": ["Wii U", "3DS", "DS", "GCN", "GBA", "N64", "SNES", "MKT", "RMX", "DX", "GP"],
"track_formatting": {
"menu_name": "{{ ('\\c{YOR2}\\x'+hex(65296+getattr(track, 'score'))[2:]+'\\{off} ') if hasattr(track, 'score') else '' }}{{ (prefix+' ') if prefix else '' }}{{ getattr(track, 'name', '') }}{{ (' ('+suffix +')') if suffix else '' }}",
"race_name": "{{ getattr(track, 'name', '/') }}",
"file_name": "{{ getattr(track, 'sha1', '/') }}"
},
"default_track": {
"music":"T32",
"special":"T32",
"author":"MrFluffy",
"since_version":"0.1",
"sha1":"54a1621fef2b137adcbe20b6dd710b5bc5f981a1",
"version":"v1.1",
"tags":[
]
"tags":[]
},
"tracks": [
{

View file

@ -3,7 +3,7 @@
"translation": {
"INSTALLER_TITLE": "MKWF-Install",
"LANGUAGE_SELECTION": "Language",
"TRACK_CONFIGURATION": "Track Configuration",
"TRACK_FILTER": "Track Filters",
"ADVANCED_CONFIGURATION": "Advanced",
"HELP": "Help"
}

View file

@ -3,7 +3,7 @@
"translation": {
"INSTALLER_TITLE": "MKWF-Install",
"LANGUAGE_SELECTION": "Langue",
"TRACK_CONFIGURATION": "Configuration des courses",
"TRACK_FILTER": "filtrer les courses",
"ADVANCED_CONFIGURATION": "Avancée",
"HELP": "Aide"
}

View file

@ -18,6 +18,8 @@ from source import event
from source import *
import os
from source.wt.wit import Extension
class SourceGameError(Exception):
def __init__(self, path: Path | str):
@ -126,6 +128,13 @@ class Window(tkinter.Tk):
"""
return self.destination_game.get_path()
def get_output_type(self) -> Extension:
"""
Get the output type
:return: output type
"""
return self.destination_game.get_output_type()
# Menu bar
class Menu(tkinter.Menu):
@ -159,8 +168,8 @@ class Menu(tkinter.Menu):
def __init__(self, master: tkinter.Menu):
super().__init__(master, tearoff=False)
master.add_cascade(label=_("TRACK_CONFIGURATION"), menu=self)
self.add_command(label="Change configuration")
master.add_cascade(label=_("TRACK_FILTER"), menu=self)
self.add_command(label="Change filter")
# Advanced menu
class Advanced(tkinter.Menu):
@ -209,6 +218,7 @@ class Menu(tkinter.Menu):
class SourceGame(ttk.LabelFrame):
def __init__(self, master: tkinter.Tk):
super().__init__(master, text="Original Game File")
self.columnconfigure(1, weight=1)
self.entry = ttk.Entry(self, width=50)
self.entry.grid(row=1, column=1, sticky="nsew")
@ -268,12 +278,17 @@ class SourceGame(ttk.LabelFrame):
class DestinationGame(ttk.LabelFrame):
def __init__(self, master: tkinter.Tk):
super().__init__(master, text="Game Directory Destination")
self.columnconfigure(1, weight=1)
self.entry = ttk.Entry(self, width=50)
self.entry = ttk.Entry(self)
self.entry.grid(row=1, column=1, sticky="nsew")
self.output_type = ttk.Combobox(self, width=5, values=[extension.name for extension in Extension])
self.output_type.set(Extension.WBFS.name)
self.output_type.grid(row=1, column=2, sticky="nsew")
self.button = ttk.Button(self, text="...", width=2, command=self.select)
self.button.grid(row=1, column=2, sticky="nsew")
self.button.grid(row=1, column=3, sticky="nsew")
def select(self) -> None:
"""
@ -304,6 +319,13 @@ class DestinationGame(ttk.LabelFrame):
if not path.exists(): raise DestinationGameError(path)
return path
def get_output_type(self) -> Extension:
"""
Get the output type
:return: the output type
"""
return Extension[self.output_type.get()]
def set_state(self, state: InstallerState) -> None:
"""
Set the progress bar state when the installer change state
@ -312,7 +334,9 @@ class DestinationGame(ttk.LabelFrame):
"""
for child in self.winfo_children():
match state:
case InstallerState.IDLE: child.config(state="normal")
case InstallerState.IDLE:
if child == self.output_type: child.config(state="readonly")
else: child.config(state="normal")
case InstallerState.INSTALLING: child.config(state="disabled")
@ -339,14 +363,20 @@ class ButtonInstall(ttk.Button):
messagebox.showerror(_("ERROR"), _("ERROR_INVALID_DESTINATION_GAME"))
return
# get space remaining on the C: drive
# if there is no more space on the installer drive, show a warning
if shutil.disk_usage(".").free < minimum_space_available:
if not messagebox.askokcancel(_("WARNING"), _("WARNING_LOW_SPACE_CONTINUE")):
return
# if there is no more space on the destination drive, show a warning
elif shutil.disk_usage(destination_path).free < minimum_space_available:
if not messagebox.askokcancel(_("WARNING"), _("WARNING_LOW_SPACE_CONTINUE")):
return
game = Game(source_path)
mod_config = self.master.get_mod_config()
self.master.progress_function(game.install_mod(destination_path, mod_config))
output_type = self.master.get_output_type()
self.master.progress_function(game.install_mod(destination_path, mod_config, output_type))
finally:
self.master.set_state(InstallerState.IDLE)

View file

@ -1,4 +1,33 @@
# class that represent a mario kart wii cup
from PIL import Image
class Cup:
def __init__(self, track1: "Track" = None, track2: "Track" = None, track3: "Track" = None, track4: "Track" = None):
self._tracks = [track1, track2, track3, track4]
__slots__ = ["_tracks", "cup_id"]
_last_cup_id = 0
def __init__(self, tracks: list["Track | TrackGroup"], cup_id: str | None = None):
self._tracks = tracks[:4]
if cup_id is None:
cup_id = self.__class__._last_cup_id
self.__class__._last_cup_id += 1
self.cup_id = cup_id
def __repr__(self):
return f"<Cup id={self.cup_id} tracks={self._tracks}>"
def get_cup_icon(self) -> Image.Image:
...
def get_ctfile(self, mod_config: "ModConfig") -> str:
"""
Get the ctfile for this cup
:return: the ctfile
"""
ctfile = f'C "{self.cup_id}"\n'
for track in self._tracks: ctfile += track.get_ctfile(mod_config=mod_config)
ctfile += "\n"
return ctfile

View file

@ -3,7 +3,7 @@ from pathlib import Path
from typing import Generator
from source.mkw.ModConfig import ModConfig
from source.wt.wit import WITPath, Region
from source.wt.wit import WITPath, Region, Extension
class Game:
@ -24,7 +24,7 @@ class Game:
"""
return not any(self.wit_path[f"./files/rel/lecode-{region.value}.bin"].exists() for region in Region)
def extract(self, dest: Path | str) -> Generator[str, None, Path]:
def extract(self, dest: Path | str) -> Generator[dict, None, Path]:
"""
Extract the game to the destination directory. If the game is a FST, just copy to the destination
:param dest: destination directory
@ -43,10 +43,13 @@ class Game:
except StopIteration as e:
return e.value
def install_mod(self, dest: Path, mod_config: ModConfig) -> Generator[str, None, None]:
def install_mod(self, dest: Path, mod_config: ModConfig, output_type: Extension) -> Generator[dict, None, None]:
"""
Patch the game with the mod
:dest: destination directory
:mod_config: mod configuration
:output_type: type of the destination game
"""
yield from self.extract(dest / f"{mod_config.nickname} {mod_config.version}")
# yield from self.extract(dest / f"{mod_config.nickname} {mod_config.version}")
print(mod_config.get_ctfile())
yield {}

View file

@ -2,6 +2,7 @@ from pathlib import Path
from typing import Generator
from source.mkw import Tag, Color
from source.mkw.Cup import Cup
from source.mkw.Track import Track
import json
@ -10,33 +11,42 @@ import json
class ModConfig:
__slots__ = ("name", "nickname", "variant", "region", "tags_prefix", "tags_suffix",
"default_track", "_tracks", "version", "original_track_prefix", "swap_original_order",
"keep_original_track", "enable_random_cup", "tags_cups")
"keep_original_track", "enable_random_cup", "tags_cups", "track_formatting")
def __init__(self, name: str, nickname: str = None, version: str = None, variant: str = None,
tags_prefix: dict[Tag, Color] = None, tags_suffix: dict[Tag, Color] = None,
tags_cups: list[Tag] = None, region: dict[int] | int = None,
default_track: "Track | TrackGroup" = None, tracks: list["Track | TrackGroup"] = None,
original_track_prefix: bool = None, swap_original_order: bool = None,
keep_original_track: bool = None, enable_random_cup: bool = None):
keep_original_track: bool = None, enable_random_cup: bool = None,
track_formatting: dict[str, str] = None):
self.name: str = name
self.nickname: str = nickname if nickname is not None else name
self.version: str = version if version is not None else "1.0.0"
self.version: str = version if version is not None else "v1.0.0"
self.variant: str = variant if variant is not None else "01"
self.region: dict[int] | int = region if region is not None else 0
self.tags_prefix: dict[Tag] = tags_prefix if tags_prefix is not None else {}
self.tags_suffix: dict[Tag] = tags_suffix if tags_suffix is not None else {}
self.tags_cups: dict[Tag] = tags_cups if tags_cups is not None else {}
self.tags_cups: list[Tag] = tags_cups if tags_cups is not None else []
self.default_track: "Track | TrackGroup" = default_track if default_track is not None else None
self._tracks: list["Track | TrackGroup"] = tracks if tracks is not None else []
self.track_formatting: dict[str, str] = {
"menu_name": "{{ getattr(track, 'name', '/') }}",
"race_name": "{{ getattr(track, 'name', '/') }}",
"file_name": "{{ getattr(track, 'sha1', '/') }}"
} | (track_formatting if track_formatting is not None else {})
self.original_track_prefix: bool = original_track_prefix if original_track_prefix is not None else True
self.swap_original_order: bool = swap_original_order if swap_original_order is not None else True
self.keep_original_track: bool = keep_original_track if keep_original_track is not None else True
self.enable_random_cup: bool = enable_random_cup if enable_random_cup is not None else True
def __repr__(self):
return f"<ModConfig name={self.name} version={self.version}>"
@classmethod
def from_dict(cls, config_dict: dict) -> "ModConfig":
"""
@ -44,11 +54,11 @@ class ModConfig:
:param config_dict: dict containing the configuration
:return: ModConfig
"""
kwargs = {
attr: config_dict.get(attr)
for attr in ["nickname", "version", "variant", "tags_prefix", "tags_suffix", "tags_cups",
"original_track_prefix", "swap_original_order", "keep_original_track", "enable_random_cup"]
for attr in cls.__slots__
if attr not in ["name", "default_track", "_tracks", "tracks"]
# these keys are treated after or are reserved
}
return cls(
@ -78,3 +88,82 @@ class ModConfig:
for track in self._tracks:
yield from track.get_tracks()
def get_ordered_cups(self) -> Generator["Cup", None, None]:
"""
Get all the cups with cup tags
:return: cups with cup tags
"""
# use self._tracks instead of self._get_tracks() because we want the TrackGroup
# for track that have a tag in self.tags_cups
for tag_cup in self.tags_cups:
track_buffer: "Track | TrackGroup" = []
current_tag_name, current_tag_count = tag_cup, 0
# every four 4 tracks, create a cup
for track in filter(lambda track: tag_cup in getattr(track, "tags", []), self._tracks):
track_buffer.append(track)
if len(track_buffer) > 4:
current_tag_count += 1
yield Cup(tracks=track_buffer, cup_id=f"{current_tag_name}-{current_tag_count}")
track_buffer = []
# if there is still tracks in the buffer, create a cup with them and fill with default>
if len(track_buffer) > 0:
track_buffer.extend([self.default_track] * (4 - len(track_buffer)))
yield Cup(tracks=track_buffer, cup_id=f"{current_tag_name}-{current_tag_count+1}")
def get_unordered_cups(self) -> Generator["Cup", None, None]:
"""
Get all the cups with no cup tags
:return: cups with no cup tags
"""
# for track that have don't have a tag in self.tags_cups
track_buffer: "Track | TrackGroup" = []
for track in filter(
lambda track: not any(item in getattr(track, "tags", []) for item in self.tags_cups),
self._tracks
):
track_buffer.append(track)
if len(track_buffer) > 4:
yield Cup(tracks=track_buffer)
track_buffer = []
# if there is still tracks in the buffer, create a cup with them and fill with default
if len(track_buffer) > 0:
track_buffer.extend([self.default_track] * (4 - len(track_buffer)))
yield Cup(tracks=track_buffer)
def get_cups(self) -> Generator["Cup", None, None]:
"""
Get all the cups
:return: cups
"""
yield from self.get_ordered_cups()
yield from self.get_unordered_cups()
def get_ctfile(self) -> str:
"""
Return the ct_file generated from the ModConfig
:return: ctfile content
"""
lecode_flags = filter(lambda v: v is not None, [
"N$SHOW" if self.keep_original_track else "N$NONE",
"N$F_WII" if self.original_track_prefix else None,
"N$SWAP" if self.swap_original_order else None
])
ctfile = (
f"#CT-CODE\n" # magic number
f"[RACING-TRACK-LIST]\n" # start of the track section
f"%LE-FLAGS=1\n" # enable lecode mode
f"%WIIMM-CUP={int(self.enable_random_cup)}\n" # enable random cup
f"N {' | '.join(lecode_flags)}\n" # other flags to disable default tracks, ...
f"\n"
)
for cup in self.get_cups():
ctfile += cup.get_ctfile(mod_config=self)
return ctfile

View file

@ -2,30 +2,11 @@ from typing import Generator
import re
from source.mkw import Tag, Slot
from source.safe_eval import safe_eval
TOKEN_START = "{{"
TOKEN_END = "}}"
common_token_map = { # these operators and function are considered safe to use in the template
operator: operator
for operator in
["+", "-", "*", "/", "%", "**", ",", "(", ")", "[", "]", "==", "!=", "in", ">", "<", ">=", "<=", "and", "or", "&",
"|", "^", "~", "<<", ">>", "not", "is", "if", "else", "abs", "int", "bin", "hex", "oct", "chr", "ord", "len",
"str", "bool", "float", "round", "min", "max", "sum", "zip", "any", "all", "issubclass", "reversed", "enumerate",
"list", "sorted", "hasattr", "for", "range", "type", "isinstance", "repr", "None", "True", "False"
]
} | { # these methods are considered safe, except for the magic methods
f".{method}": f".{method}"
for method in dir(str) + dir(list) + dir(int) + dir(float)
if not method.startswith("__")
}
class TokenParsingError(Exception):
def __init__(self, token: str):
super().__init__(f"Invalid token while parsing track representation:\n{token}")
# representation of a custom track
class Track:
@ -41,6 +22,9 @@ class Track:
if key.startswith("__"): continue
setattr(self, key, value)
def __repr__(self):
return f"<Track name={getattr(self, 'name', '/')} tags={getattr(self, 'tags', '/')}>"
@classmethod
def from_dict(cls, track_dict: dict) -> "Track | TrackGroup":
"""
@ -68,75 +52,24 @@ class Track:
:return: formatted representation of the track
"""
token_map = common_token_map | { # replace the suffix and the prefix by the corresponding values
"prefix": self.get_prefix(mod_config, ""),
"suffix": self.get_prefix(mod_config, ""),
} | { # replace the track attribute by the corresponding values
f"track.{attr}": f"track.{attr}" for attr, value in self.__dict__.items()
} | { # replace the track variable by the corresponding value, if not used with an attribute
extra_token_map = { # replace the suffix and the prefix by the corresponding values
"prefix": f'{self.get_prefix(mod_config, "")!r}',
"suffix": f'{self.get_suffix(mod_config, "")!r}',
"track": "track"
}
def format_token(match: re.Match) -> str:
# get the token string without the brackets, then strip it
process_token = match.group(1).strip()
final_token: str = ""
def matched(match: re.Match | str | None, value: str = None) -> bool:
def format_template(match: re.Match) -> str:
"""
check if token is matched, if yes, add it to the final token and remove it from the processing token
:param match: match object
:param value: if the match is a string, the value to replace the text with
:return: True if matched, False otherwise
when a token is found, replace it by the corresponding value
:param match: match in the format
:return: corresponding value
"""
nonlocal final_token, process_token
# if there is no match or the string is empty, return False
if not match: return False
if isinstance(match, re.Match):
process_token_raw = process_token[match.end():]
value = match.group()
else:
if not process_token.startswith(match): return False
process_token_raw = process_token[len(match):]
process_token = process_token_raw.lstrip()
final_token += value + (len(process_token_raw) - len(process_token)) * " "
return True
while process_token: # while there is still tokens to process
# if the section is a string, add it to the final token
# example : "hello", "hello \" world"
if matched(re.match(r'^\"(?:[^"\\]|\\.)*\"', process_token)):
continue
# if the section is a float or an int, add it to the final token
# example : 102, 102.59
if matched(re.match(r'^[0-9]+(?:\.[0-9]+)?', process_token)):
continue
# if the section is a variable, operator or function, replace it by its value
# example : track.special, +
for key, value in token_map.items():
if matched(key, value):
break
# else, the token is invalid, so raise an error
else:
raise TokenParsingError(process_token)
# if final_token is set, eval final_token and return the result
if final_token:
return str(eval(final_token, {}, {"track": self}))
else:
return final_token
# get the token string without the brackets, then strip it. Also double antislash
template = match.group(1).strip().replace("\\", "\\\\")
return safe_eval(template, extra_token_map, {"track": self})
# pass everything between TOKEN_START and TOKEN_END in the function
return re.sub(rf"{TOKEN_START}(.*?){TOKEN_END}", format_token, format)
return re.sub(rf"{TOKEN_START}(.*?){TOKEN_END}", format_template, format)
def get_prefix(self, mod_config: "ModConfig", default: any = None) -> any:
"""
@ -145,8 +78,7 @@ class Track:
:param mod_config: mod configuration
:return: formatted representation of the track prefix
"""
for tag in filter(lambda tag: tag in mod_config.tags_prefix, self.tags):
return mod_config.tags_prefix[tag]
for tag in filter(lambda tag: tag in mod_config.tags_prefix, self.tags): return tag
return default
def get_suffix(self, mod_config: "ModConfig", default: any = None) -> any:
@ -156,9 +88,29 @@ class Track:
:param mod_config: mod configuration
:return: formatted representation of the track suffix
"""
for tag in filter(lambda tag: tag in mod_config.tags_suffix, self.tags):
return mod_config.tags_suffix[tag]
for tag in filter(lambda tag: tag in mod_config.tags_suffix, self.tags): return tag
return default
def get_highlight(self, mod_config: "ModConfig", default: any = None) -> any:
def is_highlight(self, mod_config: "ModConfig", default: any = None) -> bool:
...
def is_new(self, mod_config: "ModConfig", default: any = None) -> bool:
...
def get_ctfile(self, mod_config: "ModConfig", hidden: bool = False) -> str:
"""
return the ctfile of the track
:hidden: if the track is in a group
:return: ctfile
"""
# TODO: filename, info and - are not implemented
menu_name = f'{self.repr_format(mod_config=mod_config, format=mod_config.track_formatting["menu_name"])!r}'
file_name = f'{self.repr_format(mod_config=mod_config, format=mod_config.track_formatting["file_name"])!r}'
return (
f'{"H" if hidden else "T"} {self.music}; ' # track type
f'{self.special}; {(0x04 if hidden else 0) | (0x01 if self.is_new(mod_config, False) else 0):#04x}; ' # lecode flags
f'{file_name}; ' # filename
f'{menu_name}; ' # name of the track in the menu
f'{file_name}\n' # unique identifier for each track
)

View file

@ -1,10 +1,13 @@
from typing import Generator
from source.mkw import Tag
# representation of a group of tracks
class TrackGroup:
def __init__(self, tracks: list["Track"] = None):
def __init__(self, tracks: list["Track"] = None, tags: list[Tag] = None, name: str = None):
self.tracks = tracks if tracks is not None else []
self.tags = tags if tags is not None else []
self.name = name if name is not None else ""
def get_tracks(self) -> Generator["Track", None, None]:
"""
@ -24,4 +27,19 @@ class TrackGroup:
from source.mkw.Track import Track
if "group" not in group_dict: return Track.from_dict(group_dict)
return cls(tracks=[Track.from_dict(track) for track in group_dict["group"]])
return cls(
tracks=[Track.from_dict(track) for track in group_dict["group"]],
tags=group_dict.get("tags"),
name=group_dict.get("name"),
)
def get_ctfile(self, mod_config: "ModConfig") -> str:
"""
return the ctfile of the track group
:return: ctfile
"""
ctfile = f'T T11; T11; 0x02; "-"; "info"; "-"\n'
for track in self.get_tracks():
ctfile += track.get_ctfile(mod_config=mod_config, hidden=True)
return ctfile

110
source/safe_eval.py Normal file
View file

@ -0,0 +1,110 @@
import re
from typing import Callable
common_token_map = { # these operators and function are considered safe to use in the template
operator: operator
for operator in
["+", "-", "*", "/", "%", "**", ",", "(", ")", "[", "]", "==", "!=", "in", ">", "<", ">=", "<=", "and", "or", "&",
"|", "^", "~", "<<", ">>", ":", "not", "is", "if", "else", "abs", "int", "bin", "hex", "oct", "chr", "ord", "len",
"str", "bool", "float", "round", "min", "max", "sum", "zip", "any", "all", "issubclass", "reversed", "enumerate",
"list", "sorted", "hasattr", "for", "range", "type", "isinstance", "repr", "None", "True", "False", "getattr"
]
} | { # these methods are considered safe, except for the magic methods
f".{method}": f".{method}"
for method in dir(str) + dir(list) + dir(int) + dir(float)
if not method.startswith("__")
}
class TemplateParsingError(Exception):
def __init__(self, token: str):
super().__init__(f"Invalid token while parsing track representation:\n{token}")
class SafeFunction:
@classmethod
def get_all_safe_methods(cls) -> dict[str, Callable]:
"""
get all the safe methods defined by the class
:return: all the safe methods defined by the class
"""
return {
method: getattr(cls, method)
for method in dir(cls)
if not method.startswith("__") and method not in ["get_all_safe_methods"]
}
@staticmethod
def getattr(obj: any, attr: str, default: any = None) -> any:
"""
Safe getattr, raise an error if the attribute is a function
:param obj: object to get the attribute from
:param attr: attribute name
:param default: default value if the attribute is not found
:return: the attribute value
"""
attr = getattr(obj, attr) if default is None else getattr(obj, attr, default)
if callable(attr): raise AttributeError(f"getattr can't be used for functions (tried: tr{attr})")
return attr
def safe_eval(template: str, extra_token_map: dict[str, str] = None, env: dict[str, any] = None) -> str:
"""
Evaluate the template and return the result in a safe way
:param extra_token_map: additionnal tokens to use in the template
:param env: variables to use when using eval
:param template: template to evaluate
"""
if extra_token_map is None: extra_token_map = {}
if env is None: env = {}
token_map: dict[str, str] = common_token_map | extra_token_map
final_token: str = ""
def matched(match: re.Match | str | None, value: str = None) -> bool:
"""
check if token is matched, if yes, add it to the final token and remove it from the processing token
:param match: match object
:param value: if the match is a string, the value to replace the text with
:return: True if matched, False otherwise
"""
nonlocal final_token, template
# if there is no match or the string is empty, return False
if not match: return False
if isinstance(match, re.Match):
template_raw = template[match.end():]
value = match.group()
else:
if not template.startswith(match): return False
template_raw = template[len(match):]
template = template_raw.lstrip()
final_token += value + (len(template_raw) - len(template)) * " "
return True
while template: # while there is still tokens to process
# if the section is a string, add it to the final token
# example : "hello", "hello \" world"
if matched(re.match(r'^(["\'])((\\{2})*|(.*?[^\\](\\{2})*))\1', template)):
continue
# if the section is a float or an int, add it to the final token
# example : 102, 102.59
if matched(re.match(r'^[0-9]+(?:\.[0-9]+)?', template)):
continue
# if the section is a variable, operator or function, replace it by its value
# example : track.special, +
for key, value in token_map.items():
if matched(key, value): break
# else, the token is invalid, so raise an error
else:
raise TemplateParsingError(template)
# if final_token is set, eval final_token and return the result
if final_token: return str(eval(final_token, SafeFunction.get_all_safe_methods(), env))
else: return final_token

View file

@ -13,9 +13,9 @@ class Extension(enum.Enum):
"""
Enum for game extension
"""
FST = ".dol"
WBFS = ".wbfs"
ISO = ".iso"
FST = ".dol"
@classmethod
def _missing_(cls, value: str) -> "Extension | None":