diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d78985..fb5d581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +# 0.7.0 +> [!WARNING] +> ⚠️ This release contains a minimum Python version bump to 3.12 ⚠️ + +### Added +- Added support for the new (not new) TOC variables and conditionals +- Added support for client-specific TOC files based on the file name + - You can find this value, if specified, in the `TOCFile.ClientType` attribute. This attribute does not get exported + +### Changed +- Migrated project management to `uv` +- Bumped minimum required Python version to 3.12 + # 0.6.0 > [!WARNING] > This release contains breaking changes, marked with ⚠️. diff --git a/README.md b/README.md index ee81685..d1fb011 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ # pytoc -A Python package for parsing World of Warcraft addon TOC files. +A Python package for reading and writing World of Warcraft addon [TOC](https://warcraft.wiki.gg/wiki/TOC_format) files. ## Installation You can install this package via `pip`. -```py +``` pip install wow-pytoc ``` diff --git a/src/pytoc/enums.py b/src/pytoc/enums.py new file mode 100644 index 0000000..09dda0c --- /dev/null +++ b/src/pytoc/enums.py @@ -0,0 +1,57 @@ +from enum import StrEnum + + +class TOCGameType(StrEnum): + Standard = "standard" + Mainline = "mainline" + Wowhack = "wowhack" + Wowlabs = "wowlabs" + Plunderstorm = "plunderstorm" + Classic = "classic" + Vanilla = "vanilla" + TBC = "tbc" + Wrath = "wrath" + Mists = "mists" + + +class TOCEnvironment(StrEnum): + Global = "Global" + Glue = "Glue" + Both = "Both" + + +class TOCFamily(StrEnum): + Mainline = "Mainline" + Classic = "Classic" + + +class TOCTextLocale(StrEnum): + enUS = "enUS" + enGB = "enGB" + enTW = "enTW" + zhTW = "zhTW" + esES = "esES" + ruRU = "ruRU" + koKR = "koKR" + ptPT = "ptPT" + esMX = "esMX" + itIT = "itIT" + deDE = "deDE" + frFR = "frFR" + enCN = "enCN" + zhCN = "zhCN" + ptBR = "ptBR" + + +TOC_GAME_TYPE_TO_FAMILY = { + TOCGameType.Standard: TOCFamily.Mainline, + TOCGameType.Mainline: TOCFamily.Mainline, + TOCGameType.Wowhack: TOCFamily.Mainline, + TOCGameType.Wowlabs: TOCFamily.Mainline, + TOCGameType.Plunderstorm: TOCFamily.Mainline, + TOCGameType.Classic: TOCFamily.Classic, + TOCGameType.Vanilla: TOCFamily.Classic, + TOCGameType.TBC: TOCFamily.Classic, + TOCGameType.Wrath: TOCFamily.Classic, + TOCGameType.Mists: TOCFamily.Classic, +} diff --git a/src/pytoc/file_entry.py b/src/pytoc/file_entry.py new file mode 100644 index 0000000..7030615 --- /dev/null +++ b/src/pytoc/file_entry.py @@ -0,0 +1,122 @@ +import re + +from dataclasses import dataclass +from abc import ABC, abstractmethod +from typing import Optional, Any + +from .enums import * + +# TOC eval context + + +@dataclass(frozen=True) +class TOCEvaluationContext: + GameType: TOCGameType + Environment: TOCEnvironment + TextLocale: TOCTextLocale + + @property + def Family(self) -> TOCFamily: + try: + return TOC_GAME_TYPE_TO_FAMILY[self.GameType] + except KeyError: + raise KeyError(f"Unknown GameType specified: {self.GameType}") + + +# TOC conditions + + +class TOCCondition(ABC): + AllowedValues: frozenset[Any] + ExportName: str + + @abstractmethod + def evaluate(self, ctx: TOCEvaluationContext) -> bool: ... + + def export(self) -> str: + return f"[{self.ExportName} " + ", ".join(self.AllowedValues) + "]" + + +@dataclass(frozen=True) +class TOCAllowLoad(TOCCondition): + AllowedValues: frozenset[TOCEnvironment] + ExportName: str = "AllowLoad" + + def evaluate(self, ctx: TOCEvaluationContext) -> bool: + return ctx.Environment in self.AllowedValues or TOCEnvironment.Both in self.AllowedValues + + +@dataclass(frozen=True) +class TOCAllowLoadEnvironment(TOCCondition): + AllowedValues: frozenset[TOCEnvironment] + ExportName: str = "AllowLoadEnvironment" + + def evaluate(self, ctx: TOCEvaluationContext) -> bool: + return ctx.Environment in self.AllowedValues or TOCEnvironment.Both in self.AllowedValues + + +@dataclass(frozen=True) +class TOCAllowLoadGameType(TOCCondition): + AllowedValues: frozenset[TOCGameType] + ExportName: str = "AllowLoadGameType" + + def evaluate(self, ctx: TOCEvaluationContext) -> bool: + return ctx.GameType in self.AllowedValues + + +@dataclass(frozen=True) +class TOCAllowLoadTextLocale(TOCCondition): + AllowedValues: frozenset[TOCTextLocale] + ExportName: str = "AllowLoadTextLocale" + + def evaluate(self, ctx: TOCEvaluationContext) -> bool: + return ctx.TextLocale in self.AllowedValues + + +# TOC variables + +_TOC_VAR_PATTERN = re.compile(r"\[([A-Za-z0-9_]+)\]") + +_TOC_DEFAULT_VARIABLES = {"family": lambda ctx: ctx.Family, "game": lambda ctx: ctx.GameType, "textlocale": lambda ctx: ctx.TextLocale} + + +@dataclass(frozen=True) +class TOCFileEntry: + """A file found in the 'files' section of a TOC file. Represents the .lua and .xml files.""" + + RawFilePath: str + Conditions: Optional[list[TOCCondition]] = None + + def __str__(self): + return self.RawFilePath + + def resolve_path(self, ctx: TOCEvaluationContext) -> str: + def replace(match: re.Match): + name = match.group(1) + try: + return _TOC_DEFAULT_VARIABLES[name.lower()](ctx) + except KeyError: + raise KeyError(f"Undefined file path variable: {name}") + + return _TOC_VAR_PATTERN.sub(replace, self.RawFilePath) + + def should_load(self, ctx: TOCEvaluationContext) -> bool: + should_load = True + if self.Conditions: + for condition in self.Conditions: + condition: TOCCondition + if not condition.evaluate(ctx): + should_load = False + break + + return should_load + + def export(self) -> str: + path = self.RawFilePath + + if self.Conditions: + for condition in self.Conditions: + condition_str = f" {condition.export()}" + path += condition_str + + return path.strip() diff --git a/src/pytoc/meta.py b/src/pytoc/meta.py index 2a88913..9729ed0 100644 --- a/src/pytoc/meta.py +++ b/src/pytoc/meta.py @@ -33,6 +33,9 @@ def _cast(self, value, t): origin = get_origin(t) args = get_args(t) if origin is None: + if isinstance(value, t): + return value + return t(value) if origin is Union: for t in get_args(t): diff --git a/src/pytoc/toc.py b/src/pytoc/toc.py index d373f92..69f9293 100644 --- a/src/pytoc/toc.py +++ b/src/pytoc/toc.py @@ -1,9 +1,16 @@ -import os +import re +from pathlib import Path from dataclasses import dataclass from typing import Any, Optional, Union +from .enums import * from .meta import TypedClass +from .file_entry import * + +DO_NOT_EXPORT_FIELDS = {"ClientType"} + +CONDITION_VARIABLE_PATTERN = re.compile(r"\[([^\]]+)\]") # characters/strings that are interpreted as falsey/truthy according to the WoW client FALSEY_CHARS = ("0", "n", "f") @@ -29,20 +36,15 @@ def StringToBoolean(string: str, defaultReturn: bool = False): # i don't like this, but this old code has forced my hand -BOOLEAN_DIRECTIVES_LOWER = ( - "defaultstate", - "onlybetaandptr", - "loadondemand", - "loadfirst", - "loadsavedvariablesfirst", - "usesecureenvironment", -) - -SAVEDVARIABLES_DIRECTIVES_LOWER = ( - "savedvariables", - "savedvariablespercharacter", - "savedvariablesmachine", -) +BOOLEAN_DIRECTIVES_LOWER = ("defaultstate", "onlybetaandptr", "loadondemand", "loadfirst", "loadsavedvariablesfirst", "usesecureenvironment") +SAVEDVARIABLES_DIRECTIVES_LOWER = ("savedvariables", "savedvariablespercharacter", "savedvariablesmachine") +CONDITION_DIRECTIVES_LOWER = ("allowload", "allowloadgametype", "allowloadenvironment", "allowloadtextlocale") +CONDITION_DIRECTIVES_TO_CLASS = { + "AllowLoad": TOCAllowLoad, + "AllowLoadGameType": TOCAllowLoadGameType, + "AllowLoadEnvironment": TOCAllowLoadEnvironment, + "AllowLoadTextLocale": TOCAllowLoadTextLocale, +} @dataclass @@ -52,11 +54,12 @@ class Dependency: class TOCFile(TypedClass): + ClientType: Optional[TOCGameType] = None # target client for client-specific TOC files. i.e. MyAddon_Standard.toc Interface: Optional[Union[int, list[int]]] = None Title: Optional[str] = None Author: Optional[str] = None Version: Optional[str] = None - Files: Optional[list[str]] = None + Files: Optional[list[TOCFileEntry]] = None Notes: Optional[str] = None Group: Optional[str] = None Category: Optional[str] = None @@ -78,32 +81,53 @@ class TOCFile(TypedClass): DefaultState: Optional[bool] = None OnlyBetaAndPTR: Optional[bool] = None LoadSavedVariablesFirst: Optional[bool] = None - AllowLoad: Optional[str] = None # restricted to secure addons - AllowLoadGameType: Optional[str] = None + AllowLoad: Optional[TOCAllowLoad] = None # only useful to secure addons + AllowLoadGameType: Optional[TOCAllowLoadGameType] = None + AllowLoadTextLocale: Optional[TOCAllowLoadTextLocale] = None + AllowLoadEnvironment: Optional[TOCAllowLoadEnvironment] = None UseSecureEnvironment: Optional[bool] = None # restricted to secure addons AdditionalFields: Optional[dict[str, Any]] = None # this is a dict of x- fields - def __init__(self, file_path: Optional[str] = None): + def __init__(self, file_path: Optional[Union[Path, str]] = None): super().__init__() if file_path is not None: + if not isinstance(file_path, Path): + file_path = Path(file_path) + self.parse_toc_file(file_path) def has_attr(self, attr: str) -> bool: return attr in self.__dict__ + def get_target_client_from_path(self, file_path: Path) -> Optional[TOCGameType]: + str_path = str(file_path) + if not "_" in str_path: + return None + + path_split = str_path.split("_") + suffix = path_split[-1].removesuffix(".toc") + if suffix.lower() in TOCGameType: + return TOCGameType[suffix.title()] + + return None + def export(self, file_path: str, overwrite: bool = False): - if os.path.exists(file_path) and not overwrite: + file_path = Path(file_path) + if file_path.exists() and not overwrite: raise FileExistsError("Destination file already exists. To overwrite, set overwrite=True") lines = [] files = [] for directive in self.__annotations__: + if directive in DO_NOT_EXPORT_FIELDS: + continue + if directive == "Files": _files = self.Files if _files is None or len(_files) == 0: continue - files.append("\n".join(_files)) + files.append("\n".join([f.export() for f in _files])) elif directive == "Dependencies": deps = self.Dependencies if deps is None or len(deps) == 0: @@ -130,11 +154,14 @@ def export(self, file_path: str, overwrite: bool = False): if data is None: continue - if isinstance(data, list) and len(data) > 0: + if isinstance(data, TOCCondition): + lines.append(f"## {directive}: {', '.join(data.AllowedValues)}\n") + elif isinstance(data, list) and len(data) > 0: str_data = [str(v) for v in data] lines.append(f"## {directive}: " + ", ".join(str_data) + "\n") else: - if directive.lower() in BOOLEAN_DIRECTIVES_LOWER: + directive_lower = directive.lower() + if directive_lower in BOOLEAN_DIRECTIVES_LOWER: # convert our boolean directive to a 1 or 0 data = "1" if data else "0" @@ -146,10 +173,12 @@ def export(self, file_path: str, overwrite: bool = False): with open(file_path, "w", encoding="utf-8") as f: f.writelines(lines) - def parse_toc_file(self, file_path: str): - if not os.path.exists(file_path): + def parse_toc_file(self, file_path: Path): + if not file_path.exists(): raise FileNotFoundError("TOC file not found") + self.ClientType = self.get_target_client_from_path(file_path) + # toc files should be utf-8 encoded with open(file_path, "r", encoding="utf-8") as f: toc_file = f.read() @@ -194,6 +223,8 @@ def set_field(self, directive: str, value: Any): self.__setattr__(directive, StringToBoolean(value, False)) elif directive_lower in SAVEDVARIABLES_DIRECTIVES_LOWER: self.add_saved_variable(directive, value) + elif directive_lower in CONDITION_DIRECTIVES_LOWER: + self.add_conditional_field(directive, value) else: self.__setattr__(directive, value) @@ -234,13 +265,74 @@ def add_additional_field(self, directive: str, value: Any): self.AdditionalFields[directive] = value + def split_file_path_and_conditions(self, line: str) -> tuple[str, list[str]]: + line = line.strip() + depth = 0 + + for i, char in enumerate(line): + if char == "[": + depth += 1 + elif char == "]": + depth -= 1 + elif char.isspace() and depth == 0: + path = line[:i] + rest = line[i:].strip() + return path, CONDITION_VARIABLE_PATTERN.findall(rest) + + return line, [] + + def parse_condition(self, text: str) -> Optional[TOCCondition]: + name, *rest = text.split(None, 1) + args = [] + + if rest: + args = [a.strip() for a in rest[0].split(",")] + + condition_class = CONDITION_DIRECTIVES_TO_CLASS.get(name) + + if condition_class: + return condition_class(frozenset(args)) + + return None + + def parse_file_line(self, line: str): + raw_path, condition_texts = self.split_file_path_and_conditions(line) + + conditions = [self.parse_condition(text) for text in condition_texts] + + if not conditions: + return TOCFileEntry(raw_path) + + return TOCFileEntry(raw_path, conditions) + def add_file(self, file_name: str): if not self.has_attr("_files"): self.Files = [] - self.Files.append(file_name) + file_entry = self.parse_file_line(file_name) + self.Files.append(file_entry) def add_saved_variable(self, directive: str, value: Union[str, list[str]]): if isinstance(value, str): value = [value] setattr(self, directive, value) + + def add_conditional_field(self, directive: str, value: Union[str, list[str]]): + if isinstance(value, str): + value = [value] + + try: + directive_class = CONDITION_DIRECTIVES_TO_CLASS[directive]({*value}) + except KeyError: + raise KeyError(f"Unknown conditional directive: {directive}") + + setattr(self, directive, directive_class) + + def get_raw_files(self) -> list[str]: + """Returns a list of raw file paths. (no variable or conditional parsing done)""" + + raw_files = [] + for file in self.Files: + raw_files.append(file.export()) + + return raw_files diff --git a/tests/test_toc.py b/tests/test_toc.py index b2971e3..9822784 100644 --- a/tests/test_toc.py +++ b/tests/test_toc.py @@ -28,7 +28,7 @@ def test_parser(): assert file.AddonCompartmentFuncOnEnter == "GHOST_OnAddonCompartmentEnter" assert file.AddonCompartmentFuncOnLeave == "GHOST_OnAddonCompartmentLeave" assert file.AdditionalFields["X-Website"] == "https://ghst.tools" - assert file.Files == [ + assert file.get_raw_files() == [ "Libs/LibStub/LibStub.lua", "Libs/CallbackHandler-1.0/CallbackHandler-1.0.xml", "Libs/LibDataBroker-1.1/LibDataBroker-1.1.lua", @@ -147,4 +147,4 @@ def test_read_export(): assert toc.LocalizedCategory["zhTW"] == "角色扮演" assert toc.OnlyBetaAndPTR == True assert toc.DefaultState == True - assert toc.Files == ["file1.lua", "file2.xml"] + assert toc.get_raw_files() == ["file1.lua", "file2.xml"]