diff --git a/examples/nicks_config.yaml b/examples/nicks_config.yaml index d1b0032..93c8ea6 100644 --- a/examples/nicks_config.yaml +++ b/examples/nicks_config.yaml @@ -9,51 +9,50 @@ default: # voice-based doubles double e: - impulse primary: voice medic - impulse secondary: voice activate uber + type: impulse + primary: voice medic + secondary: voice activate uber condition: mouse4 double t: - impulse primary: voice thanks - impulse secondary: voice nice shot + type: impulse + primary: voice thanks + secondary: voice nice shot condition: mouse4 double v: - impulse primary: voice spy - impulse secondary: voice help + type: impulse + primary: voice spy + secondary: voice help condition: mouse4 # hold doubles double r: - primary: - hold: "class_action" - secondary: - hold: "reload" condition: mouse4 - cancel both: yes + cancel: both + type: hold + primary: +class_action + secondary: +reload # other - double =: - primary: - impulse: kill - secondary: - impulse: explode - condition: "-" + impulse =: kill + impulse -: explode + double q: - impulse primary: lastinv - impulse secondary: + type: impulse + primary: lastinv + secondary: - "slot2" - "wait 10" - "slot1" condition: mouse4 double ctrl: # I use shift to crouch - impulse primary: - voice yes - impulse secondary: - voice no + type: impulse + primary: voice yes + secondary: voice no condition: mouse4 # toggle - toggle capslock: voicerecord + toggle capslock: +voicerecord # hold: null-cancelled movement, so hitting a while holding d causes # me to go left instead of stopping, or vice-versa. @@ -61,29 +60,28 @@ default: press: - "-moveright" - "+moveleft" - - "alias maybeMoveLeft +moveleft" + - "alias maybe_move_left +moveleft" release: - "-moveleft" - - "maybeMoveRight" - - "alias maybeMoveLeft " + - "maybe_move_right" + - "unalias maybe_move_left" hold d: press: - "-moveleft" - "+moveright" - - "alias maybeMoveRight +moveright" + - "alias maybe_move_right +moveright" release: - "-moveright" - - "maybeMoveLeft" - - "alias maybeMoveRight " + - "maybe_move_left" + - "unalias maybe_move_right" # This just stops an error message the first time you release # either of 'a' or 'd' - impulse maybeMoveLeft: + impulse maybe_move_left: alias: yes command: "" - impulse maybeMoveRight: + impulse maybe_move_right: alias: yes command: "" - # class action is something useful for each class, # like destroying and rebuilding a sentry for the engineer # this is just the default. I do a lot of hybridknight and @@ -99,16 +97,16 @@ default: impulse load0: alias: yes - command: "load_itempreset 0" + command: "loadout a" impulse load1: alias: yes - command: "load_itempreset 1" + command: "loadout b" impulse load2: alias: yes - command: "load_itempreset 2" + command: "loadout c" impulse load3: alias: yes - command: "load_itempreset 3" + command: "loadout d" impulse INS: - "load0" @@ -122,8 +120,10 @@ default: impulse END: - "load3" - "alias reload_presets load3" + literal set_default_loadout: + alias reload_presets load0 impulse backspace: - "reload_presets" + reload_presets engi: hold class_action: @@ -131,7 +131,6 @@ engi: press: - destroy sentry - build sentry - release: "" medic: # "Radar" feature: causes all teammates to autocall for medic, allowing @@ -140,12 +139,21 @@ medic: alias: yes press: "hud_medicautocallersthreshold 150" release: "hud_medicautocallersthreshold 75" + double e: + type: impulse + condition: mouse4 + primary: voice medic + secondary: voice uber ready soldier: double mouse1: - hold primary: - attack - hold secondary: + type: hold + condition: mouse4 + cancel: both + # normal firing + primary: +attack + # rocket jump + secondary: press: - +attack - +duck @@ -154,5 +162,3 @@ soldier: - -attack - -duck - -jump - condition: mouse4 - \ No newline at end of file diff --git a/src/tfscript/__init__.py b/src/tfscript/__init__.py index 6627375..b679ab8 100644 --- a/src/tfscript/__init__.py +++ b/src/tfscript/__init__.py @@ -1,270 +1,33 @@ -""" Makes the configs as a massive string """ +""" This has only one function. It mostly exists to allow imports of things like tftypes """ -# Used for the conditions in the type -condDict = {} -defaultDict = {} -bindOrAlias = "bind" from copy import deepcopy +from tfscript.tftypes import Double -def makeCFG(cfg, default=False): - global bindOrAlias - global condDict - global defaultDict +def makeCFG(bindList, default=False): - bindOrAlias = "bind" if default: # Write to defaultDict instead of condDict - condDict = defaultDict + Double.condDict = Double.defaultDict else: - condDict = deepcopy(defaultDict) + Double.condDict = deepcopy(Double.defaultDict) ret = '' - for key, data in cfg.items(): - isAlias = False - if "alias" in data: - isAlias = data.pop("alias") - if isAlias: - bindOrAlias = "alias" - else: - bindOrAlias = "bind" - ret += branch(key, data) + + for bind in bindList: + ret += bind.toTF2() # 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 - if default or condDict != defaultDict: + if default or Double.condDict != Double.defaultDict: # ==, and by extension !=, does in fact check # for dictionary equality in keys and values - for key, toggles in condDict.items(): + for key, toggles in Double.condDict.items(): onCondPress = ';'.join(toggles["change_keys"]) onCondRelease = ';'.join(toggles["restore_keys"]) - ret += f'alias +{key}_toggles "{onCondPress}"\n' +\ - f'alias -{key}_toggles "{onCondRelease}"\n' +\ - f'{bindOrAlias} {key} "+{key}_toggles"\n' - - del condDict # free deep copy + ret += ( + f'alias +{key}_toggles "{onCondPress}"\n' + + f'alias -{key}_toggles "{onCondRelease}"\n' + + f'bind {key} "+{key}_toggles"\n' + ) return ret - -def typeOf(dictIn): - """ Find the first element common to both lists """ - types = [ - "impulse", - "hold", - "toggle", - "double", - "repeat" - ] - for t in types: - if t in dictIn.keys(): - return t - -def branch(keyName, bindContent): - """ Using the provided keyName and content, call the correct function """ - - """ - Terser syntax, ex. - impulse e: - - instead of - e: - impulse: - - """ - splitKey = keyName.split(' ', 1) - if len(splitKey) > 1: - keyName = splitKey[1] - bindContent = {splitKey[0]: bindContent} - - bindType = typeOf(bindContent) - bindContent = bindContent.pop(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): - global bindOrAlias - if isinstance(instruction, dict): - instruction = instruction["command"] - - if not isinstance(instruction, list): - instruction = instruction.split(';') - - instuction = impulseShortcuts(instruction) - instruction = ';'.join(instruction) - - return f'{bindOrAlias} {key} "{instruction}"\n' - -def impulseShortcuts(instList): - for i, instruction in enumerate(instList): - splitCmd = instruction.split(' ') - cmd = splitCmd[0] - restOfCmd = ' '.join(splitCmd[1:]) - shortcuts = { - "primary": "slot1", - "secondary": "slot2", - "melee": "slot3" - } - if cmd in shortcuts: - cmd = shortcuts[cmd] - - if cmd == "voice": - cmd = "voicemenu" - restOfCmd = voice(restOfCmd) - - elif cmd == "build" or cmd == "destroy": - restOfCmd = expandBuildings(restOfCmd) - - elif cmd == "load_itempreset" and restOfCmd.isalpha(): - restOfCmd = restOfCmd.lower() - restOfCmd = ['a','b','c','d'].index(restOfCmd) - - if restOfCmd != "": - cmd += ' ' + restOfCmd - instList[i] = cmd - - return instList - -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'{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): - global bindOrAlias - # This isn't quite right, fix later! - if instruction[0] == '+' or instruction[0] == '-': - return f'{bindOrAlias} {key} "{instruction}"\n' - else: - return f'{bindOrAlias} {key} "+{instruction}"\n' - -def listHold(key, options): - global bindOrAlias - - oldBindOrAlias = bindOrAlias - bindOrAlias = 'alias' - ret = impulse(f'+{key}_press', options["press"]) +\ - impulse(f'-{key}_press', options["release"]) - - bindOrAlias = oldBindOrAlias - - ret += f'{bindOrAlias} {key} "+{key}_press"\n' - - return ret - -def toggle(key, instruction): - global bindOrAlias - onStr = f'turn_{key}_on' - offStr = f'turn_{key}_off' - togStr = f'toggle_{key}' - if instruction[0] == '+': - instruction = instruction[1:] - - ret = f'alias {onStr} "+{instruction}; alias {togStr} {offStr}"\n' +\ - f'alias {offStr} "-{instruction}; alias {togStr} {onStr}"\n' +\ - f'alias {togStr} "{onStr}"\n' +\ - f'{bindOrAlias} {key} "{togStr}"\n' - return ret - -def double(key, options): - primaryAction = options["primary"] - - secAction = options["secondary"] - - mainStr = f'{key}_main' - altStr = f'{key}_alt' - pShiftStr = f'+shift_{key}' - mShiftStr = f'-shift_{key}' - - global bindOrAlias - oldBindOrAlias = bindOrAlias - bindOrAlias = "alias" - recursiveCode = branch(mainStr, primaryAction) +\ - branch(altStr, secAction) - bindOrAlias = oldBindOrAlias - - ret = recursiveCode +\ - f'alias {pShiftStr} "{bindOrAlias} {key} {altStr}"\n' +\ - f'alias {mShiftStr} "{bindOrAlias} {key} {mainStr}"\n'+\ - f'{bindOrAlias} {key} "{mainStr}"\n' - - isToggle = ("toggle" in options and options.pop("toggle") == True) - if isToggle: - toggleStr = toggle(key, pShiftStr) - - condName = options["condition"] - global condDict - if condName in condDict: - # If the condition key (like "mouse4") already has toggles, - # just append another toggle string - changes = condDict[condName]["change_keys"] - restores = condDict[condName]["restore_keys"] - - if isToggle: - # "toggle: true" specified, add the toggle string - if toggleStr not in changes: - changes.append(toggleStr) - if pShiftStr in changes: - # If key already has normal shift, remove it - changes.remove(pShiftStr) - changes.remove(mShiftStr) - - elif pShiftStr not in changes: - # not toggle, not already in changes - changes.append(pShiftStr) - restores.append(mShiftStr) - else: - # If the condition key doesn't already exist, make it - if isToggle: - condDict.update( { - condName: { - "change_keys": [ toggleStr ], - "restore_keys": [ ] - } - } ) - else: - condDict.update( { - condName: { - "change_keys": [ pShiftStr ], - "restore_keys": [ mShiftStr ] - } - } ) - - return ret - -def repeat(key, options): - return f'placeholder for {key} (repeat)\n' diff --git a/src/tfscript/cli.py b/src/tfscript/cli.py index 506f93a..1c4f0ba 100644 --- a/src/tfscript/cli.py +++ b/src/tfscript/cli.py @@ -1,13 +1,13 @@ -""" +''' Command line module for making Team Fortress 2 macro scripts from YAML source code. -""" +''' __all__ = ['parseFile'] -__author__ = "Nicholas Hope (dict, dict): - """Parse, verify, and do the conversion.""" + '''Parse, verify, and do the conversion.''' config = yaml.safe_load(inputFile) # See verify.py - config, aliases = verify.verifyConfig(config) - if "errors" in config: - for cclass, messages in config["errors"].items(): - print(f"Error in {cclass}:") + config, defaults = verify.verifyConfig(config) + if 'warnings' in config: + for cclass, messages in config.pop('warnings').items(): + print(f'Warning in {cclass}:', file=stderr) for msg in messages: - print(f" {msg}") + print(f' {msg}', file=stderr) + + if 'errors' in config: + for cclass, messages in config['errors'].items(): + print(f'Error in {cclass}:', file=stderr) + for msg in messages: + print(f' {msg}', file=stderr) return None, None else: - return config, aliases + return config, defaults def parseConfig(config, defaults): - """With validated data structure, write out all the files.""" + '''With validated data structure, write out all the files.''' global args global targetDir if isdir(targetDir) == False: mkdir(targetDir) if args.debug: - print( f"DEBUG: Created directory {targetDir}", file=stderr) + print( f'DEBUG: Created directory {targetDir}', file=stderr) tempsAndReals = {} if defaults is not None: - stringToWrite = tfscript.makeCFG(defaults, default=True) - replaceDict = writing.writeOutput(stringToWrite, "default", args) - tempsAndReals.update(replaceDict) + config.update({'default': defaults}) - for currentClass in config: - classDict = config[currentClass] - stringToWrite = tfscript.makeCFG(classDict) - replaceDict = writing.writeOutput(stringToWrite, currentClass, args) + for class_ in config: + stringToWrite = makeCFG( + config[class_], + default=(class_ == 'default') + ) + replaceDict = writing.writeOutput(stringToWrite, class_, args) tempsAndReals.update(replaceDict) return tempsAndReals @@ -76,56 +81,56 @@ def parseConfig(config, defaults): def parseCLI(): # Handle command line parser = argparse.ArgumentParser( - description="Parse YAML file and produce TF2 config script." + description='Parse YAML file and produce TF2 config script.' ) parser.add_argument( '-d', '--debug', action='store_true', - help="Enable debugging messages.") + help='Enable debugging messages.') parser.add_argument( '-n', '--dry-run', action='store_true', - help="Parse input file, but don't write anything.") + help='Parse input file, but don\'t write anything.') parser.add_argument( '-f', '--force', action='store_true', - help="Force tfscript to continue until catastrophic failure") + help='Force tfscript to continue until catastrophic failure') parser.add_argument( '-D', '--directory', action='store', type=str, - help="Change output directory") + help='Change output directory') # positional argument: first non-hyphenated argument is input file parser.add_argument( 'infile', type=argparse.FileType('r'), help='File containing YAML to convert.') return parser def getTargetDir(systemName): - if systemName == "Darwin": + if systemName == 'Darwin': if float( '.'.join( GetOSRelease().split('.')[0:2] ) ) >= 10.15: warn( - "As of macOS Catalina (v10.15), 32-bit applications " - "like TF2 do not run. tfscript will run, but you can't run TF2 " - "on this system", + 'As of macOS Catalina (v10.15), 32-bit applications ' + 'like TF2 do not run. tfscript will run, but you can\'t run TF2 ' + 'on this system', category=RuntimeWarning ) - return expanduser("~/Library/Application Support/Steam") + return expanduser('~/Library/Application Support/Steam') - elif systemName == "Windows": + elif systemName == 'Windows': # oh god why do we have to use the registry accessReg = ConnectRegistry(None, HKEY_LOCAL_MACHINE) - accessKey = OpenKey(accessReg, "SOFTWARE\\WOW6432Node\\Valve\\Steam") + accessKey = OpenKey(accessReg, 'SOFTWARE\\WOW6432Node\\Valve\\Steam') keyNum = 0 while True: try: accessSubkeyName, data, _ = EnumValue(accessKey, keyNum) - if accessSubkeyName == "InstallPath": + if accessSubkeyName == 'InstallPath': return data except EnvironmentError: break keyNum += 1 return None - elif systemName == "Linux": - return expanduser("~/.local/Steam") + elif systemName == 'Linux': + return expanduser('~/.local/Steam') - elif systemName == "Java": - warn("Java-based OSes are not supported yet by tfscript.", category=RuntimeWarning) + elif systemName == 'Java': + warn('Java-based OSes are not supported yet by tfscript.', category=RuntimeWarning) return None def main() -> int: - """ Command line interface. """ + ''' Command line interface. ''' global args global targetDir parser = parseCLI() @@ -139,11 +144,11 @@ def main() -> int: targetDir = getTargetDir(systemName) if targetDir is not None: # Supported OS: add steamapps path - targetDir += normpath("/steamapps/common/Team Fortress 2/tf/cfg") + dirsep + targetDir += normpath('/steamapps/common/Team Fortress 2/tf/cfg') + dirsep elif args.force: # Unsupported OS but -f specified if args.debug: - print("DEBUG: forced to continue, output set to current directory", file=stderr) + print('DEBUG: forced to continue, output set to current directory', file=stderr) targetDir = '.' else: # Unsupported OS and not forced to continue @@ -160,5 +165,5 @@ def main() -> int: return 0 -if __name__ == "__main__": +if __name__ == '__main__': exit(main()) diff --git a/src/tfscript/tftypes.py b/src/tfscript/tftypes.py new file mode 100644 index 0000000..ad7cac6 --- /dev/null +++ b/src/tfscript/tftypes.py @@ -0,0 +1,629 @@ +import re + +validKeyList = [ + # top row + 'escape', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12', + # keyboard + '`', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '=', 'backspace', + 'tab', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\\', + 'capslock', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'semicolon', '\'', 'enter', + 'shift', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/', 'rshift', + 'ctrl', 'lwin', 'alt', 'space', 'rwin', 'ralt', 'rctrl', + # mouse + 'mouse1', 'mouse2', 'mouse3', 'mouse4', 'mouse5', 'mwheelup', 'mwheeldown', + # 8 of the 9 keys to the upper-right (PrtScn can't be bound) + 'scrolllock', 'numlock', + 'ins', 'home', 'pgup', + 'del', 'end', 'pgdn', + # arrows + 'uparrow', 'downarrow', + 'leftarrow', 'rightarrow' +] + +popErrors = (AttributeError, KeyError, TypeError) + +class Bind(object): + ''' + Parent class for all bind types. + Verifies key, creates local variables + ''' + bindTypes = [] + instances = {} + + def __init__(self, key='', fields={}, *, parent=None): + if parent is None: + self.alias = False + self.key = str(key) + self.fields = fields + self.errors = [] + self.warnings = [] + self.TargetType = None + else: + self.alias = parent.alias + self.key = parent.key + self.fields = parent.fields + self.errors = parent.errors + self.warnings = parent.warnings + + # redefined for each unique type, default just verifies key + # and some other universal fields like alias and finds targetType + self.verify() + + if type(self) is Bind: + # not using isinstance(), because all subclasses are also instances + # of bind. + return + + if len(self.fields) > 0: + # verify function should remove all fields relavent to the bind. + # Any extras are errors + + self.warnings.append(f'extra fields in "{self.key}":') + if isinstance(self.fields, str): + # iterating over a str returns each character, + # making meaningless error messages + self.warnings.append(f' "{self.fields}"') + else: + for field in self.fields: + self.warnings.append(f' "{field}"') + if len(self.errors) == 0: + # no errors, add new instance to the list of instances + try: + self.instances[type(self)].append(self) + except KeyError: + self.instances[type(self)] = [self] + + def verify(self): + try: + self.alias = self.fields.pop('alias') + if not isinstance(self.alias, bool): + self.errors.append( + f'alias should be "yes" or "no", not "{self.alias}"' + ) + self.alias = False + except popErrors: + self.alias = False + + try: + typeName, self.key = self.key.split(' ', 1) + # all types start with a capital + typeName = typeName.lower().capitalize() + if not self.alias: + # don't mess with alias names + self.key = self.key.lower() + except ValueError: + # catastrophic error: either no type or no key, assume no type + self.errors.append(f'could not find type in "{self.key}"') + return + + for type_ in self.bindTypes: + if typeName == type_.__name__: + self.TargetType = type_ + break + + if self.TargetType is None: + self.errors.append( + f'"{typeName}" is not a valid type for "{self.key}"' + ) + + if (not self.alias) and (self.key not in validKeyList): + self.errors.append(f'invalid key name: "{self.key}"') + + def toTargetType(self): + if self.TargetType is None: + # do nothing + return self + # cast to targetType, "inheriting" stuff from self + bind = self.TargetType(parent=self) + return bind + + def err(self, message): + self.errors.append( + f'{type(self).__name__.lower()} "{self.key}" {message}' + ) + + def warn(self, message): + self.warnings.append( + f'{type(self).__name__.lower()} "{self.key}" {message}' + ) + + +class Impulse(Bind): + def verify(self): + self.command = None + if not isinstance(self.fields, dict): + self.fields = {'command': self.fields} + + try: + self.command = self.fields.pop('command') + if isinstance(self.command, str): + self.command = self.command.split(';') + elif not isinstance(self.command, list): + self.err('`command` field must be argument of string or list') + self.command = None + except KeyError: + self.err('requires `command` field') + + + def toTF2(self) -> str: + if self.alias: + bindOrAlias = 'alias' + else: + bindOrAlias = 'bind' + + allInstructions = self.shortcut(self.command) + instruction = ';'.join(allInstructions) + + return f'{bindOrAlias} {self.key} "{instruction}"\n' + + def shortcut(self, instList): + for i, instruction in enumerate(instList): + try: + cmd, restOfCmd = instruction.split(' ', 1) + except ValueError: + # no spaces in cmd + cmd, restOfCmd = instruction, '' + + simpleSCs = { + 'primary': 'slot1', + 'secondary': 'slot2', + 'melee': 'slot3' + } + try: + cmd = simpleSCs[cmd] + except KeyError: + # not a shortcut + pass + + if cmd == 'voice': + cmd = 'voicemenu' + restOfCmd = self.expandVoice(restOfCmd) + + elif cmd == 'build' or cmd == 'destroy': + restOfCmd = self.expandBuildings(restOfCmd) + + elif cmd == 'loadout' and restOfCmd.isalpha(): + cmd = 'load_itempreset' + try: + restOfCmd = restOfCmd.lower() + restOfCmd = str(['a','b','c','d'].index(restOfCmd)) + except ValueError: + # not a load_itempreset shortcut + pass + + elif cmd == 'unalias': + cmd = 'alias' + # adding empty arg to indicate unaliasing + restOfCmd += ' ' + + if restOfCmd != '': + cmd += ' ' + restOfCmd + instList[i] = cmd + + return instList + + def expandVoice(self, 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'{menu} {selection}' + + def expandBuildings(self, 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 + + +class Hold(Bind): + def verify(self): + self.press = None + self.release = None + if not isinstance(self.fields, dict): + self.fields = {'press': self.fields} + + # verify press + try: + self.press = self.fields.pop('press') + if isinstance(self.press, str): + self.press = self.press.split(';') + elif not isinstance(self.press, list): + self.err('`press` field must be string or list') + self.press = None + except KeyError: + self.err('requires `press` field') + if self.press is None: + return + + # verify release + try: + self.release = self.fields.pop('release') + if isinstance(self.release, str): + self.release = self.release.split(';') + elif not isinstance(self.release, list): + self.err('`release` field must be string or list') + self.release = None + except popErrors: + self.warn('has no `release`, creating one') + # no release specified, do -action for each item in press + self.release = [] + for cmd in self.press: + if cmd[0] == '+': + self.release.append('-' + cmd[1:]) + + def toTF2(self) -> str: + if self.alias: + bindOrAlias = 'alias' + else: + bindOrAlias = 'bind' + holdStr = f'hold_{self.key}' + + # Making impulse instances from self.press and .release + # allows them to share the shortcuts + pressObj = Impulse(f'+{holdStr}', self.press) + pressObj.alias = True + pressStr = pressObj.toTF2() + + releaseObj = Impulse(f'-{holdStr}', self.release) + releaseObj.alias = True + releaseStr = releaseObj.toTF2() + + if self.alias: + # if alias, do this to avoid activating + # and never deactivating + self.key = '+' + self.key + + return ( + pressStr + releaseStr + + f'{bindOrAlias} {self.key} "+{holdStr}"\n' + ) + + +class Toggle(Bind): + def verify(self): + self.on = None + self.off = None + if not isinstance(self.fields, dict): + self.fields = {'on': self.fields} + + # verify on + try: + self.on = self.fields.pop('on') + if isinstance(self.on, str): + self.on = self.on.split(';') + elif not isinstance(self.on, list): + self.err('`on` field must be string or list') + self.on = None + except KeyError: + self.err('requires `on` field') + if self.on is None: + return + + # verify off + try: + self.off = self.fields.pop('off') + if isinstance(self.off, str): + self.off = self.off.split(';') + elif not isinstance(self.off, list): + self.err('`off` field must be string or list') + except popErrors: + # no off specified, do -action for each item in on + self.off = [] + for cmd in self.on: + if cmd[0] == '+': + self.off.append('-' + cmd[1:]) + + def toTF2(self) -> str: + if self.alias: + bindOrAlias = 'alias' + else: + bindOrAlias = 'bind' + toggleStr = f'toggle_{self.key}' + onStr = f'{toggleStr}_on' + offStr = f'{toggleStr}_off' + + onObj = Impulse(onStr, self.on) + onObj.alias = True + toggleOn = onObj.toTF2() + # remove starting/trailing " and \n + toggleOn = toggleOn[:-2] + + offObj = Impulse(offStr, self.off) + offObj.alias = True + toggleOff = offObj.toTF2()[:-2] + + return ( + f'{toggleOn}; alias {toggleStr} {offStr}"\n' + + f'{toggleOff}; alias {toggleStr} {onStr}"\n' + + f'alias {toggleStr} "{onStr}"\n' + + f'{bindOrAlias} {self.key} "{toggleStr}"\n' + ) + + +class Double(Bind): + defaultDict = {} + condDict = {} + bindNames = [] + + def verify(self): + self.primary = None + self.primStr = f'{self.key}_primary' + + self.secondary = None + self.secondStr = f'{self.key}_secondary' + + self.condition = None + self.isToggle = False + + # name of a bind type + self.type = None + + # either 'released' (default) or 'both' + self.cancelBoth = False + + # toggler + try: + self.condition = self.fields.pop('condition') + if self.condition not in validKeyList: + self.err(f'has invalid `condition` field: "{self.condition}"') + except popErrors: + self.err('requires `condition` field') + + try: + self.isToggle = self.fields.pop('toggle') + if not isinstance(self.isToggle, bool): + self.err( + '`toggle` field should be "yes" or "no", ' + + f'not "{self.isToggle}"' + ) + except popErrors: + self.isToggle = False + + # type + try: + self.type = self.fields.pop('type').lower() + if self.type not in self.bindNames: + # catastrophic: invalid type + self.err(f'has invalid type: "{self.type}"') + return + except popErrors: + # catastrophic: no type given + self.err('requires `type` field') + return + + # cancel mode, must happend after type has been inferred + try: + cancel = self.fields.pop('cancel') + + if not isinstance(cancel, str): + self.err(f'`cancel` field must be "released" or "both"') + + else: + if cancel == 'both': + if self.type == 'hold': + self.cancelBoth = True + else: + self.err( + '`cancel` field only affects "hold", ' + + f'not "{self.type}"' + ) + + elif cancel != 'released': + self.err( + '`cancel` field must be "released" ' + + f'or "both", not "{cancel}"' + ) + except popErrors: + cancel = 'released' + + # primary action + try: + mainSection = self.fields.pop('primary') + mainBind = Bind(f'{self.type} {self.primStr}', mainSection) + mainBind = mainBind.toTargetType() + + self.errors.extend(mainBind.errors) + self.warnings.extend(mainBind.warnings) + self.errors.remove(f'invalid key name: "{self.primStr}"') + self.primary = mainBind + except popErrors: + self.err('requires `primary` field') + + # secondary action + try: + altSection = self.fields.pop('secondary') + altBind = Bind(f'{self.type} {self.secondStr}', altSection) + altBind = altBind.toTargetType() + + self.errors.extend(altBind.errors) + self.warnings.extend(altBind.warnings) + self.errors.remove(f'invalid key name: "{self.secondStr}"') + self.secondary = altBind + except popErrors: + self.err('requires `secondary` field') + + + def toTF2(self) -> str: + # Get code for primary and secondary actions. + # alias=true so the toTF2() method aliases + # them instead of binding them + self.primary.alias = True + mainCode = self.primary.toTF2() + self.secondary.alias = True + altCode = self.secondary.toTF2() + + # Make code to switch between the two actions + if self.cancelBoth: + mainCode, altCode = self.getCancelCode(mainCode, altCode) + + if self.type == 'hold': + self.primStr = '+' + self.primStr + self.secondStr = '+' + self.secondStr + + shiftStr = f'shift_{self.key}' + shiftCode = self.getChangeCode(shiftStr) + self.addToCondDict(shiftStr) + + return mainCode + altCode + shiftCode + + def getChangeCode(self, shift): + if self.alias: + bindOrAlias = 'alias' + else: + bindOrAlias = 'bind' + code = ( + f'alias +{shift} "{bindOrAlias} {self.key} {self.secondStr}"\n' + + f'alias -{shift} "{bindOrAlias} {self.key} {self.primStr}"\n' + ) + + if self.isToggle: + toggleObj = Toggle(shift, f'+{shift}') + toggleObj.alias = True # so it aliases instead of binding + code += toggleObj.toTF2() + else: + code += f'{bindOrAlias} {self.key} "{self.primStr}"\n' + return code + + def addToCondDict(self, shiftStr): + if self.isToggle: + changeStr = shiftStr + else: + changeStr = '+' + shiftStr + restoreStr = '-' + shiftStr + + if self.condition not in self.condDict: + # if not already present, make dict for key + self.condDict.update( { + self.condition: { + 'change_keys': [], + 'restore_keys': [] + } + } ) + + self.condDict[self.condition]['change_keys'].append(changeStr) + if self.isToggle == False: + self.condDict[self.condition]['restore_keys'].append(restoreStr) + + def getCancelCode(self, mainCode, altCode) -> (str, str): + # code to cancel both if either is released + # it copies the - statement from each to both. + # if it just extracted the name of the - statement, + # you'd end up with each recursively calling the other + + mainLines = mainCode.splitlines() + mainMinusLine = mainLines[1] + # second arg, without first or last quote + mainMinusStr = mainMinusLine.split(' ', 2)[2][1:-1] + + altLines = altCode.splitlines() + altMinusLine = altLines[1] + # same as above + altMinusStr = altMinusLine.split(' ', 2)[2][1:-1] + + # remove duplicate - actions + mainMinusSet = set(mainMinusStr.split(';')) + altMinusSet = set(altMinusStr.split(';')) + allCancels = mainMinusSet | altMinusSet + allCancelStr = ';'.join(allCancels) + + altLines[1] = altLines[1][:-1] + f';{allCancelStr}"' + mainLines[1] = mainLines[1][:-1] + f';{allCancelStr}"' + + return ( + '\n'.join(mainLines) + '\n', + '\n'.join(altLines) + '\n' + ) + + +class Repeat(Bind): + def verify(self): + self.interval = None + self.command = None + + try: + intervalStr = str(self.fields.pop('interval')) + self.interval = int(intervalStr) + if self.interval <= 0: + self.err('`interval` must be greater than 0') + except (KeyError, TypeError): + self.err('requires `interval` field') + except ValueError: + self.err(f'has invalid `interval`: "{self.interval}"') + except AttributeError: + self.err(f'requires `interval` field') + + try: + self.command = self.fields.pop('command') + if not isinstance(self.command, (str, list)): + self.err('`command` must be string or list') + self.command = None + except popErrors: + self.err('requires `command` field') + + def toTF2(self) -> str: + # commented-out placeholder + return f'// repeat {self.key}\n' + + +class Literal(Bind): + def verify(self): + self.text = '' + self.run = False + if not isinstance(self.fields, dict): + self.fields = {'text': self.fields} + + if not self.alias: + try: + # keyname should be invalid, remove the error + self.errors.remove( + f'invalid key name: "{self.key}"' + ) + except ValueError: + # if not invalid key, indicate as such + self.warn('should not use a key as a label') + + if 'run' in self.fields: + self.run = self.fields.pop('run') + if not isinstance(self.run, bool): + self.errors.append( + f'`run` should be "yes" or "no", not "{self.run}"' + ) + if not self.alias: + self.warn('`run` specified without alias') + + try: + self.text = self.fields.pop('text') + except KeyError: + self.err('requires `text` field') + + if isinstance(self.text, str): + self.text = self.text.split(';') + elif not isinstance(self.text, list): + self.err('argument must be of string or list') + + def toTF2(self) -> str: + result = ';'.join(self.text) + if self.alias: + result = f'alias {self.key} "{result}"' + if self.run: + result += f'\n{self.key}' + return result + '\n' + +# This is at the bottom because it has to happen after +# all inheritances have been completed + +Bind.bindTypes = Bind.__subclasses__() +Double.bindNames = [ bind.__name__.lower() for bind in Bind.bindTypes ] \ No newline at end of file diff --git a/src/tfscript/verify.py b/src/tfscript/verify.py index 973c8ff..aabfc43 100644 --- a/src/tfscript/verify.py +++ b/src/tfscript/verify.py @@ -1,72 +1,75 @@ """Verify all the things that could go wrong.""" -from copy import deepcopy + +from tfscript import tftypes def verifyConfig(cfg: dict) -> (dict, dict): verifiedConfig = {} - # Do defaults first errors = {} + warnings = {} - defaults = None - - if "default" in cfg: - defaults = cfg.pop("default") - errMessages = [] - for key, data in defaults.items(): - isAlias = False - if "alias" in data: - isAlias = data["alias"] - if not isinstance(isAlias, bool): - errMessages.append(f'"alias" field in "{key}" makes no sense: "{isAlias}"') - errMessages.extend(validBind(key, data, alias = isAlias) ) - if len(errMessages) > 0: - errors.update( {"default": errMessages} ) + # Do defaults first + defaults = [] classList = [ - "scout", - "soldier", - "pyro", - ("demo","demoman"), - ("engi","engineer"), - ("heavy","heavyweapons"), - "medic", - "sniper", - "spy" + 'default', + 'scout', + 'soldier', + 'pyro', + ('demo','demoman'), + ('engi','engineer'), + ('heavy','heavyweapons'), + 'medic', + 'sniper', + 'spy' ] - for cclass in classList: + for isclass, class_ in enumerate(classList): + classCFG = None - className = cclass - if isinstance(cclass, str) and cclass in cfg: - classCFG = cfg.pop(cclass) - elif isinstance(cclass, tuple): - for tupClass in cclass: + className = class_ + + if isinstance(class_, str) and class_ in cfg: + classCFG = cfg.pop(class_) + elif isinstance(class_, tuple): + for tupClass in class_: if tupClass in cfg: classCFG = cfg.pop(tupClass) - className = cclass[0] + className = class_[0] break if classCFG is None: # Invalid class, this gets caught later. # It may be less efficient this way, but # it makes for more descriptive error messages continue + + classBinds = [] errMessages = [] + warnMessages = [] for key, data in classCFG.items(): - isAlias = False - if "alias" in data: - isAlias = data["alias"] - if not isinstance(isAlias, bool): - errMessages.append(f'"alias" field in "{key}" makes no sense: "{isAlias}"') - errMessages.extend( validBind(key, data, alias = isAlias) ) + bind = tftypes.Bind(key, data) + + bind = bind.toTargetType() + if isclass: + classBinds.append(bind) + else: + defaults.append(bind) + + errMessages.extend(bind.errors) + warnMessages.extend(bind.warnings) + if len(errMessages) > 0: errors.update( {className: errMessages} ) - verifiedConfig.update({className: classCFG}) + if len(warnMessages) > 0: + warnings.update( {className: warnMessages} ) + + verifiedConfig.update({className: classBinds}) # Turn list into only strings by expanding tuples - for i, clss in enumerate(classList): - if isinstance(clss, tuple): - classList.insert(i+1, clss[1]) - classList.insert(i+1, clss[0]) + for i, class_ in enumerate(classList): + if isinstance(class_, tuple): + classList.insert(i+1, class_[1]) + classList.insert(i+1, class_[0]) classList.pop(i) globalErrors = [] @@ -78,189 +81,15 @@ def verifyConfig(cfg: dict) -> (dict, dict): globalErrors.append(f'Conflicting names for section: "{remainingClass}" and "{otherName}"') if len(globalErrors) > 0: - errors.update({"file": globalErrors}) + errors.update({'file': globalErrors}) if len(errors) > 0: - verifiedConfig.update({"errors": errors}) + verifiedConfig.update({'errors': errors}) + if len(warnings) > 0: + verifiedConfig.update({'warnings': warnings}) return verifiedConfig, defaults -def validBind(key, data, alias = False) -> list: - """Check for valid key and valid binding""" - ret = [] - splitKey = key.split(' ') - if len(splitKey) > 1: - key = splitKey[1] - data = {splitKey[0]: data} - - 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, errMsgs = validBindType(key, data) - ret.extend(errMsgs) - - extras = dataCopy.keys() - if len(extras) > 0: - extrasString = "\n ".join(extras) - ret.append(f'Unused fields in "{key}":\n {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', - 'scrolllock', 'numlock', - 'ins', 'home', 'pgup', - 'del', 'end', 'pgdn' -] - -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 - break - - if validType: - expandSpaceFields(data) - dataCopy = deepcopy(data) - dataCopy, errMsgs = removeRelaventFields(key, dataCopy, potentialType) - else: - errMsgs.append(f'Key "{key}" has no known bind type') - - return dataCopy, errMsgs - -def expandSpaceFields(data): - keys = list(data.keys()) - for field in keys: - # if you change a key of a dict, while looping - # over the dict, it causes a RuntimeError. - # Looping as list avoids this - splitField = field.split(' ', 1) - if len(splitField) > 1: - newContent = {splitField[0]: data.pop(field)} - data.update({splitField[1]: newContent}) - field = splitField[1] - content = data[field] - if isinstance(content, dict): - expandSpaceFields(content) - -def removeRelaventFields(key, data, bindType): - errMsgs = [] - content = data.pop(bindType) - if "alias" in data: - # alias is universal - data.pop("alias") - - if bindType == "impulse": - if isinstance(content, dict): - if "command" not in content: - errMsgs.append('impulse requires `command` argument') - else: - content.pop("command") - else: - if not isinstance(content, str) and not isinstance(content, list): - errMsgs.append(f'impulse must be a single string or list') - - elif bindType == "toggle": - if isinstance(content, dict): - if "begin" not in content: - errMsgs.append("toggle requires `begin` argument") - if "end" not in content: - errMsgs.append("toggle requires `end` argument") - elif not isinstance(content, str): - errMsgs.append(f"toggle must be either single action or begin and end") - - elif bindType == "hold": - if isinstance(content, dict): - if "press" not in content: - errMsgs.append("hold requires `press` argument") - if "release" not in content: - errMsgs.append("hold requires `release` argument") - elif not isinstance(content, str): - errMsgs.append(f"Hold must be either single action or press and release") - - elif bindType == "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) - errMsgs.extend(errMessages) - - if "secondary" not in content: - errMsgs.append("Double requires secondary action") - else: - # Same logic as above - errMessages = validBind("secondary", content["secondary"], alias = True) - errMsgs.extend(errMessages) - - if "condition" not in content: - errMsgs.append("Double requires condition to toggle") - else: - # Validate the toggler - condition = content["condition"] - if not validKey(condition): - errMsgs.append(f'Invalid condition to toggle "{condition}"') - - if "cancel" in content: - canType = content["cancel"] - if canType not in ["released", "both"]: - errMsgs.append(f'"cancel" must be either "released" or "both", not {canType}') - - elif bindType == "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: - intervalStr = content["interval"] - if unit == "s": - interval = float(intervalStr) - else: - interval = int(intervalStr) - if interval <= 0: - errMsgs.append("Repeat interval must be positive") - elif interval <= 200/6: - errMsgs.append(f"Repeat interval must be greater than 1 tick (approx. {200/6:.3f}s)") - - return data, errMsgs - def findTwin(className): classDict = { "demo": "demoman", diff --git a/tests/types.yaml b/tests/types.yaml new file mode 100644 index 0000000..1e69eca --- /dev/null +++ b/tests/types.yaml @@ -0,0 +1,25 @@ +default: + impulse a: 'alias' + impulse b: ['alias'] + impulse c: + key: value + + hold d: 'alias' + hold e: ['alias'] + hold f: + key: value + + toggle g: 'alias' + toggle h: ['alias'] + toggle i: + key: value + + double j: 'alias' + double k: ['alias'] + double l: + key: value + + repeat m: 'alias' + repeat n: ['alias'] + repeat o: + key: value