Skip to content

Commit 4c17464

Browse files
committed
move team stuff to program module
1 parent 7ec0382 commit 4c17464

9 files changed

Lines changed: 218 additions & 273 deletions

File tree

algobattle/battle.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,8 @@ def __get_pydantic_core_schema__(cls, source: Type, handler: GetCoreSchemaHandle
250250
case _:
251251
return tagged_union_schema(
252252
choices={
253-
subclass.model_fields["type"].default: subclass.__pydantic_core_schema__
254-
for subclass in Battle._battle_types.values()
253+
battle.Config.model_fields["type"].default: battle.Config.__pydantic_core_schema__
254+
for battle in Battle._battle_types.values()
255255
},
256256
discriminator="type",
257257
)

algobattle/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
from algobattle.battle import Battle, Fight
2020
from algobattle.match import Match, Ui, AlgobattleConfig
2121
from algobattle.problem import AnyProblem, Problem
22-
from algobattle.team import Matchup
2322
from algobattle.util import Role, RunningTimer, flat_intersperse
23+
from algobattle.program import Matchup
2424

2525

2626
@dataclass

algobattle/match.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
from itertools import combinations
77
from pathlib import Path
88
import tomllib
9-
from typing import Annotated, Any, ClassVar, Self, TypeAlias, TypeVar, TypedDict, cast, overload
9+
from typing import Annotated, Any, ClassVar, Self, TypeAlias, TypeVar, cast, overload
10+
from typing_extensions import TypedDict
1011

1112
from pydantic import AfterValidator, ByteSize, ConfigDict, Field, GetCoreSchemaHandler, ValidationInfo, model_validator
1213
from pydantic.types import PathType
@@ -17,8 +18,7 @@
1718
from docker.types import LogConfig, Ulimit
1819

1920
from algobattle.battle import Battle, FightHandler, FightUi, BattleUi, Iterated
20-
from algobattle.program import ProgramUi
21-
from algobattle.team import BuildUi, Matchup, Team, TeamHandler
21+
from algobattle.program import ProgramConfigView, ProgramUi, BuildUi, Matchup, Team, TeamHandler
2222
from algobattle.problem import InstanceT, Problem, ProblemName, SolutionT
2323
from algobattle.util import (
2424
ExceptionInfo,
@@ -98,9 +98,7 @@ async def run(
9898
if ui is None:
9999
ui = Ui()
100100

101-
with await TeamHandler.build(
102-
config.teams, problem, config.execution.mode, config.match, config.docker, ui
103-
) as teams:
101+
with await TeamHandler.build(config.teams, problem, config.as_prog_config(), ui) as teams:
104102
result = cls(
105103
active_teams=[t.name for t in teams.active],
106104
excluded_teams=teams.excluded,
@@ -631,3 +629,16 @@ def from_file(cls, file: Path) -> Self:
631629
except tomllib.TOMLDecodeError as e:
632630
raise ValueError(f"The config file at {file} is not a properly formatted TOML file!\n{e}")
633631
return cls.model_validate(config_dict, context={"base_path": file.parent})
632+
633+
def as_prog_config(self) -> ProgramConfigView:
634+
"""Builds a simple object containing all program relevant settings."""
635+
return ProgramConfigView(
636+
build_timeout=self.match.build_timeout,
637+
max_image_size=self.match.image_size,
638+
strict_timeouts=self.match.strict_timeouts,
639+
build_kwargs=self.docker.build.kwargs,
640+
run_kwargs=self.docker.run.kwargs,
641+
generator=self.match.generator,
642+
solver=self.match.solver,
643+
mode=self.execution.mode,
644+
)

algobattle/program.py

Lines changed: 180 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
"""Module providing an interface to interact with the teams' programs."""
22
from abc import ABC, abstractmethod
33
from contextlib import contextmanager
4+
from itertools import combinations
45
from os import environ
56
from pathlib import Path
67
from tarfile import TarFile, is_tarfile
78
from tempfile import TemporaryDirectory
89
from timeit import default_timer
910
from 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
1112
from typing_extensions import TypedDict
1213
from uuid import uuid4
1314
import json
14-
from dataclasses import dataclass
15+
from dataclasses import dataclass, field
1516
from zipfile import ZipFile, is_zipfile
1617

1718
from docker import DockerClient
@@ -32,6 +33,7 @@
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)
11493
class ProgramConfigView:
@@ -121,6 +100,7 @@ class ProgramConfigView:
121100
run_kwargs: dict[str, Any]
122101
generator: RunConfigView
123102
solver: RunConfigView
103+
mode: MatchMode
124104

125105

126106
class 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

Comments
 (0)