Merge pull request 'class based refactor pull req' (#14) from class_based_refactor into main

Reviewed-on: #14
main
Nicholas Hope 2022-10-02 13:29:44 -04:00
commit f9c7aa403e
6 changed files with 820 additions and 563 deletions

View File

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

View File

@ -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 <double> 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:
<content>
instead of
e:
impulse:
<content>
"""
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'

View File

@ -1,13 +1,13 @@
"""
'''
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."
__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
@ -27,48 +27,53 @@ except ModuleNotFoundError:
# Local libraries
import tfscript
from tfscript import verify
from tfscript import writing
from tfscript import verify, writing, makeCFG
args = {}
targetDir = ""
targetDir = ''
def parseFile(inputFile) -> (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())

629
src/tfscript/tftypes.py Normal file
View File

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

View File

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

25
tests/types.yaml Normal file
View File

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