diff --git a/.circleci/requirements.txt b/.circleci/requirements.txt index 3735b3f..fe82aa3 100644 --- a/.circleci/requirements.txt +++ b/.circleci/requirements.txt @@ -4,3 +4,6 @@ pip >= 20 wheel >= 0.36 setuptools >= 50 + +# Temporarily include SRComp development branch +git+https://github.com/PeterJCLaw/srcomp@9dfcea54f073101da73e8fb62c8e4b108c118f1b # match-release diff --git a/docs/commands/release-match.rst b/docs/commands/release-match.rst new file mode 100644 index 0000000..17ddc9f --- /dev/null +++ b/docs/commands/release-match.rst @@ -0,0 +1,8 @@ +release-match +============= + +.. argparse:: + :module: sr.comp.cli.command_line + :func: argument_parser + :prog: srcomp + :path: release-match diff --git a/docs/commands/reset-match.rst b/docs/commands/reset-match.rst new file mode 100644 index 0000000..1fcceff --- /dev/null +++ b/docs/commands/reset-match.rst @@ -0,0 +1,8 @@ +reset-match +=========== + +.. argparse:: + :module: sr.comp.cli.command_line + :func: argument_parser + :prog: srcomp + :path: reset-match diff --git a/setup.py b/setup.py index 810cac0..006d5c1 100644 --- a/setup.py +++ b/setup.py @@ -24,6 +24,7 @@ 'python-dateutil >=2.2, <3', 'Fabric >= 2.7, <4', 'invoke >= 1.7, <3', + # TODO(PR): bump srcomp version once we know what version will include match-release 'sr.comp >=1.8, <2', 'reportlab >=3.1.44, <5', 'requests >=2.5.1, <3', diff --git a/sr/comp/cli/add_delay.py b/sr/comp/cli/add_delay.py index 6001e41..639f151 100644 --- a/sr/comp/cli/add_delay.py +++ b/sr/comp/cli/add_delay.py @@ -86,7 +86,7 @@ def get_current_match_start(compstate_path: Path) -> datetime.datetime: from sr.comp.comp import SRComp compstate = SRComp(compstate_path) now = compstate.schedule.datetime_now - current_matches = tuple(compstate.schedule.matches_at(now)) + current_matches = compstate.operations.get_matches_at(now).matches if not current_matches: raise Exception("Not currently in a match, specify a valid time instead") diff --git a/sr/comp/cli/command_line.py b/sr/comp/cli/command_line.py index 7d89c7d..5e0dec7 100644 --- a/sr/comp/cli/command_line.py +++ b/sr/comp/cli/command_line.py @@ -17,6 +17,8 @@ list_midi_ports, match_order_teams, print_schedule, + release_match, + reset_match, schedule_league, scorer, shift_matches, @@ -60,6 +62,8 @@ def argument_parser() -> argparse.ArgumentParser: list_midi_ports.add_subparser(subparsers) match_order_teams.add_subparser(subparsers) print_schedule.add_subparser(subparsers) + release_match.add_subparser(subparsers) + reset_match.add_subparser(subparsers) schedule_league.add_subparser(subparsers) scorer.add_subparser(subparsers) shift_matches.add_subparser(subparsers) diff --git a/sr/comp/cli/release_match.py b/sr/comp/cli/release_match.py new file mode 100644 index 0000000..788cb73 --- /dev/null +++ b/sr/comp/cli/release_match.py @@ -0,0 +1,146 @@ +""" +Release a given match in preparation for running it. + +Commutates can opt-in to "match releasing" by having an `operations.yaml` file +which contains configuration for the release timings. This command edits that +file to mark the given match as having been released. + +This is useful during match operations in the event a match needs to be +abandoned and re-run. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Iterable +from pathlib import Path +from typing import TypeVar + +from sr.comp.cli import add_delay, deploy +from sr.comp.match_period import Match +from sr.comp.types import MatchNumber, OperationsData + +T = TypeVar('T') + + +def first(iterable: Iterable[T]) -> T: + return next(i for i in iterable) + + +def release_match(compstate_path: Path, match_number: MatchNumber | None) -> MatchNumber: + from sr.comp.cli import yaml_round_trip as yaml + from sr.comp.comp import SRComp + from sr.comp.match_operations import MatchState + + operations_path = compstate_path / 'operations.yaml' + if not operations_path.exists(): + raise Exception("Cannot release matches in a compstate without an 'operations.yaml'") + + compstate = SRComp(compstate_path) + now = compstate.schedule.datetime_now + if match_number is None: + current_matches = compstate.operations.get_matches_at(now).matches + if not current_matches: + raise Exception("Not currently in a match, specify a valid match number instead") + match_number = min(x.num for x in current_matches) + + match: Match = first(compstate.schedule.matches[match_number].values()) + state = compstate.operations.get_match_state(match, now) + if state == MatchState.RELEASED: + print(f"Match {match_number} already released") + exit(1) + + with yaml.edit(operations_path) as operations_yaml: + when = compstate.schedule.datetime_now.replace(microsecond=0) + + operations: OperationsData = operations_yaml['operations'] + operations['released_match'] = { + 'number': match_number, + 'time': when, + } + + # Add delay if needed + times = compstate.operations.get_arena_times(match) + if when > times.release_threshold: + how_long = when - times.release_threshold + how_long_seconds = int(how_long.total_seconds()) + print( + f"Match {match.num} originally scheduled at {times.start} will " + f"be scheduled at {times.start + how_long}.", + ) + + with yaml.edit(compstate_path / 'schedule.yaml') as schedule: + add_delay.add_delay(schedule, how_long_seconds, match.start_time) + + # Self check + new_state = SRComp(compstate_path) + new_match: Match = first(new_state.schedule.matches[match_number].values()) + new_times = new_state.operations.get_arena_times(new_match) + assert new_times.release_threshold == when, (new_times.release_threshold, when) + + return match_number + + +def command(args: argparse.Namespace) -> None: + from sr.comp.raw_compstate import RawCompstate + + compstate = RawCompstate(args.compstate, local_only=False) + + deploy.require_no_changes(compstate) + + if not args.no_pull: + with deploy.exit_on_exception(): + compstate.pull_fast_forward() + + match_number = release_match(args.compstate, args.match_number) + + deploy.require_valid(compstate) + + with deploy.exit_on_exception(kind=RuntimeError): + compstate.stage('schedule.yaml') + compstate.stage('operations.yaml') + msg = f"Release match {match_number}" + compstate.commit(msg) + + if args.deploy: + hosts = deploy.get_deployments(compstate) + deploy.run_deployments(args, compstate, hosts) + + +def add_subparser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: + help_msg, *_ = __doc__.strip().splitlines() + parser = subparsers.add_parser( + 'release-match', + help=help_msg, + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + '--no-deploy', + dest='deploy', + action='store_false', + default=True, + help="Disable deploying the compstate after making the edit", + ) + parser.add_argument( + '--no-pull', + action='store_true', + help="Skip updating to the latest revision", + ) + deploy.add_options(parser) + parser.add_argument( + 'compstate', + type=Path, + help="competition state repository", + ) + parser.add_argument( + '--match-number', + type=int, + default=None, + help=( + "A specific match number to release. " + "Note that releasing a match implicitly includes all previous matches. " + "Defaults to the current match." + ), + ) + parser.set_defaults(func=command) diff --git a/sr/comp/cli/reset_match.py b/sr/comp/cli/reset_match.py new file mode 100644 index 0000000..41d766d --- /dev/null +++ b/sr/comp/cli/reset_match.py @@ -0,0 +1,141 @@ +""" +Reset the compstate back to before the start of the given match. + +This is achieved by inserting a delay so that the given match is in the future. +The point in time before the match is either: + * the "release threshold" if the compstate has opted-in to match releasing, or + * the start of the slot + +This is useful during match operations in the event a match needs to be +abandoned and re-run. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Iterable +from pathlib import Path +from typing import TypeVar + +from sr.comp.cli import add_delay, deploy +from sr.comp.match_period import Match +from sr.comp.types import MatchNumber, OperationsData + +T = TypeVar('T') + + +def first(iterable: Iterable[T]) -> T: + return next(i for i in iterable) + + +def reset_match(compstate_path: Path, match_number: MatchNumber | None) -> MatchNumber: + from sr.comp.cli import yaml_round_trip as yaml + from sr.comp.comp import SRComp + + compstate = SRComp(compstate_path) + if match_number is None: + now = compstate.schedule.datetime_now + current_matches = compstate.operations.get_matches_at(now).matches + if not current_matches: + raise Exception("Not currently in a match, specify a valid match number instead") + match_number = min(x.num for x in current_matches) + + def get_match(num: MatchNumber) -> Match: + return first(compstate.schedule.matches[num].values()) + + current_match: Match = get_match(match_number) + + delay_target = current_match.start_time + + operations_path = compstate_path / 'operations.yaml' + if operations_path.exists(): + # If the release threshold is explicitly set, use that rather than the + # slot start time as our delay target. + delay_target = compstate.operations.get_arena_times(current_match).release_threshold + + with yaml.edit(operations_path) as operations_yaml: + operations: OperationsData = operations_yaml['operations'] + + previous_match_number = MatchNumber(match_number - 1) + if previous_match_number < 0: + operations['released_match'] = None + else: + previous_match = get_match(previous_match_number) + operations['released_match'] = { + 'number': previous_match_number, + 'time': compstate.operations.get_arena_times(previous_match).release_threshold, # noqa: E501 + } + + # Add delay + how_long = compstate.schedule.datetime_now - delay_target + how_long_seconds = int(how_long.total_seconds()) + + with yaml.edit(compstate_path / 'schedule.yaml') as schedule: + add_delay.add_delay(schedule, how_long_seconds, current_match.start_time) + + return match_number + + +def command(args: argparse.Namespace) -> None: + from sr.comp.raw_compstate import RawCompstate + + compstate = RawCompstate(args.compstate, local_only=False) + + deploy.require_no_changes(compstate) + + if not args.no_pull: + with deploy.exit_on_exception(): + compstate.pull_fast_forward() + + match_number = reset_match(args.compstate, args.match_number) + + deploy.require_valid(compstate) + + with deploy.exit_on_exception(kind=RuntimeError): + compstate.stage('schedule.yaml') + compstate.stage('operations.yaml') + msg = f"Reset match {match_number}" + compstate.commit(msg) + + if args.deploy: + hosts = deploy.get_deployments(compstate) + deploy.run_deployments(args, compstate, hosts) + + +def add_subparser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: + help_msg, *_ = __doc__.strip().splitlines() + parser = subparsers.add_parser( + 'reset-match', + help=help_msg, + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + '--no-deploy', + dest='deploy', + action='store_false', + default=True, + help="Disable deploying the compstate after making the edit", + ) + parser.add_argument( + '--no-pull', + action='store_true', + help="Skip updating to the latest revision", + ) + deploy.add_options(parser) + parser.add_argument( + 'compstate', + type=Path, + help="competition state repository", + ) + parser.add_argument( + '--match-number', + type=int, + default=None, + help=( + "A specific match number to release. " + "Note that releasing a match implicitly includes all previous matches. " + "Defaults to the current match." + ), + ) + parser.set_defaults(func=command) diff --git a/sr/comp/cli/show_schedule.py b/sr/comp/cli/show_schedule.py index d6c5a7c..72d1e1c 100644 --- a/sr/comp/cli/show_schedule.py +++ b/sr/comp/cli/show_schedule.py @@ -24,6 +24,7 @@ def command(settings: argparse.Namespace) -> None: from datetime import datetime, timedelta from sr.comp.comp import SRComp + from sr.comp.match_operations import MatchState comp = SRComp(os.path.realpath(settings.compstate)) @@ -31,10 +32,14 @@ def command(settings: argparse.Namespace) -> None: matches = comp.schedule.matches now = datetime.now(comp.timezone) - current_matches = list(comp.schedule.matches_at(now)) + current_matches = comp.operations.get_matches_at(now).matches if not settings.all: - time = now - timedelta(minutes=10) + if current_matches: + time = min(x.start_time for x in current_matches) + else: + time = now + time -= timedelta(minutes=10) matches = [ slot @@ -93,7 +98,10 @@ def should_show_seconds() -> bool: print_col(m.display_name.center(DISPLAY_NAME_WIDTH)) if m in current_matches: - print(" *") + marker = " *" + if comp.operations.get_match_state(m, now) == MatchState.HELD: + marker += " [held]" + print(marker) else: print() diff --git a/sr/comp/cli/summary.py b/sr/comp/cli/summary.py index b6409f8..dbf48c6 100644 --- a/sr/comp/cli/summary.py +++ b/sr/comp/cli/summary.py @@ -101,6 +101,7 @@ def command(args: argparse.Namespace) -> None: for period in comp.schedule.match_periods: print(f' · {format_period(period, match_duration)}') + print("Last released match: {}".format(comp.operations.last_released_match)) print("Last scored match: {}".format(comp.scores.last_scored_match)) diff --git a/tests/snapshots/summary.txt b/tests/snapshots/summary.txt index 20eac94..d4ddc35 100644 --- a/tests/snapshots/summary.txt +++ b/tests/snapshots/summary.txt @@ -8,4 +8,5 @@ Match periods: · 2014-04-27 Sunday, 27 April 2014, morning (09:30–12:25, matches end 12:20) · 2014-04-27 Sunday, 27 April 2014, afternoon (13:15–15:15, matches end 15:15) · 2014-04-27 The Knockouts, Sunday, 27 April 2014, afternoon (15:30–17:35, matches end 17:30) +Last released match: 129 Last scored match: 99