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", "name": "Mario Kart Wii Faraphel",
"nickname": "MKWF", "nickname": "MKWF",
"variant": "60", "variant": "60",
@ -67,16 +67,17 @@
}, },
"tags_cups": ["Wii U", "3DS", "DS", "GCN", "GBA", "N64", "SNES", "MKT", "RMX", "DX", "GP"], "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": { "default_track": {
"music":"T32", "music":"T32",
"special":"T32", "special":"T32",
"author":"MrFluffy", "author":"MrFluffy",
"since_version":"0.1", "tags":[]
"sha1":"54a1621fef2b137adcbe20b6dd710b5bc5f981a1",
"version":"v1.1",
"tags":[
]
}, },
"tracks": [ "tracks": [
{ {

View file

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

View file

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

View file

@ -18,6 +18,8 @@ from source import event
from source import * from source import *
import os import os
from source.wt.wit import Extension
class SourceGameError(Exception): class SourceGameError(Exception):
def __init__(self, path: Path | str): def __init__(self, path: Path | str):
@ -126,6 +128,13 @@ class Window(tkinter.Tk):
""" """
return self.destination_game.get_path() 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 # Menu bar
class Menu(tkinter.Menu): class Menu(tkinter.Menu):
@ -159,8 +168,8 @@ class Menu(tkinter.Menu):
def __init__(self, master: tkinter.Menu): def __init__(self, master: tkinter.Menu):
super().__init__(master, tearoff=False) super().__init__(master, tearoff=False)
master.add_cascade(label=_("TRACK_CONFIGURATION"), menu=self) master.add_cascade(label=_("TRACK_FILTER"), menu=self)
self.add_command(label="Change configuration") self.add_command(label="Change filter")
# Advanced menu # Advanced menu
class Advanced(tkinter.Menu): class Advanced(tkinter.Menu):
@ -209,6 +218,7 @@ class Menu(tkinter.Menu):
class SourceGame(ttk.LabelFrame): class SourceGame(ttk.LabelFrame):
def __init__(self, master: tkinter.Tk): def __init__(self, master: tkinter.Tk):
super().__init__(master, text="Original Game File") super().__init__(master, text="Original Game File")
self.columnconfigure(1, weight=1)
self.entry = ttk.Entry(self, width=50) self.entry = ttk.Entry(self, width=50)
self.entry.grid(row=1, column=1, sticky="nsew") self.entry.grid(row=1, column=1, sticky="nsew")
@ -268,12 +278,17 @@ class SourceGame(ttk.LabelFrame):
class DestinationGame(ttk.LabelFrame): class DestinationGame(ttk.LabelFrame):
def __init__(self, master: tkinter.Tk): def __init__(self, master: tkinter.Tk):
super().__init__(master, text="Game Directory Destination") 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.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 = 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: def select(self) -> None:
""" """
@ -304,6 +319,13 @@ class DestinationGame(ttk.LabelFrame):
if not path.exists(): raise DestinationGameError(path) if not path.exists(): raise DestinationGameError(path)
return 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: def set_state(self, state: InstallerState) -> None:
""" """
Set the progress bar state when the installer change state Set the progress bar state when the installer change state
@ -312,7 +334,9 @@ class DestinationGame(ttk.LabelFrame):
""" """
for child in self.winfo_children(): for child in self.winfo_children():
match state: 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") case InstallerState.INSTALLING: child.config(state="disabled")
@ -339,14 +363,20 @@ class ButtonInstall(ttk.Button):
messagebox.showerror(_("ERROR"), _("ERROR_INVALID_DESTINATION_GAME")) messagebox.showerror(_("ERROR"), _("ERROR_INVALID_DESTINATION_GAME"))
return 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 shutil.disk_usage(".").free < minimum_space_available:
if not messagebox.askokcancel(_("WARNING"), _("WARNING_LOW_SPACE_CONTINUE")): if not messagebox.askokcancel(_("WARNING"), _("WARNING_LOW_SPACE_CONTINUE")):
return 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) game = Game(source_path)
mod_config = self.master.get_mod_config() 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: finally:
self.master.set_state(InstallerState.IDLE) self.master.set_state(InstallerState.IDLE)

View file

@ -1,4 +1,33 @@
# class that represent a mario kart wii cup # class that represent a mario kart wii cup
from PIL import Image
class Cup: class Cup:
def __init__(self, track1: "Track" = None, track2: "Track" = None, track3: "Track" = None, track4: "Track" = None): __slots__ = ["_tracks", "cup_id"]
self._tracks = [track1, track2, track3, track4] _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 typing import Generator
from source.mkw.ModConfig import ModConfig from source.mkw.ModConfig import ModConfig
from source.wt.wit import WITPath, Region from source.wt.wit import WITPath, Region, Extension
class Game: 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) 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 Extract the game to the destination directory. If the game is a FST, just copy to the destination
:param dest: destination directory :param dest: destination directory
@ -43,10 +43,13 @@ class Game:
except StopIteration as e: except StopIteration as e:
return e.value 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 Patch the game with the mod
:dest: destination directory :dest: destination directory
:mod_config: mod configuration :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 typing import Generator
from source.mkw import Tag, Color from source.mkw import Tag, Color
from source.mkw.Cup import Cup
from source.mkw.Track import Track from source.mkw.Track import Track
import json import json
@ -10,33 +11,42 @@ import json
class ModConfig: class ModConfig:
__slots__ = ("name", "nickname", "variant", "region", "tags_prefix", "tags_suffix", __slots__ = ("name", "nickname", "variant", "region", "tags_prefix", "tags_suffix",
"default_track", "_tracks", "version", "original_track_prefix", "swap_original_order", "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, 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_prefix: dict[Tag, Color] = None, tags_suffix: dict[Tag, Color] = None,
tags_cups: list[Tag] = None, region: dict[int] | int = None, tags_cups: list[Tag] = None, region: dict[int] | int = None,
default_track: "Track | TrackGroup" = None, tracks: list["Track | TrackGroup"] = None, default_track: "Track | TrackGroup" = None, tracks: list["Track | TrackGroup"] = None,
original_track_prefix: bool = None, swap_original_order: bool = 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.name: str = name
self.nickname: str = nickname if nickname is not None else 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.variant: str = variant if variant is not None else "01"
self.region: dict[int] | int = region if region is not None else 0 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_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_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.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._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.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.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.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 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 @classmethod
def from_dict(cls, config_dict: dict) -> "ModConfig": def from_dict(cls, config_dict: dict) -> "ModConfig":
""" """
@ -44,11 +54,11 @@ class ModConfig:
:param config_dict: dict containing the configuration :param config_dict: dict containing the configuration
:return: ModConfig :return: ModConfig
""" """
kwargs = { kwargs = {
attr: config_dict.get(attr) attr: config_dict.get(attr)
for attr in ["nickname", "version", "variant", "tags_prefix", "tags_suffix", "tags_cups", for attr in cls.__slots__
"original_track_prefix", "swap_original_order", "keep_original_track", "enable_random_cup"] if attr not in ["name", "default_track", "_tracks", "tracks"]
# these keys are treated after or are reserved
} }
return cls( return cls(
@ -78,3 +88,82 @@ class ModConfig:
for track in self._tracks: for track in self._tracks:
yield from track.get_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 import re
from source.mkw import Tag, Slot from source.mkw import Tag, Slot
from source.safe_eval import safe_eval
TOKEN_START = "{{" TOKEN_START = "{{"
TOKEN_END = "}}" 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 # representation of a custom track
class Track: class Track:
@ -41,6 +22,9 @@ class Track:
if key.startswith("__"): continue if key.startswith("__"): continue
setattr(self, key, value) setattr(self, key, value)
def __repr__(self):
return f"<Track name={getattr(self, 'name', '/')} tags={getattr(self, 'tags', '/')}>"
@classmethod @classmethod
def from_dict(cls, track_dict: dict) -> "Track | TrackGroup": def from_dict(cls, track_dict: dict) -> "Track | TrackGroup":
""" """
@ -68,75 +52,24 @@ class Track:
:return: formatted representation of the track :return: formatted representation of the track
""" """
token_map = common_token_map | { # replace the suffix and the prefix by the corresponding values extra_token_map = { # replace the suffix and the prefix by the corresponding values
"prefix": self.get_prefix(mod_config, ""), "prefix": f'{self.get_prefix(mod_config, "")!r}',
"suffix": self.get_prefix(mod_config, ""), "suffix": f'{self.get_suffix(mod_config, "")!r}',
} | { # replace the track attribute by the corresponding values "track": "track"
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
"track": "track"
}
def format_token(match: re.Match) -> str: def format_template(match: re.Match) -> str:
# get the token string without the brackets, then strip it """
process_token = match.group(1).strip() when a token is found, replace it by the corresponding value
final_token: str = "" :param match: match in the format
:return: corresponding value
def matched(match: re.Match | str | None, value: str = None) -> bool: """
""" # get the token string without the brackets, then strip it. Also double antislash
check if token is matched, if yes, add it to the final token and remove it from the processing token template = match.group(1).strip().replace("\\", "\\\\")
:param match: match object return safe_eval(template, extra_token_map, {"track": self})
:param value: if the match is a string, the value to replace the text with
:return: True if matched, False otherwise
"""
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
# pass everything between TOKEN_START and TOKEN_END in the function # 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: def get_prefix(self, mod_config: "ModConfig", default: any = None) -> any:
""" """
@ -145,8 +78,7 @@ class Track:
:param mod_config: mod configuration :param mod_config: mod configuration
:return: formatted representation of the track prefix :return: formatted representation of the track prefix
""" """
for tag in filter(lambda tag: tag in mod_config.tags_prefix, self.tags): for tag in filter(lambda tag: tag in mod_config.tags_prefix, self.tags): return tag
return mod_config.tags_prefix[tag]
return default return default
def get_suffix(self, mod_config: "ModConfig", default: any = None) -> any: def get_suffix(self, mod_config: "ModConfig", default: any = None) -> any:
@ -156,9 +88,29 @@ class Track:
:param mod_config: mod configuration :param mod_config: mod configuration
:return: formatted representation of the track suffix :return: formatted representation of the track suffix
""" """
for tag in filter(lambda tag: tag in mod_config.tags_suffix, self.tags): for tag in filter(lambda tag: tag in mod_config.tags_suffix, self.tags): return tag
return mod_config.tags_suffix[tag]
return default 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 typing import Generator
from source.mkw import Tag
# representation of a group of tracks
class TrackGroup: 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.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]: def get_tracks(self) -> Generator["Track", None, None]:
""" """
@ -24,4 +27,19 @@ class TrackGroup:
from source.mkw.Track import Track from source.mkw.Track import Track
if "group" not in group_dict: return Track.from_dict(group_dict) 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 Enum for game extension
""" """
FST = ".dol"
WBFS = ".wbfs" WBFS = ".wbfs"
ISO = ".iso" ISO = ".iso"
FST = ".dol"
@classmethod @classmethod
def _missing_(cls, value: str) -> "Extension | None": def _missing_(cls, value: str) -> "Extension | None":