moved to make installation more consitent
parent
15af6e1cb6
commit
828f35d133
|
@ -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
|
|
@ -0,0 +1,180 @@
|
|||
'''
|
||||
Command line module for making Team Fortress 2 macro scripts from
|
||||
YAML source code.
|
||||
'''
|
||||
|
||||
__all__ = ['parseFile']
|
||||
__author__ = 'Nicholas Hope <tfscript@nickhope.world'
|
||||
__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
|
||||
|
||||
try:
|
||||
from winreg import HKEY_LOCAL_MACHINE, ConnectRegistry, OpenKey, EnumValue
|
||||
except ModuleNotFoundError:
|
||||
# Not running on windows
|
||||
pass
|
||||
|
||||
# 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 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())
|
|
@ -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 ]
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue