Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simplify match json structure #125

Merged
merged 6 commits into from
Sep 28, 2023
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
16 changes: 6 additions & 10 deletions algobattle/battle.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
Solver,
)
from algobattle.problem import AnyProblem
from algobattle.util import Encodable, inherit_docs, BaseModel
from algobattle.util import Encodable, ExceptionInfo, BaseModel


_BattleConfig: TypeAlias = Any
Expand Down Expand Up @@ -221,7 +221,7 @@ class Battle(BaseModel):

fights: list[Fight] = Field(default_factory=list)
"""The list of fights that have been fought in this battle."""
run_exception: str | None = None
runtime_error: ExceptionInfo | None = None
"""The description of an otherwise unhandeled exception that occured during the execution of :meth:`Battle.run`."""

_battle_types: ClassVar[dict[str, type[Self]]] = {}
Expand Down Expand Up @@ -398,8 +398,7 @@ class Config(Battle.Config):
minimum_score: float = 1
"""Minimum score that a solver needs to achieve in order to pass."""

@inherit_docs
class UiData(Battle.UiData):
class UiData(Battle.UiData): # noqa: D106
reached: list[int]
cap: int

Expand Down Expand Up @@ -455,9 +454,8 @@ def score(self) -> float:
"""Averages the highest instance size reached in each round."""
return 0 if len(self.results) == 0 else sum(self.results) / len(self.results)

@inherit_docs
@staticmethod
def format_score(score: float) -> str:
def format_score(score: float) -> str: # noqa: D102
return str(int(score))


Expand All @@ -474,8 +472,7 @@ class Config(Battle.Config):
num_fights: int = 10
"""Number of iterations in each round."""

@inherit_docs
class UiData(Battle.UiData):
class UiData(Battle.UiData): # noqa: D106
round: int

async def run_battle(self, fight: FightHandler, config: Config, min_size: int, ui: BattleUi) -> None:
Expand All @@ -496,7 +493,6 @@ def score(self) -> float:
else:
return sum(f.score for f in self.fights) / len(self.fights)

@inherit_docs
@staticmethod
def format_score(score: float) -> str:
def format_score(score: float) -> str: # noqa: D102
return format(score, ".0%")
17 changes: 8 additions & 9 deletions algobattle/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
from tomlkit.items import Table as TomlTable

from algobattle.battle import Battle
from algobattle.match import AlgobattleConfig, EmptyUi, Match, MatchConfig, Ui, ProjectConfig
from algobattle.match import AlgobattleConfig, EmptyUi, Match, MatchConfig, MatchupStr, Ui, ProjectConfig
from algobattle.problem import Instance, Problem, Solution
from algobattle.program import Generator, Matchup, Solver
from algobattle.util import BuildError, EncodableModel, ExceptionInfo, Role, RunningTimer, BaseModel, TempDir, timestamp
Expand Down Expand Up @@ -713,13 +713,12 @@ def display_match(match: Match) -> RenderableType:
Column("Result", justify="right"),
title="[heading]Match overview",
)
for generating, battles in match.results.items():
for solving, result in battles.items():
if result.run_exception is None:
res = result.format_score(result.score())
else:
res = ":warning:"
table.add_row(generating, solving, res)
for matchup, battle in match.battles.items():
if battle.runtime_error is None:
res = battle.format_score(battle.score())
else:
res = ":warning:"
table.add_row(matchup.generator, matchup.solver, res)
return Padding(table, pad=(1, 0, 0, 0))

@override
Expand Down Expand Up @@ -769,7 +768,7 @@ def start_fight(self, matchup: Matchup, max_size: int) -> None:

@override
def end_fight(self, matchup: Matchup) -> None:
battle = self.match.battle(matchup)
battle = self.match.battles[MatchupStr.make(matchup)]
assert battle is not None
fights = battle.fights[-1:-6:-1]
panel = self.battle_panels[matchup]
Expand Down
96 changes: 32 additions & 64 deletions algobattle/match.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
"""Module defining how a match is run."""
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from functools import cached_property
from itertools import combinations
from pathlib import Path
import tomllib
from typing import Annotated, Any, Iterable, Protocol, ClassVar, Self, TypeAlias, TypeVar, cast, overload
from typing import Annotated, Any, Iterable, Protocol, ClassVar, Self, TypeAlias, TypeVar, cast
from typing_extensions import override
from typing_extensions import TypedDict

Expand All @@ -18,6 +17,7 @@
GetCoreSchemaHandler,
ValidationInfo,
field_validator,
model_serializer,
model_validator,
)
from pydantic.types import PathType
Expand All @@ -28,25 +28,46 @@
from docker.types import LogConfig, Ulimit

from algobattle.battle import Battle, FightHandler, FightUi, BattleUi, Iterated
from algobattle.program import ProgramConfigView, ProgramUi, Matchup, TeamHandler, Team, BuildUi
from algobattle.program import ProgramConfigView, ProgramUi, Matchup, TeamHandler, BuildUi
from algobattle.problem import InstanceT, Problem, SolutionT
from algobattle.util import (
ExceptionInfo,
Role,
RunningTimer,
BaseModel,
str_with_traceback,
)


@dataclass(frozen=True)
class MatchupStr:
"""Holds the names of teams in a matchup."""

generator: str
solver: str

@classmethod
def make(cls, matchup: Matchup) -> Self:
"""Creates an instance from a matchup object."""
return cls(matchup.generator.name, matchup.solver.name)

@classmethod
def __pydantic_get_core_schema__(cls, source: type[Self], handler: GetCoreSchemaHandler) -> CoreSchema:
def parse(val: str) -> Self:
return cls(*val.split(" vs "))

return no_info_after_validator_function(parse, handler(str))

@model_serializer
def __str__(self) -> str:
return f"{self.generator} vs {self.solver}"


class Match(BaseModel):
"""The Result of a whole Match."""

active_teams: list[str] = field(default_factory=list)
excluded_teams: dict[str, ExceptionInfo] = field(default_factory=dict)
results: defaultdict[str, Annotated[dict[str, Battle], Field(default_factory=dict)]] = Field(
default_factory=lambda: defaultdict(dict)
)
battles: dict[MatchupStr, Battle] = Field(default_factory=dict)

async def _run_battle(
self,
Expand Down Expand Up @@ -78,7 +99,7 @@ async def _run_battle(
battle_ui,
)
except Exception as e:
battle.run_exception = str_with_traceback(e)
battle.runtime_error = ExceptionInfo.from_exception(e)
cpus.append(set_cpus)
ui.battle_completed(matchup)

Expand Down Expand Up @@ -116,63 +137,10 @@ async def run(
async with create_task_group() as tg:
for matchup in teams.matchups:
battle = battle_cls()
self.results[matchup.generator.name][matchup.solver.name] = battle
self.battles[MatchupStr.make(matchup)] = battle
tg.start_soon(self._run_battle, battle, matchup, config, problem, match_cpus, ui, limiter)
return self

@overload
def battle(self, matchup: Matchup) -> Battle | None:
...

@overload
def battle(self, *, generating: Team, solving: Team) -> Battle | None:
...

def battle(
self,
matchup: Matchup | None = None,
*,
generating: Team | None = None,
solving: Team | None = None,
) -> Battle | None:
"""Helper method to look up the battle between a specific matchup.

Returns:
The battle if it has started already, otherwise `None`.
"""
try:
if matchup is not None:
return self.results[matchup.generator.name][matchup.solver.name]
if generating is not None and solving is not None:
return self.results[generating.name][solving.name]
raise TypeError
except KeyError:
return None

@overload
def insert_battle(self, battle: Battle, matchup: Matchup) -> None:
...

@overload
def insert_battle(self, battle: Battle, *, generating: Team, solving: Team) -> None:
...

def insert_battle(
self,
battle: Battle,
matchup: Matchup | None = None,
*,
generating: Team | None = None,
solving: Team | None = None,
) -> None:
"""Helper method to insert a new battle for a specific matchup."""
if matchup is not None:
self.results[matchup.generator.name][matchup.solver.name] = battle
elif generating is not None and solving is not None:
self.results[generating.name][solving.name] = battle
else:
raise TypeError

def calculate_points(self, total_points_per_team: int) -> dict[str, float]:
"""Calculate the number of points each team scored.

Expand All @@ -192,8 +160,8 @@ def calculate_points(self, total_points_per_team: int) -> dict[str, float]:

for first, second in combinations(self.active_teams, 2):
try:
first_res = self.results[second][first]
second_res = self.results[first][second]
first_res = self.battles[MatchupStr(second, first)]
second_res = self.battles[MatchupStr(first, second)]
except KeyError:
continue
total_score = max(0, first_res.score()) + max(0, second_res.score())
Expand Down
8 changes: 6 additions & 2 deletions algobattle/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,8 @@ async def build(
except Timeout as e:
raise BuildError("Build ran into a timeout.") from e
except DockerBuildError as e:
raise BuildError("Build did not complete successfully.", detail=e.msg) from e
logs = cast(list[dict[str, Any]], list(e.build_log))
raise BuildError("Build did not complete successfully.", detail=logs) from e
except APIError as e:
raise BuildError("Docker APIError thrown while building.", detail=str(e)) from e

Expand Down Expand Up @@ -763,7 +764,8 @@ async def build(
team_name=name,
)
except Exception:
generator.remove()
if config.cleanup_images:
generator.remove()
raise
return Team(name, generator, solver)

Expand Down Expand Up @@ -849,6 +851,8 @@ async def build(
except Exception as e:
handler.excluded[name] = ExceptionInfo.from_exception(e)
ui.finish_build(name, False)
except BaseException:
raise
else:
ui.finish_build(name, True)
return handler
Expand Down
19 changes: 3 additions & 16 deletions algobattle/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,6 @@ class Role(StrEnum):
T = TypeVar("T")


def str_with_traceback(exception: Exception) -> str:
"""Returns the full exception info with a stacktrace."""
return "\n".join(format_exception(exception))


def inherit_docs(obj: T) -> T:
"""Decorator to mark a method as inheriting its docstring.

Python 3.5+ already does this, but pydocstyle needs a static hint.
"""
return obj


ModelType = Literal["instance", "solution", "other"]
ModelReference = ModelType | Literal["self"]

Expand Down Expand Up @@ -316,7 +303,7 @@ def flat_intersperse(iterable: Iterable[Iterable[T]], element: T) -> Iterable[T]
class AlgobattleBaseException(Exception):
"""Base exception class for errors used by the algobattle package."""

def __init__(self, message: LiteralString, *, detail: str | None = None) -> None:
def __init__(self, message: LiteralString, *, detail: str | list[str] | list[dict[str, Any]] | None = None) -> None:
"""Base exception class for errors used by the algobattle package.

Args:
Expand Down Expand Up @@ -368,7 +355,7 @@ class ExceptionInfo(BaseModel):

type: str
message: str
detail: str | None = None
detail: str | list[str] | list[dict[str, Any]] | None = None

@classmethod
def from_exception(cls, error: Exception) -> Self:
Expand All @@ -383,7 +370,7 @@ def from_exception(cls, error: Exception) -> Self:
return cls(
type=error.__class__.__name__,
message=str(error),
detail=str_with_traceback(error),
detail=format_exception(error),
)


Expand Down
Loading
Loading