11"""Module providing an interface to interact with the teams' programs."""
22from abc import ABC , abstractmethod
33from contextlib import contextmanager
4+ from itertools import combinations
45from os import environ
56from pathlib import Path
67from tarfile import TarFile , is_tarfile
78from tempfile import TemporaryDirectory
89from timeit import default_timer
910from types import EllipsisType
10- from typing import Any , ClassVar , Iterator , Protocol , Self , TypeVar , cast , Generator as PyGenerator
11+ from typing import Any , ClassVar , Iterator , Mapping , Protocol , Self , TypeVar , cast , Generator as PyGenerator
1112from typing_extensions import TypedDict
1213from uuid import uuid4
1314import json
14- from dataclasses import dataclass
15+ from dataclasses import dataclass , field
1516from zipfile import ZipFile , is_zipfile
1617
1718from docker import DockerClient
3233 ExceptionInfo ,
3334 ExecutionError ,
3435 ExecutionTimeout ,
36+ MatchMode ,
3537 ValidationError ,
3638 Role ,
3739 BaseModel ,
@@ -79,36 +81,13 @@ class RunSpecs:
7981 overriden : RunConfigOverride
8082
8183
82- @dataclass (frozen = True , slots = True )
83- class RunConfigView :
84+ class RunConfigView (Protocol ):
8485 """Config view for single runs."""
8586
8687 timeout : float | None
8788 space : int | None
8889 cpus : int
8990
90- def reify (
91- self ,
92- timeout : float | None | EllipsisType ,
93- space : int | None | EllipsisType ,
94- cpus : int | EllipsisType ,
95- ) -> RunSpecs :
96- """Merges the overriden config options with the parsed ones."""
97- overriden = RunConfigOverride ()
98- if timeout is ...:
99- timeout = self .timeout
100- else :
101- overriden ["timeout" ] = timeout
102- if space is ...:
103- space = self .space
104- else :
105- overriden ["space" ] = space
106- if cpus is ...:
107- cpus = self .cpus
108- else :
109- overriden ["cpus" ] = cpus
110- return RunSpecs (timeout = timeout , space = space , cpus = cpus , overriden = overriden )
111-
11291
11392@dataclass (frozen = True , slots = True )
11493class ProgramConfigView :
@@ -121,6 +100,7 @@ class ProgramConfigView:
121100 run_kwargs : dict [str , Any ]
122101 generator : RunConfigView
123102 solver : RunConfigView
103+ mode : MatchMode
124104
125105
126106class ProgramUi (Protocol ):
@@ -691,3 +671,177 @@ async def run(
691671 battle_data = battle_data ,
692672 solution = solution ,
693673 )
674+
675+
676+ class BuildUi (Protocol ):
677+ """Provides and interface for the build process to update the ui."""
678+
679+ @abstractmethod
680+ def start_build (self , team : str , role : Role , timeout : float | None ) -> None :
681+ """Informs the ui that a new program is being built."""
682+
683+ @abstractmethod
684+ def finish_build (self ) -> None :
685+ """Informs the ui that the current build has been finished."""
686+
687+
688+ class _TeamInfo (Protocol ):
689+ generator : Path
690+ solver : Path
691+
692+
693+ @dataclass (frozen = True , slots = True )
694+ class Team :
695+ """Class bundling together the programs of a team."""
696+
697+ name : str
698+ generator : Generator
699+ solver : Solver
700+
701+ @classmethod
702+ async def build (
703+ cls ,
704+ name : str ,
705+ info : _TeamInfo ,
706+ problem : AnyProblem ,
707+ config : ProgramConfigView ,
708+ ui : BuildUi ,
709+ ) -> "Team" :
710+ """Builds the specified docker files into images and return the corresponding team.
711+
712+ Args:
713+ name: Name of the team.
714+ info: Team info containing the paths to the program data.
715+ problem: The problem class the current match is fought over.
716+ config: Config for the programs.
717+
718+ Returns:
719+ The built team.
720+
721+ Raises:
722+ ValueError: If the team name is already in use.
723+ DockerError: If the docker build fails for some reason
724+ """
725+ tag_name = name .lower ().replace (" " , "_" ) if config .mode == "testing" else None
726+ ui .start_build (name , Role .generator , config .build_timeout )
727+ generator = await Generator .build (
728+ path = info .generator ,
729+ problem = problem ,
730+ config = config ,
731+ team_name = tag_name ,
732+ )
733+ ui .finish_build ()
734+ try :
735+ ui .start_build (name , Role .solver , config .build_timeout )
736+ solver = await Solver .build (
737+ path = info .solver ,
738+ problem = problem ,
739+ config = config ,
740+ team_name = tag_name ,
741+ )
742+ ui .finish_build ()
743+ except Exception :
744+ generator .remove ()
745+ raise
746+ return Team (name , generator , solver )
747+
748+ def __str__ (self ) -> str :
749+ return self .name
750+
751+ def __eq__ (self , o : object ) -> bool :
752+ if isinstance (o , Team ):
753+ return self .name == o .name
754+ else :
755+ return False
756+
757+ def __hash__ (self ) -> int :
758+ return hash (self .name )
759+
760+ def __enter__ (self ):
761+ return self
762+
763+ def __exit__ (self , _type : Any , _value : Any , _traceback : Any ):
764+ self .cleanup ()
765+
766+ def cleanup (self ) -> None :
767+ """Removes the built docker images."""
768+ self .generator .remove ()
769+ self .solver .remove ()
770+
771+
772+ @dataclass (frozen = True )
773+ class Matchup :
774+ """Represents an individual matchup of teams."""
775+
776+ generator : Team
777+ solver : Team
778+
779+ def __iter__ (self ) -> Iterator [Team ]:
780+ yield self .generator
781+ yield self .solver
782+
783+ def __repr__ (self ) -> str :
784+ return f"Matchup({ self .generator .name } , { self .solver .name } )"
785+
786+
787+ @dataclass
788+ class TeamHandler :
789+ """Handles building teams and cleaning them up."""
790+
791+ active : list [Team ] = field (default_factory = list )
792+ excluded : dict [str , ExceptionInfo ] = field (default_factory = dict )
793+ cleanup : bool = True
794+
795+ @classmethod
796+ async def build (
797+ cls ,
798+ infos : Mapping [str , _TeamInfo ],
799+ problem : AnyProblem ,
800+ config : ProgramConfigView ,
801+ ui : BuildUi ,
802+ ) -> Self :
803+ """Builds the programs of every team.
804+
805+ Attempts to build the programs of every team. If any build fails, that team will be excluded and all its
806+ programs cleaned up.
807+
808+ Args:
809+ infos: Teams that participate in the match.
810+ problem: Problem class that the match will be fought with.
811+ config: Config options.
812+
813+ Returns:
814+ :class:`TeamHandler` containing the info about the participating teams.
815+ """
816+ handler = cls (cleanup = config .mode == "tournament" )
817+ for name , info in infos .items ():
818+ try :
819+ team = await Team .build (name , info , problem , config , ui )
820+ handler .active .append (team )
821+ except Exception as e :
822+ handler .excluded [name ] = ExceptionInfo .from_exception (e )
823+ return handler
824+
825+ def __enter__ (self ) -> Self :
826+ return self
827+
828+ def __exit__ (self , _type : Any , _value : Any , _traceback : Any ):
829+ if self .cleanup :
830+ for team in self .active :
831+ team .cleanup ()
832+
833+ @property
834+ def grouped_matchups (self ) -> list [tuple [Matchup , Matchup ]]:
835+ """All matchups, grouped by the involved teams.
836+
837+ Each tuple's first matchup has the first team in the group generating, the second has it solving.
838+ """
839+ return [(Matchup (* g ), Matchup (* g [::- 1 ])) for g in combinations (self .active , 2 )]
840+
841+ @property
842+ def matchups (self ) -> list [Matchup ]:
843+ """All matchups that will be fought."""
844+ if len (self .active ) == 1 :
845+ return [Matchup (self .active [0 ], self .active [0 ])]
846+ else :
847+ return [m for pair in self .grouped_matchups for m in pair ]
0 commit comments