diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index bf6d5d0..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: build - -on: - workflow_call - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - with: - repository: simulationcraft/simc - path: simc/ - sparse-checkout: | - '!SpellDataDump/' - - - name: configure - run: | - cmake -H'${{ runner.workspace }}/simc/' -B'${{ runner.workspace }}/simc/b/ninja' -GNinja -DBUILD_GUI=OFF - -DCMAKE_BUILD_TYPE=Release - -DCMAKE_CXX_COMPILER=clang++-18 - -DCMAKE_CXX_STANDARD=17 - - - name: build - run: | - ninja -C '${{ runner.workspace }}/simc/b/ninja' - - - name: ident - run: | - cd '${{ runner.workspace }}/simc' - git rev-parse HEAD - - - uses: actions/cache@v5 - with: - path: ${{ runner.workspace }}/simc/b/ninja/simc - key: simc-clang++-18-cpp17-${{ github.sha }} diff --git a/.github/workflows/build/action.yml b/.github/workflows/build/action.yml new file mode 100644 index 0000000..1146b94 --- /dev/null +++ b/.github/workflows/build/action.yml @@ -0,0 +1,27 @@ +name: build + +inputs: + cache-key: + required: true + +runs: + using: 'composite' + + steps: + - name: configure + shell: bash + run: | + cmake -H'simc-profile/simc/' \ + -B'simc-profile/simc/b/ninja' -GNinja -DBUILD_GUI=OFF \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER=clang++-18 \ + -DCMAKE_CXX_STANDARD=17 -DSC_NO_NETWORKING=ON + + - name: build + shell: bash + run: | + ninja -C 'simc-profile/simc/b/ninja' + + - uses: actions/cache/save@v5 + with: + path: simc-profile/simc/b/ninja/simc + key: simc-clang++-18-cpp17-${{ inputs.cache-key }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 87ad32b..3cf1721 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,5 +3,53 @@ name: CI on: [pull_request, push] jobs: - build: - uses: ./.github/workflows/build.yml + CI: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 2 + + - name: modified_files + run: | + files=$(git diff --diff-filter=AM --no-commit-id --name-only HEAD~1 -- '***.simc' | xargs) + echo "MODIFIED_FILES=$files" >> $GITHUB_ENV + + - name: validate_python + run: | + python ${{ runner.workspace }}/simc-profile/scripts/validate.py \ + ${{ env.MODIFIED_FILES }} + + - uses: ./.github/workflows/setup_simc + id: setup + + - name: validate_simc_pr + if: github.event_name == 'pull_request' + run: | + python ${{ runner.workspace }}/simc-profile/scripts/execute.py \ + ${{ env.MODIFIED_FILES }} -b ${{ runner.workspace }}/simc-profile/simc-profile/simc/b/ninja/simc \ + --save . + + - name: execute_simc_pr + if: github.event_name == 'pull_request' + run: | + python ${{ runner.workspace }}/simc-profile/scripts/execute.py \ + ${{ env.MODIFIED_FILES }} -b ${{ runner.workspace }}/simc-profile/simc-profile/simc/b/ninja/simc \ + --execute + + - name: validate_simc_main + if: github.event_name == 'push' && ( success() || failure() ) && github.repository == 'simulationcraft/simc-profile' && github.ref_name == github.event.repository.default_branch + run: | + find ${{ runner.workspace }}/simc-profile/profiles -type f \ + | xargs python ${{ runner.workspace }}/simc-profile/scripts/execute.py \ + -b ${{ runner.workspace }}/simc-profile/simc-profile/simc/b/ninja/simc \ + --save . + + - name: execute_simc_main + if: github.event_name == 'push' && ( success() || failure() ) && github.repository == 'simulationcraft/simc-profile' && github.ref_name == github.event.repository.default_branch + run: | + find ${{ runner.workspace }}/simc-profile/profiles -type f \ + | xargs python ${{ runner.workspace }}/simc-profile/scripts/execute.py \ + -b ${{ runner.workspace }}/simc-profile/simc-profile/simc/b/ninja/simc \ + --execute diff --git a/.github/workflows/setup_simc/action.yml b/.github/workflows/setup_simc/action.yml new file mode 100644 index 0000000..2c54bad --- /dev/null +++ b/.github/workflows/setup_simc/action.yml @@ -0,0 +1,33 @@ +name: setup_simc + +outputs: + rebuilt-simc: + value: ${{ !steps.restore_cache.outputs.cache-hit }} + +runs: + using: 'composite' + + steps: + - uses: actions/checkout@v6 + with: + repository: simulationcraft/simc + path: simc-profile/simc/ + + - name: cache_key + shell: bash + run: | + cd simc-profile/simc/ + echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV + cd ../.. + + - uses: actions/cache/restore@v5 + id: restore_cache + continue-on-error: true + with: + path: simc-profile/simc/b/ninja/simc + key: simc-clang++-18-cpp17-${{ env.SHA }} + + - uses: ./.github/workflows/build + if: steps.restore_cache.outputs.cache-hit != 'true' + with: + cache-key: ${{ env.SHA }} diff --git a/scripts/execute.py b/scripts/execute.py index 1f01c70..f4eea79 100644 --- a/scripts/execute.py +++ b/scripts/execute.py @@ -7,51 +7,73 @@ def validate_header_option(line): return ParsedOption(line).validate(HEADER_OPTIONS) +def handle_header_line(line, deferral_list): + option = ParsedOption(line) + if option.validate(HEADER_OPTIONS): + if option.scope(HEADER_OPTIONS) == 'player': + deferral_list.append(line) + return '' + else: + return line + return '' + def generate_simc_input(profiles: list[Profile]): for profile in profiles: + profile.validate() + profile.params = [] + deferred_options = [] + push_deferred_options = False with open(profile) as handle: header = True for line in handle.readlines(): line = line.strip() if not len(line): continue - if line[0] == '#': - if header and validate_header_option(line[1:].strip()): - line = line[1:].strip() - else: - line = '' + if line[0] == '#' and header: + line = handle_header_line(line[1:].strip(), deferred_options) + elif line[0] == '#' and not header: + line = '' else: + option = ParsedOption(line) + if option.validate_class(profile) and option.validate_class_value(profile): + push_deferred_options = True header = False if line != '': profile.params.append(line) + if push_deferred_options: + profile.params += deferred_options + deferred_options = [] def run_sim(binary: Path, profiles: list[str], prefix: list[str], suffix: list[str] = []): - with subprocess.Popen([binary] + prefix + profiles + suffix, stdout=sys.stdout, stderr=sys.stderr) as _: - pass + proc = subprocess.Popen([binary] + prefix + profiles + suffix, stdout=sys.stdout, stderr=sys.stderr) + proc.wait() + return proc.returncode def print_dps_data(filename: Path): - with subprocess.Popen(['jq', '[.sim.players[] | {name: .name, dps: .collected_data.dps}]', filename]) as _: - pass + proc = subprocess.Popen(['jq', '[.sim.players[] | {name: .name, dps: .collected_data.dps}]', filename]) + proc.wait() + return proc.returncode def save_profiles(binary: Path, profiles: list[Profile], location: Path): params = [] for profile in profiles: params += profile.params params += [f'save={location}/{profile.expected_name()}.simc'] - run_sim(binary, params, ['output=/dev/null']) + return run_sim(binary, params, ['output=/dev/null']) def run_profiles(binary: Path, profiles: list[Profile]): prefix = [ 'output=/dev/null', 'target_error=0.05', - 'json=/tmp/out.json' + 'json=output.json', + 'html=output.html' ] - run_sim(binary, [line for profile in profiles for line in profile.params], prefix) + return run_sim(binary, [line for profile in profiles for line in profile.params], prefix) parser = ArgumentParser(prog='SimulationCraft Profile Runner') -parser.add_argument('filenames', nargs='+', type=Profile) +parser.add_argument('filenames', nargs='*', type=Profile) parser.add_argument('-b', '--binary', type=Path, required=True, metavar='PATH') parser.add_argument('--save', type=Path, default=False, metavar='PATH', help='root directory to save all profiles') parser.add_argument('--execute', action='store_true', default=False, help='execute profiles') @@ -60,9 +82,16 @@ def run_profiles(binary: Path, profiles: list[Profile]): generate_simc_input(args.filenames) +if not len(args.filenames): + exit(0) + +rc = [] if args.save: - save_profiles(args.binary, args.filenames, args.save) + rc.append(save_profiles(args.binary, args.filenames, args.save)) if args.execute: - run_profiles(args.binary, args.filenames) - print_dps_data('/tmp/out.json') + rc.append(run_profiles(args.binary, args.filenames)) + rc.append(print_dps_data('output.json')) + +print(rc) +exit(max(rc)) diff --git a/scripts/shared.py b/scripts/shared.py index 33666d6..9e49a8e 100644 --- a/scripts/shared.py +++ b/scripts/shared.py @@ -24,22 +24,27 @@ class Option: ignore_value: bool values: list[str] case_sensitive: bool + scope: str - def __init__(self, key, values=[], ignore_value=False, case_sensitive=True): + def __init__(self, key, values=[], ignore_value=False, case_sensitive=True, scope='player'): self.key = key self.values = values self.ignore_value = ignore_value self.case_sensitive = case_sensitive + self.scope = scope def __eq__(self, other: ParsedOption): - if self.key != other.key: - return False - if self.ignore_value: - return True - if self.case_sensitive: - return other.value in self.values - else: - return other.value.lower() in self.values + if isinstance(other, ParsedOption): + if self.key != other.key: + return False + if self.ignore_value: + return True + if self.case_sensitive: + return other.value in self.values + else: + return other.value.lower() in self.values + assert False + return False class Options: options: list[Option] @@ -52,6 +57,10 @@ def __init__(self, *options): def __contains__(self, other): return other in self.options + def __iter__(self): + for option in self.options: + yield option + # class (handled separately as value depends on filename) SIMC_OPTIONS = Options( Option('level', ['90']), @@ -96,14 +105,14 @@ def __contains__(self, other): Option('warlock.default_pet', ['sayaad', 'succubus', 'incubus', 'felguard']), ) HEADER_OPTIONS = Options( - Option('desired_targets', ignore_value=True), - Option('fight_style', ['patchwerk', 'castingpatchwerk', 'dungeonslice']), - Option('source', ['default']), - Option('potion', ignore_value=True), - Option('flask', ignore_value=True), - Option('food', ignore_value=True), - Option('augmentation', ignore_value=True), - Option('temporary_enchant', ignore_value=True), + Option('desired_targets', ignore_value=True, scope='sim'), + Option('fight_style', ['patchwerk', 'castingpatchwerk', 'dungeonslice'], scope='sim'), + Option('source', ['default'], scope='player'), + Option('potion', ignore_value=True, scope='player'), + Option('flask', ignore_value=True, scope='player'), + Option('food', ignore_value=True, scope='player'), + Option('augmentation', ignore_value=True, scope='player'), + Option('temporary_enchant', ignore_value=True, scope='player'), ) class Profile: @@ -125,21 +134,26 @@ def validate(self): # =_ if not self.path.exists(): print(f'Path {self} does not exist.') - return + return False class_name, trailing_fragment, spec_name = self.path_parts() + if not class_name and not trailing_fragment and not spec_name: + return False + if class_name not in SPEC_NAMES.keys(): print(f'Profile {self} is not in a `profiles//` directory.') - return + return False if spec_name not in SPEC_NAMES[class_name]: - print(f'Profile {self} does not contain a valid specialization name. Try one of {", ".join(SPEC_NAMES[class_name])}.') - return + print(f'Profile {self} does not contain a valid specialization name. It should include one of {", ".join(SPEC_NAMES[class_name])}.') + 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.') - return + return False + + return True def expected_name(self): class_name, trailing_fragment, _ = self.path_parts() @@ -149,7 +163,7 @@ def path_parts(self): path_parts = PurePath.relative_to(self.path.resolve(), Path(__file__).resolve(), walk_up=True).parts[2:] if path_parts[0] != 'profiles': print(f'Profile {self} is not in the `profiles/` directory.') - return + return False, False, False trailing_fragment = path_parts[2].split('.')[:-1][0] return path_parts[1], trailing_fragment, trailing_fragment.split('_')[0] @@ -192,6 +206,9 @@ def __str__(self): return f'Invalid Option {self.key}' return f'{self.key}{self.operator}{self.value}' + def scope(self, options: Options): + return next((o for o in options if o == self)).scope + def validate_class(self, profile: Profile): class_name, _, _ = profile.path_parts() return self.parsed and self.key == class_name diff --git a/scripts/validate.py b/scripts/validate.py index 84d3699..ea14450 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -7,27 +7,45 @@ def parse_header_option(line, profile): # only validate header options if they look like they could be options if option.validate_key(HEADER_OPTIONS) and not option.validate_value(HEADER_OPTIONS): print(f'Profile {profile} has invalid Header option {option}.') + return False + + return True def parse_simc_option(line, profile): option = ParsedOption(line) if option.validate_class(profile): if not option.validate_class_value(profile): print(f'Profile {profile} has invalid name {option}. Expected {profile.expected_name()}.') - elif not option.validate_key(SIMC_OPTIONS): + return False + return True + + if not option.validate_key(SIMC_OPTIONS): print(f'Profile {profile} has invalid Profile option {option.key}.', end='') if option.validate_key(HEADER_OPTIONS): print(' Perhaps this option was intended to be placed in header?') else: print() + return False elif not option.validate_value(SIMC_OPTIONS): print(f'Profile {profile} has invalid Profile option {option}.') + return False + + return True parser = ArgumentParser(prog='SimulationCraft Profile Validator') -parser.add_argument('filenames', nargs='+', type=Profile) +parser.add_argument('filenames', nargs='*', type=Profile) args = parser.parse_args() +if not len(args.filenames): + exit(0) + +success = True + for profile in args.filenames: + if not profile.validate(): + print(profile) + success = False class_name, trailing_fragment, _ = profile.path_parts() with open(profile) as handle: @@ -38,7 +56,16 @@ def parse_simc_option(line, profile): continue if line[0] == '#': if header: - parse_header_option(line[1:].strip(), profile) + if not parse_header_option(line[1:].strip(), profile): + print(line) + success = False else: header = False - parse_simc_option(line, profile) + if not parse_simc_option(line, profile): + print(line) + success = False + +if success: + exit(0) +else: + exit(1)