diff --git a/tfscript/__init__.py b/tfscript/__init__.py new file mode 100644 index 0000000..b679ab8 --- /dev/null +++ b/tfscript/__init__.py @@ -0,0 +1,33 @@ +""" This has only one function. It mostly exists to allow imports of things like tftypes """ + +from copy import deepcopy +from tfscript.tftypes import Double + +def makeCFG(bindList, default=False): + + if default: + # Write to defaultDict instead of condDict + Double.condDict = Double.defaultDict + else: + Double.condDict = deepcopy(Double.defaultDict) + + ret = '' + + 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 Double.condDict != Double.defaultDict: + # ==, and by extension !=, does in fact check + # for dictionary equality in keys and values + 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'bind {key} "+{key}_toggles"\n' + ) + + return ret diff --git a/tfscript/cli.py b/tfscript/cli.py new file mode 100644 index 0000000..368eb6a --- /dev/null +++ b/tfscript/cli.py @@ -0,0 +1,180 @@ +''' +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.''' + config = yaml.safe_load(inputFile) + + # See verify.py + 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}', 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, defaults + +def parseConfig(config, defaults): + '''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) + + tempsAndReals = {} + + if defaults is not None: + config.update({'default': defaults}) + + for class_ in config: + stringToWrite = makeCFG( + config[class_], + default=(class_ == 'default') + ) + replaceDict = writing.writeOutput(stringToWrite, class_, args) + tempsAndReals.update(replaceDict) + + return tempsAndReals + +def parseCLI(): + # 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.') + parser.add_argument( '-f', '--force', action='store_true', + help='Force tfscript to continue until catastrophic failure') + parser.add_argument( '-D', '--directory', action='store', type=str, + help='Change output directory') + # warnings + parseWarnNames = [ + 'implicit-release', 'implicit-off', + 'implicit-primary', 'implicit-secondary', + 'implicit' + ] + for warnName in parseWarnNames: + splitWarnName = ' '.join(warnName.split('-')) + parser.add_argument( '-W' + warnName, action='store_true', + help=f'Generate warning on {splitWarnName} creation') + # 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 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', + category=RuntimeWarning ) + return None + return expanduser('~/Library/Application Support/Steam') + + 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') + keyNum = 0 + while True: + try: + accessSubkeyName, data, _ = EnumValue(accessKey, keyNum) + if accessSubkeyName == 'InstallPath': + return data + except EnvironmentError: + break + keyNum += 1 + return None + + elif systemName == 'Linux': + return expanduser('~/.local/Steam') + + elif systemName == 'Java': + warn('Java-based OSes are not supported yet by tfscript.', category=RuntimeWarning) + + return None + +def main() -> int: + ''' Command line interface. ''' + global args + global targetDir + parser = parseCLI() + + args = parser.parse_args() + + systemName = GetOSName() + if args.directory is not None: + targetDir = normpath(args.directory) + dirsep + else: + targetDir = getTargetDir(systemName) + if targetDir is not None: + # Supported OS: add steamapps path + 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) + targetDir = '.' + else: + # Unsupported OS and not forced to continue + return 2 + + config, defaults = parseFile(args.infile) + if config is None: + return 2 + + fileNames = parseConfig(config, defaults) + fileList = writing.replaceFiles(targetDir, fileNames, args) + defaultsGiven = (defaults is not None) + writing.appendToActuals(targetDir, fileList, defaultsGiven, args) + + return 0 + +if __name__ == '__main__': + exit(main()) diff --git a/tfscript/tftypes.py b/tfscript/tftypes.py new file mode 100644 index 0000000..e4b8fd4 --- /dev/null +++ b/tfscript/tftypes.py @@ -0,0 +1,624 @@ +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): + self.alias = self.optional('alias', default=False) + if not isinstance(self.alias, bool): + self.err( + f'`alias` should be "yes" or "no", not "{self.alias}"' + ) + + 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 optional(self, name, /,*, default=None): + try: + return self.fields.pop(name) + except popErrors: + return default + + def cmdListFrom(self, name, /,*, default=None): + result = self.fields.pop(name) + if isinstance(result, str): + return result.split(';') + elif isinstance(result, list): + return result + else: + return default + + 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: list = None + if not isinstance(self.fields, dict): + self.fields = {'command': self.fields} + + try: + self.command = self.cmdListFrom( + 'command', + default=self.fields + ) + if self.command is None: + self.err('`command` field must be string or list') + except popErrors: + 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' + } + # if is shortcut, change + cmd = simpleSCs.get(cmd, cmd) + + 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: + loadoutNum = ['a','b','c','d'].index(restOfCmd.lower()) + restOfCmd = str(loadoutNum) + 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' + } + return buildingNums.get(building, building) + + +class Hold(Bind): + def verify(self): + self.press: list = None + self.release: list = None + if not isinstance(self.fields, dict): + self.fields = {'press': self.fields} + + # verify press + try: + self.press = self.cmdListFrom('press') + if self.press is None: + self.err('`press` field must be string or list') + except popErrors: + self.err('requires `press` field') + + # verify release + try: + self.release = self.cmdListFrom('release') + if self.release is None: + self.err('`release` field must be string or list') + except popErrors: + if self.press is None: + return + 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('+' + holdStr, self.press) + pressObj.alias = True + pressStr = pressObj.toTF2() + + releaseObj = Impulse('-' + holdStr, self.release) + releaseObj.alias = True + releaseStr = releaseObj.toTF2() + + if self.alias: + # if alias, do this to avoid activating + # and never deactivating + minuskey = '-' + self.key + self.key = '+' + self.key + + code = ( + pressStr + releaseStr + + f'{bindOrAlias} {self.key} "+{holdStr}"\n' + ) + if self.alias: + code += f'alias {minuskey} "-{holdStr}"\n' + return code + + +class Toggle(Bind): + def verify(self): + self.on : list = None + self.off: list = None + if not isinstance(self.fields, dict): + self.fields = {'on': self.fields} + + # verify on + try: + self.on = self.cmdListFrom('on') + if self.on is None: + self.err(f'`on` field must be string or list') + except popErrors: + self.err('requires `on` field') + + # verify off + try: + self.off = self.cmdListFrom('off') + if self.off is None: + self.err(f'`off` field must be string or list') + except popErrors: + # no off specified, do -action for each item in on + self.off = [] + if self.on is None: + return + 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.primStr = f'{self.key}_primary' + self.secondStr = f'{self.key}_secondary' + self.isToggle = False + self.cancelBoth = False + + self.primary: Bind = None + self.secondary: Bind = None + self.condition: str = None + self.type: str = None + + # 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') + + self.isToggle = self.optional('toggle', default=False) + if not isinstance(self.isToggle, bool): + self.err( + '`toggle` field should be "yes" or "no",' + + f' not "{self.isToggle}"' + ) + + # 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 + cancel = self.optional('cancel', default='released') + + 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.cancelBoth = False + else: + self.err( + '`cancel` field must be "released"' + + f' or "both", not "{cancel}"' + ) + + try: + self.primary = self.getSection('primary', self.primStr) + except popErrors: + self.primary = None + + try: + self.secondary = self.getSection('secondary', self.secondStr) + except popErrors: + self.secondary = None + + if self.primary is self.secondary is None: + self.err('has neither primary nor secondary') + + def getSection(self, popName, key, /) -> Bind: + section = self.fields.pop(popName) + bind = Bind(f'{self.type} {key}', section) + bind = bind.toTargetType() + + bind.errors.remove(f'invalid key name: "{key}"') + self.prettifyList(bind.errors, key) + self.errors.extend(bind.errors) + self.prettifyList(bind.warnings, key) + self.warnings.extend(bind.warnings) + + return bind + + def prettifyList(self, strList, origStr): + repStr = ' '.join(origStr.split('_', 1)) + for i, cmd in enumerate(strList): + strList[i] = cmd.replace(origStr, repStr) + + 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 = '+hold_' + self.primStr + self.secondStr = '+hold_' + 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] + mainMinusName = mainMinusLine.split(' ')[1] + # second arg, without first or last quote + mainMinusStr = mainMinusLine.split(' ', 2)[2][1:-1] + + altLines = altCode.splitlines() + altMinusLine = altLines[1] + altMinusName = altMinusLine.split(' ')[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) + + altMinusLineStart = ' '.join(altMinusLine.split(' ')[:2]) + altLines[1] = altMinusLineStart + f' "{allCancelStr}"' + altLines.insert(3, f'alias -{self.secondStr} "{altMinusName}"') + mainMinusLineStart = ' '.join(mainMinusLine.split(' ')[:2]) + mainLines[1] = mainMinusLineStart + f' "{allCancelStr}"' + mainLines.insert(3, f'alias -{self.primStr} "{mainMinusName}"') + + 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/tfscript/verify.py b/tfscript/verify.py new file mode 100644 index 0000000..aabfc43 --- /dev/null +++ b/tfscript/verify.py @@ -0,0 +1,105 @@ +"""Verify all the things that could go wrong.""" + +from tfscript import tftypes + +def verifyConfig(cfg: dict) -> (dict, dict): + verifiedConfig = {} + + errors = {} + warnings = {} + + # Do defaults first + defaults = [] + + classList = [ + 'default', + 'scout', + 'soldier', + 'pyro', + ('demo','demoman'), + ('engi','engineer'), + ('heavy','heavyweapons'), + 'medic', + 'sniper', + 'spy' + ] + + for isclass, class_ in enumerate(classList): + + classCFG = None + 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 = 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(): + 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} ) + if len(warnMessages) > 0: + warnings.update( {className: warnMessages} ) + + verifiedConfig.update({className: classBinds}) + + # Turn list into only strings by expanding tuples + 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 = [] + for remainingClass in cfg: + if remainingClass not in classList: + globalErrors.append(f'"{remainingClass}" is not a valid class') + else: + otherName = findTwin(remainingClass) + globalErrors.append(f'Conflicting names for section: "{remainingClass}" and "{otherName}"') + + if len(globalErrors) > 0: + errors.update({'file': globalErrors}) + + if len(errors) > 0: + verifiedConfig.update({'errors': errors}) + if len(warnings) > 0: + verifiedConfig.update({'warnings': warnings}) + + return verifiedConfig, defaults + +def findTwin(className): + classDict = { + "demo": "demoman", + "engi": "engineer", + "heavy": "heavyweapons" + } + for className1, className2 in classDict.items(): + if className == className1: + return className2 + elif className == className2: + return className1 + + return None \ No newline at end of file diff --git a/tfscript/writing.py b/tfscript/writing.py new file mode 100644 index 0000000..2b939a9 --- /dev/null +++ b/tfscript/writing.py @@ -0,0 +1,149 @@ +from sys import stderr +from os.path import exists +from tempfile import NamedTemporaryFile +from shutil import move as moveRobust + +def writeOutput(data, className, args) -> dict: + """ + Write `data' to various files as needed, returning a dict of + the temporary file names and their target destination names, + not including the target directory + """ + namesDict = {} # return dict + + # Variables + lineList = [ l.encode('utf8') for l in data.splitlines() ] + fileNum = 1 + bytesWritten = 0 + + # Constants + maxFileSize = 2 ** 20 # 1MiB maximum cfg file size + filesNeeded = 1 + int( len(data)/maxFileSize ) + if args.debug: + print( f'DEBUG: need {filesNeeded} files for {className}', file=stderr) + + FilNedLen = len(str(filesNeeded)) + # extra 4 bytes is leeway + reservedSpace = len(f'{className}_script_{filesNeeded}.cfg') + 4 + + # Initialize variables + outfile = NamedTemporaryFile(prefix=className, delete=False) + # I know % formatting is old-school and pylint hates it, + # but "%*d" is the easiest way to left-pad with zeros + # without hardcoding a number. + namesDict.update({ outfile.name: '%s_script_%0*d.cfg' % (className, FilNedLen, fileNum) }) + + while (fileNum <= filesNeeded and len(lineList) > 0): + line = lineList.pop(0) + '\n'.encode('utf8') + lineLen = len(line) # nice + + if bytesWritten + reservedSpace + lineLen > maxFileSize: + outfile.write( ('exec %s_script_%0*d' % (className, FilNedLen, fileNum+1)).encode('utf8') ) + bytesWritten += reservedSpace + if args.debug: + print( f'DEBUG: Wrote {bytesWritten} bytes to {className} ({fileNum}/{filesNeeded})', file=stderr) + + outfile.close() + outfile = NamedTemporaryFile(prefix=className, delete=False) + + fileNum += 1 + namesDict.update({ outfile.name: '%s_script_%0*d.cfg' % (className, FilNedLen, fileNum) }) + bytesWritten = 0 + + outfile.write(line) + bytesWritten += lineLen + + outfile.close() # the most-recent tempfile will not have been closed + if args.debug: + print( f'DEBUG: Wrote {bytesWritten} bytes to {className} ({fileNum}/{filesNeeded})', end='\n\n', file=stderr) + + return namesDict + +def replaceFiles(targetDir, fileNames, args): + for tmpName, realName in fileNames.items(): + if args.dry_run: + if args.debug: + print( f'DEBUG: {tmpName} would be {targetDir}{realName}.cfg', file=stderr) + else: + # using shutil.move() because it can move files across disk drives on windows + moveRobust( tmpName, f'{targetDir}{realName}' ) + if args.debug: + print( f'DEBUG: Created {targetDir}{realName}', file=stderr) + + if args.debug: + # Break up the debug messages + print(end='\n') + + return list(fileNames.values()) + +def appendToActuals(targetDir, fileList, defaultsGiven, args): + if defaultsGiven: + classList = [ + "scout", + "soldier", + "pyro", + "demo", + "engi", + "heavy", + "medic", + "sniper", + "spy" + ] + for cclass in classList: + addCallIfUncalled('exec default_script_1', targetDir, cclass, args) + + fileList = onlyFirsts(fileList) + for currFile in fileList: + execStr = f'exec {currFile.split(".")[0]}' + addCallIfUncalled(execStr, targetDir, currFile, args) + +def addCallIfUncalled(execStr, targetDir, fileName, args): + realFilePath = targetDir + getRealName(fileName) + + realExists = exists(realFilePath) + + # creates if it doesn't exist, so must come after the exists() call + cfgFile = open(realFilePath, 'a+') + if not realExists: + if args.debug: + print( f"DEBUG: Created {realFilePath}" ) + cfgFile.write(execStr + '\n') + + elif not strInFile(execStr, cfgFile): + cfgFile.write('\n' + execStr + '\n') + + cfgFile.close() + +def onlyFirsts(fileList): + for i, fileName in enumerate(fileList): + noExtension = fileName.split('.')[0] + number = int(noExtension.split('_')[2]) + if number != 1: + fileList.pop(i) + + return fileList + +def getRealName(fileName): + className = fileName.split('_')[0] + targetNames = { + "demo": "demoman", + "heavy": "heavyweapons", + "engi": "engineer", + "default": "autoexec" + } + if className in targetNames: + className = targetNames[className] + + return className + '.cfg' + +def strInFile(execStr, f): + # Opened in append mode, so cursor is at the end. + # Must reopen to put cursor at the start. + with open(f.name, 'r') as dupfile: + lineList = [ ' '.join(line.split()) for line in dupfile.readlines() ] + for line in lineList: + # Remove indent and outdent, including trailing newline + if execStr == line: + return True + + return False \ No newline at end of file