From 3ddf60d8abf5e36429094579bfd6288b472d56af Mon Sep 17 00:00:00 2001 From: Paco Hope Date: Sat, 6 Aug 2022 21:45:36 -0400 Subject: [PATCH] moved to new locations --- .vscode/launch.json | 2 +- src/tfscript/cli.py | 106 ++++++++++++++++++++ src/tfscript/tfscript.py | 206 +++++++++++++++++++++++++++++++++++++++ src/tfscript/verify.py | 161 ++++++++++++++++++++++++++++++ tests/testScript.yaml | 53 ++++++++++ 5 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 src/tfscript/cli.py create mode 100644 src/tfscript/tfscript.py create mode 100644 src/tfscript/verify.py create mode 100644 tests/testScript.yaml diff --git a/.vscode/launch.json b/.vscode/launch.json index 7b7ede5..cefabff 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,7 @@ "request": "launch", "program": "${file}", "console": "integratedTerminal", - "args": ["-d", "demo.yaml"], + "args": ["-d", "./tests/mega.yaml"], "justMyCode": true } ] diff --git a/src/tfscript/cli.py b/src/tfscript/cli.py new file mode 100644 index 0000000..7739a70 --- /dev/null +++ b/src/tfscript/cli.py @@ -0,0 +1,106 @@ +"""CLI module for converting YAML to tfscript""" +# https://www.w3schools.io/file/yaml-arrays/ + +# Standard libraries +import sys +import os +import argparse +import tempfile +import yaml + +# Local libraries +import tfscript +import verify + +def parseFile(inputFile): + """Parse, verify, and do the conversion.""" + config = yaml.safe_load(inputFile) + + # See verify.py + config = verify.verifyConfig(config) + if "errors" in config: + for e in config["errors"]: + print(e,file=sys.stderr) + else: + parseConfig(config) + +def writeOutput(scriptString, className): + """Given the string of stuff to write, write it out to the given handle.""" + global args + prefix = './cfg' + chunksize = 2**20 # 1Mb maximum cfg file size + chunk = 1 + # Make sure ./cfg exists before we try to use it + if os.path.isdir( prefix ) == False: + try: + os.mkdir( prefix ) + if args.debug: + print( f'DEBUG: created {prefix}') + except Exception as fileExcept: + print( f'WARN: Failed to create {prefix}: {fileExcept.strerror}\nUsing current directory instead.' ) + prefix = '.' + # If the string is more than 1048576 bytes, we need divide it into files that each + # are less than 1048576 bytes + chunksneeded = int( 1 + len(scriptString) / chunksize ) + if args.debug: + print( f'DEBUG: need {chunksneeded} files for {className}') + + if( chunksneeded == 1): + # If it can be done in one chunk, do it in one chunk. + outfile = tempfile.NamedTemporaryFile( prefix=className, delete=False ) + if args.debug: + print( f'DEBUG: created temporary {outfile.name} ') + outfile.write(scriptString.encode("utf8")) + outfile.close() + os.replace(outfile.name, f'{prefix}/{className}_script_{chunk:02d}.cfg') + if args.debug: + print( f'DEBUG: Created {prefix}/{className}_script_{chunk:02d}.cfg') + else: + # Gotta do it in multiple chunks + classLines = scriptString.splitlines() + execString = f'exec {className}_script_{chunk:02d}'.encode("utf8") + # extra 4 bytes is just a little buffer so we don't get exactly chunksize bytes + reservedSpace = len(execString) + 4 + n = 0 + while( chunk <= chunksneeded ): + outfile = tempfile.NamedTemporaryFile( prefix=className, delete=False ) + byteswritten = 0 + if args.debug: + print( f'DEBUG: created temporary {outfile.name} ') + while( n < len(classLines) and (byteswritten + len(classLines[n]) + reservedSpace) < chunksize ): + line = classLines[n].encode("utf8") + os.linesep.encode("utf8") + outfile.write(line) + byteswritten += len(line) + n+=1 + if( chunk < chunksneeded ): + line = f'exec {className}_script_{chunk+1:02d}'.encode("utf8") + os.linesep.encode("utf8") + outfile.write(line) + byteswritten += len(line) + outfile.close() + os.replace(outfile.name, f'{prefix}/{className}_script_{chunk:02d}.cfg') + if args.debug: + print( f'DEBUG: Wrote {byteswritten} bytes to {prefix}/{className}_script_{chunk:02d}.cfg') + chunk += 1 + +def parseConfig(config): + """With validated data structure, write out all the files.""" + for currentClass in config: + classDict = config[currentClass] + stringToWrite = tfscript.makeCFG(classDict) + writeOutput(stringToWrite, currentClass) + +# Main function +if __name__ == "__main__": + # Handle command line + parser = argparse.ArgumentParser( + description="Parse YAML file and produce TF2 config script." + ) + parser.add_argument( '-d', '--debug', action='store_true', + help="Enable debugging messages.") + parser.add_argument( '-n', '--dry-run', action='store_true', + help="Parse input file, but don't write anything.") + # positional argument: first non-hyphenated argument is input file + parser.add_argument( 'infile', type=argparse.FileType('r'), + help='File containing YAML to convert.') + args = parser.parse_args() + parseFile(args.infile) \ No newline at end of file diff --git a/src/tfscript/tfscript.py b/src/tfscript/tfscript.py new file mode 100644 index 0000000..5e9169c --- /dev/null +++ b/src/tfscript/tfscript.py @@ -0,0 +1,206 @@ +""" Makes the configs as a massive string """ + +# Used for the conditions in the type +condDict = {} + +def makeCFG(cfg): + condDict.clear() + ret = '' + for key, data in cfg.items(): + # I know all of these fields exist because it was verified in verify.py + bindType = firstTypeIn(data.keys()) + bindContent = data[bindType] + ret += branch(key, bindContent, bindType) + + # Doubles are weird. All of the toggles got put into a dictionary. + # This takes all of the nested dictionaries and turns them into the right string + for key, toggles in condDict.items(): + ret += f'alias +{key}_toggles "{toggles["plus_toggles"]}"\n' +\ + f'alias -{key}_toggles "{toggles["minus_toggles"]}"\n' +\ + f'bind {key} "+{key}_toggles"\n' + + return ret + +def firstTypeIn(inputList): + """ Find the first element common to both lists """ + types = [ + "impulse", + "hold", + "toggle", + "double", + "repeat" + ] + for t in types: + if t in inputList: + return t + +def branch(keyName, bindContent, bindType): + if bindType == "impulse": + return impulse(keyName, bindContent) + + elif bindType == "hold": + if isinstance(bindContent, str): + return simpleHold(keyName, bindContent) + else: + return listHold(keyName, bindContent) + + elif bindType == "toggle": + return toggle(keyName, bindContent) + + elif bindType == "double": + return double(keyName, bindContent) + + elif bindType == "repeat": + return repeat(keyName, bindContent) + +def impulse(key, instruction): + if isinstance(instruction, list): + instruction = ';'.join(instruction) + + allInstructions = [] + + for indivCmd in instruction.split(';'): + allInstructions.append(impulseShortcuts(indivCmd)) + + instruction = ';'.join(allInstructions) + + return f'bind {key} "{instruction}"\n' + +def impulseShortcuts(instruction): + splitCommand = instruction.split(' ') + cmd = splitCommand[0] + shortcuts = { + "primary": "slot1", + "secondary": "slot2", + "melee": "slot3" + } + if cmd in shortcuts: + for sc, expansion in shortcuts.items(): + if cmd == shortcut: + splitCommand[0] = expansion + break + instruction = ' '.join(splitCommand) + + restOfCmd = ' '.join(splitCommand[1:]) + if cmd == "voice": + instruction = voice(restOfCmd) + + elif cmd == "build" or cmd == "destroy": + instruction = f"{cmd} " + expandBuildings(restOfCmd) + + return instruction + +def voice(keyword): + keyword = keyword.lower() + + allLists = [ + ["medic", "thanks", "go", "move up", "go left", "go right", "yes", "no", "pass to me"], + ["incoming", "spy", "sentry ahead", "teleporter here", "dispenser here", "sentry here", "activate uber", "uber ready"], + ["help", "battle cry", "cheers", "jeers", "positive", "negative", "nice shot", "good job"] + ] + + for menu, voiceList in enumerate(allLists): + for selection, shortcut in enumerate(voiceList): + if keyword == shortcut: + return f'voicemenu {menu} {selection}' + +def expandBuildings(building): + buildingNums = { + "dispenser": "0 0", + "entrance": "1 0", + "exit": "1 1", + "sentry": "2 0" + } + for shortBuild, num in buildingNums.items(): + if building == shortBuild: + return num + +def simpleHold(key, instruction): + # This isn't quite right, fix later! + if instruction[0] != '+': + return f'bind {key} "+{instruction}"\n' + else: + return f'bind {key} "{instruction}"\n' + +def listHold(key, options): + press_str = options["press"] + if isinstance(press_str, list): + press_str = ';'.join(press_str) + + release_str = options["release"] + if isinstance(release_str, list): + release_str = ';'.join(release_str) + + ret = f'alias +{key}_bind "{press_str}"\n' +\ + f'alias -{key}_bind "{release_str}"\n' +\ + f'bind {key} "+{key}_bind"\n' + return ret + +def toggle(key, instruction): + onStr = f'turn_{key}_on' + offStr = f'turn_{key}_off' + togStr = f'toggle_{key}' + + ret = f'alias {onStr} "+{instruction}; alias {togStr} {offStr}"\n' +\ + f'alias {offStr} "-{instruction}; alias {togStr} {onStr}"\n' +\ + f'alias {togStr} "{onStr}"\n' +\ + f'bind {key} "{togStr}"\n' + return ret + +def double(key, options): + prim_action = options["primary"] + pBindType = firstTypeIn(prim_action.keys()) + pBindContent = prim_action[pBindType] + + sec_action = options["secondary"] + sBindType = firstTypeIn(sec_action.keys()) + sBindContent = sec_action[sBindType] + + main_str = f'{key}_main' + alt_str = f'{key}_alt' + tog_str = f'toggle_{key}' + + recursive_code = branch(main_str, pBindContent, pBindType) +\ + branch(alt_str, sBindContent, sBindType) + + newcode = [] + for line in recursive_code.split('\n'): + # For every line gotten by the recursive call, change all "bind"s to "alias", + # since main_str and alt_str aren't valid bind targes + llist = line.split(' ') + for i in range(len(llist)): + alphanum_chars = ''.join(c for c in llist[i] if c.isalnum()) + if alphanum_chars == 'bind': + if llist[i][0].isalnum(): + llist[i] = 'alias' + else: + # If the first character isn't a normal character. + # Almost always because it is a double quote + llist[i] = llist[i][0] + 'alias' + newcode.append(' '.join(llist)) + + ret = '\n'.join(newcode) +\ + f'alias +{tog_str} "bind {key} {alt_str}"\n' +\ + f'alias -{tog_str} "bind {key} {main_str}"\n'+\ + f'bind {key} "{main_str}"\n' + + cond_name = options["condition"] + + if cond_name in condDict: + # If the condition key (like "mouse4") already has toggles, + # just append another toggle string (it gets encased in quotes later) + condDict[cond_name]["plus_toggles"] += f"; +{tog_str}" + condDict[cond_name]["minus_toggles"] += f"; -{tog_str}" + else: + # If the condition key doesn't already exist, make it with the correct values + condDict.update( { + cond_name: { + "plus_toggles": f"+{tog_str}", + "minus_toggles": f"-{tog_str}" + } + } ) + + return ret + +def repeat(key, options): + return f'placeholder for {key} (repeat)\n' diff --git a/src/tfscript/verify.py b/src/tfscript/verify.py new file mode 100644 index 0000000..9f209f2 --- /dev/null +++ b/src/tfscript/verify.py @@ -0,0 +1,161 @@ +"""Verify all the things that could go wrong.""" +def verifyConfig(cfg: dict): + + # Do aliases first + aliasErrors = [] + + aliases = None + + if "aliases" in cfg: + aliases = cfg.pop("aliases") + for key, data in aliases.items(): + errMessages = validBind(key, data, alias = True) + if len(errMessages) > 0: + for msg in errMessages: + aliasErrors.append(f"Error in aliases: {msg}") + + errors = [] + + for cclass in cfg: + for key, data in cfg[cclass].items(): + errMessages = validBind(key, data) + if len(errMessages) > 0: + for msg in errMessages: + errors.append(f"Error in {cclass}: {msg}") + + errors += aliasErrors + + if len(errors) > 0: + cfg.update({"errors": errors}) + + return cfg + +def validBind(key, data, alias = False) -> list: + """Check for valid key and valid binding""" + ret = [] + if (not alias and not validKey(key)): + ret.append(f'Invalid key "{key}"') + + # The values passed to validBindType get mutilated, so a copy must be made + dataCopy = data.copy() + dataCopy, errMsgs = validBindType(key, dataCopy) + if len(errMsgs) > 0: + for msg in errMsgs: + ret.append(msg) + + extras = dataCopy.keys() + if len(extras) > 0: + extrasString = "\n\t".join(extras) + ret.append(f'Unused fields in "{key}":\n\t{extrasString}') + + return ret + +validKeyList = [ + '\'', '=', ',', '-', '[', '\\', ']', '`', '.', '/', + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', + 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', + 'u', 'v', 'w', 'x', 'y', 'z', + 'mouse1', 'mouse2', 'mouse3', 'mouse4', 'mouse5', + 'shift', 'capslock', 'ctrl', 'semicolon', 'space', 'enter', + 'backspace' +] + +def validKey(key): + """determines if the key is a valid key""" + key = str(key).lower() + return key in validKeyList + +def validBindType(key, data: dict): + """ + Checks if `key` has a valid bind type, + like 'double' or 'hold' + """ + validType = False + types = [ + "impulse", + "hold", + "toggle", + "double", + "repeat" + ] + + errMsgs = [] + for potentialType in data.keys(): + if potentialType in types: + validType = True + data, errMsgs = removeRelaventFields(data, potentialType) + break + + if not validType: + errMsgs.append(f'Key "{key}" has no known bind type') + + return data, errMsgs + +def removeRelaventFields(data, bindType): + errMsgs = [] + # These types are simple, just the bind type and argument + if bindType in ["impulse", "toggle"]: + data.pop(bindType) + + elif bindType == "hold": + content = data.pop("hold") + if isinstance(content, dict): + if "press" not in content: + errMsgs.append("If hold is not a single argument, it requires a `press` argument") + elif "release" not in content: + errMsgs.append("If hold is not a single argument, it requires a `release` argument") + elif not isinstance(content, str): + errMsgs.append(f"Hold must be either single action or press and release") + + elif bindType == "double": + content = data.pop("double") + if "primary" not in content: + errMsgs.append("Double requires primary action") + else: + # Nasty bit of recursion to validate the action. + # It takes advantage of `alias = True` not verifying the key, + # but it isn't an alias, I'm just lazy. + errMessages = validBind("primary", content["primary"], alias = True) + if len(errMessages) > 0: + errMsgs += errMessages + + if "secondary" not in content: + errMsgs.append("Double requires secondary action") + else: + # Same logic as above + errMessages = validBind("secondary", content["secondary"], alias = True) + if len(errMessages) > 0: + errMsgs += errMessages + + if "condition" not in content: + errMsgs.append("Double requires condition to toggle") + else: + # Validate the toggler + key = content["condition"] + if not validKey(key): + errMsgs.append(f'Invalid condition to toggle "{key}"') + + elif bindType == "repeat": + content = data.pop("repeat") + + unit = "s" + if "unit" in content: + # Set unit if provided + unitStr = str(content["unit"]).lower() + if unitStr in ["t", "ticks"]: + unit = "t" + elif unitStr not in ["s", "seconds"]: + # If not seconds or ticks, error + errMsgs.append(f"Invalid interval unit {unitStr}") + + if "interval" not in content: + errMsgs.append("Repeat requires interval") + else: + interval = content["interval"] + if interval < 0: + errMsgs.append("Repeat interval cannot be negative") + if (unit == "t" and not isinstance(interval, int)): + errMsgs.append("Repeat interval must be integer if unit is ticks") + + return data, errMsgs diff --git a/tests/testScript.yaml b/tests/testScript.yaml new file mode 100644 index 0000000..2411391 --- /dev/null +++ b/tests/testScript.yaml @@ -0,0 +1,53 @@ +# Example tfscript +# https://www.w3schools.io/file/yaml-arrays/ + +medic: + e: + impulse: "voicemenu 0 0" + condition: "ctrl" + foobar: "baz" + 1: + toggle: "forward" + +spy: + mouse2: + action: "sapperSpam" + condition: "mouse4" + default: "secondary fire" + +pyro: + mouse1: + hold: pyrocombo + +engi: + q: + impulse: con_pda + +aliases: + sapperSpam: + repeat: sap + interval: 0.1 + unit: "seconds" + + pyrocombo: + hold: primary fire + press: + - "slot1" + - "+attack" + - "wait 32" + - "slot2" + release: + - "-attack" + - "slot1" + +default: + w: + hold: forward + a: + hold: left + s: + hold: backward + d: + hold: right + mouse1: + hold: primary_fire \ No newline at end of file