Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions .github/workflows/validate.yml

This file was deleted.

3 changes: 2 additions & 1 deletion scripts/execute.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from argparse import ArgumentParser
from pathlib import Path
import sys
import subprocess
import sys

from shared import ParsedOption, Profile, Options, HEADER_OPTIONS, SIMC_OPTIONS, SPEC_NAMES

def handle_header_line(line: str, deferral_list: Options, profile: Profile):
Expand Down
110 changes: 63 additions & 47 deletions scripts/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,39 @@ class ParsedOption:
pass

class Option:
key: str
key: str | list[str]
alias: str
ignore_value: bool
values: list[str]
case_sensitive: bool
scope: str
simc_option: bool
validate_fn: Callable[[ParsedOption], bool] | None

def __init__(self, key, values=None, case_sensitive=True, scope=None, simc_option=True, validate_fn=None):
assert scope is not None
required: bool
unique_value: bool

def __init__(self, key, values=None,
case_sensitive=True, scope=None, simc_option=True,
validate_fn=None, required=False, unique_value=False,
alias=None):
if scope is None:
print(key, values)
assert scope is not None
self.key = key
self.alias = alias
self.values = values if values is not None else []
self.ignore_value = True if values is None else False
self.case_sensitive = case_sensitive
self.scope = scope
self.simc_option = simc_option
self.validate_fn = validate_fn
self.required = required
self.unique_value = unique_value

def __str__(self):
return f"{self.scope} option " \
f"'{self.key if self.alias is None else self.alias}'" \
f"{'' if self.ignore_value else f' (possible value{'s' if len(self.values) > 1 else ''}: {', '.join(self.values)})'}"

def __eq__(self, other: ParsedOption):
if isinstance(other, ParsedOption):
Expand All @@ -66,13 +82,20 @@ def __eq__(self, other: ParsedOption):
assert False
return False

def __hash__(self):
return hash((repr(self.key), self.ignore_value, repr(self.values),
self.case_sensitive, self.scope, self.simc_option,
self.validate_fn, self.required, self.unique_value))

class Options:
options: list[Option]
keys: set()
required: set()

def __init__(self, *options):
self.options = options
self.keys = set(flatten((option.key for option in options)))
self.required = set(option for option in options if option.required)

def __contains__(self, other):
return other in self.options
Expand All @@ -88,44 +111,36 @@ def validate_class_option(option: ParsedOption):
return success

SIMC_OPTIONS = Options(
Option(list(SPEC_NAMES.keys()), validate_fn=validate_class_option, scope='sim'),
Option('level', ['90'], scope='player'),
Option('spec', [spec for specs in SPEC_NAMES.values() for spec in specs], scope='player'),
Option(list(SPEC_NAMES.keys()), alias='<class name>', validate_fn=validate_class_option, scope='sim', required=True),
Option('level', ['90'], scope='player', required=True),
Option('spec', [spec for specs in SPEC_NAMES.values() for spec in specs], scope='player', required=True),
# copied from util::race_type_string and util::parse_race_type
Option('race',
['blood_elf', 'dark_iron_dwarf', 'dracthyr_alliance', 'dracthyr_horde', 'draenei', 'dwarf', 'gnome', 'goblin', 'haranir_alliance', 'haranir_horde', 'highmountain_tauren', 'human', 'kul_tiran', 'lightforged_draenei', 'maghar_orc', 'mechagnome', 'night_elf', 'nightborne', 'orc', 'pandaren', 'pandaren_alliance', 'pandaren_horde', 'tauren', 'troll', 'undead', 'void_elf', 'vulpera', 'worgen', 'zandalari_troll', 'forsaken', 'dracthyr', 'earthen', 'earthen_dwarf', 'haranir', 'harronir', 'harronir_horde', 'harronir_alliance'],
case_sensitive=False,
scope='player'),
case_sensitive=False, scope='player', required=True),
Option('timeofday', ['day', 'daytime', 'night', 'nighttime'], scope='player'),
# copied from util::role_type_string
Option('role', ['attack', 'spell', 'hybrid', 'dps', 'tank', 'heal', 'auto'], scope='player'),
# copied from util::position_type_string
Option('position', ['none', 'back', 'front', 'ranged_back', 'ranged_front'], scope='player'),
# talents
Option('talents', scope='player'),
Option('talents', scope='player', required=True),
# gear
Option('head', scope='player'),
Option('neck', scope='player'),
Option('shoulders', scope='player'),
Option('shoulder', scope='player'),
Option('chest', scope='player'),
Option('waist', scope='player'),
Option('legs', scope='player'),
Option('leg', scope='player'),
Option('feet', scope='player'),
Option('foot', scope='player'),
Option('wrists', scope='player'),
Option('wrist', scope='player'),
Option('hands', scope='player'),
Option('hand', scope='player'),
Option('finger1', scope='player'),
Option('finger2', scope='player'),
Option('ring1', scope='player'),
Option('ring2', scope='player'),
Option('trinket1', scope='player'),
Option('trinket2', scope='player'),
Option('back', scope='player'),
Option('main_hand', scope='player'),
Option('head', scope='player', required=True),
Option('neck', scope='player', required=True),
Option(['shoulder', 'shoulders'], alias='shoulder', scope='player', required=True),
Option('chest', scope='player', required=True),
Option('waist', scope='player', required=True),
Option(['leg', 'legs'], alias='leg', scope='player', required=True),
Option(['foot', 'feet'], alias='foot', scope='player', required=True),
Option(['wrist', 'wrists'], alias='wrist', scope='player', required=True),
Option(['hand', 'hands'], alias='hand', scope='player', required=True),
Option(['finger1', 'ring1'], alias='finger1', scope='player', required=True),
Option(['finger2', 'ring2'], alias='finger2', scope='player', required=True),
Option('trinket1', scope='player', required=True),
Option('trinket2', scope='player', required=True),
Option('back', scope='player', required=True),
Option('main_hand', scope='player', required=True),
Option('off_hand', scope='player'),
# consumables
Option('potion', scope='player'),
Expand All @@ -141,21 +156,17 @@ def validate_class_option(option: ParsedOption):
HEADER_OPTIONS = Options(
Option('desired_targets', scope='sim'),
Option('fight_style', ['patchwerk', 'castingpatchwerk', 'dungeonslice'], scope='sim'),
Option('source', ['default'], scope='player'),
# consumables
Option('potion', scope='player'),
Option('flask', scope='player'),
Option('food', scope='player'),
Option('augmentation', scope='player'),
Option('temporary_enchant', scope='player'),
Option('profile_type', simc_option=False, scope='header')
)

class Profile:
path: Path
params: list[str]
observed_options: set[Option]

def __init__(self, path):
self.path = Path(path)
self.observed_options = set()

def __str__(self):
return str(self.path)
Expand All @@ -171,8 +182,8 @@ def validate(self):
print(f'Path {self} does not exist.')
return False

class_name, trailing_fragment, spec_name = self.path_parts()
if not class_name and not trailing_fragment and not spec_name:
class_name, spec_name, suffix = self.path_parts()
if not class_name or not spec_name:
return False

if class_name not in SPEC_NAMES.keys():
Expand All @@ -184,15 +195,15 @@ def validate(self):
return False

# python has no way to nicely test if a string contains only printable ascii characters :)
if not all((c in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_' for c in trailing_fragment[len(spec_name):])):
print(f'Profile {self} trailing fragment {trailing_fragment[len(spec_name):]} is not alphanumeric.')
if not all((c in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_' for c in suffix)):
print(f'Profile {self} suffix {suffix} is not alphanumeric.')
return False

return True

def expected_name(self):
class_name, trailing_fragment, _ = self.path_parts()
return f'{class_name}_{trailing_fragment}'
class_name, spec_name, suffix = self.path_parts()
return f'{class_name}_{spec_name}{"" if suffix == "" else "_"}{suffix}'

def path_parts(self):
path_parts = PurePath.relative_to(self.path.resolve(), Path(__file__).resolve(), walk_up=True).parts[2:]
Expand All @@ -201,7 +212,12 @@ def path_parts(self):
return False, False, False

trailing_fragment = path_parts[2].split('.')[:-1][0]
return path_parts[1], trailing_fragment, trailing_fragment.split('_')[0]
spec_name = trailing_fragment.split('_')[0]
suffix = '_'.join(trailing_fragment.split('_')[1:])
return path_parts[1], spec_name, suffix

def related_profiles(self):
return self.path.parent.glob(f'{self.path_parts()[1]}*.simc')

class ParsedOption:
profile: Profile
Expand Down Expand Up @@ -246,7 +262,7 @@ def __str__(self):
return f'{self.key}{self.operator}{self.value}'

def option(self, options: Options):
return next((o for o in options if o == self))
return self in options and next((o for o in options if o == self)) or None

def validate(self, options: Options):
return self.parsed and self.validate_key(options) and self.validate_value(options)
Expand Down
55 changes: 46 additions & 9 deletions scripts/validate.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
from argparse import ArgumentParser
from collections import Counter
from dataclasses import dataclass
from difflib import ndiff
from json import loads
from urllib.request import Request, urlopen

from shared import ParsedOption, Profile, HEADER_OPTIONS, SIMC_OPTIONS

def find_option(option_key: str, profile: Profile):
with open(profile) as handle:
for line in handle.readlines():
line = line.strip()
option = ParsedOption(line[1:].strip() if len(line) and line[0] == '#' else line, profile)
if option.key == option_key:
yield option

def parse_header_option(line: str, profile: Profile):
option = ParsedOption(line, profile)

Expand All @@ -14,6 +23,7 @@ def parse_header_option(line: str, profile: Profile):
print(f'Profile {profile} has invalid Header option {option}.')
return False

profile.observed_options.add(option.option(HEADER_OPTIONS))
return True

def parse_simc_option(line: str, profile: Profile):
Expand All @@ -29,15 +39,40 @@ def parse_simc_option(line: str, profile: Profile):
print(f'Profile {profile} has invalid Profile option {option}.')
return False

profile.observed_options.add(option.option(SIMC_OPTIONS))
return True

def validate(profile: Profile):
success = True
def validate_unique_option_key(option_key: str, profile: Profile):
relatives = set(profile.related_profiles())
observed = []
for relative in relatives:
observed += list(find_option(option_key, relative))
options_as_str = (str(s) for s in observed)
duplicate_options = list(k for k, v in (Counter(options_as_str) - Counter(options_as_str)).items() if v > 1)

if len(duplicate_options):
print(f'More than one profile in the {" ".join(profile.path_parts()[:1])}' \
f' family contains {", ".join(duplicate_options)}. This ' \
'option must have a unique value in each profile it exists in.')
for duplicate in duplicate_options:
print(duplicate)
for option in observed:
if str(option) == duplicate:
print(f' {option.profile}')
return False

if not profile.validate():
success = False
return True

def validate_missing_options(profile: Profile):
missing_options = (SIMC_OPTIONS.required | HEADER_OPTIONS.required) - profile.observed_options
for option in missing_options:
print(f'Profile {profile} is missing {option}.')

return len(missing_options) == 0

def validate(profile: Profile):
success = profile.validate()

class_name, trailing_fragment, _ = profile.path_parts()
with open(profile) as handle:
header = True
for line in handle.readlines():
Expand All @@ -46,12 +81,14 @@ def validate(profile: Profile):
continue
if line[0] == '#':
if header:
if not parse_header_option(line[1:].strip(), profile):
success = False
success &= parse_header_option(line[1:].strip(), profile)
else:
header = False
if not parse_simc_option(line, profile):
success = False
success &= parse_simc_option(line, profile)

success &= validate_unique_option_key('profile_type', profile)
success &= validate_missing_options(profile)

return success

def validate_seasonal(profile: Profile):
Expand Down