moved to make installation more consitent

Nicholas Hope 2022-10-31 13:43:07 -04:00
parent 15af6e1cb6
commit 828f35d133
5 changed files with 1091 additions and 0 deletions

tfscript/ Normal file
View File

@ -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
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

tfscript/ Normal file
View File

@ -0,0 +1,180 @@
Command line module for making Team Fortress 2 macro scripts from
YAML source code.
__all__ = ['parseFile']
__author__ = 'Nicholas Hope <'
__date__ = '26 August 2022'
__version__ = '1.0'
__copyright__ = 'Copyright © 2022 Nicholas Hope. See LICENSE for details.'
# Standard libraries
from sys import stderr
from os import mkdir, sep as dirsep
from os.path import isdir, expanduser, normpath
import argparse
from warnings import warn
from tempfile import NamedTemporaryFile
import yaml
from platform import system as GetOSName, release as GetOSRelease
from winreg import HKEY_LOCAL_MACHINE, ConnectRegistry, OpenKey, EnumValue
except ModuleNotFoundError:
# Not running on windows
# Local libraries
import tfscript
from tfscript import verify, writing, makeCFG
args = {}
targetDir = ''
def parseFile(inputFile) -> (dict, dict):
'''Parse, verify, and do the conversion.'''
config = yaml.safe_load(inputFile)
# See
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
return config, defaults
def parseConfig(config, defaults):
'''With validated data structure, write out all the files.'''
global args
global targetDir
if isdir(targetDir) == False:
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(
default=(class_ == 'default')
replaceDict = writing.writeOutput(stringToWrite, class_, args)
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',
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:
'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:
accessSubkeyName, data, _ = EnumValue(accessKey, keyNum)
if accessSubkeyName == 'InstallPath':
return data
except EnvironmentError:
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 is not None:
targetDir = normpath( + dirsep
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 = '.'
# 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__':

tfscript/ Normal file
View File

@ -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
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
if type(self) is Bind:
# not using isinstance(), because all subclasses are also instances
# of bind.
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}"')
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
except KeyError:
self.instances[type(self)] = [self]
def verify(self):
self.alias = self.optional('alias', default=False)
if not isinstance(self.alias, bool):
f'`alias` should be "yes" or "no", not "{self.alias}"'
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}"')
for type_ in self.bindTypes:
if typeName == type_.__name__:
self.TargetType = type_
if self.TargetType is None:
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):
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
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):
f'{type(self).__name__.lower()} "{self.key}" {message}'
def warn(self, message):
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}
self.command = self.cmdListFrom(
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'
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):
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'
loadoutNum = ['a','b','c','d'].index(restOfCmd.lower())
restOfCmd = str(loadoutNum)
except ValueError:
# not a load_itempreset shortcut
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): list = None
self.release: list = None
if not isinstance(self.fields, dict):
self.fields = {'press': self.fields}
# verify press
try: = self.cmdListFrom('press')
if is None:
self.err('`press` field must be string or list')
except popErrors:
self.err('requires `press` field')
# verify release
self.release = self.cmdListFrom('release')
if self.release is None:
self.err('`release` field must be string or list')
except popErrors:
if is None:
self.warn('has no `release`, creating one')
# no release specified, do -action for each item in press
self.release = []
for cmd in
if cmd[0] == '+':
self.release.append('-' + cmd[1:])
def toTF2(self) -> str:
if self.alias:
bindOrAlias = 'alias'
bindOrAlias = 'bind'
holdStr = f'hold_{self.key}'
# Making impulse instances from and .release
# allows them to share the shortcuts
pressObj = Impulse('+' + holdStr,
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 list = None
if not isinstance(self.fields, dict):
self.fields = {'on': self.fields}
# verify on
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.cmdListFrom('off')
if is None:
self.err(f'`off` field must be string or list')
except popErrors:
# no off specified, do -action for each item in on = []
if self.on is None:
for cmd in self.on:
if cmd[0] == '+':'-' + cmd[1:])
def toTF2(self) -> str:
if self.alias:
bindOrAlias = 'alias'
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,
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
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):
'`toggle` field should be "yes" or "no",'
+ f' not "{self.isToggle}"'
# type
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}"')
except popErrors:
# catastrophic: no type given
self.err('requires `type` field')
# 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"')
if cancel == 'both':
if self.type == 'hold':
self.cancelBoth = True
'`cancel` field only affects "hold",'
+ f' not "{self.type}"'
elif cancel == 'released':
self.cancelBoth = False
'`cancel` field must be "released"'
+ f' or "both", not "{cancel}"'
self.primary = self.getSection('primary', self.primStr)
except popErrors:
self.primary = None
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.prettifyList(bind.warnings, key)
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)
return mainCode + altCode + shiftCode
def getChangeCode(self, shift):
if self.alias:
bindOrAlias = 'alias'
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()
code += f'{bindOrAlias} {self.key} "{self.primStr}"\n'
return code
def addToCondDict(self, shiftStr):
if self.isToggle:
changeStr = shiftStr
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': []
} )
if self.isToggle == False:
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
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')
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 = '' = False
if not isinstance(self.fields, dict):
self.fields = {'text': self.fields}
if not self.alias:
# keyname should be invalid, remove the error
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.fields.pop('run')
if not isinstance(, bool):
f'`run` should be "yes" or "no", not "{}"'
if not self.alias:
self.warn('`run` specified without alias')
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}"'
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 ]

tfscript/ Normal file
View File

@ -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 = [
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]
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
classBinds = []
errMessages = []
warnMessages = []
for key, data in classCFG.items():
bind = tftypes.Bind(key, data)
bind = bind.toTargetType()
if isclass:
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])
globalErrors = []
for remainingClass in cfg:
if remainingClass not in classList:
globalErrors.append(f'"{remainingClass}" is not a valid class')
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

tfscript/ Normal file
View File

@ -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({ '%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 = NamedTemporaryFile(prefix=className, delete=False)
fileNum += 1
namesDict.update({ '%s_script_%0*d.cfg' % (className, FilNedLen, fileNum) })
bytesWritten = 0
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)
# 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
return list(fileNames.values())
def appendToActuals(targetDir, fileList, defaultsGiven, args):
if defaultsGiven:
classList = [
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')
def onlyFirsts(fileList):
for i, fileName in enumerate(fileList):
noExtension = fileName.split('.')[0]
number = int(noExtension.split('_')[2])
if number != 1:
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(, '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